diff --git a/admin-web/static/app.js b/admin-web/static/app.js index e3c9e19..c11b4ab 100644 --- a/admin-web/static/app.js +++ b/admin-web/static/app.js @@ -1138,12 +1138,12 @@ function renderUserTraffic() { } function renderUsers() { - const tbody = $("#usersTable"); + const container = $("#usersTable"); if (!state.users.length) { - tbody.innerHTML = `${escapeHtml(t("noKeys"))}`; + container.innerHTML = `
${escapeHtml(t("noKeys"))}
`; return; } - tbody.innerHTML = state.users.map((user) => { + container.innerHTML = state.users.map((user) => { const pending = state.pendingUsers.has(user.name); const selected = user.name === state.userTrafficUser; const traffic = user.traffic || {}; @@ -1151,13 +1151,12 @@ function renderUsers() { const activeIps = Number(traffic.active_unique_ips) || 0; const maxUniqueIps = Number.isFinite(Number(user.max_unique_ips)) ? Math.max(0, Number(user.max_unique_ips)) : 0; return ` - - +
+
+ ${escapeHtml(t("tableUser"))} - -
${escapeHtml(pending ? t("applying") : (user.enabled ? t("enabled") : t("disabled")))}
- - ${escapeHtml(user.secret)} - +
+
+ ${escapeHtml(t("tableSecret"))} + ${escapeHtml(user.secret)} +
+ +
+ ${escapeHtml(t("tableTraffic"))}
@@ -1188,14 +1192,15 @@ function renderUsers() {
- - +
+
+ ${escapeHtml(t("tableActions"))}
- - +
+
`; }).join(""); } diff --git a/admin-web/static/index.html b/admin-web/static/index.html index d6a6753..67dce75 100644 --- a/admin-web/static/index.html +++ b/admin-web/static/index.html @@ -194,20 +194,8 @@ -
- - - - - - - - - - - - -
UserStatusSecretLinkTrafficActions
+
+
@@ -400,6 +388,6 @@
- + diff --git a/admin-web/static/styles.css b/admin-web/static/styles.css index ede974d..34948a3 100644 --- a/admin-web/static/styles.css +++ b/admin-web/static/styles.css @@ -783,6 +783,79 @@ h2 { border-radius: 8px; } +.keys-wrap { + margin-top: 14px; +} + +.keys-list { + display: grid; + gap: 12px; +} + +.key-card { + display: grid; + grid-template-columns: + minmax(150px, .85fr) + minmax(260px, 1.35fr) + minmax(200px, 1fr) + minmax(260px, 1.1fr) + minmax(210px, .9fr); + grid-template-areas: "user secret links traffic actions"; + gap: 12px; + align-items: stretch; + min-width: 0; + padding: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + cursor: pointer; + transition: background .16s ease, box-shadow .16s ease, border-color .16s ease; +} + +.key-card:hover { + background: color-mix(in srgb, var(--blue) 7%, var(--panel)); +} + +.key-card.selected-row { + border-color: color-mix(in srgb, var(--blue) 34%, var(--line)); + background: color-mix(in srgb, var(--blue) 10%, var(--panel)); + box-shadow: inset 4px 0 0 var(--blue); +} + +.key-card-user, +.key-card-secret, +.key-card-links, +.key-card-traffic, +.key-card-actions { + display: grid; + align-content: center; + gap: 8px; + min-width: 0; +} + +.key-card-user { grid-area: user; } +.key-card-secret { grid-area: secret; } +.key-card-links { grid-area: links; } +.key-card-traffic { grid-area: traffic; } +.key-card-actions { grid-area: actions; } + +.key-card-secret code { + display: block; + max-width: 100%; + overflow-wrap: anywhere; + word-break: break-word; + white-space: normal; + font-size: 13px; + line-height: 1.35; +} + +.field-label { + color: var(--muted); + font-size: 12px; + font-weight: 800; + text-transform: uppercase; +} + table { width: 100%; min-width: 720px; @@ -863,6 +936,10 @@ td small { color: var(--muted); } +.key-name-button small { + color: var(--muted); +} + .action-buttons { display: flex; align-items: center; @@ -899,6 +976,24 @@ td small { flex-wrap: nowrap; } +.key-card .mini-actions, +.key-card .action-buttons { + display: grid; + grid-template-columns: 1fr; + align-content: center; + align-items: stretch; + gap: 8px; + width: 100%; +} + +.key-card .mini-actions button, +.key-card .action-buttons button { + width: 100%; + min-height: 44px; + white-space: normal; + line-height: 1.2; +} + .traffic-cell { display: grid; gap: 10px; @@ -1310,6 +1405,14 @@ td small { } @media (max-width: 1280px) { + .key-card { + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-areas: + "user traffic" + "secret links" + "actions actions"; + } + .service-grid { grid-template-columns: repeat(3, minmax(150px, 1fr)); } @@ -1488,6 +1591,16 @@ td small { flex-direction: column; } + .key-card { + grid-template-columns: 1fr; + grid-template-areas: + "user" + "secret" + "links" + "traffic" + "actions"; + } + .ip-limit-control { grid-template-columns: 1fr; } diff --git a/tests/test_admin_features.py b/tests/test_admin_features.py index ce99a78..857b6b6 100644 --- a/tests/test_admin_features.py +++ b/tests/test_admin_features.py @@ -129,19 +129,22 @@ class AdminFeatureTests(unittest.TestCase): self.assertNotIn("client = 3", text) self.assertNotIn("old = 4", text) - def test_keys_table_keeps_actions_inside_table_cell_wrapper(self): + def test_keys_view_uses_card_layout_without_horizontal_table(self): app_js = (ROOT / "admin-web" / "static" / "app.js").read_text(encoding="utf-8") styles = (ROOT / "admin-web" / "static" / "styles.css").read_text(encoding="utf-8") index = (ROOT / "admin-web" / "static" / "index.html").read_text(encoding="utf-8") self.assertNotIn('class="actions"', app_js) + self.assertIn('class="key-card', app_js) + self.assertIn('class="key-card-actions"', app_js) self.assertIn('class="action-buttons"', app_js) self.assertIn('class="key-name-button"', app_js) - self.assertIn(".keys-table .mini-actions,\n.keys-table .action-buttons", styles) + self.assertIn(".key-card .mini-actions,\n.key-card .action-buttons", styles) self.assertIn("grid-template-columns: 1fr", styles) self.assertIn("min-height: 44px", styles) self.assertNotIn("td.actions", styles) - self.assertRegex(index, r'[\s\S]*?') + self.assertIn('class="keys-list" id="usersTable"', index) + self.assertNotIn('class="keys-table"', index) if __name__ == "__main__":