changes
This commit is contained in:
@@ -12,7 +12,29 @@
|
|||||||
"Bash(kill 128755)",
|
"Bash(kill 128755)",
|
||||||
"Bash(echo \"exit=$?\")",
|
"Bash(echo \"exit=$?\")",
|
||||||
"Bash(npm view *)",
|
"Bash(npm view *)",
|
||||||
"Bash(echo \"--- exit $? ---\")"
|
"Bash(echo \"--- exit $? ---\")",
|
||||||
|
"Bash(rm -rf /tmp/nas-test)",
|
||||||
|
"Bash(mkdir -p \"/tmp/nas-test/AAA = ACTIVE/Test Biz GmbH/docs/subdir\")",
|
||||||
|
"Bash(mkdir -p \"/tmp/nas-test/AAA = ACTIVE/Test Biz GmbH/.git\")",
|
||||||
|
"Bash(ln -s /etc/passwd \"/tmp/nas-test/AAA = ACTIVE/Test Biz GmbH/escape.pdf\")",
|
||||||
|
"Bash(mkdir -p \"/tmp/nas-test/AAA = SOLD\" \"/tmp/nas-test/AAA = INACTIVE\")",
|
||||||
|
"Bash(break)",
|
||||||
|
"Bash(node -pe 'JSON.parse\\(require\\(\"fs\"\\).readFileSync\\(0\\)\\).[0].id')",
|
||||||
|
"Bash(curl -s localhost:8090/api/staff)",
|
||||||
|
"Bash(pkill -f 'tsx.*server')",
|
||||||
|
"Bash(curl -s -m 2 -o /dev/null -w '%{http_code}\\\\n' localhost:8090/api/health)",
|
||||||
|
"Bash(/tmp/claude-1000/-home-aknuth-git-bizmatch-app/4b67ffa5-f538-477a-ae54-6d68ec7ee19c/scratchpad/run-server.sh *)",
|
||||||
|
"Bash(docker run *)",
|
||||||
|
"Skill(claude-in-chrome)",
|
||||||
|
"Bash(/tmp/claude-1000/-home-aknuth-git-bizmatch-app/4b67ffa5-f538-477a-ae54-6d68ec7ee19c/scratchpad/run-vite.sh *)",
|
||||||
|
"Bash(curl -s -m 2 localhost:8090/api/health)",
|
||||||
|
"Bash(ps -eo pid,etime,cmd)",
|
||||||
|
"Bash(curl -s localhost:8090/viewer/pdf_viewer.js)",
|
||||||
|
"Bash(echo \"QC occurrences served: $?\")",
|
||||||
|
"Bash(kill 29669 29703 29714)",
|
||||||
|
"Bash(curl -s -m 2 -o /dev/null -w 'server: %{http_code}\\\\n' localhost:8090/api/health)",
|
||||||
|
"Bash(git check-ignore *)",
|
||||||
|
"Bash(git add *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ web/node_modules
|
|||||||
web/dist
|
web/dist
|
||||||
.env
|
.env
|
||||||
.git
|
.git
|
||||||
|
web/public/pdfjs
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ dist/
|
|||||||
web/node_modules/
|
web/node_modules/
|
||||||
web/dist/
|
web/dist/
|
||||||
.env
|
.env
|
||||||
|
web/public/pdfjs/
|
||||||
|
|||||||
68
README.md
68
README.md
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Module 1: foundation (Docker Compose, PostgreSQL, schema, migrations, login).
|
Module 1: foundation (Docker Compose, PostgreSQL, schema, migrations, login).
|
||||||
Module 2: business scan (NAS -> DB) and the first UI.
|
Module 2: business scan (NAS -> DB) and the first UI.
|
||||||
|
Module 3: recursive file listing, PDF streaming from the NAS and the ported
|
||||||
|
pdf.js viewer.
|
||||||
|
|
||||||
The UI and all domain constants are English.
|
The UI and all domain constants are English.
|
||||||
|
|
||||||
@@ -83,7 +85,7 @@ directory aborts the scan with an error naming the path.
|
|||||||
3. Copy the project folder to the AI machine, run `docker compose up -d --build`
|
3. Copy the project folder to the AI machine, run `docker compose up -d --build`
|
||||||
4. Restore the dump: `docker compose exec -T db psql -U bizmatch bizmatch < backup.sql`
|
4. Restore the dump: `docker compose exec -T db psql -U bizmatch bizmatch < backup.sql`
|
||||||
|
|
||||||
## API (as of module 2)
|
## API (as of module 3)
|
||||||
|
|
||||||
| Method | Path | Purpose | Session |
|
| Method | Path | Purpose | Session |
|
||||||
| ------ | --------------------------- | ------------------------------------------------ | ------- |
|
| ------ | --------------------------- | ------------------------------------------------ | ------- |
|
||||||
@@ -96,22 +98,74 @@ directory aborts the scan with an error naming the path.
|
|||||||
| POST | /api/businesses/scan | scan the NAS → `{scanned, inserted, updated, missing}` | yes |
|
| POST | /api/businesses/scan | scan the NAS → `{scanned, inserted, updated, missing}` | yes |
|
||||||
| GET | /api/businesses | list `?status=&search=` + counts per status | yes |
|
| GET | /api/businesses | list `?status=&search=` + counts per status | yes |
|
||||||
| GET | /api/businesses/:id | single business incl. `nas_path` | yes |
|
| GET | /api/businesses/:id | single business incl. `nas_path` | yes |
|
||||||
| GET | /api/businesses/:id/files | live directory listing (PDFs first) | yes |
|
| GET | /api/businesses/:id/files | recursive listing, max depth 3 (PDFs first) | yes |
|
||||||
|
| GET | /api/businesses/:id/file | stream one file, `?path=<relative>` | yes |
|
||||||
|
|
||||||
Everything except health, staff (GET+POST) and login requires the session
|
Everything except health, staff (GET+POST) and login requires the session
|
||||||
cookie; without it the API answers `401`.
|
cookie; without it the API answers `401`.
|
||||||
|
|
||||||
|
### File listing and streaming
|
||||||
|
|
||||||
|
`/files` walks the business directory recursively (max depth 3), skipping
|
||||||
|
dotfiles, dot-directories and symlinks, and returns
|
||||||
|
`{ path, size, mtime }` with `path` relative to the business directory and
|
||||||
|
always posix-separated. PDFs come first, then everything else, each group
|
||||||
|
alphabetical.
|
||||||
|
|
||||||
|
`/file?path=…` streams one of those files straight from disk
|
||||||
|
(`createReadStream`, never buffered):
|
||||||
|
|
||||||
|
* the path is resolved against `nas_path` and then `realpath`-validated to be
|
||||||
|
inside `realpath(business dir)`. Absolute paths, `..`, leading dots, empty
|
||||||
|
paths and symlinks pointing out of the tree get `400`; a missing file `404`.
|
||||||
|
* single-range HTTP `Range` requests answer `206` with `Content-Range`,
|
||||||
|
unsatisfiable ones `416`.
|
||||||
|
* `ETag` is derived from mtime + size, `If-None-Match` answers `304`.
|
||||||
|
* `.pdf` is served as `application/pdf` (inline), anything else as
|
||||||
|
`application/octet-stream` with `Content-Disposition: attachment`.
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
`web/` is a Vite + React + TypeScript app with Tailwind v4 (no router, no state
|
`web/` is a Vite + React + TypeScript app with Tailwind v4 (no router, no state
|
||||||
library). Views: login ("Who is working?"), business list (tabs with counts,
|
library). Views: login ("Who is working?"), business list (tabs with counts,
|
||||||
search, "Scan NAS now") and business detail (status badge, live file list).
|
search, "Scan NAS now") and business detail — a master-detail split filling the
|
||||||
|
viewport: file table left, PDF viewer right.
|
||||||
|
|
||||||
In dev, Vite proxies `/api` to `http://localhost:8090`. In production the
|
In dev, Vite proxies `/api` to `http://localhost:8090`. In production the
|
||||||
Fastify app serves `web/dist` via `@fastify/static` with an SPA fallback to
|
Fastify app serves `web/dist` via `@fastify/static` with an SPA fallback to
|
||||||
`index.html` for all non-`/api` routes; the Dockerfile builds the frontend in
|
`index.html` for all non-`/api` routes; the Dockerfile builds the frontend in
|
||||||
its own stage and copies `web/dist` into the runtime image.
|
its own stage and copies `web/dist` into the runtime image.
|
||||||
|
|
||||||
|
### PDF viewer
|
||||||
|
|
||||||
|
The viewer is the proven one from the phase-1 Deno desktop app (see
|
||||||
|
`viewer-phase1/`), ported nearly byte-identical. It lives in
|
||||||
|
`web/public/viewer/` as plain, unbundled ES modules — Vite serves `public/`
|
||||||
|
as-is, so the same files work in dev and prod. The React app embeds it in an
|
||||||
|
`<iframe>`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/viewer/index.html?file=<urlencoded /api/businesses/:id/file?path=...>
|
||||||
|
```
|
||||||
|
|
||||||
|
The page refuses any `file` value that is not a root-relative `/api/` path, and
|
||||||
|
the iframe is same-origin, so the normal session cookie authenticates it.
|
||||||
|
|
||||||
|
`web/public/pdfjs/` holds the pdf.js runtime, copied out of
|
||||||
|
`node_modules/pdfjs-dist` (pinned to exactly 6.1.200) by
|
||||||
|
`web/scripts/copy-pdfjs.mjs`, which runs on `predev` and `prebuild` — also
|
||||||
|
inside the Docker web stage. The directory is generated and git-ignored:
|
||||||
|
|
||||||
|
```
|
||||||
|
web/public/pdfjs/legacy/ pdf.min.mjs + pdf.worker.min.mjs
|
||||||
|
web/public/pdfjs/wasm/ CCITT-G4/JBIG2, JPEG2000 and ICC decoders
|
||||||
|
web/public/pdfjs/standard_fonts/ standardFontDataUrl
|
||||||
|
web/public/pdfjs/iccs/ iccUrl
|
||||||
|
```
|
||||||
|
|
||||||
|
The `wasm/` directory is what makes scanned B/W pages render at all; without it
|
||||||
|
pdf.js fails the decoders silently and shows blank white canvases.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -120,10 +174,14 @@ src/
|
|||||||
config.ts env configuration
|
config.ts env configuration
|
||||||
db.ts pg pool + query helpers
|
db.ts pg pool + query helpers
|
||||||
migrate.ts migration runner (transactional, advisory lock)
|
migrate.ts migration runner (transactional, advisory lock)
|
||||||
business-scan.ts NAS scan + directory listing
|
business-scan.ts NAS scan, recursive listing, safe file path resolution
|
||||||
server.ts Fastify app (health, staff, login, businesses, static)
|
server.ts Fastify app (health, staff, login, businesses, file, static)
|
||||||
web/
|
web/
|
||||||
|
scripts/copy-pdfjs.mjs pdfjs-dist -> public/pdfjs/ (predev + prebuild)
|
||||||
|
public/viewer/ standalone, unbundled pdf.js viewer page
|
||||||
|
public/pdfjs/ generated, git-ignored pdf.js runtime
|
||||||
src/api.ts typed API client
|
src/api.ts typed API client
|
||||||
src/App.tsx session gate + view switch
|
src/App.tsx session gate + view switch
|
||||||
src/views/ Login, Businesses, BusinessDetail
|
src/views/ Login, Businesses, BusinessDetail
|
||||||
|
viewer-phase1/ reference copy of the phase-1 desktop viewer
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readdir, stat } from 'node:fs/promises';
|
import { readdir, realpath, stat } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
import { query } from './db.js';
|
import { query } from './db.js';
|
||||||
@@ -103,23 +103,101 @@ export async function scanBusinesses(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BusinessFile {
|
export interface BusinessFile {
|
||||||
name: string;
|
/** Relative to the business directory, always with posix separators. */
|
||||||
|
path: string;
|
||||||
size: number;
|
size: number;
|
||||||
mtime: string;
|
mtime: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Live, non-recursive listing of a business directory: PDFs first, then the rest, alphabetical. */
|
/** How deep below the business directory the listing descends. */
|
||||||
export async function listBusinessFiles(nasPath: string): Promise<BusinessFile[]> {
|
const MAX_DEPTH = 3;
|
||||||
const entries = await readdir(nasPath, { withFileTypes: true });
|
|
||||||
const files: BusinessFile[] = [];
|
async function walk(root: string, relative: string, depth: number, out: BusinessFile[]) {
|
||||||
|
const entries = await readdir(path.join(root, relative), { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.isFile()) continue;
|
if (entry.name.startsWith('.')) continue; // dotfiles and dot-directories
|
||||||
const info = await stat(path.join(nasPath, entry.name));
|
const rel = relative ? `${relative}/${entry.name}` : entry.name;
|
||||||
files.push({ name: entry.name, size: info.size, mtime: info.mtime.toISOString() });
|
if (entry.isDirectory()) {
|
||||||
|
if (depth < MAX_DEPTH) await walk(root, rel, depth + 1, out);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!entry.isFile()) continue; // symlinks, sockets, ... are not listed
|
||||||
|
const info = await stat(path.join(root, rel));
|
||||||
|
out.push({ path: rel, size: info.size, mtime: info.mtime.toISOString() });
|
||||||
}
|
}
|
||||||
const isPdf = (name: string) => name.toLowerCase().endsWith('.pdf');
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Live, recursive listing of a business directory (max depth 3): PDFs first,
|
||||||
|
* then the rest, each group alphabetical by relative path.
|
||||||
|
*/
|
||||||
|
export async function listBusinessFiles(nasPath: string): Promise<BusinessFile[]> {
|
||||||
|
const files: BusinessFile[] = [];
|
||||||
|
await walk(nasPath, '', 1, files);
|
||||||
|
const isPdf = (p: string) => p.toLowerCase().endsWith('.pdf');
|
||||||
return files.sort((a, b) => {
|
return files.sort((a, b) => {
|
||||||
if (isPdf(a.name) !== isPdf(b.name)) return isPdf(a.name) ? -1 : 1;
|
if (isPdf(a.path) !== isPdf(b.path)) return isPdf(a.path) ? -1 : 1;
|
||||||
return a.name.localeCompare(b.name);
|
return a.path.localeCompare(b.path);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Thrown by resolveBusinessFile; `status` is the HTTP status the route should answer with. */
|
||||||
|
export class BusinessFileError extends Error {
|
||||||
|
constructor(readonly status: 400 | 404, message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedFile {
|
||||||
|
/** Absolute, symlink-resolved path — safe to open. */
|
||||||
|
absPath: string;
|
||||||
|
size: number;
|
||||||
|
mtimeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a client-supplied relative path into an absolute path inside the business
|
||||||
|
* directory. Rejects empty/absolute paths and "..", then realpath-validates that
|
||||||
|
* the result really is below realpath(business directory), which also catches
|
||||||
|
* symlinks pointing out of the NAS tree.
|
||||||
|
*/
|
||||||
|
export async function resolveBusinessFile(
|
||||||
|
nasPath: string,
|
||||||
|
relPath: unknown,
|
||||||
|
): Promise<ResolvedFile> {
|
||||||
|
if (typeof relPath !== 'string' || relPath === '') {
|
||||||
|
throw new BusinessFileError(400, 'path is missing');
|
||||||
|
}
|
||||||
|
if (relPath.includes('\0')) throw new BusinessFileError(400, 'invalid path');
|
||||||
|
if (relPath.startsWith('/') || relPath.startsWith('\\') || /^[a-zA-Z]:/.test(relPath)) {
|
||||||
|
throw new BusinessFileError(400, 'absolute paths are not allowed');
|
||||||
|
}
|
||||||
|
// Both separators, so a "..\\.." variant cannot slip past on any platform.
|
||||||
|
// Rejecting every leading dot covers "." and ".." and keeps the endpoint in
|
||||||
|
// sync with listBusinessFiles(), which skips dotfiles and dot-directories.
|
||||||
|
const segments = relPath.split(/[/\\]/);
|
||||||
|
if (segments.some((s) => s === '' || s.startsWith('.'))) {
|
||||||
|
throw new BusinessFileError(400, 'invalid path segment');
|
||||||
|
}
|
||||||
|
|
||||||
|
let base: string;
|
||||||
|
try {
|
||||||
|
base = await realpath(nasPath);
|
||||||
|
} catch {
|
||||||
|
throw new BusinessFileError(404, 'business directory not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
let absPath: string;
|
||||||
|
try {
|
||||||
|
absPath = await realpath(path.join(base, ...segments));
|
||||||
|
} catch {
|
||||||
|
throw new BusinessFileError(404, 'file not found');
|
||||||
|
}
|
||||||
|
if (absPath !== base && !absPath.startsWith(base + path.sep)) {
|
||||||
|
throw new BusinessFileError(400, 'path escapes the business directory');
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = await stat(absPath);
|
||||||
|
if (!info.isFile()) throw new BusinessFileError(404, 'file not found');
|
||||||
|
return { absPath, size: info.size, mtimeMs: Math.floor(info.mtimeMs) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { existsSync } from 'node:fs';
|
import { createReadStream, existsSync } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
@@ -7,7 +7,12 @@ import fastifyStatic from '@fastify/static';
|
|||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
import { pool, query, queryOne } from './db.js';
|
import { pool, query, queryOne } from './db.js';
|
||||||
import { runMigrations } from './migrate.js';
|
import { runMigrations } from './migrate.js';
|
||||||
import { listBusinessFiles, scanBusinesses } from './business-scan.js';
|
import {
|
||||||
|
BusinessFileError,
|
||||||
|
listBusinessFiles,
|
||||||
|
resolveBusinessFile,
|
||||||
|
scanBusinesses,
|
||||||
|
} from './business-scan.js';
|
||||||
|
|
||||||
interface Staff {
|
interface Staff {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -169,6 +174,83 @@ app.get<{ Params: { id: string } }>('/api/businesses/:id/files', async (req, rep
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Parses a single-range `Range` header. null = ignore, 'invalid' = 416. */
|
||||||
|
function parseRange(
|
||||||
|
header: string | undefined,
|
||||||
|
size: number,
|
||||||
|
): { start: number; end: number } | null | 'invalid' {
|
||||||
|
if (!header) return null;
|
||||||
|
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
||||||
|
if (!match) return null; // multi-range or garbage: answer with the full body
|
||||||
|
const [, rawStart, rawEnd] = match;
|
||||||
|
if (rawStart === '' && rawEnd === '') return 'invalid';
|
||||||
|
|
||||||
|
let start: number;
|
||||||
|
let end: number;
|
||||||
|
if (rawStart === '') {
|
||||||
|
// suffix range: the last N bytes
|
||||||
|
const suffix = Number(rawEnd);
|
||||||
|
if (suffix === 0) return 'invalid';
|
||||||
|
start = Math.max(0, size - suffix);
|
||||||
|
end = size - 1;
|
||||||
|
} else {
|
||||||
|
start = Number(rawStart);
|
||||||
|
end = rawEnd === '' ? size - 1 : Math.min(Number(rawEnd), size - 1);
|
||||||
|
}
|
||||||
|
if (start > end || start >= size) return 'invalid';
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string }; Querystring: { path?: string } }>(
|
||||||
|
'/api/businesses/:id/file',
|
||||||
|
async (req, reply) => {
|
||||||
|
const row = await queryOne<Business>('SELECT * FROM business WHERE id = $1', [req.params.id]);
|
||||||
|
if (!row) return reply.code(404).send({ error: 'business not found' });
|
||||||
|
|
||||||
|
let file;
|
||||||
|
try {
|
||||||
|
file = await resolveBusinessFile(row.nas_path, req.query.path);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof BusinessFileError) {
|
||||||
|
return reply.code(err.status).send({ error: err.message });
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const etag = `"${file.mtimeMs.toString(16)}-${file.size.toString(16)}"`;
|
||||||
|
reply.header('ETag', etag);
|
||||||
|
reply.header('Accept-Ranges', 'bytes');
|
||||||
|
reply.header('Cache-Control', 'private, max-age=0, must-revalidate');
|
||||||
|
|
||||||
|
const ifNoneMatch = req.headers['if-none-match'];
|
||||||
|
if (ifNoneMatch && ifNoneMatch.split(',').some((t) => t.trim().replace(/^W\//, '') === etag)) {
|
||||||
|
return reply.code(304).send();
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = parseRange(req.headers.range, file.size);
|
||||||
|
if (range === 'invalid') {
|
||||||
|
reply.header('Content-Range', `bytes */${file.size}`);
|
||||||
|
return reply.code(416).send({ error: 'range not satisfiable' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = (req.query.path ?? '').split('/').pop() ?? 'file';
|
||||||
|
const isPdf = name.toLowerCase().endsWith('.pdf');
|
||||||
|
const filenameStar = `filename*=UTF-8''${encodeURIComponent(name)}`;
|
||||||
|
reply.header('Content-Type', isPdf ? 'application/pdf' : 'application/octet-stream');
|
||||||
|
reply.header('Content-Disposition', `${isPdf ? 'inline' : 'attachment'}; ${filenameStar}`);
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
reply.code(206);
|
||||||
|
reply.header('Content-Range', `bytes ${range.start}-${range.end}/${file.size}`);
|
||||||
|
reply.header('Content-Length', range.end - range.start + 1);
|
||||||
|
return reply.send(createReadStream(file.absPath, { start: range.start, end: range.end }));
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header('Content-Length', file.size);
|
||||||
|
return reply.send(createReadStream(file.absPath));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// --------------------------------------------------------------- Static
|
// --------------------------------------------------------------- Static
|
||||||
// Serves the built frontend in production; SPA fallback for non-/api routes.
|
// Serves the built frontend in production; SPA fallback for non-/api routes.
|
||||||
const WEB_DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web', 'dist');
|
const WEB_DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'web', 'dist');
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"copy-pdfjs": "node scripts/copy-pdfjs.mjs",
|
||||||
|
"predev": "npm run copy-pdfjs",
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"prebuild": "npm run copy-pdfjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
24
web/public/viewer/index.html
Normal file
24
web/public/viewer/index.html
Normal 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>
|
||||||
385
web/public/viewer/pdf_viewer.js
Normal file
385
web/public/viewer/pdf_viewer.js
Normal 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;
|
||||||
136
web/public/viewer/viewer.css
Normal file
136
web/public/viewer/viewer.css
Normal 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;
|
||||||
|
}
|
||||||
51
web/public/viewer/viewer.js
Normal file
51
web/public/viewer/viewer.js
Normal 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)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
39
web/scripts/copy-pdfjs.mjs
Normal file
39
web/scripts/copy-pdfjs.mjs
Normal 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/`);
|
||||||
@@ -31,8 +31,10 @@ export default function App() {
|
|||||||
if (!staff) return <Login onLogin={setStaff} />;
|
if (!staff) return <Login onLogin={setStaff} />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 text-gray-900">
|
// h-screen + min-h-0 below, so the business detail's master-detail split can
|
||||||
<header className="flex items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
|
// 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
|
<button
|
||||||
className="text-base font-semibold tracking-tight"
|
className="text-base font-semibold tracking-tight"
|
||||||
onClick={() => setView({ name: 'businesses' })}
|
onClick={() => setView({ name: 'businesses' })}
|
||||||
@@ -50,13 +52,15 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 })} />
|
<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' })} />
|
<BusinessDetail id={view.id} onBack={() => setView({ name: 'businesses' })} />
|
||||||
)}
|
</main>
|
||||||
</main>
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ export interface Business {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BusinessFile {
|
export interface BusinessFile {
|
||||||
name: string;
|
/** Relative to the business directory, posix separators. */
|
||||||
|
path: string;
|
||||||
size: number;
|
size: number;
|
||||||
mtime: string;
|
mtime: string;
|
||||||
}
|
}
|
||||||
@@ -73,3 +74,13 @@ export const api = {
|
|||||||
businessFiles: (id: string) => request<BusinessFile[]>(`/api/businesses/${id}/files`),
|
businessFiles: (id: string) => request<BusinessFile[]>(`/api/businesses/${id}/files`),
|
||||||
scan: () => post<ScanResult>('/api/businesses/scan'),
|
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))}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
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 {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
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 }) {
|
export default function BusinessDetail({ id, onBack }: { id: string; onBack: () => void }) {
|
||||||
const [business, setBusiness] = useState<Business | null>(null);
|
const [business, setBusiness] = useState<Business | null>(null);
|
||||||
const [files, setFiles] = useState<BusinessFile[] | null>(null);
|
const [files, setFiles] = useState<BusinessFile[] | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setSelected(null);
|
||||||
api.business(id).then(setBusiness).catch((err: Error) => setError(err.message));
|
api.business(id).then(setBusiness).catch((err: Error) => setError(err.message));
|
||||||
api.businessFiles(id).then(setFiles).catch((err: Error) => setError(err.message));
|
api.businessFiles(id).then(setFiles).catch((err: Error) => setError(err.message));
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="flex h-full min-h-0 flex-col">
|
||||||
<button onClick={onBack} className="mb-4 text-sm text-blue-600 hover:underline">
|
<div className="mb-3 shrink-0">
|
||||||
← Back to businesses
|
<button onClick={onBack} className="text-sm text-blue-600 hover:underline">
|
||||||
</button>
|
← 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 && (
|
{business && (
|
||||||
<div className="mb-5">
|
<div className="mt-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-lg font-semibold">{business.name}</h1>
|
<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">
|
<span className="rounded-full border border-gray-300 bg-white px-2 py-0.5 text-xs text-gray-600">
|
||||||
{business.status}
|
{business.status}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 font-mono text-xs text-gray-500">{business.nas_path}</p>
|
||||||
</div>
|
</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">
|
{/* Master-detail: file table left, PDF viewer right, together filling the viewport. */}
|
||||||
<thead>
|
<div className="flex min-h-0 flex-1 gap-4">
|
||||||
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
|
<div className="min-w-0 flex-1 overflow-auto rounded border border-gray-200 bg-white">
|
||||||
<th className="px-3 py-2 font-medium">File</th>
|
<table className="w-full border-collapse text-sm">
|
||||||
<th className="w-24 px-3 py-2 font-medium">Size</th>
|
<thead className="sticky top-0 bg-white">
|
||||||
<th className="w-48 px-3 py-2 font-medium">Modified</th>
|
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
|
||||||
</tr>
|
<th className="px-3 py-2 font-medium">File</th>
|
||||||
</thead>
|
<th className="w-24 px-3 py-2 font-medium">Size</th>
|
||||||
<tbody>
|
<th className="w-44 px-3 py-2 font-medium">Modified</th>
|
||||||
{files?.map((f) => (
|
</tr>
|
||||||
<tr key={f.name} className="border-b border-gray-100">
|
</thead>
|
||||||
<td className="px-3 py-1.5">{f.name}</td>
|
<tbody>
|
||||||
<td className="px-3 py-1.5 text-gray-500">{formatSize(f.size)}</td>
|
{files?.map((f) => (
|
||||||
<td className="px-3 py-1.5 text-gray-500">{formatDate(f.mtime)}</td>
|
<tr
|
||||||
</tr>
|
key={f.path}
|
||||||
))}
|
onClick={isPdf(f.path) ? () => setSelected(f.path) : undefined}
|
||||||
{files && files.length === 0 && (
|
className={`border-b border-gray-100 ${
|
||||||
<tr>
|
isPdf(f.path) ? 'cursor-pointer hover:bg-gray-50' : ''
|
||||||
<td colSpan={3} className="px-3 py-4 text-gray-500">
|
} ${f.path === selected ? 'bg-blue-50 hover:bg-blue-50' : ''}`}
|
||||||
No files in this directory.
|
>
|
||||||
</td>
|
<td className="px-3 py-1.5">
|
||||||
</tr>
|
{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>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user