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": {
|
||||
"tagline": "Compliance Portal",
|
||||
"getAccess": "Get Access",
|
||||
"openMenu": "Open menu",
|
||||
"closeMenu": "Close menu",
|
||||
"menuTitle": "Menu",
|
||||
"nav": {
|
||||
"documents": "Documents",
|
||||
"subprocessors": "Subprocessors",
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
"topBar": {
|
||||
"tagline": "Portail de conformité",
|
||||
"getAccess": "Obtenir l'accès",
|
||||
"openMenu": "Ouvrir le menu",
|
||||
"closeMenu": "Fermer le menu",
|
||||
"menuTitle": "Menu",
|
||||
"nav": {
|
||||
"documents": "Documents",
|
||||
"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.
|
||||
export const backdropCard = tv({
|
||||
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",
|
||||
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",
|
||||
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;
|
||||
// Optional accent sub-label below the title.
|
||||
eyebrow?: ReactNode;
|
||||
// Right-aligned metadata (e.g. relative time).
|
||||
// Trailing metadata on desktop; stacks under the title on small screens.
|
||||
meta?: ReactNode;
|
||||
}
|
||||
|
||||
@@ -43,20 +43,22 @@ export function ComplianceArticleItem({ icon, title, eyebrow, meta }: Compliance
|
||||
<div className={slots.root()}>
|
||||
<span className={slots.icon()}>{icon}</span>
|
||||
<div className={slots.content()}>
|
||||
<Text size={2} weight="medium" color="neutral" highContrast>
|
||||
{title}
|
||||
</Text>
|
||||
{eyebrow != null && (
|
||||
<Text size={1} color="gold">
|
||||
{eyebrow}
|
||||
<div className={slots.text()}>
|
||||
<Text size={2} weight="medium" color="neutral" highContrast className={slots.title()}>
|
||||
{title}
|
||||
</Text>
|
||||
{eyebrow != null && (
|
||||
<Text size={1} color="gold">
|
||||
{eyebrow}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{meta != null && (
|
||||
<Text size={1} color="faint" className={slots.meta()}>
|
||||
{meta}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{meta != null && (
|
||||
<Text size={1} color="faint" className={slots.meta()}>
|
||||
{meta}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton";
|
||||
import { complianceArticleItem } from "./variants";
|
||||
|
||||
// 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() {
|
||||
const slots = complianceArticleItem();
|
||||
|
||||
@@ -31,9 +31,11 @@ export function ComplianceArticleItemSkeleton() {
|
||||
<div className={slots.root()} aria-hidden>
|
||||
<div className={slots.iconPlaceholder()} />
|
||||
<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>
|
||||
<TextSkeleton size={1} className="w-20 shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,15 +21,16 @@
|
||||
import { tv } from "tailwind-variants/lite";
|
||||
|
||||
// Compliance article list row (Figma "Compliance Article Item"): a leading
|
||||
// icon, a title (+ optional eyebrow), and right-aligned meta. Designed to sit
|
||||
// inside a list container that draws the dividers (e.g. `divide-y`), so rows
|
||||
// stay divider-agnostic whether or not each is wrapped in a link. Slots are
|
||||
// icon, a title (+ optional eyebrow), and meta. Desktop keeps meta on the
|
||||
// trailing edge; mobile stacks it under the title with a tight gap. Slots are
|
||||
// shared by the row and its skeleton.
|
||||
export const complianceArticleItem = tv({
|
||||
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",
|
||||
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",
|
||||
iconPlaceholder: "size-6 shrink-0 animate-pulse rounded-2 bg-sand-3",
|
||||
},
|
||||
|
||||
@@ -80,7 +80,7 @@ function ComplianceFrameworksSectionContent({ trustCenterKey }: ComplianceFramew
|
||||
|
||||
return (
|
||||
<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 => (
|
||||
<ComplianceFrameworkListItem key={framework.id} complianceFrameworkKey={framework} />
|
||||
))}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function ComplianceFrameworksSectionSkeleton() {
|
||||
<div className={slots.header()}>
|
||||
<TextSkeleton size={2} className="w-24" />
|
||||
</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) => (
|
||||
<MediaTileSkeleton key={index} />
|
||||
))}
|
||||
|
||||
@@ -25,7 +25,7 @@ import { tv } from "tailwind-variants/lite";
|
||||
// Hero and the page headers so the band is defined once.
|
||||
export const headerBand = tv({
|
||||
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",
|
||||
},
|
||||
variants: {
|
||||
|
||||
@@ -36,8 +36,8 @@ export const hero = tv({
|
||||
export const organizationContactInfo = tv({
|
||||
slots: {
|
||||
// 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",
|
||||
item: "flex items-center gap-2 text-sand-11 [&_svg]:size-5",
|
||||
link: "flex items-center gap-2 text-sand-11 hover:underline [&_svg]:size-5",
|
||||
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 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 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({
|
||||
slots: {
|
||||
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)").
|
||||
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 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">
|
||||
{updates.map(update => (
|
||||
// Rows share fate (one connection query), so a row's field error
|
||||
|
||||
@@ -35,7 +35,7 @@ export function RecentUpdatesSectionSkeleton() {
|
||||
</div>
|
||||
<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 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">
|
||||
{Array.from({ length: 5 }, (_, index) => (
|
||||
<ComplianceArticleItemSkeleton key={index} />
|
||||
|
||||
@@ -27,6 +27,6 @@ export const securityCommitments = tv({
|
||||
root: "flex w-full flex-col gap-8 py-8",
|
||||
group: "flex w-full flex-col gap-4",
|
||||
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 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 { 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`
|
||||
fragment TopBar_query on Query {
|
||||
viewer {
|
||||
...TopBarUserMenu_identity
|
||||
...TopBarMobileNav_identity
|
||||
}
|
||||
currentTrustCenter @required(action: THROW) {
|
||||
themedLogoUrl
|
||||
@@ -88,10 +84,10 @@ export function TopBar({ queryKey }: TopBarProps) {
|
||||
fallback={organizationName.charAt(0) || "?"}
|
||||
className={slots.logo()}
|
||||
/>
|
||||
<Text size={2} weight="medium" color="neutral" highContrast>
|
||||
<Text size={2} weight="medium" color="neutral" highContrast className={slots.brandName()}>
|
||||
{organizationName}
|
||||
</Text>
|
||||
<Text size={2} color="neutral">
|
||||
<Text size={2} color="neutral" className={slots.tagline()}>
|
||||
{t("topBar.tagline")}
|
||||
</Text>
|
||||
</RouterLink>
|
||||
@@ -99,7 +95,7 @@ export function TopBar({ queryKey }: TopBarProps) {
|
||||
<div className={slots.spacer()} />
|
||||
|
||||
<nav className={slots.nav()}>
|
||||
{NAV_ITEMS.map(item => (
|
||||
{TOP_BAR_NAV_ITEMS.map(item => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
@@ -125,6 +121,8 @@ export function TopBar({ queryKey }: TopBarProps) {
|
||||
)
|
||||
: <TopBarUserMenu identityKey={data.viewer} />}
|
||||
</nav>
|
||||
|
||||
<TopBarMobileNav identityKey={data.viewer ?? null} />
|
||||
</div>
|
||||
</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.brand()}>
|
||||
<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 className={slots.spacer()} />
|
||||
@@ -54,6 +55,10 @@ export function TopBarSkeleton() {
|
||||
))}
|
||||
<ButtonSkeleton size={2} />
|
||||
</nav>
|
||||
|
||||
<div className={slots.menuTrigger()}>
|
||||
<div className="size-8 animate-pulse rounded-2 bg-sand-3" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -76,10 +76,10 @@ export function TopBarUserMenu({ identityKey }: TopBarUserMenuProps) {
|
||||
radius="small"
|
||||
fallback={<UserIcon />}
|
||||
/>
|
||||
<Text size={2} weight="medium" color="neutral" highContrast>
|
||||
<Text size={2} weight="medium" color="neutral" highContrast className="max-w-36 truncate">
|
||||
{displayName}
|
||||
</Text>
|
||||
<CaretDownIcon className="size-4 text-sand-11" />
|
||||
<CaretDownIcon className="size-4 shrink-0 text-sand-11" />
|
||||
</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";
|
||||
|
||||
// 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({
|
||||
slots: {
|
||||
bar: "flex h-14 items-center bg-sand-1 px-8",
|
||||
inner: "mx-auto flex w-full max-w-5xl items-center gap-10",
|
||||
brand: "flex items-center gap-2",
|
||||
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 max-md:gap-3",
|
||||
brand: "flex min-w-0 items-center gap-2",
|
||||
brandName: "truncate",
|
||||
tagline: "max-md:hidden",
|
||||
logo: "shrink-0",
|
||||
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",
|
||||
],
|
||||
});
|
||||
|
||||
// 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 (
|
||||
<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 => (
|
||||
<TrustCenterReferenceListItem key={reference.id} referenceKey={reference} />
|
||||
))}
|
||||
|
||||
@@ -31,7 +31,7 @@ export function TrustedBySectionSkeleton() {
|
||||
<div className={slots.header()}>
|
||||
<TextSkeleton size={2} className="w-20" />
|
||||
</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) => (
|
||||
<MediaTileSkeleton key={index} variant="logo" />
|
||||
))}
|
||||
|
||||
@@ -64,7 +64,7 @@ export function HomePage({ queryRef }: HomePageProps) {
|
||||
>
|
||||
<OrganizationContactInfo organizationKey={organization} />
|
||||
</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">
|
||||
<ComplianceFrameworksSection trustCenterKey={currentTrustCenter} />
|
||||
<SecurityCommitmentsSection trustCenterKey={currentTrustCenter} />
|
||||
|
||||
@@ -28,7 +28,7 @@ export function HomePageSkeleton() {
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<ComplianceFrameworksSectionSkeleton />
|
||||
<SecurityCommitmentsSectionSkeleton />
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { PreloadedQuery } 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 { TopBar } from "#/components/TopBar/TopBar";
|
||||
@@ -48,22 +48,35 @@ interface MainLayoutProps {
|
||||
export function MainLayout({ queryRef }: MainLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
const data = usePreloadedQuery<MainLayoutQuery>(mainLayoutQuery, queryRef);
|
||||
const isDocumentViewer = useMatch("documents/:alias") != null;
|
||||
|
||||
// Resume a deferred "request access" once the user lands back authenticated.
|
||||
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 (
|
||||
// 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>
|
||||
<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} />
|
||||
<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 />
|
||||
</div>
|
||||
<PoweredBy label={t("footer.poweredBy")} />
|
||||
{isDocumentViewer ? null : <PoweredBy label={t("footer.poweredBy")} />}
|
||||
</div>
|
||||
</SubscribeDialogProvider>
|
||||
</SignInDialogProvider>
|
||||
|
||||
@@ -22,8 +22,9 @@ import { TopBarSkeleton } from "#/components/TopBar/TopBarSkeleton";
|
||||
|
||||
export function MainLayoutSkeleton() {
|
||||
return (
|
||||
<div className="min-h-screen bg-sand-2">
|
||||
<div className="flex min-h-dvh flex-col bg-sand-2">
|
||||
<TopBarSkeleton />
|
||||
<div className="flex flex-1 flex-col" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// 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 { Link } from "@probo/ui/src/v2/Button/Link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -34,6 +34,30 @@ interface DocumentAccessActionProps {
|
||||
onGetAccess: () => void;
|
||||
// Whether the access request is in flight.
|
||||
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
|
||||
@@ -45,12 +69,31 @@ export function DocumentAccessAction({
|
||||
viewHref,
|
||||
onGetAccess,
|
||||
isRequesting,
|
||||
interactive = true,
|
||||
}: DocumentAccessActionProps) {
|
||||
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) {
|
||||
return (
|
||||
<Link to={viewHref} variant="ghost" color="neutral" highContrast iconStart={<ArrowRightIcon />}>
|
||||
<Link
|
||||
to={viewHref}
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
highContrast
|
||||
iconStart={<ArrowRightIcon />}
|
||||
>
|
||||
{t("actions.view")}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,8 @@ 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";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link as RouterLink } from "react-router";
|
||||
|
||||
import { DocumentAccessAction } from "./DocumentAccessAction";
|
||||
|
||||
@@ -43,8 +45,8 @@ interface DocumentEntryProps {
|
||||
}
|
||||
|
||||
// Presentational row shared by the document / file / report list items: a title
|
||||
// with accent metadata and the trailing access action. The connection-item
|
||||
// wrappers own their fragments and supply these values.
|
||||
// with accent metadata and the trailing access action. On small screens the
|
||||
// whole row is the hit target (the trailing icon is a status affordance only).
|
||||
export function DocumentEntry({
|
||||
title,
|
||||
meta,
|
||||
@@ -54,8 +56,21 @@ export function DocumentEntry({
|
||||
onGetAccess,
|
||||
isRequesting,
|
||||
}: DocumentEntryProps) {
|
||||
const { t } = useTranslation("documents");
|
||||
|
||||
const mobileHitLabel = requested
|
||||
? null
|
||||
: isAuthorized
|
||||
? t("actions.view")
|
||||
: t("actions.getAccess");
|
||||
|
||||
return (
|
||||
<ListItem>
|
||||
<ListItem
|
||||
className={[
|
||||
"relative",
|
||||
mobileHitLabel != null ? "max-sm:cursor-pointer max-sm:hover:bg-sand-2" : "",
|
||||
].filter(Boolean).join(" ")}
|
||||
>
|
||||
<ListItemContent>
|
||||
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
|
||||
{title}
|
||||
@@ -64,13 +79,50 @@ export function DocumentEntry({
|
||||
{meta}
|
||||
</Text>
|
||||
</ListItemContent>
|
||||
<DocumentAccessAction
|
||||
isAuthorized={isAuthorized}
|
||||
requested={requested}
|
||||
viewHref={viewHref}
|
||||
onGetAccess={onGetAccess}
|
||||
isRequesting={isRequesting}
|
||||
/>
|
||||
|
||||
{/* Desktop: labeled interactive control. */}
|
||||
<div className="max-sm:hidden">
|
||||
<DocumentAccessAction
|
||||
isAuthorized={isAuthorized}
|
||||
requested={requested}
|
||||
viewHref={viewHref}
|
||||
onGetAccess={onGetAccess}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,8 +163,14 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP
|
||||
)}
|
||||
</div>
|
||||
<div className={slots.actions()}>
|
||||
<Button variant="ghost" color="neutral" iconStart={<ShareNetworkIcon />} onClick={handleShare}>
|
||||
{t("viewer.share")}
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
iconStart={<ShareNetworkIcon />}
|
||||
onClick={handleShare}
|
||||
aria-label={t("viewer.share")}
|
||||
>
|
||||
<span className={slots.actionLabel()}>{t("viewer.share")}</span>
|
||||
</Button>
|
||||
<Separator orientation="vertical" className={slots.separator()} />
|
||||
<Button
|
||||
@@ -173,8 +179,9 @@ export function DocumentViewer({ title, dataUri, downloadName }: DocumentViewerP
|
||||
iconStart={<DownloadSimpleIcon />}
|
||||
disabled={dataUri == null}
|
||||
onClick={handleDownload}
|
||||
aria-label={t("viewer.download")}
|
||||
>
|
||||
{t("viewer.download")}
|
||||
<span className={slots.actionLabel()}>{t("viewer.download")}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function DocumentsToolbar() {
|
||||
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={value => setTab(value as DocumentTab)}>
|
||||
<TabsList>
|
||||
<TabsList className="max-w-full overflow-x-auto">
|
||||
{DOCUMENT_TABS.map(value => (
|
||||
<TabsTab key={value} value={value}>
|
||||
{t(`tabs.${value}`)}
|
||||
|
||||
@@ -28,7 +28,7 @@ import { times } from "@probo/helpers";
|
||||
// eslint-disable-next-line import-x/default
|
||||
import workerSrc from "pdfjs-dist/build/pdf.worker.min.mjs?url";
|
||||
import type { ComponentRef, Ref } from "react";
|
||||
import { useImperativeHandle, useRef, useState } from "react";
|
||||
import { useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { Document, Page, pdfjs } from "react-pdf";
|
||||
|
||||
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.
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerSrc;
|
||||
|
||||
// Horizontal inset so pages don't kiss the viewport edge on phones.
|
||||
const PAGE_GUTTER_PX = 32;
|
||||
|
||||
export interface PdfPreviewHandle {
|
||||
scrollToPage: (page: number) => void;
|
||||
}
|
||||
@@ -44,7 +47,7 @@ export interface PdfPreviewHandle {
|
||||
interface PdfPreviewProps {
|
||||
// Base64 data URI of the PDF to render.
|
||||
file: string;
|
||||
// Zoom factor applied to every page.
|
||||
// Zoom factor applied on top of fit-to-width sizing.
|
||||
scale: number;
|
||||
// Handle exposing imperative page navigation to the toolbar.
|
||||
ref?: Ref<PdfPreviewHandle>;
|
||||
@@ -54,11 +57,12 @@ interface PdfPreviewProps {
|
||||
onVisiblePageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
// Scrollable react-pdf renderer, controlled by the viewer toolbar: it takes the
|
||||
// zoom `scale`, reports the page count and the visible page, and exposes an
|
||||
// imperative `scrollToPage` for the page-navigation buttons.
|
||||
// Scrollable react-pdf renderer, controlled by the viewer toolbar: pages fit
|
||||
// the viewport width by default, then the zoom `scale` multiplies that width.
|
||||
// Reports the page count and the visible page, and exposes `scrollToPage`.
|
||||
export function PdfPreview({ file, scale, ref, onNumPages, onVisiblePageChange }: PdfPreviewProps) {
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const [pageWidth, setPageWidth] = useState<number | null>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(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 wrapper = wrapperRef.current;
|
||||
const pages = documentRef.current?.pages.current;
|
||||
@@ -104,9 +124,17 @@ export function PdfPreview({ file, scale, ref, onNumPages, onVisiblePageChange }
|
||||
onVisiblePageChange(1);
|
||||
}}
|
||||
>
|
||||
{times(numPages, index => (
|
||||
<Page key={index} pageNumber={index + 1} scale={scale} className={slots.page()} />
|
||||
))}
|
||||
{pageWidth != null
|
||||
? times(numPages, index => (
|
||||
<Page
|
||||
key={index}
|
||||
pageNumber={index + 1}
|
||||
width={pageWidth}
|
||||
scale={scale}
|
||||
className={slots.page()}
|
||||
/>
|
||||
))
|
||||
: null}
|
||||
</Document>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,17 +45,20 @@ export const pdfPreview = tv({
|
||||
// scrollable grey stage for the PDF / image / download-fallback body.
|
||||
export const documentViewer = tv({
|
||||
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",
|
||||
back: "-ml-2 self-start",
|
||||
toolbar: "flex min-h-16 items-center justify-between gap-4",
|
||||
toolbarStart: "flex items-center gap-2",
|
||||
// Stay on one row so page/zoom controls and share/download share a baseline;
|
||||
// icon-only action labels on max-sm keep this viable on phones.
|
||||
toolbar: "flex min-h-16 flex-wrap items-center justify-between gap-x-4 gap-y-2",
|
||||
toolbarStart: "flex min-w-0 flex-wrap items-center gap-2",
|
||||
controls: "flex items-center gap-1",
|
||||
actions: "flex items-center gap-2",
|
||||
separator: "h-6",
|
||||
actions: "flex shrink-0 items-center gap-2",
|
||||
actionLabel: "max-sm:hidden",
|
||||
separator: "h-6 max-sm:hidden",
|
||||
body: "min-h-0 flex-1",
|
||||
stage: "grid h-full place-items-center bg-sand-3",
|
||||
imageStage: "grid h-full place-items-center overflow-auto bg-sand-3 p-8",
|
||||
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",
|
||||
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.
|
||||
export const documentsLayout = tv({
|
||||
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",
|
||||
},
|
||||
variants: {
|
||||
|
||||
@@ -27,10 +27,10 @@ export const ndaPage = tv({
|
||||
root: "flex h-dvh flex-col",
|
||||
header: "flex w-full flex-col gap-3",
|
||||
text: "flex flex-col gap-1",
|
||||
toolbar: "flex min-h-16 items-center justify-between gap-4",
|
||||
toolbarStart: "flex items-center gap-2",
|
||||
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",
|
||||
separator: "h-6",
|
||||
separator: "h-6 max-sm:hidden",
|
||||
consent: "max-w-2xl",
|
||||
actions: "flex shrink-0 items-center gap-2",
|
||||
body: "min-h-0 flex-1",
|
||||
|
||||
@@ -61,25 +61,27 @@ export function RightsRequestListItem({ rightsRequestKey }: RightsRequestListIte
|
||||
const { icon, subline, trailing } = rightsRequestList();
|
||||
|
||||
return (
|
||||
<ListItem>
|
||||
<span className={icon()}>
|
||||
{getRightsRequestTypeIcon(request.requestType)}
|
||||
</span>
|
||||
<ListItemContent>
|
||||
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
|
||||
{t(`types.${request.requestType}`)}
|
||||
</Text>
|
||||
<div className={subline()}>
|
||||
<Text size={1} color="gold">
|
||||
{reference}
|
||||
<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()}>
|
||||
{getRightsRequestTypeIcon(request.requestType)}
|
||||
</span>
|
||||
<ListItemContent>
|
||||
<Text size={2} weight="medium" color="neutral" highContrast className="truncate">
|
||||
{t(`types.${request.requestType}`)}
|
||||
</Text>
|
||||
{request.actionTaken != null && request.actionTaken !== "" && (
|
||||
<Text size={1} color="faint" className="truncate">
|
||||
{`· ${request.actionTaken}`}
|
||||
<div className={subline()}>
|
||||
<Text size={1} color="gold">
|
||||
{reference}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</ListItemContent>
|
||||
{request.actionTaken != null && request.actionTaken !== "" && (
|
||||
<Text size={1} color="faint" className="truncate">
|
||||
{`· ${request.actionTaken}`}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</ListItemContent>
|
||||
</div>
|
||||
<div className={trailing()}>
|
||||
<Text size={1} color="faint">
|
||||
{formatRelativeTime(request.createdAt, i18n.language)}
|
||||
|
||||
@@ -26,7 +26,7 @@ export const rightsRequestList = tv({
|
||||
slots: {
|
||||
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",
|
||||
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.
|
||||
export const requestsLayout = tv({
|
||||
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",
|
||||
loadMore: "flex justify-center",
|
||||
},
|
||||
|
||||
@@ -107,7 +107,7 @@ export function SubprocessorsPage({ queryRef }: SubprocessorsPageProps) {
|
||||
<PageHeader title={t("title")} count={subprocessors.totalCount} flushBottomSpace>
|
||||
<SubprocessorsToolbar queryKey={root} />
|
||||
</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
|
||||
aria-busy={isRefetching}
|
||||
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>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<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 />
|
||||
<TextFieldSkeleton />
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<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 => (
|
||||
<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`)}
|
||||
</Text>
|
||||
</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 => (
|
||||
<SubprocessorListItem key={subprocessor.id} subprocessorKey={subprocessor} />
|
||||
))}
|
||||
|
||||
@@ -69,8 +69,11 @@ export function SubprocessorsToolbar({ queryKey }: SubprocessorsToolbarProps) {
|
||||
}, [subprocessorCountries, countryLabel]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-16 flex-wrap items-center gap-3">
|
||||
<div className="w-40">
|
||||
// flushBottomSpace drops the band's bottom padding so the desktop toolbar
|
||||
// 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 ?? "")}>
|
||||
<SelectTrigger placeholder={t("filters.allCategories")}>
|
||||
{(value: string | null) => (value ? t(`categories.${value}.label`) : t("filters.allCategories"))}
|
||||
@@ -83,7 +86,7 @@ export function SubprocessorsToolbar({ queryKey }: SubprocessorsToolbarProps) {
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<div className="w-40 max-sm:w-full">
|
||||
<Select value={country || null} onValueChange={value => setCountry(value ?? "")}>
|
||||
<SelectTrigger placeholder={t("filters.allRegions")}>
|
||||
{(value: string | null) => (value ? countryLabel(value) : t("filters.allRegions"))}
|
||||
@@ -96,7 +99,7 @@ export function SubprocessorsToolbar({ queryKey }: SubprocessorsToolbarProps) {
|
||||
</SelectPopup>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="w-60">
|
||||
<div className="min-w-60 flex-1 max-sm:w-full max-sm:min-w-0">
|
||||
<TextField
|
||||
value={queryInput}
|
||||
onValueChange={setQueryInput}
|
||||
|
||||
@@ -106,7 +106,7 @@ export function UpdatesPage({ queryRef }: UpdatesPageProps) {
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{isEmpty
|
||||
? <UpdatesEmpty />
|
||||
|
||||
@@ -18,26 +18,32 @@
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
import { ButtonSkeleton } from "@probo/ui/src/v2/Button/ButtonSkeleton";
|
||||
import { PaginationSkeleton } from "@probo/ui/src/v2/Pagination/PaginationSkeleton";
|
||||
import { HeadingSkeleton } from "@probo/ui/src/v2/typography/HeadingSkeleton";
|
||||
|
||||
import { ComplianceArticleItemSkeleton } from "#/components/ComplianceArticleItem/ComplianceArticleItemSkeleton";
|
||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||
import { pageHeader } from "#/components/PageHeader/variants";
|
||||
|
||||
import { updatesList } from "./_components/variants";
|
||||
import { UPDATES_PAGE_SIZE } from "./_lib/constants";
|
||||
|
||||
export function UpdatesPageSkeleton() {
|
||||
const { card, rows } = updatesList();
|
||||
const header = pageHeader();
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderBand>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<HeadingSkeleton size={7} className="w-40" />
|
||||
<div className={header.content()}>
|
||||
<div className={header.titleRow()}>
|
||||
<HeadingSkeleton size={7} className="w-40" />
|
||||
<ButtonSkeleton size={2} className="max-sm:w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</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={card()} aria-hidden>
|
||||
<div className={rows()}>
|
||||
|
||||
@@ -44,8 +44,8 @@ export const updatesList = tv({
|
||||
// Slots are shared by the detail page and its skeleton.
|
||||
export const updateArticle = tv({
|
||||
slots: {
|
||||
toolbar: "flex w-full items-center justify-between gap-4",
|
||||
content: "flex w-full flex-col items-center px-8 py-8",
|
||||
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 max-md:px-4",
|
||||
article: "flex w-full max-w-2xl flex-col gap-4",
|
||||
meta: "flex items-center gap-1.5",
|
||||
metaIcon: "size-4 text-gold-9",
|
||||
|
||||
@@ -43,7 +43,7 @@ export const dialog = tv({
|
||||
title: "text-4 font-medium text-sand-12",
|
||||
description: "text-2 text-sand-11",
|
||||
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",
|
||||
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",
|
||||
actions: "flex items-center justify-center gap-2",
|
||||
actions: "flex flex-wrap items-center justify-center gap-2",
|
||||
},
|
||||
variants: {
|
||||
// Standalone fills the viewport; in-shell sits inside the app chrome.
|
||||
|
||||
Reference in New Issue
Block a user