diff --git a/apps/compliance-portal/src/pages/documents/DocumentViewerPageSkeleton.tsx b/apps/compliance-portal/src/pages/documents/DocumentViewerPageSkeleton.tsx index 6d290f0c4..ea7d8ce21 100644 --- a/apps/compliance-portal/src/pages/documents/DocumentViewerPageSkeleton.tsx +++ b/apps/compliance-portal/src/pages/documents/DocumentViewerPageSkeleton.tsx @@ -24,10 +24,11 @@ import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton"; import { HeaderBand } from "#/components/HeaderBand/HeaderBand"; -import { documentViewer } from "./_components/variants"; +import { documentViewer, documentViewerToolbar } from "./_components/variants"; export function DocumentViewerPageSkeleton() { const slots = documentViewer(); + const toolbar = documentViewerToolbar(); return (
@@ -35,9 +36,9 @@ export function DocumentViewerPageSkeleton() {
-
+
-
+
diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentViewer.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentViewer.tsx index eb0d7ab48..32acd0a23 100644 --- a/apps/compliance-portal/src/pages/documents/_components/DocumentViewer.tsx +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentViewer.tsx @@ -18,22 +18,9 @@ // 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, - LinkSimpleIcon, - MagnifyingGlassMinusIcon, - MagnifyingGlassPlusIcon, - SpinnerGapIcon, -} from "@phosphor-icons/react"; -import { Button } from "@probo/ui/src/v2/Button/Button"; +import { CaretLeftIcon, SpinnerGapIcon } from "@phosphor-icons/react"; 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"; @@ -44,13 +31,11 @@ import { dataUriMimeType, downloadDataUri } from "../_lib/dataUri"; import { DocumentDownloadFallback } from "./DocumentDownloadFallback"; import { DocumentLocked } from "./DocumentLocked"; +import { DocumentViewerToolbar } from "./DocumentViewerToolbar"; 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); } @@ -83,7 +68,6 @@ interface DocumentViewerProps { // place of the preview. export function DocumentViewer({ title, dataUri = null, downloadName = title, locked }: DocumentViewerProps) { const { t } = useTranslation("documents"); - const toast = Toast.useToastManager(); const localizedPath = useLocalizedPath(); const pdfRef = useRef(null); @@ -102,13 +86,6 @@ export function DocumentViewer({ title, dataUri = null, downloadName = title, lo setCurrentPage(next); }; - const handleCopyLink = () => { - navigator.clipboard.writeText(window.location.href).then( - () => toast.add({ title: t("viewer.linkCopied"), type: "success" }), - () => {}, - ); - }; - const handleDownload = () => { if (dataUri) { downloadDataUri(dataUri, downloadName); @@ -128,81 +105,16 @@ export function DocumentViewer({ title, dataUri = null, downloadName = title, lo {title} {!isLocked && ( -
-
- {isPdf && ( - <> -
- movePage(-1)} - > - - - - {t("common.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))} - > - - -
- - )} -
-
- - - -
-
+ )}
diff --git a/apps/compliance-portal/src/pages/documents/_components/DocumentViewerToolbar.tsx b/apps/compliance-portal/src/pages/documents/_components/DocumentViewerToolbar.tsx new file mode 100644 index 000000000..f17d40334 --- /dev/null +++ b/apps/compliance-portal/src/pages/documents/_components/DocumentViewerToolbar.tsx @@ -0,0 +1,168 @@ +// 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, + LinkSimpleIcon, + MagnifyingGlassMinusIcon, + MagnifyingGlassPlusIcon, +} from "@phosphor-icons/react"; +import { Button } from "@probo/ui/src/v2/Button/Button"; +import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton"; +import { Separator } from "@probo/ui/src/v2/Separator/Separator"; +import { Text } from "@probo/ui/src/v2/typography/Text"; +import { useTranslation } from "react-i18next"; + +import { downloadDataUri } from "../_lib/dataUri"; + +import { documentViewerToolbar } 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 DocumentViewerToolbarProps { + // Whether PDF page/zoom controls are shown (only for PDF previews). + isPdf: boolean; + currentPage: number; + numPages: number; + scale: number; + onScaleChange: (scale: number) => void; + // Moves the preview by one page in the given direction. + onMovePage: (direction: 1 | -1) => void; + // The exported base64 data URI, or null while it is still loading. + dataUri: string | null; + // File name used when downloading. + downloadName: string; +} + +// Viewer chrome under the title: page navigation + zoom for PDFs, plus copy +// link and download actions shared by every previewable file type. +export function DocumentViewerToolbar({ + isPdf, + currentPage, + numPages, + scale, + onScaleChange, + onMovePage, + dataUri, + downloadName, +}: DocumentViewerToolbarProps) { + const { t } = useTranslation("documents"); + const toast = Toast.useToastManager(); + const slots = documentViewerToolbar(); + + const handleCopyLink = () => { + navigator.clipboard.writeText(window.location.href).then( + () => toast.add({ title: t("viewer.linkCopied"), type: "success" }), + () => {}, + ); + }; + + const handleDownload = () => { + if (dataUri) { + downloadDataUri(dataUri, downloadName); + } + }; + + return ( +
+
+ {isPdf && ( + <> +
+ onMovePage(-1)} + > + + + + {t("common.pageOf", { current: currentPage, total: numPages })} + + = numPages} + onClick={() => onMovePage(1)} + > + + +
+ +
+ onScaleChange(clamp(scale * 0.8, MIN_SCALE, MAX_SCALE))} + > + + + + {`${Math.round(scale * 100)}%`} + + onScaleChange(clamp(scale * 1.25, MIN_SCALE, MAX_SCALE))} + > + + +
+ + )} +
+
+ + + +
+
+ ); +} diff --git a/apps/compliance-portal/src/pages/documents/_components/variants.ts b/apps/compliance-portal/src/pages/documents/_components/variants.ts index 32bd199d3..d9c5552f2 100644 --- a/apps/compliance-portal/src/pages/documents/_components/variants.ts +++ b/apps/compliance-portal/src/pages/documents/_components/variants.ts @@ -51,21 +51,13 @@ export const pdfPreview = tv({ }, }); -// Full-page viewer: fixed header band with the title and toolbar above a -// scrollable grey stage for the PDF / image / download-fallback body. +// Full-page viewer: fixed header band with the title (and optional toolbar) +// above a scrollable grey stage for the PDF / image / download-fallback body. export const documentViewer = tv({ slots: { root: "flex h-full min-h-0 flex-1 flex-col", header: "flex w-full flex-col gap-3", back: "-ml-2 self-start", - // Stay on one row so page/zoom controls and copy/download share a baseline; - // icon-only action labels on max-sm keep this viable on phones. - toolbar: "flex min-h-16 flex-wrap items-center justify-between gap-x-4 gap-y-2", - toolbarStart: "flex min-w-0 flex-wrap items-center gap-2", - controls: "flex items-center gap-1", - actions: "flex shrink-0 items-center gap-2", - actionLabel: "max-sm:hidden", - separator: "h-6 max-sm:hidden", 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 max-md:p-4", @@ -73,3 +65,16 @@ export const documentViewer = tv({ spinner: "size-6 animate-spin text-sand-a10", }, }); + +// Viewer toolbar: stay on one row so page/zoom controls and copy/download share +// a baseline; icon-only action labels on max-sm keep this viable on phones. +export const documentViewerToolbar = tv({ + slots: { + root: "flex min-h-16 flex-wrap items-center justify-between gap-x-4 gap-y-2", + start: "flex min-w-0 flex-wrap items-center gap-2", + controls: "flex items-center gap-1", + actions: "flex shrink-0 items-center gap-2", + actionLabel: "max-sm:hidden", + separator: "h-6 max-sm:hidden", + }, +});