Make compliance portal layout responsive
Add a burger drawer below md, fix document-flow scrolling so PoweredBy sits after content, and collapse home grids, list rows, toolbars, and dialogs for smaller viewports. PDF viewer fits width; updates skeleton mirrors stacked meta. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -2,6 +2,9 @@
|
|||||||
"topBar": {
|
"topBar": {
|
||||||
"tagline": "Compliance Portal",
|
"tagline": "Compliance Portal",
|
||||||
"getAccess": "Get Access",
|
"getAccess": "Get Access",
|
||||||
|
"openMenu": "Open menu",
|
||||||
|
"closeMenu": "Close menu",
|
||||||
|
"menuTitle": "Menu",
|
||||||
"nav": {
|
"nav": {
|
||||||
"documents": "Documents",
|
"documents": "Documents",
|
||||||
"subprocessors": "Subprocessors",
|
"subprocessors": "Subprocessors",
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
"topBar": {
|
"topBar": {
|
||||||
"tagline": "Portail de conformité",
|
"tagline": "Portail de conformité",
|
||||||
"getAccess": "Obtenir l'accès",
|
"getAccess": "Obtenir l'accès",
|
||||||
|
"openMenu": "Ouvrir le menu",
|
||||||
|
"closeMenu": "Fermer le menu",
|
||||||
|
"menuTitle": "Menu",
|
||||||
"nav": {
|
"nav": {
|
||||||
"documents": "Documents",
|
"documents": "Documents",
|
||||||
"subprocessors": "Sous-traitants",
|
"subprocessors": "Sous-traitants",
|
||||||
|
|||||||
@@ -25,10 +25,10 @@ import { tv } from "tailwind-variants/lite";
|
|||||||
// the card surface, with centered media on top, above a left-aligned body.
|
// the card surface, with centered media on top, above a left-aligned body.
|
||||||
export const backdropCard = tv({
|
export const backdropCard = tv({
|
||||||
slots: {
|
slots: {
|
||||||
header: "relative flex w-full items-center overflow-hidden p-8",
|
header: "relative flex w-full items-center overflow-hidden p-8 max-md:p-4",
|
||||||
blurBackdrop: "pointer-events-none absolute inset-0 size-full scale-150 object-cover opacity-10 blur-lg",
|
blurBackdrop: "pointer-events-none absolute inset-0 size-full scale-150 object-cover opacity-10 blur-lg",
|
||||||
backdrop: "pointer-events-none absolute inset-0",
|
backdrop: "pointer-events-none absolute inset-0",
|
||||||
backdropFade: "pointer-events-none absolute inset-0 bg-linear-to-b from-sand-1/0 to-sand-1",
|
backdropFade: "pointer-events-none absolute inset-0 bg-linear-to-b from-sand-1/0 to-sand-1",
|
||||||
body: "flex flex-col gap-2 px-8 pb-8",
|
body: "flex flex-col gap-2 px-8 pb-8 max-md:px-4 max-md:pb-4",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export interface ComplianceArticleItemProps {
|
|||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
// Optional accent sub-label below the title.
|
// Optional accent sub-label below the title.
|
||||||
eyebrow?: ReactNode;
|
eyebrow?: ReactNode;
|
||||||
// Right-aligned metadata (e.g. relative time).
|
// Trailing metadata on desktop; stacks under the title on small screens.
|
||||||
meta?: ReactNode;
|
meta?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +43,8 @@ export function ComplianceArticleItem({ icon, title, eyebrow, meta }: Compliance
|
|||||||
<div className={slots.root()}>
|
<div className={slots.root()}>
|
||||||
<span className={slots.icon()}>{icon}</span>
|
<span className={slots.icon()}>{icon}</span>
|
||||||
<div className={slots.content()}>
|
<div className={slots.content()}>
|
||||||
<Text size={2} weight="medium" color="neutral" highContrast>
|
<div className={slots.text()}>
|
||||||
|
<Text size={2} weight="medium" color="neutral" highContrast className={slots.title()}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{eyebrow != null && (
|
{eyebrow != null && (
|
||||||
@@ -58,5 +59,6 @@ export function ComplianceArticleItem({ icon, title, eyebrow, meta }: Compliance
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton";
|
|||||||
import { complianceArticleItem } from "./variants";
|
import { complianceArticleItem } from "./variants";
|
||||||
|
|
||||||
// Loading placeholder paired with ComplianceArticleItem: same row layout with
|
// Loading placeholder paired with ComplianceArticleItem: same row layout with
|
||||||
// a pulse icon and skeleton text.
|
// a pulse icon and skeleton text (meta stacks under the title on max-sm).
|
||||||
export function ComplianceArticleItemSkeleton() {
|
export function ComplianceArticleItemSkeleton() {
|
||||||
const slots = complianceArticleItem();
|
const slots = complianceArticleItem();
|
||||||
|
|
||||||
@@ -31,9 +31,11 @@ export function ComplianceArticleItemSkeleton() {
|
|||||||
<div className={slots.root()} aria-hidden>
|
<div className={slots.root()} aria-hidden>
|
||||||
<div className={slots.iconPlaceholder()} />
|
<div className={slots.iconPlaceholder()} />
|
||||||
<div className={slots.content()}>
|
<div className={slots.content()}>
|
||||||
<TextSkeleton size={2} className="w-48" />
|
<div className={slots.text()}>
|
||||||
|
<TextSkeleton size={2} className="w-48 max-sm:w-40" />
|
||||||
|
</div>
|
||||||
|
<TextSkeleton size={1} className={`w-20 ${slots.meta()}`} />
|
||||||
</div>
|
</div>
|
||||||
<TextSkeleton size={1} className="w-20 shrink-0" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,15 +21,16 @@
|
|||||||
import { tv } from "tailwind-variants/lite";
|
import { tv } from "tailwind-variants/lite";
|
||||||
|
|
||||||
// Compliance article list row (Figma "Compliance Article Item"): a leading
|
// Compliance article list row (Figma "Compliance Article Item"): a leading
|
||||||
// icon, a title (+ optional eyebrow), and right-aligned meta. Designed to sit
|
// icon, a title (+ optional eyebrow), and meta. Desktop keeps meta on the
|
||||||
// inside a list container that draws the dividers (e.g. `divide-y`), so rows
|
// trailing edge; mobile stacks it under the title with a tight gap. Slots are
|
||||||
// stay divider-agnostic whether or not each is wrapped in a link. Slots are
|
|
||||||
// shared by the row and its skeleton.
|
// shared by the row and its skeleton.
|
||||||
export const complianceArticleItem = tv({
|
export const complianceArticleItem = tv({
|
||||||
slots: {
|
slots: {
|
||||||
root: "flex w-full items-center gap-4 px-8 py-4",
|
root: "flex w-full items-center gap-4 px-8 py-4 max-md:px-4",
|
||||||
icon: "flex size-6 shrink-0 items-center justify-center text-gold-9 [&_svg]:size-full",
|
icon: "flex size-6 shrink-0 items-center justify-center text-gold-9 [&_svg]:size-full",
|
||||||
content: "flex min-w-0 flex-1 flex-col gap-1",
|
content: "flex min-w-0 flex-1 items-center gap-4 max-sm:flex-col max-sm:items-start max-sm:gap-0.5",
|
||||||
|
text: "flex min-w-0 flex-1 flex-col gap-0.5",
|
||||||
|
title: "truncate",
|
||||||
meta: "shrink-0",
|
meta: "shrink-0",
|
||||||
iconPlaceholder: "size-6 shrink-0 animate-pulse rounded-2 bg-sand-3",
|
iconPlaceholder: "size-6 shrink-0 animate-pulse rounded-2 bg-sand-3",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ function ComplianceFrameworksSectionContent({ trustCenterKey }: ComplianceFramew
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HomeSection title={t("home.sections.compliance")}>
|
<HomeSection title={t("home.sections.compliance")}>
|
||||||
<div className="grid grid-cols-6 gap-4">
|
<div className="grid grid-cols-6 gap-4 max-lg:grid-cols-3 max-sm:grid-cols-2">
|
||||||
{frameworks.map(framework => (
|
{frameworks.map(framework => (
|
||||||
<ComplianceFrameworkListItem key={framework.id} complianceFrameworkKey={framework} />
|
<ComplianceFrameworkListItem key={framework.id} complianceFrameworkKey={framework} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function ComplianceFrameworksSectionSkeleton() {
|
|||||||
<div className={slots.header()}>
|
<div className={slots.header()}>
|
||||||
<TextSkeleton size={2} className="w-24" />
|
<TextSkeleton size={2} className="w-24" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-6 gap-4">
|
<div className="grid grid-cols-6 gap-4 max-lg:grid-cols-3 max-sm:grid-cols-2">
|
||||||
{Array.from({ length: 4 }, (_, index) => (
|
{Array.from({ length: 4 }, (_, index) => (
|
||||||
<MediaTileSkeleton key={index} />
|
<MediaTileSkeleton key={index} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { tv } from "tailwind-variants/lite";
|
|||||||
// Hero and the page headers so the band is defined once.
|
// Hero and the page headers so the band is defined once.
|
||||||
export const headerBand = tv({
|
export const headerBand = tv({
|
||||||
slots: {
|
slots: {
|
||||||
band: "flex w-full flex-col items-center border-b border-sand-a3 bg-sand-1 px-8",
|
band: "flex w-full flex-col items-center border-b border-sand-a3 bg-sand-1 px-8 max-md:px-4",
|
||||||
inner: "w-full max-w-5xl",
|
inner: "w-full max-w-5xl",
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ export const hero = tv({
|
|||||||
export const organizationContactInfo = tv({
|
export const organizationContactInfo = tv({
|
||||||
slots: {
|
slots: {
|
||||||
// Only a top divider gap; the band's py-8 provides the bottom spacing.
|
// Only a top divider gap; the band's py-8 provides the bottom spacing.
|
||||||
root: "flex w-full items-center gap-6 border-t border-sand-a2 pt-4",
|
root: "flex w-full flex-wrap items-center gap-x-6 gap-y-2 border-t border-sand-a2 pt-4 max-md:flex-col max-md:items-start",
|
||||||
item: "flex items-center gap-2 text-sand-11 [&_svg]:size-5",
|
item: "flex min-w-0 items-center gap-2 text-sand-11 [&_svg]:size-5 [&_svg]:shrink-0 [&>*]:min-w-0 [&>*:not(svg)]:break-words",
|
||||||
link: "flex items-center gap-2 text-sand-11 hover:underline [&_svg]:size-5",
|
link: "flex min-w-0 items-center gap-2 text-sand-11 hover:underline [&_svg]:size-5 [&_svg]:shrink-0 [&>*]:min-w-0 [&>*:not(svg)]:break-all",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { tv } from "tailwind-variants/lite";
|
|||||||
export const pageHeader = tv({
|
export const pageHeader = tv({
|
||||||
slots: {
|
slots: {
|
||||||
content: "flex w-full flex-col gap-2",
|
content: "flex w-full flex-col gap-2",
|
||||||
titleRow: "flex w-full items-center justify-between gap-4",
|
titleRow: "flex w-full items-center justify-between gap-4 max-sm:flex-col max-sm:items-stretch",
|
||||||
// Muted item count appended to the title (e.g. "Documents (3)").
|
// Muted item count appended to the title (e.g. "Documents (3)").
|
||||||
count: "font-light text-sand-8",
|
count: "font-light text-sand-8",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ function RecentUpdatesSectionContent({ trustCenterKey }: RecentUpdatesSectionPro
|
|||||||
>
|
>
|
||||||
<div className="relative overflow-hidden rounded-5 border border-sand-3 bg-sand-1">
|
<div className="relative overflow-hidden rounded-5 border border-sand-3 bg-sand-1">
|
||||||
<div aria-hidden className="pointer-events-none absolute inset-0" style={dotPatternStyle} />
|
<div aria-hidden className="pointer-events-none absolute inset-0" style={dotPatternStyle} />
|
||||||
<div aria-hidden className="pointer-events-none absolute inset-0 bg-linear-to-r from-sand-1/0 to-sand-1 to-[96px]" />
|
<div aria-hidden className="pointer-events-none absolute inset-0 bg-linear-to-r from-sand-1/0 to-sand-1 to-[96px] max-sm:to-[48px]" />
|
||||||
<div className="relative divide-y divide-sand-a2">
|
<div className="relative divide-y divide-sand-a2">
|
||||||
{updates.map(update => (
|
{updates.map(update => (
|
||||||
// Rows share fate (one connection query), so a row's field error
|
// Rows share fate (one connection query), so a row's field error
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function RecentUpdatesSectionSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative overflow-hidden rounded-5 border border-sand-3 bg-sand-1">
|
<div className="relative overflow-hidden rounded-5 border border-sand-3 bg-sand-1">
|
||||||
<div aria-hidden className="pointer-events-none absolute inset-0" style={dotPatternStyle} />
|
<div aria-hidden className="pointer-events-none absolute inset-0" style={dotPatternStyle} />
|
||||||
<div aria-hidden className="pointer-events-none absolute inset-0 bg-linear-to-r from-sand-1/0 to-sand-1 to-[96px]" />
|
<div aria-hidden className="pointer-events-none absolute inset-0 bg-linear-to-r from-sand-1/0 to-sand-1 to-[96px] max-sm:to-[48px]" />
|
||||||
<div className="relative divide-y divide-sand-a2">
|
<div className="relative divide-y divide-sand-a2">
|
||||||
{Array.from({ length: 5 }, (_, index) => (
|
{Array.from({ length: 5 }, (_, index) => (
|
||||||
<ComplianceArticleItemSkeleton key={index} />
|
<ComplianceArticleItemSkeleton key={index} />
|
||||||
|
|||||||
@@ -27,6 +27,6 @@ export const securityCommitments = tv({
|
|||||||
root: "flex w-full flex-col gap-8 py-8",
|
root: "flex w-full flex-col gap-8 py-8",
|
||||||
group: "flex w-full flex-col gap-4",
|
group: "flex w-full flex-col gap-4",
|
||||||
groupHeader: "flex w-full flex-col gap-2",
|
groupHeader: "flex w-full flex-col gap-2",
|
||||||
grid: "grid grid-cols-3 gap-4",
|
grid: "grid grid-cols-3 gap-4 max-lg:grid-cols-2 max-md:grid-cols-1",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,20 +31,16 @@ import { buildRequestAllContinueUrl } from "#/lib/auth/continueUrl";
|
|||||||
import { useSignInDialog } from "#/lib/auth/signInDialogContext";
|
import { useSignInDialog } from "#/lib/auth/signInDialogContext";
|
||||||
|
|
||||||
import type { TopBar_query$key } from "./__generated__/TopBar_query.graphql";
|
import type { TopBar_query$key } from "./__generated__/TopBar_query.graphql";
|
||||||
|
import { TOP_BAR_NAV_ITEMS } from "./navItems";
|
||||||
|
import { TopBarMobileNav } from "./TopBarMobileNav";
|
||||||
import { TopBarUserMenu } from "./TopBarUserMenu";
|
import { TopBarUserMenu } from "./TopBarUserMenu";
|
||||||
import { topBar } from "./variants";
|
import { topBar } from "./variants";
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
|
||||||
{ to: "/documents", labelKey: "topBar.nav.documents" },
|
|
||||||
{ to: "/subprocessors", labelKey: "topBar.nav.subprocessors" },
|
|
||||||
{ to: "/updates", labelKey: "topBar.nav.updates" },
|
|
||||||
{ to: "/requests", labelKey: "topBar.nav.requests" },
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const topBarFragment = graphql`
|
const topBarFragment = graphql`
|
||||||
fragment TopBar_query on Query {
|
fragment TopBar_query on Query {
|
||||||
viewer {
|
viewer {
|
||||||
...TopBarUserMenu_identity
|
...TopBarUserMenu_identity
|
||||||
|
...TopBarMobileNav_identity
|
||||||
}
|
}
|
||||||
currentTrustCenter @required(action: THROW) {
|
currentTrustCenter @required(action: THROW) {
|
||||||
themedLogoUrl
|
themedLogoUrl
|
||||||
@@ -88,10 +84,10 @@ export function TopBar({ queryKey }: TopBarProps) {
|
|||||||
fallback={organizationName.charAt(0) || "?"}
|
fallback={organizationName.charAt(0) || "?"}
|
||||||
className={slots.logo()}
|
className={slots.logo()}
|
||||||
/>
|
/>
|
||||||
<Text size={2} weight="medium" color="neutral" highContrast>
|
<Text size={2} weight="medium" color="neutral" highContrast className={slots.brandName()}>
|
||||||
{organizationName}
|
{organizationName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size={2} color="neutral">
|
<Text size={2} color="neutral" className={slots.tagline()}>
|
||||||
{t("topBar.tagline")}
|
{t("topBar.tagline")}
|
||||||
</Text>
|
</Text>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -99,7 +95,7 @@ export function TopBar({ queryKey }: TopBarProps) {
|
|||||||
<div className={slots.spacer()} />
|
<div className={slots.spacer()} />
|
||||||
|
|
||||||
<nav className={slots.nav()}>
|
<nav className={slots.nav()}>
|
||||||
{NAV_ITEMS.map(item => (
|
{TOP_BAR_NAV_ITEMS.map(item => (
|
||||||
<Link
|
<Link
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
@@ -125,6 +121,8 @@ export function TopBar({ queryKey }: TopBarProps) {
|
|||||||
)
|
)
|
||||||
: <TopBarUserMenu identityKey={data.viewer} />}
|
: <TopBarUserMenu identityKey={data.viewer} />}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<TopBarMobileNav identityKey={data.viewer ?? null} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
212
apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx
Normal file
212
apps/compliance-portal/src/components/TopBar/TopBarMobileNav.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// 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 { Drawer } from "@base-ui/react/drawer";
|
||||||
|
import {
|
||||||
|
BellIcon,
|
||||||
|
BellRingingIcon,
|
||||||
|
ListIcon,
|
||||||
|
LockSimpleIcon,
|
||||||
|
SignOutIcon,
|
||||||
|
XIcon,
|
||||||
|
} 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 { Text } from "@probo/ui/src/v2/typography/Text";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { graphql, useFragment } from "react-relay";
|
||||||
|
import { useLocation } from "react-router";
|
||||||
|
|
||||||
|
import { buildRequestAllContinueUrl } from "#/lib/auth/continueUrl";
|
||||||
|
import { useSignInDialog } from "#/lib/auth/signInDialogContext";
|
||||||
|
import { useSignOut } from "#/lib/auth/useSignOut";
|
||||||
|
import { useSubscribeDialog } from "#/lib/mailingList/subscribeDialogContext";
|
||||||
|
|
||||||
|
import type { TopBarMobileNav_identity$key } from "./__generated__/TopBarMobileNav_identity.graphql";
|
||||||
|
import { TOP_BAR_NAV_ITEMS } from "./navItems";
|
||||||
|
import { topBar, topBarMobileNav } from "./variants";
|
||||||
|
|
||||||
|
const topBarMobileNavFragment = graphql`
|
||||||
|
fragment TopBarMobileNav_identity on Identity {
|
||||||
|
fullName
|
||||||
|
email
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface TopBarMobileNavProps {
|
||||||
|
identityKey: TopBarMobileNav_identity$key | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(pathname: string, to: string): boolean {
|
||||||
|
return pathname === to || pathname.startsWith(`${to}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Burger trigger + right-edge drawer listing the same nav and account actions as
|
||||||
|
// the desktop TopBar.
|
||||||
|
export function TopBarMobileNav({ identityKey }: TopBarMobileNavProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const { openSignIn } = useSignInDialog();
|
||||||
|
const { openSubscribe, isSubscribed, unsubscribe, isUnsubscribing } = useSubscribeDialog();
|
||||||
|
const [signOut, isSigningOut] = useSignOut();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const identity = useFragment(topBarMobileNavFragment, identityKey);
|
||||||
|
|
||||||
|
const barSlots = topBar();
|
||||||
|
const slots = topBarMobileNav();
|
||||||
|
|
||||||
|
const displayName = identity
|
||||||
|
? (identity.fullName.trim() || identity.email)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const close = () => setOpen(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer.Root open={open} onOpenChange={setOpen} swipeDirection="right">
|
||||||
|
<div className={barSlots.menuTrigger()}>
|
||||||
|
<Drawer.Trigger
|
||||||
|
render={(
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
size={2}
|
||||||
|
aria-label={t("topBar.openMenu")}
|
||||||
|
>
|
||||||
|
<ListIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Drawer.Portal>
|
||||||
|
<Drawer.Backdrop className={slots.backdrop()} />
|
||||||
|
<Drawer.Viewport className={slots.viewport()}>
|
||||||
|
<Drawer.Popup className={slots.popup()}>
|
||||||
|
<Drawer.Content className={slots.content()}>
|
||||||
|
<div className={slots.header()}>
|
||||||
|
<Drawer.Title className={slots.title()}>
|
||||||
|
{t("topBar.menuTitle")}
|
||||||
|
</Drawer.Title>
|
||||||
|
<Drawer.Close
|
||||||
|
render={(
|
||||||
|
<IconButton
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
size={2}
|
||||||
|
aria-label={t("topBar.closeMenu")}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className={slots.nav()}>
|
||||||
|
{TOP_BAR_NAV_ITEMS.map(item => (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
size={3}
|
||||||
|
active={isActive(pathname, item.to)}
|
||||||
|
className="w-full justify-start"
|
||||||
|
onClick={close}
|
||||||
|
>
|
||||||
|
{t(item.labelKey)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className={slots.actions()}>
|
||||||
|
{identity == null
|
||||||
|
? (
|
||||||
|
<Button
|
||||||
|
variant="solid"
|
||||||
|
color="neutral"
|
||||||
|
highContrast
|
||||||
|
size={3}
|
||||||
|
className="w-full"
|
||||||
|
iconStart={<LockSimpleIcon />}
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
openSignIn({ continueTo: buildRequestAllContinueUrl() });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("topBar.getAccess")}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
{displayName
|
||||||
|
? (
|
||||||
|
<div className="flex flex-col gap-0.5 px-1 pb-2">
|
||||||
|
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
<Text size={1} color="faint" className="truncate">
|
||||||
|
{identity.email}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color={isSubscribed ? "green" : "gold"}
|
||||||
|
size={3}
|
||||||
|
className="w-full justify-start"
|
||||||
|
iconStart={isSubscribed ? <BellRingingIcon /> : <BellIcon />}
|
||||||
|
disabled={isUnsubscribing}
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
if (isSubscribed) {
|
||||||
|
void unsubscribe();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openSubscribe();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubscribed ? t("userMenu.subscribed") : t("userMenu.subscribe")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
color="red"
|
||||||
|
size={3}
|
||||||
|
className="w-full justify-start"
|
||||||
|
iconStart={<SignOutIcon />}
|
||||||
|
disabled={isSigningOut}
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
void signOut();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("userMenu.signOut")}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Drawer.Content>
|
||||||
|
</Drawer.Popup>
|
||||||
|
</Drawer.Viewport>
|
||||||
|
</Drawer.Portal>
|
||||||
|
</Drawer.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -43,7 +43,8 @@ export function TopBarSkeleton() {
|
|||||||
<div className={slots.inner()}>
|
<div className={slots.inner()}>
|
||||||
<div className={slots.brand()}>
|
<div className={slots.brand()}>
|
||||||
<AvatarSkeleton size={1} radius="small" />
|
<AvatarSkeleton size={1} radius="small" />
|
||||||
<TextSkeleton size={2} className="w-24" />
|
<TextSkeleton size={2} className={`w-24 ${slots.brandName()}`} />
|
||||||
|
<TextSkeleton size={2} className={`w-28 ${slots.tagline()}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={slots.spacer()} />
|
<div className={slots.spacer()} />
|
||||||
@@ -54,6 +55,10 @@ export function TopBarSkeleton() {
|
|||||||
))}
|
))}
|
||||||
<ButtonSkeleton size={2} />
|
<ButtonSkeleton size={2} />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<div className={slots.menuTrigger()}>
|
||||||
|
<div className="size-8 animate-pulse rounded-2 bg-sand-3" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,10 +76,10 @@ export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) {
|
|||||||
radius="small"
|
radius="small"
|
||||||
fallback={<UserIcon />}
|
fallback={<UserIcon />}
|
||||||
/>
|
/>
|
||||||
<Text size={2} weight="medium" color="neutral" highContrast>
|
<Text size={2} weight="medium" color="neutral" highContrast className="max-w-36 truncate">
|
||||||
{displayName}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
<CaretDownIcon className="size-4 text-sand-11" />
|
<CaretDownIcon className="size-4 shrink-0 text-sand-11" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
26
apps/compliance-portal/src/components/TopBar/navItems.ts
Normal file
26
apps/compliance-portal/src/components/TopBar/navItems.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// 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.
|
||||||
|
|
||||||
|
export const TOP_BAR_NAV_ITEMS = [
|
||||||
|
{ to: "/documents", labelKey: "topBar.nav.documents" },
|
||||||
|
{ to: "/subprocessors", labelKey: "topBar.nav.subprocessors" },
|
||||||
|
{ to: "/updates", labelKey: "topBar.nav.updates" },
|
||||||
|
{ to: "/requests", labelKey: "topBar.nav.requests" },
|
||||||
|
] as const;
|
||||||
@@ -21,15 +21,20 @@
|
|||||||
import { tv } from "tailwind-variants/lite";
|
import { tv } from "tailwind-variants/lite";
|
||||||
|
|
||||||
// Trust Center top navigation bar. Slots are shared by the live TopBar and its
|
// Trust Center top navigation bar. Slots are shared by the live TopBar and its
|
||||||
// skeleton so the loading placeholder is structurally identical.
|
// skeleton so the loading placeholder is structurally identical. Desktop-first:
|
||||||
|
// unprefixed classes are the desktop layout; max-md: collapses into the burger.
|
||||||
export const topBar = tv({
|
export const topBar = tv({
|
||||||
slots: {
|
slots: {
|
||||||
bar: "flex h-14 items-center bg-sand-1 px-8",
|
bar: "flex h-14 items-center bg-sand-1 px-8 max-md:px-4",
|
||||||
inner: "mx-auto flex w-full max-w-5xl items-center gap-10",
|
inner: "mx-auto flex w-full max-w-5xl items-center gap-10 max-md:gap-3",
|
||||||
brand: "flex items-center gap-2",
|
brand: "flex min-w-0 items-center gap-2",
|
||||||
|
brandName: "truncate",
|
||||||
|
tagline: "max-md:hidden",
|
||||||
logo: "shrink-0",
|
logo: "shrink-0",
|
||||||
spacer: "h-px flex-1",
|
spacer: "h-px flex-1",
|
||||||
nav: "flex items-center gap-1",
|
nav: "flex items-center gap-1 max-md:hidden",
|
||||||
|
// Wrapper (not the IconButton) so kit `inline-flex` cannot override `hidden`.
|
||||||
|
menuTrigger: "hidden max-md:block",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -42,3 +47,26 @@ export const topBarUserMenuTrigger = tv({
|
|||||||
"focus-visible:ring-2 focus-visible:ring-sand-8 focus-visible:ring-offset-1 focus-visible:ring-offset-sand-1",
|
"focus-visible:ring-2 focus-visible:ring-sand-8 focus-visible:ring-offset-1 focus-visible:ring-offset-sand-1",
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Right-edge mobile navigation drawer (Base UI Drawer).
|
||||||
|
export const topBarMobileNav = tv({
|
||||||
|
slots: {
|
||||||
|
backdrop: [
|
||||||
|
"fixed inset-0 z-50 bg-sand-12/40",
|
||||||
|
"transition-opacity duration-200",
|
||||||
|
"data-starting-style:opacity-0 data-ending-style:opacity-0",
|
||||||
|
],
|
||||||
|
viewport: "fixed inset-0 z-50 flex justify-end",
|
||||||
|
popup: [
|
||||||
|
"relative flex h-full w-[min(20rem,100%)] flex-col bg-sand-1 shadow-6 outline-none",
|
||||||
|
"[transform:translateX(var(--drawer-swipe-movement-x,0px))]",
|
||||||
|
"transition-transform duration-200",
|
||||||
|
"data-starting-style:translate-x-full data-ending-style:translate-x-full",
|
||||||
|
],
|
||||||
|
content: "flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4",
|
||||||
|
header: "flex items-center justify-between gap-3",
|
||||||
|
title: "text-4 font-medium text-sand-12",
|
||||||
|
nav: "flex flex-col gap-1",
|
||||||
|
actions: "mt-auto flex flex-col gap-2 border-t border-sand-a3 pt-4",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ function TrustedBySectionContent({ trustCenterKey }: TrustedBySectionProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HomeSection title={t("home.sections.trustedBy")}>
|
<HomeSection title={t("home.sections.trustedBy")}>
|
||||||
<div className="grid grid-cols-6 gap-4">
|
<div className="grid grid-cols-6 gap-4 max-lg:grid-cols-3 max-sm:grid-cols-2">
|
||||||
{references.map(reference => (
|
{references.map(reference => (
|
||||||
<TrustCenterReferenceListItem key={reference.id} referenceKey={reference} />
|
<TrustCenterReferenceListItem key={reference.id} referenceKey={reference} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function TrustedBySectionSkeleton() {
|
|||||||
<div className={slots.header()}>
|
<div className={slots.header()}>
|
||||||
<TextSkeleton size={2} className="w-20" />
|
<TextSkeleton size={2} className="w-20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-6 gap-4">
|
<div className="grid grid-cols-6 gap-4 max-lg:grid-cols-3 max-sm:grid-cols-2">
|
||||||
{Array.from({ length: 6 }, (_, index) => (
|
{Array.from({ length: 6 }, (_, index) => (
|
||||||
<MediaTileSkeleton key={index} variant="logo" />
|
<MediaTileSkeleton key={index} variant="logo" />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function HomePage({ queryRef }: HomePageProps) {
|
|||||||
>
|
>
|
||||||
<OrganizationContactInfo organizationKey={organization} />
|
<OrganizationContactInfo organizationKey={organization} />
|
||||||
</Hero>
|
</Hero>
|
||||||
<div className="flex w-full flex-col items-center px-8">
|
<div className="flex w-full flex-col items-center px-8 max-md:px-4">
|
||||||
<div className="flex w-full max-w-5xl flex-col">
|
<div className="flex w-full max-w-5xl flex-col">
|
||||||
<ComplianceFrameworksSection trustCenterKey={currentTrustCenter} />
|
<ComplianceFrameworksSection trustCenterKey={currentTrustCenter} />
|
||||||
<SecurityCommitmentsSection trustCenterKey={currentTrustCenter} />
|
<SecurityCommitmentsSection trustCenterKey={currentTrustCenter} />
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export function HomePageSkeleton() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeroSkeleton />
|
<HeroSkeleton />
|
||||||
<div className="flex w-full flex-col items-center px-8">
|
<div className="flex w-full flex-col items-center px-8 max-md:px-4">
|
||||||
<div className="flex w-full max-w-5xl flex-col">
|
<div className="flex w-full max-w-5xl flex-col">
|
||||||
<ComplianceFrameworksSectionSkeleton />
|
<ComplianceFrameworksSectionSkeleton />
|
||||||
<SecurityCommitmentsSectionSkeleton />
|
<SecurityCommitmentsSectionSkeleton />
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type { PreloadedQuery } from "react-relay";
|
import type { PreloadedQuery } from "react-relay";
|
||||||
import { graphql, usePreloadedQuery } from "react-relay";
|
import { graphql, usePreloadedQuery } from "react-relay";
|
||||||
import { Outlet } from "react-router";
|
import { Outlet, useMatch } from "react-router";
|
||||||
|
|
||||||
import { PoweredBy } from "#/components/PoweredBy/PoweredBy";
|
import { PoweredBy } from "#/components/PoweredBy/PoweredBy";
|
||||||
import { TopBar } from "#/components/TopBar/TopBar";
|
import { TopBar } from "#/components/TopBar/TopBar";
|
||||||
@@ -48,22 +48,35 @@ interface MainLayoutProps {
|
|||||||
export function MainLayout({ queryRef }: MainLayoutProps) {
|
export function MainLayout({ queryRef }: MainLayoutProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const data = usePreloadedQuery<MainLayoutQuery>(mainLayoutQuery, queryRef);
|
const data = usePreloadedQuery<MainLayoutQuery>(mainLayoutQuery, queryRef);
|
||||||
|
const isDocumentViewer = useMatch("documents/:alias") != null;
|
||||||
|
|
||||||
// Resume a deferred "request access" once the user lands back authenticated.
|
// Resume a deferred "request access" once the user lands back authenticated.
|
||||||
useResumeAccessRequest(data.viewer != null);
|
useResumeAccessRequest(data.viewer != null);
|
||||||
|
|
||||||
|
// Document viewer fills the viewport under the TopBar and scrolls its own
|
||||||
|
// stage; every other page uses normal document flow so the footer sits after
|
||||||
|
// content (and at the bottom of short pages via flex-1 main).
|
||||||
return (
|
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.
|
|
||||||
<SignInDialogProvider>
|
<SignInDialogProvider>
|
||||||
<SubscribeDialogProvider queryKey={data}>
|
<SubscribeDialogProvider queryKey={data}>
|
||||||
<div className="flex h-dvh flex-col bg-sand-2">
|
<div
|
||||||
|
className={
|
||||||
|
isDocumentViewer
|
||||||
|
? "flex h-dvh flex-col bg-sand-2"
|
||||||
|
: "flex min-h-dvh flex-col bg-sand-2"
|
||||||
|
}
|
||||||
|
>
|
||||||
<TopBar queryKey={data} />
|
<TopBar queryKey={data} />
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div
|
||||||
|
className={
|
||||||
|
isDocumentViewer
|
||||||
|
? "flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||||
|
: "flex flex-1 flex-col"
|
||||||
|
}
|
||||||
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
<PoweredBy label={t("footer.poweredBy")} />
|
{isDocumentViewer ? null : <PoweredBy label={t("footer.poweredBy")} />}
|
||||||
</div>
|
</div>
|
||||||
</SubscribeDialogProvider>
|
</SubscribeDialogProvider>
|
||||||
</SignInDialogProvider>
|
</SignInDialogProvider>
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ import { TopBarSkeleton } from "#/components/TopBar/TopBarSkeleton";
|
|||||||
|
|
||||||
export function MainLayoutSkeleton() {
|
export function MainLayoutSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-sand-2">
|
<div className="flex min-h-dvh flex-col bg-sand-2">
|
||||||
<TopBarSkeleton />
|
<TopBarSkeleton />
|
||||||
|
<div className="flex flex-1 flex-col" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
// 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 { ArrowRightIcon, ClockIcon, LockSimpleIcon } from "@phosphor-icons/react";
|
import { ArrowRightIcon, ClockIcon, LockSimpleIcon, SpinnerGapIcon } 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 { Link } from "@probo/ui/src/v2/Button/Link";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -34,6 +34,30 @@ interface DocumentAccessActionProps {
|
|||||||
onGetAccess: () => void;
|
onGetAccess: () => void;
|
||||||
// Whether the access request is in flight.
|
// Whether the access request is in flight.
|
||||||
isRequesting: boolean;
|
isRequesting: boolean;
|
||||||
|
// When false, render a non-interactive status icon (used as the mobile
|
||||||
|
// affordance while the row itself handles the hit target).
|
||||||
|
interactive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusIcon({
|
||||||
|
isAuthorized,
|
||||||
|
requested,
|
||||||
|
isRequesting,
|
||||||
|
}: {
|
||||||
|
isAuthorized: boolean;
|
||||||
|
requested: boolean;
|
||||||
|
isRequesting: boolean;
|
||||||
|
}) {
|
||||||
|
if (isAuthorized) {
|
||||||
|
return <ArrowRightIcon className="size-4 text-sand-12" />;
|
||||||
|
}
|
||||||
|
if (requested) {
|
||||||
|
return <ClockIcon className="size-4 text-sand-11" />;
|
||||||
|
}
|
||||||
|
if (isRequesting) {
|
||||||
|
return <SpinnerGapIcon className="size-4 animate-spin text-sand-12" />;
|
||||||
|
}
|
||||||
|
return <LockSimpleIcon className="size-4 text-sand-12" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trailing access control for a document entry: a "View" link to the viewer when
|
// Trailing access control for a document entry: a "View" link to the viewer when
|
||||||
@@ -45,12 +69,31 @@ export function DocumentAccessAction({
|
|||||||
viewHref,
|
viewHref,
|
||||||
onGetAccess,
|
onGetAccess,
|
||||||
isRequesting,
|
isRequesting,
|
||||||
|
interactive = true,
|
||||||
}: DocumentAccessActionProps) {
|
}: DocumentAccessActionProps) {
|
||||||
const { t } = useTranslation("documents");
|
const { t } = useTranslation("documents");
|
||||||
|
|
||||||
|
if (!interactive) {
|
||||||
|
return (
|
||||||
|
<span className="flex size-8 items-center justify-center" aria-hidden>
|
||||||
|
<StatusIcon
|
||||||
|
isAuthorized={isAuthorized}
|
||||||
|
requested={requested}
|
||||||
|
isRequesting={isRequesting}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isAuthorized) {
|
if (isAuthorized) {
|
||||||
return (
|
return (
|
||||||
<Link to={viewHref} variant="ghost" color="neutral" highContrast iconStart={<ArrowRightIcon />}>
|
<Link
|
||||||
|
to={viewHref}
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
highContrast
|
||||||
|
iconStart={<ArrowRightIcon />}
|
||||||
|
>
|
||||||
{t("actions.view")}
|
{t("actions.view")}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { ListItem } from "@probo/ui/src/v2/List/ListItem";
|
|||||||
import { ListItemContent } from "@probo/ui/src/v2/List/ListItemContent";
|
import { ListItemContent } from "@probo/ui/src/v2/List/ListItemContent";
|
||||||
import { Text } from "@probo/ui/src/v2/typography/Text";
|
import { Text } from "@probo/ui/src/v2/typography/Text";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Link as RouterLink } from "react-router";
|
||||||
|
|
||||||
import { DocumentAccessAction } from "./DocumentAccessAction";
|
import { DocumentAccessAction } from "./DocumentAccessAction";
|
||||||
|
|
||||||
@@ -43,8 +45,8 @@ interface DocumentEntryProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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. On small screens the
|
||||||
// wrappers own their fragments and supply these values.
|
// whole row is the hit target (the trailing icon is a status affordance only).
|
||||||
export function DocumentEntry({
|
export function DocumentEntry({
|
||||||
title,
|
title,
|
||||||
meta,
|
meta,
|
||||||
@@ -54,8 +56,21 @@ export function DocumentEntry({
|
|||||||
onGetAccess,
|
onGetAccess,
|
||||||
isRequesting,
|
isRequesting,
|
||||||
}: DocumentEntryProps) {
|
}: DocumentEntryProps) {
|
||||||
|
const { t } = useTranslation("documents");
|
||||||
|
|
||||||
|
const mobileHitLabel = requested
|
||||||
|
? null
|
||||||
|
: isAuthorized
|
||||||
|
? t("actions.view")
|
||||||
|
: t("actions.getAccess");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItem>
|
<ListItem
|
||||||
|
className={[
|
||||||
|
"relative",
|
||||||
|
mobileHitLabel != null ? "max-sm:cursor-pointer max-sm:hover:bg-sand-2" : "",
|
||||||
|
].filter(Boolean).join(" ")}
|
||||||
|
>
|
||||||
<ListItemContent>
|
<ListItemContent>
|
||||||
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
|
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
|
||||||
{title}
|
{title}
|
||||||
@@ -64,6 +79,9 @@ export function DocumentEntry({
|
|||||||
{meta}
|
{meta}
|
||||||
</Text>
|
</Text>
|
||||||
</ListItemContent>
|
</ListItemContent>
|
||||||
|
|
||||||
|
{/* Desktop: labeled interactive control. */}
|
||||||
|
<div className="max-sm:hidden">
|
||||||
<DocumentAccessAction
|
<DocumentAccessAction
|
||||||
isAuthorized={isAuthorized}
|
isAuthorized={isAuthorized}
|
||||||
requested={requested}
|
requested={requested}
|
||||||
@@ -71,6 +89,40 @@ export function DocumentEntry({
|
|||||||
onGetAccess={onGetAccess}
|
onGetAccess={onGetAccess}
|
||||||
isRequesting={isRequesting}
|
isRequesting={isRequesting}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: status icon only; the row overlay handles activation. */}
|
||||||
|
<div className="hidden shrink-0 max-sm:block" aria-hidden={mobileHitLabel != null}>
|
||||||
|
<DocumentAccessAction
|
||||||
|
isAuthorized={isAuthorized}
|
||||||
|
requested={requested}
|
||||||
|
viewHref={viewHref}
|
||||||
|
onGetAccess={onGetAccess}
|
||||||
|
isRequesting={isRequesting}
|
||||||
|
interactive={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sit above the row content so title / icon areas are part of the hit target. */}
|
||||||
|
{mobileHitLabel != null && (
|
||||||
|
isAuthorized
|
||||||
|
? (
|
||||||
|
<RouterLink
|
||||||
|
to={viewHref}
|
||||||
|
className="absolute inset-0 z-10 hidden max-sm:block"
|
||||||
|
aria-label={mobileHitLabel}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-0 z-10 hidden max-sm:block"
|
||||||
|
aria-label={mobileHitLabel}
|
||||||
|
disabled={isRequesting}
|
||||||
|
onClick={onGetAccess}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</ListItem>
|
</ListItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,8 +163,14 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={slots.actions()}>
|
<div className={slots.actions()}>
|
||||||
<Button variant="ghost" color="neutral" iconStart={<ShareNetworkIcon />} onClick={handleShare}>
|
<Button
|
||||||
{t("viewer.share")}
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
iconStart={<ShareNetworkIcon />}
|
||||||
|
onClick={handleShare}
|
||||||
|
aria-label={t("viewer.share")}
|
||||||
|
>
|
||||||
|
<span className={slots.actionLabel()}>{t("viewer.share")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Separator orientation="vertical" className={slots.separator()} />
|
<Separator orientation="vertical" className={slots.separator()} />
|
||||||
<Button
|
<Button
|
||||||
@@ -173,8 +179,9 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP
|
|||||||
iconStart={<DownloadSimpleIcon />}
|
iconStart={<DownloadSimpleIcon />}
|
||||||
disabled={dataUri == null}
|
disabled={dataUri == null}
|
||||||
onClick={handleDownload}
|
onClick={handleDownload}
|
||||||
|
aria-label={t("viewer.download")}
|
||||||
>
|
>
|
||||||
{t("viewer.download")}
|
<span className={slots.actionLabel()}>{t("viewer.download")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function DocumentsToolbar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs value={tab} onValueChange={value => setTab(value as DocumentTab)}>
|
<Tabs value={tab} onValueChange={value => setTab(value as DocumentTab)}>
|
||||||
<TabsList>
|
<TabsList className="max-w-full overflow-x-auto">
|
||||||
{DOCUMENT_TABS.map(value => (
|
{DOCUMENT_TABS.map(value => (
|
||||||
<TabsTab key={value} value={value}>
|
<TabsTab key={value} value={value}>
|
||||||
{t(`tabs.${value}`)}
|
{t(`tabs.${value}`)}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { times } from "@probo/helpers";
|
|||||||
// eslint-disable-next-line import-x/default
|
// eslint-disable-next-line import-x/default
|
||||||
import workerSrc from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
import workerSrc from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||||
import type { ComponentRef, Ref } from "react";
|
import type { ComponentRef, Ref } from "react";
|
||||||
import { useImperativeHandle, useRef, useState } from "react";
|
import { useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||||
import { Document, Page, pdfjs } from "react-pdf";
|
import { Document, Page, pdfjs } from "react-pdf";
|
||||||
|
|
||||||
import { pdfPreview } from "./variants";
|
import { pdfPreview } from "./variants";
|
||||||
@@ -37,6 +37,9 @@ import { pdfPreview } from "./variants";
|
|||||||
// it from a CDN, so the viewer works under a strict trust-center CSP.
|
// it from a CDN, so the viewer works under a strict trust-center CSP.
|
||||||
pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
|
pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
|
||||||
|
|
||||||
|
// Horizontal inset so pages don't kiss the viewport edge on phones.
|
||||||
|
const PAGE_GUTTER_PX = 32;
|
||||||
|
|
||||||
export interface PdfPreviewHandle {
|
export interface PdfPreviewHandle {
|
||||||
scrollToPage: (page: number) => void;
|
scrollToPage: (page: number) => void;
|
||||||
}
|
}
|
||||||
@@ -44,7 +47,7 @@ export interface PdfPreviewHandle {
|
|||||||
interface PdfPreviewProps {
|
interface PdfPreviewProps {
|
||||||
// Base64 data URI of the PDF to render.
|
// Base64 data URI of the PDF to render.
|
||||||
file: string;
|
file: string;
|
||||||
// Zoom factor applied to every page.
|
// Zoom factor applied on top of fit-to-width sizing.
|
||||||
scale: number;
|
scale: number;
|
||||||
// Handle exposing imperative page navigation to the toolbar.
|
// Handle exposing imperative page navigation to the toolbar.
|
||||||
ref?: Ref<PdfPreviewHandle>;
|
ref?: Ref<PdfPreviewHandle>;
|
||||||
@@ -54,11 +57,12 @@ interface PdfPreviewProps {
|
|||||||
onVisiblePageChange: (page: number) => void;
|
onVisiblePageChange: (page: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scrollable react-pdf renderer, controlled by the viewer toolbar: it takes the
|
// Scrollable react-pdf renderer, controlled by the viewer toolbar: pages fit
|
||||||
// zoom `scale`, reports the page count and the visible page, and exposes an
|
// the viewport width by default, then the zoom `scale` multiplies that width.
|
||||||
// imperative `scrollToPage` for the page-navigation buttons.
|
// Reports the page count and the visible page, and exposes `scrollToPage`.
|
||||||
export function PdfPreview({ file, scale, ref, onNumPages, onVisiblePageChange }: PdfPreviewProps) {
|
export function PdfPreview({ file, scale, ref, onNumPages, onVisiblePageChange }: PdfPreviewProps) {
|
||||||
const [numPages, setNumPages] = useState(0);
|
const [numPages, setNumPages] = useState(0);
|
||||||
|
const [pageWidth, setPageWidth] = useState<number | null>(null);
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const documentRef = useRef<ComponentRef<typeof Document>>(null);
|
const documentRef = useRef<ComponentRef<typeof Document>>(null);
|
||||||
|
|
||||||
@@ -69,6 +73,22 @@ export function PdfPreview({ file, scale, ref, onNumPages, onVisiblePageChange }
|
|||||||
},
|
},
|
||||||
}), []);
|
}), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const wrapper = wrapperRef.current;
|
||||||
|
if (!wrapper) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateWidth = () => {
|
||||||
|
setPageWidth(Math.max(wrapper.clientWidth - PAGE_GUTTER_PX, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateWidth();
|
||||||
|
const observer = new ResizeObserver(updateWidth);
|
||||||
|
observer.observe(wrapper);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const resolveVisiblePage = () => {
|
const resolveVisiblePage = () => {
|
||||||
const wrapper = wrapperRef.current;
|
const wrapper = wrapperRef.current;
|
||||||
const pages = documentRef.current?.pages.current;
|
const pages = documentRef.current?.pages.current;
|
||||||
@@ -104,9 +124,17 @@ export function PdfPreview({ file, scale, ref, onNumPages, onVisiblePageChange }
|
|||||||
onVisiblePageChange(1);
|
onVisiblePageChange(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{times(numPages, index => (
|
{pageWidth != null
|
||||||
<Page key={index} pageNumber={index + 1} scale={scale} className={slots.page()} />
|
? times(numPages, index => (
|
||||||
))}
|
<Page
|
||||||
|
key={index}
|
||||||
|
pageNumber={index + 1}
|
||||||
|
width={pageWidth}
|
||||||
|
scale={scale}
|
||||||
|
className={slots.page()}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
</Document>
|
</Document>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,17 +45,20 @@ export const pdfPreview = tv({
|
|||||||
// scrollable grey stage for the PDF / image / download-fallback body.
|
// scrollable grey stage for the PDF / image / download-fallback body.
|
||||||
export const documentViewer = tv({
|
export const documentViewer = tv({
|
||||||
slots: {
|
slots: {
|
||||||
root: "flex h-full flex-col",
|
root: "flex h-full min-h-0 flex-1 flex-col",
|
||||||
header: "flex w-full flex-col gap-3",
|
header: "flex w-full flex-col gap-3",
|
||||||
back: "-ml-2 self-start",
|
back: "-ml-2 self-start",
|
||||||
toolbar: "flex min-h-16 items-center justify-between gap-4",
|
// Stay on one row so page/zoom controls and share/download share a baseline;
|
||||||
toolbarStart: "flex items-center gap-2",
|
// 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",
|
controls: "flex items-center gap-1",
|
||||||
actions: "flex items-center gap-2",
|
actions: "flex shrink-0 items-center gap-2",
|
||||||
separator: "h-6",
|
actionLabel: "max-sm:hidden",
|
||||||
|
separator: "h-6 max-sm:hidden",
|
||||||
body: "min-h-0 flex-1",
|
body: "min-h-0 flex-1",
|
||||||
stage: "grid h-full place-items-center bg-sand-3",
|
stage: "grid h-full place-items-center bg-sand-3",
|
||||||
imageStage: "grid h-full place-items-center overflow-auto bg-sand-3 p-8",
|
imageStage: "grid h-full place-items-center overflow-auto bg-sand-3 p-8 max-md:p-4",
|
||||||
image: "max-h-full max-w-full object-contain shadow-3",
|
image: "max-h-full max-w-full object-contain shadow-3",
|
||||||
spinner: "size-6 animate-spin text-sand-a10",
|
spinner: "size-6 animate-spin text-sand-a10",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { tv } from "tailwind-variants/lite";
|
|||||||
// `busy` variant dims the current results while a filtered slice refetches.
|
// `busy` variant dims the current results while a filtered slice refetches.
|
||||||
export const documentsLayout = tv({
|
export const documentsLayout = tv({
|
||||||
slots: {
|
slots: {
|
||||||
page: "flex w-full flex-col items-center px-8 py-8",
|
page: "flex w-full flex-col items-center px-8 py-8 max-md:px-4",
|
||||||
results: "flex w-full max-w-5xl flex-col gap-8 transition-opacity duration-150",
|
results: "flex w-full max-w-5xl flex-col gap-8 transition-opacity duration-150",
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ export const ndaPage = tv({
|
|||||||
root: "flex h-dvh flex-col",
|
root: "flex h-dvh flex-col",
|
||||||
header: "flex w-full flex-col gap-3",
|
header: "flex w-full flex-col gap-3",
|
||||||
text: "flex flex-col gap-1",
|
text: "flex flex-col gap-1",
|
||||||
toolbar: "flex min-h-16 items-center justify-between gap-4",
|
toolbar: "flex min-h-16 flex-wrap items-center justify-between gap-x-4 gap-y-2",
|
||||||
toolbarStart: "flex items-center gap-2",
|
toolbarStart: "flex min-w-0 flex-wrap items-center gap-2",
|
||||||
controls: "flex items-center gap-1",
|
controls: "flex items-center gap-1",
|
||||||
separator: "h-6",
|
separator: "h-6 max-sm:hidden",
|
||||||
consent: "max-w-2xl",
|
consent: "max-w-2xl",
|
||||||
actions: "flex shrink-0 items-center gap-2",
|
actions: "flex shrink-0 items-center gap-2",
|
||||||
body: "min-h-0 flex-1",
|
body: "min-h-0 flex-1",
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ export function RightsRequestListItem({ rightsRequestKey }: RightsRequestListIte
|
|||||||
const { icon, subline, trailing } = rightsRequestList();
|
const { icon, subline, trailing } = rightsRequestList();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListItem>
|
<ListItem className="max-sm:flex-col max-sm:items-stretch max-sm:gap-2">
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||||
<span className={icon()}>
|
<span className={icon()}>
|
||||||
{getRightsRequestTypeIcon(request.requestType)}
|
{getRightsRequestTypeIcon(request.requestType)}
|
||||||
</span>
|
</span>
|
||||||
@@ -80,6 +81,7 @@ export function RightsRequestListItem({ rightsRequestKey }: RightsRequestListIte
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ListItemContent>
|
</ListItemContent>
|
||||||
|
</div>
|
||||||
<div className={trailing()}>
|
<div className={trailing()}>
|
||||||
<Text size={1} color="faint">
|
<Text size={1} color="faint">
|
||||||
{formatRelativeTime(request.createdAt, i18n.language)}
|
{formatRelativeTime(request.createdAt, i18n.language)}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const rightsRequestList = tv({
|
|||||||
slots: {
|
slots: {
|
||||||
icon: "flex size-9 shrink-0 items-center justify-center rounded-3 bg-sand-3 text-sand-a11 [&_svg]:size-4",
|
icon: "flex size-9 shrink-0 items-center justify-center rounded-3 bg-sand-3 text-sand-a11 [&_svg]:size-4",
|
||||||
subline: "flex min-w-0 items-center gap-1.5",
|
subline: "flex min-w-0 items-center gap-1.5",
|
||||||
trailing: "flex shrink-0 items-center gap-3",
|
trailing: "flex shrink-0 items-center gap-3 max-sm:self-start",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { tv } from "tailwind-variants/lite";
|
|||||||
// Data requests page shell: a centered content column below the header band.
|
// Data requests page shell: a centered content column below the header band.
|
||||||
export const requestsLayout = tv({
|
export const requestsLayout = tv({
|
||||||
slots: {
|
slots: {
|
||||||
page: "flex w-full flex-col items-center px-8 py-8",
|
page: "flex w-full flex-col items-center px-8 py-8 max-md:px-4",
|
||||||
results: "flex w-full max-w-5xl flex-col gap-6",
|
results: "flex w-full max-w-5xl flex-col gap-6",
|
||||||
loadMore: "flex justify-center",
|
loadMore: "flex justify-center",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function SubprocessorsPage({ queryRef }: SubprocessorsPageProps) {
|
|||||||
<PageHeader title={t("title")} count={subprocessors.totalCount} flushBottomSpace>
|
<PageHeader title={t("title")} count={subprocessors.totalCount} flushBottomSpace>
|
||||||
<SubprocessorsToolbar queryKey={root} />
|
<SubprocessorsToolbar queryKey={root} />
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<div className="flex w-full flex-col items-center px-8 py-8">
|
<div className="flex w-full flex-col items-center px-8 py-8 max-md:px-4">
|
||||||
<div
|
<div
|
||||||
aria-busy={isRefetching}
|
aria-busy={isRefetching}
|
||||||
className={`flex w-full max-w-5xl flex-col gap-8 transition-opacity duration-150 ${isRefetching ? "opacity-60" : ""}`}
|
className={`flex w-full max-w-5xl flex-col gap-8 transition-opacity duration-150 ${isRefetching ? "opacity-60" : ""}`}
|
||||||
|
|||||||
@@ -33,17 +33,17 @@ export function SubprocessorsPageSkeleton() {
|
|||||||
<HeaderBand flushBottomSpace>
|
<HeaderBand flushBottomSpace>
|
||||||
<div className="flex w-full flex-col gap-2">
|
<div className="flex w-full flex-col gap-2">
|
||||||
<HeadingSkeleton size={7} className="w-64" />
|
<HeadingSkeleton size={7} className="w-64" />
|
||||||
<div className="flex min-h-16 items-center gap-3">
|
<div className="flex min-h-16 flex-wrap items-center gap-3 max-sm:pb-8">
|
||||||
<SelectSkeleton />
|
<SelectSkeleton />
|
||||||
<SelectSkeleton />
|
<SelectSkeleton />
|
||||||
<TextFieldSkeleton />
|
<TextFieldSkeleton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HeaderBand>
|
</HeaderBand>
|
||||||
<div className="flex w-full flex-col items-center px-8 py-8">
|
<div className="flex w-full flex-col items-center px-8 py-8 max-md:px-4">
|
||||||
<div className="flex w-full max-w-5xl flex-col gap-4">
|
<div className="flex w-full max-w-5xl flex-col gap-4">
|
||||||
<TextSkeleton size={3} className="w-48" />
|
<TextSkeleton size={3} className="w-48" />
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-3 gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1">
|
||||||
{CARD_PLACEHOLDERS.map(placeholder => (
|
{CARD_PLACEHOLDERS.map(placeholder => (
|
||||||
<div key={placeholder} className="h-56 animate-pulse rounded-5 bg-sand-3" />
|
<div key={placeholder} className="h-56 animate-pulse rounded-5 bg-sand-3" />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function SubprocessorCategorySection({ category, subprocessors }: Subproc
|
|||||||
{t(`categories.${category}.description`)}
|
{t(`categories.${category}.description`)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-3 gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1">
|
||||||
{subprocessors.map(subprocessor => (
|
{subprocessors.map(subprocessor => (
|
||||||
<SubprocessorListItem key={subprocessor.id} subprocessorKey={subprocessor} />
|
<SubprocessorListItem key={subprocessor.id} subprocessorKey={subprocessor} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -69,8 +69,11 @@ export function SubprocessorsToolbar({ queryKey }: SubprocessorsToolbarProps) {
|
|||||||
}, [subprocessorCountries, countryLabel]);
|
}, [subprocessorCountries, countryLabel]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-16 flex-wrap items-center gap-3">
|
// flushBottomSpace drops the band's bottom padding so the desktop toolbar
|
||||||
<div className="w-40">
|
// sits on the header edge; restore that padding on small screens where the
|
||||||
|
// controls stack into a column.
|
||||||
|
<div className="flex min-h-16 flex-wrap items-center gap-3 max-sm:pb-8">
|
||||||
|
<div className="w-40 max-sm:w-full">
|
||||||
<Select value={category || null} onValueChange={value => setCategory(value ?? "")}>
|
<Select value={category || null} onValueChange={value => setCategory(value ?? "")}>
|
||||||
<SelectTrigger placeholder={t("filters.allCategories")}>
|
<SelectTrigger placeholder={t("filters.allCategories")}>
|
||||||
{(value: string | null) => (value ? t(`categories.${value}.label`) : t("filters.allCategories"))}
|
{(value: string | null) => (value ? t(`categories.${value}.label`) : t("filters.allCategories"))}
|
||||||
@@ -83,7 +86,7 @@ export function SubprocessorsToolbar({ queryKey }: SubprocessorsToolbarProps) {
|
|||||||
</SelectPopup>
|
</SelectPopup>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-40">
|
<div className="w-40 max-sm:w-full">
|
||||||
<Select value={country || null} onValueChange={value => setCountry(value ?? "")}>
|
<Select value={country || null} onValueChange={value => setCountry(value ?? "")}>
|
||||||
<SelectTrigger placeholder={t("filters.allRegions")}>
|
<SelectTrigger placeholder={t("filters.allRegions")}>
|
||||||
{(value: string | null) => (value ? countryLabel(value) : t("filters.allRegions"))}
|
{(value: string | null) => (value ? countryLabel(value) : t("filters.allRegions"))}
|
||||||
@@ -96,7 +99,7 @@ export function SubprocessorsToolbar({ queryKey }: SubprocessorsToolbarProps) {
|
|||||||
</SelectPopup>
|
</SelectPopup>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-60">
|
<div className="min-w-60 flex-1 max-sm:w-full max-sm:min-w-0">
|
||||||
<TextField
|
<TextField
|
||||||
value={queryInput}
|
value={queryInput}
|
||||||
onValueChange={setQueryInput}
|
onValueChange={setQueryInput}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function UpdatesPage({ queryRef }: UpdatesPageProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title={t("title")} actions={isEmpty ? undefined : <UpdatesSubscribeButton />} />
|
<PageHeader title={t("title")} actions={isEmpty ? undefined : <UpdatesSubscribeButton />} />
|
||||||
<div className="flex w-full flex-col items-center px-8 py-8">
|
<div className="flex w-full flex-col items-center px-8 py-8 max-md:px-4">
|
||||||
<div className="w-full max-w-5xl">
|
<div className="w-full max-w-5xl">
|
||||||
{isEmpty
|
{isEmpty
|
||||||
? <UpdatesEmpty />
|
? <UpdatesEmpty />
|
||||||
|
|||||||
@@ -18,26 +18,32 @@
|
|||||||
// 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 { ButtonSkeleton } from "@probo/ui/src/v2/Button/ButtonSkeleton";
|
||||||
import { PaginationSkeleton } from "@probo/ui/src/v2/Pagination/PaginationSkeleton";
|
import { PaginationSkeleton } from "@probo/ui/src/v2/Pagination/PaginationSkeleton";
|
||||||
import { HeadingSkeleton } from "@probo/ui/src/v2/typography/HeadingSkeleton";
|
import { HeadingSkeleton } from "@probo/ui/src/v2/typography/HeadingSkeleton";
|
||||||
|
|
||||||
import { ComplianceArticleItemSkeleton } from "#/components/ComplianceArticleItem/ComplianceArticleItemSkeleton";
|
import { ComplianceArticleItemSkeleton } from "#/components/ComplianceArticleItem/ComplianceArticleItemSkeleton";
|
||||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||||
|
import { pageHeader } from "#/components/PageHeader/variants";
|
||||||
|
|
||||||
import { updatesList } from "./_components/variants";
|
import { updatesList } from "./_components/variants";
|
||||||
import { UPDATES_PAGE_SIZE } from "./_lib/constants";
|
import { UPDATES_PAGE_SIZE } from "./_lib/constants";
|
||||||
|
|
||||||
export function UpdatesPageSkeleton() {
|
export function UpdatesPageSkeleton() {
|
||||||
const { card, rows } = updatesList();
|
const { card, rows } = updatesList();
|
||||||
|
const header = pageHeader();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeaderBand>
|
<HeaderBand>
|
||||||
<div className="flex w-full flex-col gap-2">
|
<div className={header.content()}>
|
||||||
|
<div className={header.titleRow()}>
|
||||||
<HeadingSkeleton size={7} className="w-40" />
|
<HeadingSkeleton size={7} className="w-40" />
|
||||||
|
<ButtonSkeleton size={2} className="max-sm:w-full" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</HeaderBand>
|
</HeaderBand>
|
||||||
<div className="flex w-full flex-col items-center px-8 py-8">
|
<div className="flex w-full flex-col items-center px-8 py-8 max-md:px-4">
|
||||||
<div className="flex w-full max-w-5xl flex-col gap-8">
|
<div className="flex w-full max-w-5xl flex-col gap-8">
|
||||||
<div className={card()} aria-hidden>
|
<div className={card()} aria-hidden>
|
||||||
<div className={rows()}>
|
<div className={rows()}>
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ export const updatesList = tv({
|
|||||||
// Slots are shared by the detail page and its skeleton.
|
// Slots are shared by the detail page and its skeleton.
|
||||||
export const updateArticle = tv({
|
export const updateArticle = tv({
|
||||||
slots: {
|
slots: {
|
||||||
toolbar: "flex w-full items-center justify-between gap-4",
|
toolbar: "flex w-full items-center justify-between gap-4 max-sm:flex-col max-sm:items-stretch",
|
||||||
content: "flex w-full flex-col items-center px-8 py-8",
|
content: "flex w-full flex-col items-center px-8 py-8 max-md:px-4",
|
||||||
article: "flex w-full max-w-2xl flex-col gap-4",
|
article: "flex w-full max-w-2xl flex-col gap-4",
|
||||||
meta: "flex items-center gap-1.5",
|
meta: "flex items-center gap-1.5",
|
||||||
metaIcon: "size-4 text-gold-9",
|
metaIcon: "size-4 text-gold-9",
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export const dialog = tv({
|
|||||||
title: "text-4 font-medium text-sand-12",
|
title: "text-4 font-medium text-sand-12",
|
||||||
description: "text-2 text-sand-11",
|
description: "text-2 text-sand-11",
|
||||||
body: "px-6",
|
body: "px-6",
|
||||||
footer: "flex items-center justify-end gap-3 px-6",
|
footer: "flex flex-wrap items-center justify-end gap-3 px-6 max-sm:flex-col-reverse max-sm:items-stretch",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const errorState = tv({
|
|||||||
root: "flex w-full items-center justify-center",
|
root: "flex w-full items-center justify-center",
|
||||||
block: "flex min-w-64 max-w-md flex-col items-center gap-6 text-center",
|
block: "flex min-w-64 max-w-md flex-col items-center gap-6 text-center",
|
||||||
content: "flex w-full flex-col items-center gap-2",
|
content: "flex w-full flex-col items-center gap-2",
|
||||||
actions: "flex items-center justify-center gap-2",
|
actions: "flex flex-wrap items-center justify-center gap-2",
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
// Standalone fills the viewport; in-shell sits inside the app chrome.
|
// Standalone fills the viewport; in-shell sits inside the app chrome.
|
||||||
|
|||||||
Reference in New Issue
Block a user