From ec277d4e3fee600eae47b513162eeff98c8f0d6d Mon Sep 17 00:00:00 2001 From: biss Date: Tue, 4 Aug 2026 17:08:35 +0800 Subject: [PATCH] =?UTF-8?q?keycloakify-starter=20=E4=BA=AE=E8=89=B2?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E9=9B=BE=E7=99=BD=E3=80=81=E5=86=B0=E8=93=9D?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=BA=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=A4=A7?= =?UTF-8?q?=E9=9D=A2=E7=A7=AF=E6=B7=B1=E8=89=B2=E3=80=82=20=E6=9A=97?= =?UTF-8?q?=E8=89=B2=E4=BD=BF=E7=94=A8=E6=B7=B1=E6=B5=B7=E8=93=9D=E7=81=B0?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E4=BF=9D=E6=8C=81=E8=B6=B3=E5=A4=9F=E7=9A=84?= =?UTF-8?q?=E5=B1=82=E7=BA=A7=E5=92=8C=E5=AF=B9=E6=AF=94=E5=BA=A6=E3=80=82?= =?UTF-8?q?=20class=20=E4=B8=BB=E9=A2=98=E5=90=8C=E6=A0=B7=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=BA=AE=E6=9A=97=E6=A8=A1=E5=BC=8F=E3=80=82=20Accoun?= =?UTF-8?q?t/Admin=20=E9=A1=B5=E5=A4=B4=E5=A2=9E=E5=8A=A0=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E5=88=87=E6=8D=A2=E6=8C=89=E9=92=AE=EF=BC=8C=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E4=BC=9A=E6=8C=81=E4=B9=85=E4=BF=9D=E5=AD=98=E3=80=82?= =?UTF-8?q?=20=E4=BB=8D=E9=81=B5=E5=AE=88=20Keycloak=20=E7=9A=84=20darkMod?= =?UTF-8?q?e=3Dfalse=20=E9=85=8D=E7=BD=AE=EF=BC=8C=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E6=97=B6=E5=8F=AA=E5=85=81=E8=AE=B8=E4=BA=AE=E8=89=B2=E3=80=82?= =?UTF-8?q?=20Account=20=E7=BB=9F=E4=B8=80=E4=B8=BA=E2=80=9C=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E4=B8=8A=E4=B8=8B=E6=96=87=20=E2=86=92=20=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E4=B8=9A=E5=8A=A1=E5=8C=BA=20=E2=86=92=20=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E2=80=9D=E7=BB=93=E6=9E=84=E3=80=82=20Admin?= =?UTF-8?q?=20=E7=BB=9F=E4=B8=80=E4=B8=BA=E2=80=9C=E9=A2=86=E5=9F=9F?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=20=E2=86=92=20=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E5=86=85=E5=AE=B9=20=E2=86=92=20=E5=9B=BA=E5=AE=9A=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E2=80=9D=E7=BB=93=E6=9E=84=E3=80=82=20?= =?UTF-8?q?=E8=A1=A8=E6=A0=BC=E3=80=81=E8=A1=A8=E5=8D=95=E3=80=81=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E3=80=81=E5=90=91=E5=AF=BC=E3=80=81=E5=BC=B9=E7=AA=97?= =?UTF-8?q?=E3=80=81=E7=A9=BA=E7=8A=B6=E6=80=81=E3=80=81=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E5=8F=8A=E5=BA=94=E7=94=A8=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E7=BB=84=E4=BB=B6=E5=85=A8=E9=83=A8=E7=BA=B3=E5=85=A5=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E8=AE=BE=E8=AE=A1=E7=B3=BB=E7=BB=9F=E3=80=82=20?= =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E7=AB=AF=E8=87=AA=E5=8A=A8=E6=94=B6=E8=B5=B7?= =?UTF-8?q?=E4=BE=A7=E6=A0=8F=E3=80=81=E5=8F=8C=E6=A0=8F=E5=8F=98=E5=8D=95?= =?UTF-8?q?=E6=A0=8F=EF=BC=8C=E5=B9=B6=E8=B0=83=E6=95=B4=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E5=8C=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/.gitignore | 10 +- src/account/colorScheme.ts | 21 ++ src/account/components/page/Page.tsx | 24 +- src/account/root/Header.tsx | 73 ++++++ src/account/root/Root.tsx | 113 +++++++++ src/account/workspace.css | 359 ++++++++++++++++----------- src/admin/App.tsx | 9 +- src/admin/PageHeader.tsx | 150 +++++++++++ src/admin/colorScheme.ts | 21 ++ src/admin/dashboard/dashboard.css | 72 ++++-- src/admin/workspace.css | 86 +++++++ src/console-ui.css | 136 ++++++++-- src/shared/ThemeModeToggle.tsx | 51 ++++ src/shared/consoleColorScheme.ts | 88 +++++++ 14 files changed, 1021 insertions(+), 192 deletions(-) create mode 100644 src/account/colorScheme.ts create mode 100644 src/account/root/Header.tsx create mode 100644 src/account/root/Root.tsx create mode 100644 src/admin/PageHeader.tsx create mode 100644 src/admin/colorScheme.ts create mode 100644 src/shared/ThemeModeToggle.tsx create mode 100644 src/shared/consoleColorScheme.ts diff --git a/src/.gitignore b/src/.gitignore index 7734487..2d65f07 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -1,17 +1,22 @@ # This file is managed by Keycloakify, do not edit it manually. # === Owned files start === +# account/colorScheme.ts # account/KcAccountUi.tsx # account/KcContext.ts # account/KcPage.tsx # admin/App.tsx +# admin/colorScheme.ts # admin/index.css # admin/KcAdminUi.tsx # admin/page-nav.css +# admin/PageHeader.tsx # admin/PageNav.tsx # account/assets/logo.svg # account/personal-info/PersonalInfo.tsx +# account/root/Header.tsx # account/root/PageNav.tsx +# account/root/Root.tsx # admin/assets/logo.svg # admin/dashboard/dashboard.css # admin/dashboard/Dashboard.tsx @@ -91,7 +96,6 @@ /admin/admin-client.tsx /admin/banners.module.css /admin/Banners.tsx -/admin/colorScheme.ts /admin/environment-types.ts /admin/environment.ts /admin/ForbiddenSection.tsx @@ -99,7 +103,6 @@ /admin/i18next.d.ts /admin/KcContext.ts /admin/KcPage.tsx -/admin/PageHeader.tsx /admin/PageHeaderClearCachesModal.tsx /admin/PageNotFoundSection.tsx /admin/Root.tsx @@ -841,7 +844,6 @@ # === @keycloakify/keycloak-account-ui v260700.0.3 === /account/api.ts -/account/colorScheme.ts /account/environment.ts /account/i18n-type.ts /account/i18next.d.ts @@ -915,8 +917,6 @@ /account/resources/SharedWith.tsx /account/resources/ShareTheResource.tsx /account/root/header.module.css -/account/root/Header.tsx -/account/root/Root.tsx /account/utils/formatDate.ts /account/utils/joinPath.ts /account/utils/useAccountAlerts.ts diff --git a/src/account/colorScheme.ts b/src/account/colorScheme.ts new file mode 100644 index 0000000..66c1a56 --- /dev/null +++ b/src/account/colorScheme.ts @@ -0,0 +1,21 @@ +/** + * This file has been claimed for ownership from @keycloakify/keycloak-account-ui version 260700.0.3. + * To relinquish ownership and restore this file to its original content, run the following command: + * + * $ npx keycloakify own --path "account/colorScheme.ts" --revert + */ + +/* IMPORTANT NOTE: + * If you modify how the light/dark mode is implemented + * you must also update public/admin/early-color-scheme.js + * This scrip is ran before this to avoid white flashes + */ + +import { getKcContext } from "./KcContext"; +import { startConsoleColorScheme } from "../shared/consoleColorScheme"; + +export function startColorSchemeManagement() { + const { kcContext } = getKcContext(); + + startConsoleColorScheme(kcContext.darkMode !== false); +} diff --git a/src/account/components/page/Page.tsx b/src/account/components/page/Page.tsx index 9875960..a7a2b3f 100644 --- a/src/account/components/page/Page.tsx +++ b/src/account/components/page/Page.tsx @@ -24,12 +24,12 @@ type PageProps = { export const Page = ({ title, description, children }: PropsWithChildren) => { return ( - <> - -
-
- - NEXUS / Identity control +
+ +
+
+ + Identity workspace / Account @@ -38,14 +38,16 @@ export const Page = ({ title, description, children }: PropsWithChildren<PagePro <Text component="p">{description}</Text> </TextContent> </div> - <div className="nexus-page-intro__mark" aria-hidden="true"> - <span>ID</span> + <div className="account-view-header__context" aria-hidden="true"> + <span>SELF SERVICE</span> + <strong>ID</strong> <i /> - <span>ACCOUNT</span> </div> </div> </PageSection> - <PageSection variant="light">{children}</PageSection> - </> + <PageSection variant="light" className="account-view-body"> + <div className="account-view-body__surface">{children}</div> + </PageSection> + </article> ); }; diff --git a/src/account/root/Header.tsx b/src/account/root/Header.tsx new file mode 100644 index 0000000..209ed79 --- /dev/null +++ b/src/account/root/Header.tsx @@ -0,0 +1,73 @@ +/** + * This file has been claimed for ownership from @keycloakify/keycloak-account-ui version 260700.0.3. + * To relinquish ownership and restore this file to its original content, run the following command: + * + * $ npx keycloakify own --path "account/root/Header.tsx" --revert + */ + +/* eslint-disable */ + +// @ts-nocheck + +import logoSvgUrl from "../assets/logo.svg"; +import { KeycloakMasthead, label, useEnvironment } from "../../shared/keycloak-ui-shared"; +import { Button } from "../../shared/@patternfly/react-core"; +import { ExternalLinkSquareAltIcon } from "../../shared/@patternfly/react-icons"; +import { useTranslation } from "react-i18next"; +import { useHref } from "react-router-dom"; + +import { AccountEnvironment } from ".."; +import { joinPath } from "../utils/joinPath"; +import { ThemeModeToggle } from "../../shared/ThemeModeToggle"; + +import style from "./header.module.css"; + +const ReferrerLink = () => { + const { environment } = useEnvironment<AccountEnvironment>(); + const { t } = useTranslation(); + + return environment.referrerUrl ? ( + <Button + data-testid="referrer-link" + component="a" + href={environment.referrerUrl.replace("_hash_", "#")} + variant="link" + icon={<ExternalLinkSquareAltIcon />} + iconPosition="right" + isInline + > + {t("backTo", { + app: label(t, environment.referrerName, environment.referrerUrl) + })} + </Button> + ) : null; +}; + +export const Header = () => { + const { environment, keycloak } = useEnvironment(); + const { t } = useTranslation(); + + const logoUrl = environment.logoUrl ? environment.logoUrl : "/"; + const internalLogoHref = useHref(logoUrl); + + // User can indicate that he wants an internal URL by starting it with "/" + const indexHref = logoUrl.startsWith("/") ? internalLogoHref : logoUrl; + + return ( + <KeycloakMasthead + data-testid="page-header" + keycloak={keycloak} + features={{ hasManageAccount: false }} + brand={{ + href: indexHref, + src: logoSvgUrl, + alt: t("logo"), + className: style.brand + }} + toolbarItems={[ + <ThemeModeToggle key="theme-mode" />, + <ReferrerLink key="link" /> + ]} + /> + ); +}; diff --git a/src/account/root/Root.tsx b/src/account/root/Root.tsx new file mode 100644 index 0000000..290e0d2 --- /dev/null +++ b/src/account/root/Root.tsx @@ -0,0 +1,113 @@ +/** + * This file has been claimed for ownership from @keycloakify/keycloak-account-ui version 260700.0.3. + * To relinquish ownership and restore this file to its original content, run the following command: + * + * $ npx keycloakify own --path "account/root/Root.tsx" --revert + */ + +/* eslint-disable */ + +// @ts-nocheck + +import { + ErrorPage, + useAlerts, + useEnvironment, + KeycloakContext +} from "../../shared/keycloak-ui-shared"; +import { AlertVariant, Page, Spinner } from "../../shared/@patternfly/react-core"; +import { Suspense, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + createBrowserRouter, + Navigate, + Outlet, + RouteObject, + RouterProvider +} from "react-router-dom"; +import fetchContentJson from "../content/fetchContent"; +import { type AccountEnvironment } from ".."; +import { usePromise } from "../utils/usePromise"; +import { Header } from "./Header"; +import { MenuItem, PageNav } from "./PageNav"; +import { routes } from "../routes"; + +function mapRoutes( + context: KeycloakContext<AccountEnvironment>, + content: MenuItem[] +): RouteObject[] { + return content + .map(item => { + if ("children" in item) { + return mapRoutes(context, item.children); + } + + // Do not add route disabled via feature flags + if (item.isVisible && !context.environment.features[item.isVisible]) { + return null; + } + + return { + ...item, + element: + "path" in item + ? routes.find(r => r.path === (item.id ?? item.path))?.element + : undefined + }; + }) + .filter(item => !!item) + .flat(); +} + +function CatchAllRedirect() { + const { t } = useTranslation(); + const { addAlert } = useAlerts(); + + useEffect(() => { + addAlert(t("pageNotFound"), AlertVariant.warning); + }, [addAlert, t]); + + return <Navigate to="." replace />; +} + +export const Root = () => { + const context = useEnvironment<AccountEnvironment>(); + const [content, setContent] = useState<RouteObject[]>(); + + usePromise( + signal => fetchContentJson({ signal, context }), + content => { + setContent([ + { + path: decodeURIComponent( + new URL(context.environment.baseUrl).pathname + ), + element: ( + <Page header={<Header />} sidebar={<PageNav />} isManagedSidebar> + <Suspense fallback={<Spinner />}> + <div + className="account-workspace-route" + data-workspace="personal-identity" + > + <div className="account-workspace-route__content"> + <Outlet /> + </div> + </div> + </Suspense> + </Page> + ), + errorElement: <ErrorPage />, + children: [ + ...mapRoutes(context, content), + { path: "*", element: <CatchAllRedirect /> } + ] + } + ]); + } + ); + + if (!content) { + return <Spinner />; + } + return <RouterProvider router={createBrowserRouter(content)} />; +}; diff --git a/src/account/workspace.css b/src/account/workspace.css index 5664c9c..107efb8 100644 --- a/src/account/workspace.css +++ b/src/account/workspace.css @@ -1,7 +1,15 @@ -/* Account-specific composition on top of the shared console language. */ +/* Account workspace: self-service identity tasks share one business-page skeleton. */ + +.account-workspace-route, +.account-workspace-route__content, +.account-business-page { + min-width: 0; + min-height: 100%; +} .nexus-nav-context { - gap: 0.25rem; + display: grid; + gap: 0.28rem; margin: 0 0.35rem 1.25rem; padding: 0.15rem 0.45rem 1.15rem; border-bottom: 1px solid var(--console-line); @@ -9,122 +17,255 @@ .nexus-nav-context__eyebrow { color: var(--console-primary-deep); - font-family: inherit; - font-size: 0.65rem; - font-weight: 750; - letter-spacing: 0.11em; + font-size: 0.64rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; } .nexus-nav-context strong { color: var(--console-ink); - font-family: inherit; font-size: 1.05rem; - font-weight: 720; + font-weight: 740; } .nexus-nav-context__rule { - width: 4.25rem; + width: 4.5rem; height: 3px; - margin-top: 0.45rem; + margin-top: 0.48rem; + border-radius: 99px; background: linear-gradient( 90deg, var(--console-teal) 0 34%, var(--console-amber) 34% 67%, var(--console-coral) 67% ); - opacity: 1; - mask-image: none; +} + +.nexus-nav-label { + display: grid; + grid-template-columns: 1.6rem minmax(0, 1fr); + align-items: center; + gap: 0.68rem; } .nexus-nav-label__icon { + display: grid; + width: 1.6rem; + height: 1.6rem; + border: 1px solid var(--console-line); + border-radius: 7px; color: var(--console-faint); + background: color-mix(in srgb, var(--console-surface) 58%, transparent); + place-items: center; } .pf-v5-c-nav__link:hover .nexus-nav-label__icon, .pf-v5-c-nav__link:focus .nexus-nav-label__icon, .pf-v5-c-nav__link.pf-m-current .nexus-nav-label__icon { - color: var(--console-primary); - transform: none; + border-color: color-mix(in srgb, var(--console-primary) 30%, var(--console-line)); + color: var(--console-primary-deep); + background: color-mix(in srgb, var(--console-primary) 9%, var(--console-surface)); } -.pf-v5-c-page__main-section:first-child { - padding-top: clamp(2rem, 4vw, 3.5rem); - padding-bottom: clamp(1.5rem, 2.5vw, 2.25rem); - border-bottom: 0; +.account-view-header { + position: relative; + padding-top: clamp(1.9rem, 4vw, 3.7rem); + padding-bottom: clamp(1.5rem, 2.8vw, 2.4rem); + border: 0; background: transparent; - backdrop-filter: none; } -.pf-v5-c-page__main-section:first-child::after { - display: none; +.account-view-header::before { + position: absolute; + top: 0; + bottom: 0; + left: clamp(1.25rem, 3.2vw, 3.75rem); + width: 3px; + border-radius: 99px; + background: linear-gradient( + 180deg, + var(--console-teal) 0 34%, + var(--console-amber) 34% 67%, + var(--console-coral) 67% + ); + content: ""; } -.nexus-page-intro { - align-items: center; +.account-view-header__layout { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + gap: 2rem; + padding-left: 1.25rem; } -.nexus-page-intro__kicker { - margin-bottom: 0.65rem; +.account-view-header__copy { + min-width: 0; +} + +.account-view-header__eyebrow { + display: block; + margin-bottom: 0.62rem; color: var(--console-primary-deep); - font-family: inherit; - font-size: 0.7rem; - font-weight: 750; - letter-spacing: 0.11em; -} - -.nexus-page-intro__mark { - grid-template-columns: auto 3.5rem auto; - gap: 0.65rem; - padding: 0.65rem 0.85rem; - border: 1px solid var(--console-line); - border-radius: 999px; - color: var(--console-muted); - background: color-mix(in srgb, var(--console-surface) 76%, transparent); - font-family: inherit; - font-size: 0.62rem; - font-weight: 700; - box-shadow: 0 8px 22px rgba(23, 50, 77, 0.055); -} - -.nexus-page-intro__mark > span:first-child { - color: var(--console-primary-deep); - font-family: inherit; - font-size: 0.72rem; + font-size: 0.67rem; font-weight: 800; + letter-spacing: 0.13em; + text-transform: uppercase; } -.nexus-page-intro__mark i { +.account-view-header h1 { + margin: 0; + color: var(--console-ink); + font-size: clamp(2.15rem, 3.6vw, 3.35rem); + font-weight: 740; + letter-spacing: -0.05em; + line-height: 1.02; +} + +.account-view-header p { + max-width: 72ch; + margin: 0.85rem 0 0; + color: var(--console-muted); + line-height: 1.7; +} + +.account-view-header__context { + display: grid; + grid-template-columns: auto auto; + align-items: center; + gap: 0.4rem 0.8rem; + min-width: 156px; + padding: 0.9rem 1rem; + border: 1px solid var(--console-line); + border-radius: 12px; + color: var(--console-muted); + background: color-mix(in srgb, var(--console-surface) 78%, transparent); + box-shadow: var(--console-shadow); +} + +.account-view-header__context span { + font-size: 0.59rem; + font-weight: 800; + letter-spacing: 0.1em; +} + +.account-view-header__context strong { + grid-row: span 2; + color: var(--console-primary-deep); + font-size: 1.45rem; + letter-spacing: -0.06em; +} + +.account-view-header__context i { + width: 100%; + height: 3px; + border-radius: 99px; background: linear-gradient(90deg, var(--console-teal), var(--console-primary)); } -.pf-v5-c-page__main-section + .pf-v5-c-page__main-section { +.account-view-body { padding-top: 0; - padding-bottom: 5rem; + padding-bottom: clamp(3.5rem, 7vw, 6rem); + background: transparent; } +.account-view-body__surface { + min-width: 0; +} + +/* Lists, security devices, applications and permissions become operational decks. */ +.account-view-body__surface > .pf-v5-c-data-list, +.account-view-body__surface > .pf-v5-c-table, +.account-view-body__surface > .pf-v5-c-card, +.account-view-body__surface > .pf-v5-c-stack, +.account-view-body__surface > .pf-v5-c-form, +.account-view-body__surface > form { + width: 100%; +} + +.account-view-body .pf-v5-c-data-list__item-row { + min-height: 72px; + align-items: center; +} + +.account-view-body .pf-v5-c-data-list__cell { + color: var(--console-muted); + line-height: 1.55; +} + +.account-view-body .pf-v5-c-data-list__cell strong, +.account-view-body .pf-v5-c-data-list__cell b, +.account-view-body .pf-v5-c-data-list__cell h2, +.account-view-body .pf-v5-c-data-list__cell h3 { + color: var(--console-ink); +} + +.account-view-body .pf-v5-c-stack > .pf-v5-c-stack__item + .pf-v5-c-stack__item { + margin-top: 1.25rem; +} + +.account-view-body .pf-v5-c-title.pf-m-xl, +.account-view-body .pf-v5-c-title.pf-m-lg { + margin-bottom: 0.85rem; + padding-bottom: 0.72rem; + border-bottom: 1px solid var(--console-line); + color: var(--console-ink); +} + +.account-view-body .pf-v5-c-form { + max-width: 1120px; +} + +.account-view-body .pf-v5-c-form__group { + padding-block: 0.28rem; +} + +.account-view-body .pf-v5-c-form__actions { + position: sticky; + bottom: 0; + z-index: 4; + margin-top: 2rem; + padding: 1rem; + border: 1px solid var(--console-line); + border-radius: 12px; + background: color-mix(in srgb, var(--console-surface) 92%, transparent); + box-shadow: 0 -10px 28px color-mix(in srgb, var(--console-ink) 8%, transparent); + backdrop-filter: blur(16px); +} + +/* Personal information is a two-column identity record, not a generic form. */ .nexus-profile-layout { + display: grid; grid-template-columns: minmax(245px, 0.58fr) minmax(0, 1.65fr); + align-items: start; gap: clamp(1.25rem, 2.6vw, 2.5rem); } +.nexus-identity-card, +.nexus-profile-editor { + position: relative; + overflow: hidden; + border: 1px solid var(--console-line); + border-radius: var(--console-radius); + color: var(--console-ink); + background: var(--console-surface); + box-shadow: var(--console-shadow); +} + .nexus-identity-card { min-height: 0; padding: 1.45rem; - border: 1px solid var(--console-line); - border-radius: var(--console-radius); - color: var(--console-ink); background: linear-gradient( 145deg, var(--console-surface), var(--console-surface-soft) ); - box-shadow: var(--console-shadow); } .nexus-identity-card::before { position: absolute; inset: 0 0 auto; - width: auto; height: 4px; background: linear-gradient( 90deg, @@ -135,21 +276,12 @@ content: ""; } -.nexus-identity-card::after { - right: -4.5rem; - bottom: -5.5rem; - border-color: color-mix(in srgb, var(--console-primary) 10%, transparent); - box-shadow: - 0 0 0 2.8rem color-mix(in srgb, var(--console-primary) 3%, transparent), - 0 0 0 5.6rem color-mix(in srgb, var(--console-teal) 2%, transparent); -} - .nexus-identity-card__topline, .nexus-identity-card__name > span, .nexus-identity-card__facts dt, -.nexus-identity-card__footer { +.nexus-identity-card__footer, +.nexus-identity-card__name p { color: var(--console-muted); - font-family: inherit; font-weight: 700; } @@ -158,7 +290,6 @@ border-radius: 50%; background: var(--console-primary); box-shadow: 0 0 0 4px color-mix(in srgb, var(--console-primary) 12%, transparent); - transform: none; } .nexus-identity-card__avatar { @@ -170,14 +301,11 @@ color: #ffffff; background: linear-gradient(145deg, var(--console-primary), var(--console-teal)); box-shadow: 0 10px 24px color-mix(in srgb, var(--console-primary) 22%, transparent); - transform: none; } .nexus-identity-card__avatar span { - font-family: inherit; font-size: 1.35rem; - font-weight: 760; - transform: none; + font-weight: 780; } .nexus-identity-card__name h2, @@ -186,27 +314,14 @@ } .nexus-identity-card__name h2 { - font-family: inherit; font-size: 1.55rem; letter-spacing: -0.03em; } -.nexus-identity-card__name p { - color: var(--console-muted); -} - .nexus-identity-card__facts > div { border-top-color: var(--console-line); } -.nexus-profile-editor { - overflow: hidden; - border: 1px solid var(--console-line); - border-radius: var(--console-radius); - background: var(--console-surface); - box-shadow: var(--console-shadow); -} - .nexus-profile-editor__header { padding: 1.55rem clamp(1.25rem, 2.5vw, 2rem); border-bottom: 1px solid var(--console-line); @@ -219,13 +334,11 @@ .nexus-profile-editor__header > span { color: var(--console-primary-deep); - font-family: inherit; - font-weight: 750; + font-weight: 780; } .nexus-profile-editor__header h2 { color: var(--console-ink); - font-family: inherit; font-size: 1.28rem; } @@ -237,65 +350,33 @@ padding: clamp(1.35rem, 3vw, 2.2rem); } -.nexus-profile-editor .pf-v5-c-form__group { - padding-block: 0.45rem; -} - -html[data-kc-theme-name="class"] .nexus-identity-card, -html[data-kc-theme-name="class"] .nexus-profile-editor, -html[data-kc-theme-name="class"] .pf-v5-c-card { - border-radius: var(--console-radius); -} - -html[data-kc-theme-name="class"] .nexus-identity-card { - border-color: var(--console-line); - color: var(--console-ink); - background: linear-gradient( - 145deg, - var(--console-surface), - var(--console-surface-soft) - ); - box-shadow: var(--console-shadow); -} - -html[data-kc-theme-name="class"] .nexus-identity-card::after { - display: block; -} - -html[data-kc-theme-name="class"] .nexus-identity-card__topline, -html[data-kc-theme-name="class"] .nexus-identity-card__name p, -html[data-kc-theme-name="class"] .nexus-identity-card__facts dt, -html[data-kc-theme-name="class"] .nexus-identity-card__footer { - color: var(--console-muted); -} - -html[data-kc-theme-name="class"] .nexus-identity-card__facts dd { - color: var(--console-ink); -} - -html[data-kc-theme-name="class"] .nexus-identity-card__avatar { - border: 0; - border-radius: 18px; - color: #ffffff; - background: linear-gradient(145deg, var(--console-primary), var(--console-teal)); - box-shadow: 0 10px 24px color-mix(in srgb, var(--console-primary) 22%, transparent); -} - @media (max-width: 768px) { - .nexus-page-intro__mark { + .account-view-header::before { + left: 1rem; + } + + .account-view-header__layout { + grid-template-columns: 1fr; + padding-left: 1rem; + } + + .account-view-header__context { display: none; } + .account-view-header h1 { + font-size: 2.15rem; + } + .nexus-profile-layout { grid-template-columns: 1fr; } - .nexus-identity-card { - position: relative; - top: auto; + .account-view-body .pf-v5-c-data-list__item-row { + align-items: start; } - .nexus-profile-editor__header { - grid-template-columns: 1fr; + .account-view-body .pf-v5-c-form__actions { + position: static; } } diff --git a/src/admin/App.tsx b/src/admin/App.tsx index 91f0139..98c7d21 100644 --- a/src/admin/App.tsx +++ b/src/admin/App.tsx @@ -81,8 +81,13 @@ export const App = () => { <ErrorBoundaryFallback fallback={ErrorRenderer}> <Suspense fallback={<KeycloakSpinner />}> <AuthWall> - <div className="admin-workspace-route"> - <Outlet /> + <div + className="admin-workspace-route" + data-workspace="realm-operations" + > + <div className="admin-workspace-route__content"> + <Outlet /> + </div> </div> </AuthWall> </Suspense> diff --git a/src/admin/PageHeader.tsx b/src/admin/PageHeader.tsx new file mode 100644 index 0000000..7eabaeb --- /dev/null +++ b/src/admin/PageHeader.tsx @@ -0,0 +1,150 @@ +/** + * This file has been claimed for ownership from @keycloakify/keycloak-admin-ui version 260700.0.2. + * To relinquish ownership and restore this file to its original content, run the following command: + * + * $ npx keycloakify own --path "admin/PageHeader.tsx" --revert + */ + +/* eslint-disable */ + +// @ts-nocheck + +import logoSvgUrl from "./assets/logo.svg"; +import { KeycloakMasthead, useEnvironment, useHelp } from "../shared/keycloak-ui-shared"; +import { DropdownItem, ToolbarItem } from "../shared/@patternfly/react-core"; +import { HelpIcon } from "../shared/@patternfly/react-icons"; +import { useTranslation } from "react-i18next"; +import { Link, useHref } from "react-router-dom"; +import { PageHeaderClearCachesModal } from "./PageHeaderClearCachesModal"; +import { HelpHeader } from "./components/help-enabler/HelpHeader"; +import { useAccess } from "./context/access/Access"; +import { useRealm } from "./context/realm-context/RealmContext"; +import { toDashboard } from "./dashboard/routes/Dashboard"; +import type { Environment } from "./environment-types"; +import { usePreviewLogo } from "./realm-settings/themes/LogoContext"; +import { joinPath } from "./utils/joinPath"; +import { useIsFeatureDisabled, Feature } from "./utils/useIsFeatureEnabled"; +import useToggle from "./utils/useToggle"; +import { ThemeModeToggle } from "../shared/ThemeModeToggle"; + +const ManageAccountDropdownItem = () => { + const { keycloak } = useEnvironment(); + const { t } = useTranslation(); + const isFeatureDisabled = useIsFeatureDisabled(); + + if (isFeatureDisabled(Feature.AccountV3)) { + return null; + } + + return ( + <DropdownItem + key="manage account" + id="manage-account" + onClick={() => keycloak.accountManagement()} + > + {t("manageAccount")} + </DropdownItem> + ); +}; + +const ServerInfoDropdownItem = () => { + const { realm } = useRealm(); + const { t } = useTranslation(); + + return ( + <DropdownItem + key="server info" + component={props => <Link {...props} to={toDashboard({ realm })} />} + > + {t("realmInfo")} + </DropdownItem> + ); +}; + +const ClearCachesDropdownItem = () => { + const { t } = useTranslation(); + const [open, toggleModal] = useToggle(); + + return ( + <> + <DropdownItem key="clear caches" onClick={() => toggleModal()}> + {t("clearCachesTitle")} + </DropdownItem> + {open && <PageHeaderClearCachesModal onClose={() => toggleModal()} />} + </> + ); +}; + +const HelpDropdownItem = () => { + const { t } = useTranslation(); + const { enabled, toggleHelp } = useHelp(); + return ( + <DropdownItem data-testId="helpIcon" icon={<HelpIcon />} onClick={toggleHelp}> + {enabled ? t("helpEnabled") : t("helpDisabled")} + </DropdownItem> + ); +}; + +const kebabDropdownItems = (isMasterRealm: boolean, isManager: boolean) => [ + <ManageAccountDropdownItem key="kebab Manage Account" />, + <ServerInfoDropdownItem key="kebab Server Info" />, + ...(isMasterRealm && isManager + ? [<ClearCachesDropdownItem key="Clear Caches" />] + : []), + <HelpDropdownItem key="kebab Help" /> +]; + +const userDropdownItems = (isMasterRealm: boolean, isManager: boolean) => [ + <ManageAccountDropdownItem key="Manage Account" />, + <ServerInfoDropdownItem key="Server info" />, + ...(isMasterRealm && isManager + ? [<ClearCachesDropdownItem key="Clear Caches" />] + : []) +]; + +export const Header = () => { + const { environment, keycloak } = useEnvironment<Environment>(); + const { t } = useTranslation(); + const { realm } = useRealm(); + const { hasAccess } = useAccess(); + + const contextLogo = usePreviewLogo(); + const customLogo = contextLogo?.logo; + + const isMasterRealm = realm === environment.masterRealm; + const isManager = hasAccess("manage-realm"); + + const url = useHref(toDashboard({ realm })); + const logoUrl = environment.logoUrl ? environment.logoUrl : url; + + return ( + <KeycloakMasthead + data-testid="page-header" + keycloak={keycloak} + features={{ hasManageAccount: false }} + brand={{ + href: logoUrl, + src: customLogo?.trim() ? customLogo : logoSvgUrl, + alt: t("logo"), + className: "keycloak__pageheader_brand" + }} + dropdownItems={userDropdownItems(isMasterRealm, isManager)} + kebabDropdownItems={kebabDropdownItems(isMasterRealm, isManager)} + toolbarItems={[ + <ToolbarItem key="theme-mode"> + <ThemeModeToggle /> + </ToolbarItem>, + <ToolbarItem + key="help" + align={{ default: "alignRight" }} + visibility={{ + default: "hidden", + md: "visible" + }} /** the settings and help icon buttons are only visible on desktop sizes and replaced by a kebab dropdown for other sizes */ + > + <HelpHeader /> + </ToolbarItem> + ]} + /> + ); +}; diff --git a/src/admin/colorScheme.ts b/src/admin/colorScheme.ts new file mode 100644 index 0000000..8cd3d43 --- /dev/null +++ b/src/admin/colorScheme.ts @@ -0,0 +1,21 @@ +/** + * This file has been claimed for ownership from @keycloakify/keycloak-admin-ui version 260700.0.2. + * To relinquish ownership and restore this file to its original content, run the following command: + * + * $ npx keycloakify own --path "admin/colorScheme.ts" --revert + */ + +/* IMPORTANT NOTE: + * If you modify how the light/dark mode is implemented + * you must also update public/admin/early-color-scheme.js + * This scrip is ran before this to avoid white flashes + */ + +import { getKcContext } from "./KcContext"; +import { startConsoleColorScheme } from "../shared/consoleColorScheme"; + +export function startColorSchemeManagement() { + const { kcContext } = getKcContext(); + + startConsoleColorScheme(kcContext.darkMode !== false); +} diff --git a/src/admin/dashboard/dashboard.css b/src/admin/dashboard/dashboard.css index 11e7f5b..ff095d7 100644 --- a/src/admin/dashboard/dashboard.css +++ b/src/admin/dashboard/dashboard.css @@ -17,16 +17,21 @@ min-height: 260px; border: 1px solid var(--console-line); border-radius: 20px; - color: #f7fbff; - background: linear-gradient(135deg, #193a5b, #245a89 58%, #277e78); - box-shadow: 0 24px 52px rgba(23, 50, 77, 0.16); + color: var(--console-ink); + background: linear-gradient( + 135deg, + var(--console-surface) 0%, + color-mix(in srgb, var(--console-primary) 8%, var(--console-surface)) 58%, + color-mix(in srgb, var(--console-teal) 12%, var(--console-surface)) 100% + ); + box-shadow: var(--console-shadow); } .admin-dashboard-hero::before { position: absolute; inset: 0; - background-image: linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px), - linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px); + background-image: linear-gradient(var(--console-line) 1px, transparent 1px), + linear-gradient(90deg, var(--console-line) 1px, transparent 1px); background-size: 48px 48px; content: ""; mask-image: linear-gradient(90deg, transparent, #000 65%); @@ -65,12 +70,12 @@ } .admin-dashboard-eyebrow { - color: #92e3cf; + color: var(--console-teal); } .admin-dashboard-hero h1 { margin: 0; - color: #ffffff; + color: var(--console-ink); font-size: clamp(2.4rem, 5vw, 4.8rem); font-weight: 740; letter-spacing: -0.055em; @@ -80,7 +85,7 @@ .admin-dashboard-hero__copy > p { max-width: 60ch; margin: 1.25rem 0 1.6rem; - color: rgba(235, 246, 255, 0.78); + color: var(--console-muted); font-size: 1rem; line-height: 1.7; } @@ -90,18 +95,18 @@ flex-wrap: wrap; align-items: center; gap: 0.65rem 0.9rem; - color: rgba(235, 246, 255, 0.62); + color: var(--console-muted); font-size: 0.8rem; } .admin-dashboard-hero__meta strong { - color: #ffffff; + color: var(--console-ink); font-family: "Cascadia Code", Consolas, monospace; font-weight: 600; } .admin-dashboard-hero .admin-dashboard-status { - color: rgba(235, 246, 255, 0.82); + color: var(--console-muted); } .admin-dashboard-hero__signal { @@ -111,12 +116,12 @@ align-content: center; justify-items: end; padding: clamp(1.5rem, 3vw, 2.5rem); - border-left: 1px solid rgba(255, 255, 255, 0.13); + border-left: 1px solid var(--console-line); text-align: right; } .admin-dashboard-hero__signal > span { - color: rgba(235, 246, 255, 0.48); + color: var(--console-faint); font-size: 0.64rem; font-weight: 800; letter-spacing: 0.18em; @@ -126,11 +131,11 @@ width: 100%; height: 1px; margin: 0.75rem 0 1rem; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.38)); + background: linear-gradient(90deg, transparent, var(--console-line-strong)); } .admin-dashboard-hero__signal strong { - color: #ffffff; + color: var(--console-primary-deep); font-size: clamp(3.5rem, 7vw, 6.5rem); font-weight: 300; letter-spacing: -0.08em; @@ -139,7 +144,7 @@ .admin-dashboard-hero__signal small { margin-top: 0.75rem; - color: rgba(235, 246, 255, 0.66); + color: var(--console-muted); } .admin-dashboard-layout { @@ -361,6 +366,41 @@ height: 100%; } +.pf-v5-theme-dark .admin-dashboard-hero { + color: #edf6fb; + background: linear-gradient(135deg, #112d43, #173f5e 58%, #1c625e); + box-shadow: 0 24px 52px rgba(0, 0, 0, 0.28); +} + +.pf-v5-theme-dark .admin-dashboard-hero::before { + background-image: linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px); +} + +.pf-v5-theme-dark .admin-dashboard-eyebrow { + color: #83dcc5; +} + +.pf-v5-theme-dark .admin-dashboard-hero h1, +.pf-v5-theme-dark .admin-dashboard-hero__meta strong, +.pf-v5-theme-dark .admin-dashboard-hero__signal strong { + color: #ffffff; +} + +.pf-v5-theme-dark .admin-dashboard-hero__copy > p, +.pf-v5-theme-dark .admin-dashboard-hero .admin-dashboard-status { + color: rgba(235, 246, 255, 0.8); +} + +.pf-v5-theme-dark .admin-dashboard-hero__meta, +.pf-v5-theme-dark .admin-dashboard-hero__signal small { + color: rgba(235, 246, 255, 0.64); +} + +.pf-v5-theme-dark .admin-dashboard-hero__signal { + border-left-color: rgba(255, 255, 255, 0.13); +} + @media (max-width: 1050px) { .admin-dashboard-layout { grid-template-columns: 1fr; diff --git a/src/admin/workspace.css b/src/admin/workspace.css index 658489d..393066a 100644 --- a/src/admin/workspace.css +++ b/src/admin/workspace.css @@ -3,6 +3,11 @@ min-height: 100%; } +.admin-workspace-route__content { + min-width: 0; + min-height: 100%; +} + .admin-view-header { position: relative; padding-top: clamp(1.7rem, 3vw, 2.7rem); @@ -130,6 +135,82 @@ font-weight: 700; } +/* Every administration route follows the same operational content grammar. */ +.admin-workspace-route .pf-v5-c-page__main-section:not(.admin-view-header) { + color: var(--console-ink); +} + +.admin-workspace-route .pf-v5-c-page__main-section:not(.admin-view-header) > form, +.admin-workspace-route + .pf-v5-c-page__main-section:not(.admin-view-header) + > .pf-v5-c-form, +.admin-workspace-route + .pf-v5-c-page__main-section:not(.admin-view-header) + > .keycloak__form { + width: 100%; + max-width: 1160px; +} + +.admin-workspace-route .pf-v5-c-card + .pf-v5-c-card, +.admin-workspace-route .pf-v5-c-panel + .pf-v5-c-panel, +.admin-workspace-route .pf-v5-c-alert + .pf-v5-c-card { + margin-top: 1rem; +} + +.admin-workspace-route .pf-v5-c-description-list { + padding: 1.15rem; + border: 1px solid var(--console-line); + border-radius: var(--console-radius); + background: var(--console-surface); +} + +.admin-workspace-route .pf-v5-c-description-list__term { + color: var(--console-muted); + font-size: 0.76rem; + font-weight: 750; + letter-spacing: 0.035em; +} + +.admin-workspace-route .pf-v5-c-description-list__description { + color: var(--console-ink); +} + +.admin-workspace-route .pf-v5-c-form__actions, +.admin-workspace-route .keycloak__form_actions { + position: sticky; + bottom: 0; + z-index: 6; + margin-top: 2rem; + padding: 1rem; + border: 1px solid var(--console-line); + border-radius: 12px; + background: color-mix(in srgb, var(--console-surface) 92%, transparent); + box-shadow: 0 -10px 28px color-mix(in srgb, var(--console-ink) 8%, transparent); + backdrop-filter: blur(16px); +} + +.admin-workspace-route .pf-v5-c-wizard { + overflow: hidden; + border: 1px solid var(--console-line); + border-radius: var(--console-radius); + background: var(--console-surface); + box-shadow: var(--console-shadow); +} + +.admin-workspace-route .pf-v5-c-wizard__nav { + border-right-color: var(--console-line); + background: var(--console-surface-soft); +} + +.admin-workspace-route .pf-v5-c-drawer__panel { + background: var(--console-surface-raised); +} + +.admin-workspace-route .pf-v5-c-empty-state { + min-height: 260px; + place-content: center; +} + @media (max-width: 768px) { .admin-view-header::before { left: 1rem; @@ -149,4 +230,9 @@ .admin-view-header h1 { font-size: 2rem; } + + .admin-workspace-route .pf-v5-c-form__actions, + .admin-workspace-route .keycloak__form_actions { + position: static; + } } diff --git a/src/console-ui.css b/src/console-ui.css index 1c0b34a..31c3cb8 100644 --- a/src/console-ui.css +++ b/src/console-ui.css @@ -5,20 +5,22 @@ */ :root { - --console-ink: #17324d; - --console-muted: #667a8e; - --console-faint: #8da0b1; - --console-canvas: #f3f7fb; + --console-ink: #15334c; + --console-muted: #5f768a; + --console-faint: #8399aa; + --console-canvas: #edf5fa; --console-surface: #ffffff; - --console-surface-soft: #f8fbfe; - --console-nav: #edf4fa; - --console-line: #d9e4ed; - --console-line-strong: #b9cbd9; - --console-primary: #356fd6; - --console-primary-deep: #2459b4; - --console-teal: #279477; - --console-amber: #e9ad38; - --console-coral: #e86452; + --console-surface-soft: #f6fafc; + --console-surface-raised: #ffffff; + --console-surface-sunken: #e7f0f6; + --console-nav: #e5f0f7; + --console-line: #d2e0e9; + --console-line-strong: #adc3d2; + --console-primary: #2f72d6; + --console-primary-deep: #1f58ad; + --console-teal: #1e9476; + --console-amber: #d99b25; + --console-coral: #dd5c4b; --console-danger: #c94046; --console-radius-sm: 8px; --console-radius: 14px; @@ -43,12 +45,14 @@ --console-ink: #eaf2f8; --console-muted: #a4b5c4; --console-faint: #8295a7; - --console-canvas: #0d1823; - --console-surface: #142332; - --console-surface-soft: #182a3a; - --console-nav: #101f2d; - --console-line: #294052; - --console-line-strong: #456176; + --console-canvas: #09151f; + --console-surface: #122331; + --console-surface-soft: #172b3a; + --console-surface-raised: #192d3d; + --console-surface-sunken: #0d1d29; + --console-nav: #0d1d29; + --console-line: #294354; + --console-line-strong: #49667a; --console-primary: #78a8ff; --console-primary-deep: #96baff; --console-teal: #61c7aa; @@ -75,6 +79,8 @@ html[data-kc-theme-name="class"] { --console-muted: #61778c; --console-canvas: #f4f9fd; --console-nav: #edf6fc; + --console-surface-raised: #ffffff; + --console-surface-sunken: #e8f3fa; --console-line: #d4e3ee; --console-line-strong: #abc4d7; --console-primary: #3f7fd6; @@ -91,6 +97,8 @@ html[data-kc-theme-name="class"].pf-v5-theme-dark { --console-surface: #102435; --console-surface-soft: #152c3e; --console-nav: #0d2132; + --console-surface-raised: #193246; + --console-surface-sunken: #0a1824; --console-line: #2d485d; --console-line-strong: #4d6c82; --console-primary: #76aafa; @@ -115,6 +123,16 @@ body { font-size: 15px; } +html[data-console-color-mode] body, +html[data-console-color-mode] .pf-v5-c-page, +html[data-console-color-mode] .pf-v5-c-page__sidebar, +html[data-console-color-mode] .pf-v5-c-masthead { + transition: + color 180ms ease, + background-color 180ms ease, + border-color 180ms ease; +} + .pf-v5-c-page, .pf-v5-c-page__main, .pf-v5-c-page__main-section, @@ -190,6 +208,31 @@ img.keycloak__pageheader_brand { background: var(--console-nav); } +.console-theme-toggle.pf-v5-c-button { + display: inline-flex; + align-items: center; + gap: 0.48rem; + min-width: 5.15rem; + min-height: 38px; + padding: 0.45rem 0.7rem; + border: 1px solid var(--console-line); + border-radius: 999px; + color: var(--console-muted); + background: var(--console-surface-soft); +} + +.console-theme-toggle.pf-v5-c-button:hover { + border-color: var(--console-line-strong); + color: var(--console-primary-deep); + background: var(--console-surface-raised); +} + +.console-theme-toggle__label { + font-size: 0.75rem; + font-weight: 750; + letter-spacing: 0.02em; +} + /* Navigation is now a calm secondary surface instead of a competing dark panel. */ .pf-v5-c-page__sidebar, .pf-v5-c-page__sidebar-body { @@ -345,6 +388,24 @@ img.keycloak__pageheader_brand { box-shadow: var(--console-shadow); } +.pf-v5-c-accordion, +.pf-v5-c-panel, +.pf-v5-c-description-list, +.pf-v5-c-tree-view, +.pf-v5-c-dual-list-selector, +.pf-v5-c-wizard, +.pf-v5-c-code-block { + color: var(--console-ink); +} + +.pf-v5-c-panel, +.pf-v5-c-wizard__main, +.pf-v5-c-drawer__content, +.pf-v5-c-code-block { + border-color: var(--console-line); + background: var(--console-surface); +} + .pf-v5-c-card, .pf-v5-c-table, .pf-v5-c-modal-box { @@ -390,6 +451,10 @@ img.keycloak__pageheader_brand { background: color-mix(in srgb, var(--console-primary) 4%, var(--console-surface)); } +.pf-v5-c-table tbody tr:nth-child(even) > * { + background: color-mix(in srgb, var(--console-surface-soft) 55%, transparent); +} + .pf-v5-c-toolbar__content { padding-inline: 0; } @@ -459,6 +524,12 @@ img.keycloak__pageheader_brand { border-bottom: 1px solid var(--console-line); } +.pf-v5-c-tabs__scroll-button, +.pf-v5-c-tabs__toggle { + color: var(--console-muted); + background: var(--console-surface); +} + .pf-v5-c-tabs__link { color: var(--console-muted); font-weight: 650; @@ -505,6 +576,24 @@ input[type="radio"] { box-shadow: 0 -10px 30px rgba(23, 50, 77, 0.055); } +.pf-v5-c-modal-box__body, +.pf-v5-c-modal-box__footer, +.pf-v5-c-menu__content, +.pf-v5-c-popover__body, +.pf-v5-c-tooltip__content { + color: var(--console-ink); + background: var(--console-surface); +} + +.pf-v5-c-menu__item { + color: var(--console-ink); +} + +.pf-v5-c-menu__item:hover, +.pf-v5-c-menu__item:focus { + background: var(--console-surface-sunken); +} + div.w-tc-editor { border-color: var(--console-line); border-radius: var(--console-radius-sm); @@ -538,6 +627,15 @@ div.w-tc-editor { height: 36px; } + .console-theme-toggle.pf-v5-c-button { + min-width: 2.5rem; + padding-inline: 0.65rem; + } + + .console-theme-toggle__label { + display: none; + } + .pf-v5-c-page__main-section, .pf-v5-c-page__main-breadcrumb { padding-inline: 1rem; diff --git a/src/shared/ThemeModeToggle.tsx b/src/shared/ThemeModeToggle.tsx new file mode 100644 index 0000000..6e2f01f --- /dev/null +++ b/src/shared/ThemeModeToggle.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState } from "react"; +import { Button } from "./@patternfly/react-core"; +import { MoonIcon, SunIcon } from "./@patternfly/react-icons"; +import { + getConsoleColorMode, + subscribeToConsoleColorMode, + toggleConsoleColorMode +} from "./consoleColorScheme"; + +function labels(isDark: boolean) { + const isChinese = document.documentElement.lang.toLowerCase().startsWith("zh"); + + if (isChinese) { + return { + current: isDark ? "暗色" : "亮色", + action: isDark ? "切换至亮色模式" : "切换至暗色模式" + }; + } + + return { + current: isDark ? "Dark" : "Light", + action: isDark ? "Switch to light mode" : "Switch to dark mode" + }; +} + +export function ThemeModeToggle() { + const [mode, setMode] = useState(getConsoleColorMode); + + useEffect(() => subscribeToConsoleColorMode(setMode), []); + + if (document.documentElement.dataset.consoleDarkAllowed === "false") { + return null; + } + + const isDark = mode === "dark"; + const copy = labels(isDark); + + return ( + <Button + variant="plain" + className="console-theme-toggle" + aria-label={copy.action} + title={copy.action} + aria-pressed={isDark} + icon={isDark ? <MoonIcon /> : <SunIcon />} + onClick={toggleConsoleColorMode} + > + <span className="console-theme-toggle__label">{copy.current}</span> + </Button> + ); +} diff --git a/src/shared/consoleColorScheme.ts b/src/shared/consoleColorScheme.ts new file mode 100644 index 0000000..f1f0615 --- /dev/null +++ b/src/shared/consoleColorScheme.ts @@ -0,0 +1,88 @@ +export type ConsoleColorMode = "light" | "dark"; + +const STORAGE_KEY = "nexus-console-color-mode"; +const DARK_THEME_CLASS = "pf-v5-theme-dark"; +const COLOR_MODE_EVENT = "nexus:color-mode-change"; + +function readStoredMode(): ConsoleColorMode | undefined { + try { + const value = localStorage.getItem(STORAGE_KEY); + return value === "light" || value === "dark" ? value : undefined; + } catch { + return undefined; + } +} + +function preferredMode(): ConsoleColorMode { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +export function getConsoleColorMode(): ConsoleColorMode { + return document.documentElement.classList.contains(DARK_THEME_CLASS) + ? "dark" + : "light"; +} + +export function setConsoleColorMode( + mode: ConsoleColorMode, + options: { persist?: boolean } = {} +) { + const { persist = true } = options; + const isDark = mode === "dark"; + const root = document.documentElement; + + document.getElementById("root-color-scheme-style")?.remove(); + + const style = document.createElement("style"); + style.id = "root-color-scheme-style"; + style.textContent = `:root { color-scheme: ${mode}; }`; + document.head.appendChild(style); + + root.style.removeProperty("background-color"); + root.classList.toggle(DARK_THEME_CLASS, isDark); + root.dataset.consoleColorMode = mode; + + if (persist) { + try { + localStorage.setItem(STORAGE_KEY, mode); + } catch { + // Storage can be unavailable in hardened browser sessions. + } + } + + window.dispatchEvent(new CustomEvent(COLOR_MODE_EVENT, { detail: mode })); +} + +export function startConsoleColorScheme(darkModeAllowed: boolean) { + const root = document.documentElement; + root.dataset.consoleDarkAllowed = String(darkModeAllowed); + + if (!darkModeAllowed) { + setConsoleColorMode("light", { persist: false }); + return; + } + + setConsoleColorMode(readStoredMode() ?? preferredMode(), { persist: false }); + + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + mediaQuery.addEventListener("change", event => { + if (!readStoredMode()) { + setConsoleColorMode(event.matches ? "dark" : "light", { + persist: false + }); + } + }); +} + +export function toggleConsoleColorMode() { + setConsoleColorMode(getConsoleColorMode() === "dark" ? "light" : "dark"); +} + +export function subscribeToConsoleColorMode(listener: (mode: ConsoleColorMode) => void) { + const handleChange = (event: Event) => { + listener((event as CustomEvent<ConsoleColorMode>).detail); + }; + + window.addEventListener(COLOR_MODE_EVENT, handleChange); + return () => window.removeEventListener(COLOR_MODE_EVENT, handleChange); +}