mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-11 04:37:16 +00:00
feat(ui): add global command palette (Ctrl+K) for fast navigation and search (#6352)
* feat(ui): add global command palette (Ctrl+K) for fast navigation and search * fix(ui): address review feedback for shortcut listener, i18n parity, and search deep links * fix(ui): resolve search routing, translation keys, and palette state reset * fix(ui): improve command palette styling and sidebar transitions * fix(ui): address review feedback for typecheck, codegen, debouncing, and state reset * fix(ui): resolve effect state update warning and debounce reset in command palette * fix(ui): address review feedback for stale client search results and theme action * style(ui): apply oxfmt formatting to command palette and tests * fix(deps): update js-yaml override to resolve audit advisory * docs(api): sync the docs OpenAPI copy with the new InboundOption fields Adding Network/Security to InboundOption regenerated frontend/public/openapi.json, but docs/public/openapi.json is a hand-kept copy of that file and nothing checks it: make verify never reaches docs/, and docs-ci.yml fires only on docs/**. The two files were byte-identical on main and had diverged here, so the published API reference described a response shape the panel no longer returns. Regenerating the MDX under docs/content/docs/en/reference/api/ produced no change — the schema is read from the JSON at render time. * fix(ui): unnest the command palette row control and label its shortcut The palette row was a <button> wrapping the copy-subscription <button>. Nested interactive content is invalid HTML and React 19 logs two errors for it on every client result. The row is now a role="button" div using activateOnKey, the pattern the rest of the panel already uses, with line-height pinned so dropping the UA button style does not grow every row. Its keydown handler ignores events bubbling from the nested button: activateOnKey preventDefaults Enter, which would otherwise cancel the browser's Enter-to-click on the copy button and navigate instead. The sidebar chip hardcoded the Mac glyph while the handler accepts Ctrl as well, so Linux and Windows operators were shown a key they do not have; it now picks the modifier from the platform. Also restores the comment on ClientsPage's debouncedSearch that the deep-link change removed — the code it explains is unchanged.
This commit is contained in:
@@ -2744,6 +2744,9 @@
|
|||||||
"mtprotoDomain": {
|
"mtprotoDomain": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"network": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"nodeAddress": {
|
"nodeAddress": {
|
||||||
"description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.",
|
"description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2765,6 +2768,9 @@
|
|||||||
"example": "VLESS-443",
|
"example": "VLESS-443",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"security": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"shareAddr": {
|
"shareAddr": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -4515,11 +4521,13 @@
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"listen": "",
|
"listen": "",
|
||||||
"mtprotoDomain": "",
|
"mtprotoDomain": "",
|
||||||
|
"network": "",
|
||||||
"nodeAddress": "",
|
"nodeAddress": "",
|
||||||
"nodeId": null,
|
"nodeId": null,
|
||||||
"port": 443,
|
"port": 443,
|
||||||
"protocol": "vless",
|
"protocol": "vless",
|
||||||
"remark": "VLESS-443",
|
"remark": "VLESS-443",
|
||||||
|
"security": "",
|
||||||
"shareAddr": "",
|
"shareAddr": "",
|
||||||
"shareAddrStrategy": "",
|
"shareAddrStrategy": "",
|
||||||
"ssMethod": "",
|
"ssMethod": "",
|
||||||
|
|||||||
@@ -2744,6 +2744,9 @@
|
|||||||
"mtprotoDomain": {
|
"mtprotoDomain": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"network": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"nodeAddress": {
|
"nodeAddress": {
|
||||||
"description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.",
|
"description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2765,6 +2768,9 @@
|
|||||||
"example": "VLESS-443",
|
"example": "VLESS-443",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"security": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"shareAddr": {
|
"shareAddr": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -4515,11 +4521,13 @@
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"listen": "",
|
"listen": "",
|
||||||
"mtprotoDomain": "",
|
"mtprotoDomain": "",
|
||||||
|
"network": "",
|
||||||
"nodeAddress": "",
|
"nodeAddress": "",
|
||||||
"nodeId": null,
|
"nodeId": null,
|
||||||
"port": 443,
|
"port": 443,
|
||||||
"protocol": "vless",
|
"protocol": "vless",
|
||||||
"remark": "VLESS-443",
|
"remark": "VLESS-443",
|
||||||
|
"security": "",
|
||||||
"shareAddr": "",
|
"shareAddr": "",
|
||||||
"shareAddrStrategy": "",
|
"shareAddrStrategy": "",
|
||||||
"ssMethod": "",
|
"ssMethod": "",
|
||||||
|
|||||||
@@ -0,0 +1,625 @@
|
|||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Command Palette Backdrop & Modal
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.command-palette-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding-top: 12vh;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
animation: cp-fade-in 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-backdrop.light {
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-backdrop.ultra {
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cp-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal {
|
||||||
|
position: relative;
|
||||||
|
width: 680px;
|
||||||
|
max-width: 92vw;
|
||||||
|
max-height: 72vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: cp-scale-in 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
transition:
|
||||||
|
background 0.2s ease,
|
||||||
|
border-color 0.2s ease,
|
||||||
|
color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cp-scale-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.96) translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.light,
|
||||||
|
body.light .command-palette-modal {
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
color: #1f1f1f;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
box-shadow:
|
||||||
|
0 20px 50px rgba(0, 0, 0, 0.16),
|
||||||
|
0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.dark,
|
||||||
|
body.dark .command-palette-modal {
|
||||||
|
background: rgba(35, 37, 43, 0.95);
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow:
|
||||||
|
0 25px 60px rgba(0, 0, 0, 0.65),
|
||||||
|
0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.ultra,
|
||||||
|
html[data-theme='ultra-dark'] .command-palette-modal {
|
||||||
|
background: rgba(16, 16, 19, 0.98);
|
||||||
|
color: #ffffff;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
box-shadow: 0 30px 70px rgba(0, 0, 0, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Header & Search Input
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.command-palette-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
gap: 14px;
|
||||||
|
border-bottom: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.light .command-palette-header {
|
||||||
|
border-bottom-color: rgba(0, 0, 0, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.dark .command-palette-header {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.ultra .command-palette-header {
|
||||||
|
border-bottom-color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-search-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
color: #1677ff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-search-icon.spinning {
|
||||||
|
color: #1677ff;
|
||||||
|
animation: cp-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cp-spin {
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-input {
|
||||||
|
flex: 1;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 16.5px;
|
||||||
|
font-weight: 400;
|
||||||
|
font-family: inherit;
|
||||||
|
letter-spacing: -0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.light .command-palette-input {
|
||||||
|
color: #1f1f1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.light .command-palette-input::placeholder {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.dark .command-palette-input,
|
||||||
|
.command-palette-modal.ultra .command-palette-input {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.dark .command-palette-input::placeholder,
|
||||||
|
.command-palette-modal.ultra .command-palette-input::placeholder {
|
||||||
|
color: #737373;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Body & Scrollbar
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.command-palette-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
max-height: calc(72vh - 116px);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.light .command-palette-body {
|
||||||
|
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.dark .command-palette-body,
|
||||||
|
.command-palette-modal.ultra .command-palette-body {
|
||||||
|
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-body::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-body::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.light .command-palette-body::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.light .command-palette-body::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.dark .command-palette-body::-webkit-scrollbar-thumb,
|
||||||
|
.command-palette-modal.ultra .command-palette-body::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.dark .command-palette-body::-webkit-scrollbar-thumb:hover,
|
||||||
|
.command-palette-modal.ultra .command-palette-body::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Groups & Items
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.command-palette-group {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-group-title {
|
||||||
|
padding: 6px 12px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.light .command-palette-group-title {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.dark .command-palette-group-title,
|
||||||
|
.command-palette-modal.ultra .command-palette-group-title {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 14px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: start;
|
||||||
|
font-family: inherit;
|
||||||
|
/* The row was a <button>; keep the UA line-height it had so swapping the
|
||||||
|
tag does not grow every row by the panel's body line-height. */
|
||||||
|
line-height: normal;
|
||||||
|
color: inherit;
|
||||||
|
transition:
|
||||||
|
background 0.12s ease,
|
||||||
|
color 0.12s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.light .command-palette-item.active {
|
||||||
|
background: #e6f4ff;
|
||||||
|
color: #0958d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.dark .command-palette-item.active {
|
||||||
|
background: rgba(22, 119, 255, 0.2);
|
||||||
|
color: #4096ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.ultra .command-palette-item.active {
|
||||||
|
background: rgba(22, 119, 255, 0.28);
|
||||||
|
color: #69b1ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.light .command-palette-item-subtitle {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.dark .command-palette-item-subtitle,
|
||||||
|
.command-palette-modal.ultra .command-palette-item-subtitle {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-inline-start: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-action-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.light .command-palette-action-btn {
|
||||||
|
color: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.light .command-palette-action-btn:hover {
|
||||||
|
color: #0958d9;
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.dark .command-palette-action-btn {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.dark .command-palette-action-btn:hover {
|
||||||
|
color: #4096ff;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.ultra .command-palette-action-btn {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.ultra .command-palette-action-btn:hover {
|
||||||
|
color: #69b1ff;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item.active .command-palette-action-btn {
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-item.active .command-palette-action-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-tooltip {
|
||||||
|
z-index: 2500 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-empty {
|
||||||
|
padding: 36px 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.light .command-palette-empty {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.dark .command-palette-empty,
|
||||||
|
.command-palette-modal.ultra .command-palette-empty {
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Footer & Keyboard Badges
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.command-palette-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
border-top: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.light .command-palette-footer {
|
||||||
|
background: #fafafa;
|
||||||
|
border-top-color: rgba(0, 0, 0, 0.08);
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.dark .command-palette-footer {
|
||||||
|
background: rgba(21, 22, 26, 0.95);
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.ultra .command-palette-footer {
|
||||||
|
background: #050507;
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-kbd-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 5px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-inline-end: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.light .command-palette-kbd {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
color: #595959;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.dark .command-palette-kbd {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
color: #d9d9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-palette-modal.ultra .command-palette-kbd {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Sidebar Trigger Button
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
.sidebar-command-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: calc(100% - 16px);
|
||||||
|
height: 36px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 8px 8px 4px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 7px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s cubic-bezier(0.2, 0, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-command-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-command-trigger .sidebar-command-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-command-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 1;
|
||||||
|
max-width: 130px;
|
||||||
|
transition:
|
||||||
|
opacity 0.2s ease,
|
||||||
|
max-width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-command-trigger.collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-command-trigger.collapsed .sidebar-command-left {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-command-trigger.collapsed .sidebar-command-icon {
|
||||||
|
margin: 0 auto;
|
||||||
|
font-size: 16px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-command-trigger.collapsed .sidebar-command-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-command-trigger.collapsed .sidebar-command-kbd {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light .sidebar-command-trigger {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light .sidebar-command-trigger:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.08);
|
||||||
|
border-color: rgba(0, 0, 0, 0.15);
|
||||||
|
color: #1f1f1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .sidebar-command-trigger {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
color: #8c8c8c;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .sidebar-command-trigger:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.09);
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-command-trigger .sidebar-command-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-command-trigger .sidebar-command-kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2.5px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-command-trigger .sidebar-command-kbd .kbd-cmd {
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-block;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-command-trigger .sidebar-command-kbd .kbd-key {
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light .sidebar-command-trigger .sidebar-command-kbd {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
|
color: #595959;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .sidebar-command-trigger .sidebar-command-kbd {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
color: #d9d9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark .sidebar-command-trigger:hover .sidebar-command-kbd {
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
@@ -0,0 +1,802 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ConfigProvider, Tag, Tooltip, message } from 'antd';
|
||||||
|
import {
|
||||||
|
ApiOutlined,
|
||||||
|
ApartmentOutlined,
|
||||||
|
CheckCircleFilled,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
CloseCircleFilled,
|
||||||
|
CloudServerOutlined,
|
||||||
|
ClusterOutlined,
|
||||||
|
CodeOutlined,
|
||||||
|
CopyOutlined,
|
||||||
|
DashboardOutlined,
|
||||||
|
DatabaseOutlined,
|
||||||
|
ExportOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
GlobalOutlined,
|
||||||
|
ImportOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
MailOutlined,
|
||||||
|
MessageOutlined,
|
||||||
|
MoonOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SafetyOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
SunOutlined,
|
||||||
|
SwapOutlined,
|
||||||
|
TagsOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
ToolOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { ClipboardManager, HttpUtil, SizeFormatter } from '@/utils';
|
||||||
|
import { activateOnKey } from '@/utils/a11y';
|
||||||
|
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||||
|
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||||
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
|
import type { ClientRecord, InboundOption } from '@/schemas/client';
|
||||||
|
import { commandPaletteStore, useCommandPalette } from './useCommandPalette';
|
||||||
|
import './CommandPalette.css';
|
||||||
|
|
||||||
|
interface PaletteItem {
|
||||||
|
id: string;
|
||||||
|
category: 'clients' | 'inbounds' | 'navigation' | 'settings' | 'actions';
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
keywords?: string[];
|
||||||
|
icon: ReactNode;
|
||||||
|
tag?: ReactNode;
|
||||||
|
action: () => void | Promise<void>;
|
||||||
|
secondaryAction?: {
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
execute: (e: React.MouseEvent) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommandPalette() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
|
||||||
|
const { isOpen, close } = useCommandPalette();
|
||||||
|
const { allSetting } = useAllSettings();
|
||||||
|
const { data: inbounds = [] } = useInboundOptions();
|
||||||
|
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||||
|
const [clientSearch, setClientSearch] = useState<{ query: string; items: ClientRecord[] }>({
|
||||||
|
query: '',
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
const [loadingClients, setLoadingClients] = useState(false);
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleGlobalKeyDown(e: KeyboardEvent) {
|
||||||
|
const isK = e.code === 'KeyK' || e.key === 'k' || e.key === 'K';
|
||||||
|
if ((e.metaKey || e.ctrlKey) && isK) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isOpen) {
|
||||||
|
close();
|
||||||
|
} else {
|
||||||
|
commandPaletteStore.open();
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape' && isOpen) {
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleGlobalKeyDown, { capture: true });
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleGlobalKeyDown, { capture: true });
|
||||||
|
};
|
||||||
|
}, [isOpen, close]);
|
||||||
|
|
||||||
|
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
|
||||||
|
if (isOpen !== prevIsOpen) {
|
||||||
|
setPrevIsOpen(isOpen);
|
||||||
|
if (!isOpen) {
|
||||||
|
setQuery('');
|
||||||
|
setDebouncedQuery('');
|
||||||
|
setClientSearch({ query: '', items: [] });
|
||||||
|
setActiveIndex(0);
|
||||||
|
setLoadingClients(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [prevQuery, setPrevQuery] = useState(query);
|
||||||
|
if (query !== prevQuery) {
|
||||||
|
setPrevQuery(query);
|
||||||
|
setActiveIndex(0);
|
||||||
|
if (!query.trim()) {
|
||||||
|
setDebouncedQuery('');
|
||||||
|
setClientSearch({ query: '', items: [] });
|
||||||
|
setLoadingClients(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed || trimmed === debouncedQuery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setLoadingClients(true);
|
||||||
|
setDebouncedQuery(trimmed);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [isOpen, query, debouncedQuery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || debouncedQuery.length < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isCurrent = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
HttpUtil.get(
|
||||||
|
`/panel/api/clients/list/paged?search=${encodeURIComponent(debouncedQuery)}&pageSize=8`,
|
||||||
|
undefined,
|
||||||
|
{ silent: true, signal: controller.signal },
|
||||||
|
)
|
||||||
|
.then((msg) => {
|
||||||
|
if (!isCurrent) return;
|
||||||
|
if (
|
||||||
|
msg?.success &&
|
||||||
|
msg?.obj &&
|
||||||
|
Array.isArray((msg.obj as { items?: ClientRecord[] }).items)
|
||||||
|
) {
|
||||||
|
setClientSearch({
|
||||||
|
query: debouncedQuery,
|
||||||
|
items: (msg.obj as { items: ClientRecord[] }).items,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setClientSearch({ query: debouncedQuery, items: [] });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (isCurrent) setLoadingClients(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCurrent = false;
|
||||||
|
controller.abort();
|
||||||
|
};
|
||||||
|
}, [isOpen, debouncedQuery]);
|
||||||
|
|
||||||
|
const copySubscription = useCallback(
|
||||||
|
async (client: ClientRecord) => {
|
||||||
|
if (!client.subId || !allSetting.subURI) {
|
||||||
|
message.warning(t('pages.clients.noSubId'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const link = `${allSetting.subURI}${client.subId}`;
|
||||||
|
const ok = await ClipboardManager.copyText(link);
|
||||||
|
if (ok) message.success(t('copied'));
|
||||||
|
},
|
||||||
|
[allSetting.subURI, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const restartXray = useCallback(async () => {
|
||||||
|
close();
|
||||||
|
const msg = await HttpUtil.post('/panel/api/server/restartXrayService', undefined, {
|
||||||
|
silentSuccess: true,
|
||||||
|
});
|
||||||
|
if (msg?.success) {
|
||||||
|
message.success(t('commandPalette.restartXraySuccess'));
|
||||||
|
}
|
||||||
|
}, [close, t]);
|
||||||
|
|
||||||
|
const cycleTheme = useCallback(() => {
|
||||||
|
if (!isDark) {
|
||||||
|
toggleTheme();
|
||||||
|
if (isUltra) toggleUltra();
|
||||||
|
} else if (!isUltra) {
|
||||||
|
toggleUltra();
|
||||||
|
} else {
|
||||||
|
toggleUltra();
|
||||||
|
toggleTheme();
|
||||||
|
}
|
||||||
|
close();
|
||||||
|
}, [isDark, isUltra, toggleTheme, toggleUltra, close]);
|
||||||
|
|
||||||
|
const trimmedQuery = query.trim();
|
||||||
|
const isDebouncing = isOpen && trimmedQuery.length > 0 && trimmedQuery !== debouncedQuery;
|
||||||
|
const isClientSearching =
|
||||||
|
isOpen &&
|
||||||
|
trimmedQuery.length > 0 &&
|
||||||
|
(loadingClients || isDebouncing || clientSearch.query !== trimmedQuery);
|
||||||
|
|
||||||
|
const items = useMemo<PaletteItem[]>(() => {
|
||||||
|
const list: PaletteItem[] = [];
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
const matches = (title: string, subtitle?: string, keywords: string[] = []) => {
|
||||||
|
if (!q) return true;
|
||||||
|
if (title.toLowerCase().includes(q)) return true;
|
||||||
|
if (subtitle && subtitle.toLowerCase().includes(q)) return true;
|
||||||
|
return keywords.some((k) => k.toLowerCase().includes(q));
|
||||||
|
};
|
||||||
|
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (trimmed.length > 0 && clientSearch.query === trimmed && clientSearch.items.length > 0) {
|
||||||
|
clientSearch.items.forEach((c) => {
|
||||||
|
const up = Number(c.traffic?.up || 0);
|
||||||
|
const down = Number(c.traffic?.down || 0);
|
||||||
|
const total = Number(c.traffic?.total || c.totalGB || 0);
|
||||||
|
const trafficUsed = SizeFormatter.sizeFormat(up + down);
|
||||||
|
const trafficTotal = total > 0 ? SizeFormatter.sizeFormat(total) : '∞';
|
||||||
|
const isOnline = c.enable !== false;
|
||||||
|
|
||||||
|
list.push({
|
||||||
|
id: `client-${c.id ?? c.email}`,
|
||||||
|
category: 'clients',
|
||||||
|
title: c.email,
|
||||||
|
subtitle: `${trafficUsed} / ${trafficTotal}${c.comment ? ` · ${c.comment}` : ''}`,
|
||||||
|
icon: isOnline ? (
|
||||||
|
<CheckCircleFilled style={{ color: '#52c41a' }} />
|
||||||
|
) : (
|
||||||
|
<CloseCircleFilled style={{ color: '#ff4d4f' }} />
|
||||||
|
),
|
||||||
|
action: () => {
|
||||||
|
close();
|
||||||
|
navigate(`/clients?search=${encodeURIComponent(c.email)}`);
|
||||||
|
},
|
||||||
|
secondaryAction:
|
||||||
|
c.subId && allSetting.subURI
|
||||||
|
? {
|
||||||
|
label: t('commandPalette.copySubscription'),
|
||||||
|
icon: <CopyOutlined />,
|
||||||
|
execute: (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
copySubscription(c);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchedInbounds = inbounds.filter((ib: InboundOption) => {
|
||||||
|
if (!q) return false;
|
||||||
|
return (
|
||||||
|
(ib.tag && ib.tag.toLowerCase().includes(q)) ||
|
||||||
|
(ib.remark && ib.remark.toLowerCase().includes(q)) ||
|
||||||
|
(ib.protocol && ib.protocol.toLowerCase().includes(q)) ||
|
||||||
|
(ib.port && String(ib.port).includes(q))
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
matchedInbounds.slice(0, 8).forEach((ib) => {
|
||||||
|
const tags: ReactNode[] = [];
|
||||||
|
if (ib.protocol) {
|
||||||
|
tags.push(
|
||||||
|
<Tag key="protocol" color="purple">
|
||||||
|
{ib.protocol}
|
||||||
|
</Tag>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ib.network) {
|
||||||
|
const n = ib.network.toLowerCase();
|
||||||
|
let netLabel = n.toUpperCase();
|
||||||
|
if (n === 'httpupgrade') netLabel = 'HTTPUpgrade';
|
||||||
|
else if (n === 'splithttp') netLabel = 'SplitHTTP';
|
||||||
|
else if (n === 'xhttp') netLabel = 'XHTTP';
|
||||||
|
tags.push(
|
||||||
|
<Tag key="network" color="green">
|
||||||
|
{netLabel}
|
||||||
|
</Tag>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ib.security && ib.security !== 'none') {
|
||||||
|
const s = ib.security.toLowerCase();
|
||||||
|
const secLabel = s === 'reality' ? 'Reality' : s === 'tls' ? 'TLS' : s.toUpperCase();
|
||||||
|
tags.push(
|
||||||
|
<Tag key="security" color="blue">
|
||||||
|
{secLabel}
|
||||||
|
</Tag>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.push({
|
||||||
|
id: `inbound-${ib.id}`,
|
||||||
|
category: 'inbounds',
|
||||||
|
title: ib.remark || ib.tag || `Inbound #${ib.id}`,
|
||||||
|
subtitle: `Port ${ib.port || ''}`,
|
||||||
|
icon: <ImportOutlined style={{ color: '#1677ff' }} />,
|
||||||
|
tag:
|
||||||
|
tags.length > 0 ? (
|
||||||
|
<div style={{ display: 'inline-flex', gap: 4, flexWrap: 'wrap' }}>{tags}</div>
|
||||||
|
) : undefined,
|
||||||
|
action: () => {
|
||||||
|
close();
|
||||||
|
navigate(`/inbounds?search=${encodeURIComponent(ib.remark || String(ib.port || ''))}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const pages = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
title: t('menu.dashboard'),
|
||||||
|
keywords: ['overview', 'dashboard', 'cpu', 'ram', 'memory', 'traffic', 'speed'],
|
||||||
|
icon: <DashboardOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/inbounds',
|
||||||
|
title: t('menu.inbounds'),
|
||||||
|
keywords: [
|
||||||
|
'inbounds',
|
||||||
|
'ports',
|
||||||
|
'vless',
|
||||||
|
'vmess',
|
||||||
|
'reality',
|
||||||
|
'trojan',
|
||||||
|
'shadowsocks',
|
||||||
|
'wireguard',
|
||||||
|
'hysteria',
|
||||||
|
],
|
||||||
|
icon: <ImportOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/clients',
|
||||||
|
title: t('menu.clients'),
|
||||||
|
keywords: ['clients', 'users', 'sub', 'traffic', 'quota'],
|
||||||
|
icon: <TeamOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/groups',
|
||||||
|
title: t('menu.groups'),
|
||||||
|
keywords: ['groups', 'tags', 'batch'],
|
||||||
|
icon: <TagsOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/nodes',
|
||||||
|
title: t('menu.nodes'),
|
||||||
|
keywords: ['nodes', 'servers', 'cluster', 'remote nodes'],
|
||||||
|
icon: <ClusterOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/hosts',
|
||||||
|
title: t('menu.hosts'),
|
||||||
|
keywords: ['hosts', 'sni', 'domains'],
|
||||||
|
icon: <GlobalOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/outbound',
|
||||||
|
title: t('menu.outbounds'),
|
||||||
|
keywords: ['outbounds', 'freedom', 'blackhole', 'socks', 'http', 'warp', 'nord', 'pia'],
|
||||||
|
icon: <ExportOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/routing',
|
||||||
|
title: t('menu.routing'),
|
||||||
|
keywords: ['routing', 'rules', 'geoip', 'geosite', 'direct', 'block'],
|
||||||
|
icon: <SwapOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings',
|
||||||
|
title: t('menu.settings'),
|
||||||
|
keywords: ['settings', 'config', 'port', 'password', 'ssl', 'telegram'],
|
||||||
|
icon: <SettingOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/xray',
|
||||||
|
title: t('menu.xray'),
|
||||||
|
keywords: ['xray', 'templates', 'balancer', 'dns'],
|
||||||
|
icon: <ToolOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/api-docs',
|
||||||
|
title: t('menu.apiDocs'),
|
||||||
|
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
|
||||||
|
icon: <ApiOutlined />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
pages
|
||||||
|
.filter((p) => matches(p.title, undefined, p.keywords))
|
||||||
|
.forEach((p) => {
|
||||||
|
list.push({
|
||||||
|
id: `nav-${p.path}`,
|
||||||
|
category: 'navigation',
|
||||||
|
title: p.title,
|
||||||
|
keywords: p.keywords,
|
||||||
|
icon: p.icon,
|
||||||
|
action: () => {
|
||||||
|
close();
|
||||||
|
navigate(p.path);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const settingsSubSections = [
|
||||||
|
{
|
||||||
|
path: '/settings#general',
|
||||||
|
title: `${t('menu.settings')} · ${t('pages.settings.panelSettings')}`,
|
||||||
|
subtitle: t('pages.settings.panelSettings'),
|
||||||
|
keywords: ['general', 'webPort', 'webBasePath', 'listenIP', 'ssl', 'certificate'],
|
||||||
|
icon: <SettingOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings#security',
|
||||||
|
title: `${t('menu.settings')} · ${t('pages.settings.securitySettings')}`,
|
||||||
|
subtitle: t('pages.settings.securitySettings'),
|
||||||
|
keywords: ['security', 'password', 'username', '2fa', 'two factor', 'login limit'],
|
||||||
|
icon: <SafetyOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings#telegram',
|
||||||
|
title: `${t('menu.settings')} · ${t('pages.settings.TGBotSettings')}`,
|
||||||
|
subtitle: t('pages.settings.TGBotSettings'),
|
||||||
|
keywords: ['telegram', 'tgbot', 'bot token', 'chat id', 'notifications', 'alerts'],
|
||||||
|
icon: <MessageOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings#email',
|
||||||
|
title: `${t('menu.settings')} · ${t('pages.settings.emailSettings')}`,
|
||||||
|
subtitle: t('pages.settings.emailSettings'),
|
||||||
|
keywords: ['email', 'smtp', 'mail', 'crash alerts'],
|
||||||
|
icon: <MailOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings#subscription',
|
||||||
|
title: `${t('menu.settings')} · ${t('pages.settings.subSettings')}`,
|
||||||
|
subtitle: t('pages.settings.subSettings'),
|
||||||
|
keywords: ['subscription', 'subPort', 'subURI', 'subDomain', 'reverse proxy'],
|
||||||
|
icon: <CloudServerOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings#subscription-formats',
|
||||||
|
title: `${t('menu.settings')} · ${t('menu.subFormats')}`,
|
||||||
|
subtitle: t('menu.subFormats'),
|
||||||
|
keywords: ['formats', 'clash', 'sing-box', 'v2ray', 'json', 'sub formats'],
|
||||||
|
icon: <CodeOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/settings#subscription-balancers',
|
||||||
|
title: `${t('menu.settings')} · ${t('pages.settings.subBalancers.menu')}`,
|
||||||
|
subtitle: t('pages.settings.subBalancers.menu'),
|
||||||
|
keywords: ['balancers', 'sub balancers', 'balancer nodes'],
|
||||||
|
icon: <ApartmentOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/xray#basic',
|
||||||
|
title: `${t('menu.xray')} · ${t('pages.xray.basicTemplate')}`,
|
||||||
|
subtitle: t('pages.xray.basicTemplate'),
|
||||||
|
keywords: ['basics', 'freedom strategy', 'happy eyeballs', 'torrent', 'connection'],
|
||||||
|
icon: <ToolOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/xray#basic',
|
||||||
|
title: `${t('menu.xray')} · ${t('pages.xray.metricsListen')}`,
|
||||||
|
subtitle: t('pages.xray.metricsListen'),
|
||||||
|
keywords: [
|
||||||
|
'metrics',
|
||||||
|
'prometheus',
|
||||||
|
'statistics',
|
||||||
|
'listen',
|
||||||
|
'statsInbound',
|
||||||
|
'statsOutbound',
|
||||||
|
'metrics_out',
|
||||||
|
],
|
||||||
|
icon: <DashboardOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/xray#basic',
|
||||||
|
title: `${t('menu.xray')} · ${t('pages.xray.connectionLimits')}`,
|
||||||
|
subtitle: t('pages.xray.connectionLimits'),
|
||||||
|
keywords: ['limits', 'idle timeout', 'bufferSize', 'connIdle', 'timeout'],
|
||||||
|
icon: <ClockCircleOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/xray#basic',
|
||||||
|
title: `${t('menu.xray')} · ${t('pages.xray.logConfigs')}`,
|
||||||
|
subtitle: t('pages.xray.logConfigs'),
|
||||||
|
keywords: ['logs', 'access log', 'error log', 'dns log', 'mask address', 'loglevel'],
|
||||||
|
icon: <FileTextOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/xray#balancer',
|
||||||
|
title: `${t('menu.xray')} · ${t('pages.xray.Balancers')}`,
|
||||||
|
subtitle: t('pages.xray.Balancers'),
|
||||||
|
keywords: ['balancers', 'leastPing', 'roundRobin', 'fallback', 'strategy'],
|
||||||
|
icon: <ClusterOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/xray#dns',
|
||||||
|
title: `${t('menu.xray')} · DNS`,
|
||||||
|
subtitle: 'DNS',
|
||||||
|
keywords: ['dns', 'dns servers', 'hosts', 'doh', 'dot', 'cloudflare dns'],
|
||||||
|
icon: <DatabaseOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/xray#outbound',
|
||||||
|
title: `${t('menu.xray')} · ${t('pages.xray.Outbounds')}`,
|
||||||
|
subtitle: t('pages.xray.Outbounds'),
|
||||||
|
keywords: ['outbound', 'freedom', 'direct', 'proxy outbounds'],
|
||||||
|
icon: <ExportOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/xray#routing',
|
||||||
|
title: `${t('menu.xray')} · ${t('pages.xray.basicRouting')}`,
|
||||||
|
subtitle: t('pages.xray.basicRouting'),
|
||||||
|
keywords: ['routing', 'routing rules', 'geoip', 'geosite', 'block', 'direct'],
|
||||||
|
icon: <SwapOutlined />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/xray#advanced',
|
||||||
|
title: `${t('menu.xray')} · ${t('pages.xray.advancedTemplate')}`,
|
||||||
|
subtitle: t('pages.xray.advancedTemplate'),
|
||||||
|
keywords: ['advanced', 'json template', 'advanced config', 'custom json'],
|
||||||
|
icon: <CodeOutlined />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
settingsSubSections
|
||||||
|
.filter((s) => matches(s.title, s.subtitle, s.keywords))
|
||||||
|
.forEach((s) => {
|
||||||
|
list.push({
|
||||||
|
id: `setting-${s.path}-${s.title}`,
|
||||||
|
category: 'settings',
|
||||||
|
title: s.title,
|
||||||
|
subtitle: s.subtitle,
|
||||||
|
keywords: s.keywords,
|
||||||
|
icon: s.icon,
|
||||||
|
action: () => {
|
||||||
|
close();
|
||||||
|
navigate(s.path);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions: PaletteItem[] = [
|
||||||
|
{
|
||||||
|
id: 'act-restart-xray',
|
||||||
|
category: 'actions',
|
||||||
|
title: t('commandPalette.restartXray'),
|
||||||
|
subtitle: t('pages.index.restartXray'),
|
||||||
|
keywords: ['restart', 'xray restart', 'reboot xray'],
|
||||||
|
icon: <ReloadOutlined style={{ color: '#faad14' }} />,
|
||||||
|
action: restartXray,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'act-cycle-theme',
|
||||||
|
category: 'actions',
|
||||||
|
title: t('menu.theme'),
|
||||||
|
subtitle: isUltra ? 'Ultra Dark' : isDark ? 'Dark' : 'Light',
|
||||||
|
keywords: ['theme', 'light', 'dark', 'ultra'],
|
||||||
|
icon: isDark ? <SunOutlined /> : <MoonOutlined />,
|
||||||
|
action: cycleTheme,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'act-add-inbound',
|
||||||
|
category: 'actions',
|
||||||
|
title: t('pages.inbounds.addInbound'),
|
||||||
|
subtitle: t('menu.inbounds'),
|
||||||
|
keywords: ['add inbound', 'create inbound', 'new port', 'new inbound'],
|
||||||
|
icon: <PlusOutlined style={{ color: '#52c41a' }} />,
|
||||||
|
action: () => {
|
||||||
|
close();
|
||||||
|
navigate('/inbounds');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'act-add-client',
|
||||||
|
category: 'actions',
|
||||||
|
title: t('pages.clients.addClient'),
|
||||||
|
subtitle: t('menu.clients'),
|
||||||
|
keywords: ['add client', 'create user', 'new client', 'new user'],
|
||||||
|
icon: <PlusOutlined style={{ color: '#52c41a' }} />,
|
||||||
|
action: () => {
|
||||||
|
close();
|
||||||
|
navigate('/clients');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
actions.filter((a) => matches(a.title, a.subtitle, a.keywords)).forEach((a) => list.push(a));
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}, [
|
||||||
|
query,
|
||||||
|
clientSearch,
|
||||||
|
inbounds,
|
||||||
|
isDark,
|
||||||
|
isUltra,
|
||||||
|
allSetting.subURI,
|
||||||
|
t,
|
||||||
|
close,
|
||||||
|
navigate,
|
||||||
|
copySubscription,
|
||||||
|
restartXray,
|
||||||
|
cycleTheme,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const clampedActiveIndex = Math.min(activeIndex, Math.max(0, items.length - 1));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!listRef.current) return;
|
||||||
|
const activeEl = listRef.current.querySelector(
|
||||||
|
`.command-palette-item[data-index="${clampedActiveIndex}"]`,
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (activeEl) {
|
||||||
|
activeEl.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
}, [clampedActiveIndex]);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((prev) => (items.length ? (prev + 1) % items.length : 0));
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setActiveIndex((prev) => (items.length ? (prev - 1 + items.length) % items.length : 0));
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const current = items[clampedActiveIndex];
|
||||||
|
if (current) current.action();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
let lastCategory = '';
|
||||||
|
const themeModeClass = isUltra ? 'ultra' : isDark ? 'dark' : 'light';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider theme={antdThemeConfig}>
|
||||||
|
<div
|
||||||
|
className={`command-palette-backdrop ${themeModeClass}`}
|
||||||
|
role="presentation"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) close();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`command-palette-modal ${themeModeClass}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('commandPalette.title')}
|
||||||
|
>
|
||||||
|
<div className="command-palette-header">
|
||||||
|
{isClientSearching ? (
|
||||||
|
<LoadingOutlined className="command-palette-search-icon spinning" />
|
||||||
|
) : (
|
||||||
|
<SearchOutlined className="command-palette-search-icon" />
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="command-palette-input"
|
||||||
|
type="text"
|
||||||
|
placeholder={t('commandPalette.placeholder')}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="command-palette-body" ref={listRef}>
|
||||||
|
{!isClientSearching && items.length === 0 && (
|
||||||
|
<div className="command-palette-empty">{t('noData')}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const isFirstOfCategory = item.category !== lastCategory;
|
||||||
|
lastCategory = item.category;
|
||||||
|
|
||||||
|
const categoryLabel =
|
||||||
|
item.category === 'clients'
|
||||||
|
? t('menu.clients')
|
||||||
|
: item.category === 'inbounds'
|
||||||
|
? t('menu.inbounds')
|
||||||
|
: item.category === 'navigation'
|
||||||
|
? t('commandPalette.navigation')
|
||||||
|
: item.category === 'settings'
|
||||||
|
? t('commandPalette.settings') || t('menu.settings')
|
||||||
|
: t('commandPalette.actions');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.id} className="command-palette-group">
|
||||||
|
{isFirstOfCategory && (
|
||||||
|
<div className="command-palette-group-title">{categoryLabel}</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={`command-palette-item ${index === clampedActiveIndex ? 'active' : ''}`}
|
||||||
|
data-index={index}
|
||||||
|
onClick={() => item.action()}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter on the nested copy button must activate that
|
||||||
|
// button, not the row it sits in.
|
||||||
|
if (e.target === e.currentTarget) activateOnKey(() => item.action())(e);
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setActiveIndex(index)}
|
||||||
|
>
|
||||||
|
<div className="command-palette-item-main">
|
||||||
|
<span className="command-palette-item-icon">{item.icon}</span>
|
||||||
|
<div className="command-palette-item-content">
|
||||||
|
<span className="command-palette-item-title">{item.title}</span>
|
||||||
|
{item.subtitle && (
|
||||||
|
<span className="command-palette-item-subtitle">{item.subtitle}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="command-palette-item-actions">
|
||||||
|
{item.tag}
|
||||||
|
{item.secondaryAction && (
|
||||||
|
<Tooltip
|
||||||
|
title={item.secondaryAction.label}
|
||||||
|
placement="top"
|
||||||
|
zIndex={2500}
|
||||||
|
rootClassName="command-palette-tooltip"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="command-palette-action-btn"
|
||||||
|
onClick={item.secondaryAction.execute}
|
||||||
|
aria-label={item.secondaryAction.label}
|
||||||
|
>
|
||||||
|
{item.secondaryAction.icon}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="command-palette-footer">
|
||||||
|
<div className="command-palette-kbd-group">
|
||||||
|
<span>
|
||||||
|
<kbd className="command-palette-kbd">↑</kbd>
|
||||||
|
<kbd className="command-palette-kbd">↓</kbd>
|
||||||
|
{t('commandPalette.navigate')}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="command-palette-kbd">↵</kbd>
|
||||||
|
{t('commandPalette.select')}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="command-palette-kbd">Esc</kbd>
|
||||||
|
{t('close')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span>3x-ui Command Palette</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useCallback, useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
let isOpen = false;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
listeners.forEach((listener) => listener());
|
||||||
|
}
|
||||||
|
|
||||||
|
export const commandPaletteStore = {
|
||||||
|
getSnapshot: () => isOpen,
|
||||||
|
subscribe: (listener: () => void) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
open: () => {
|
||||||
|
if (!isOpen) {
|
||||||
|
isOpen = true;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
if (isOpen) {
|
||||||
|
isOpen = false;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggle: () => {
|
||||||
|
isOpen = !isOpen;
|
||||||
|
notify();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCommandPalette() {
|
||||||
|
const open = useSyncExternalStore(commandPaletteStore.subscribe, commandPaletteStore.getSnapshot);
|
||||||
|
|
||||||
|
const openPalette = useCallback(() => commandPaletteStore.open(), []);
|
||||||
|
const closePalette = useCallback(() => commandPaletteStore.close(), []);
|
||||||
|
const togglePalette = useCallback(() => commandPaletteStore.toggle(), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isOpen: open,
|
||||||
|
open: openPalette,
|
||||||
|
close: closePalette,
|
||||||
|
toggle: togglePalette,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -712,11 +712,13 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"id": 1,
|
"id": 1,
|
||||||
"listen": "",
|
"listen": "",
|
||||||
"mtprotoDomain": "",
|
"mtprotoDomain": "",
|
||||||
|
"network": "",
|
||||||
"nodeAddress": "",
|
"nodeAddress": "",
|
||||||
"nodeId": null,
|
"nodeId": null,
|
||||||
"port": 443,
|
"port": 443,
|
||||||
"protocol": "vless",
|
"protocol": "vless",
|
||||||
"remark": "VLESS-443",
|
"remark": "VLESS-443",
|
||||||
|
"security": "",
|
||||||
"shareAddr": "",
|
"shareAddr": "",
|
||||||
"shareAddrStrategy": "",
|
"shareAddrStrategy": "",
|
||||||
"ssMethod": "",
|
"ssMethod": "",
|
||||||
|
|||||||
@@ -2718,6 +2718,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"mtprotoDomain": {
|
"mtprotoDomain": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"network": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"nodeAddress": {
|
"nodeAddress": {
|
||||||
"description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.",
|
"description": "Share-host resolution inputs, mirroring the subscription's\nresolveInboundAddress so the clients page renders a node-managed WireGuard\nEndpoint that points at the node, not the master panel. NodeAddress is the\nhosting node's externally reachable address (empty for this panel's own\ninbounds); Listen and ShareAddrStrategy/ShareAddr feed the same\nnode→listen→custom fallback the share/QR links already use.",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -2739,6 +2742,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"example": "VLESS-443",
|
"example": "VLESS-443",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"security": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"shareAddr": {
|
"shareAddr": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -622,11 +622,13 @@ export interface InboundOption {
|
|||||||
id: number;
|
id: number;
|
||||||
listen?: string;
|
listen?: string;
|
||||||
mtprotoDomain?: string;
|
mtprotoDomain?: string;
|
||||||
|
network?: string;
|
||||||
nodeAddress?: string;
|
nodeAddress?: string;
|
||||||
nodeId?: number | null;
|
nodeId?: number | null;
|
||||||
port: number;
|
port: number;
|
||||||
protocol: string;
|
protocol: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
|
security?: string;
|
||||||
shareAddr?: string;
|
shareAddr?: string;
|
||||||
shareAddrStrategy?: string;
|
shareAddrStrategy?: string;
|
||||||
ssMethod: string;
|
ssMethod: string;
|
||||||
|
|||||||
@@ -664,11 +664,13 @@ export const InboundOptionSchema = z.object({
|
|||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
listen: z.string().optional(),
|
listen: z.string().optional(),
|
||||||
mtprotoDomain: z.string().optional(),
|
mtprotoDomain: z.string().optional(),
|
||||||
|
network: z.string().optional(),
|
||||||
nodeAddress: z.string().optional(),
|
nodeAddress: z.string().optional(),
|
||||||
nodeId: z.number().int().nullable().optional(),
|
nodeId: z.number().int().nullable().optional(),
|
||||||
port: z.number().int(),
|
port: z.number().int(),
|
||||||
protocol: z.string(),
|
protocol: z.string(),
|
||||||
remark: z.string(),
|
remark: z.string(),
|
||||||
|
security: z.string().optional(),
|
||||||
shareAddr: z.string().optional(),
|
shareAddr: z.string().optional(),
|
||||||
shareAddrStrategy: z.string().optional(),
|
shareAddrStrategy: z.string().optional(),
|
||||||
ssMethod: z.string(),
|
ssMethod: z.string(),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import type { ComponentType, CSSProperties } from 'react';
|
import type { ComponentType, CSSProperties } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router';
|
import { useLocation, useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Drawer, Layout, Menu } from 'antd';
|
import { Drawer, Layout, Menu, Tooltip } from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
import {
|
import {
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
PushpinOutlined,
|
PushpinOutlined,
|
||||||
ReadOutlined,
|
ReadOutlined,
|
||||||
SafetyOutlined,
|
SafetyOutlined,
|
||||||
|
SearchOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
SunOutlined,
|
SunOutlined,
|
||||||
SwapOutlined,
|
SwapOutlined,
|
||||||
@@ -40,9 +41,13 @@ import { HttpUtil } from '@/utils';
|
|||||||
import { formatPanelVersion } from '@/lib/panel-version';
|
import { formatPanelVersion } from '@/lib/panel-version';
|
||||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||||
|
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
|
||||||
import './AppSidebar.css';
|
import './AppSidebar.css';
|
||||||
|
|
||||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||||
|
// The palette listens for Ctrl as well as Cmd, so the chip must not show a
|
||||||
|
// Mac glyph to the Linux and Windows operators who are most of this panel's.
|
||||||
|
const SHORTCUT_MODIFIER = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent) ? '⌘' : 'Ctrl';
|
||||||
const DOCS_URL = 'https://docs.sanaei.dev/';
|
const DOCS_URL = 'https://docs.sanaei.dev/';
|
||||||
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
||||||
const LOGOUT_KEY = '__logout__';
|
const LOGOUT_KEY = '__logout__';
|
||||||
@@ -174,6 +179,7 @@ function saveSidebarPinned(pinned: boolean) {
|
|||||||
export default function AppSidebar() {
|
export default function AppSidebar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
||||||
|
const { open: openCommandPalette } = useCommandPalette();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pathname, hash } = useLocation();
|
const { pathname, hash } = useLocation();
|
||||||
const { allSetting } = useAllSettings();
|
const { allSetting } = useAllSettings();
|
||||||
@@ -392,6 +398,30 @@ export default function AppSidebar() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
railCollapsed ? t('commandPalette.title') || 'Command Palette (Ctrl + K)' : undefined
|
||||||
|
}
|
||||||
|
placement="right"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`sidebar-command-trigger${railCollapsed ? ' collapsed' : ''}`}
|
||||||
|
onClick={openCommandPalette}
|
||||||
|
aria-label={t('commandPalette.title') || 'Command Palette (Ctrl + K)'}
|
||||||
|
>
|
||||||
|
<span className="sidebar-command-left">
|
||||||
|
<SearchOutlined className="sidebar-command-icon" />
|
||||||
|
<span className="sidebar-command-text">
|
||||||
|
{t('commandPalette.search') || 'Search...'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="sidebar-command-kbd">
|
||||||
|
<span className="kbd-cmd">{SHORTCUT_MODIFIER}</span>
|
||||||
|
<span className="kbd-key">K</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
<Menu
|
<Menu
|
||||||
theme={currentTheme}
|
theme={currentTheme}
|
||||||
mode="inline"
|
mode="inline"
|
||||||
@@ -452,6 +482,25 @@ export default function AppSidebar() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="sidebar-command-trigger"
|
||||||
|
onClick={() => {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
openCommandPalette();
|
||||||
|
}}
|
||||||
|
aria-label={t('commandPalette.title') || 'Command Palette (Ctrl + K)'}
|
||||||
|
style={{ margin: '8px 12px 4px', width: 'calc(100% - 24px)' }}
|
||||||
|
>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<SearchOutlined className="sidebar-command-icon" />
|
||||||
|
<span>{t('commandPalette.search') || 'Search...'}</span>
|
||||||
|
</span>
|
||||||
|
<span className="sidebar-command-kbd">
|
||||||
|
<span className="kbd-cmd">{SHORTCUT_MODIFIER}</span>
|
||||||
|
<span className="kbd-key">K</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
<Menu
|
<Menu
|
||||||
theme={currentTheme}
|
theme={currentTheme}
|
||||||
mode="inline"
|
mode="inline"
|
||||||
|
|||||||
@@ -2,9 +2,15 @@ import { Outlet } from 'react-router';
|
|||||||
|
|
||||||
import { useWebSocketBridge } from '@/api/websocketBridge';
|
import { useWebSocketBridge } from '@/api/websocketBridge';
|
||||||
import { usePageTitle } from '@/hooks/usePageTitle';
|
import { usePageTitle } from '@/hooks/usePageTitle';
|
||||||
|
import CommandPalette from '@/components/command-palette/CommandPalette';
|
||||||
|
|
||||||
export default function PanelLayout() {
|
export default function PanelLayout() {
|
||||||
useWebSocketBridge();
|
useWebSocketBridge();
|
||||||
usePageTitle();
|
usePageTitle();
|
||||||
return <Outlet />;
|
return (
|
||||||
|
<>
|
||||||
|
<Outlet />
|
||||||
|
<CommandPalette />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useLocation, useSearchParams } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -382,7 +383,12 @@ export default function ClientsPage() {
|
|||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const initial = readFilterState();
|
const initial = readFilterState();
|
||||||
const [searchKey, setSearchKey] = useState(initial.searchKey);
|
const location = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const searchParam = searchParams.get('search');
|
||||||
|
const [searchKey, setSearchKey] = useState(
|
||||||
|
searchParam !== null ? searchParam : initial.searchKey,
|
||||||
|
);
|
||||||
const [filters, setFilters] = useState<ClientFilters>(initial.filters);
|
const [filters, setFilters] = useState<ClientFilters>(initial.filters);
|
||||||
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
const [filterDrawerOpen, setFilterDrawerOpen] = useState(false);
|
||||||
|
|
||||||
@@ -405,6 +411,15 @@ export default function ClientsPage() {
|
|||||||
// debouncedSearch lags behind the input so we don't spam the server on every
|
// debouncedSearch lags behind the input so we don't spam the server on every
|
||||||
// keystroke; the search box still feels instant locally.
|
// keystroke; the search box still feels instant locally.
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
|
const [debouncedSearch, setDebouncedSearch] = useState(searchKey);
|
||||||
|
const [prevLocationKey, setPrevLocationKey] = useState(location.key);
|
||||||
|
|
||||||
|
if (location.key !== prevLocationKey) {
|
||||||
|
setPrevLocationKey(location.key);
|
||||||
|
if (searchParam !== null) {
|
||||||
|
setSearchKey(searchParam);
|
||||||
|
setDebouncedSearch(searchParam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useMemo, useState, type Key } from 'react';
|
import { useCallback, useMemo, useState, type Key } from 'react';
|
||||||
|
import { useLocation, useSearchParams } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -58,7 +59,18 @@ export default function InboundList({
|
|||||||
// Node filter (#4997): 'all' shows everything, 0 is the local-panel
|
// Node filter (#4997): 'all' shows everything, 0 is the local-panel
|
||||||
// sentinel (inbounds without a nodeId), otherwise a node id. Session-only.
|
// sentinel (inbounds without a nodeId), otherwise a node id. Session-only.
|
||||||
const [nodeFilter, setNodeFilter] = useState<number | 'all'>('all');
|
const [nodeFilter, setNodeFilter] = useState<number | 'all'>('all');
|
||||||
const [searchKey, setSearchKey] = useState('');
|
const location = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const searchParam = searchParams.get('search');
|
||||||
|
const [searchKey, setSearchKey] = useState(() => searchParam || '');
|
||||||
|
const [prevLocationKey, setPrevLocationKey] = useState(location.key);
|
||||||
|
|
||||||
|
if (location.key !== prevLocationKey) {
|
||||||
|
setPrevLocationKey(location.key);
|
||||||
|
if (searchParam !== null) {
|
||||||
|
setSearchKey(searchParam);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const showNodeFilter = useMemo(
|
const showNodeFilter = useMemo(
|
||||||
() => nodesById.size > 0 || dbInbounds.some((ib) => ib.nodeId != null),
|
() => nodesById.size > 0 || dbInbounds.some((ib) => ib.nodeId != null),
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ export const InboundOptionSchema = z
|
|||||||
tag: z.string().optional(),
|
tag: z.string().optional(),
|
||||||
protocol: z.string().optional(),
|
protocol: z.string().optional(),
|
||||||
port: z.number().optional(),
|
port: z.number().optional(),
|
||||||
|
network: z.string().optional(),
|
||||||
|
security: z.string().optional(),
|
||||||
tlsFlowCapable: z.boolean().optional(),
|
tlsFlowCapable: z.boolean().optional(),
|
||||||
ssMethod: z.string().optional(),
|
ssMethod: z.string().optional(),
|
||||||
wgPublicKey: z.string().optional(),
|
wgPublicKey: z.string().optional(),
|
||||||
|
|||||||
@@ -65,3 +65,9 @@ test('returns to the compact rail after unpinning', () => {
|
|||||||
expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 72px');
|
expect(sidebarRoot?.getAttribute('style')).toContain('--sider-rail: 72px');
|
||||||
expect(localStorage.getItem('sidebar-pinned')).toBe('false');
|
expect(localStorage.getItem('sidebar-pinned')).toBe('false');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('labels the palette shortcut with the modifier the platform actually uses', () => {
|
||||||
|
const view = renderSidebar();
|
||||||
|
const chip = view.container.querySelector('.sidebar-command-kbd');
|
||||||
|
expect(chip?.textContent).toBe('CtrlK');
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router';
|
||||||
|
|
||||||
|
import CommandPalette from '@/components/command-palette/CommandPalette';
|
||||||
|
import { commandPaletteStore } from '@/components/command-palette/useCommandPalette';
|
||||||
|
import { HttpUtil, Msg } from '@/utils';
|
||||||
|
import { renderWithProviders } from './test-utils';
|
||||||
|
|
||||||
|
function renderPalette() {
|
||||||
|
return renderWithProviders(
|
||||||
|
<MemoryRouter>
|
||||||
|
<CommandPalette />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CommandPalette component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||||
|
act(() => {
|
||||||
|
commandPaletteStore.close();
|
||||||
|
});
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render when closed', () => {
|
||||||
|
renderPalette();
|
||||||
|
expect(screen.queryByRole('dialog')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders and focuses input when opened via store', async () => {
|
||||||
|
renderPalette();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
commandPaletteStore.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||||
|
const input = screen.getByPlaceholderText(/Type a command or search/i);
|
||||||
|
expect(input).toBeTruthy();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles open and closed with Ctrl+K and Escape keyboard shortcuts', () => {
|
||||||
|
renderPalette();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', code: 'KeyK', ctrlKey: true }));
|
||||||
|
});
|
||||||
|
expect(commandPaletteStore.getSnapshot()).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
|
});
|
||||||
|
expect(commandPaletteStore.getSnapshot()).toBe(false);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ن', code: 'KeyK', ctrlKey: true }));
|
||||||
|
});
|
||||||
|
expect(commandPaletteStore.getSnapshot()).toBe(true);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||||
|
});
|
||||||
|
expect(commandPaletteStore.getSnapshot()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes when clicking backdrop', () => {
|
||||||
|
renderPalette();
|
||||||
|
act(() => {
|
||||||
|
commandPaletteStore.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
const backdrop = screen.getByRole('presentation');
|
||||||
|
fireEvent.click(backdrop);
|
||||||
|
|
||||||
|
expect(commandPaletteStore.getSnapshot()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates items with ArrowDown and ArrowUp', () => {
|
||||||
|
renderPalette();
|
||||||
|
act(() => {
|
||||||
|
commandPaletteStore.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(/Type a command or search/i);
|
||||||
|
const items = document.querySelectorAll('.command-palette-item');
|
||||||
|
expect(items.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
expect(items[0]?.classList.contains('active')).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||||
|
const updatedItems = document.querySelectorAll('.command-palette-item');
|
||||||
|
expect(updatedItems[1]?.classList.contains('active')).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowUp' });
|
||||||
|
const reupdatedItems = document.querySelectorAll('.command-palette-item');
|
||||||
|
expect(reupdatedItems[0]?.classList.contains('active')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters items when typing a search query', async () => {
|
||||||
|
renderPalette();
|
||||||
|
act(() => {
|
||||||
|
commandPaletteStore.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(/Type a command or search/i);
|
||||||
|
fireEvent.change(input, { target: { value: 'settings' } });
|
||||||
|
|
||||||
|
expect(screen.getAllByText(/Panel Settings/i).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets query on close and does not persist query on reopen', async () => {
|
||||||
|
renderPalette();
|
||||||
|
act(() => {
|
||||||
|
commandPaletteStore.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(/Type a command or search/i) as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { value: 'settings' } });
|
||||||
|
expect(input.value).toBe('settings');
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
commandPaletteStore.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
commandPaletteStore.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
const reopenedInput = screen.getByPlaceholderText(
|
||||||
|
/Type a command or search/i,
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(reopenedInput.value).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show spinning loader on whitespace-only input', () => {
|
||||||
|
renderPalette();
|
||||||
|
act(() => {
|
||||||
|
commandPaletteStore.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(/Type a command or search/i);
|
||||||
|
fireEvent.change(input, { target: { value: ' ' } });
|
||||||
|
|
||||||
|
expect(document.querySelector('.command-palette-search-icon.spinning')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not display stale client rows when a new search query is being fetched', async () => {
|
||||||
|
let resolveBob: ((val: Msg<{ items: unknown[] }>) => void) | undefined;
|
||||||
|
const bobPromise = new Promise<Msg<{ items: unknown[] }>>((resolve) => {
|
||||||
|
resolveBob = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
|
||||||
|
if (url.includes('/panel/api/inbounds/options')) {
|
||||||
|
return new Msg(true, '', []);
|
||||||
|
}
|
||||||
|
if (url.includes('search=ali')) {
|
||||||
|
return new Msg(true, '', {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
email: 'alice@example.com',
|
||||||
|
totalGB: 1000,
|
||||||
|
enable: true,
|
||||||
|
traffic: { up: 100, down: 200, total: 1000 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes('search=bob')) {
|
||||||
|
return bobPromise as Promise<Msg<unknown>>;
|
||||||
|
}
|
||||||
|
return new Msg(true, '', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPalette();
|
||||||
|
act(() => {
|
||||||
|
commandPaletteStore.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(/Type a command or search/i);
|
||||||
|
|
||||||
|
// Type 'ali' and wait for Alice to appear after debounce
|
||||||
|
fireEvent.change(input, { target: { value: 'ali' } });
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(screen.getByText('alice@example.com')).toBeTruthy();
|
||||||
|
},
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now type 'bob'
|
||||||
|
fireEvent.change(input, { target: { value: 'bob' } });
|
||||||
|
|
||||||
|
// Alice must vanish immediately upon new input
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('alice@example.com')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait past the 300ms debounce interval while bob fetch is still pending
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||||
|
|
||||||
|
// Stale Alice row must STILL not be rendered
|
||||||
|
expect(screen.queryByText('alice@example.com')).toBeNull();
|
||||||
|
|
||||||
|
// Now resolve bob
|
||||||
|
act(() => {
|
||||||
|
resolveBob?.(
|
||||||
|
new Msg(true, '', {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
email: 'bob@example.com',
|
||||||
|
totalGB: 500,
|
||||||
|
enable: true,
|
||||||
|
traffic: { up: 50, down: 100, total: 500 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(screen.getByText('bob@example.com')).toBeTruthy();
|
||||||
|
},
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('alice@example.com')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not re-trigger loading when adding trailing whitespace to settled query', async () => {
|
||||||
|
const getSpy = vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
|
||||||
|
if (url.includes('/panel/api/inbounds/options')) return new Msg(true, '', []);
|
||||||
|
return new Msg(true, '', { items: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPalette();
|
||||||
|
act(() => {
|
||||||
|
commandPaletteStore.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(/Type a command or search/i);
|
||||||
|
fireEvent.change(input, { target: { value: 'abc' } });
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
const calls = getSpy.mock.calls.filter((c) => String(c[0]).includes('search=abc')).length;
|
||||||
|
expect(calls).toBe(1);
|
||||||
|
},
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add trailing whitespace
|
||||||
|
fireEvent.change(input, { target: { value: 'abc ' } });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||||
|
|
||||||
|
// No extra search call because trimmed query has not changed
|
||||||
|
const callsAfterAbcSpace = getSpy.mock.calls.filter((c) =>
|
||||||
|
String(c[0]).includes('search=abc'),
|
||||||
|
).length;
|
||||||
|
expect(callsAfterAbcSpace).toBe(1);
|
||||||
|
expect(document.querySelector('.command-palette-search-icon.spinning')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the row secondary action independent of the row control', async () => {
|
||||||
|
vi.spyOn(HttpUtil, 'post').mockImplementation(
|
||||||
|
async (url: string) =>
|
||||||
|
new Msg(true, '', url.includes('/setting/all') ? { subURI: 'https://sub.example/' } : {}),
|
||||||
|
);
|
||||||
|
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
|
||||||
|
if (url.includes('/panel/api/inbounds/options')) return new Msg(true, '', []);
|
||||||
|
if (url.includes('search=ali')) {
|
||||||
|
return new Msg(true, '', {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
email: 'alice@example.com',
|
||||||
|
subId: 'sub123',
|
||||||
|
enable: true,
|
||||||
|
totalGB: 0,
|
||||||
|
traffic: { up: 100, down: 200, total: 0 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Msg(true, '', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPalette();
|
||||||
|
act(() => {
|
||||||
|
commandPaletteStore.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(/Type a command or search/i);
|
||||||
|
fireEvent.change(input, { target: { value: 'ali' } });
|
||||||
|
await waitFor(
|
||||||
|
() => {
|
||||||
|
expect(screen.getByText('alice@example.com')).toBeTruthy();
|
||||||
|
},
|
||||||
|
{ timeout: 2000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const copyBtn = document.querySelector('.command-palette-action-btn');
|
||||||
|
expect(copyBtn).toBeTruthy();
|
||||||
|
expect(copyBtn?.parentElement?.closest('button')).toBeNull();
|
||||||
|
|
||||||
|
// Enter on the copy button must not also fire the row's own action.
|
||||||
|
fireEvent.keyDown(copyBtn as Element, { key: 'Enter' });
|
||||||
|
expect(commandPaletteStore.getSnapshot()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a single theme action item without duplicates', () => {
|
||||||
|
renderPalette();
|
||||||
|
act(() => {
|
||||||
|
commandPaletteStore.open();
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(/Type a command or search/i);
|
||||||
|
fireEvent.change(input, { target: { value: 'theme' } });
|
||||||
|
|
||||||
|
const themeItems = screen.getAllByText(/Theme/i);
|
||||||
|
expect(themeItems.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -334,6 +334,8 @@ type InboundOption struct {
|
|||||||
Protocol string `json:"protocol" example:"vless"`
|
Protocol string `json:"protocol" example:"vless"`
|
||||||
Port int `json:"port" example:"443"`
|
Port int `json:"port" example:"443"`
|
||||||
Enable bool `json:"enable" example:"true"`
|
Enable bool `json:"enable" example:"true"`
|
||||||
|
Network string `json:"network,omitempty"`
|
||||||
|
Security string `json:"security,omitempty"`
|
||||||
TlsFlowCapable bool `json:"tlsFlowCapable" example:"true"`
|
TlsFlowCapable bool `json:"tlsFlowCapable" example:"true"`
|
||||||
SsMethod string `json:"ssMethod"`
|
SsMethod string `json:"ssMethod"`
|
||||||
WgPublicKey string `json:"wgPublicKey,omitempty"`
|
WgPublicKey string `json:"wgPublicKey,omitempty"`
|
||||||
@@ -389,6 +391,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
|||||||
out := make([]InboundOption, 0, len(rows))
|
out := make([]InboundOption, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
wgPublicKey, wgMtu, wgDns := inboundWireguardHints(r.Protocol, r.Settings)
|
wgPublicKey, wgMtu, wgDns := inboundWireguardHints(r.Protocol, r.Settings)
|
||||||
|
netHint, secHint := inboundStreamHints(r.Protocol, r.StreamSettings, r.Settings)
|
||||||
shareAddrStrategy := r.ShareAddrStrategy
|
shareAddrStrategy := r.ShareAddrStrategy
|
||||||
if shareAddrStrategy == "node" {
|
if shareAddrStrategy == "node" {
|
||||||
shareAddrStrategy = ""
|
shareAddrStrategy = ""
|
||||||
@@ -400,6 +403,8 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
|||||||
Protocol: r.Protocol,
|
Protocol: r.Protocol,
|
||||||
Port: r.Port,
|
Port: r.Port,
|
||||||
Enable: r.Enable,
|
Enable: r.Enable,
|
||||||
|
Network: netHint,
|
||||||
|
Security: secHint,
|
||||||
TlsFlowCapable: !r.DisableFlow && inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
|
TlsFlowCapable: !r.DisableFlow && inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
|
||||||
SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings),
|
SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings),
|
||||||
WgPublicKey: wgPublicKey,
|
WgPublicKey: wgPublicKey,
|
||||||
@@ -417,6 +422,44 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inboundStreamHints(protocol string, streamSettings string, settings string) (string, string) {
|
||||||
|
p := strings.ToLower(protocol)
|
||||||
|
if p == "wireguard" || p == "amneziawg" || p == "hysteria" {
|
||||||
|
return "udp", ""
|
||||||
|
}
|
||||||
|
var netHint, secHint string
|
||||||
|
if strings.TrimSpace(streamSettings) != "" {
|
||||||
|
var raw struct {
|
||||||
|
Network string `json:"network"`
|
||||||
|
Security string `json:"security"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(streamSettings), &raw); err == nil {
|
||||||
|
netHint = raw.Network
|
||||||
|
secHint = raw.Security
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if netHint == "" && strings.TrimSpace(settings) != "" {
|
||||||
|
var raw struct {
|
||||||
|
Network string `json:"network"`
|
||||||
|
AllowedNetwork string `json:"allowedNetwork"`
|
||||||
|
UDP bool `json:"udp"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(settings), &raw); err == nil {
|
||||||
|
if raw.Network != "" {
|
||||||
|
netHint = raw.Network
|
||||||
|
} else if raw.AllowedNetwork != "" {
|
||||||
|
netHint = raw.AllowedNetwork
|
||||||
|
} else if raw.UDP {
|
||||||
|
netHint = "tcp,udp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if netHint == "" {
|
||||||
|
netHint = "tcp"
|
||||||
|
}
|
||||||
|
return netHint, secHint
|
||||||
|
}
|
||||||
|
|
||||||
func inboundWireguardHints(protocol string, settings string) (string, int, string) {
|
func inboundWireguardHints(protocol string, settings string) (string, int, string) {
|
||||||
if protocol != string(model.WireGuard) || strings.TrimSpace(settings) == "" {
|
if protocol != string(model.WireGuard) || strings.TrimSpace(settings) == "" {
|
||||||
return "", 0, ""
|
return "", 0, ""
|
||||||
|
|||||||
@@ -2365,5 +2365,18 @@
|
|||||||
"statusFailed": "فشل",
|
"statusFailed": "فشل",
|
||||||
"statusDown": "غير متصل",
|
"statusDown": "غير متصل",
|
||||||
"statusUp": "متصل"
|
"statusUp": "متصل"
|
||||||
|
},
|
||||||
|
"commandPalette": {
|
||||||
|
"title": "لوحة الأوامر",
|
||||||
|
"placeholder": "اكتب أمرًا أو ابحث عن العملاء، المنافذ، الصفحات...",
|
||||||
|
"search": "بحث...",
|
||||||
|
"navigation": "التنقل",
|
||||||
|
"actions": "الإجراءات",
|
||||||
|
"navigate": "تنقل",
|
||||||
|
"select": "تحديد",
|
||||||
|
"copySubscription": "نسخ الاشتراك",
|
||||||
|
"restartXray": "إعادة تشغيل خدمة Xray",
|
||||||
|
"restartXraySuccess": "تمت إعادة تشغيل خدمة Xray بنجاح",
|
||||||
|
"settings": "الإعدادات"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2365,5 +2365,18 @@
|
|||||||
"statusFailed": "FAILED",
|
"statusFailed": "FAILED",
|
||||||
"statusDown": "DOWN",
|
"statusDown": "DOWN",
|
||||||
"statusUp": "UP"
|
"statusUp": "UP"
|
||||||
|
},
|
||||||
|
"commandPalette": {
|
||||||
|
"title": "Command Palette",
|
||||||
|
"placeholder": "Type a command or search clients, inbounds, pages...",
|
||||||
|
"search": "Search...",
|
||||||
|
"navigation": "Navigation",
|
||||||
|
"actions": "Actions",
|
||||||
|
"navigate": "Navigate",
|
||||||
|
"select": "Select",
|
||||||
|
"copySubscription": "Copy subscription",
|
||||||
|
"restartXray": "Restart Xray Service",
|
||||||
|
"restartXraySuccess": "Xray has been successfully relaunched",
|
||||||
|
"settings": "Settings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2365,5 +2365,18 @@
|
|||||||
"statusFailed": "FALLIDO",
|
"statusFailed": "FALLIDO",
|
||||||
"statusDown": "CAÍDO",
|
"statusDown": "CAÍDO",
|
||||||
"statusUp": "ACTIVO"
|
"statusUp": "ACTIVO"
|
||||||
|
},
|
||||||
|
"commandPalette": {
|
||||||
|
"title": "Paleta de comandos",
|
||||||
|
"placeholder": "Escriba un comando o busque clientes, inbounds, páginas...",
|
||||||
|
"search": "Buscar...",
|
||||||
|
"navigation": "Navegación",
|
||||||
|
"actions": "Acciones",
|
||||||
|
"navigate": "Navegar",
|
||||||
|
"select": "Seleccionar",
|
||||||
|
"copySubscription": "Copiar suscripción",
|
||||||
|
"restartXray": "Reiniciar servicio Xray",
|
||||||
|
"restartXraySuccess": "El servicio Xray se ha reiniciado con éxito",
|
||||||
|
"settings": "Configuración"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2365,5 +2365,18 @@
|
|||||||
"statusFailed": "ناموفق",
|
"statusFailed": "ناموفق",
|
||||||
"statusDown": "قطع",
|
"statusDown": "قطع",
|
||||||
"statusUp": "وصل"
|
"statusUp": "وصل"
|
||||||
|
},
|
||||||
|
"commandPalette": {
|
||||||
|
"title": "پالت دستورات",
|
||||||
|
"placeholder": "جستجوی کلاینتها، اینباندها، صفحات یا تایپ دستور...",
|
||||||
|
"search": "جستجو...",
|
||||||
|
"navigation": "ناوبری و صفحات",
|
||||||
|
"actions": "دستورات سیستمی",
|
||||||
|
"navigate": "پیمایش",
|
||||||
|
"select": "انتخاب",
|
||||||
|
"copySubscription": "کپی لینک اشتراک",
|
||||||
|
"restartXray": "راهاندازی مجدد سرویس Xray",
|
||||||
|
"restartXraySuccess": "سرویس Xray با موفقیت مجدداً راهاندازی شد",
|
||||||
|
"settings": "تنظیمات"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2365,5 +2365,18 @@
|
|||||||
"statusFailed": "GAGAL",
|
"statusFailed": "GAGAL",
|
||||||
"statusDown": "MATI",
|
"statusDown": "MATI",
|
||||||
"statusUp": "AKTIF"
|
"statusUp": "AKTIF"
|
||||||
|
},
|
||||||
|
"commandPalette": {
|
||||||
|
"title": "Palet Perintah",
|
||||||
|
"placeholder": "Ketik perintah atau cari klien, inbound, halaman...",
|
||||||
|
"search": "Cari...",
|
||||||
|
"navigation": "Navigasi",
|
||||||
|
"actions": "Tindakan",
|
||||||
|
"navigate": "Navigasi",
|
||||||
|
"select": "Pilih",
|
||||||
|
"copySubscription": "Salin langganan",
|
||||||
|
"restartXray": "Mulai Ulang Layanan Xray",
|
||||||
|
"restartXraySuccess": "Layanan Xray berhasil dimulai ulang",
|
||||||
|
"settings": "Pengaturan"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2365,5 +2365,18 @@
|
|||||||
"statusFailed": "失敗",
|
"statusFailed": "失敗",
|
||||||
"statusDown": "ダウン",
|
"statusDown": "ダウン",
|
||||||
"statusUp": "アップ"
|
"statusUp": "アップ"
|
||||||
|
},
|
||||||
|
"commandPalette": {
|
||||||
|
"title": "コマンドパレット",
|
||||||
|
"placeholder": "コマンドを入力するか、クライアント、インバウンド、ページを検索...",
|
||||||
|
"search": "検索...",
|
||||||
|
"navigation": "ナビゲーション",
|
||||||
|
"actions": "操作",
|
||||||
|
"navigate": "移動",
|
||||||
|
"select": "選択",
|
||||||
|
"copySubscription": "サブスクリプションをコピー",
|
||||||
|
"restartXray": "Xrayサービスを再起動",
|
||||||
|
"restartXraySuccess": "Xrayサービスが正常に再起動されました",
|
||||||
|
"settings": "設定"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2365,5 +2365,18 @@
|
|||||||
"statusFailed": "FALHOU",
|
"statusFailed": "FALHOU",
|
||||||
"statusDown": "INATIVO",
|
"statusDown": "INATIVO",
|
||||||
"statusUp": "ATIVO"
|
"statusUp": "ATIVO"
|
||||||
|
},
|
||||||
|
"commandPalette": {
|
||||||
|
"title": "Paleta de comandos",
|
||||||
|
"placeholder": "Digite um comando ou busque clientes, inbounds, páginas...",
|
||||||
|
"search": "Buscar...",
|
||||||
|
"navigation": "Navegação",
|
||||||
|
"actions": "Ações",
|
||||||
|
"navigate": "Navegar",
|
||||||
|
"select": "Selecionar",
|
||||||
|
"copySubscription": "Copiar assinatura",
|
||||||
|
"restartXray": "Reiniciar serviço Xray",
|
||||||
|
"restartXraySuccess": "O serviço Xray foi reiniciado com sucesso",
|
||||||
|
"settings": "Configurações"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2365,5 +2365,18 @@
|
|||||||
"statusFailed": "НЕУДАЧНО",
|
"statusFailed": "НЕУДАЧНО",
|
||||||
"statusDown": "НЕДОСТУПЕН",
|
"statusDown": "НЕДОСТУПЕН",
|
||||||
"statusUp": "РАБОТАЕТ"
|
"statusUp": "РАБОТАЕТ"
|
||||||
|
},
|
||||||
|
"commandPalette": {
|
||||||
|
"title": "Командная строка",
|
||||||
|
"placeholder": "Введите команду или найдите клиентов, инбаунды, страницы...",
|
||||||
|
"search": "Поиск...",
|
||||||
|
"navigation": "Навигация",
|
||||||
|
"actions": "Действия",
|
||||||
|
"navigate": "Перейти",
|
||||||
|
"select": "Выбрать",
|
||||||
|
"copySubscription": "Копировать подписку",
|
||||||
|
"restartXray": "Перезапустить службу Xray",
|
||||||
|
"restartXraySuccess": "Служба Xray успешно перезапущена",
|
||||||
|
"settings": "Настройки"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2365,5 +2365,18 @@
|
|||||||
"statusFailed": "BAŞARISIZ",
|
"statusFailed": "BAŞARISIZ",
|
||||||
"statusDown": "ÇEVRİMDIŞI",
|
"statusDown": "ÇEVRİMDIŞI",
|
||||||
"statusUp": "ÇEVRİMİÇİ"
|
"statusUp": "ÇEVRİMİÇİ"
|
||||||
|
},
|
||||||
|
"commandPalette": {
|
||||||
|
"title": "Komut Paleti",
|
||||||
|
"placeholder": "Komut yazın veya istemcileri, gelen bağlantıları, sayfaları arayın...",
|
||||||
|
"search": "Ara...",
|
||||||
|
"navigation": "Gezinme",
|
||||||
|
"actions": "İşlemler",
|
||||||
|
"navigate": "Gezin",
|
||||||
|
"select": "Seç",
|
||||||
|
"copySubscription": "Aboneliği kopyala",
|
||||||
|
"restartXray": "Xray Hizmetini Yeniden Başlat",
|
||||||
|
"restartXraySuccess": "Xray hizmeti başarıyla yeniden başlatıldı",
|
||||||
|
"settings": "Ayarlar"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2365,5 +2365,18 @@
|
|||||||
"statusFailed": "НЕВДАЛО",
|
"statusFailed": "НЕВДАЛО",
|
||||||
"statusDown": "НЕДОСТУПНО",
|
"statusDown": "НЕДОСТУПНО",
|
||||||
"statusUp": "ДОСТУПНО"
|
"statusUp": "ДОСТУПНО"
|
||||||
|
},
|
||||||
|
"commandPalette": {
|
||||||
|
"title": "Командний рядок",
|
||||||
|
"placeholder": "Введіть команду або знайдіть клієнтів, інбаунди, сторінки...",
|
||||||
|
"search": "Пошук...",
|
||||||
|
"navigation": "Навігація",
|
||||||
|
"actions": "Дії",
|
||||||
|
"navigate": "Перейти",
|
||||||
|
"select": "Вибрати",
|
||||||
|
"copySubscription": "Копіювати підписку",
|
||||||
|
"restartXray": "Перезапустити службу Xray",
|
||||||
|
"restartXraySuccess": "Службу Xray успішно перезапущено",
|
||||||
|
"settings": "Налаштування"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2365,5 +2365,18 @@
|
|||||||
"statusFailed": "THẤT BẠI",
|
"statusFailed": "THẤT BẠI",
|
||||||
"statusDown": "NGỪNG HOẠT ĐỘNG",
|
"statusDown": "NGỪNG HOẠT ĐỘNG",
|
||||||
"statusUp": "HOẠT ĐỘNG"
|
"statusUp": "HOẠT ĐỘNG"
|
||||||
|
},
|
||||||
|
"commandPalette": {
|
||||||
|
"title": "Bảng lệnh",
|
||||||
|
"placeholder": "Nhập lệnh hoặc tìm kiếm khách hàng, cổng vào, trang...",
|
||||||
|
"search": "Tìm kiếm...",
|
||||||
|
"navigation": "Điều hướng",
|
||||||
|
"actions": "Hành động",
|
||||||
|
"navigate": "Điều hướng",
|
||||||
|
"select": "Chọn",
|
||||||
|
"copySubscription": "Sao chép liên kết gói",
|
||||||
|
"restartXray": "Khởi động lại dịch vụ Xray",
|
||||||
|
"restartXraySuccess": "Dịch vụ Xray đã được khởi động lại thành công",
|
||||||
|
"settings": "Cài đặt"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2365,5 +2365,18 @@
|
|||||||
"statusFailed": "失败",
|
"statusFailed": "失败",
|
||||||
"statusDown": "断开",
|
"statusDown": "断开",
|
||||||
"statusUp": "恢复"
|
"statusUp": "恢复"
|
||||||
|
},
|
||||||
|
"commandPalette": {
|
||||||
|
"title": "命令面板",
|
||||||
|
"placeholder": "输入命令或搜索客户端、入站、页面...",
|
||||||
|
"search": "搜索...",
|
||||||
|
"navigation": "页面导航",
|
||||||
|
"actions": "系统操作",
|
||||||
|
"navigate": "导航",
|
||||||
|
"select": "选择",
|
||||||
|
"copySubscription": "复制订阅链接",
|
||||||
|
"restartXray": "重启 Xray 服务",
|
||||||
|
"restartXraySuccess": "Xray 服务已成功重启",
|
||||||
|
"settings": "设置"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2365,5 +2365,18 @@
|
|||||||
"statusFailed": "失敗",
|
"statusFailed": "失敗",
|
||||||
"statusDown": "中斷",
|
"statusDown": "中斷",
|
||||||
"statusUp": "恢復"
|
"statusUp": "恢復"
|
||||||
|
},
|
||||||
|
"commandPalette": {
|
||||||
|
"title": "命令面板",
|
||||||
|
"placeholder": "輸入命令或搜尋客戶端、入站、頁面...",
|
||||||
|
"search": "搜尋...",
|
||||||
|
"navigation": "頁面導航",
|
||||||
|
"actions": "系統操作",
|
||||||
|
"navigate": "導航",
|
||||||
|
"select": "選擇",
|
||||||
|
"copySubscription": "複製訂閱連結",
|
||||||
|
"restartXray": "重啟 Xray 服務",
|
||||||
|
"restartXraySuccess": "Xray 服務已成功重啟",
|
||||||
|
"settings": "設定"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user