mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-07 08:02:23 +00:00
feat(web): mobile dashboard UX polish (#28127)
* feat(web): mobile dashboard UX polish Bottom sheets for sidebar theme/language pickers on narrow viewports with enter/exit animation and drag-to-close; inline header badges beside titles; bottom padding on the route outlet for scroll clearance; profiles loading uses a unicode braille spinner; align profile/cron card actions to the top; viewport-fit cover and supporting layout tweaks across dashboard pages. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix Nix web npm hash and mobile sheet accessibility. Align fetchNpmDeps in nix/web.nix with web/package-lock.json for CI. Improve BottomPickSheet backdrop labeling, avoid aria-hidden on the dialog during exit animation, and wire theme/language sheets with listbox semantics and localized dismiss labels. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
52e3bfc2f4
commit
6fa1701bd3
24 changed files with 779 additions and 295 deletions
|
|
@ -4,7 +4,7 @@ let
|
||||||
src = ../web;
|
src = ../web;
|
||||||
npmDeps = pkgs.fetchNpmDeps {
|
npmDeps = pkgs.fetchNpmDeps {
|
||||||
inherit src;
|
inherit src;
|
||||||
hash = "sha256-HWB1piIPglTXbzQHXFYHLgVZIbDb60esupXSQGa1+lI=";
|
hash = "sha256-H98reD4N++WroZOQ9NFrKtC5aiHj6KqaYDzUOiZA2bE=";
|
||||||
};
|
};
|
||||||
|
|
||||||
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
|
npm = hermesNpmLib.mkNpmPassthru { folder = "web"; attr = "web"; pname = "hermes-web"; };
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||||
|
/>
|
||||||
<title>Hermes Agent - Dashboard</title>
|
<title>Hermes Agent - Dashboard</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
33
web/package-lock.json
generated
33
web/package-lock.json
generated
|
|
@ -19,6 +19,7 @@
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"flag-icons": "^7.5.0",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
"leva": "^0.10.1",
|
"leva": "^0.10.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
|
|
@ -76,6 +77,7 @@
|
||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
|
|
@ -1124,6 +1126,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz",
|
"resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz",
|
||||||
"integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==",
|
"integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"interval-tree-1d": "^1.0.0",
|
"interval-tree-1d": "^1.0.0",
|
||||||
|
|
@ -1776,6 +1779,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz",
|
||||||
"integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==",
|
"integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.17.8",
|
"@babel/runtime": "^7.17.8",
|
||||||
"@types/webxr": "*",
|
"@types/webxr": "*",
|
||||||
|
|
@ -2481,6 +2485,7 @@
|
||||||
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2490,6 +2495,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
|
|
@ -2500,6 +2506,7 @@
|
||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
|
|
@ -2564,6 +2571,7 @@
|
||||||
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
|
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.59.1",
|
"@typescript-eslint/scope-manager": "8.59.1",
|
||||||
"@typescript-eslint/types": "8.59.1",
|
"@typescript-eslint/types": "8.59.1",
|
||||||
|
|
@ -2892,6 +2900,7 @@
|
||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
|
|
@ -3044,6 +3053,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
|
|
@ -3551,6 +3561,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
|
|
@ -3864,6 +3875,7 @@
|
||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
|
|
@ -4143,6 +4155,12 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/flag-icons": {
|
||||||
|
"version": "7.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
|
||||||
|
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/flat-cache": {
|
"node_modules/flat-cache": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
|
||||||
|
|
@ -4242,7 +4260,8 @@
|
||||||
"version": "3.15.0",
|
"version": "3.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
|
||||||
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
|
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
|
||||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
"license": "Standard 'no charge' license: https://gsap.com/standard-license.",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
|
|
@ -4548,6 +4567,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz",
|
||||||
"integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==",
|
"integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@radix-ui/react-portal": "^1.1.4",
|
"@radix-ui/react-portal": "^1.1.4",
|
||||||
"@radix-ui/react-tooltip": "^1.1.8",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
|
|
@ -4986,6 +5006,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.0.0 || >=22.0.0"
|
"node": "^20.0.0 || >=22.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -5113,6 +5134,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -5184,6 +5206,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -5203,6 +5226,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||||
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
|
|
@ -5562,7 +5586,8 @@
|
||||||
"version": "0.180.0",
|
"version": "0.180.0",
|
||||||
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
"resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz",
|
||||||
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
"integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.16",
|
"version": "0.2.16",
|
||||||
|
|
@ -5627,6 +5652,7 @@
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -5725,6 +5751,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -5740,6 +5767,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
|
||||||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|
@ -5861,6 +5889,7 @@
|
||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"flag-icons": "^7.5.0",
|
||||||
"gsap": "^3.15.0",
|
"gsap": "^3.15.0",
|
||||||
"leva": "^0.10.1",
|
"leva": "^0.10.1",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
|
|
|
||||||
|
|
@ -424,8 +424,8 @@ export default function App() {
|
||||||
|
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
"lg:hidden fixed top-0 left-0 right-0 z-40 h-12",
|
"lg:hidden fixed top-0 left-0 right-0 z-40 min-h-14",
|
||||||
"flex items-center gap-2 px-3",
|
"flex items-center gap-2 px-4 py-2",
|
||||||
"border-b border-current/20",
|
"border-b border-current/20",
|
||||||
"bg-background-base/90 backdrop-blur-sm",
|
"bg-background-base/90 backdrop-blur-sm",
|
||||||
)}
|
)}
|
||||||
|
|
@ -469,7 +469,7 @@ export default function App() {
|
||||||
|
|
||||||
<PluginSlot name="header-banner" />
|
<PluginSlot name="header-banner" />
|
||||||
|
|
||||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-12 lg:pt-0">
|
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-14 lg:pt-0">
|
||||||
<div className="flex min-h-0 min-w-0 flex-1">
|
<div className="flex min-h-0 min-w-0 flex-1">
|
||||||
<aside
|
<aside
|
||||||
id="app-sidebar"
|
id="app-sidebar"
|
||||||
|
|
@ -575,7 +575,7 @@ export default function App() {
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<PluginSlot name="header-right" />
|
<PluginSlot name="header-right" />
|
||||||
<ThemeSwitcher dropUp />
|
<ThemeSwitcher dropUp />
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher dropUp />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -588,8 +588,8 @@ export default function App() {
|
||||||
"relative z-2 flex min-w-0 min-h-0 flex-1 flex-col",
|
"relative z-2 flex min-w-0 min-h-0 flex-1 flex-col",
|
||||||
"px-3 sm:px-6",
|
"px-3 sm:px-6",
|
||||||
isChatRoute
|
isChatRoute
|
||||||
? "pb-3 pt-1 sm:pb-4 sm:pt-2 lg:pt-4"
|
? "pb-0 pt-1 sm:pt-2 lg:pt-4"
|
||||||
: "pt-2 sm:pt-4 lg:pt-6 pb-4 sm:pb-8",
|
: "pt-2 sm:pt-4 lg:pt-6",
|
||||||
isDocsRoute && "min-h-0 flex-1",
|
isDocsRoute && "min-h-0 flex-1",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
@ -597,6 +597,8 @@ export default function App() {
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full min-w-0",
|
"w-full min-w-0",
|
||||||
|
!isChatRoute &&
|
||||||
|
"pb-[calc(2rem+env(safe-area-inset-bottom,0px))] lg:pb-8",
|
||||||
(isDocsRoute || isChatRoute) &&
|
(isDocsRoute || isChatRoute) &&
|
||||||
"min-h-0 flex flex-1 flex-col",
|
"min-h-0 flex flex-1 flex-col",
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
224
web/src/components/BottomPickSheet.tsx
Normal file
224
web/src/components/BottomPickSheet.tsx
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
import {
|
||||||
|
type PointerEvent as ReactPointerEvent,
|
||||||
|
type ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Typography } from "@/components/NouiTypography";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const CLOSE_DRAG_MIN_PX = 72;
|
||||||
|
const CLOSE_DRAG_RATIO = 0.18;
|
||||||
|
const SHEET_TRANSITION_MS = 280;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile-first picker shell: fixed backdrop + bottom sheet, portaled to `body`
|
||||||
|
* so nested overflow/transform in the sidebar cannot clip menus (theme /
|
||||||
|
* language switchers). Open/close uses slide + fade; teardown is delayed until
|
||||||
|
* the exit animation finishes so animations can complete.
|
||||||
|
*
|
||||||
|
* Drag the header/handle downward to dismiss (skipped when reduced motion is on).
|
||||||
|
*/
|
||||||
|
export function BottomPickSheet({
|
||||||
|
backdropDismissLabel = "Dismiss",
|
||||||
|
children,
|
||||||
|
onClose,
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
}: BottomPickSheetProps) {
|
||||||
|
const [renderPortal, setRenderPortal] = useState(open);
|
||||||
|
const [entered, setEntered] = useState(false);
|
||||||
|
const [dragOffsetPx, setDragOffsetPx] = useState(0);
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
|
||||||
|
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const sheetRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragTrackingRef = useRef(false);
|
||||||
|
const dragStartYRef = useRef(0);
|
||||||
|
const dragOffsetRef = useRef(0);
|
||||||
|
|
||||||
|
const reducedMotion =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
|
||||||
|
const syncDragPx = (next: number) => {
|
||||||
|
dragOffsetRef.current = next;
|
||||||
|
setDragOffsetPx(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (closeTimerRef.current) {
|
||||||
|
clearTimeout(closeTimerRef.current);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ms = reducedMotion ? 0 : SHEET_TRANSITION_MS;
|
||||||
|
|
||||||
|
let openRafId = 0;
|
||||||
|
let exitRafId = 0;
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
openRafId = requestAnimationFrame(() => {
|
||||||
|
dragTrackingRef.current = false;
|
||||||
|
dragOffsetRef.current = 0;
|
||||||
|
setDragActive(false);
|
||||||
|
setDragOffsetPx(0);
|
||||||
|
setRenderPortal(true);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => setEntered(true));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
exitRafId = requestAnimationFrame(() => {
|
||||||
|
dragTrackingRef.current = false;
|
||||||
|
setDragActive(false);
|
||||||
|
setEntered(false);
|
||||||
|
closeTimerRef.current = window.setTimeout(() => {
|
||||||
|
dragOffsetRef.current = 0;
|
||||||
|
setDragOffsetPx(0);
|
||||||
|
setRenderPortal(false);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(openRafId);
|
||||||
|
cancelAnimationFrame(exitRafId);
|
||||||
|
if (closeTimerRef.current) {
|
||||||
|
clearTimeout(closeTimerRef.current);
|
||||||
|
closeTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [open, reducedMotion]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!renderPortal) return;
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, [renderPortal]);
|
||||||
|
|
||||||
|
if (!renderPortal || typeof document === "undefined") return null;
|
||||||
|
|
||||||
|
const durationClass = reducedMotion ? "duration-0" : "duration-[280ms]";
|
||||||
|
|
||||||
|
const draggingVisual = dragActive || dragOffsetPx > 0;
|
||||||
|
|
||||||
|
const onDragPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (reducedMotion || !entered) return;
|
||||||
|
if (e.pointerType === "mouse" && e.button !== 0) return;
|
||||||
|
|
||||||
|
dragTrackingRef.current = true;
|
||||||
|
setDragActive(true);
|
||||||
|
dragStartYRef.current = e.clientY;
|
||||||
|
syncDragPx(0);
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!dragTrackingRef.current) return;
|
||||||
|
const dy = e.clientY - dragStartYRef.current;
|
||||||
|
const next = Math.max(0, dy);
|
||||||
|
const sheetH = sheetRef.current?.offsetHeight ?? 560;
|
||||||
|
syncDragPx(Math.min(next, sheetH));
|
||||||
|
};
|
||||||
|
|
||||||
|
const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||||
|
if (!dragTrackingRef.current) return;
|
||||||
|
dragTrackingRef.current = false;
|
||||||
|
setDragActive(false);
|
||||||
|
try {
|
||||||
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* already released */
|
||||||
|
}
|
||||||
|
|
||||||
|
const sheetH = sheetRef.current?.offsetHeight ?? 560;
|
||||||
|
const threshold = Math.max(CLOSE_DRAG_MIN_PX, sheetH * CLOSE_DRAG_RATIO);
|
||||||
|
const d = dragOffsetRef.current;
|
||||||
|
|
||||||
|
if (d >= threshold) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncDragPx(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[200] flex flex-col justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={backdropDismissLabel}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 bg-black/55 backdrop-blur-[2px]",
|
||||||
|
"transition-opacity ease-out motion-reduce:transition-none",
|
||||||
|
durationClass,
|
||||||
|
entered ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-label={title}
|
||||||
|
aria-modal="true"
|
||||||
|
ref={sheetRef}
|
||||||
|
className={cn(
|
||||||
|
"relative flex max-h-[85dvh] min-h-0 flex-col rounded-t-xl border border-current/20",
|
||||||
|
"bg-background-base/98 pb-[max(1rem,env(safe-area-inset-bottom))]",
|
||||||
|
"shadow-[0_-12px_40px_-8px_rgba(0,0,0,0.55)] backdrop-blur-md",
|
||||||
|
"ease-out motion-reduce:transition-none transform-gpu",
|
||||||
|
draggingVisual ? "transition-none" : cn("transition-transform", durationClass),
|
||||||
|
entered ? "translate-y-0" : "translate-y-full",
|
||||||
|
)}
|
||||||
|
role="dialog"
|
||||||
|
style={
|
||||||
|
entered && dragOffsetPx > 0
|
||||||
|
? { transform: `translateY(${dragOffsetPx}px)` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex shrink-0 flex-col gap-2 border-b border-current/15 px-4 pb-3 pt-2",
|
||||||
|
"touch-none select-none",
|
||||||
|
reducedMotion ? "cursor-default" : "cursor-grab active:cursor-grabbing",
|
||||||
|
)}
|
||||||
|
onPointerCancel={endDrag}
|
||||||
|
onPointerDown={onDragPointerDown}
|
||||||
|
onPointerMove={onDragPointerMove}
|
||||||
|
onPointerUp={endDrag}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="mx-auto h-1 w-10 shrink-0 rounded-full bg-current/20"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography
|
||||||
|
mondwest
|
||||||
|
className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BottomPickSheetProps {
|
||||||
|
backdropDismissLabel?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
open: boolean;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { Button } from "@nous-research/ui/ui/components/button";
|
import { Button } from "@nous-research/ui/ui/components/button";
|
||||||
|
import { BottomPickSheet } from "@/components/BottomPickSheet";
|
||||||
import { Typography } from "@/components/NouiTypography";
|
import { Typography } from "@/components/NouiTypography";
|
||||||
|
import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint";
|
||||||
import { useI18n } from "@/i18n/context";
|
import { useI18n } from "@/i18n/context";
|
||||||
import { LOCALE_META } from "@/i18n";
|
import { LOCALE_META } from "@/i18n";
|
||||||
import type { Locale } from "@/i18n";
|
import type { Locale } from "@/i18n";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Language picker — shows the current language's flag + endonym, opens a
|
* Language picker — shows the current language's flag + endonym, opens a
|
||||||
|
|
@ -12,15 +15,34 @@ import type { Locale } from "@/i18n";
|
||||||
*
|
*
|
||||||
* Replaces the older two-state EN↔ZH toggle now that we ship 16 locales
|
* Replaces the older two-state EN↔ZH toggle now that we ship 16 locales
|
||||||
* (en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu).
|
* (en, zh, zh-hant, ja, de, es, fr, tr, uk, af, ko, it, ga, pt, ru, hu).
|
||||||
|
*
|
||||||
|
* Locale markers use lipis/flag-icons (SVG sprites) instead of emoji so flags
|
||||||
|
* render consistently across platforms.
|
||||||
|
*
|
||||||
|
* When placed at the bottom of the sidebar (next to ThemeSwitcher), pass
|
||||||
|
* `dropUp` so the list opens above the trigger and avoids clipping below the
|
||||||
|
* viewport / overflow ancestors. Below the `sm` breakpoint, `dropUp` uses a
|
||||||
|
* bottom sheet portaled to `document.body` instead of an anchored dropdown.
|
||||||
*/
|
*/
|
||||||
export function LanguageSwitcher() {
|
export function LanguageSwitcher({ dropUp = false }: LanguageSwitcherProps) {
|
||||||
const { locale, setLocale, t } = useI18n();
|
const { locale, setLocale, t } = useI18n();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const narrowViewport = useBelowBreakpoint(640);
|
||||||
|
const useMobileSheet = Boolean(dropUp && narrowViewport);
|
||||||
|
|
||||||
// Close on outside click / Escape so the dropdown doesn't trap the user.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Outside-click closing only for anchored dropdown — sheet uses backdrop + portal.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || useMobileSheet) return;
|
||||||
|
|
||||||
function onPointerDown(e: PointerEvent) {
|
function onPointerDown(e: PointerEvent) {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
@ -28,20 +50,14 @@ export function LanguageSwitcher() {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onKey(e: KeyboardEvent) {
|
|
||||||
if (e.key === "Escape") setOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("pointerdown", onPointerDown);
|
document.addEventListener("pointerdown", onPointerDown);
|
||||||
document.addEventListener("keydown", onKey);
|
return () => document.removeEventListener("pointerdown", onPointerDown);
|
||||||
return () => {
|
}, [open, useMobileSheet]);
|
||||||
document.removeEventListener("pointerdown", onPointerDown);
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
};
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
const current = LOCALE_META[locale];
|
const current = LOCALE_META[locale];
|
||||||
const allLocales = Object.entries(LOCALE_META) as Array<[Locale, typeof current]>;
|
const allLocales = Object.entries(LOCALE_META) as Array<[Locale, typeof current]>;
|
||||||
|
const sheetTitle = t.language.switchTo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative inline-flex">
|
<div ref={containerRef} className="relative inline-flex">
|
||||||
|
|
@ -55,7 +71,7 @@ export function LanguageSwitcher() {
|
||||||
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-muted-foreground hover:text-foreground"
|
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-1.5">
|
<span className="inline-flex items-center gap-1.5">
|
||||||
<span className="text-base leading-none">{current.flag}</span>
|
<LocaleFlagIcon countryCode={current.flagCountryCode} />
|
||||||
<Typography
|
<Typography
|
||||||
mondwest
|
mondwest
|
||||||
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
|
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
|
||||||
|
|
@ -65,36 +81,103 @@ export function LanguageSwitcher() {
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{open && (
|
{useMobileSheet && (
|
||||||
<div
|
<BottomPickSheet
|
||||||
role="listbox"
|
backdropDismissLabel={t.common.close}
|
||||||
aria-label={t.language.switchTo}
|
onClose={() => setOpen(false)}
|
||||||
className="absolute right-0 top-full mt-1 z-50 min-w-[10rem] rounded-md border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto"
|
open={open}
|
||||||
|
title={sheetTitle}
|
||||||
>
|
>
|
||||||
{allLocales.map(([code, meta]) => {
|
<div aria-label={sheetTitle} role="listbox">
|
||||||
const selected = code === locale;
|
<LanguageSwitcherOptions
|
||||||
return (
|
allLocales={allLocales}
|
||||||
<button
|
locale={locale}
|
||||||
key={code}
|
setLocale={setLocale}
|
||||||
role="option"
|
setOpen={setOpen}
|
||||||
aria-selected={selected}
|
/>
|
||||||
onClick={() => {
|
</div>
|
||||||
setLocale(code);
|
</BottomPickSheet>
|
||||||
setOpen(false);
|
)}
|
||||||
}}
|
|
||||||
className={
|
{open && !useMobileSheet && (
|
||||||
"w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-accent hover:text-accent-foreground transition-colors " +
|
<div
|
||||||
(selected ? "font-semibold text-foreground" : "text-muted-foreground")
|
aria-label={sheetTitle}
|
||||||
}
|
className={cn(
|
||||||
>
|
"absolute right-0 z-50 min-w-[10rem] rounded-md border border-border bg-popover shadow-md py-1 max-h-80 overflow-y-auto",
|
||||||
<span className="text-base leading-none">{meta.flag}</span>
|
dropUp ? "bottom-full mb-1" : "top-full mt-1",
|
||||||
<span className="truncate">{meta.name}</span>
|
)}
|
||||||
{selected && <span className="ml-auto text-xs">✓</span>}
|
role="listbox"
|
||||||
</button>
|
>
|
||||||
);
|
<LanguageSwitcherOptions
|
||||||
})}
|
allLocales={allLocales}
|
||||||
|
locale={locale}
|
||||||
|
setLocale={setLocale}
|
||||||
|
setOpen={setOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LanguageSwitcherOptions({
|
||||||
|
allLocales,
|
||||||
|
locale,
|
||||||
|
setLocale,
|
||||||
|
setOpen,
|
||||||
|
}: LanguageSwitcherOptionsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{allLocales.map(([code, meta]) => {
|
||||||
|
const selected = code === locale;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-selected={selected}
|
||||||
|
className={
|
||||||
|
"w-full text-left px-3 py-1.5 text-xs flex items-center gap-2 hover:bg-accent hover:text-accent-foreground transition-colors " +
|
||||||
|
(selected ? "font-semibold text-foreground" : "text-muted-foreground")
|
||||||
|
}
|
||||||
|
key={code}
|
||||||
|
onClick={() => {
|
||||||
|
setLocale(code);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
role="option"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<LocaleFlagIcon countryCode={meta.flagCountryCode} />
|
||||||
|
|
||||||
|
<span className="truncate">{meta.name}</span>
|
||||||
|
|
||||||
|
{selected && <span className="ml-auto text-xs">✓</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocaleFlagIcon({ countryCode }: LocaleFlagIconProps) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn("fi fis shrink-0 text-base leading-none", `fi-${countryCode}`)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LanguageSwitcherOptionsProps {
|
||||||
|
allLocales: Array<[Locale, (typeof LOCALE_META)[Locale]]>;
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (code: Locale) => void;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LanguageSwitcherProps {
|
||||||
|
dropUp?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocaleFlagIconProps {
|
||||||
|
countryCode: string;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Palette, Check } from "lucide-react";
|
import { Palette, Check } from "lucide-react";
|
||||||
import { Button } from "@nous-research/ui/ui/components/button";
|
import { Button } from "@nous-research/ui/ui/components/button";
|
||||||
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
||||||
|
import { BottomPickSheet } from "@/components/BottomPickSheet";
|
||||||
import { Typography } from "@/components/NouiTypography";
|
import { Typography } from "@/components/NouiTypography";
|
||||||
|
import { useBelowBreakpoint } from "@/hooks/useBelowBreakpoint";
|
||||||
import { BUILTIN_THEMES, useTheme } from "@/themes";
|
import { BUILTIN_THEMES, useTheme } from "@/themes";
|
||||||
import type { DashboardTheme } from "@/themes";
|
import type { DashboardTheme, ThemeListEntry } from "@/themes";
|
||||||
import { useI18n } from "@/i18n";
|
import { useI18n } from "@/i18n";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -17,18 +19,31 @@ import { cn } from "@/lib/utils";
|
||||||
*
|
*
|
||||||
* When placed at the bottom of a container (e.g. the sidebar rail), pass
|
* When placed at the bottom of a container (e.g. the sidebar rail), pass
|
||||||
* `dropUp` so the menu opens above the trigger instead of clipping below
|
* `dropUp` so the menu opens above the trigger instead of clipping below
|
||||||
* the viewport.
|
* the viewport. On viewports below the `sm` breakpoint, `dropUp` uses a
|
||||||
|
* bottom sheet portaled to `document.body` so the picker is not clipped by
|
||||||
|
* the sidebar (same idea as a responsive Drawer).
|
||||||
*/
|
*/
|
||||||
export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||||
const { themeName, availableThemes, setTheme } = useTheme();
|
const { themeName, availableThemes, setTheme } = useTheme();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const narrowViewport = useBelowBreakpoint(640);
|
||||||
|
const useMobileSheet = Boolean(dropUp && narrowViewport);
|
||||||
|
|
||||||
const close = useCallback(() => setOpen(false), []);
|
const close = useCallback(() => setOpen(false), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") close();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, close]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || useMobileSheet) return;
|
||||||
const onMouseDown = (e: MouseEvent) => {
|
const onMouseDown = (e: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
wrapperRef.current &&
|
wrapperRef.current &&
|
||||||
|
|
@ -37,19 +52,13 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||||
close();
|
close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const onKey = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") close();
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", onMouseDown);
|
document.addEventListener("mousedown", onMouseDown);
|
||||||
document.addEventListener("keydown", onKey);
|
return () => document.removeEventListener("mousedown", onMouseDown);
|
||||||
return () => {
|
}, [open, close, useMobileSheet]);
|
||||||
document.removeEventListener("mousedown", onMouseDown);
|
|
||||||
document.removeEventListener("keydown", onKey);
|
|
||||||
};
|
|
||||||
}, [open, close]);
|
|
||||||
|
|
||||||
const current = availableThemes.find((th) => th.name === themeName);
|
const current = availableThemes.find((th) => th.name === themeName);
|
||||||
const label = current?.label ?? themeName;
|
const label = current?.label ?? themeName;
|
||||||
|
const sheetTitle = t.theme?.title ?? "Theme";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={wrapperRef} className="relative">
|
<div ref={wrapperRef} className="relative">
|
||||||
|
|
@ -74,77 +83,113 @@ export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{open && (
|
{useMobileSheet && (
|
||||||
|
<BottomPickSheet
|
||||||
|
backdropDismissLabel={t.common.close}
|
||||||
|
onClose={close}
|
||||||
|
open={open}
|
||||||
|
title={sheetTitle}
|
||||||
|
>
|
||||||
|
<div aria-label={sheetTitle} role="listbox">
|
||||||
|
<ThemeSwitcherOptions
|
||||||
|
availableThemes={availableThemes}
|
||||||
|
close={close}
|
||||||
|
setTheme={setTheme}
|
||||||
|
themeName={themeName}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</BottomPickSheet>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{open && !useMobileSheet && (
|
||||||
<div
|
<div
|
||||||
role="listbox"
|
aria-label={sheetTitle}
|
||||||
aria-label={t.theme?.title ?? "Theme"}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute z-50 min-w-[240px] max-h-[70dvh] overflow-y-auto",
|
"absolute z-50 min-w-[240px] max-h-[70dvh] overflow-y-auto",
|
||||||
dropUp ? "left-0 bottom-full mb-1" : "right-0 top-full mt-1",
|
dropUp ? "left-0 bottom-full mb-1" : "right-0 top-full mt-1",
|
||||||
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
|
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
|
||||||
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
|
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
|
||||||
)}
|
)}
|
||||||
|
role="listbox"
|
||||||
>
|
>
|
||||||
<div className="border-b border-current/20 px-3 py-2">
|
<div className="border-b border-current/20 px-3 py-2">
|
||||||
<Typography
|
<Typography
|
||||||
mondwest
|
mondwest
|
||||||
className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70"
|
className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70"
|
||||||
>
|
>
|
||||||
{t.theme?.title ?? "Theme"}
|
{sheetTitle}
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{availableThemes.map((th) => {
|
<ThemeSwitcherOptions
|
||||||
const isActive = th.name === themeName;
|
availableThemes={availableThemes}
|
||||||
const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition;
|
close={close}
|
||||||
|
setTheme={setTheme}
|
||||||
return (
|
themeName={themeName}
|
||||||
<ListItem
|
/>
|
||||||
key={th.name}
|
|
||||||
active={isActive}
|
|
||||||
role="option"
|
|
||||||
aria-selected={isActive}
|
|
||||||
onClick={() => {
|
|
||||||
setTheme(th.name);
|
|
||||||
close();
|
|
||||||
}}
|
|
||||||
className="gap-3"
|
|
||||||
>
|
|
||||||
{paletteTheme ? (
|
|
||||||
<ThemeSwatch theme={paletteTheme} />
|
|
||||||
) : (
|
|
||||||
<PlaceholderSwatch />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
||||||
<Typography
|
|
||||||
mondwest
|
|
||||||
className="truncate text-[0.75rem] tracking-wide uppercase"
|
|
||||||
>
|
|
||||||
{th.label}
|
|
||||||
</Typography>
|
|
||||||
{th.description && (
|
|
||||||
<Typography className="truncate text-[0.65rem] normal-case tracking-normal text-midground/50">
|
|
||||||
{th.description}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"h-3 w-3 shrink-0 text-midground",
|
|
||||||
isActive ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ThemeSwitcherOptions({
|
||||||
|
availableThemes,
|
||||||
|
close,
|
||||||
|
setTheme,
|
||||||
|
themeName,
|
||||||
|
}: ThemeSwitcherOptionsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{availableThemes.map((th) => {
|
||||||
|
const isActive = th.name === themeName;
|
||||||
|
const paletteTheme = BUILTIN_THEMES[th.name] ?? th.definition;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListItem
|
||||||
|
active={isActive}
|
||||||
|
aria-selected={isActive}
|
||||||
|
className="gap-3"
|
||||||
|
key={th.name}
|
||||||
|
onClick={() => {
|
||||||
|
setTheme(th.name);
|
||||||
|
close();
|
||||||
|
}}
|
||||||
|
role="option"
|
||||||
|
>
|
||||||
|
{paletteTheme ? (
|
||||||
|
<ThemeSwatch theme={paletteTheme} />
|
||||||
|
) : (
|
||||||
|
<PlaceholderSwatch />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<Typography
|
||||||
|
mondwest
|
||||||
|
className="truncate text-[0.75rem] tracking-wide uppercase"
|
||||||
|
>
|
||||||
|
{th.label}
|
||||||
|
</Typography>
|
||||||
|
{th.description && (
|
||||||
|
<Typography className="truncate text-[0.65rem] normal-case tracking-normal text-midground/50">
|
||||||
|
{th.description}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"h-3 w-3 shrink-0 text-midground",
|
||||||
|
isActive ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
|
function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
|
||||||
const { background, midground, warmGlow } = theme.palette;
|
const { background, midground, warmGlow } = theme.palette;
|
||||||
return (
|
return (
|
||||||
|
|
@ -168,6 +213,13 @@ function PlaceholderSwatch() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ThemeSwitcherOptionsProps {
|
||||||
|
availableThemes: ThemeListEntry[];
|
||||||
|
close: () => void;
|
||||||
|
setTheme: (name: string) => void;
|
||||||
|
themeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ThemeSwitcherProps {
|
interface ThemeSwitcherProps {
|
||||||
dropUp?: boolean;
|
dropUp?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ export function PageHeaderProvider({
|
||||||
const displayTitle = titleOverride ?? defaultTitle;
|
const displayTitle = titleOverride ?? defaultTitle;
|
||||||
|
|
||||||
const isChatRoute = pathname === "/chat" || pathname === "/chat/";
|
const isChatRoute = pathname === "/chat" || pathname === "/chat/";
|
||||||
|
/** Env jump-nav is wide — stack below title on small screens so KEYS stays readable. */
|
||||||
|
const isEnvRoute =
|
||||||
|
pathname === "/env" || pathname.startsWith("/env/");
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
@ -51,37 +54,65 @@ export function PageHeaderProvider({
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
"z-1 w-full shrink-0",
|
"z-1 w-full shrink-0",
|
||||||
"box-border h-14 min-h-14",
|
"box-border border-b border-current/20",
|
||||||
"border-b border-current/20",
|
|
||||||
"bg-background-base/40 backdrop-blur-sm",
|
"bg-background-base/40 backdrop-blur-sm",
|
||||||
"overflow-hidden",
|
// Mobile stacks title + toolbar — fixed h-14 clips content; desktop stays one row.
|
||||||
"sm:min-h-0",
|
"min-h-0 overflow-x-hidden overflow-y-visible py-3 sm:h-14 sm:min-h-[3.5rem] sm:overflow-hidden sm:py-0",
|
||||||
)}
|
)}
|
||||||
role="banner"
|
role="banner"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full min-w-0 flex-1 gap-2 px-3 py-2 sm:gap-3 sm:px-6 sm:py-0",
|
"flex w-full min-w-0 flex-1 gap-3 px-3 sm:h-full sm:gap-3 sm:px-6",
|
||||||
isChatRoute
|
isChatRoute
|
||||||
? "flex-row items-center"
|
? "flex-row items-center"
|
||||||
: "flex-col justify-center sm:flex-row sm:items-center",
|
: "flex-col justify-center sm:flex-row sm:items-center",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-0 flex-1 gap-2 sm:gap-3",
|
||||||
|
afterTitle && isEnvRoute
|
||||||
|
? "flex-col items-start sm:flex-row sm:items-center"
|
||||||
|
: afterTitle
|
||||||
|
? "flex-row flex-wrap items-center"
|
||||||
|
: "flex-row items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<h1
|
<h1
|
||||||
className="font-expanded min-w-0 truncate text-sm font-bold tracking-[0.08em] text-midground"
|
className={cn(
|
||||||
|
"font-expanded min-w-0 text-sm font-bold tracking-[0.08em] text-midground",
|
||||||
|
afterTitle && isEnvRoute
|
||||||
|
? "max-w-full sm:min-w-0 sm:shrink sm:truncate"
|
||||||
|
: afterTitle
|
||||||
|
? "shrink truncate"
|
||||||
|
: "truncate",
|
||||||
|
)}
|
||||||
style={{ mixBlendMode: "plus-lighter" }}
|
style={{ mixBlendMode: "plus-lighter" }}
|
||||||
>
|
>
|
||||||
{displayTitle}
|
{displayTitle}
|
||||||
</h1>
|
</h1>
|
||||||
{afterTitle}
|
{afterTitle ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 scrollbar-none",
|
||||||
|
isEnvRoute
|
||||||
|
? "w-full overflow-x-auto sm:flex-1 sm:overflow-x-auto"
|
||||||
|
: "shrink-0 overflow-visible",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{afterTitle}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{end ? (
|
{end ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex min-w-0 justify-end sm:max-w-md sm:flex-1",
|
"flex min-w-0 sm:max-w-md sm:flex-1",
|
||||||
isChatRoute ? "w-auto shrink-0" : "w-full",
|
isChatRoute
|
||||||
|
? "w-auto shrink-0 justify-end"
|
||||||
|
: "w-full justify-start sm:justify-end",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{end}
|
{end}
|
||||||
|
|
@ -93,6 +124,8 @@ export function PageHeaderProvider({
|
||||||
<main
|
<main
|
||||||
className={cn(
|
className={cn(
|
||||||
"min-h-0 w-full min-w-0 flex-1 flex flex-col",
|
"min-h-0 w-full min-w-0 flex-1 flex flex-col",
|
||||||
|
// Bottom inset for scrolled pages lives on the route outlet wrapper in
|
||||||
|
// `App.tsx` (`w-full min-w-0`) so it pads scrollable content, not flex chrome.
|
||||||
isChatRoute
|
isChatRoute
|
||||||
? "overflow-hidden"
|
? "overflow-hidden"
|
||||||
: "overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]",
|
: "overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]",
|
||||||
|
|
|
||||||
19
web/src/hooks/useBelowBreakpoint.ts
Normal file
19
web/src/hooks/useBelowBreakpoint.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/** True when viewport width is strictly below `px` (matches Tailwind `min-width: px`). */
|
||||||
|
export function useBelowBreakpoint(px: number) {
|
||||||
|
const query = `(max-width: ${px - 1}px)`;
|
||||||
|
const [matches, setMatches] = useState(() =>
|
||||||
|
typeof window !== "undefined" ? window.matchMedia(query).matches : false,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mql = window.matchMedia(query);
|
||||||
|
const sync = () => setMatches(mql.matches);
|
||||||
|
sync();
|
||||||
|
mql.addEventListener("change", sync);
|
||||||
|
return () => mql.removeEventListener("change", sync);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
@ -38,25 +38,26 @@ const TRANSLATIONS: Record<Locale, Translations> = {
|
||||||
|
|
||||||
// Display metadata for the language picker — endonym (native name) so users
|
// Display metadata for the language picker — endonym (native name) so users
|
||||||
// recognize their language even if they don't speak the current UI language,
|
// recognize their language even if they don't speak the current UI language,
|
||||||
// plus a flag emoji for visual scanning. Exposed as a constant so the
|
// plus a flag-icons sprite (ISO 3166-1 alpha-2) for visual scanning.
|
||||||
// LanguageSwitcher and any future settings page can share the same list.
|
// Exposed as a constant so the LanguageSwitcher and any future settings page
|
||||||
export const LOCALE_META: Record<Locale, { name: string; flag: string }> = {
|
// can share the same list.
|
||||||
en: { name: "English", flag: "🇬🇧" },
|
export const LOCALE_META: Record<Locale, { name: string; flagCountryCode: string }> = {
|
||||||
zh: { name: "简体中文", flag: "🇨🇳" },
|
en: { name: "English", flagCountryCode: "gb" },
|
||||||
"zh-hant": { name: "繁體中文", flag: "🇹🇼" },
|
zh: { name: "简体中文", flagCountryCode: "cn" },
|
||||||
ja: { name: "日本語", flag: "🇯🇵" },
|
"zh-hant": { name: "繁體中文", flagCountryCode: "tw" },
|
||||||
de: { name: "Deutsch", flag: "🇩🇪" },
|
ja: { name: "日本語", flagCountryCode: "jp" },
|
||||||
es: { name: "Español", flag: "🇪🇸" },
|
de: { name: "Deutsch", flagCountryCode: "de" },
|
||||||
fr: { name: "Français", flag: "🇫🇷" },
|
es: { name: "Español", flagCountryCode: "es" },
|
||||||
tr: { name: "Türkçe", flag: "🇹🇷" },
|
fr: { name: "Français", flagCountryCode: "fr" },
|
||||||
uk: { name: "Українська", flag: "🇺🇦" },
|
tr: { name: "Türkçe", flagCountryCode: "tr" },
|
||||||
af: { name: "Afrikaans", flag: "🇿🇦" },
|
uk: { name: "Українська", flagCountryCode: "ua" },
|
||||||
ko: { name: "한국어", flag: "🇰🇷" },
|
af: { name: "Afrikaans", flagCountryCode: "za" },
|
||||||
it: { name: "Italiano", flag: "🇮🇹" },
|
ko: { name: "한국어", flagCountryCode: "kr" },
|
||||||
ga: { name: "Gaeilge", flag: "🇮🇪" },
|
it: { name: "Italiano", flagCountryCode: "it" },
|
||||||
pt: { name: "Português", flag: "🇵🇹" },
|
ga: { name: "Gaeilge", flagCountryCode: "ie" },
|
||||||
ru: { name: "Русский", flag: "🇷🇺" },
|
pt: { name: "Português", flagCountryCode: "pt" },
|
||||||
hu: { name: "Magyar", flag: "🇭🇺" },
|
ru: { name: "Русский", flagCountryCode: "ru" },
|
||||||
|
hu: { name: "Magyar", flagCountryCode: "hu" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const SUPPORTED_LOCALES = Object.keys(TRANSLATIONS) as Locale[];
|
const SUPPORTED_LOCALES = Object.keys(TRANSLATIONS) as Locale[];
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import "flag-icons/css/flag-icons.min.css";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { SystemActionsProvider } from "./contexts/SystemActions";
|
import { SystemActionsProvider } from "./contexts/SystemActions";
|
||||||
|
|
|
||||||
|
|
@ -439,7 +439,7 @@ export default function AnalyticsPage() {
|
||||||
);
|
);
|
||||||
setEnd(
|
setEnd(
|
||||||
showTokens === false ? null : (
|
showTokens === false ? null : (
|
||||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-2">
|
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{PERIODS.map((p) => (
|
{PERIODS.map((p) => (
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -417,14 +417,14 @@ export default function ConfigPage() {
|
||||||
<PluginSlot name="config:top" />
|
<PluginSlot name="config:top" />
|
||||||
<Toast toast={toast} />
|
<Toast toast={toast} />
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2 sm:flex-1">
|
||||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5">
|
<code className="min-w-0 flex-1 break-words text-xs text-muted-foreground bg-muted/50 px-2 py-0.5">
|
||||||
{configPath ?? t.config.configPath}
|
{configPath ?? t.config.configPath}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5 sm:shrink-0">
|
||||||
<Button
|
<Button
|
||||||
ghost
|
ghost
|
||||||
size="icon"
|
size="icon"
|
||||||
|
|
|
||||||
|
|
@ -370,7 +370,7 @@ export default function CronPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={job.id}>
|
<Card key={job.id}>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<CardContent className="flex items-start gap-4 py-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="font-medium text-sm truncate">
|
<span className="font-medium text-sm truncate">
|
||||||
|
|
|
||||||
|
|
@ -537,13 +537,16 @@ export default function EnvPage() {
|
||||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
};
|
};
|
||||||
setAfterTitle(
|
setAfterTitle(
|
||||||
<nav className="flex items-center gap-1" aria-label="Jump to section">
|
<nav
|
||||||
|
className="flex shrink-0 flex-nowrap items-center gap-1"
|
||||||
|
aria-label="Jump to section"
|
||||||
|
>
|
||||||
{sections.map((s) => (
|
{sections.map((s) => (
|
||||||
<button
|
<button
|
||||||
key={s.id}
|
key={s.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => scrollTo(s.id)}
|
onClick={() => scrollTo(s.id)}
|
||||||
className="cursor-pointer px-2 py-0.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors"
|
className="shrink-0 cursor-pointer px-2 py-0.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground border border-border/50 hover:border-foreground/30 transition-colors"
|
||||||
>
|
>
|
||||||
{s.label}
|
{s.label}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,12 @@ const LINE_COLORS: Record<string, string> = {
|
||||||
const toOptions = <T extends string>(values: readonly T[]) =>
|
const toOptions = <T extends string>(values: readonly T[]) =>
|
||||||
values.map((v) => ({ value: v, label: v }));
|
values.map((v) => ({ value: v, label: v }));
|
||||||
|
|
||||||
|
const filterGroupClass =
|
||||||
|
"flex min-w-0 w-full flex-col items-start gap-1.5 sm:w-auto sm:max-w-full sm:flex-row sm:items-center";
|
||||||
|
|
||||||
|
const segmentedClass =
|
||||||
|
"w-fit max-w-full flex-wrap justify-start self-start";
|
||||||
|
|
||||||
export default function LogsPage() {
|
export default function LogsPage() {
|
||||||
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
|
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
|
||||||
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
|
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
|
||||||
|
|
@ -87,7 +93,7 @@ export default function LogsPage() {
|
||||||
</span>,
|
</span>,
|
||||||
);
|
);
|
||||||
setEnd(
|
setEnd(
|
||||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-3">
|
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
checked={autoRefresh}
|
checked={autoRefresh}
|
||||||
|
|
@ -145,39 +151,43 @@ export default function LogsPage() {
|
||||||
}, [autoRefresh, fetchLogs]);
|
}, [autoRefresh, fetchLogs]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex min-w-0 max-w-full flex-col gap-4">
|
||||||
<PluginSlot name="logs:top" />
|
<PluginSlot name="logs:top" />
|
||||||
<div
|
<div
|
||||||
role="toolbar"
|
role="toolbar"
|
||||||
aria-label={t.logs.title}
|
aria-label={t.logs.title}
|
||||||
className="flex flex-wrap items-center gap-x-6 gap-y-2"
|
className="flex min-w-0 max-w-full flex-col items-start gap-3 sm:flex-row sm:flex-wrap sm:items-start sm:gap-x-6 sm:gap-y-3"
|
||||||
>
|
>
|
||||||
<FilterGroup label={t.logs.file}>
|
<FilterGroup label={t.logs.file} className={filterGroupClass}>
|
||||||
<Segmented
|
<Segmented
|
||||||
|
className={segmentedClass}
|
||||||
value={file}
|
value={file}
|
||||||
onChange={setFile}
|
onChange={setFile}
|
||||||
options={toOptions(FILES)}
|
options={toOptions(FILES)}
|
||||||
/>
|
/>
|
||||||
</FilterGroup>
|
</FilterGroup>
|
||||||
|
|
||||||
<FilterGroup label={t.logs.level}>
|
<FilterGroup label={t.logs.level} className={filterGroupClass}>
|
||||||
<Segmented
|
<Segmented
|
||||||
|
className={segmentedClass}
|
||||||
value={level}
|
value={level}
|
||||||
onChange={setLevel}
|
onChange={setLevel}
|
||||||
options={toOptions(LEVELS)}
|
options={toOptions(LEVELS)}
|
||||||
/>
|
/>
|
||||||
</FilterGroup>
|
</FilterGroup>
|
||||||
|
|
||||||
<FilterGroup label={t.logs.component}>
|
<FilterGroup label={t.logs.component} className={filterGroupClass}>
|
||||||
<Segmented
|
<Segmented
|
||||||
|
className={segmentedClass}
|
||||||
value={component}
|
value={component}
|
||||||
onChange={setComponent}
|
onChange={setComponent}
|
||||||
options={toOptions(COMPONENTS)}
|
options={toOptions(COMPONENTS)}
|
||||||
/>
|
/>
|
||||||
</FilterGroup>
|
</FilterGroup>
|
||||||
|
|
||||||
<FilterGroup label={t.logs.lines}>
|
<FilterGroup label={t.logs.lines} className={filterGroupClass}>
|
||||||
<Segmented
|
<Segmented
|
||||||
|
className={segmentedClass}
|
||||||
value={String(lineCount)}
|
value={String(lineCount)}
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
|
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
|
||||||
|
|
@ -190,7 +200,7 @@ export default function LogsPage() {
|
||||||
</FilterGroup>
|
</FilterGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||||
<CardHeader className="py-3 px-4">
|
<CardHeader className="py-3 px-4">
|
||||||
<CardTitle className="text-sm flex items-center gap-2">
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
<FileText className="h-4 w-4" />
|
<FileText className="h-4 w-4" />
|
||||||
|
|
@ -206,7 +216,7 @@ export default function LogsPage() {
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="p-4 font-mono-ui text-xs leading-5 overflow-auto min-h-[400px] max-h-[calc(100vh-220px)]"
|
className="max-w-full min-h-[400px] max-h-[calc(100vh-220px)] overflow-auto p-4 font-mono-ui text-xs leading-5 break-words"
|
||||||
>
|
>
|
||||||
{lines.length === 0 && !loading && (
|
{lines.length === 0 && !loading && (
|
||||||
<p className="text-muted-foreground text-center py-8">
|
<p className="text-muted-foreground text-center py-8">
|
||||||
|
|
|
||||||
|
|
@ -336,7 +336,9 @@ function ModelCard({
|
||||||
)?.task ?? null;
|
)?.task ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={isMain ? "ring-1 ring-primary/40" : undefined}>
|
<Card
|
||||||
|
className={`min-w-0 max-w-full overflow-hidden${isMain ? " ring-1 ring-primary/40" : ""}`}
|
||||||
|
>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|
@ -666,22 +668,20 @@ function ModelSettingsPanel({
|
||||||
).length ?? 0;
|
).length ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="min-w-0 pb-3">
|
||||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<Settings2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
<CardTitle className="text-sm">Model Settings</CardTitle>
|
||||||
<CardTitle className="text-sm">Model Settings</CardTitle>
|
<span className="max-w-full min-w-0 text-[10px] text-muted-foreground [overflow-wrap:anywhere]">
|
||||||
<span className="text-[10px] text-muted-foreground">
|
applies to new sessions
|
||||||
applies to new sessions
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-3 pt-3">
|
<CardContent className="min-w-0 space-y-3 pt-3">
|
||||||
{/* Main row */}
|
{/* Main row */}
|
||||||
<div className="flex items-center justify-between gap-3 bg-muted/20 border border-border/50 px-3 py-2">
|
<div className="flex min-w-0 flex-col gap-2 bg-muted/20 border border-border/50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<Star className="h-3 w-3 text-primary" />
|
<Star className="h-3 w-3 text-primary" />
|
||||||
|
|
@ -698,14 +698,14 @@ function ModelSettingsPanel({
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setPicker({ kind: "main" })}
|
onClick={() => setPicker({ kind: "main" })}
|
||||||
className="text-xs"
|
className="shrink-0 self-start text-xs sm:self-center"
|
||||||
>
|
>
|
||||||
Change
|
Change
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auxiliary tasks summary + open modal */}
|
{/* Auxiliary tasks summary + open modal */}
|
||||||
<div className="flex items-center justify-between gap-3 bg-muted/20 border border-border/50 px-3 py-2">
|
<div className="flex min-w-0 flex-col gap-2 bg-muted/20 border border-border/50 px-3 py-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2 mb-0.5">
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
<Cpu className="h-3 w-3 text-muted-foreground" />
|
<Cpu className="h-3 w-3 text-muted-foreground" />
|
||||||
|
|
@ -723,7 +723,7 @@ function ModelSettingsPanel({
|
||||||
size="sm"
|
size="sm"
|
||||||
outlined
|
outlined
|
||||||
onClick={() => setAuxModalOpen(true)}
|
onClick={() => setAuxModalOpen(true)}
|
||||||
className="text-xs"
|
className="shrink-0 self-start text-xs sm:self-center"
|
||||||
>
|
>
|
||||||
Configure
|
Configure
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -827,7 +827,7 @@ export default function ModelsPage() {
|
||||||
</span>,
|
</span>,
|
||||||
);
|
);
|
||||||
setEnd(
|
setEnd(
|
||||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-2">
|
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:gap-2">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{PERIODS.map((p) => (
|
{PERIODS.map((p) => (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -864,10 +864,10 @@ export default function ModelsPage() {
|
||||||
}, [load]);
|
}, [load]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex min-w-0 max-w-full flex-col gap-6">
|
||||||
<PluginSlot name="models:top" />
|
<PluginSlot name="models:top" />
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid min-w-0 gap-6 lg:grid-cols-2">
|
||||||
<ModelSettingsPanel
|
<ModelSettingsPanel
|
||||||
aux={aux}
|
aux={aux}
|
||||||
refreshKey={saveKey}
|
refreshKey={saveKey}
|
||||||
|
|
@ -875,10 +875,12 @@ export default function ModelsPage() {
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
<Card>
|
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||||
<CardContent className="py-6">
|
<CardContent className="min-w-0 py-6">
|
||||||
<Stats
|
<div className="min-w-0 max-w-full [&_div.grid]:grid-cols-[auto_minmax(0,1fr)_auto]">
|
||||||
items={
|
<Stats
|
||||||
|
className="min-w-0"
|
||||||
|
items={
|
||||||
showTokens
|
showTokens
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
|
|
@ -920,6 +922,7 @@ export default function ModelsPage() {
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
{!showTokens && (
|
{!showTokens && (
|
||||||
<p className="mt-4 text-[10px] text-muted-foreground/70 leading-relaxed">
|
<p className="mt-4 text-[10px] text-muted-foreground/70 leading-relaxed">
|
||||||
Token & cost analytics are hidden because the local counts
|
Token & cost analytics are hidden because the local counts
|
||||||
|
|
@ -953,7 +956,7 @@ export default function ModelsPage() {
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
{data.models.length > 0 ? (
|
{data.models.length > 0 ? (
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid min-w-0 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{data.models.map((m, i) => (
|
{data.models.map((m, i) => (
|
||||||
<ModelCard
|
<ModelCard
|
||||||
key={`${m.model}:${m.provider}`}
|
key={`${m.model}:${m.provider}`}
|
||||||
|
|
|
||||||
|
|
@ -60,16 +60,18 @@ export default function PluginsPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEnd(
|
setEnd(
|
||||||
<Button
|
<div className="flex w-full min-w-0 justify-start">
|
||||||
ghost
|
<Button
|
||||||
size="sm"
|
ghost
|
||||||
className="shrink-0 gap-2"
|
size="sm"
|
||||||
disabled={loading || rescanBusy}
|
className="w-max max-w-full shrink-0 gap-2"
|
||||||
onClick={() => void onRescan()}
|
disabled={loading || rescanBusy}
|
||||||
>
|
onClick={() => void onRescan()}
|
||||||
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
|
>
|
||||||
{t.pluginsPage.refreshDashboard}
|
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
|
||||||
</Button>,
|
{t.pluginsPage.refreshDashboard}
|
||||||
|
</Button>
|
||||||
|
</div>,
|
||||||
);
|
);
|
||||||
return () => setEnd(null);
|
return () => setEnd(null);
|
||||||
}, [loading, rescanBusy, setEnd, t.pluginsPage.refreshDashboard]);
|
}, [loading, rescanBusy, setEnd, t.pluginsPage.refreshDashboard]);
|
||||||
|
|
@ -413,32 +415,20 @@ function PluginRowCard(props: PluginRowCardProps) {
|
||||||
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<span className="truncate font-semibold">{row.name}</span>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<Badge tone="outline">
|
||||||
|
{t.pluginsPage.sourceBadge}: {row.source}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
<span className="truncate font-semibold">{row.name}</span>
|
<Badge tone="outline">v{row.version || "—"}</Badge>
|
||||||
|
|
||||||
<Badge tone="outline">
|
<Badge tone={badgeTone}>{row.runtime_status}</Badge>
|
||||||
{t.pluginsPage.sourceBadge}: {row.source}
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
|
{row.auth_required ? (
|
||||||
<Badge tone="outline">v{row.version || "—"}</Badge>
|
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
|
||||||
|
|
||||||
<Badge tone={badgeTone}>{row.runtime_status}</Badge>
|
|
||||||
|
|
||||||
{row.auth_required ? (
|
|
||||||
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{row.description ? (
|
|
||||||
|
|
||||||
<p className="mt-2 max-w-2xl text-[0.7rem] tracking-[0.06em] text-midforeground/75 normal-case">
|
|
||||||
{row.description}
|
|
||||||
</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -544,6 +534,12 @@ function PluginRowCard(props: PluginRowCardProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{row.description ? (
|
||||||
|
<p className="min-w-0 w-full text-[0.7rem] tracking-[0.06em] text-midforeground/75 normal-case break-words">
|
||||||
|
{row.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{dm?.slots?.length ? (
|
{dm?.slots?.length ? (
|
||||||
|
|
||||||
<p className="text-[0.65rem] tracking-[0.05em] text-midforeground/55 normal-case">
|
<p className="text-[0.65rem] tracking-[0.05em] text-midforeground/55 normal-case">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users, X } from "lucide-react";
|
import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users, X } from "lucide-react";
|
||||||
|
import spinners from "unicode-animations";
|
||||||
import { H2 } from "@/components/NouiTypography";
|
import { H2 } from "@/components/NouiTypography";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { ProfileInfo } from "@/lib/api";
|
import type { ProfileInfo } from "@/lib/api";
|
||||||
|
|
@ -21,6 +22,35 @@ import { usePageHeader } from "@/contexts/usePageHeader";
|
||||||
// invalid names (uppercase, spaces, …) before round-tripping a doomed POST.
|
// invalid names (uppercase, spaces, …) before round-tripping a doomed POST.
|
||||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||||
|
|
||||||
|
/** Braille unicode spinner (`unicode-animations`); static first frame when reduced motion is preferred. */
|
||||||
|
function ProfilesLoadingSpinner() {
|
||||||
|
const { frames, interval } = spinners.braille;
|
||||||
|
const [frameIndex, setFrameIndex] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = window.setInterval(
|
||||||
|
() => setFrameIndex((i) => (i + 1) % frames.length),
|
||||||
|
interval,
|
||||||
|
);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [frames.length, interval]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-block select-none font-mono text-xl leading-none text-muted-foreground"
|
||||||
|
>
|
||||||
|
{frames[frameIndex]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProfilesPage() {
|
export default function ProfilesPage() {
|
||||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -199,8 +229,14 @@ export default function ProfilesPage() {
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-24">
|
<div
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
aria-busy="true"
|
||||||
|
aria-live="polite"
|
||||||
|
className="flex items-center justify-center py-24"
|
||||||
|
>
|
||||||
|
<span className="sr-only">{t.common.loading}</span>
|
||||||
|
|
||||||
|
<ProfilesLoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -318,7 +354,7 @@ export default function ProfilesPage() {
|
||||||
const isEditingSoul = editingSoulFor === p.name;
|
const isEditingSoul = editingSoulFor === p.name;
|
||||||
return (
|
return (
|
||||||
<Card key={p.name}>
|
<Card key={p.name}>
|
||||||
<CardContent className="flex items-center gap-4 py-4">
|
<CardContent className="flex items-start gap-4 py-4">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
{isRenaming ? (
|
{isRenaming ? (
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
|
||||||
parts.push(snippet.slice(last));
|
parts.push(snippet.slice(last));
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<p className="text-xs text-muted-foreground/80 truncate max-w-lg mt-0.5">
|
<p className="mt-0.5 min-w-0 max-w-full truncate text-xs text-muted-foreground/80">
|
||||||
{parts}
|
{parts}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|
@ -296,24 +296,24 @@ function SessionRow({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`border overflow-hidden transition-colors ${
|
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${
|
||||||
session.is_active
|
session.is_active
|
||||||
? "border-success/30 bg-success/[0.03]"
|
? "border-success/30 bg-success/[0.03]"
|
||||||
: "border-border"
|
: "border-border"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-secondary/30 transition-colors"
|
className="flex cursor-pointer items-start gap-3 p-3 transition-colors hover:bg-secondary/30"
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
<div className={`shrink-0 pt-0.5 ${sourceInfo.color}`}>
|
||||||
<div className={`shrink-0 ${sourceInfo.color}`}>
|
<SourceIcon className="h-4 w-4" />
|
||||||
<SourceIcon className="h-4 w-4" />
|
</div>
|
||||||
</div>
|
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
||||||
<div className="flex flex-col gap-0.5 min-w-0">
|
<div className="flex min-w-0 flex-col gap-0.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
|
className={`min-w-0 flex-1 truncate text-sm ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
|
||||||
>
|
>
|
||||||
{hasTitle
|
{hasTitle
|
||||||
? session.title
|
? session.title
|
||||||
|
|
@ -322,71 +322,70 @@ function SessionRow({
|
||||||
: t.sessions.untitledSession}
|
: t.sessions.untitledSession}
|
||||||
</span>
|
</span>
|
||||||
{session.is_active && (
|
{session.is_active && (
|
||||||
<Badge tone="success" className="text-[10px] shrink-0">
|
<Badge tone="success" className="shrink-0 text-[10px]">
|
||||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||||
{t.common.live}
|
{t.common.live}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted-foreground">
|
||||||
<span className="truncate max-w-[120px] sm:max-w-[180px]">
|
<span className="max-w-[min(100%,12rem)] truncate sm:max-w-[180px]">
|
||||||
{(session.model ?? t.common.unknown).split("/").pop()}
|
{(session.model ?? t.common.unknown).split("/").pop()}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-border">·</span>
|
<span className="text-border">·</span>
|
||||||
<span>
|
<span className="shrink-0">
|
||||||
{session.message_count} {t.common.msgs}
|
{session.message_count} {t.common.msgs}
|
||||||
</span>
|
</span>
|
||||||
{session.tool_call_count > 0 && (
|
{session.tool_call_count > 0 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-border">·</span>
|
<span className="text-border">·</span>
|
||||||
<span>
|
<span className="shrink-0">
|
||||||
{session.tool_call_count} {t.common.tools}
|
{session.tool_call_count} {t.common.tools}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<span className="text-border">·</span>
|
<span className="text-border">·</span>
|
||||||
<span>{timeAgo(session.last_active)}</span>
|
<span className="shrink-0">{timeAgo(session.last_active)}</span>
|
||||||
</div>
|
</div>
|
||||||
{snippet && <SnippetHighlight snippet={snippet} />}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{snippet && <SnippetHighlight snippet={snippet} />}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<Badge tone="outline" className="text-[10px]">
|
||||||
<Badge tone="outline" className="text-[10px]">
|
{session.source ?? "local"}
|
||||||
{session.source ?? "local"}
|
</Badge>
|
||||||
</Badge>
|
{resumeInChatEnabled && (
|
||||||
{resumeInChatEnabled && (
|
<Button
|
||||||
|
ghost
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-success"
|
||||||
|
aria-label={t.sessions.resumeInChat}
|
||||||
|
title={t.sessions.resumeInChat}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Play />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
ghost
|
ghost
|
||||||
|
destructive
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-muted-foreground hover:text-success"
|
aria-label={t.sessions.deleteSession}
|
||||||
aria-label={t.sessions.resumeInChat}
|
|
||||||
title={t.sessions.resumeInChat}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
|
onDelete();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Play />
|
<Trash2 />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
<Button
|
|
||||||
ghost
|
|
||||||
destructive
|
|
||||||
size="icon"
|
|
||||||
aria-label={t.sessions.deleteSession}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDelete();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="border-t border-border bg-background/50 p-4">
|
<div className="min-w-0 border-t border-border bg-background/50 p-4">
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Spinner className="text-xl text-primary" />
|
<Spinner className="text-xl text-primary" />
|
||||||
|
|
@ -624,7 +623,7 @@ export default function SessionsPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex min-w-0 w-full max-w-full flex-col gap-4">
|
||||||
<PluginSlot name="sessions:top" />
|
<PluginSlot name="sessions:top" />
|
||||||
<Toast toast={toast} />
|
<Toast toast={toast} />
|
||||||
|
|
||||||
|
|
@ -732,28 +731,28 @@ export default function SessionsPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recentSessions.length > 0 && (
|
{recentSessions.length > 0 && (
|
||||||
<Card>
|
<Card className="min-w-0 max-w-full overflow-hidden">
|
||||||
<CardHeader>
|
<CardHeader className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
<Clock className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||||
<CardTitle className="text-base">
|
<CardTitle className="min-w-0 truncate text-base">
|
||||||
{t.status.recentSessions}
|
{t.status.recentSessions}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="grid gap-3">
|
<CardContent className="grid min-w-0 gap-3">
|
||||||
{recentSessions.map((s) => (
|
{recentSessions.map((s) => (
|
||||||
<div
|
<div
|
||||||
key={s.id}
|
key={s.id}
|
||||||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
|
className="flex min-w-0 max-w-full flex-col gap-2 border border-border p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1 min-w-0 w-full">
|
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||||
<span className="font-medium text-sm truncate">
|
<span className="min-w-0 truncate text-sm font-medium">
|
||||||
{s.title ?? t.common.untitled}
|
{s.title ?? t.common.untitled}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="text-xs text-muted-foreground truncate">
|
<span className="min-w-0 break-words text-xs text-muted-foreground">
|
||||||
<span className="font-mono-ui">
|
<span className="font-mono-ui">
|
||||||
{(s.model ?? t.common.unknown).split("/").pop()}
|
{(s.model ?? t.common.unknown).split("/").pop()}
|
||||||
</span>{" "}
|
</span>{" "}
|
||||||
|
|
@ -762,15 +761,15 @@ export default function SessionsPage() {
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{s.preview && (
|
{s.preview && (
|
||||||
<span className="text-xs text-muted-foreground/70 truncate">
|
<p className="min-w-0 max-w-full text-xs leading-snug text-muted-foreground/70 [overflow-wrap:anywhere]">
|
||||||
{s.preview}
|
{s.preview}
|
||||||
</span>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Badge
|
<Badge
|
||||||
tone="outline"
|
tone="outline"
|
||||||
className="text-[10px] shrink-0 self-start sm:self-center"
|
className="shrink-0 self-start text-[10px] sm:self-center"
|
||||||
>
|
>
|
||||||
<Database className="mr-1 h-3 w-3" />
|
<Database className="mr-1 h-3 w-3" />
|
||||||
{s.source ?? "local"}
|
{s.source ?? "local"}
|
||||||
|
|
@ -795,7 +794,7 @@ export default function SessionsPage() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex min-w-0 flex-col gap-1.5">
|
||||||
{filtered.map((s) => (
|
{filtered.map((s) => (
|
||||||
<SessionRow
|
<SessionRow
|
||||||
key={s.id}
|
key={s.id}
|
||||||
|
|
|
||||||
|
|
@ -205,7 +205,7 @@ export default function SkillsPage() {
|
||||||
<div className="relative w-full min-w-0 sm:max-w-xs">
|
<div className="relative w-full min-w-0 sm:max-w-xs">
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
className="h-8 pl-8 pr-7 text-xs"
|
className="h-8 rounded-none pl-8 pr-7 text-xs"
|
||||||
placeholder={t.common.search}
|
placeholder={t.common.search}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
|
@ -256,12 +256,7 @@ export default function SkillsPage() {
|
||||||
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||||
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
|
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
|
||||||
<div className="sm:sticky sm:top-0">
|
<div className="sm:sticky sm:top-0">
|
||||||
<div
|
<div className="flex flex-col rounded-none border border-border bg-muted/20">
|
||||||
className={`
|
|
||||||
flex flex-col
|
|
||||||
border border-border bg-muted/20
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
|
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||||
<Filter className="h-3 w-3 text-muted-foreground" />
|
<Filter className="h-3 w-3 text-muted-foreground" />
|
||||||
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
|
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
|
||||||
|
|
@ -309,7 +304,7 @@ export default function SkillsPage() {
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setActiveCategory(isActive ? null : key)
|
setActiveCategory(isActive ? null : key)
|
||||||
}
|
}
|
||||||
className="rounded-sm px-2 py-1 text-[11px]"
|
className="rounded-none px-2 py-1 text-[11px]"
|
||||||
>
|
>
|
||||||
<span className="flex-1 truncate">{name}</span>
|
<span className="flex-1 truncate">{name}</span>
|
||||||
<span
|
<span
|
||||||
|
|
@ -333,7 +328,7 @@ export default function SkillsPage() {
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{isSearching ? (
|
{isSearching ? (
|
||||||
<Card>
|
<Card className="rounded-none">
|
||||||
<CardHeader className="py-3 px-4">
|
<CardHeader className="py-3 px-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-sm flex items-center gap-2">
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
|
@ -372,7 +367,7 @@ export default function SkillsPage() {
|
||||||
</Card>
|
</Card>
|
||||||
) : view === "skills" ? (
|
) : view === "skills" ? (
|
||||||
/* Skills list */
|
/* Skills list */
|
||||||
<Card>
|
<Card className="rounded-none">
|
||||||
<CardHeader className="py-3 px-4">
|
<CardHeader className="py-3 px-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-sm flex items-center gap-2">
|
<CardTitle className="text-sm flex items-center gap-2">
|
||||||
|
|
@ -417,7 +412,7 @@ export default function SkillsPage() {
|
||||||
/* Toolsets grid */
|
/* Toolsets grid */
|
||||||
<>
|
<>
|
||||||
{filteredToolsets.length === 0 ? (
|
{filteredToolsets.length === 0 ? (
|
||||||
<Card>
|
<Card className="rounded-none">
|
||||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||||
{t.skills.noToolsetsMatch}
|
{t.skills.noToolsetsMatch}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -431,7 +426,7 @@ export default function SkillsPage() {
|
||||||
ts.name;
|
ts.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={ts.name} className="relative">
|
<Card key={ts.name} className="relative rounded-none">
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<TsIcon className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
<TsIcon className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||||
|
|
@ -536,7 +531,7 @@ function PanelItem({ active, icon: Icon, label, onClick }: PanelItemProps) {
|
||||||
active={active}
|
active={active}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-sm whitespace-nowrap px-2.5 py-1.5",
|
"rounded-none whitespace-nowrap px-2.5 py-1.5",
|
||||||
"font-mondwest text-[0.7rem] tracking-[0.08em] uppercase",
|
"font-mondwest text-[0.7rem] tracking-[0.08em] uppercase",
|
||||||
active && "bg-foreground/90 text-background hover:text-background",
|
active && "bg-foreground/90 text-background hover:text-background",
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import type {
|
||||||
ThemeLayer,
|
ThemeLayer,
|
||||||
ThemeLayout,
|
ThemeLayout,
|
||||||
ThemeLayoutVariant,
|
ThemeLayoutVariant,
|
||||||
|
ThemeListEntry,
|
||||||
ThemePalette,
|
ThemePalette,
|
||||||
ThemeTypography,
|
ThemeTypography,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
@ -311,7 +312,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
|
||||||
/** All selectable themes (shown in the picker). Starts with just the
|
/** All selectable themes (shown in the picker). Starts with just the
|
||||||
* built-ins; the API call below merges in user themes. */
|
* built-ins; the API call below merges in user themes. */
|
||||||
const [availableThemes, setAvailableThemes] = useState<ThemeSummary[]>(() =>
|
const [availableThemes, setAvailableThemes] = useState<ThemeListEntry[]>(() =>
|
||||||
Object.values(BUILTIN_THEMES).map((t) => ({
|
Object.values(BUILTIN_THEMES).map((t) => ({
|
||||||
name: t.name,
|
name: t.name,
|
||||||
label: t.label,
|
label: t.label,
|
||||||
|
|
@ -429,15 +430,8 @@ const ThemeContext = createContext<ThemeContextValue>({
|
||||||
});
|
});
|
||||||
|
|
||||||
interface ThemeContextValue {
|
interface ThemeContextValue {
|
||||||
availableThemes: ThemeSummary[];
|
availableThemes: ThemeListEntry[];
|
||||||
setTheme: (name: string) => void;
|
setTheme: (name: string) => void;
|
||||||
theme: DashboardTheme;
|
theme: DashboardTheme;
|
||||||
themeName: string;
|
themeName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ThemeSummary {
|
|
||||||
description: string;
|
|
||||||
label: string;
|
|
||||||
name: string;
|
|
||||||
definition?: DashboardTheme;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
export { ThemeProvider, useTheme } from "./context";
|
export { ThemeProvider, useTheme } from "./context";
|
||||||
export { BUILTIN_THEMES, defaultTheme } from "./presets";
|
export { BUILTIN_THEMES, defaultTheme } from "./presets";
|
||||||
export type { DashboardTheme, ThemeLayer, ThemeListResponse, ThemePalette } from "./types";
|
export type { DashboardTheme, ThemeLayer, ThemeListEntry, ThemeListResponse, ThemePalette } from "./types";
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue