Compare commits
3 Commits
4ffa1c7c25
...
af0b0e450b
| Author | SHA1 | Date | |
|---|---|---|---|
| af0b0e450b | |||
|
|
9ad4182b2b | ||
|
|
49c859ff55 |
29
src/App.tsx
29
src/App.tsx
@ -1,4 +1,6 @@
|
|||||||
import { useReducer } from "react";
|
import { useReducer, useState } from "react";
|
||||||
|
import { SessionQueryIntent, SessionTable } from "./SessionTable";
|
||||||
|
import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState";
|
||||||
import {
|
import {
|
||||||
createInitialWorkspaceState,
|
createInitialWorkspaceState,
|
||||||
selectActiveTab,
|
selectActiveTab,
|
||||||
@ -15,8 +17,11 @@ const sessions = [
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [workspace, dispatch] = useReducer(workspaceReducer, undefined, createInitialWorkspaceState);
|
const [workspace, dispatch] = useReducer(workspaceReducer, undefined, createInitialWorkspaceState);
|
||||||
|
const [tableStates, setTableStates] = useState(() => initializeSessionTableStates(workspace.serverOrder));
|
||||||
|
const [lastQueryIntent, setLastQueryIntent] = useState<Record<string, SessionQueryIntent | undefined>>({});
|
||||||
const server = selectActiveWorkspace(workspace);
|
const server = selectActiveWorkspace(workspace);
|
||||||
const activeTab = selectActiveTab(server);
|
const activeTab = selectActiveTab(server);
|
||||||
|
const tableState = { ...tableStates[server.id], filter: server.filter };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="shell">
|
<main className="shell">
|
||||||
@ -82,23 +87,11 @@ export function App() {
|
|||||||
<div><h2>{activeTab.title}</h2><p>{activeTab.context.project} 中的 248 个 Session · 4 个实时更新</p></div>
|
<div><h2>{activeTab.title}</h2><p>{activeTab.context.project} 中的 248 个 Session · 4 个实时更新</p></div>
|
||||||
<div className="heading-actions"><button className="secondary">导出视图</button><button className="primary">+ 新建操作</button></div>
|
<div className="heading-actions"><button className="secondary">导出视图</button><button className="primary">+ 新建操作</button></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="table-toolbar">
|
<SessionTable rows={sessions} state={tableState} capabilities={{ filtering: true, sortableColumns: ["status", "id", "seen"], columnVisibility: true, pagination: true }} page={{ start: 1, end: 50, total: 248, hasPrevious: false, hasNext: true }} onStateChange={(state) => {
|
||||||
<label><span>⌕</span><input aria-label="筛选 Session" placeholder="筛选 Session…" value={server.filter} onChange={(event) => dispatch({ type: "setFilter", serverId: server.id, filter: event.target.value })} /></label>
|
if (state.filter !== server.filter) dispatch({ type: "setFilter", serverId: server.id, filter: state.filter });
|
||||||
<button>状态:全部⌄</button><button>标签⌄</button><button>+ 筛选</button>
|
setTableStates((current) => updateSessionTableState(current, server.id, state));
|
||||||
<span className="toolbar-spacer" /><button aria-label="调整列">☷ 列</button><button aria-label="更多操作">•••</button>
|
}} onQueryIntent={(intent) => setLastQueryIntent((current) => ({ ...current, [server.id]: intent }))} />
|
||||||
</div>
|
<output className="query-intent" aria-live="polite">{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}</output>
|
||||||
<section className="session-table" aria-label="Session 表格">
|
|
||||||
<div className="table-row table-head" role="row"><span><input type="checkbox" aria-label="选择全部" /></span><span>STATUS ↕</span><span>SESSION ID</span><span>HOST / USER</span><span>OS</span><span>SOURCE ADDRESS</span><span>LAST SEEN ↓</span><span>TAGS</span><span /></div>
|
|
||||||
{sessions.map((session) => <div className={`table-row ${session.id === "NW-042" ? "focused" : ""}`} role="row" key={session.id} tabIndex={0}>
|
|
||||||
<span><input type="checkbox" aria-label={`选择 ${session.id}`} /></span>
|
|
||||||
<span><em className={`status-mark ${session.status.toLowerCase()}`} />{session.status}</span>
|
|
||||||
<strong className="mono">{session.id}</strong>
|
|
||||||
<span><b>{session.host}</b><small>{session.user}</small></span>
|
|
||||||
<span>{session.os}</span><span className="mono muted">{session.address}</span><span className="mono muted">{session.seen}</span>
|
|
||||||
<span className="tags">{session.tags.map((tag) => <small key={tag}>{tag}</small>)}</span><button className="row-action" aria-label={`${session.id} 快速操作`}>•••</button>
|
|
||||||
</div>)}
|
|
||||||
<footer><span>已选择 0 项</span><span>1–50 / 248</span><button>‹</button><button>›</button></footer>
|
|
||||||
</section>
|
|
||||||
</article>
|
</article>
|
||||||
<footer className="statusbar"><span>● {server.connection}</span><span>身份 operator@example</span><span>延迟 38ms</span><span>协议 v1.0</span><span>事件流 ✓ 已同步</span><span className="status-spacer" />内存工作区</footer>
|
<footer className="statusbar"><span>● {server.connection}</span><span>身份 operator@example</span><span>延迟 38ms</span><span>协议 v1.0</span><span>事件流 ✓ 已同步</span><span className="status-spacer" />内存工作区</footer>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
53
src/SessionTable.test.tsx
Normal file
53
src/SessionTable.test.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { createSessionTableViewState, SessionQueryIntent, SessionRow, SessionTable } from "./SessionTable";
|
||||||
|
|
||||||
|
const rows: SessionRow[] = [
|
||||||
|
{ id: "S-1", status: "ONLINE", host: "alpha", user: "one", os: "Linux", address: "10.0.0.1", seen: "1m", tags: ["a"] },
|
||||||
|
{ id: "S-2", status: "OFFLINE", host: "beta", user: "two", os: "Windows", address: "10.0.0.2", seen: "2m", tags: ["b"] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Harness({ onIntent = () => undefined }: { onIntent?: (intent: SessionQueryIntent) => void }) {
|
||||||
|
const [state, setState] = useState(createSessionTableViewState);
|
||||||
|
return <SessionTable rows={rows} state={state} capabilities={{ filtering: true, sortableColumns: ["status", "id", "seen"], columnVisibility: true, pagination: true }} page={{ start: 1, end: 2, total: 200, hasPrevious: false, hasNext: true }} onStateChange={setState} onQueryIntent={onIntent} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("SessionTable", () => {
|
||||||
|
it("使用方向键移动行焦点,并用空格选择", () => {
|
||||||
|
render(<Harness />);
|
||||||
|
const first = screen.getByRole("row", { name: /S-1/ });
|
||||||
|
first.focus();
|
||||||
|
fireEvent.keyDown(first, { key: "ArrowDown" });
|
||||||
|
const second = screen.getByRole("row", { name: /S-2/ });
|
||||||
|
expect(second).toHaveFocus();
|
||||||
|
fireEvent.keyDown(second, { key: " " });
|
||||||
|
expect(screen.getByRole("checkbox", { name: "选择 S-2" })).toBeChecked();
|
||||||
|
expect(screen.getByText("已选择 1 项")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("支持当前页多选,不声称选择服务端未加载行", () => {
|
||||||
|
render(<Harness />);
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "选择当前页全部 Session" }));
|
||||||
|
expect(screen.getByText("已选择 2 项")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1–2 / 200")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("筛选和排序产生显式查询意图", () => {
|
||||||
|
const onIntent = vi.fn();
|
||||||
|
render(<Harness onIntent={onIntent} />);
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "筛选 Session" }), { target: { value: "alpha" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /最后活动/ }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /最后活动/ }));
|
||||||
|
expect(onIntent).toHaveBeenNthCalledWith(1, { type: "filter", value: "alpha" });
|
||||||
|
expect(onIntent).toHaveBeenNthCalledWith(2, { type: "sort", column: "seen", direction: "ascending" });
|
||||||
|
expect(onIntent).toHaveBeenNthCalledWith(3, { type: "sort", column: "seen", direction: "descending" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("可以隐藏列", () => {
|
||||||
|
render(<Harness />);
|
||||||
|
fireEvent.click(screen.getByText("☷ 列"));
|
||||||
|
fireEvent.click(screen.getByRole("checkbox", { name: "来源地址" }));
|
||||||
|
expect(screen.queryByText("10.0.0.1")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
147
src/SessionTable.tsx
Normal file
147
src/SessionTable.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { CSSProperties, KeyboardEvent, useRef } from "react";
|
||||||
|
|
||||||
|
export type SessionStatus = "ONLINE" | "IDLE" | "OFFLINE" | (string & {});
|
||||||
|
|
||||||
|
export interface SessionRow {
|
||||||
|
id: string;
|
||||||
|
status: SessionStatus;
|
||||||
|
host: string;
|
||||||
|
user: string;
|
||||||
|
os: string;
|
||||||
|
address: string;
|
||||||
|
seen: string;
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionColumnId = "status" | "id" | "identity" | "os" | "address" | "seen" | "tags";
|
||||||
|
export type SortDirection = "ascending" | "descending";
|
||||||
|
|
||||||
|
export interface SessionTableViewState {
|
||||||
|
filter: string;
|
||||||
|
focusedSessionId?: string;
|
||||||
|
selectedSessionIds: string[];
|
||||||
|
sort?: { column: SessionColumnId; direction: SortDirection };
|
||||||
|
visibleColumns: SessionColumnId[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SessionQueryIntent =
|
||||||
|
| { type: "filter"; value: string }
|
||||||
|
| { type: "sort"; column: SessionColumnId; direction: SortDirection }
|
||||||
|
| { type: "page"; direction: "previous" | "next" };
|
||||||
|
|
||||||
|
export interface SessionTableProps {
|
||||||
|
rows: SessionRow[];
|
||||||
|
state: SessionTableViewState;
|
||||||
|
capabilities: SessionTableCapabilities;
|
||||||
|
page: { start: number; end: number; total: number; hasPrevious: boolean; hasNext: boolean };
|
||||||
|
onStateChange: (state: SessionTableViewState) => void;
|
||||||
|
onQueryIntent: (intent: SessionQueryIntent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionTableCapabilities {
|
||||||
|
filtering: boolean;
|
||||||
|
sortableColumns: SessionColumnId[];
|
||||||
|
columnVisibility: boolean;
|
||||||
|
pagination: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionColumns: { id: SessionColumnId; label: string; sortable?: boolean }[] = [
|
||||||
|
{ id: "status", label: "状态", sortable: true },
|
||||||
|
{ id: "id", label: "Session ID", sortable: true },
|
||||||
|
{ id: "identity", label: "主机 / 用户" },
|
||||||
|
{ id: "os", label: "OS" },
|
||||||
|
{ id: "address", label: "来源地址" },
|
||||||
|
{ id: "seen", label: "最后活动", sortable: true },
|
||||||
|
{ id: "tags", label: "标签" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createSessionTableViewState(): SessionTableViewState {
|
||||||
|
return { filter: "", selectedSessionIds: [], visibleColumns: sessionColumns.map(({ id }) => id) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextSort(state: SessionTableViewState, column: SessionColumnId): SortDirection {
|
||||||
|
return state.sort?.column === column && state.sort.direction === "ascending" ? "descending" : "ascending";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionTable({ rows, state, capabilities, page, onStateChange, onQueryIntent }: SessionTableProps) {
|
||||||
|
const rowRefs = useRef(new Map<string, HTMLDivElement>());
|
||||||
|
const selected = new Set(state.selectedSessionIds);
|
||||||
|
const visible = new Set(state.visibleColumns);
|
||||||
|
const allSelected = rows.length > 0 && rows.every(({ id }) => selected.has(id));
|
||||||
|
|
||||||
|
const updateSelection = (id: string, checked: boolean) => {
|
||||||
|
const next = new Set(selected);
|
||||||
|
checked ? next.add(id) : next.delete(id);
|
||||||
|
onStateChange({ ...state, selectedSessionIds: [...next] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const focusRow = (index: number) => {
|
||||||
|
const row = rows[Math.max(0, Math.min(rows.length - 1, index))];
|
||||||
|
if (!row) return;
|
||||||
|
onStateChange({ ...state, focusedSessionId: row.id });
|
||||||
|
rowRefs.current.get(row.id)?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowKeyDown = (event: KeyboardEvent<HTMLDivElement>, index: number, id: string) => {
|
||||||
|
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") {
|
||||||
|
event.preventDefault();
|
||||||
|
const target = event.key === "Home" ? 0 : event.key === "End" ? rows.length - 1 : index + (event.key === "ArrowDown" ? 1 : -1);
|
||||||
|
focusRow(target);
|
||||||
|
}
|
||||||
|
if (event.key === " " && event.target === event.currentTarget) {
|
||||||
|
event.preventDefault();
|
||||||
|
updateSelection(id, !selected.has(id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sort = (column: SessionColumnId) => {
|
||||||
|
const direction = nextSort(state, column);
|
||||||
|
onStateChange({ ...state, sort: { column, direction } });
|
||||||
|
onQueryIntent({ type: "sort", column, direction });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="table-toolbar">
|
||||||
|
<label><span>⌕</span><input aria-label="筛选 Session" disabled={!capabilities.filtering} value={state.filter} placeholder={capabilities.filtering ? "筛选 Session…" : "服务端未声明筛选能力"} onChange={(event) => {
|
||||||
|
const filter = event.currentTarget.value;
|
||||||
|
onStateChange({ ...state, filter });
|
||||||
|
onQueryIntent({ type: "filter", value: filter });
|
||||||
|
}} /></label>
|
||||||
|
<button>状态:全部⌄</button><button>标签⌄</button><button>+ 筛选</button>
|
||||||
|
<span className="toolbar-spacer" />
|
||||||
|
{capabilities.columnVisibility && <details className="column-picker"><summary aria-label="调整列">☷ 列</summary><fieldset><legend>显示列</legend>{sessionColumns.map((column) => <label key={column.id}><input type="checkbox" checked={visible.has(column.id)} onChange={(event) => {
|
||||||
|
const next = event.currentTarget.checked ? [...state.visibleColumns, column.id] : state.visibleColumns.filter((id) => id !== column.id);
|
||||||
|
onStateChange({ ...state, visibleColumns: next });
|
||||||
|
}} />{column.label}</label>)}</fieldset></details>}
|
||||||
|
<button aria-label="更多操作">•••</button>
|
||||||
|
</div>
|
||||||
|
<section className="session-table" aria-label="Session 表格" style={{ "--session-columns": state.visibleColumns.map((id) => `var(--column-${id})`).join(" ") } as CSSProperties}>
|
||||||
|
<div className="table-row table-head" role="row">
|
||||||
|
<span><input type="checkbox" aria-label="选择当前页全部 Session" checked={allSelected} onChange={(event) => {
|
||||||
|
const next = new Set(selected);
|
||||||
|
rows.forEach(({ id }) => event.currentTarget.checked ? next.add(id) : next.delete(id));
|
||||||
|
onStateChange({ ...state, selectedSessionIds: [...next] });
|
||||||
|
}} /></span>
|
||||||
|
{sessionColumns.filter(({ id }) => visible.has(id)).map((column) => {
|
||||||
|
const sortable = column.sortable && capabilities.sortableColumns.includes(column.id);
|
||||||
|
return <span role="columnheader" aria-sort={sortable ? state.sort?.column === column.id ? state.sort.direction : "none" : undefined} key={column.id}>{sortable ? <button className="sort-button" onClick={() => sort(column.id)}>{column.label}<span aria-hidden="true">{state.sort?.column === column.id ? state.sort.direction === "ascending" ? " ↑" : " ↓" : " ↕"}</span></button> : column.label}</span>;
|
||||||
|
})}
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
{rows.map((session, index) => <div className={`table-row ${session.id === state.focusedSessionId ? "focused" : ""}`} role="row" aria-selected={selected.has(session.id)} key={session.id} tabIndex={session.id === state.focusedSessionId || (!state.focusedSessionId && index === 0) ? 0 : -1} ref={(element) => { if (element) rowRefs.current.set(session.id, element); else rowRefs.current.delete(session.id); }} onFocus={() => state.focusedSessionId !== session.id && onStateChange({ ...state, focusedSessionId: session.id })} onKeyDown={(event) => handleRowKeyDown(event, index, session.id)}>
|
||||||
|
<span><input type="checkbox" aria-label={`选择 ${session.id}`} checked={selected.has(session.id)} onChange={(event) => updateSelection(session.id, event.currentTarget.checked)} /></span>
|
||||||
|
{visible.has("status") && <span><em className={`status-mark ${session.status.toLowerCase()}`} aria-hidden="true" /><span className="sr-only">状态:</span>{session.status}</span>}
|
||||||
|
{visible.has("id") && <strong className="mono">{session.id}</strong>}
|
||||||
|
{visible.has("identity") && <span><b>{session.host}</b><small>{session.user}</small></span>}
|
||||||
|
{visible.has("os") && <span>{session.os}</span>}
|
||||||
|
{visible.has("address") && <span className="mono muted">{session.address}</span>}
|
||||||
|
{visible.has("seen") && <span className="mono muted">{session.seen}</span>}
|
||||||
|
{visible.has("tags") && <span className="tags">{session.tags.map((tag) => <small key={tag}>{tag}</small>)}</span>}
|
||||||
|
<button className="row-action" aria-label={`${session.id} 快速操作`}>•••</button>
|
||||||
|
</div>)}
|
||||||
|
<footer><span aria-live="polite">已选择 {state.selectedSessionIds.length} 项</span><span>{page.start}–{page.end} / {page.total}</span><button aria-label="上一页" disabled={!capabilities.pagination || !page.hasPrevious} onClick={() => onQueryIntent({ type: "page", direction: "previous" })}>‹</button><button aria-label="下一页" disabled={!capabilities.pagination || !page.hasNext} onClick={() => onQueryIntent({ type: "page", direction: "next" })}>›</button></footer>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/sessionTableState.test.ts
Normal file
13
src/sessionTableState.test.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState";
|
||||||
|
|
||||||
|
describe("Teamserver Session 表格视图状态", () => {
|
||||||
|
it("在进程内按 Teamserver 隔离并恢复视图", () => {
|
||||||
|
const initial = initializeSessionTableStates(["atlas", "ember"]);
|
||||||
|
const next = updateSessionTableState(initial, "atlas", { ...initial.atlas, filter: "finance", selectedSessionIds: ["S-1"] });
|
||||||
|
expect(next.atlas.filter).toBe("finance");
|
||||||
|
expect(next.atlas.selectedSessionIds).toEqual(["S-1"]);
|
||||||
|
expect(next.ember.filter).toBe("");
|
||||||
|
expect(initial.atlas.filter).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
15
src/sessionTableState.ts
Normal file
15
src/sessionTableState.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { createSessionTableViewState, SessionTableViewState } from "./SessionTable";
|
||||||
|
|
||||||
|
export type SessionTableStateByServer = Record<string, SessionTableViewState>;
|
||||||
|
|
||||||
|
export function initializeSessionTableStates(serverIds: readonly string[]): SessionTableStateByServer {
|
||||||
|
return Object.fromEntries(serverIds.map((serverId) => [serverId, createSessionTableViewState()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSessionTableState(
|
||||||
|
states: SessionTableStateByServer,
|
||||||
|
serverId: string,
|
||||||
|
state: SessionTableViewState,
|
||||||
|
): SessionTableStateByServer {
|
||||||
|
return { ...states, [serverId]: state };
|
||||||
|
}
|
||||||
@ -63,13 +63,20 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
|
|||||||
.table-toolbar { height: 40px; display: flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-bottom: 0; padding: 5px 7px; background: #15191f; }
|
.table-toolbar { height: 40px; display: flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-bottom: 0; padding: 5px 7px; background: #15191f; }
|
||||||
.table-toolbar label { width: 220px; height: 28px; padding: 0 8px; }
|
.table-toolbar label { width: 220px; height: 28px; padding: 0 8px; }
|
||||||
.table-toolbar button { height: 28px; padding: 0 8px; color: #aeb5c0; }
|
.table-toolbar button { height: 28px; padding: 0 8px; color: #aeb5c0; }
|
||||||
|
.column-picker { position: relative; }
|
||||||
|
.column-picker summary { display: flex; align-items: center; height: 28px; padding: 0 8px; border: 1px solid var(--border); border-radius: 4px; background: #1b2027; color: #aeb5c0; cursor: pointer; list-style: none; }
|
||||||
|
.column-picker summary::-webkit-details-marker { display: none; }
|
||||||
|
.column-picker fieldset { position: absolute; z-index: 5; right: 0; top: 31px; width: 170px; margin: 0; padding: 9px; border: 1px solid #3a424e; border-radius: 4px; background: #15191f; box-shadow: 0 8px 24px #090b0eaa; }
|
||||||
|
.column-picker legend { color: var(--muted); font-size: 10px; }
|
||||||
|
.column-picker fieldset label { width: auto; height: 26px; padding: 0; border: 0; background: transparent; color: #cbd0d8; }
|
||||||
.toolbar-spacer, .status-spacer { flex: 1; }
|
.toolbar-spacer, .status-spacer { flex: 1; }
|
||||||
.session-table { min-width: 880px; border: 1px solid var(--border); background: #15191f; }
|
.session-table { --column-status: 92px; --column-id: 104px; --column-identity: minmax(150px, 1.3fr); --column-os: 120px; --column-address: 130px; --column-seen: 92px; --column-tags: minmax(120px, 1fr); min-width: 880px; border: 1px solid var(--border); background: #15191f; }
|
||||||
.table-row { display: grid; grid-template-columns: 34px 92px 104px minmax(150px, 1.3fr) 120px 130px 92px minmax(120px, 1fr) 34px; min-height: 36px; border-bottom: 1px solid #252b33; align-items: stretch; font-size: 12px; color: #cbd0d8; }
|
.table-row { display: grid; grid-template-columns: 34px var(--session-columns) 34px; min-height: 36px; border-bottom: 1px solid #252b33; align-items: stretch; font-size: 12px; color: #cbd0d8; }
|
||||||
.table-row > span, .table-row > strong, .table-row > button { display: flex; align-items: center; padding: 0 9px; min-width: 0; border-right: 1px solid #222831; }
|
.table-row > span, .table-row > strong, .table-row > button { display: flex; align-items: center; padding: 0 9px; min-width: 0; border-right: 1px solid #222831; }
|
||||||
.table-row:hover { background: var(--hover); }
|
.table-row:hover { background: var(--hover); }
|
||||||
.table-row.focused { background: #202a35; box-shadow: inset 2px 0 var(--info); }
|
.table-row.focused { background: #202a35; box-shadow: inset 2px 0 var(--info); }
|
||||||
.table-head { position: sticky; top: -22px; z-index: 2; min-height: 30px; background: #111419; color: #78818e; font-size: 9px; font-weight: 650; letter-spacing: .04em; }
|
.table-head { position: sticky; top: -22px; z-index: 2; min-height: 30px; background: #111419; color: #78818e; font-size: 9px; font-weight: 650; letter-spacing: .04em; }
|
||||||
|
.sort-button { width: 100%; height: 100%; padding: 0; border: 0; background: transparent; color: inherit; text-align: left; text-transform: uppercase; }
|
||||||
.table-row b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 550; color: #dde1e7; }
|
.table-row b { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 550; color: #dde1e7; }
|
||||||
.table-row small { display: block; color: #7f8895; font-size: 10px; }
|
.table-row small { display: block; color: #7f8895; font-size: 10px; }
|
||||||
.mono { font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; font-weight: 500; color: #9ec7f4; }
|
.mono { font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace; font-weight: 500; color: #9ec7f4; }
|
||||||
@ -83,8 +90,11 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
|
|||||||
.session-table > footer { display: flex; align-items: center; justify-content: flex-end; gap: 10px; height: 34px; padding: 0 8px; color: #7f8895; font-size: 10px; }
|
.session-table > footer { display: flex; align-items: center; justify-content: flex-end; gap: 10px; height: 34px; padding: 0 8px; color: #7f8895; font-size: 10px; }
|
||||||
.session-table > footer span:first-child { margin-right: auto; }
|
.session-table > footer span:first-child { margin-right: auto; }
|
||||||
.session-table > footer button { width: 24px; height: 22px; border: 1px solid var(--border); background: #1b2027; border-radius: 3px; color: var(--muted); }
|
.session-table > footer button { width: 24px; height: 22px; border: 1px solid var(--border); background: #1b2027; border-radius: 3px; color: var(--muted); }
|
||||||
|
.session-table > footer button:disabled { cursor: not-allowed; opacity: .45; }
|
||||||
|
.query-intent { display: block; margin-top: 7px; color: #737c89; font: 9px ui-monospace, monospace; }
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||||
.statusbar { display: flex; gap: 20px; align-items: center; padding: 0 10px; border-top: 1px solid #2b323c; color: #78818d; background: #111419; font-size: 9px; }
|
.statusbar { display: flex; gap: 20px; align-items: center; padding: 0 10px; border-top: 1px solid #2b323c; color: #78818d; background: #111419; font-size: 9px; }
|
||||||
.statusbar span:first-child { color: #75c99c; }
|
.statusbar span:first-child { color: #75c99c; }
|
||||||
input[type="checkbox"] { accent-color: var(--info); width: 12px; height: 12px; }
|
input[type="checkbox"] { accent-color: var(--info); width: 12px; height: 12px; }
|
||||||
@media (max-width: 1050px) { .shell { grid-template-columns: 52px 230px 1fr; } .content { padding-inline: 16px; } .table-row { grid-template-columns: 34px 84px 96px minmax(140px, 1fr) 110px 120px 84px minmax(110px, 1fr) 34px; } }
|
@media (max-width: 1050px) { .shell { grid-template-columns: 52px 230px 1fr; } .content { padding-inline: 16px; } }
|
||||||
@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; } }
|
@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; } }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user