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/routes": "1.0.0",
|
||||||
"@probo/ui": "1.0.0",
|
"@probo/ui": "1.0.0",
|
||||||
"i18next": "^26.3.4",
|
"i18next": "^26.3.4",
|
||||||
|
"pdfjs-dist": "^5.4.296",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
|
"react-pdf": "^10.3.0",
|
||||||
"react-relay": "^21.0.1",
|
"react-relay": "^21.0.1",
|
||||||
"react-router": "^8.1.0",
|
"react-router": "^8.1.0",
|
||||||
"relay-runtime": "^21.0.1"
|
"relay-runtime": "^21.0.1"
|
||||||
|
|||||||
@@ -43,9 +43,12 @@ export function MainLayout({ queryRef }: MainLayoutProps) {
|
|||||||
const data = usePreloadedQuery<MainLayoutQuery>(mainLayoutQuery, queryRef);
|
const data = usePreloadedQuery<MainLayoutQuery>(mainLayoutQuery, queryRef);
|
||||||
|
|
||||||
return (
|
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} />
|
<TopBar queryKey={data} />
|
||||||
<div className="flex-1">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
<PoweredBy label={t("footer.poweredBy")} />
|
<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
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
// Opens a base64 data URI (returned by the export mutations) in a new browser
|
import { useEffect } from "react";
|
||||||
// tab. Decodes to a Blob so the object URL carries the right MIME type and the
|
import { useQueryLoader } from "react-relay";
|
||||||
// browser previews the PDF inline instead of navigating to a huge data: URL.
|
import { useParams } from "react-router";
|
||||||
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";
|
|
||||||
|
|
||||||
const binary = atob(base64);
|
import type { DocumentViewerPageQuery } from "./__generated__/DocumentViewerPageQuery.graphql";
|
||||||
const bytes = new Uint8Array(binary.length);
|
import { DocumentViewerPage, documentViewerPageQuery } from "./DocumentViewerPage";
|
||||||
for (let i = 0; i < binary.length; i += 1) {
|
import { DocumentViewerPageSkeleton } from "./DocumentViewerPageSkeleton";
|
||||||
bytes[i] = binary.charCodeAt(i);
|
|
||||||
|
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 });
|
return <DocumentViewerPage queryRef={queryRef} />;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
@@ -18,34 +18,35 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { ButtonSkeleton } from "@probo/ui/src/v2/Button/ButtonSkeleton";
|
||||||
import type { GraphQLTaggedNode, MutationParameters } from "relay-runtime";
|
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:
|
export function DocumentViewerPageSkeleton() {
|
||||||
// commit the resource's export mutation, open the returned base64 payload, and
|
const slots = documentViewer();
|
||||||
// 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);
|
|
||||||
|
|
||||||
const open = useCallback(
|
return (
|
||||||
(variables: T["variables"]) => {
|
<div className={slots.root()}>
|
||||||
commit({
|
<HeaderBand flushBottomSpace>
|
||||||
variables,
|
<div className={slots.header()}>
|
||||||
onCompleted: response => openExportedFile(selectData(response)),
|
<TextSkeleton size={1} className="w-20" />
|
||||||
}).catch(() => {
|
<HeadingSkeleton size={7} className="w-80" />
|
||||||
// The mutation failure is already surfaced through a toast.
|
<div className={slots.toolbar()}>
|
||||||
});
|
<ButtonSkeleton size={2} />
|
||||||
},
|
<div className={slots.actions()}>
|
||||||
[commit, selectData],
|
<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 { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
import { useExportAndOpen } from "../_lib/useExportAndOpen";
|
|
||||||
|
|
||||||
import type { AuditReportListItem_audit$key } from "./__generated__/AuditReportListItem_audit.graphql";
|
import type { AuditReportListItem_audit$key } from "./__generated__/AuditReportListItem_audit.graphql";
|
||||||
import type { AuditReportListItemExportMutation } from "./__generated__/AuditReportListItemExportMutation.graphql";
|
|
||||||
import { DocumentEntry } from "./DocumentEntry";
|
import { DocumentEntry } from "./DocumentEntry";
|
||||||
|
|
||||||
const auditReportListItemFragment = graphql`
|
const auditReportListItemFragment = graphql`
|
||||||
@@ -33,6 +30,7 @@ const auditReportListItemFragment = graphql`
|
|||||||
}
|
}
|
||||||
reportFile {
|
reportFile {
|
||||||
id
|
id
|
||||||
|
alias
|
||||||
fileName
|
fileName
|
||||||
isUserAuthorized
|
isUserAuthorized
|
||||||
access {
|
access {
|
||||||
@@ -42,27 +40,15 @@ const auditReportListItemFragment = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const exportReportMutation = graphql`
|
|
||||||
mutation AuditReportListItemExportMutation($input: ExportReportPDFInput!) {
|
|
||||||
exportReportPDF(input: $input) {
|
|
||||||
data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
interface AuditReportListItemProps {
|
interface AuditReportListItemProps {
|
||||||
auditKey: AuditReportListItem_audit$key;
|
auditKey: AuditReportListItem_audit$key;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A single audit report entry: the framework name, the report file name, and an
|
// 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.
|
// access action linking to the viewer when authorized. Renders nothing when the
|
||||||
// Renders nothing when the audit has no report file.
|
// audit has no report file.
|
||||||
export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
|
export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
|
||||||
const audit = useFragment(auditReportListItemFragment, auditKey);
|
const audit = useFragment(auditReportListItemFragment, auditKey);
|
||||||
const [openReport, isExporting] = useExportAndOpen<AuditReportListItemExportMutation>(
|
|
||||||
exportReportMutation,
|
|
||||||
response => response.exportReportPDF.data,
|
|
||||||
);
|
|
||||||
|
|
||||||
const report = audit.reportFile;
|
const report = audit.reportFile;
|
||||||
if (report == null) {
|
if (report == null) {
|
||||||
@@ -75,8 +61,7 @@ export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
|
|||||||
meta={report.fileName}
|
meta={report.fileName}
|
||||||
isAuthorized={report.isUserAuthorized}
|
isAuthorized={report.isUserAuthorized}
|
||||||
requested={report.access?.status === "REQUESTED"}
|
requested={report.access?.status === "REQUESTED"}
|
||||||
onView={() => openReport({ input: { reportId: report.id } })}
|
viewHref={`/documents/${encodeURIComponent(report.alias ?? report.id)}`}
|
||||||
isViewing={isExporting}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// 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 { Button } from "@probo/ui/src/v2/Button/Button";
|
||||||
|
import { Link } from "@probo/ui/src/v2/Button/Link";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface DocumentAccessActionProps {
|
interface DocumentAccessActionProps {
|
||||||
@@ -27,30 +28,21 @@ interface DocumentAccessActionProps {
|
|||||||
isAuthorized: boolean;
|
isAuthorized: boolean;
|
||||||
// Whether an access request is already pending for this document.
|
// Whether an access request is already pending for this document.
|
||||||
requested: boolean;
|
requested: boolean;
|
||||||
// Opens the document; only invoked when authorized.
|
// Route to the document viewer, used when authorized.
|
||||||
onView: () => void;
|
viewHref: string;
|
||||||
// Whether the export/open is in flight.
|
|
||||||
isViewing: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trailing access control for a document entry: "View" when authorized, a
|
// Trailing access control for a document entry: a "View" link to the viewer when
|
||||||
// pending label when access was requested, otherwise a (currently inert) "Get
|
// authorized, a pending label when access was requested, otherwise a (currently
|
||||||
// Access" call to action.
|
// inert) "Get Access" call to action.
|
||||||
export function DocumentAccessAction({ isAuthorized, requested, onView, isViewing }: DocumentAccessActionProps) {
|
export function DocumentAccessAction({ isAuthorized, requested, viewHref }: DocumentAccessActionProps) {
|
||||||
const { t } = useTranslation("documents");
|
const { t } = useTranslation("documents");
|
||||||
|
|
||||||
if (isAuthorized) {
|
if (isAuthorized) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Link to={viewHref} variant="ghost" color="neutral" highContrast iconStart={<ArrowRightIcon />}>
|
||||||
variant="ghost"
|
|
||||||
color="neutral"
|
|
||||||
highContrast
|
|
||||||
iconStart={<ArrowSquareOutIcon />}
|
|
||||||
loading={isViewing}
|
|
||||||
onClick={onView}
|
|
||||||
>
|
|
||||||
{t("actions.view")}
|
{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;
|
isAuthorized: boolean;
|
||||||
// Whether an access request is already pending for the entry.
|
// Whether an access request is already pending for the entry.
|
||||||
requested: boolean;
|
requested: boolean;
|
||||||
// Opens the entry; only invoked when authorized.
|
// Route to the document viewer, used when authorized.
|
||||||
onView: () => void;
|
viewHref: string;
|
||||||
// Whether the export/open is in flight.
|
|
||||||
isViewing: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Presentational row shared by the document / file / report list items: a title
|
// Presentational row shared by the document / file / report list items: a title
|
||||||
// with accent metadata and the trailing access action. The connection-item
|
// with accent metadata and the trailing access action. The connection-item
|
||||||
// wrappers own their fragments and supply these values.
|
// 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();
|
const { root, content } = documentListItem();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -55,12 +53,7 @@ export function DocumentEntry({ title, meta, isAuthorized, requested, onView, is
|
|||||||
{meta}
|
{meta}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<DocumentAccessAction
|
<DocumentAccessAction isAuthorized={isAuthorized} requested={requested} viewHref={viewHref} />
|
||||||
isAuthorized={isAuthorized}
|
|
||||||
requested={requested}
|
|
||||||
onView={onView}
|
|
||||||
isViewing={isViewing}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,15 +21,13 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
import { useExportAndOpen } from "../_lib/useExportAndOpen";
|
|
||||||
|
|
||||||
import type { DocumentListItem_document$key } from "./__generated__/DocumentListItem_document.graphql";
|
import type { DocumentListItem_document$key } from "./__generated__/DocumentListItem_document.graphql";
|
||||||
import type { DocumentListItemExportMutation } from "./__generated__/DocumentListItemExportMutation.graphql";
|
|
||||||
import { DocumentEntry } from "./DocumentEntry";
|
import { DocumentEntry } from "./DocumentEntry";
|
||||||
|
|
||||||
const documentListItemFragment = graphql`
|
const documentListItemFragment = graphql`
|
||||||
fragment DocumentListItem_document on Document @throwOnFieldError {
|
fragment DocumentListItem_document on Document @throwOnFieldError {
|
||||||
id
|
id
|
||||||
|
alias
|
||||||
title
|
title
|
||||||
documentType
|
documentType
|
||||||
isUserAuthorized
|
isUserAuthorized
|
||||||
@@ -39,27 +37,15 @@ const documentListItemFragment = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const exportDocumentMutation = graphql`
|
|
||||||
mutation DocumentListItemExportMutation($input: ExportDocumentPDFInput!) {
|
|
||||||
exportDocumentPDF(input: $input) {
|
|
||||||
data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
interface DocumentListItemProps {
|
interface DocumentListItemProps {
|
||||||
documentKey: DocumentListItem_document$key;
|
documentKey: DocumentListItem_document$key;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A single Probo document entry: title, its document type, and an access action
|
// 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) {
|
export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
||||||
const { t } = useTranslation("documents");
|
const { t } = useTranslation("documents");
|
||||||
const document = useFragment(documentListItemFragment, documentKey);
|
const document = useFragment(documentListItemFragment, documentKey);
|
||||||
const [openDocument, isExporting] = useExportAndOpen<DocumentListItemExportMutation>(
|
|
||||||
exportDocumentMutation,
|
|
||||||
response => response.exportDocumentPDF.data,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentEntry
|
<DocumentEntry
|
||||||
@@ -67,8 +53,7 @@ export function DocumentListItem({ documentKey }: DocumentListItemProps) {
|
|||||||
meta={t(`types.${document.documentType}`)}
|
meta={t(`types.${document.documentType}`)}
|
||||||
isAuthorized={document.isUserAuthorized}
|
isAuthorized={document.isUserAuthorized}
|
||||||
requested={document.access?.status === "REQUESTED"}
|
requested={document.access?.status === "REQUESTED"}
|
||||||
onView={() => openDocument({ input: { documentId: document.id } })}
|
viewHref={`/documents/${encodeURIComponent(document.alias ?? document.id)}`}
|
||||||
isViewing={isExporting}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { graphql, useFragment } from "react-relay";
|
||||||
|
|
||||||
import { useExportAndOpen } from "../_lib/useExportAndOpen";
|
|
||||||
|
|
||||||
import type { TrustCenterFileListItem_file$key } from "./__generated__/TrustCenterFileListItem_file.graphql";
|
import type { TrustCenterFileListItem_file$key } from "./__generated__/TrustCenterFileListItem_file.graphql";
|
||||||
import type { TrustCenterFileListItemExportMutation } from "./__generated__/TrustCenterFileListItemExportMutation.graphql";
|
|
||||||
import { DocumentEntry } from "./DocumentEntry";
|
import { DocumentEntry } from "./DocumentEntry";
|
||||||
|
|
||||||
const trustCenterFileListItemFragment = graphql`
|
const trustCenterFileListItemFragment = graphql`
|
||||||
fragment TrustCenterFileListItem_file on TrustCenterFile @throwOnFieldError {
|
fragment TrustCenterFileListItem_file on TrustCenterFile @throwOnFieldError {
|
||||||
id
|
id
|
||||||
|
alias
|
||||||
name
|
name
|
||||||
category
|
category
|
||||||
isUserAuthorized
|
isUserAuthorized
|
||||||
@@ -38,26 +36,14 @@ const trustCenterFileListItemFragment = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const exportTrustCenterFileMutation = graphql`
|
|
||||||
mutation TrustCenterFileListItemExportMutation($input: ExportTrustCenterFileInput!) {
|
|
||||||
exportTrustCenterFile(input: $input) {
|
|
||||||
data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
interface TrustCenterFileListItemProps {
|
interface TrustCenterFileListItemProps {
|
||||||
fileKey: TrustCenterFileListItem_file$key;
|
fileKey: TrustCenterFileListItem_file$key;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A single uploaded trust-center file entry: name, its category, and an access
|
// 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) {
|
export function TrustCenterFileListItem({ fileKey }: TrustCenterFileListItemProps) {
|
||||||
const file = useFragment(trustCenterFileListItemFragment, fileKey);
|
const file = useFragment(trustCenterFileListItemFragment, fileKey);
|
||||||
const [openFile, isExporting] = useExportAndOpen<TrustCenterFileListItemExportMutation>(
|
|
||||||
exportTrustCenterFileMutation,
|
|
||||||
response => response.exportTrustCenterFile.data,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentEntry
|
<DocumentEntry
|
||||||
@@ -65,8 +51,7 @@ export function TrustCenterFileListItem({ fileKey }: TrustCenterFileListItemProp
|
|||||||
meta={file.category}
|
meta={file.category}
|
||||||
isAuthorized={file.isUserAuthorized}
|
isAuthorized={file.isUserAuthorized}
|
||||||
requested={file.access?.status === "REQUESTED"}
|
requested={file.access?.status === "REQUESTED"}
|
||||||
onView={() => openFile({ input: { trustCenterFileId: file.id } })}
|
viewHref={`/documents/${encodeURIComponent(file.alias ?? file.id)}`}
|
||||||
isViewing={isExporting}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,3 +38,34 @@ export const documentListItem = tv({
|
|||||||
content: "flex min-w-0 flex-1 flex-col gap-0.5",
|
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.",
|
"description": "This trust center has not published any documents yet.",
|
||||||
"filteredTitle": "No documents match this filter",
|
"filteredTitle": "No documents match this filter",
|
||||||
"filteredDescription": "Try a different tab to see available documents."
|
"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.",
|
"description": "Ce trust center n'a pas encore publié de documents.",
|
||||||
"filteredTitle": "Aucun document ne correspond à ce filtre",
|
"filteredTitle": "Aucun document ne correspond à ce filtre",
|
||||||
"filteredDescription": "Essayez un autre onglet pour voir les documents disponibles."
|
"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 type { AppRoute } from "@probo/routes";
|
||||||
|
|
||||||
import { DocumentsPageSkeleton } from "./DocumentsPageSkeleton";
|
import { DocumentsPageSkeleton } from "./DocumentsPageSkeleton";
|
||||||
|
import { DocumentViewerPageSkeleton } from "./DocumentViewerPageSkeleton";
|
||||||
|
|
||||||
export const documentRoutes = [
|
export const documentRoutes = [
|
||||||
{
|
{
|
||||||
@@ -29,4 +30,9 @@ export const documentRoutes = [
|
|||||||
Fallback: DocumentsPageSkeleton,
|
Fallback: DocumentsPageSkeleton,
|
||||||
Component: lazy(() => import("./DocumentsPageLoader")),
|
Component: lazy(() => import("./DocumentsPageLoader")),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "documents/:alias",
|
||||||
|
Fallback: DocumentViewerPageSkeleton,
|
||||||
|
Component: lazy(() => import("./DocumentViewerPageLoader")),
|
||||||
|
},
|
||||||
] satisfies AppRoute[];
|
] satisfies AppRoute[];
|
||||||
|
|||||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -36,9 +36,11 @@
|
|||||||
"@probo/routes": "1.0.0",
|
"@probo/routes": "1.0.0",
|
||||||
"@probo/ui": "1.0.0",
|
"@probo/ui": "1.0.0",
|
||||||
"i18next": "^26.3.4",
|
"i18next": "^26.3.4",
|
||||||
|
"pdfjs-dist": "^5.4.296",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
"react-i18next": "^17.0.8",
|
"react-i18next": "^17.0.8",
|
||||||
|
"react-pdf": "^10.3.0",
|
||||||
"react-relay": "^21.0.1",
|
"react-relay": "^21.0.1",
|
||||||
"react-router": "^8.1.0",
|
"react-router": "^8.1.0",
|
||||||
"relay-runtime": "^21.0.1"
|
"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