Add document viewer to the compliance portal
Add a full-page viewer at /documents/:alias that resolves the aliased node, exports its watermarked bytes, and renders them: PDFs via react-pdf with page navigation and zoom, images inline, and a download fallback for other file types. Unauthorized visitors see a locked state. Wire the documents list "View" action to link into the viewer (fragments now select alias) and drop the previous open-in-new-tab helpers, since the viewer owns the export. Bound MainLayout to the viewport so the top bar and footer stay fixed and the page area scrolls on its own; the viewer then keeps its toolbar fixed while the PDF body scrolls, matching the design. Add the react-pdf dependency with the pdf.js worker bundled via Vite (for CSP safety) and a headless v2 Separator kit component for the toolbar. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -17,9 +17,11 @@
|
||||
"@probo/routes": "1.0.0",
|
||||
"@probo/ui": "1.0.0",
|
||||
"i18next": "^26.3.4",
|
||||
"pdfjs-dist": "^5.4.296",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-pdf": "^10.3.0",
|
||||
"react-relay": "^21.0.1",
|
||||
"react-router": "^8.1.0",
|
||||
"relay-runtime": "^21.0.1"
|
||||
|
||||
@@ -43,9 +43,12 @@ export function MainLayout({ queryRef }: MainLayoutProps) {
|
||||
const data = usePreloadedQuery<MainLayoutQuery>(mainLayoutQuery, queryRef);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-sand-2">
|
||||
// Bound the shell to the viewport so the TopBar and footer stay fixed and the
|
||||
// page area scrolls on its own. Pages that fill the height (the document
|
||||
// viewer) then scroll their own body while their toolbar stays put.
|
||||
<div className="flex h-dvh flex-col bg-sand-2">
|
||||
<TopBar queryKey={data} />
|
||||
<div className="flex-1">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
<PoweredBy label={t("footer.poweredBy")} />
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import type { PreloadedQuery } from "react-relay";
|
||||
import { graphql, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { DocumentViewerPageQuery } from "./__generated__/DocumentViewerPageQuery.graphql";
|
||||
import { DocumentLocked } from "./_components/DocumentLocked";
|
||||
import { DocumentViewer } from "./_components/DocumentViewer";
|
||||
import type { DocumentKind } from "./_lib/useDocumentExport";
|
||||
import { useDocumentExport } from "./_lib/useDocumentExport";
|
||||
|
||||
export const documentViewerPageQuery = graphql`
|
||||
query DocumentViewerPageQuery($alias: String!) {
|
||||
aliasedNode(alias: $alias) @required(action: THROW) {
|
||||
__typename
|
||||
... on Document {
|
||||
id
|
||||
title
|
||||
isUserAuthorized
|
||||
}
|
||||
... on TrustCenterFile {
|
||||
id
|
||||
name
|
||||
isUserAuthorized
|
||||
}
|
||||
... on AuditReport {
|
||||
id
|
||||
fileName
|
||||
isUserAuthorized
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface DocumentViewerPageProps {
|
||||
queryRef: PreloadedQuery<DocumentViewerPageQuery>;
|
||||
}
|
||||
|
||||
interface ResolvedNode {
|
||||
kind: DocumentKind;
|
||||
id: string;
|
||||
title: string;
|
||||
isAuthorized: boolean;
|
||||
}
|
||||
|
||||
function resolveNode(node: DocumentViewerPageQuery["response"]["aliasedNode"]): ResolvedNode {
|
||||
switch (node.__typename) {
|
||||
case "Document":
|
||||
return { kind: "Document", id: node.id, title: node.title, isAuthorized: node.isUserAuthorized };
|
||||
case "TrustCenterFile":
|
||||
return { kind: "TrustCenterFile", id: node.id, title: node.name, isAuthorized: node.isUserAuthorized };
|
||||
case "AuditReport":
|
||||
return { kind: "AuditReport", id: node.id, title: node.fileName, isAuthorized: node.isUserAuthorized };
|
||||
default:
|
||||
throw new Error(`Unexpected aliased node type: ${node.__typename}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Full-page viewer for a single document/file/report resolved by its alias. It
|
||||
// exports the (watermarked) bytes and renders them; unauthorized visitors get a
|
||||
// locked state instead.
|
||||
export function DocumentViewerPage({ queryRef }: DocumentViewerPageProps) {
|
||||
const data = usePreloadedQuery<DocumentViewerPageQuery>(documentViewerPageQuery, queryRef);
|
||||
const node = resolveNode(data.aliasedNode);
|
||||
const { dataUri } = useDocumentExport(node.kind, node.id, node.isAuthorized);
|
||||
|
||||
if (!node.isAuthorized) {
|
||||
return <DocumentLocked />;
|
||||
}
|
||||
|
||||
return <DocumentViewer title={node.title} dataUri={dataUri} downloadName={node.title} />;
|
||||
}
|
||||
@@ -18,25 +18,27 @@
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
// Opens a base64 data URI (returned by the export mutations) in a new browser
|
||||
// tab. Decodes to a Blob so the object URL carries the right MIME type and the
|
||||
// browser previews the PDF inline instead of navigating to a huge data: URL.
|
||||
export function openExportedFile(dataUri: string): void {
|
||||
const commaIndex = dataUri.indexOf(",");
|
||||
const base64 = commaIndex === -1 ? dataUri : dataUri.slice(commaIndex + 1);
|
||||
const mimeMatch = dataUri.match(/^data:([^;]+);/);
|
||||
const mimeType = mimeMatch?.[1] ?? "application/octet-stream";
|
||||
import { useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
import type { DocumentViewerPageQuery } from "./__generated__/DocumentViewerPageQuery.graphql";
|
||||
import { DocumentViewerPage, documentViewerPageQuery } from "./DocumentViewerPage";
|
||||
import { DocumentViewerPageSkeleton } from "./DocumentViewerPageSkeleton";
|
||||
|
||||
export default function DocumentViewerPageLoader() {
|
||||
const { alias } = useParams();
|
||||
const [queryRef, loadQuery] = useQueryLoader<DocumentViewerPageQuery>(documentViewerPageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (alias) {
|
||||
loadQuery({ alias });
|
||||
}
|
||||
}, [loadQuery, alias]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <DocumentViewerPageSkeleton />;
|
||||
}
|
||||
|
||||
const blob = new Blob([bytes], { type: mimeType });
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
window.open(objectUrl, "_blank", "noopener,noreferrer");
|
||||
|
||||
// Give the new tab time to claim the URL before releasing it.
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
|
||||
return <DocumentViewerPage queryRef={queryRef} />;
|
||||
}
|
||||
@@ -18,34 +18,35 @@
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { useCallback } from "react";
|
||||
import type { GraphQLTaggedNode, MutationParameters } from "relay-runtime";
|
||||
import { ButtonSkeleton } from "@probo/ui/src/v2/Button/ButtonSkeleton";
|
||||
import { HeadingSkeleton } from "@probo/ui/src/v2/typography/HeadingSkeleton";
|
||||
import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton";
|
||||
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||
|
||||
import { openExportedFile } from "./openExportedFile";
|
||||
import { documentViewer } from "./_components/variants";
|
||||
|
||||
// Shared "export then open" behavior for the document/file/report list items:
|
||||
// commit the resource's export mutation, open the returned base64 payload, and
|
||||
// let failures surface through the mutation notifier's toast. Each caller
|
||||
// supplies its typed mutation and a selector for the payload string.
|
||||
export function useExportAndOpen<T extends MutationParameters>(
|
||||
mutation: GraphQLTaggedNode,
|
||||
selectData: (response: T["response"]) => string,
|
||||
): readonly [(variables: T["variables"]) => void, boolean] {
|
||||
const [commit, isExporting] = useMutation<T>(mutation);
|
||||
export function DocumentViewerPageSkeleton() {
|
||||
const slots = documentViewer();
|
||||
|
||||
const open = useCallback(
|
||||
(variables: T["variables"]) => {
|
||||
commit({
|
||||
variables,
|
||||
onCompleted: response => openExportedFile(selectData(response)),
|
||||
}).catch(() => {
|
||||
// The mutation failure is already surfaced through a toast.
|
||||
});
|
||||
},
|
||||
[commit, selectData],
|
||||
return (
|
||||
<div className={slots.root()}>
|
||||
<HeaderBand flushBottomSpace>
|
||||
<div className={slots.header()}>
|
||||
<TextSkeleton size={1} className="w-20" />
|
||||
<HeadingSkeleton size={7} className="w-80" />
|
||||
<div className={slots.toolbar()}>
|
||||
<ButtonSkeleton size={2} />
|
||||
<div className={slots.actions()}>
|
||||
<ButtonSkeleton size={2} />
|
||||
<ButtonSkeleton size={2} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HeaderBand>
|
||||
<div className={slots.body()}>
|
||||
<div className={slots.stage()} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return [open, isExporting];
|
||||
}
|
||||
@@ -20,10 +20,7 @@
|
||||
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import { useExportAndOpen } from "../_lib/useExportAndOpen";
|
||||
|
||||
import type { AuditReportListItem_audit$key } from "./__generated__/AuditReportListItem_audit.graphql";
|
||||
import type { AuditReportListItemExportMutation } from "./__generated__/AuditReportListItemExportMutation.graphql";
|
||||
import { DocumentEntry } from "./DocumentEntry";
|
||||
|
||||
const auditReportListItemFragment = graphql`
|
||||
@@ -33,6 +30,7 @@ const auditReportListItemFragment = graphql`
|
||||
}
|
||||
reportFile {
|
||||
id
|
||||
alias
|
||||
fileName
|
||||
isUserAuthorized
|
||||
access {
|
||||
@@ -42,27 +40,15 @@ const auditReportListItemFragment = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const exportReportMutation = graphql`
|
||||
mutation AuditReportListItemExportMutation($input: ExportReportPDFInput!) {
|
||||
exportReportPDF(input: $input) {
|
||||
data
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface AuditReportListItemProps {
|
||||
auditKey: AuditReportListItem_audit$key;
|
||||
}
|
||||
|
||||
// A single audit report entry: the framework name, the report file name, and an
|
||||
// access action that opens the exported report when the viewer is authorized.
|
||||
// Renders nothing when the audit has no report file.
|
||||
// access action linking to the viewer when authorized. Renders nothing when the
|
||||
// audit has no report file.
|
||||
export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
|
||||
const audit = useFragment(auditReportListItemFragment, auditKey);
|
||||
const [openReport, isExporting] = useExportAndOpen<AuditReportListItemExportMutation>(
|
||||
exportReportMutation,
|
||||
response => response.exportReportPDF.data,
|
||||
);
|
||||
|
||||
const report = audit.reportFile;
|
||||
if (report == null) {
|
||||
@@ -75,8 +61,7 @@ export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
|
||||
meta={report.fileName}
|
||||
isAuthorized={report.isUserAuthorized}
|
||||
requested={report.access?.status === "REQUESTED"}
|
||||
onView={() => openReport({ input: { reportId: report.id } })}
|
||||
isViewing={isExporting}
|
||||
viewHref={`/documents/${encodeURIComponent(report.alias ?? report.id)}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { ArrowSquareOutIcon, ClockIcon, LockSimpleIcon } from "@phosphor-icons/react";
|
||||
import { ArrowRightIcon, ClockIcon, LockSimpleIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||
import { Link } from "@probo/ui/src/v2/Button/Link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DocumentAccessActionProps {
|
||||
@@ -27,30 +28,21 @@ interface DocumentAccessActionProps {
|
||||
isAuthorized: boolean;
|
||||
// Whether an access request is already pending for this document.
|
||||
requested: boolean;
|
||||
// Opens the document; only invoked when authorized.
|
||||
onView: () => void;
|
||||
// Whether the export/open is in flight.
|
||||
isViewing: boolean;
|
||||
// Route to the document viewer, used when authorized.
|
||||
viewHref: string;
|
||||
}
|
||||
|
||||
// Trailing access control for a document entry: "View" when authorized, a
|
||||
// pending label when access was requested, otherwise a (currently inert) "Get
|
||||
// Access" call to action.
|
||||
export function DocumentAccessAction({ isAuthorized, requested, onView, isViewing }: DocumentAccessActionProps) {
|
||||
// Trailing access control for a document entry: a "View" link to the viewer when
|
||||
// authorized, a pending label when access was requested, otherwise a (currently
|
||||
// inert) "Get Access" call to action.
|
||||
export function DocumentAccessAction({ isAuthorized, requested, viewHref }: DocumentAccessActionProps) {
|
||||
const { t } = useTranslation("documents");
|
||||
|
||||
if (isAuthorized) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
highContrast
|
||||
iconStart={<ArrowSquareOutIcon />}
|
||||
loading={isViewing}
|
||||
onClick={onView}
|
||||
>
|
||||
<Link to={viewHref} variant="ghost" color="neutral" highContrast iconStart={<ArrowRightIcon />}>
|
||||
{t("actions.view")}
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { DownloadSimpleIcon, FileIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { EmptyState } from "#/components/EmptyState/EmptyState";
|
||||
|
||||
interface DocumentDownloadFallbackProps {
|
||||
onDownload: () => void;
|
||||
}
|
||||
|
||||
// Body shown for file types that can't be previewed in the browser (office,
|
||||
// data, text): offer a download instead.
|
||||
export function DocumentDownloadFallback({ onDownload }: DocumentDownloadFallbackProps) {
|
||||
const { t } = useTranslation("documents");
|
||||
|
||||
return (
|
||||
<div className="grid h-full place-items-center bg-sand-3 p-8">
|
||||
<EmptyState
|
||||
icon={<FileIcon />}
|
||||
title={t("viewer.previewUnavailable")}
|
||||
description={t("viewer.downloadToView")}
|
||||
action={(
|
||||
<Button color="neutral" highContrast iconStart={<DownloadSimpleIcon />} onClick={onDownload}>
|
||||
{t("viewer.download")}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -33,16 +33,14 @@ interface DocumentEntryProps {
|
||||
isAuthorized: boolean;
|
||||
// Whether an access request is already pending for the entry.
|
||||
requested: boolean;
|
||||
// Opens the entry; only invoked when authorized.
|
||||
onView: () => void;
|
||||
// Whether the export/open is in flight.
|
||||
isViewing: boolean;
|
||||
// Route to the document viewer, used when authorized.
|
||||
viewHref: string;
|
||||
}
|
||||
|
||||
// Presentational row shared by the document / file / report list items: a title
|
||||
// with accent metadata and the trailing access action. The connection-item
|
||||
// wrappers own their fragments and supply these values.
|
||||
export function DocumentEntry({ title, meta, isAuthorized, requested, onView, isViewing }: DocumentEntryProps) {
|
||||
export function DocumentEntry({ title, meta, isAuthorized, requested, viewHref }: DocumentEntryProps) {
|
||||
const { root, content } = documentListItem();
|
||||
|
||||
return (
|
||||
@@ -55,12 +53,7 @@ export function DocumentEntry({ title, meta, isAuthorized, requested, onView, is
|
||||
{meta}
|
||||
</Text>
|
||||
</div>
|
||||
<DocumentAccessAction
|
||||
isAuthorized={isAuthorized}
|
||||
requested={requested}
|
||||
onView={onView}
|
||||
isViewing={isViewing}
|
||||
/>
|
||||
<DocumentAccessAction isAuthorized={isAuthorized} requested={requested} viewHref={viewHref} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,15 +21,13 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import { useExportAndOpen } from "../_lib/useExportAndOpen";
|
||||
|
||||
import type { DocumentListItem_document$key } from "./__generated__/DocumentListItem_document.graphql";
|
||||
import type { DocumentListItemExportMutation } from "./__generated__/DocumentListItemExportMutation.graphql";
|
||||
import { DocumentEntry } from "./DocumentEntry";
|
||||
|
||||
const documentListItemFragment = graphql`
|
||||
fragment DocumentListItem_document on Document @throwOnFieldError {
|
||||
id
|
||||
alias
|
||||
title
|
||||
documentType
|
||||
isUserAuthorized
|
||||
@@ -39,27 +37,15 @@ const documentListItemFragment = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const exportDocumentMutation = graphql`
|
||||
mutation DocumentListItemExportMutation($input: ExportDocumentPDFInput!) {
|
||||
exportDocumentPDF(input: $input) {
|
||||
data
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface DocumentListItemProps {
|
||||
documentKey: DocumentListItem_document$key;
|
||||
}
|
||||
|
||||
// A single Probo document entry: title, its document type, and an access action
|
||||
// that opens the exported PDF when the viewer is authorized.
|
||||
// linking to the viewer when authorized.
|
||||
export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
||||
const { t } = useTranslation("documents");
|
||||
const document = useFragment(documentListItemFragment, documentKey);
|
||||
const [openDocument, isExporting] = useExportAndOpen<DocumentListItemExportMutation>(
|
||||
exportDocumentMutation,
|
||||
response => response.exportDocumentPDF.data,
|
||||
);
|
||||
|
||||
return (
|
||||
<DocumentEntry
|
||||
@@ -67,8 +53,7 @@ export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
||||
meta={t(`types.${document.documentType}`)}
|
||||
isAuthorized={document.isUserAuthorized}
|
||||
requested={document.access?.status === "REQUESTED"}
|
||||
onView={() => openDocument({ input: { documentId: document.id } })}
|
||||
isViewing={isExporting}
|
||||
viewHref={`/documents/${encodeURIComponent(document.alias ?? document.id)}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { LockSimpleIcon } from "@phosphor-icons/react";
|
||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { EmptyState } from "#/components/EmptyState/EmptyState";
|
||||
|
||||
// Shown when the viewer resolves a document the visitor may not access. The
|
||||
// Get Access CTA is display-only until the auth flow lands (see the list rows).
|
||||
export function DocumentLocked() {
|
||||
const { t } = useTranslation("documents");
|
||||
|
||||
return (
|
||||
<div className="grid h-full place-items-center bg-sand-3 p-8">
|
||||
<EmptyState
|
||||
icon={<LockSimpleIcon />}
|
||||
title={t("viewer.locked.title")}
|
||||
description={t("viewer.locked.description")}
|
||||
action={(
|
||||
<Button color="neutral" highContrast iconStart={<LockSimpleIcon />}>
|
||||
{t("actions.getAccess")}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { Toast } from "@base-ui/react/toast";
|
||||
import {
|
||||
CaretLeftIcon,
|
||||
CaretRightIcon,
|
||||
DownloadSimpleIcon,
|
||||
MagnifyingGlassMinusIcon,
|
||||
MagnifyingGlassPlusIcon,
|
||||
ShareNetworkIcon,
|
||||
SpinnerGapIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Button } from "@probo/ui/src/v2/Button/Button";
|
||||
import { Link } from "@probo/ui/src/v2/Button/Link";
|
||||
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
|
||||
import { Separator } from "@probo/ui/src/v2/Separator/Separator";
|
||||
import { Heading } from "@probo/ui/src/v2/typography/Heading";
|
||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||
|
||||
import { dataUriMimeType, downloadDataUri } from "../_lib/dataUri";
|
||||
|
||||
import { DocumentDownloadFallback } from "./DocumentDownloadFallback";
|
||||
import type { PdfPreviewHandle } from "./PdfPreview";
|
||||
import { PdfPreview } from "./PdfPreview";
|
||||
import { documentViewer } from "./variants";
|
||||
|
||||
const MIN_SCALE = 0.5;
|
||||
const MAX_SCALE = 3;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
interface DocumentViewerProps {
|
||||
// The document/file/report display name.
|
||||
title: string;
|
||||
// The exported base64 data URI, or null while it is still loading.
|
||||
dataUri: string | null;
|
||||
// File name used when downloading.
|
||||
downloadName: string;
|
||||
}
|
||||
|
||||
// Full-page document viewer: a header band with the title and a toolbar
|
||||
// (page navigation + zoom for PDFs, share, download) above the scrollable body.
|
||||
// PDFs render with react-pdf, images inline, and anything else offers a
|
||||
// download.
|
||||
export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerProps) {
|
||||
const { t } = useTranslation("documents");
|
||||
const toast = Toast.useToastManager();
|
||||
|
||||
const pdfRef = useRef<PdfPreviewHandle>(null);
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [scale, setScale] = useState(1);
|
||||
|
||||
const mimeType = dataUri ? dataUriMimeType(dataUri) : null;
|
||||
const isPdf = mimeType === "application/pdf";
|
||||
const isImage = mimeType?.startsWith("image/") ?? false;
|
||||
|
||||
const movePage = (direction: 1 | -1) => {
|
||||
const next = clamp(currentPage + direction, 1, numPages);
|
||||
pdfRef.current?.scrollToPage(next);
|
||||
setCurrentPage(next);
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
navigator.clipboard.writeText(window.location.href).then(
|
||||
() => toast.add({ title: t("viewer.linkCopied"), type: "success" }),
|
||||
() => {},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
if (dataUri) {
|
||||
downloadDataUri(dataUri, downloadName);
|
||||
}
|
||||
};
|
||||
|
||||
const slots = documentViewer();
|
||||
|
||||
return (
|
||||
<div className={slots.root()}>
|
||||
<HeaderBand flushBottomSpace>
|
||||
<div className={slots.header()}>
|
||||
<Link to="/documents" variant="ghost" color="neutral" size={1} iconStart={<CaretLeftIcon />} className={slots.back()}>
|
||||
{t("viewer.back")}
|
||||
</Link>
|
||||
<Heading level={1} size={7} weight="medium" highContrast className="truncate">
|
||||
{title}
|
||||
</Heading>
|
||||
<div className={slots.toolbar()}>
|
||||
<div className={slots.toolbarStart()}>
|
||||
{isPdf && (
|
||||
<>
|
||||
<div className={slots.controls()}>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
aria-label={t("viewer.previousPage")}
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => movePage(-1)}
|
||||
>
|
||||
<CaretLeftIcon />
|
||||
</IconButton>
|
||||
<Text size={2} color="neutral">
|
||||
{t("viewer.pageOf", { current: currentPage, total: numPages })}
|
||||
</Text>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
aria-label={t("viewer.nextPage")}
|
||||
disabled={currentPage >= numPages}
|
||||
onClick={() => movePage(1)}
|
||||
>
|
||||
<CaretRightIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
<Separator orientation="vertical" className={slots.separator()} />
|
||||
<div className={slots.controls()}>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
aria-label={t("viewer.zoomOut")}
|
||||
onClick={() => setScale(value => clamp(value * 0.8, MIN_SCALE, MAX_SCALE))}
|
||||
>
|
||||
<MagnifyingGlassMinusIcon />
|
||||
</IconButton>
|
||||
<Text size={2} color="neutral">
|
||||
{`${Math.round(scale * 100)}%`}
|
||||
</Text>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
aria-label={t("viewer.zoomIn")}
|
||||
onClick={() => setScale(value => clamp(value * 1.25, MIN_SCALE, MAX_SCALE))}
|
||||
>
|
||||
<MagnifyingGlassPlusIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className={slots.actions()}>
|
||||
<Button variant="ghost" color="neutral" iconStart={<ShareNetworkIcon />} onClick={handleShare}>
|
||||
{t("viewer.share")}
|
||||
</Button>
|
||||
<Separator orientation="vertical" className={slots.separator()} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
iconStart={<DownloadSimpleIcon />}
|
||||
disabled={dataUri == null}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
{t("viewer.download")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HeaderBand>
|
||||
|
||||
<div className={slots.body()}>
|
||||
{dataUri == null
|
||||
? (
|
||||
<div className={slots.stage()}>
|
||||
<SpinnerGapIcon className={slots.spinner()} />
|
||||
</div>
|
||||
)
|
||||
: isPdf
|
||||
? (
|
||||
<PdfPreview
|
||||
ref={pdfRef}
|
||||
file={dataUri}
|
||||
scale={scale}
|
||||
onNumPages={setNumPages}
|
||||
onVisiblePageChange={setCurrentPage}
|
||||
/>
|
||||
)
|
||||
: isImage
|
||||
? (
|
||||
<div className={slots.imageStage()}>
|
||||
<img src={dataUri} alt={title} className={slots.image()} />
|
||||
</div>
|
||||
)
|
||||
: <DocumentDownloadFallback onDownload={handleDownload} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import "react-pdf/dist/Page/AnnotationLayer.css";
|
||||
import "react-pdf/dist/Page/TextLayer.css";
|
||||
|
||||
import { SpinnerGapIcon } from "@phosphor-icons/react";
|
||||
import { times } from "@probo/helpers";
|
||||
// Vite `?url` import resolves to the bundled worker URL (string); the import-x
|
||||
// resolver doesn't understand the suffix, and `vite/client` types cover it.
|
||||
// eslint-disable-next-line import-x/default
|
||||
import workerSrc from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
import type { ComponentRef, Ref } from "react";
|
||||
import { useImperativeHandle, useRef, useState } from "react";
|
||||
import { Document, Page, pdfjs } from "react-pdf";
|
||||
|
||||
import { pdfPreview } from "./variants";
|
||||
|
||||
// Bundle the pdf.js worker with the app (via Vite's `?url`) instead of loading
|
||||
// it from a CDN, so the viewer works under a strict trust-center CSP.
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
|
||||
|
||||
export interface PdfPreviewHandle {
|
||||
scrollToPage: (page: number) => void;
|
||||
}
|
||||
|
||||
interface PdfPreviewProps {
|
||||
// Base64 data URI of the PDF to render.
|
||||
file: string;
|
||||
// Zoom factor applied to every page.
|
||||
scale: number;
|
||||
// Handle exposing imperative page navigation to the toolbar.
|
||||
ref?: Ref<PdfPreviewHandle>;
|
||||
// Reports the page count once the document has loaded.
|
||||
onNumPages: (numPages: number) => void;
|
||||
// Reports the page currently centered in the viewport.
|
||||
onVisiblePageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
// Scrollable react-pdf renderer, controlled by the viewer toolbar: it takes the
|
||||
// zoom `scale`, reports the page count and the visible page, and exposes an
|
||||
// imperative `scrollToPage` for the page-navigation buttons.
|
||||
export function PdfPreview({ file, scale, ref, onNumPages, onVisiblePageChange }: PdfPreviewProps) {
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const documentRef = useRef<ComponentRef<typeof Document>>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToPage(page) {
|
||||
const node = documentRef.current?.pages.current[page - 1];
|
||||
node?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
},
|
||||
}), []);
|
||||
|
||||
const resolveVisiblePage = () => {
|
||||
const wrapper = wrapperRef.current;
|
||||
const pages = documentRef.current?.pages.current;
|
||||
if (!wrapper || !pages?.length) {
|
||||
return;
|
||||
}
|
||||
const middle = wrapper.getBoundingClientRect().top + wrapper.clientHeight / 2;
|
||||
for (let index = 0; index < pages.length; index += 1) {
|
||||
const rect = pages[index].getBoundingClientRect();
|
||||
if (rect.top <= middle && rect.bottom >= middle) {
|
||||
onVisiblePageChange(index + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const slots = pdfPreview();
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} onScrollEnd={resolveVisiblePage} className={slots.viewport()}>
|
||||
<Document
|
||||
ref={documentRef}
|
||||
file={file}
|
||||
className={slots.list()}
|
||||
loading={(
|
||||
<div className={slots.loading()}>
|
||||
<SpinnerGapIcon className={slots.spinner()} />
|
||||
</div>
|
||||
)}
|
||||
onLoadSuccess={(document) => {
|
||||
setNumPages(document.numPages);
|
||||
onNumPages(document.numPages);
|
||||
onVisiblePageChange(1);
|
||||
}}
|
||||
>
|
||||
{times(numPages, index => (
|
||||
<Page key={index} pageNumber={index + 1} scale={scale} className={slots.page()} />
|
||||
))}
|
||||
</Document>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,15 +20,13 @@
|
||||
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import { useExportAndOpen } from "../_lib/useExportAndOpen";
|
||||
|
||||
import type { TrustCenterFileListItem_file$key } from "./__generated__/TrustCenterFileListItem_file.graphql";
|
||||
import type { TrustCenterFileListItemExportMutation } from "./__generated__/TrustCenterFileListItemExportMutation.graphql";
|
||||
import { DocumentEntry } from "./DocumentEntry";
|
||||
|
||||
const trustCenterFileListItemFragment = graphql`
|
||||
fragment TrustCenterFileListItem_file on TrustCenterFile @throwOnFieldError {
|
||||
id
|
||||
alias
|
||||
name
|
||||
category
|
||||
isUserAuthorized
|
||||
@@ -38,26 +36,14 @@ const trustCenterFileListItemFragment = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const exportTrustCenterFileMutation = graphql`
|
||||
mutation TrustCenterFileListItemExportMutation($input: ExportTrustCenterFileInput!) {
|
||||
exportTrustCenterFile(input: $input) {
|
||||
data
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface TrustCenterFileListItemProps {
|
||||
fileKey: TrustCenterFileListItem_file$key;
|
||||
}
|
||||
|
||||
// A single uploaded trust-center file entry: name, its category, and an access
|
||||
// action that opens the exported file when the viewer is authorized.
|
||||
// action linking to the viewer when authorized.
|
||||
export function TrustCenterFileListItem({ fileKey }: TrustCenterFileListItemProps) {
|
||||
const file = useFragment(trustCenterFileListItemFragment, fileKey);
|
||||
const [openFile, isExporting] = useExportAndOpen<TrustCenterFileListItemExportMutation>(
|
||||
exportTrustCenterFileMutation,
|
||||
response => response.exportTrustCenterFile.data,
|
||||
);
|
||||
|
||||
return (
|
||||
<DocumentEntry
|
||||
@@ -65,8 +51,7 @@ export function TrustCenterFileListItem({ fileKey }: TrustCenterFileListItemProp
|
||||
meta={file.category}
|
||||
isAuthorized={file.isUserAuthorized}
|
||||
requested={file.access?.status === "REQUESTED"}
|
||||
onView={() => openFile({ input: { trustCenterFileId: file.id } })}
|
||||
isViewing={isExporting}
|
||||
viewHref={`/documents/${encodeURIComponent(file.alias ?? file.id)}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,3 +38,34 @@ export const documentListItem = tv({
|
||||
content: "flex min-w-0 flex-1 flex-col gap-0.5",
|
||||
},
|
||||
});
|
||||
|
||||
// PDF preview: a scrollable grey stage holding the stacked, centered pages.
|
||||
export const pdfPreview = tv({
|
||||
slots: {
|
||||
viewport: "h-full overflow-auto bg-sand-3",
|
||||
list: "flex flex-col items-center gap-4 py-8",
|
||||
loading: "grid place-items-center py-16 text-sand-a10",
|
||||
spinner: "size-6 animate-spin",
|
||||
page: "shadow-3",
|
||||
},
|
||||
});
|
||||
|
||||
// Full-page viewer: fixed header band with the title and toolbar above a
|
||||
// scrollable grey stage for the PDF / image / download-fallback body.
|
||||
export const documentViewer = tv({
|
||||
slots: {
|
||||
root: "flex h-full flex-col",
|
||||
header: "flex w-full flex-col gap-3",
|
||||
back: "-ml-2 self-start",
|
||||
toolbar: "flex min-h-16 items-center justify-between gap-4",
|
||||
toolbarStart: "flex items-center gap-2",
|
||||
controls: "flex items-center gap-1",
|
||||
actions: "flex items-center gap-2",
|
||||
separator: "h-6",
|
||||
body: "min-h-0 flex-1",
|
||||
stage: "grid h-full place-items-center bg-sand-3",
|
||||
imageStage: "grid h-full place-items-center overflow-auto bg-sand-3 p-8",
|
||||
image: "max-h-full max-w-full object-contain shadow-3",
|
||||
spinner: "size-6 animate-spin text-sand-a10",
|
||||
},
|
||||
});
|
||||
|
||||
35
apps/compliance-portal/src/pages/documents/_lib/dataUri.ts
Normal file
35
apps/compliance-portal/src/pages/documents/_lib/dataUri.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
// The MIME type declared in a `data:<mime>;base64,...` URI (the shape the export
|
||||
// mutations return), or null when the string isn't a recognizable data URI.
|
||||
export function dataUriMimeType(dataUri: string): string | null {
|
||||
return dataUri.match(/^data:([^;,]+)[;,]/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
// Triggers a browser download of a data URI under the given file name.
|
||||
export function downloadDataUri(dataUri: string, filename: string): void {
|
||||
const link = document.createElement("a");
|
||||
link.href = dataUri;
|
||||
link.download = filename;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
|
||||
import { useMutation } from "#/lib/relay/useMutation";
|
||||
|
||||
import type { useDocumentExportDocumentMutation } from "./__generated__/useDocumentExportDocumentMutation.graphql";
|
||||
import type { useDocumentExportFileMutation } from "./__generated__/useDocumentExportFileMutation.graphql";
|
||||
import type { useDocumentExportReportMutation } from "./__generated__/useDocumentExportReportMutation.graphql";
|
||||
|
||||
export type DocumentKind = "Document" | "TrustCenterFile" | "AuditReport";
|
||||
|
||||
const exportDocumentMutation = graphql`
|
||||
mutation useDocumentExportDocumentMutation($input: ExportDocumentPDFInput!) {
|
||||
exportDocumentPDF(input: $input) {
|
||||
data
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const exportFileMutation = graphql`
|
||||
mutation useDocumentExportFileMutation($input: ExportTrustCenterFileInput!) {
|
||||
exportTrustCenterFile(input: $input) {
|
||||
data
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const exportReportMutation = graphql`
|
||||
mutation useDocumentExportReportMutation($input: ExportReportPDFInput!) {
|
||||
exportReportPDF(input: $input) {
|
||||
data
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface DocumentExportState {
|
||||
// The exported base64 data URI, or null while it is still loading.
|
||||
dataUri: string | null;
|
||||
isExporting: boolean;
|
||||
}
|
||||
|
||||
// Exports the aliased node's (watermarked) bytes for the viewer. Fires the
|
||||
// export mutation matching the node kind once `enabled`, and resets when the
|
||||
// target id changes. Failures surface through the mutation notifier's toast.
|
||||
export function useDocumentExport(kind: DocumentKind, id: string, enabled: boolean): DocumentExportState {
|
||||
const [exportDocument, isExportingDocument] = useMutation<useDocumentExportDocumentMutation>(exportDocumentMutation);
|
||||
const [exportFile, isExportingFile] = useMutation<useDocumentExportFileMutation>(exportFileMutation);
|
||||
const [exportReport, isExportingReport] = useMutation<useDocumentExportReportMutation>(exportReportMutation);
|
||||
|
||||
const [dataUri, setDataUri] = useState<string | null>(null);
|
||||
|
||||
// Drop the previous document's bytes as soon as the target changes so a stale
|
||||
// preview is never shown for the new document.
|
||||
const [loadedId, setLoadedId] = useState(id);
|
||||
if (loadedId !== id) {
|
||||
setLoadedId(id);
|
||||
setDataUri(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || dataUri) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case "Document":
|
||||
exportDocument({
|
||||
variables: { input: { documentId: id } },
|
||||
onCompleted: response => setDataUri(response.exportDocumentPDF.data),
|
||||
}).catch(() => {});
|
||||
break;
|
||||
case "TrustCenterFile":
|
||||
exportFile({
|
||||
variables: { input: { trustCenterFileId: id } },
|
||||
onCompleted: response => setDataUri(response.exportTrustCenterFile.data),
|
||||
}).catch(() => {});
|
||||
break;
|
||||
case "AuditReport":
|
||||
exportReport({
|
||||
variables: { input: { reportId: id } },
|
||||
onCompleted: response => setDataUri(response.exportReportPDF.data),
|
||||
}).catch(() => {});
|
||||
break;
|
||||
}
|
||||
}, [enabled, dataUri, kind, id, exportDocument, exportFile, exportReport]);
|
||||
|
||||
return { dataUri, isExporting: isExportingDocument || isExportingFile || isExportingReport };
|
||||
}
|
||||
@@ -30,5 +30,22 @@
|
||||
"description": "This trust center has not published any documents yet.",
|
||||
"filteredTitle": "No documents match this filter",
|
||||
"filteredDescription": "Try a different tab to see available documents."
|
||||
},
|
||||
"viewer": {
|
||||
"back": "Documents",
|
||||
"pageOf": "Page {{current}} of {{total}}",
|
||||
"previousPage": "Previous page",
|
||||
"nextPage": "Next page",
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"share": "Share document",
|
||||
"linkCopied": "Link copied to clipboard",
|
||||
"download": "Download",
|
||||
"previewUnavailable": "Preview unavailable",
|
||||
"downloadToView": "This file type can't be previewed here. Download it to view.",
|
||||
"locked": {
|
||||
"title": "Access required",
|
||||
"description": "You don't have access to this document yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,5 +30,22 @@
|
||||
"description": "Ce trust center n'a pas encore publié de documents.",
|
||||
"filteredTitle": "Aucun document ne correspond à ce filtre",
|
||||
"filteredDescription": "Essayez un autre onglet pour voir les documents disponibles."
|
||||
},
|
||||
"viewer": {
|
||||
"back": "Documents",
|
||||
"pageOf": "Page {{current}} sur {{total}}",
|
||||
"previousPage": "Page précédente",
|
||||
"nextPage": "Page suivante",
|
||||
"zoomIn": "Zoom avant",
|
||||
"zoomOut": "Zoom arrière",
|
||||
"share": "Partager le document",
|
||||
"linkCopied": "Lien copié dans le presse-papiers",
|
||||
"download": "Télécharger",
|
||||
"previewUnavailable": "Aperçu indisponible",
|
||||
"downloadToView": "Ce type de fichier ne peut pas être prévisualisé ici. Téléchargez-le pour le consulter.",
|
||||
"locked": {
|
||||
"title": "Accès requis",
|
||||
"description": "Vous n'avez pas encore accès à ce document."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import { lazy } from "@probo/react-lazy";
|
||||
import type { AppRoute } from "@probo/routes";
|
||||
|
||||
import { DocumentsPageSkeleton } from "./DocumentsPageSkeleton";
|
||||
import { DocumentViewerPageSkeleton } from "./DocumentViewerPageSkeleton";
|
||||
|
||||
export const documentRoutes = [
|
||||
{
|
||||
@@ -29,4 +30,9 @@ export const documentRoutes = [
|
||||
Fallback: DocumentsPageSkeleton,
|
||||
Component: lazy(() => import("./DocumentsPageLoader")),
|
||||
},
|
||||
{
|
||||
path: "documents/:alias",
|
||||
Fallback: DocumentViewerPageSkeleton,
|
||||
Component: lazy(() => import("./DocumentViewerPageLoader")),
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -36,9 +36,11 @@
|
||||
"@probo/routes": "1.0.0",
|
||||
"@probo/ui": "1.0.0",
|
||||
"i18next": "^26.3.4",
|
||||
"pdfjs-dist": "^5.4.296",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-pdf": "^10.3.0",
|
||||
"react-relay": "^21.0.1",
|
||||
"react-router": "^8.1.0",
|
||||
"relay-runtime": "^21.0.1"
|
||||
|
||||
48
packages/ui/src/v2/Separator/Separator.stories.tsx
Normal file
48
packages/ui/src/v2/Separator/Separator.stories.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { Text } from "../typography/Text";
|
||||
|
||||
import { Separator } from "./Separator";
|
||||
|
||||
export default {
|
||||
title: "v2/Separator",
|
||||
component: Separator,
|
||||
};
|
||||
|
||||
export function Horizontal() {
|
||||
return (
|
||||
<div className="flex w-64 flex-col gap-3">
|
||||
<Text size={2}>Above</Text>
|
||||
<Separator />
|
||||
<Text size={2}>Below</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Vertical() {
|
||||
return (
|
||||
<div className="flex h-8 items-center gap-3">
|
||||
<Text size={2}>Left</Text>
|
||||
<Separator orientation="vertical" />
|
||||
<Text size={2}>Right</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
packages/ui/src/v2/Separator/Separator.tsx
Normal file
42
packages/ui/src/v2/Separator/Separator.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { Separator as BaseSeparator } from "@base-ui/react/separator";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { separator } from "./variants";
|
||||
|
||||
export type SeparatorProps = Omit<ComponentProps<typeof BaseSeparator>, "className"> & {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// A hairline rule (Radix "Separator"). Defaults to horizontal; pass
|
||||
// `orientation="vertical"` for inline dividers (e.g. toolbar groups).
|
||||
export function Separator(props: SeparatorProps) {
|
||||
const { orientation = "horizontal", className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<BaseSeparator
|
||||
orientation={orientation}
|
||||
className={separator({ orientation, className })}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
36
packages/ui/src/v2/Separator/variants.ts
Normal file
36
packages/ui/src/v2/Separator/variants.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
// of this software and associated documentation files (the "Software"), to deal
|
||||
// in the Software without restriction, including without limitation the rights
|
||||
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
// copies of the Software, and to permit persons to whom the Software is
|
||||
// furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in
|
||||
// all copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { tv } from "tailwind-variants/lite";
|
||||
|
||||
// Separator (Radix "Separator" over Base UI's Separator). A hairline rule that
|
||||
// stretches along the cross axis of its flex parent when vertical.
|
||||
export const separator = tv({
|
||||
base: "shrink-0 bg-sand-a3",
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal: "h-px w-full",
|
||||
vertical: "w-px self-stretch",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user