changes
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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))}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user