Add bulk request access to portal documents

Visitors could only request access to one document, report, or file at
a time. Add row checkboxes and a bottom selection toolbar to the
compliance portal documents page so a visitor can select several rows
and request access to all still-locked ones in a single round-trip.

Expose a selection-scoped requestAccesses mutation that forwards the
chosen id lists to the existing RequestPortalAccess service (one
transaction, one NDA/auth gate). The resolver loads and tenant-checks
every target before requesting so a foreign id is rejected before any
access row is written, and echoes the affected nodes so the client
flips each row to pending in place.

Add a styled Base UI Checkbox to the v2 kit, a local selection context
shared by the independent row fragments, and mirror the new selection
strings across all locales.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-22 19:11:07 +02:00
parent 15b51640d3
commit 9353d85d03
29 changed files with 971 additions and 3 deletions

View File

@@ -35,7 +35,10 @@ import { CompliancePortalFileListItem } from "./_components/CompliancePortalFile
import { DocumentListItem } from "./_components/DocumentListItem";
import { DocumentSection } from "./_components/DocumentSection";
import { DocumentsEmpty } from "./_components/DocumentsEmpty";
import { DocumentsSelectionBar } from "./_components/DocumentsSelectionBar";
import { DocumentsToolbar } from "./_components/DocumentsToolbar";
import type { DocumentSelectionEntry } from "./_lib/DocumentSelectionContext";
import { DocumentSelectionProvider } from "./_lib/DocumentSelectionContext";
import { toQueryVariables } from "./_lib/toQueryVariables";
import { useDocumentTab } from "./_lib/useDocumentTab";
import { documentsLayout } from "./variants";
@@ -56,6 +59,10 @@ const documentsPageFragment = graphql`
node {
id
documentType
isUserAuthorized
access {
status
}
...DocumentListItem_document
}
}
@@ -66,6 +73,10 @@ const documentsPageFragment = graphql`
id
reportFile {
id
isUserAuthorized
access {
status
}
}
...AuditReportListItem_audit
}
@@ -76,6 +87,10 @@ const documentsPageFragment = graphql`
node {
id
category
isUserAuthorized
access {
status
}
...CompliancePortalFileListItem_file
}
}
@@ -149,6 +164,36 @@ export function DocumentsPage({ queryRef }: DocumentsPageProps) {
const total = documentNodes.length + fileNodes.length + auditNodes.length;
// A row is "locked" (an access request would do something) when the viewer is
// not authorized and no request is already pending. Computed at page level so
// the selection bar can count locked rows without reaching into each fragment.
const isLocked = (isUserAuthorized: boolean, status: string | null | undefined) =>
!isUserAuthorized && status !== "REQUESTED";
const selectionEntries: DocumentSelectionEntry[] = [
...documentNodes.map(node => ({
id: node.id,
kind: "Document" as const,
locked: isLocked(node.isUserAuthorized, node.access?.status),
})),
...auditNodes.flatMap((node): DocumentSelectionEntry[] => {
const report = node.reportFile;
if (report == null) {
return [];
}
return [{
id: report.id,
kind: "AuditReport",
locked: isLocked(report.isUserAuthorized, report.access?.status),
}];
}),
...fileNodes.map(node => ({
id: node.id,
kind: "CompliancePortalFile" as const,
locked: isLocked(node.isUserAuthorized, node.access?.status),
})),
];
const documentGroups = Object.entries(groupBy(documentNodes, node => node.documentType))
.map(([key, nodes]) => ({ key, nodes }))
.sort((a, b) => t(`types.${a.key}`).localeCompare(t(`types.${b.key}`)));
@@ -159,7 +204,7 @@ export function DocumentsPage({ queryRef }: DocumentsPageProps) {
const { page, results } = documentsLayout({ busy: isRefetching });
return (
<>
<DocumentSelectionProvider resetKey={tab}>
<PageHeader title={t("title")} count={total} flushBottomSpace>
<DocumentsToolbar />
</PageHeader>
@@ -200,6 +245,7 @@ export function DocumentsPage({ queryRef }: DocumentsPageProps) {
</ListErrorBoundary>
</div>
</div>
</>
<DocumentsSelectionBar entries={selectionEntries} />
</DocumentSelectionProvider>
);
}

View File

@@ -22,6 +22,7 @@ import { graphql, useFragment } from "react-relay";
import { useLocalizedPath } from "#/lib/i18n/useLocale";
import { useDocumentSelection } from "../_lib/DocumentSelectionContext";
import { useRequestReportAccess } from "../_lib/useAccessRequest";
import type { AuditReportListItem_audit$key } from "./__generated__/AuditReportListItem_audit.graphql";
@@ -58,11 +59,14 @@ export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
// Hook must run unconditionally; the empty id is never used when there is no
// report file (the component returns null below).
const { requestAccess, isRequesting } = useRequestReportAccess(report?.id ?? "");
const { isSelected, toggle } = useDocumentSelection();
if (report == null) {
return null;
}
const reportId = report.id;
return (
<DocumentEntry
title={audit.framework.name}
@@ -72,6 +76,8 @@ export function AuditReportListItem({ auditKey }: AuditReportListItemProps) {
viewHref={localizedPath(`/documents/${encodeURIComponent(report.alias ?? report.id)}`)}
onGetAccess={requestAccess}
isRequesting={isRequesting}
selected={isSelected(reportId)}
onSelectedChange={() => toggle(reportId)}
/>
);
}

View File

@@ -22,6 +22,7 @@ import { graphql, useFragment } from "react-relay";
import { useLocalizedPath } from "#/lib/i18n/useLocale";
import { useDocumentSelection } from "../_lib/DocumentSelectionContext";
import { useRequestFileAccess } from "../_lib/useAccessRequest";
import type { CompliancePortalFileListItem_file$key } from "./__generated__/CompliancePortalFileListItem_file.graphql";
@@ -50,6 +51,7 @@ export function CompliancePortalFileListItem({ fileKey }: CompliancePortalFileLi
const localizedPath = useLocalizedPath();
const file = useFragment(compliancePortalFileListItemFragment, fileKey);
const { requestAccess, isRequesting } = useRequestFileAccess(file.id);
const { isSelected, toggle } = useDocumentSelection();
return (
<DocumentEntry
@@ -60,6 +62,8 @@ export function CompliancePortalFileListItem({ fileKey }: CompliancePortalFileLi
viewHref={localizedPath(`/documents/${encodeURIComponent(file.alias ?? file.id)}`)}
onGetAccess={requestAccess}
isRequesting={isRequesting}
selected={isSelected(file.id)}
onSelectedChange={() => toggle(file.id)}
/>
);
}

View File

@@ -18,6 +18,7 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Checkbox } from "@probo/ui/src/v2/Checkbox/Checkbox";
import { ListItem } from "@probo/ui/src/v2/List/ListItem";
import { ListItemContent } from "@probo/ui/src/v2/List/ListItemContent";
import { Text } from "@probo/ui/src/v2/typography/Text";
@@ -42,6 +43,11 @@ interface DocumentEntryProps {
onGetAccess: () => void;
// Whether the access request is in flight.
isRequesting: boolean;
// Whether this row is currently part of the multi-selection.
selected?: boolean;
// Toggles this row's membership in the multi-selection. When provided, a
// leading checkbox is rendered; omit it to render a non-selectable row.
onSelectedChange?: () => void;
}
// Presentational row shared by the document / file / report list items: a title
@@ -55,6 +61,8 @@ export function DocumentEntry({
viewHref,
onGetAccess,
isRequesting,
selected,
onSelectedChange,
}: DocumentEntryProps) {
const { t } = useTranslation("documents");
@@ -71,6 +79,17 @@ export function DocumentEntry({
mobileHitLabel != null ? "max-sm:cursor-pointer max-sm:hover:bg-sand-2" : "",
].filter(Boolean).join(" ")}
>
{onSelectedChange != null && (
// Sit above the mobile full-row overlay (z-1) so ticking a row never
// triggers the row's view / request-access activation.
<Checkbox
className="relative z-2"
checked={selected ?? false}
onCheckedChange={onSelectedChange}
aria-label={t("selection.selectRow", { title: typeof title === "string" ? title : "" })}
/>
)}
<ListItemContent>
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
{title}

View File

@@ -23,6 +23,7 @@ import { graphql, useFragment } from "react-relay";
import { useLocalizedPath } from "#/lib/i18n/useLocale";
import { useDocumentSelection } from "../_lib/DocumentSelectionContext";
import { useRequestDocumentAccess } from "../_lib/useAccessRequest";
import type { DocumentListItem_document$key } from "./__generated__/DocumentListItem_document.graphql";
@@ -52,6 +53,7 @@ export function DocumentListItem({ documentKey }: DocumentListItemProps) {
const localizedPath = useLocalizedPath();
const document = useFragment(documentListItemFragment, documentKey);
const { requestAccess, isRequesting } = useRequestDocumentAccess(document.id);
const { isSelected, toggle } = useDocumentSelection();
return (
<DocumentEntry
@@ -62,6 +64,8 @@ export function DocumentListItem({ documentKey }: DocumentListItemProps) {
viewHref={localizedPath(`/documents/${encodeURIComponent(document.alias ?? document.id)}`)}
onGetAccess={requestAccess}
isRequesting={isRequesting}
selected={isSelected(document.id)}
onSelectedChange={() => toggle(document.id)}
/>
);
}

View File

@@ -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 { LockSimpleIcon } from "@phosphor-icons/react";
import { Button } from "@probo/ui/src/v2/Button/Button";
import { Text } from "@probo/ui/src/v2/typography/Text";
import { useTranslation } from "react-i18next";
import type { DocumentSelectionEntry } from "../_lib/DocumentSelectionContext";
import { useDocumentSelection } from "../_lib/DocumentSelectionContext";
import { useBulkRequestAccess } from "../_lib/useBulkRequestAccess";
interface DocumentsSelectionBarProps {
// Every selectable row on the page, used to resolve the current selection into
// concrete entries (kind + lock state) and to power "Select all".
entries: DocumentSelectionEntry[];
}
// Bottom action bar shown while rows are selected: the selection count, clear /
// select-all shortcuts, and a bulk "Request Access" that acts only on the
// selected rows still locked.
export function DocumentsSelectionBar({ entries }: DocumentsSelectionBarProps) {
const { t } = useTranslation("documents");
const { selectedIds, selectAll, clear } = useDocumentSelection();
const { requestAccess, isRequesting } = useBulkRequestAccess(clear);
if (selectedIds.size === 0) {
return null;
}
const lockedSelected = entries.filter(entry => selectedIds.has(entry.id) && entry.locked);
const lockedCount = lockedSelected.length;
const handleRequestAccess = () => {
if (lockedCount === 0) {
return;
}
requestAccess(lockedSelected.map(entry => ({ id: entry.id, kind: entry.kind })));
};
return (
<div className="fixed inset-x-0 bottom-0 z-10 border-t border-sand-a3 bg-sand-1/80 px-8 py-4 backdrop-blur max-md:px-4">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between gap-4">
<Text size={2} weight="medium" color="neutral" highContrast>
{t("selection.count", { count: selectedIds.size })}
</Text>
<div className="flex items-center gap-2">
<Button variant="ghost" color="neutral" onClick={clear}>
{t("selection.clear")}
</Button>
<Button
variant="ghost"
color="neutral"
onClick={() => selectAll(entries.map(entry => entry.id))}
>
{t("selection.selectAll")}
</Button>
<Button
variant="solid"
color="neutral"
highContrast
iconStart={<LockSimpleIcon />}
loading={isRequesting}
disabled={lockedCount === 0}
onClick={handleRequestAccess}
>
{t("selection.requestAccess", { count: lockedCount })}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,106 @@
// 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 { ReactNode } from "react";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
// The three selectable resource kinds on the documents page. They map onto the
// requestAccesses mutation's id lists (Document → documentIds, AuditReport →
// reportIds using the report file id, CompliancePortalFile → compliancePortalFileIds).
export type DocumentKind = "Document" | "AuditReport" | "CompliancePortalFile";
// A selectable row, resolved at page level so the toolbar can compute counts
// (e.g. how many selected rows are still locked) without touching each fragment.
export interface DocumentSelectionEntry {
id: string;
kind: DocumentKind;
locked: boolean;
}
interface DocumentSelectionContextValue {
selectedIds: ReadonlySet<string>;
isSelected: (id: string) => boolean;
toggle: (id: string) => void;
selectAll: (ids: string[]) => void;
clear: () => void;
}
const DocumentSelectionContext = createContext<DocumentSelectionContextValue | null>(null);
interface DocumentSelectionProviderProps {
// Selection is cleared whenever this value changes (e.g. the active tab), so
// switching between slices never carries a stale selection across.
resetKey?: string;
children: ReactNode;
}
export function DocumentSelectionProvider({ resetKey, children }: DocumentSelectionProviderProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(() => new Set());
// Reset the selection during render when the key changes (e.g. the active
// tab). This is React's "adjust state on prop change" pattern, avoiding an
// effect and the extra commit it would cost.
const [prevResetKey, setPrevResetKey] = useState(resetKey);
if (resetKey !== prevResetKey) {
setPrevResetKey(resetKey);
setSelectedIds(new Set());
}
const toggle = useCallback((id: string) => {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
const selectAll = useCallback((ids: string[]) => {
setSelectedIds(new Set(ids));
}, []);
const clear = useCallback(() => {
setSelectedIds(new Set());
}, []);
const isSelected = useCallback((id: string) => selectedIds.has(id), [selectedIds]);
const value = useMemo<DocumentSelectionContextValue>(
() => ({ selectedIds, isSelected, toggle, selectAll, clear }),
[selectedIds, isSelected, toggle, selectAll, clear],
);
return (
<DocumentSelectionContext.Provider value={value}>
{children}
</DocumentSelectionContext.Provider>
);
}
export function useDocumentSelection() {
const context = useContext(DocumentSelectionContext);
if (context == null) {
throw new Error("useDocumentSelection must be used within a DocumentSelectionProvider");
}
return context;
}

View File

@@ -0,0 +1,148 @@
// 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 { UnAuthenticatedError } from "@probo/relay";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import type { PayloadError } from "relay-runtime";
import { graphql } from "relay-runtime";
import { gateRedirectPath, getSafeContinueUrl, redirectToInitiate } from "#/lib/auth/continueUrl";
import { useLocale } from "#/lib/i18n/useLocale";
import { useMutation } from "#/lib/relay/useMutation";
import type { useBulkRequestAccessMutation } from "./__generated__/useBulkRequestAccessMutation.graphql";
import type { DocumentKind } from "./DocumentSelectionContext";
// One selection-scoped call for the whole batch. The payload echoes each
// affected node's updated access record so Relay flips every requested row to
// its "pending" state in place, without a refetch.
const bulkMutation = graphql`
mutation useBulkRequestAccessMutation($input: RequestAccessesInput!) {
requestAccesses(input: $input) {
documents {
id
access {
id
status
}
}
audits {
id
reportFile {
id
access {
id
status
}
}
}
files {
id
access {
id
status
}
}
}
}
`;
export interface BulkAccessRequestEntry {
id: string;
kind: DocumentKind;
}
export interface BulkAccessRequest {
requestAccess: (entries: BulkAccessRequestEntry[]) => void;
isRequesting: boolean;
}
// Requests access for a mixed selection of documents / reports / files in a
// single mutation. Auth, full-name, and NDA gates are thrown by the fetch layer
// and surface in `onError`: unauthenticated redirects to OAuth /initiate, while
// full-name and NDA deep-link to their gate page. Unlike the single-row flow
// this is a "simple redirect": the current URL carries no batch marker, so the
// selection is not resumed after the gate is cleared (the user re-selects).
export function useBulkRequestAccess(onSuccess?: () => void): BulkAccessRequest {
const navigate = useNavigate();
const locale = useLocale();
const toast = Toast.useToastManager();
const { t } = useTranslation();
const [mutate, isRequesting] = useMutation<useBulkRequestAccessMutation>(
bulkMutation,
{ errorToast: false },
);
const requestAccess = useCallback(
(entries: BulkAccessRequestEntry[]) => {
const documentIds: string[] = [];
const reportIds: string[] = [];
const compliancePortalFileIds: string[] = [];
for (const entry of entries) {
switch (entry.kind) {
case "Document":
documentIds.push(entry.id);
break;
case "AuditReport":
reportIds.push(entry.id);
break;
case "CompliancePortalFile":
compliancePortalFileIds.push(entry.id);
break;
}
}
void mutate({
variables: { input: { documentIds, reportIds, compliancePortalFileIds } },
onCompleted: (_response: unknown, errors: PayloadError[] | null) => {
if (errors && errors.length > 0) {
toast.add({ title: t("auth.errors.requestFailed"), type: "error" });
return;
}
toast.add({ title: t("auth.requestAccess.success"), type: "success" });
onSuccess?.();
},
onError: (error: Error) => {
const continueUrl = getSafeContinueUrl(window.location.href);
if (error instanceof UnAuthenticatedError) {
redirectToInitiate(continueUrl);
return;
}
const gatePath = gateRedirectPath(error, continueUrl, locale);
if (gatePath) {
void navigate(gatePath);
return;
}
toast.add({ title: t("auth.errors.requestFailed"), type: "error" });
},
}).catch(() => {});
},
[mutate, toast, t, navigate, locale, onSuccess],
);
return { requestAccess, isRequesting };
}

View File

@@ -25,6 +25,13 @@
"getAccess": "Zugang anfordern",
"requested": "Zugang angefordert"
},
"selection": {
"count": "{{count}} ausgewählt",
"clear": "Auswahl aufheben",
"selectAll": "Alle auswählen",
"requestAccess": "Zugang anfordern ({{count}})",
"selectRow": "{{title}} auswählen"
},
"empty": {
"title": "Keine Dokumente verfügbar",
"description": "Dieses Compliance-Portal hat noch keine Dokumente veröffentlicht.",

View File

@@ -25,6 +25,13 @@
"getAccess": "Get Access",
"requested": "Access requested"
},
"selection": {
"count": "{{count}} selected",
"clear": "Clear selection",
"selectAll": "Select all",
"requestAccess": "Request Access ({{count}})",
"selectRow": "Select {{title}}"
},
"empty": {
"title": "No documents available",
"description": "This compliance portal has not published any documents yet.",

View File

@@ -25,6 +25,13 @@
"getAccess": "Solicitar acceso",
"requested": "Acceso solicitado"
},
"selection": {
"count": "{{count}} seleccionados",
"clear": "Borrar selección",
"selectAll": "Seleccionar todo",
"requestAccess": "Solicitar acceso ({{count}})",
"selectRow": "Seleccionar {{title}}"
},
"empty": {
"title": "No hay documentos disponibles",
"description": "Este portal de cumplimiento aún no ha publicado ningún documento.",

View File

@@ -25,6 +25,13 @@
"getAccess": "Obtenir l'accès",
"requested": "Accès demandé"
},
"selection": {
"count": "{{count}} sélectionné(s)",
"clear": "Effacer la sélection",
"selectAll": "Tout sélectionner",
"requestAccess": "Demander l'accès ({{count}})",
"selectRow": "Sélectionner {{title}}"
},
"empty": {
"title": "Aucun document disponible",
"description": "Ce portail de conformité n'a pas encore publié de documents.",

View File

@@ -25,6 +25,13 @@
"getAccess": "Dapatkan Akses",
"requested": "Akses telah diminta"
},
"selection": {
"count": "{{count}} dipilih",
"clear": "Hapus pilihan",
"selectAll": "Pilih semua",
"requestAccess": "Minta Akses ({{count}})",
"selectRow": "Pilih {{title}}"
},
"empty": {
"title": "Belum ada dokumen yang tersedia",
"description": "Portal kepatuhan ini belum mempublikasikan dokumen apa pun.",

View File

@@ -25,6 +25,13 @@
"getAccess": "Richiedi accesso",
"requested": "Accesso richiesto"
},
"selection": {
"count": "{{count}} selezionati",
"clear": "Cancella selezione",
"selectAll": "Seleziona tutto",
"requestAccess": "Richiedi accesso ({{count}})",
"selectRow": "Seleziona {{title}}"
},
"empty": {
"title": "Nessun documento disponibile",
"description": "Questo portale di conformità non ha ancora pubblicato alcun documento.",

View File

@@ -25,6 +25,13 @@
"getAccess": "アクセスをリクエスト",
"requested": "アクセスをリクエスト済み"
},
"selection": {
"count": "{{count}} 件選択中",
"clear": "選択をクリア",
"selectAll": "すべて選択",
"requestAccess": "アクセスをリクエスト ({{count}})",
"selectRow": "{{title}} を選択"
},
"empty": {
"title": "利用可能なドキュメントはありません",
"description": "このコンプライアンスポータルでは、まだドキュメントが公開されていません。",

View File

@@ -25,6 +25,13 @@
"getAccess": "액세스 요청",
"requested": "액세스가 요청되었습니다"
},
"selection": {
"count": "{{count}}개 선택됨",
"clear": "선택 해제",
"selectAll": "모두 선택",
"requestAccess": "액세스 요청 ({{count}})",
"selectRow": "{{title}} 선택"
},
"empty": {
"title": "이용 가능한 문서가 없습니다",
"description": "이 컴플라이언스 포털은 아직 문서를 공개하지 않았습니다.",

View File

@@ -25,6 +25,13 @@
"getAccess": "Uzyskaj dostęp",
"requested": "Poproszono o dostęp"
},
"selection": {
"count": "Zaznaczono: {{count}}",
"clear": "Wyczyść zaznaczenie",
"selectAll": "Zaznacz wszystko",
"requestAccess": "Poproś o dostęp ({{count}})",
"selectRow": "Zaznacz {{title}}"
},
"empty": {
"title": "Brak dostępnych dokumentów",
"description": "Ten Portal Zgodności nie opublikował jeszcze żadnych dokumentów.",

View File

@@ -25,6 +25,13 @@
"getAccess": "Obter Acesso",
"requested": "Acesso solicitado"
},
"selection": {
"count": "{{count}} selecionados",
"clear": "Limpar seleção",
"selectAll": "Selecionar tudo",
"requestAccess": "Solicitar acesso ({{count}})",
"selectRow": "Selecionar {{title}}"
},
"empty": {
"title": "Nenhum documento disponível",
"description": "Este portal de conformidade ainda não publicou documentos.",

View File

@@ -25,6 +25,13 @@
"getAccess": "Erişim İste",
"requested": "Erişim talep edildi"
},
"selection": {
"count": "{{count}} seçildi",
"clear": "Seçimi temizle",
"selectAll": "Tümünü seç",
"requestAccess": "Erişim İste ({{count}})",
"selectRow": "{{title}} öğesini seç"
},
"empty": {
"title": "Kullanılabilir belge yok",
"description": "Bu Uyumluluk Portalı henüz herhangi bir belge yayınlamadı.",

View File

@@ -25,6 +25,13 @@
"getAccess": "Отримати доступ",
"requested": "Доступ запитано"
},
"selection": {
"count": "Вибрано: {{count}}",
"clear": "Очистити вибір",
"selectAll": "Вибрати все",
"requestAccess": "Запитати доступ ({{count}})",
"selectRow": "Вибрати {{title}}"
},
"empty": {
"title": "Немає доступних документів",
"description": "Цей портал відповідності ще не опублікував жодних документів.",

View File

@@ -25,6 +25,13 @@
"getAccess": "获取访问权限",
"requested": "已申请访问权限"
},
"selection": {
"count": "已选择 {{count}} 项",
"clear": "清除选择",
"selectAll": "全选",
"requestAccess": "申请访问权限 ({{count}})",
"selectRow": "选择 {{title}}"
},
"empty": {
"title": "暂无可用文档",
"description": "此合规门户尚未发布任何文档。",

View File

@@ -24,7 +24,7 @@ import { tv } from "tailwind-variants/lite";
// `busy` variant dims the current results while a filtered slice refetches.
export const documentsLayout = tv({
slots: {
page: "flex w-full flex-col items-center px-8 py-8 max-md:px-4",
page: "flex w-full flex-col items-center px-8 pt-8 pb-28 max-md:px-4",
results: "flex w-full max-w-5xl flex-col gap-8 transition-opacity duration-150",
},
variants: {