Refine updates pages styling and pagination
Generalize the cursor Prev/Next pagination hook into a reusable useCursorPagination in lib/relay, taking the page size as a parameter, and keep the updates page size (25) as a feature constant. Move the list card surface and its loading-dim state into tv variants behind an UpdatesList component, and lift the detail article layout and its gold metadata styling into shared variants, so the pages carry only placement classes. Skeletons reuse the same variants. Relocate the generic pager labels to the app-root namespace and expose Intl.DateTimeFormat options on the formatDate helper. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -38,5 +38,9 @@
|
|||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"poweredBy": "Powered by"
|
"poweredBy": "Powered by"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"previous": "Previous page",
|
||||||
|
"next": "Next page"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,5 +38,9 @@
|
|||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"poweredBy": "Propulsé par"
|
"poweredBy": "Propulsé par"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"previous": "Page précédente",
|
||||||
|
"next": "Page suivante"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,15 +12,23 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
// Locale-aware long date formatting via Intl.DateTimeFormat, e.g.
|
// Long-date defaults, e.g. "August 6, 2026". Callers can override or extend any
|
||||||
// "August 6, 2026". The locale must be the active i18next language so the output
|
// of these through the `options` argument.
|
||||||
// follows the UI.
|
const DEFAULT_OPTIONS: Intl.DateTimeFormatOptions = {
|
||||||
export function formatDate(date: Date | string | number, locale: string): string {
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Locale-aware date formatting via Intl.DateTimeFormat. The locale must be the
|
||||||
|
// active i18next language so the output follows the UI. `options` are merged
|
||||||
|
// over the long-date defaults, exposing the full Intl.DateTimeFormat surface.
|
||||||
|
export function formatDate(
|
||||||
|
date: Date | string | number,
|
||||||
|
locale: string,
|
||||||
|
options?: Intl.DateTimeFormatOptions,
|
||||||
|
): string {
|
||||||
const target = date instanceof Date ? date : new Date(date);
|
const target = date instanceof Date ? date : new Date(date);
|
||||||
|
|
||||||
return new Intl.DateTimeFormat(locale, {
|
return new Intl.DateTimeFormat(locale, { ...DEFAULT_OPTIONS, ...options }).format(target);
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
}).format(target);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,33 +14,36 @@
|
|||||||
|
|
||||||
import { useCallback, useTransition } from "react";
|
import { useCallback, useTransition } from "react";
|
||||||
|
|
||||||
// Page size for the cursor-paginated updates list. Matches the Figma list frame.
|
// The `pageInfo` shape a Relay connection exposes for bidirectional cursor
|
||||||
export const UPDATES_PAGE_SIZE = 10;
|
// pagination. Structurally compatible with generated connection page info.
|
||||||
|
export interface CursorPageInfo {
|
||||||
export interface UpdatesPageInfo {
|
|
||||||
hasPreviousPage: boolean;
|
hasPreviousPage: boolean;
|
||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
startCursor: string | null | undefined;
|
startCursor: string | null | undefined;
|
||||||
endCursor: string | null | undefined;
|
endCursor: string | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdatesPaginationVariables {
|
// The connection pagination arguments passed to a refetch.
|
||||||
|
export interface CursorPaginationVariables {
|
||||||
first?: number | null;
|
first?: number | null;
|
||||||
after?: string | null;
|
after?: string | null;
|
||||||
last?: number | null;
|
last?: number | null;
|
||||||
before?: string | null;
|
before?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RefetchUpdates = (variables: UpdatesPaginationVariables) => void;
|
type CursorRefetch = (variables: CursorPaginationVariables) => void;
|
||||||
|
|
||||||
// Cursor-based Prev/Next pagination for the updates list. Drives the connection
|
// Prev/Next pagination for a Relay cursor connection. Drives the refetch inside
|
||||||
// refetch inside a transition so the current page stays mounted (dimmed) while
|
// a transition so the current page stays mounted (dimmed) while the next one
|
||||||
// the next one loads. Enabled/disabled state comes from the server `pageInfo`,
|
// loads; Prev/Next availability comes from the server `pageInfo`, so it stays
|
||||||
// which stays correct however the page is reached. No page-number counter:
|
// correct however the page is reached. There is no page-number counter: cursor
|
||||||
// cursor pagination encodes a position, not an ordinal, so a reliable page
|
// pagination encodes a position, not an ordinal, so a reliable page index
|
||||||
// index (deep-linkable or refresh-safe) would need offset + totalCount, which
|
// (deep-linkable or refresh-safe) would need offset + totalCount.
|
||||||
// this API does not expose.
|
export function useCursorPagination(
|
||||||
export function useUpdatesPagination(refetch: RefetchUpdates, pageInfo: UpdatesPageInfo) {
|
refetch: CursorRefetch,
|
||||||
|
pageInfo: CursorPageInfo,
|
||||||
|
pageSize: number,
|
||||||
|
) {
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const goNext = useCallback(() => {
|
const goNext = useCallback(() => {
|
||||||
@@ -48,18 +51,18 @@ export function useUpdatesPagination(refetch: RefetchUpdates, pageInfo: UpdatesP
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
refetch({ first: UPDATES_PAGE_SIZE, after: pageInfo.endCursor, last: null, before: null });
|
refetch({ first: pageSize, after: pageInfo.endCursor, last: null, before: null });
|
||||||
});
|
});
|
||||||
}, [refetch, pageInfo.hasNextPage, pageInfo.endCursor]);
|
}, [refetch, pageSize, pageInfo.hasNextPage, pageInfo.endCursor]);
|
||||||
|
|
||||||
const goPrevious = useCallback(() => {
|
const goPrevious = useCallback(() => {
|
||||||
if (!pageInfo.hasPreviousPage || pageInfo.startCursor == null) {
|
if (!pageInfo.hasPreviousPage || pageInfo.startCursor == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
refetch({ first: null, after: null, last: UPDATES_PAGE_SIZE, before: pageInfo.startCursor });
|
refetch({ first: null, after: null, last: pageSize, before: pageInfo.startCursor });
|
||||||
});
|
});
|
||||||
}, [refetch, pageInfo.hasPreviousPage, pageInfo.startCursor]);
|
}, [refetch, pageSize, pageInfo.hasPreviousPage, pageInfo.startCursor]);
|
||||||
|
|
||||||
return { isPending, goPrevious, goNext };
|
return { isPending, goPrevious, goNext };
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,7 @@ import { formatDate } from "#/lib/datetime/formatDate";
|
|||||||
|
|
||||||
import type { UpdateDetailPageQuery } from "./__generated__/UpdateDetailPageQuery.graphql";
|
import type { UpdateDetailPageQuery } from "./__generated__/UpdateDetailPageQuery.graphql";
|
||||||
import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton";
|
import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton";
|
||||||
|
import { updateArticle } from "./_components/variants";
|
||||||
|
|
||||||
export const updateDetailPageQuery = graphql`
|
export const updateDetailPageQuery = graphql`
|
||||||
query UpdateDetailPageQuery($updateId: ID!) {
|
query UpdateDetailPageQuery($updateId: ID!) {
|
||||||
@@ -52,20 +53,22 @@ export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
|
|||||||
}
|
}
|
||||||
const update = data.node;
|
const update = data.node;
|
||||||
|
|
||||||
|
const { toolbar, content, article, meta, metaIcon, body } = updateArticle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeaderBand>
|
<HeaderBand>
|
||||||
<div className="flex w-full items-center justify-between gap-4">
|
<div className={toolbar()}>
|
||||||
<Link to="/updates" variant="soft" color="neutral" highContrast iconStart={<CaretLeftIcon />}>
|
<Link to="/updates" variant="soft" color="neutral" highContrast iconStart={<CaretLeftIcon />}>
|
||||||
{t("backToUpdates")}
|
{t("backToUpdates")}
|
||||||
</Link>
|
</Link>
|
||||||
<UpdatesSubscribeButton />
|
<UpdatesSubscribeButton />
|
||||||
</div>
|
</div>
|
||||||
</HeaderBand>
|
</HeaderBand>
|
||||||
<div className="flex w-full flex-col items-center px-8 py-8">
|
<div className={content()}>
|
||||||
<article className="flex w-full max-w-2xl flex-col gap-4">
|
<article className={article()}>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className={meta()}>
|
||||||
<NewspaperIcon weight="light" className="size-4 text-gold-9" />
|
<NewspaperIcon weight="light" className={metaIcon()} />
|
||||||
<Text size={1} color="gold">
|
<Text size={1} color="gold">
|
||||||
{formatDate(update.updatedAt, i18n.language)}
|
{formatDate(update.updatedAt, i18n.language)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -73,7 +76,7 @@ export function UpdateDetailPage({ queryRef }: UpdateDetailPageProps) {
|
|||||||
<Heading level={1} size={7} weight="medium" highContrast>
|
<Heading level={1} size={7} weight="medium" highContrast>
|
||||||
{update.title}
|
{update.title}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Text size={3} className="block whitespace-pre-wrap">
|
<Text size={3} className={body()}>
|
||||||
{update.body}
|
{update.body}
|
||||||
</Text>
|
</Text>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -18,24 +18,28 @@ import { TextSkeleton } from "@probo/ui/src/v2/typography/TextSkeleton";
|
|||||||
|
|
||||||
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
import { HeaderBand } from "#/components/HeaderBand/HeaderBand";
|
||||||
|
|
||||||
const BODY_PLACEHOLDERS = ["a", "b", "c", "d", "e"];
|
import { updateArticle } from "./_components/variants";
|
||||||
|
|
||||||
|
const BODY_LINE_COUNT = 5;
|
||||||
|
|
||||||
export function UpdateDetailPageSkeleton() {
|
export function UpdateDetailPageSkeleton() {
|
||||||
|
const { toolbar, content, article } = updateArticle();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeaderBand>
|
<HeaderBand>
|
||||||
<div className="flex w-full items-center justify-between gap-4">
|
<div className={toolbar()}>
|
||||||
<ButtonSkeleton size={2} />
|
<ButtonSkeleton size={2} />
|
||||||
<ButtonSkeleton size={2} />
|
<ButtonSkeleton size={2} />
|
||||||
</div>
|
</div>
|
||||||
</HeaderBand>
|
</HeaderBand>
|
||||||
<div className="flex w-full flex-col items-center px-8 py-8">
|
<div className={content()}>
|
||||||
<div className="flex w-full max-w-2xl flex-col gap-4" aria-hidden>
|
<div className={article()} aria-hidden>
|
||||||
<TextSkeleton size={1} className="w-28" />
|
<TextSkeleton size={1} className="w-28" />
|
||||||
<HeadingSkeleton size={7} className="w-96" />
|
<HeadingSkeleton size={7} className="w-96" />
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{BODY_PLACEHOLDERS.map(placeholder => (
|
{Array.from({ length: BODY_LINE_COUNT }, (_, index) => (
|
||||||
<TextSkeleton key={placeholder} size={3} className="w-full" />
|
<TextSkeleton key={index} size={3} className="w-full" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,14 +20,16 @@ import { graphql, usePreloadedQuery, useRefetchableFragment } from "react-relay"
|
|||||||
|
|
||||||
import { MailingListUpdateListItem } from "#/components/MailingListUpdateListItem/MailingListUpdateListItem";
|
import { MailingListUpdateListItem } from "#/components/MailingListUpdateListItem/MailingListUpdateListItem";
|
||||||
import { PageHeader } from "#/components/PageHeader/PageHeader";
|
import { PageHeader } from "#/components/PageHeader/PageHeader";
|
||||||
|
import type { CursorPaginationVariables } from "#/lib/relay/useCursorPagination";
|
||||||
|
import { useCursorPagination } from "#/lib/relay/useCursorPagination";
|
||||||
|
|
||||||
import type { UpdatesPage_query$key } from "./__generated__/UpdatesPage_query.graphql";
|
import type { UpdatesPage_query$key } from "./__generated__/UpdatesPage_query.graphql";
|
||||||
import type { UpdatesPageQuery } from "./__generated__/UpdatesPageQuery.graphql";
|
import type { UpdatesPageQuery } from "./__generated__/UpdatesPageQuery.graphql";
|
||||||
import type { UpdatesPageRefetchQuery } from "./__generated__/UpdatesPageRefetchQuery.graphql";
|
import type { UpdatesPageRefetchQuery } from "./__generated__/UpdatesPageRefetchQuery.graphql";
|
||||||
import { UpdatesEmpty } from "./_components/UpdatesEmpty";
|
import { UpdatesEmpty } from "./_components/UpdatesEmpty";
|
||||||
|
import { UpdatesList } from "./_components/UpdatesList";
|
||||||
import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton";
|
import { UpdatesSubscribeButton } from "./_components/UpdatesSubscribeButton";
|
||||||
import type { UpdatesPaginationVariables } from "./_lib/useUpdatesPagination";
|
import { UPDATES_PAGE_SIZE } from "./_lib/constants";
|
||||||
import { useUpdatesPagination } from "./_lib/useUpdatesPagination";
|
|
||||||
|
|
||||||
export const updatesPageQuery = graphql`
|
export const updatesPageQuery = graphql`
|
||||||
query UpdatesPageQuery($first: Int, $after: CursorKey, $last: Int, $before: CursorKey) {
|
query UpdatesPageQuery($first: Int, $after: CursorKey, $last: Int, $before: CursorKey) {
|
||||||
@@ -69,19 +71,20 @@ interface UpdatesPageProps {
|
|||||||
|
|
||||||
export function UpdatesPage({ queryRef }: UpdatesPageProps) {
|
export function UpdatesPage({ queryRef }: UpdatesPageProps) {
|
||||||
const { t } = useTranslation("updates");
|
const { t } = useTranslation("updates");
|
||||||
|
const { t: tCommon } = useTranslation();
|
||||||
const root = usePreloadedQuery<UpdatesPageQuery>(updatesPageQuery, queryRef);
|
const root = usePreloadedQuery<UpdatesPageQuery>(updatesPageQuery, queryRef);
|
||||||
const [data, refetch] = useRefetchableFragment<UpdatesPageRefetchQuery, UpdatesPage_query$key>(
|
const [data, refetch] = useRefetchableFragment<UpdatesPageRefetchQuery, UpdatesPage_query$key>(
|
||||||
updatesPageFragment,
|
updatesPageFragment,
|
||||||
root,
|
root,
|
||||||
);
|
);
|
||||||
|
|
||||||
const refetchUpdates = useCallback((variables: UpdatesPaginationVariables) => {
|
const refetchUpdates = useCallback((variables: CursorPaginationVariables) => {
|
||||||
refetch(variables, { fetchPolicy: "store-or-network" });
|
refetch(variables, { fetchPolicy: "store-or-network" });
|
||||||
}, [refetch]);
|
}, [refetch]);
|
||||||
|
|
||||||
const { updates } = data.currentTrustCenter;
|
const { updates } = data.currentTrustCenter;
|
||||||
const { pageInfo } = updates;
|
const { pageInfo } = updates;
|
||||||
const { isPending, goPrevious, goNext } = useUpdatesPagination(refetchUpdates, pageInfo);
|
const { isPending, goPrevious, goNext } = useCursorPagination(refetchUpdates, pageInfo, UPDATES_PAGE_SIZE);
|
||||||
|
|
||||||
const nodes = updates.edges.map(edge => edge.node);
|
const nodes = updates.edges.map(edge => edge.node);
|
||||||
const isEmpty = nodes.length === 0;
|
const isEmpty = nodes.length === 0;
|
||||||
@@ -95,21 +98,16 @@ export function UpdatesPage({ queryRef }: UpdatesPageProps) {
|
|||||||
? <UpdatesEmpty />
|
? <UpdatesEmpty />
|
||||||
: (
|
: (
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<div
|
<UpdatesList busy={isPending}>
|
||||||
aria-busy={isPending}
|
{nodes.map(node => (
|
||||||
className={`overflow-hidden rounded-5 border border-sand-3 bg-sand-1 transition-opacity duration-150 ${isPending ? "opacity-60" : ""}`}
|
<MailingListUpdateListItem key={node.id} updateKey={node} />
|
||||||
>
|
))}
|
||||||
<div className="divide-y divide-sand-a2">
|
</UpdatesList>
|
||||||
{nodes.map(node => (
|
|
||||||
<MailingListUpdateListItem key={node.id} updateKey={node} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Pagination
|
<Pagination
|
||||||
hasPrevious={pageInfo.hasPreviousPage}
|
hasPrevious={pageInfo.hasPreviousPage}
|
||||||
hasNext={pageInfo.hasNextPage}
|
hasNext={pageInfo.hasNextPage}
|
||||||
previousLabel={t("pagination.previous")}
|
previousLabel={tCommon("pagination.previous")}
|
||||||
nextLabel={t("pagination.next")}
|
nextLabel={tCommon("pagination.next")}
|
||||||
onPrevious={goPrevious}
|
onPrevious={goPrevious}
|
||||||
onNext={goNext}
|
onNext={goNext}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { useEffect } from "react";
|
|||||||
import { useQueryLoader } from "react-relay";
|
import { useQueryLoader } from "react-relay";
|
||||||
|
|
||||||
import type { UpdatesPageQuery } from "./__generated__/UpdatesPageQuery.graphql";
|
import type { UpdatesPageQuery } from "./__generated__/UpdatesPageQuery.graphql";
|
||||||
import { UPDATES_PAGE_SIZE } from "./_lib/useUpdatesPagination";
|
import { UPDATES_PAGE_SIZE } from "./_lib/constants";
|
||||||
import { UpdatesPage, updatesPageQuery } from "./UpdatesPage";
|
import { UpdatesPage, updatesPageQuery } from "./UpdatesPage";
|
||||||
import { UpdatesPageSkeleton } from "./UpdatesPageSkeleton";
|
import { UpdatesPageSkeleton } from "./UpdatesPageSkeleton";
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,13 @@ 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";
|
||||||
|
|
||||||
const ROW_PLACEHOLDERS = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"];
|
import { updatesList } from "./_components/variants";
|
||||||
|
|
||||||
|
const ROW_COUNT = 10;
|
||||||
|
|
||||||
export function UpdatesPageSkeleton() {
|
export function UpdatesPageSkeleton() {
|
||||||
|
const { card, rows } = updatesList();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeaderBand>
|
<HeaderBand>
|
||||||
@@ -30,10 +34,10 @@ export function UpdatesPageSkeleton() {
|
|||||||
</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">
|
||||||
<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="overflow-hidden rounded-5 border border-sand-3 bg-sand-1" aria-hidden>
|
<div className={card()} aria-hidden>
|
||||||
<div className="divide-y divide-sand-a2">
|
<div className={rows()}>
|
||||||
{ROW_PLACEHOLDERS.map(placeholder => (
|
{Array.from({ length: ROW_COUNT }, (_, index) => (
|
||||||
<ComplianceArticleItemSkeleton key={placeholder} />
|
<ComplianceArticleItemSkeleton key={index} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { updatesList } from "./variants";
|
||||||
|
|
||||||
|
interface UpdatesListProps {
|
||||||
|
// Dims the list while a page change is loading.
|
||||||
|
busy?: boolean;
|
||||||
|
// The rendered update rows.
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// White card surface holding divider-separated update rows.
|
||||||
|
export function UpdatesList({ busy = false, children }: UpdatesListProps) {
|
||||||
|
const { card, rows } = updatesList();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={card({ busy })} aria-busy={busy || undefined}>
|
||||||
|
<div className={rows()}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { tv } from "tailwind-variants/lite";
|
||||||
|
|
||||||
|
// Updates list surface: a white card holding divider-separated rows. The card
|
||||||
|
// dims while a page change is loading. Slots are shared by the list and its
|
||||||
|
// skeleton so the loading placeholder matches the real layout.
|
||||||
|
export const updatesList = tv({
|
||||||
|
slots: {
|
||||||
|
card: "overflow-hidden rounded-5 border border-sand-3 bg-sand-1 transition-opacity duration-150",
|
||||||
|
rows: "divide-y divide-sand-a2",
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
busy: {
|
||||||
|
true: { card: "opacity-60" },
|
||||||
|
false: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
busy: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update detail article layout: the toolbar row (back link + subscribe), the
|
||||||
|
// centered content column, and the article body with its gold metadata row.
|
||||||
|
// 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",
|
||||||
|
article: "flex w-full max-w-2xl flex-col gap-4",
|
||||||
|
meta: "flex items-center gap-1.5",
|
||||||
|
metaIcon: "size-4 text-gold-9",
|
||||||
|
body: "block whitespace-pre-wrap",
|
||||||
|
},
|
||||||
|
});
|
||||||
16
apps/compliance-portal/src/pages/updates/_lib/constants.ts
Normal file
16
apps/compliance-portal/src/pages/updates/_lib/constants.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
// Page size for the cursor-paginated updates list.
|
||||||
|
export const UPDATES_PAGE_SIZE = 25;
|
||||||
@@ -5,9 +5,5 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"title": "No updates yet.",
|
"title": "No updates yet.",
|
||||||
"description": "Subscribe to get notified when new updates are published."
|
"description": "Subscribe to get notified when new updates are published."
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"previous": "Previous page",
|
|
||||||
"next": "Next page"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,5 @@
|
|||||||
"empty": {
|
"empty": {
|
||||||
"title": "Aucune mise à jour pour le moment.",
|
"title": "Aucune mise à jour pour le moment.",
|
||||||
"description": "Abonnez-vous pour être informé lors de la publication de nouvelles mises à jour."
|
"description": "Abonnez-vous pour être informé lors de la publication de nouvelles mises à jour."
|
||||||
},
|
|
||||||
"pagination": {
|
|
||||||
"previous": "Page précédente",
|
|
||||||
"next": "Page suivante"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user