From 6054c928990ea19078a014f7b230bfb9d515ebe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Thu, 16 Jul 2026 11:14:51 +0200 Subject: [PATCH] Add document viewer to the compliance portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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é --- apps/compliance-portal/package.json | 2 + .../src/pages/MainLayout.tsx | 7 +- .../pages/documents/DocumentViewerPage.tsx | 90 ++++++++ ...edFile.ts => DocumentViewerPageLoader.tsx} | 38 ++-- ...Open.ts => DocumentViewerPageSkeleton.tsx} | 51 ++--- .../_components/AuditReportListItem.tsx | 23 +- .../_components/DocumentAccessAction.tsx | 28 +-- .../_components/DocumentDownloadFallback.tsx | 50 +++++ .../documents/_components/DocumentEntry.tsx | 15 +- .../_components/DocumentListItem.tsx | 21 +- .../documents/_components/DocumentLocked.tsx | 46 ++++ .../documents/_components/DocumentViewer.tsx | 211 ++++++++++++++++++ .../documents/_components/PdfPreview.tsx | 113 ++++++++++ .../_components/TrustCenterFileListItem.tsx | 21 +- .../pages/documents/_components/variants.ts | 31 +++ .../src/pages/documents/_lib/dataUri.ts | 35 +++ .../pages/documents/_lib/useDocumentExport.ts | 108 +++++++++ .../src/pages/documents/_locales/en-US.json | 17 ++ .../src/pages/documents/_locales/fr-FR.json | 17 ++ .../src/pages/documents/routes.ts | 6 + package-lock.json | 2 + .../ui/src/v2/Separator/Separator.stories.tsx | 48 ++++ packages/ui/src/v2/Separator/Separator.tsx | 42 ++++ packages/ui/src/v2/Separator/variants.ts | 36 +++ 24 files changed, 929 insertions(+), 129 deletions(-) create mode 100644 apps/compliance-portal/src/pages/documents/DocumentViewerPage.tsx rename apps/compliance-portal/src/pages/documents/{_lib/openExportedFile.ts => DocumentViewerPageLoader.tsx} (53%) rename apps/compliance-portal/src/pages/documents/{_lib/useExportAndOpen.ts => DocumentViewerPageSkeleton.tsx} (50%) create mode 100644 apps/compliance-portal/src/pages/documents/_components/DocumentDownloadFallback.tsx create mode 100644 apps/compliance-portal/src/pages/documents/_components/DocumentLocked.tsx create mode 100644 apps/compliance-portal/src/pages/documents/_components/DocumentViewer.tsx create mode 100644 apps/compliance-portal/src/pages/documents/_components/PdfPreview.tsx create mode 100644 apps/compliance-portal/src/pages/documents/_lib/dataUri.ts create mode 100644 apps/compliance-portal/src/pages/documents/_lib/useDocumentExport.ts create mode 100644 packages/ui/src/v2/Separator/Separator.stories.tsx create mode 100644 packages/ui/src/v2/Separator/Separator.tsx create mode 100644 packages/ui/src/v2/Separator/variants.ts diff --git a/apps/compliance-portal/package.json b/apps/compliance-portal/package.json index 352dfa8cd..00f00466a 100644 --- a/apps/compliance-portal/package.json +++ b/apps/compliance-portal/package.json @@ -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" diff --git a/apps/compliance-portal/src/pages/MainLayout.tsx b/apps/compliance-portal/src/pages/MainLayout.tsx index a9e697e68..d04cc51a3 100644 --- a/apps/compliance-portal/src/pages/MainLayout.tsx +++ b/apps/compliance-portal/src/pages/MainLayout.tsx @@ -43,9 +43,12 @@ export function MainLayout({ queryRef }: MainLayoutProps) { const data = usePreloadedQuery(mainLayoutQuery, queryRef); return ( -
+ // 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. +
-
+
diff --git a/apps/compliance-portal/src/pages/documents/DocumentViewerPage.tsx b/apps/compliance-portal/src/pages/documents/DocumentViewerPage.tsx new file mode 100644 index 000000000..7fa37a6be --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/DocumentViewerPage.tsx @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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; +} + +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, queryRef); + const node = resolveNode(data.aliasedNode); + const { dataUri } = useDocumentExport(node.kind, node.id, node.isAuthorized); + + if (!node.isAuthorized) { + return ; + } + + return ; +} diff --git a/apps/compliance-portal/src/pages/documents/_lib/openExportedFile.ts b/apps/compliance-portal/src/pages/documents/DocumentViewerPageLoader.tsx similarity index 53% rename from apps/compliance-portal/src/pages/documents/_lib/openExportedFile.ts rename to apps/compliance-portal/src/pages/documents/DocumentViewerPageLoader.tsx index 467eaee27..b5f7dc792 100644 --- a/apps/compliance-portal/src/pages/documents/_lib/openExportedFile.ts +++ b/apps/compliance-portal/src/pages/documents/DocumentViewerPageLoader.tsx @@ -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); + + useEffect(() => { + if (alias) { + loadQuery({ alias }); + } + }, [loadQuery, alias]); + + if (!queryRef) { + return ; } - 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 ; } diff --git a/apps/compliance-portal/src/pages/documents/_lib/useExportAndOpen.ts b/apps/compliance-portal/src/pages/documents/DocumentViewerPageSkeleton.tsx similarity index 50% rename from apps/compliance-portal/src/pages/documents/_lib/useExportAndOpen.ts rename to apps/compliance-portal/src/pages/documents/DocumentViewerPageSkeleton.tsx index ed65f2287..6d290f0c4 100644 --- a/apps/compliance-portal/src/pages/documents/_lib/useExportAndOpen.ts +++ b/apps/compliance-portal/src/pages/documents/DocumentViewerPageSkeleton.tsx @@ -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( - mutation: GraphQLTaggedNode, - selectData: (response: T["response"]) => string, -): readonly [(variables: T["variables"]) => void, boolean] { - const [commit, isExporting] = useMutation(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 ( +
+ +
+ + +
+ +
+ + +
+
+
+
+
+
+
+
); - - return [open, isExporting]; } diff --git a/apps/compliance-portal/src/pages/documents/_components/AuditReportListItem.tsx b/apps/compliance-portal/src/pages/documents/_components/AuditReportListItem.tsx index 0efd61689..f5962af3a 100644 --- a/apps/compliance-portal/src/pages/documents/_components/AuditReportListItem.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/AuditReportListItem.tsx @@ -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( - 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)}`} /> ); } diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx index 33f6d977c..250eb23c2 100644 --- a/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentAccessAction.tsx @@ -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 ( - + ); } diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentDownloadFallback.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentDownloadFallback.tsx new file mode 100644 index 000000000..fcd33a670 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentDownloadFallback.tsx @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( +
+ } + title={t("viewer.previewUnavailable")} + description={t("viewer.downloadToView")} + action={( + + )} + /> +
+ ); +} diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentEntry.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentEntry.tsx index 1804bcbf6..d4cea6b1b 100644 --- a/apps/compliance-portal/src/pages/documents/_components/DocumentEntry.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentEntry.tsx @@ -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}
- +
); } diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentListItem.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentListItem.tsx index 1a372db16..d64cc3e49 100644 --- a/apps/compliance-portal/src/pages/documents/_components/DocumentListItem.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentListItem.tsx @@ -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( - exportDocumentMutation, - response => response.exportDocumentPDF.data, - ); return ( openDocument({ input: { documentId: document.id } })} - isViewing={isExporting} + viewHref={`/documents/${encodeURIComponent(document.alias ?? document.id)}`} /> ); } diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentLocked.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentLocked.tsx new file mode 100644 index 000000000..7cf8b1428 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentLocked.tsx @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( +
+ } + title={t("viewer.locked.title")} + description={t("viewer.locked.description")} + action={( + + )} + /> +
+ ); +} diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentViewer.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentViewer.tsx new file mode 100644 index 000000000..79a0b89bf --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentViewer.tsx @@ -0,0 +1,211 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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(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 ( +
+ +
+ } className={slots.back()}> + {t("viewer.back")} + + + {title} + +
+
+ {isPdf && ( + <> +
+ movePage(-1)} + > + + + + {t("viewer.pageOf", { current: currentPage, total: numPages })} + + = numPages} + onClick={() => movePage(1)} + > + + +
+ +
+ setScale(value => clamp(value * 0.8, MIN_SCALE, MAX_SCALE))} + > + + + + {`${Math.round(scale * 100)}%`} + + setScale(value => clamp(value * 1.25, MIN_SCALE, MAX_SCALE))} + > + + +
+ + )} +
+
+ + + +
+
+
+
+ +
+ {dataUri == null + ? ( +
+ +
+ ) + : isPdf + ? ( + + ) + : isImage + ? ( +
+ {title} +
+ ) + : } +
+
+ ); +} diff --git a/apps/compliance-portal/src/pages/documents/_components/PdfPreview.tsx b/apps/compliance-portal/src/pages/documents/_components/PdfPreview.tsx new file mode 100644 index 000000000..88319e83d --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_components/PdfPreview.tsx @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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; + // 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(null); + const documentRef = useRef>(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 ( +
+ + +
+ )} + onLoadSuccess={(document) => { + setNumPages(document.numPages); + onNumPages(document.numPages); + onVisiblePageChange(1); + }} + > + {times(numPages, index => ( + + ))} + +
+ ); +} diff --git a/apps/compliance-portal/src/pages/documents/_components/TrustCenterFileListItem.tsx b/apps/compliance-portal/src/pages/documents/_components/TrustCenterFileListItem.tsx index a9aa7bf79..3913773da 100644 --- a/apps/compliance-portal/src/pages/documents/_components/TrustCenterFileListItem.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/TrustCenterFileListItem.tsx @@ -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( - exportTrustCenterFileMutation, - response => response.exportTrustCenterFile.data, - ); return ( openFile({ input: { trustCenterFileId: file.id } })} - isViewing={isExporting} + viewHref={`/documents/${encodeURIComponent(file.alias ?? file.id)}`} /> ); } diff --git a/apps/compliance-portal/src/pages/documents/_components/variants.ts b/apps/compliance-portal/src/pages/documents/_components/variants.ts index 375c7e7e7..b9baa8f13 100644 --- a/apps/compliance-portal/src/pages/documents/_components/variants.ts +++ b/apps/compliance-portal/src/pages/documents/_components/variants.ts @@ -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", + }, +}); diff --git a/apps/compliance-portal/src/pages/documents/_lib/dataUri.ts b/apps/compliance-portal/src/pages/documents/_lib/dataUri.ts new file mode 100644 index 000000000..c0d2f4612 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_lib/dataUri.ts @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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:;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(); +} diff --git a/apps/compliance-portal/src/pages/documents/_lib/useDocumentExport.ts b/apps/compliance-portal/src/pages/documents/_lib/useDocumentExport.ts new file mode 100644 index 000000000..909a0abe7 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_lib/useDocumentExport.ts @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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(exportDocumentMutation); + const [exportFile, isExportingFile] = useMutation(exportFileMutation); + const [exportReport, isExportingReport] = useMutation(exportReportMutation); + + const [dataUri, setDataUri] = useState(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 }; +} diff --git a/apps/compliance-portal/src/pages/documents/_locales/en-US.json b/apps/compliance-portal/src/pages/documents/_locales/en-US.json index ef388f872..7128d64f7 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/en-US.json +++ b/apps/compliance-portal/src/pages/documents/_locales/en-US.json @@ -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." + } } } diff --git a/apps/compliance-portal/src/pages/documents/_locales/fr-FR.json b/apps/compliance-portal/src/pages/documents/_locales/fr-FR.json index 52b45d2f2..52ac8faf0 100644 --- a/apps/compliance-portal/src/pages/documents/_locales/fr-FR.json +++ b/apps/compliance-portal/src/pages/documents/_locales/fr-FR.json @@ -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." + } } } diff --git a/apps/compliance-portal/src/pages/documents/routes.ts b/apps/compliance-portal/src/pages/documents/routes.ts index 1f81dd001..4ca5dfb05 100644 --- a/apps/compliance-portal/src/pages/documents/routes.ts +++ b/apps/compliance-portal/src/pages/documents/routes.ts @@ -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[]; diff --git a/package-lock.json b/package-lock.json index bc62cd744..e87fd50c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/packages/ui/src/v2/Separator/Separator.stories.tsx b/packages/ui/src/v2/Separator/Separator.stories.tsx new file mode 100644 index 000000000..8121256d1 --- /dev/null +++ b/packages/ui/src/v2/Separator/Separator.stories.tsx @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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 ( +
+ Above + + Below +
+ ); +} + +export function Vertical() { + return ( +
+ Left + + Right +
+ ); +} diff --git a/packages/ui/src/v2/Separator/Separator.tsx b/packages/ui/src/v2/Separator/Separator.tsx new file mode 100644 index 000000000..97ed87c44 --- /dev/null +++ b/packages/ui/src/v2/Separator/Separator.tsx @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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, "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 ( + + ); +} diff --git a/packages/ui/src/v2/Separator/variants.ts b/packages/ui/src/v2/Separator/variants.ts new file mode 100644 index 000000000..0270c44c4 --- /dev/null +++ b/packages/ui/src/v2/Separator/variants.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Probo Inc . +// +// 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", + }, +});