This is an automated email from the ASF dual-hosted git repository. hsluoyz pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit 7d64859803c6beebb05130be34f1d4dcd0111d3a Author: Yang Luo <[email protected]> AuthorDate: Fri Sep 4 15:06:06 2026 +0800 feat(theme): let the header switch between amber and neutral palettes --- web/src/App.tsx | 14 ++++- web/src/Setting.tsx | 20 ++++++- web/src/components/shared/app-header.tsx | 57 +++++++++++++++++++- web/src/index.css | 93 ++++++++++++++++++++++++++++++++ web/src/locales/en/data.json | 3 ++ web/src/locales/zh/data.json | 3 ++ web/src/types.ts | 3 ++ 7 files changed, 189 insertions(+), 4 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index f6f052e..25b710e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -46,7 +46,7 @@ import UsagePage from "@/pages/UsagePage"; import PricingPage from "@/pages/PricingPage"; import SettingPage from "@/pages/SettingPage"; import SigninPage from "@/pages/SigninPage"; -import type {Account, ThemeAlgorithm} from "@/types"; +import type {Account, Palette, ThemeAlgorithm} from "@/types"; Setting.initCasdoorSdk(Conf.AuthConfig); @@ -64,6 +64,11 @@ export default function App() { Setting.applyThemeAlgorithm(stored); return stored; }); + const [colorPalette, setColorPalette] = React.useState<Palette>(() => { + const stored = Setting.readPalette(); + Setting.applyPalette(stored); + return stored; + }); const [collapsed, setCollapsed] = React.useState(() => localStorage.getItem(collapsedKey) === "true"); // Below md the rail is a drawer, so the same header button opens it instead // of narrowing a column that is not on screen. @@ -135,6 +140,11 @@ export default function App() { Setting.saveThemeAlgorithm(next); }; + const changeColorPalette = (next: Palette) => { + setColorPalette(next); + Setting.savePalette(next); + }; + const getAccount = React.useCallback(() => { AccountBackend.getAccount() .then(res => { @@ -239,6 +249,8 @@ export default function App() { isAdmin={Setting.isAdminUser(account)} themeAlgorithm={themeAlgorithm} onThemeChange={changeTheme} + colorPalette={colorPalette} + onColorPaletteChange={changeColorPalette} onSignout={signout} onOpenPalette={() => palette.setOpen(true)} /> diff --git a/web/src/Setting.tsx b/web/src/Setting.tsx index 6e90deb..8b9aa90 100644 --- a/web/src/Setting.tsx +++ b/web/src/Setting.tsx @@ -18,7 +18,7 @@ import {toast} from "sonner"; import * as Conf from "@/Conf"; import {CopyButton} from "@/components/shared/misc"; -import type {Account, ApiResponse, ThemeAlgorithm} from "@/types"; +import type {Account, ApiResponse, Palette, ThemeAlgorithm} from "@/types"; /** * Where the REST API lives. It stays empty on purpose: the dev server proxies @@ -231,6 +231,24 @@ export function saveThemeAlgorithm(themeAlgorithm: ThemeAlgorithm) { applyThemeAlgorithm(themeAlgorithm); } +// The palette is orthogonal to light/dark: each one defines both, so switching +// palettes never drags the reader out of the mode they chose. +export const Palettes: Palette[] = ["amber", "neutral"]; + +export function applyPalette(palette: Palette) { + document.documentElement.setAttribute("data-palette", palette); +} + +export function readPalette(): Palette { + const raw = localStorage.getItem("palette"); + return Palettes.includes(raw as Palette) ? (raw as Palette) : "amber"; +} + +export function savePalette(palette: Palette) { + localStorage.setItem("palette", palette); + applyPalette(palette); +} + export function setLanguage(language: string) { localStorage.setItem("language", language); i18next.changeLanguage(language); diff --git a/web/src/components/shared/app-header.tsx b/web/src/components/shared/app-header.tsx index 3cf1325..f41f782 100644 --- a/web/src/components/shared/app-header.tsx +++ b/web/src/components/shared/app-header.tsx @@ -14,11 +14,22 @@ import * as React from "react"; import {useNavigate} from "react-router-dom"; -import {ChevronDown, Languages, LogOut, Moon, PanelLeft, PanelLeftClose, Settings, Sun} from "lucide-react"; +import { + Check, + ChevronDown, + Languages, + LogOut, + Moon, + PanelLeft, + PanelLeftClose, + Palette as PaletteIcon, + Settings, + Sun, +} from "lucide-react"; import i18next from "i18next"; import * as Setting from "@/Setting"; -import type {Account, ThemeAlgorithm} from "@/types"; +import type {Account, Palette, ThemeAlgorithm} from "@/types"; import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"; import {Button} from "@/components/ui/button"; import { @@ -57,6 +68,43 @@ function ThemeToggle({ ); } +// The swatches are literal rather than var(--primary): the menu has to show the +// palette you are not in, and both colours have to read on either background. +const paletteSwatch: Record<Palette, string> = { + amber: "oklch(0.64 0.16 52)", + neutral: "oklch(0.62 0 0)", +}; + +const paletteLabel: Record<Palette, string> = { + amber: "general:Amber & Ink", + neutral: "general:Neutral", +}; + +function PaletteSelect({palette, onChange}: {palette: Palette; onChange: (next: Palette) => void}) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" size="icon-sm" aria-label={i18next.t("general:Palette")}> + <PaletteIcon /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + {Setting.Palettes.map(option => ( + <DropdownMenuItem + key={option} + className={cn(palette === option && "font-semibold")} + onSelect={() => onChange(option)} + > + <span className="size-3 shrink-0 rounded-full" style={{backgroundColor: paletteSwatch[option]}} /> + {i18next.t(paletteLabel[option])} + {palette === option ? <Check className="ml-auto size-3.5" /> : null} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> + ); +} + function LanguageSelect() { const [, forceRender] = React.useReducer((count: number) => count + 1, 0); const current = Setting.getLanguage(); @@ -171,6 +219,8 @@ export function AppHeader({ isAdmin, themeAlgorithm, onThemeChange, + colorPalette, + onColorPaletteChange, onSignout, onOpenPalette, }: { @@ -181,6 +231,8 @@ export function AppHeader({ isAdmin: boolean; themeAlgorithm: ThemeAlgorithm; onThemeChange: (next: ThemeAlgorithm) => void; + colorPalette: Palette; + onColorPaletteChange: (next: Palette) => void; onSignout: () => void; onOpenPalette: () => void; }) { @@ -202,6 +254,7 @@ export function AppHeader({ {account ? <CommandPaletteTrigger onOpen={onOpenPalette} /> : null} <VersionPanel isAdmin={isAdmin} signedIn={account !== null && account !== undefined} /> <ThemeToggle themeAlgorithm={themeAlgorithm} onChange={onThemeChange} /> + <PaletteSelect palette={colorPalette} onChange={onColorPaletteChange} /> <LanguageSelect /> <AccountMenu account={account} onSignout={onSignout} /> </div> diff --git a/web/src/index.css b/web/src/index.css index 1d76dd5..de64168 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -111,6 +111,99 @@ --sidebar-ring: oklch(0.735 0.152 58); } +/* The palette Gateway shipped before "Amber & Ink", kept as an opt-out for + anyone who wants the plain grey. `:not(.dark)` and `.dark` never both match, + so neither block has to out-specify the other or come first. */ +html[data-palette="neutral"]:not(.dark) { + --radius: 0.625rem; + + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.985 0 0); + --success: oklch(0.52 0.115 152); + --success-foreground: oklch(0.985 0 0); + --warning: oklch(0.55 0.115 65); + --warning-foreground: oklch(0.985 0 0); + --info: oklch(0.52 0.115 248); + --info-foreground: oklch(0.985 0 0); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +html[data-palette="neutral"].dark { + --radius: 0.625rem; + + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --destructive-foreground: oklch(0.985 0 0); + --success: oklch(0.72 0.105 158); + --success-foreground: oklch(0.145 0 0); + --warning: oklch(0.78 0.115 72); + --warning-foreground: oklch(0.145 0 0); + --info: oklch(0.7 0.095 245); + --info-foreground: oklch(0.145 0 0); + --border: oklch(1 0 0 / 12%); + --input: oklch(1 0 0 / 18%); + --ring: oklch(0.556 0 0); + + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.985 0 0); + --sidebar-primary-foreground: oklch(0.205 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 12%); + --sidebar-ring: oklch(0.556 0 0); +} + @theme inline { --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json index e600d05..a78a9b2 100644 --- a/web/src/locales/en/data.json +++ b/web/src/locales/en/data.json @@ -737,6 +737,7 @@ "general": { "Action": "Action", "Advanced": "Advanced", + "Amber & Ink": "Amber & Ink", "Back Home": "Back Home", "Built": "Built", "Cancel": "Cancel", @@ -775,12 +776,14 @@ "Login Error": "Login Error", "Name": "Name", "Name cannot be empty": "Name cannot be empty", + "Neutral": "Neutral", "New": "New", "Next": "Next", "No build is published for this platform, so the update has to be done by hand": "No build is published for this platform, so the update has to be done by hand", "No data": "No data", "OK": "OK", "Owner": "Owner", + "Palette": "Palette", "Password": "Password", "Path": "Path", "Previous": "Previous", diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json index 96e410a..ca36f19 100644 --- a/web/src/locales/zh/data.json +++ b/web/src/locales/zh/data.json @@ -809,6 +809,7 @@ "general": { "Action": "操作", "Advanced": "高级", + "Amber & Ink": "琥珀墨", "Back Home": "返回首页", "Built": "构建于", "Cancel": "取消", @@ -847,12 +848,14 @@ "Login Error": "登录失败", "Name": "名称", "Name cannot be empty": "名称不能为空", + "Neutral": "中性灰", "New": "新", "Next": "下一页", "No build is published for this platform, so the update has to be done by hand": "该平台没有发布构建产物,需要手动升级", "No data": "暂无数据", "OK": "确定", "Owner": "所有者", + "Palette": "配色", "Password": "密码", "Path": "路径", "Previous": "上一页", diff --git a/web/src/types.ts b/web/src/types.ts index 5f5a9af..63f9190 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -15,6 +15,9 @@ /** The theme the shell renders in; "dark" toggles the .dark class on <html>. */ export type ThemeAlgorithm = ("default" | "dark")[]; +/** Which colour set the theme draws from, light or dark; see index.css. */ +export type Palette = "amber" | "neutral"; + /** * The envelope every /api handler returns (controllers.Response): "ok" or * "error" in `status`, the payload in `data`, and a second value such as a row --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
