This commit is contained in:
2026-07-26 17:45:58 -05:00
parent 5830cd7ab5
commit b0e677d298
15 changed files with 1016 additions and 68 deletions

View File

@@ -4,7 +4,10 @@
"private": true,
"type": "module",
"scripts": {
"copy-pdfjs": "node scripts/copy-pdfjs.mjs",
"predev": "npm run copy-pdfjs",
"dev": "vite",
"prebuild": "npm run copy-pdfjs",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"

View File

@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PDF</title>
<link rel="stylesheet" href="/viewer/viewer.css">
<script type="module">
// Vite serves public/ verbatim, so this page is unbundled in dev and prod alike.
console.log("[BizMatch] PDF.js frontend build: legacy");
import * as pdfjsLib from "/pdfjs/legacy/pdf.min.mjs";
pdfjsLib.GlobalWorkerOptions.workerSrc = "/pdfjs/legacy/pdf.worker.min.mjs";
globalThis.pdfjsLib = pdfjsLib;
</script>
<script type="module" src="/viewer/pdf_viewer.js"></script>
</head>
<body>
<section class="viewer">
<div id="pdfViewer"></div>
<div id="pdfMessage">Select a document</div>
</section>
<script type="module" src="/viewer/viewer.js"></script>
</body>
</html>

View File

@@ -0,0 +1,385 @@
// PDF.js viewer using pdfjs-dist 6.1.200 legacy build.
// Depends on global pdfjsLib (loaded in index.html).
const ZOOM_MIN = 0.25;
const ZOOM_MAX = 5;
const ZOOM_STEP = 1.2; // multiplicative
class PdfViewer {
constructor(container, options = {}) {
this.container = container;
this.generation = 0;
this.loadingTask = null;
this.doc = null;
this.numPages = 0;
this.renderTasks = new Set();
this.lastUrl = null;
this.lastSize = null;
// multiplier on fit-to-width (1 = fit width); can be seeded from the
// previous viewer instance so zoom survives switching documents
this.zoom = Math.min(
ZOOM_MAX,
Math.max(ZOOM_MIN, options.initialZoom ?? 1),
);
this._lastRenderWidth = 0;
this._resizeTimer = null;
this._zoomTimer = null;
this.resizeObserver = null;
this._createDOM();
this._updateZoomLabel();
this._observeResize();
}
_createDOM() {
// The pages container is always visible (never hidden).
// Loading and error states are overlays on top.
this.container.innerHTML = `
<div class="pdf-v-toolbar" hidden>
<button class="pdf-v-zoom-out" title="Zoom out (Ctrl+mouse wheel)">\u2212</button>
<button class="pdf-v-zoom-label" title="Fit to page width">100%</button>
<button class="pdf-v-zoom-in" title="Zoom in (Ctrl+mouse wheel)">+</button>
</div>
<div class="pdf-v-pages"></div>
<div class="pdf-v-overlay pdf-v-loading" hidden>Loading PDF\u2026</div>
<div class="pdf-v-overlay pdf-v-error" hidden>
<div class="pdf-v-error-msg"></div>
<button class="pdf-v-retry">Retry</button>
</div>
`;
this.toolbar = this.container.querySelector(".pdf-v-toolbar");
this.zoomLabel = this.container.querySelector(".pdf-v-zoom-label");
this.pagesDiv = this.container.querySelector(".pdf-v-pages");
this.loadingDiv = this.container.querySelector(".pdf-v-loading");
this.errorDiv = this.container.querySelector(".pdf-v-error");
this.errorMsg = this.container.querySelector(".pdf-v-error-msg");
this.retryBtn = this.container.querySelector(".pdf-v-retry");
this.retryBtn.addEventListener("click", () => {
if (this.lastUrl != null) {
this._setState("loading");
this._loadInternal(this.lastUrl, this.lastSize);
}
});
this.container.querySelector(".pdf-v-zoom-in").addEventListener(
"click",
() => this.setZoom(this.zoom * ZOOM_STEP),
);
this.container.querySelector(".pdf-v-zoom-out").addEventListener(
"click",
() => this.setZoom(this.zoom / ZOOM_STEP),
);
// Clicking the percentage resets to fit-width.
this.zoomLabel.addEventListener("click", () => this.setZoom(1));
// Ctrl + mouse wheel zoom, like in browsers / PDF readers.
this.pagesDiv.addEventListener("wheel", (e) => {
if (!e.ctrlKey) return;
e.preventDefault();
this.setZoom(this.zoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1));
}, { passive: false });
}
// ---- Zoom ----
setZoom(zoom) {
const clamped = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom));
if (Math.abs(clamped - this.zoom) < 0.001) return;
this.zoom = clamped;
this._updateZoomLabel();
if (!this.doc) return;
// Debounce so repeated +/+/+ or wheel ticks trigger one re-render.
clearTimeout(this._zoomTimer);
this._zoomTimer = setTimeout(() => this._rerender(), 120);
}
_updateZoomLabel() {
this.zoomLabel.textContent = `${Math.round(this.zoom * 100)}%`;
}
// ---- Resize ----
_observeResize() {
if (typeof ResizeObserver === "undefined") return;
this.resizeObserver = new ResizeObserver(() => {
if (!this.doc) return;
const width = this._renderWidth();
if (Math.abs(width - this._lastRenderWidth) < 2) return;
clearTimeout(this._resizeTimer);
this._resizeTimer = setTimeout(() => {
console.log(
`[BizMatch] PDF viewer resized, re-rendering at ${this._renderWidth()}px`,
);
this._rerender();
}, 150);
});
this.resizeObserver.observe(this.container);
}
_renderWidth() {
return Math.max(Math.round(this._getViewportWidth() * this.zoom), 100);
}
// Re-render all pages of the already-loaded document (zoom / resize),
// preserving the relative scroll position.
async _rerender() {
if (!this.doc) return;
const generation = ++this.generation;
for (const rt of this.renderTasks) {
try {
rt.cancel();
} catch { /* ok */ }
}
this.renderTasks.clear();
const scrollRatio = this.pagesDiv.scrollHeight > 0
? this.pagesDiv.scrollTop / this.pagesDiv.scrollHeight
: 0;
await this._renderAllPages(this.doc, generation, false);
if (generation !== this.generation) return;
this.pagesDiv.scrollTop = scrollRatio * this.pagesDiv.scrollHeight;
}
// ---- Loading ----
async load(url, byteSize) {
this.lastUrl = url;
this.lastSize = byteSize;
this._setState("loading");
await this._loadInternal(url, byteSize);
}
async _loadInternal(url, _byteSize) {
const generation = ++this.generation;
console.log(
`[BizMatch] PDF viewer load started: generation ${generation}`,
);
// Await cleanup of previous document (promises may be involved)
await this._disposeCurrentDocument();
if (generation !== this.generation) return;
this.pagesDiv.innerHTML = "";
const loadStart = performance.now();
try {
const task = pdfjsLib.getDocument({
url,
rangeChunkSize: 65536,
disableAutoFetch: false,
// PDF.js >= 5 decodes CCITT-G4/JBIG2 (B/W scans), JPEG2000 and ICC
// color via WASM modules fetched from wasmUrl. Without these URLs
// the decoders fail silently (ignoreErrors default) and scanned
// pages render as blank white canvases of the correct size.
wasmUrl: "/pdfjs/wasm/",
standardFontDataUrl: "/pdfjs/standard_fonts/",
iccUrl: "/pdfjs/iccs/",
});
this.loadingTask = task;
const doc = await task.promise;
if (generation !== this.generation) {
doc.destroy();
return;
}
this.doc = doc;
this.numPages = doc.numPages;
const loadMs = Math.round(performance.now() - loadStart);
console.log(
`[BizMatch] PDF document loaded: ${this.numPages} pages in ${loadMs}ms`,
);
this.toolbar.hidden = false;
this._updateZoomLabel();
await this._renderAllPages(doc, generation, true);
if (generation !== this.generation) return;
const totalMs = Math.round(performance.now() - loadStart);
console.log(
`[BizMatch] PDF viewer load completed: generation ${generation}`,
);
console.log(
`[BizMatch] PDF fully rendered: ${this.numPages} pages in ${totalMs}ms`,
);
} catch (err) {
if (generation !== this.generation) return;
// Distinguish cancellation from real errors
if (isCancellationError(err)) {
console.log(
`[BizMatch] PDF generation ${generation} cancelled`,
);
return;
}
const msg = errorMsg(err);
console.error(`[BizMatch] PDF load failed:`, msg);
this.errorMsg.textContent = `Unable to open PDF: ${msg}`;
this._setState("error");
}
}
// Shared render loop for initial load, zoom and resize.
// hideLoadingAfterFirstPage: true on initial load (removes the overlay
// as soon as page 1 is visible).
async _renderAllPages(doc, generation, hideLoadingAfterFirstPage) {
this.pagesDiv.innerHTML = "";
const viewerWidth = this._renderWidth();
this._lastRenderWidth = viewerWidth;
console.log(`[BizMatch] PDF render width: ${viewerWidth}px`);
for (let p = 1; p <= this.numPages; p++) {
if (generation !== this.generation) return;
const pStart = performance.now();
await this._renderPage(doc, p, viewerWidth, generation);
if (generation !== this.generation) return;
if (p === 1 && hideLoadingAfterFirstPage) {
this._setState(null); // Remove loading overlay
}
console.log(
`[BizMatch] PDF page ${p} rendered in ${
Math.round(performance.now() - pStart)
}ms`,
);
await new Promise((r) => requestAnimationFrame(r));
}
}
_setState(state) {
this.loadingDiv.hidden = true;
this.errorDiv.hidden = true;
if (state === "loading") this.loadingDiv.hidden = false;
if (state === "error") this.errorDiv.hidden = false;
this._viewerState = state;
}
_getViewportWidth() {
const style = globalThis.getComputedStyle
? getComputedStyle(this.pagesDiv)
: null;
let padding = 24; // default guess
if (style) {
padding = (parseFloat(style.paddingLeft) || 0) +
(parseFloat(style.paddingRight) || 0);
}
const raw = Math.round(this.pagesDiv.clientWidth || 600);
return Math.max(raw - padding, 200);
}
async _disposeCurrentDocument() {
for (const rt of this.renderTasks) {
try {
rt.cancel();
} catch { /* ok */ }
}
this.renderTasks.clear();
if (this.loadingTask) {
try {
await this.loadingTask.destroy();
} catch { /* ok */ }
this.loadingTask = null;
}
if (this.doc) {
try {
await this.doc.destroy();
} catch { /* ok */ }
this.doc = null;
this.numPages = 0;
}
}
async _renderPage(doc, pageNum, viewerWidth, generation) {
const pageDiv = document.createElement("div");
pageDiv.className = "pdf-v-page";
pageDiv.dataset.page = String(pageNum);
pageDiv.dataset.renderState = "idle";
const canvas = document.createElement("canvas");
pageDiv.appendChild(canvas);
this.pagesDiv.appendChild(pageDiv);
pageDiv.dataset.renderState = "rendering";
try {
const page = await doc.getPage(pageNum);
if (generation !== this.generation) return;
const vp1 = page.getViewport({ scale: 1 });
const fitScale = viewerWidth / vp1.width;
const viewport = page.getViewport({ scale: fitScale });
const dpr = Math.min(globalThis.devicePixelRatio || 1, 2);
canvas.width = Math.floor(viewport.width * dpr);
canvas.height = Math.floor(viewport.height * dpr);
canvas.style.width = `${Math.floor(viewport.width)}px`;
canvas.style.height = `${Math.floor(viewport.height)}px`;
const ctx = canvas.getContext("2d");
const transform = dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined;
const renderTask = page.render({
canvasContext: ctx,
viewport,
transform,
});
this.renderTasks.add(renderTask);
await renderTask.promise;
this.renderTasks.delete(renderTask);
if (generation !== this.generation) return;
pageDiv.dataset.renderState = "rendered";
} catch (err) {
if (generation !== this.generation) return;
if (isCancellationError(err)) return;
pageDiv.dataset.renderState = "error";
console.error(
`[BizMatch] Failed to render PDF page ${pageNum}`,
err,
);
if (pageNum === 1) {
this.errorMsg.textContent = `Unable to open PDF: ${errorMsg(err)}`;
this._setState("error");
} else {
pageDiv.textContent = `Error rendering page ${pageNum}`;
pageDiv.style.padding = "20px";
pageDiv.style.color = "#f88";
pageDiv.style.textAlign = "center";
}
}
}
destroy() {
this.generation = 0;
clearTimeout(this._resizeTimer);
clearTimeout(this._zoomTimer);
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
this._disposeCurrentDocument().catch(() => {});
this.pagesDiv.innerHTML = "";
this.toolbar.hidden = true;
this._setState("loading");
}
}
function isCancellationError(err) {
if (!(err instanceof Error)) return false;
const msg = err.message || "";
return msg.includes("cancelled") ||
msg.includes("canceled") ||
msg.includes("destroyed") ||
msg.includes("Worker was destroyed");
}
function errorMsg(err) {
return err instanceof Error ? err.message : String(err);
}
globalThis.PdfViewer = PdfViewer;

View File

@@ -0,0 +1,136 @@
/* Taken from viewer-phase1/styles.css: the .viewer / #pdfViewer rules plus the
PDF.js viewer + zoom toolbar blocks. The page is nothing but the viewer pane,
so .viewer fills the iframe instead of sitting in a grid column. */
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
font: 14px system-ui, sans-serif;
color: #202124;
}
.viewer {
position: relative;
height: 100%;
background: #555;
overflow: hidden;
}
.viewer #pdfViewer {
position: relative;
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
background: #525659;
}
.viewer #pdfMessage {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: white;
pointer-events: none;
z-index: 2;
white-space: pre-wrap;
padding: 20px;
text-align: center;
}
.viewer.loaded #pdfMessage {
display: none;
}
/* PDF.js viewer */
.pdf-v-overlay {
position: absolute;
inset: 0;
z-index: 1;
}
.pdf-v-loading {
padding: 40px 20px;
color: #aaa;
text-align: center;
font-size: 15px;
}
.pdf-v-error {
padding: 30px 20px;
text-align: center;
}
.pdf-v-error-msg {
color: #f88;
font-size: 16px;
margin-bottom: 12px;
white-space: pre-wrap;
}
.pdf-v-retry {
padding: 8px 24px;
font-size: 14px;
cursor: pointer;
border: 1px solid #888;
background: #444;
color: #eee;
border-radius: 4px;
}
.pdf-v-retry:hover {
background: #555;
}
.pdf-v-pages {
box-sizing: border-box;
width: 100%;
height: 100%;
overflow-y: scroll;
overflow-x: auto;
scrollbar-gutter: stable;
display: flex;
flex-direction: column;
align-items: center;
align-items: safe center; /* keeps left edge reachable when zoomed wider than the pane */
gap: 16px;
padding: 12px;
}
.pdf-v-page {
background: white;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.35);
flex-shrink: 0;
}
.pdf-v-page canvas {
display: block;
}
/* Zoom toolbar */
.pdf-v-toolbar {
position: absolute;
top: 10px;
right: 24px;
z-index: 3;
display: flex;
align-items: center;
gap: 2px;
background: rgba(35, 35, 38, 0.88);
border: 1px solid #666;
border-radius: 6px;
padding: 3px 4px;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.4);
}
.pdf-v-toolbar button {
border: 0;
background: transparent;
color: #e5e5e5;
font-size: 15px;
line-height: 1;
padding: 5px 9px;
cursor: pointer;
border-radius: 4px;
}
.pdf-v-toolbar button:hover {
background: #555;
}
.pdf-v-zoom-label {
min-width: 52px;
text-align: center;
font-size: 12px !important;
color: #ccc !important;
}

View File

@@ -0,0 +1,51 @@
// Bootstrap for the standalone PDF viewer page. Plays the role of app.js in
// viewer-phase1: it owns the #pdfMessage overlay and hands one URL to PdfViewer
// (from pdf_viewer.js, unchanged). Everything else — zoom, resize, retry — lives
// in PdfViewer already.
//
// URL contract: /viewer/index.html?file=<urlencoded /api/... path>
// Same-origin only: any file value that is not a root-relative /api/ path is
// refused before it ever reaches pdf.js.
const viewer = document.querySelector(".viewer");
const pdfContainer = document.querySelector("#pdfViewer");
const pdfMessage = document.querySelector("#pdfMessage");
function showMessage(text) {
viewer.classList.remove("loaded");
pdfMessage.textContent = text;
pdfMessage.hidden = false;
}
// Only root-relative /api/ paths. Rejects absolute URLs ("https://..."),
// protocol-relative ones ("//host/api/x") and anything outside /api/.
function safeFileUrl(raw) {
if (!raw) return null;
if (!raw.startsWith("/api/") || raw.startsWith("//")) return null;
// A backslash can be normalized to "/" by some URL parsers — reject outright.
if (raw.includes("\\")) return null;
return raw;
}
const params = new URLSearchParams(globalThis.location.search);
const file = safeFileUrl(params.get("file"));
if (!file) {
showMessage(
params.get("file")
? "Refused to load this document: only same-origin /api/ paths are allowed."
: "Select a document",
);
} else {
showMessage("Loading PDF…");
const pdfViewer = new PdfViewer(pdfContainer);
viewer.classList.add("loaded");
pdfMessage.hidden = true;
pdfViewer.load(file).catch((error) => {
showMessage(
`Cannot open PDF: ${
error instanceof Error ? error.message : String(error)
}`,
);
});
}

View File

@@ -0,0 +1,39 @@
// Copies the pdf.js runtime assets out of node_modules into public/pdfjs/ so the
// standalone viewer (public/viewer/) can load them as plain, unbundled ES modules.
// Runs via predev/prebuild, i.e. also inside the Docker web build stage.
//
// The target layout is dictated by the URLs the ported viewer uses:
// /pdfjs/legacy/pdf.min.mjs (import + GlobalWorkerOptions.workerSrc)
// /pdfjs/wasm/ (wasmUrl — CCITT-G4/JBIG2, JPEG2000, ICC decoders)
// /pdfjs/standard_fonts/ (standardFontDataUrl)
// /pdfjs/iccs/ (iccUrl)
import { cp, mkdir, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const src = path.join(webRoot, 'node_modules', 'pdfjs-dist');
const dest = path.join(webRoot, 'public', 'pdfjs');
try {
await stat(src);
} catch {
console.error(`[copy-pdfjs] ${src} not found — run npm install first.`);
process.exit(1);
}
// Full rebuild, so a pdfjs-dist upgrade never leaves stale files behind.
await rm(dest, { recursive: true, force: true });
await mkdir(path.join(dest, 'legacy'), { recursive: true });
const files = ['pdf.min.mjs', 'pdf.worker.min.mjs'];
for (const file of files) {
await cp(path.join(src, 'legacy', 'build', file), path.join(dest, 'legacy', file));
}
const dirs = ['wasm', 'standard_fonts', 'iccs'];
for (const dir of dirs) {
await cp(path.join(src, dir), path.join(dest, dir), { recursive: true });
}
console.log(`[copy-pdfjs] ${files.join(', ')} + ${dirs.join('/, ')}/ -> public/pdfjs/`);

View File

@@ -31,8 +31,10 @@ export default function App() {
if (!staff) return <Login onLogin={setStaff} />;
return (
<div className="min-h-screen bg-gray-50 text-gray-900">
<header className="flex items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
// h-screen + min-h-0 below, so the business detail's master-detail split can
// fill exactly the remaining viewport height.
<div className="flex h-screen flex-col bg-gray-50 text-gray-900">
<header className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
<button
className="text-base font-semibold tracking-tight"
onClick={() => setView({ name: 'businesses' })}
@@ -50,13 +52,15 @@ export default function App() {
</div>
</header>
<main className="mx-auto max-w-5xl px-6 py-6">
{view.name === 'businesses' ? (
{view.name === 'businesses' ? (
<main className="mx-auto w-full max-w-5xl flex-1 overflow-auto px-6 py-6">
<Businesses onOpen={(id) => setView({ name: 'business', id })} />
) : (
</main>
) : (
<main className="flex min-h-0 flex-1 flex-col px-6 py-4">
<BusinessDetail id={view.id} onBack={() => setView({ name: 'businesses' })} />
)}
</main>
</main>
)}
</div>
);
}

View File

@@ -25,7 +25,8 @@ export interface Business {
}
export interface BusinessFile {
name: string;
/** Relative to the business directory, posix separators. */
path: string;
size: number;
mtime: string;
}
@@ -73,3 +74,13 @@ export const api = {
businessFiles: (id: string) => request<BusinessFile[]>(`/api/businesses/${id}/files`),
scan: () => post<ScanResult>('/api/businesses/scan'),
};
/** Same-origin streaming URL of one file inside a business directory. */
export function businessFileUrl(id: string, filePath: string): string {
return `/api/businesses/${id}/file?path=${encodeURIComponent(filePath)}`;
}
/** The standalone (unbundled) pdf.js viewer page, pointed at a business file. */
export function viewerUrl(id: string, filePath: string): string {
return `/viewer/index.html?file=${encodeURIComponent(businessFileUrl(id, filePath))}`;
}

View File

@@ -1,5 +1,11 @@
import { useEffect, useState } from 'react';
import { api, type Business, type BusinessFile } from '../api.js';
import {
api,
businessFileUrl,
viewerUrl,
type Business,
type BusinessFile,
} from '../api.js';
function formatSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
@@ -16,61 +22,108 @@ function formatDate(iso: string): string {
});
}
const isPdf = (filePath: string) => filePath.toLowerCase().endsWith('.pdf');
export default function BusinessDetail({ id, onBack }: { id: string; onBack: () => void }) {
const [business, setBusiness] = useState<Business | null>(null);
const [files, setFiles] = useState<BusinessFile[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
setSelected(null);
api.business(id).then(setBusiness).catch((err: Error) => setError(err.message));
api.businessFiles(id).then(setFiles).catch((err: Error) => setError(err.message));
}, [id]);
return (
<div>
<button onClick={onBack} className="mb-4 text-sm text-blue-600 hover:underline">
Back to businesses
</button>
<div className="flex h-full min-h-0 flex-col">
<div className="mb-3 shrink-0">
<button onClick={onBack} className="text-sm text-blue-600 hover:underline">
Back to businesses
</button>
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
{business && (
<div className="mb-5">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold">{business.name}</h1>
<span className="rounded-full border border-gray-300 bg-white px-2 py-0.5 text-xs text-gray-600">
{business.status}
</span>
{business && (
<div className="mt-2">
<div className="flex items-center gap-3">
<h1 className="text-lg font-semibold">{business.name}</h1>
<span className="rounded-full border border-gray-300 bg-white px-2 py-0.5 text-xs text-gray-600">
{business.status}
</span>
</div>
<p className="mt-1 font-mono text-xs text-gray-500">{business.nas_path}</p>
</div>
<p className="mt-1 font-mono text-xs text-gray-500">{business.nas_path}</p>
</div>
)}
)}
</div>
<table className="w-full border-collapse bg-white text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
<th className="px-3 py-2 font-medium">File</th>
<th className="w-24 px-3 py-2 font-medium">Size</th>
<th className="w-48 px-3 py-2 font-medium">Modified</th>
</tr>
</thead>
<tbody>
{files?.map((f) => (
<tr key={f.name} className="border-b border-gray-100">
<td className="px-3 py-1.5">{f.name}</td>
<td className="px-3 py-1.5 text-gray-500">{formatSize(f.size)}</td>
<td className="px-3 py-1.5 text-gray-500">{formatDate(f.mtime)}</td>
</tr>
))}
{files && files.length === 0 && (
<tr>
<td colSpan={3} className="px-3 py-4 text-gray-500">
No files in this directory.
</td>
</tr>
{/* Master-detail: file table left, PDF viewer right, together filling the viewport. */}
<div className="flex min-h-0 flex-1 gap-4">
<div className="min-w-0 flex-1 overflow-auto rounded border border-gray-200 bg-white">
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 bg-white">
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
<th className="px-3 py-2 font-medium">File</th>
<th className="w-24 px-3 py-2 font-medium">Size</th>
<th className="w-44 px-3 py-2 font-medium">Modified</th>
</tr>
</thead>
<tbody>
{files?.map((f) => (
<tr
key={f.path}
onClick={isPdf(f.path) ? () => setSelected(f.path) : undefined}
className={`border-b border-gray-100 ${
isPdf(f.path) ? 'cursor-pointer hover:bg-gray-50' : ''
} ${f.path === selected ? 'bg-blue-50 hover:bg-blue-50' : ''}`}
>
<td className="px-3 py-1.5">
{isPdf(f.path) ? (
<span className={f.path === selected ? 'font-medium text-blue-700' : ''}>
{f.path}
</span>
) : (
<a
href={businessFileUrl(id, f.path)}
className="text-blue-600 hover:underline"
>
{f.path}
</a>
)}
</td>
<td className="px-3 py-1.5 text-gray-500">{formatSize(f.size)}</td>
<td className="px-3 py-1.5 text-gray-500">{formatDate(f.mtime)}</td>
</tr>
))}
{files && files.length === 0 && (
<tr>
<td colSpan={3} className="px-3 py-4 text-gray-500">
No files in this directory.
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="min-w-0 flex-1 overflow-hidden rounded border border-gray-200 bg-gray-100">
{selected ? (
<iframe
// Remounting on `key` change is what makes the standalone page reload
// the new document — it reads ?file= once at startup.
key={selected}
src={viewerUrl(id, selected)}
title={selected}
className="h-full w-full border-0"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-gray-500">
Select a PDF to view it here.
</div>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}