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

@@ -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>
);
}