mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-10 20:27:15 +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": {
|
||||
"type": "string"
|
||||
},
|
||||
"network": {
|
||||
"type": "string"
|
||||
},
|
||||
"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.",
|
||||
"type": "string"
|
||||
@@ -2765,6 +2768,9 @@
|
||||
"example": "VLESS-443",
|
||||
"type": "string"
|
||||
},
|
||||
"security": {
|
||||
"type": "string"
|
||||
},
|
||||
"shareAddr": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4515,11 +4521,13 @@
|
||||
"id": 1,
|
||||
"listen": "",
|
||||
"mtprotoDomain": "",
|
||||
"network": "",
|
||||
"nodeAddress": "",
|
||||
"nodeId": null,
|
||||
"port": 443,
|
||||
"protocol": "vless",
|
||||
"remark": "VLESS-443",
|
||||
"security": "",
|
||||
"shareAddr": "",
|
||||
"shareAddrStrategy": "",
|
||||
"ssMethod": "",
|
||||
|
||||
@@ -2744,6 +2744,9 @@
|
||||
"mtprotoDomain": {
|
||||
"type": "string"
|
||||
},
|
||||
"network": {
|
||||
"type": "string"
|
||||
},
|
||||
"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.",
|
||||
"type": "string"
|
||||
@@ -2765,6 +2768,9 @@
|
||||
"example": "VLESS-443",
|
||||
"type": "string"
|
||||
},
|
||||
"security": {
|
||||
"type": "string"
|
||||
},
|
||||
"shareAddr": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4515,11 +4521,13 @@
|
||||
"id": 1,
|
||||
"listen": "",
|
||||
"mtprotoDomain": "",
|
||||
"network": "",
|
||||
"nodeAddress": "",
|
||||
"nodeId": null,
|
||||
"port": 443,
|
||||
"protocol": "vless",
|
||||
"remark": "VLESS-443",
|
||||
"security": "",
|
||||
"shareAddr": "",
|
||||
"shareAddrStrategy": "",
|
||||
"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,
|
||||
"listen": "",
|
||||
"mtprotoDomain": "",
|
||||
"network": "",
|
||||
"nodeAddress": "",
|
||||
"nodeId": null,
|
||||
"port": 443,
|
||||
"protocol": "vless",
|
||||
"remark": "VLESS-443",
|
||||
"security": "",
|
||||
"shareAddr": "",
|
||||
"shareAddrStrategy": "",
|
||||
"ssMethod": "",
|
||||
|
||||
@@ -2718,6 +2718,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"mtprotoDomain": {
|
||||
"type": "string"
|
||||
},
|
||||
"network": {
|
||||
"type": "string"
|
||||
},
|
||||
"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.",
|
||||
"type": "string"
|
||||
@@ -2739,6 +2742,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": "VLESS-443",
|
||||
"type": "string"
|
||||
},
|
||||
"security": {
|
||||
"type": "string"
|
||||
},
|
||||
"shareAddr": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -622,11 +622,13 @@ export interface InboundOption {
|
||||
id: number;
|
||||
listen?: string;
|
||||
mtprotoDomain?: string;
|
||||
network?: string;
|
||||
nodeAddress?: string;
|
||||
nodeId?: number | null;
|
||||
port: number;
|
||||
protocol: string;
|
||||
remark: string;
|
||||
security?: string;
|
||||
shareAddr?: string;
|
||||
shareAddrStrategy?: string;
|
||||
ssMethod: string;
|
||||
|
||||
@@ -664,11 +664,13 @@ export const InboundOptionSchema = z.object({
|
||||
id: z.number().int(),
|
||||
listen: z.string().optional(),
|
||||
mtprotoDomain: z.string().optional(),
|
||||
network: z.string().optional(),
|
||||
nodeAddress: z.string().optional(),
|
||||
nodeId: z.number().int().nullable().optional(),
|
||||
port: z.number().int(),
|
||||
protocol: z.string(),
|
||||
remark: z.string(),
|
||||
security: z.string().optional(),
|
||||
shareAddr: z.string().optional(),
|
||||
shareAddrStrategy: z.string().optional(),
|
||||
ssMethod: z.string(),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ComponentType, CSSProperties } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Drawer, Layout, Menu } from 'antd';
|
||||
import { Drawer, Layout, Menu, Tooltip } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import {
|
||||
ApiOutlined,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
PushpinOutlined,
|
||||
ReadOutlined,
|
||||
SafetyOutlined,
|
||||
SearchOutlined,
|
||||
SettingOutlined,
|
||||
SunOutlined,
|
||||
SwapOutlined,
|
||||
@@ -40,9 +41,13 @@ import { HttpUtil } from '@/utils';
|
||||
import { formatPanelVersion } from '@/lib/panel-version';
|
||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
|
||||
import './AppSidebar.css';
|
||||
|
||||
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 REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
||||
const LOGOUT_KEY = '__logout__';
|
||||
@@ -174,6 +179,7 @@ function saveSidebarPinned(pinned: boolean) {
|
||||
export default function AppSidebar() {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
||||
const { open: openCommandPalette } = useCommandPalette();
|
||||
const navigate = useNavigate();
|
||||
const { pathname, hash } = useLocation();
|
||||
const { allSetting } = useAllSettings();
|
||||
@@ -392,6 +398,30 @@ export default function AppSidebar() {
|
||||
</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
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
@@ -452,6 +482,25 @@ export default function AppSidebar() {
|
||||
</button>
|
||||
</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
|
||||
theme={currentTheme}
|
||||
mode="inline"
|
||||
|
||||
@@ -2,9 +2,15 @@ import { Outlet } from 'react-router';
|
||||
|
||||
import { useWebSocketBridge } from '@/api/websocketBridge';
|
||||
import { usePageTitle } from '@/hooks/usePageTitle';
|
||||
import CommandPalette from '@/components/command-palette/CommandPalette';
|
||||
|
||||
export default function PanelLayout() {
|
||||
useWebSocketBridge();
|
||||
usePageTitle();
|
||||
return <Outlet />;
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<CommandPalette />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
@@ -382,7 +383,12 @@ export default function ClientsPage() {
|
||||
>(null);
|
||||
|
||||
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 [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
|
||||
// keystroke; the search box still feels instant locally.
|
||||
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(() => {
|
||||
localStorage.setItem(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo, useState, type Key } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Button,
|
||||
@@ -58,7 +59,18 @@ export default function InboundList({
|
||||
// Node filter (#4997): 'all' shows everything, 0 is the local-panel
|
||||
// sentinel (inbounds without a nodeId), otherwise a node id. Session-only.
|
||||
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(
|
||||
() => nodesById.size > 0 || dbInbounds.some((ib) => ib.nodeId != null),
|
||||
|
||||
@@ -108,6 +108,8 @@ export const InboundOptionSchema = z
|
||||
tag: z.string().optional(),
|
||||
protocol: z.string().optional(),
|
||||
port: z.number().optional(),
|
||||
network: z.string().optional(),
|
||||
security: z.string().optional(),
|
||||
tlsFlowCapable: z.boolean().optional(),
|
||||
ssMethod: 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(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"`
|
||||
Port int `json:"port" example:"443"`
|
||||
Enable bool `json:"enable" example:"true"`
|
||||
Network string `json:"network,omitempty"`
|
||||
Security string `json:"security,omitempty"`
|
||||
TlsFlowCapable bool `json:"tlsFlowCapable" example:"true"`
|
||||
SsMethod string `json:"ssMethod"`
|
||||
WgPublicKey string `json:"wgPublicKey,omitempty"`
|
||||
@@ -389,6 +391,7 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
||||
out := make([]InboundOption, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
wgPublicKey, wgMtu, wgDns := inboundWireguardHints(r.Protocol, r.Settings)
|
||||
netHint, secHint := inboundStreamHints(r.Protocol, r.StreamSettings, r.Settings)
|
||||
shareAddrStrategy := r.ShareAddrStrategy
|
||||
if shareAddrStrategy == "node" {
|
||||
shareAddrStrategy = ""
|
||||
@@ -400,6 +403,8 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
||||
Protocol: r.Protocol,
|
||||
Port: r.Port,
|
||||
Enable: r.Enable,
|
||||
Network: netHint,
|
||||
Security: secHint,
|
||||
TlsFlowCapable: !r.DisableFlow && inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings, r.Settings),
|
||||
SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings),
|
||||
WgPublicKey: wgPublicKey,
|
||||
@@ -417,6 +422,44 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
||||
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) {
|
||||
if protocol != string(model.WireGuard) || strings.TrimSpace(settings) == "" {
|
||||
return "", 0, ""
|
||||
|
||||
@@ -2365,5 +2365,18 @@
|
||||
"statusFailed": "فشل",
|
||||
"statusDown": "غير متصل",
|
||||
"statusUp": "متصل"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "لوحة الأوامر",
|
||||
"placeholder": "اكتب أمرًا أو ابحث عن العملاء، المنافذ، الصفحات...",
|
||||
"search": "بحث...",
|
||||
"navigation": "التنقل",
|
||||
"actions": "الإجراءات",
|
||||
"navigate": "تنقل",
|
||||
"select": "تحديد",
|
||||
"copySubscription": "نسخ الاشتراك",
|
||||
"restartXray": "إعادة تشغيل خدمة Xray",
|
||||
"restartXraySuccess": "تمت إعادة تشغيل خدمة Xray بنجاح",
|
||||
"settings": "الإعدادات"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2365,5 +2365,18 @@
|
||||
"statusFailed": "FAILED",
|
||||
"statusDown": "DOWN",
|
||||
"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",
|
||||
"statusDown": "CAÍDO",
|
||||
"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": "ناموفق",
|
||||
"statusDown": "قطع",
|
||||
"statusUp": "وصل"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "پالت دستورات",
|
||||
"placeholder": "جستجوی کلاینتها، اینباندها، صفحات یا تایپ دستور...",
|
||||
"search": "جستجو...",
|
||||
"navigation": "ناوبری و صفحات",
|
||||
"actions": "دستورات سیستمی",
|
||||
"navigate": "پیمایش",
|
||||
"select": "انتخاب",
|
||||
"copySubscription": "کپی لینک اشتراک",
|
||||
"restartXray": "راهاندازی مجدد سرویس Xray",
|
||||
"restartXraySuccess": "سرویس Xray با موفقیت مجدداً راهاندازی شد",
|
||||
"settings": "تنظیمات"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2365,5 +2365,18 @@
|
||||
"statusFailed": "GAGAL",
|
||||
"statusDown": "MATI",
|
||||
"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": "失敗",
|
||||
"statusDown": "ダウン",
|
||||
"statusUp": "アップ"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "コマンドパレット",
|
||||
"placeholder": "コマンドを入力するか、クライアント、インバウンド、ページを検索...",
|
||||
"search": "検索...",
|
||||
"navigation": "ナビゲーション",
|
||||
"actions": "操作",
|
||||
"navigate": "移動",
|
||||
"select": "選択",
|
||||
"copySubscription": "サブスクリプションをコピー",
|
||||
"restartXray": "Xrayサービスを再起動",
|
||||
"restartXraySuccess": "Xrayサービスが正常に再起動されました",
|
||||
"settings": "設定"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2365,5 +2365,18 @@
|
||||
"statusFailed": "FALHOU",
|
||||
"statusDown": "INATIVO",
|
||||
"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": "НЕУДАЧНО",
|
||||
"statusDown": "НЕДОСТУПЕН",
|
||||
"statusUp": "РАБОТАЕТ"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Командная строка",
|
||||
"placeholder": "Введите команду или найдите клиентов, инбаунды, страницы...",
|
||||
"search": "Поиск...",
|
||||
"navigation": "Навигация",
|
||||
"actions": "Действия",
|
||||
"navigate": "Перейти",
|
||||
"select": "Выбрать",
|
||||
"copySubscription": "Копировать подписку",
|
||||
"restartXray": "Перезапустить службу Xray",
|
||||
"restartXraySuccess": "Служба Xray успешно перезапущена",
|
||||
"settings": "Настройки"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2365,5 +2365,18 @@
|
||||
"statusFailed": "BAŞARISIZ",
|
||||
"statusDown": "ÇEVRİMDIŞI",
|
||||
"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": "НЕВДАЛО",
|
||||
"statusDown": "НЕДОСТУПНО",
|
||||
"statusUp": "ДОСТУПНО"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Командний рядок",
|
||||
"placeholder": "Введіть команду або знайдіть клієнтів, інбаунди, сторінки...",
|
||||
"search": "Пошук...",
|
||||
"navigation": "Навігація",
|
||||
"actions": "Дії",
|
||||
"navigate": "Перейти",
|
||||
"select": "Вибрати",
|
||||
"copySubscription": "Копіювати підписку",
|
||||
"restartXray": "Перезапустити службу Xray",
|
||||
"restartXraySuccess": "Службу Xray успішно перезапущено",
|
||||
"settings": "Налаштування"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2365,5 +2365,18 @@
|
||||
"statusFailed": "THẤT BẠI",
|
||||
"statusDown": "NGỪNG 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": "失败",
|
||||
"statusDown": "断开",
|
||||
"statusUp": "恢复"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "命令面板",
|
||||
"placeholder": "输入命令或搜索客户端、入站、页面...",
|
||||
"search": "搜索...",
|
||||
"navigation": "页面导航",
|
||||
"actions": "系统操作",
|
||||
"navigate": "导航",
|
||||
"select": "选择",
|
||||
"copySubscription": "复制订阅链接",
|
||||
"restartXray": "重启 Xray 服务",
|
||||
"restartXraySuccess": "Xray 服务已成功重启",
|
||||
"settings": "设置"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2365,5 +2365,18 @@
|
||||
"statusFailed": "失敗",
|
||||
"statusDown": "中斷",
|
||||
"statusUp": "恢復"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "命令面板",
|
||||
"placeholder": "輸入命令或搜尋客戶端、入站、頁面...",
|
||||
"search": "搜尋...",
|
||||
"navigation": "頁面導航",
|
||||
"actions": "系統操作",
|
||||
"navigate": "導航",
|
||||
"select": "選擇",
|
||||
"copySubscription": "複製訂閱連結",
|
||||
"restartXray": "重啟 Xray 服務",
|
||||
"restartXraySuccess": "Xray 服務已成功重啟",
|
||||
"settings": "設定"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user