fix(activity-log): UI polish - accessible export menu, i18n placeholders, zero-result spinner fix
- export button is now a click-toggle menu (aria-haspopup/expanded, role=menu, caret, Escape + click-outside close) - filter placeholders moved to i18n (actor/entity_type .placeholder keys, en/ru/zh) - _fetchPage clears _loading before render so a zero-result page doesn't spin forever - toolbar/entry use elevated card surface (--lux-bg-1); light-theme device badge contrast; mobile message grid
This commit is contained in:
@@ -24,8 +24,11 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
padding: var(--space-md) var(--space-md);
|
padding: var(--space-md) var(--space-md);
|
||||||
background: var(--bg-secondary);
|
/* Match the elevated card surface used by entity cards (.dashboard-target),
|
||||||
border: var(--lux-hairline) solid var(--border-color);
|
not the near-black --bg-secondary, so the panel reads as one of the app's
|
||||||
|
cards rather than a separate flat sheet. */
|
||||||
|
background: var(--lux-bg-1, var(--card-bg));
|
||||||
|
border: var(--lux-hairline) solid var(--lux-line, var(--border-color));
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
margin-bottom: var(--space-md);
|
margin-bottom: var(--space-md);
|
||||||
}
|
}
|
||||||
@@ -137,6 +140,16 @@
|
|||||||
|
|
||||||
.al-export-btn .icon { width: 14px; height: 14px; }
|
.al-export-btn .icon { width: 14px; height: 14px; }
|
||||||
|
|
||||||
|
/* Caret signals this button opens a menu (rather than firing a direct action),
|
||||||
|
and rotates to point up while the menu is open. */
|
||||||
|
.al-export-caret {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-left: 1px;
|
||||||
|
transition: transform var(--duration-fast) var(--ease-out);
|
||||||
|
}
|
||||||
|
.al-export-caret .icon { width: 12px; height: 12px; }
|
||||||
|
.al-export-wrap.open .al-export-caret { transform: rotate(180deg); }
|
||||||
|
|
||||||
.al-export-menu {
|
.al-export-menu {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -151,8 +164,7 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.al-export-wrap:hover .al-export-menu,
|
.al-export-wrap.open .al-export-menu { display: block; }
|
||||||
.al-export-wrap:focus-within .al-export-menu { display: block; }
|
|
||||||
|
|
||||||
.al-export-menu button {
|
.al-export-menu button {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -166,7 +178,8 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.al-export-menu button:hover { background: var(--bg-secondary); }
|
.al-export-menu button:hover,
|
||||||
|
.al-export-menu button:focus-visible { background: var(--bg-secondary); outline: none; }
|
||||||
|
|
||||||
/* Filter label */
|
/* Filter label */
|
||||||
.al-filter-label {
|
.al-filter-label {
|
||||||
@@ -311,8 +324,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.al-entry {
|
.al-entry {
|
||||||
background: var(--card-bg);
|
/* Same elevated surface + hairline as entity cards (see .al-toolbar). */
|
||||||
border: var(--lux-hairline) solid var(--border-color);
|
background: var(--lux-bg-1, var(--card-bg));
|
||||||
|
border: var(--lux-hairline) solid var(--lux-line, var(--border-color));
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: border-color var(--duration-fast);
|
transition: border-color var(--duration-fast);
|
||||||
@@ -320,10 +334,11 @@
|
|||||||
|
|
||||||
.al-entry:hover { border-color: var(--text-muted); }
|
.al-entry:hover { border-color: var(--text-muted); }
|
||||||
|
|
||||||
/* New-entry flash */
|
/* New-entry flash — settles on the card surface (animation-fill-mode: forwards
|
||||||
|
holds the 100% frame, so it must match the .al-entry background exactly). */
|
||||||
@keyframes al-new-flash {
|
@keyframes al-new-flash {
|
||||||
0% { background: color-mix(in srgb, var(--primary-color) 18%, var(--card-bg)); }
|
0% { background: color-mix(in srgb, var(--primary-color) 18%, var(--lux-bg-1, var(--card-bg))); }
|
||||||
100% { background: var(--card-bg); }
|
100% { background: var(--lux-bg-1, var(--card-bg)); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.al-entry-new.al-entry-appear { animation: al-new-flash 1.8s var(--ease-out) forwards; }
|
.al-entry-new.al-entry-appear { animation: al-new-flash 1.8s var(--ease-out) forwards; }
|
||||||
@@ -382,7 +397,9 @@
|
|||||||
.al-cat-system { background: rgba(120, 120, 120, 0.12); color: var(--text-secondary); border-color: rgba(120, 120, 120, 0.25); }
|
.al-cat-system { background: rgba(120, 120, 120, 0.12); color: var(--text-secondary); border-color: rgba(120, 120, 120, 0.25); }
|
||||||
|
|
||||||
[data-theme="light"] .al-cat-auth { background: rgba(33, 150, 243, 0.08); }
|
[data-theme="light"] .al-cat-auth { background: rgba(33, 150, 243, 0.08); }
|
||||||
[data-theme="light"] .al-cat-device { background: rgba(156, 39, 176, 0.08); }
|
/* Darker purple text in light theme — the dark-theme #ab47bc fails AA contrast
|
||||||
|
on the pale tinted background at this small badge size. */
|
||||||
|
[data-theme="light"] .al-cat-device { background: rgba(156, 39, 176, 0.08); color: #8e24aa; }
|
||||||
[data-theme="light"] .al-cat-entity { background: rgba(76, 175, 80, 0.08); }
|
[data-theme="light"] .al-cat-entity { background: rgba(76, 175, 80, 0.08); }
|
||||||
[data-theme="light"] .al-cat-capture { background: rgba(255, 152, 0, 0.08); }
|
[data-theme="light"] .al-cat-capture { background: rgba(255, 152, 0, 0.08); }
|
||||||
[data-theme="light"] .al-cat-system { background: rgba(120, 120, 120, 0.08); }
|
[data-theme="light"] .al-cat-system { background: rgba(120, 120, 120, 0.08); }
|
||||||
@@ -585,9 +602,11 @@
|
|||||||
gap: 4px var(--space-xs);
|
gap: 4px var(--space-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Row 1: [sev] [message] [badge] [chevron]; Row 2: [time] under the message.
|
||||||
|
message stays in its own 1fr column so it never overlaps the badge. */
|
||||||
.al-time { grid-column: 2; grid-row: 2; font-size: 0.6875rem; }
|
.al-time { grid-column: 2; grid-row: 2; font-size: 0.6875rem; }
|
||||||
.al-cat-badge{ grid-column: 3; grid-row: 1; }
|
.al-cat-badge{ grid-column: 3; grid-row: 1; }
|
||||||
.al-msg { grid-column: 2 / span 2; grid-row: 1; }
|
.al-msg { grid-column: 2; grid-row: 1; }
|
||||||
|
|
||||||
.al-toolbar-advanced .al-field-group { min-width: 100%; }
|
.al-toolbar-advanced .al-field-group { min-width: 100%; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ function _renderFilterToolbar(): string {
|
|||||||
const catChips = CATEGORIES.map(cat => {
|
const catChips = CATEGORIES.map(cat => {
|
||||||
const active = _filters.categories.includes(cat);
|
const active = _filters.categories.includes(cat);
|
||||||
return `<button class="al-chip al-cat-chip${active ? ' active' : ''} al-cat-${cat}"
|
return `<button class="al-chip al-cat-chip${active ? ' active' : ''} al-cat-${cat}"
|
||||||
type="button" onclick="activityLogToggleCat(${JSON.stringify(cat)})"
|
type="button" onclick="activityLogToggleCat('${cat}')"
|
||||||
aria-pressed="${active}">${escapeHtml(_categoryLabel(cat))}</button>`;
|
aria-pressed="${active}">${escapeHtml(_categoryLabel(cat))}</button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -267,7 +267,7 @@ function _renderFilterToolbar(): string {
|
|||||||
const active = _filters.severities.includes(sev);
|
const active = _filters.severities.includes(sev);
|
||||||
const icon = _severityIcon(sev);
|
const icon = _severityIcon(sev);
|
||||||
return `<button class="al-chip al-sev-chip${active ? ' active' : ''} al-sev-chip-${sev}"
|
return `<button class="al-chip al-sev-chip${active ? ' active' : ''} al-sev-chip-${sev}"
|
||||||
type="button" onclick="activityLogToggleSev(${JSON.stringify(sev)})"
|
type="button" onclick="activityLogToggleSev('${sev}')"
|
||||||
aria-pressed="${active}">${icon} ${escapeHtml(t(`activity_log.severity.${sev}`))}</button>`;
|
aria-pressed="${active}">${icon} ${escapeHtml(t(`activity_log.severity.${sev}`))}</button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
@@ -278,7 +278,7 @@ function _renderFilterToolbar(): string {
|
|||||||
{ key: 'devices', label: t('activity_log.preset.devices') },
|
{ key: 'devices', label: t('activity_log.preset.devices') },
|
||||||
];
|
];
|
||||||
const presetBtns = presets.map(p =>
|
const presetBtns = presets.map(p =>
|
||||||
`<button class="al-preset-btn" type="button" onclick="activityLogPreset(${JSON.stringify(p.key)})">${escapeHtml(p.label)}</button>`
|
`<button class="al-preset-btn" type="button" onclick="activityLogPreset('${p.key}')">${escapeHtml(p.label)}</button>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
const hasFilters = _filters.categories.length || _filters.severities.length ||
|
const hasFilters = _filters.categories.length || _filters.severities.length ||
|
||||||
@@ -300,12 +300,12 @@ function _renderFilterToolbar(): string {
|
|||||||
aria-label="${escapeHtml(t('activity_log.filter.clear'))}">${ICON_X_CIRCLE}</button>` : ''}
|
aria-label="${escapeHtml(t('activity_log.filter.clear'))}">${ICON_X_CIRCLE}</button>` : ''}
|
||||||
<div class="al-export-wrap">
|
<div class="al-export-wrap">
|
||||||
<button class="btn btn-secondary al-export-btn" type="button"
|
<button class="btn btn-secondary al-export-btn" type="button"
|
||||||
onclick="activityLogExport('csv')"
|
data-al-export-toggle aria-haspopup="menu" aria-expanded="false"
|
||||||
title="${escapeHtml(t('activity_log.export.csv'))}"
|
title="${escapeHtml(t('activity_log.export'))}"
|
||||||
aria-label="${escapeHtml(t('activity_log.export.csv'))}">${ICON_DOWNLOAD} <span>${escapeHtml(t('activity_log.export'))}</span></button>
|
aria-label="${escapeHtml(t('activity_log.export'))}">${ICON_DOWNLOAD} <span>${escapeHtml(t('activity_log.export'))}</span><span class="al-export-caret" aria-hidden="true">${ICON_CHEVRON_DOWN}</span></button>
|
||||||
<div class="al-export-menu">
|
<div class="al-export-menu" role="menu">
|
||||||
<button type="button" onclick="activityLogExport('csv')">${escapeHtml(t('activity_log.export.csv'))}</button>
|
<button type="button" role="menuitem" onclick="activityLogExport('csv')">${escapeHtml(t('activity_log.export.csv'))}</button>
|
||||||
<button type="button" onclick="activityLogExport('json')">${escapeHtml(t('activity_log.export.json'))}</button>
|
<button type="button" role="menuitem" onclick="activityLogExport('json')">${escapeHtml(t('activity_log.export.json'))}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -320,7 +320,7 @@ function _renderFilterToolbar(): string {
|
|||||||
<label for="al-actor-input" class="al-field-label">${escapeHtml(t('activity_log.filter.actor'))}</label>
|
<label for="al-actor-input" class="al-field-label">${escapeHtml(t('activity_log.filter.actor'))}</label>
|
||||||
<input type="text" id="al-actor-input" class="al-field-input"
|
<input type="text" id="al-actor-input" class="al-field-input"
|
||||||
value="${_escapeAttr(_filters.actor)}"
|
value="${_escapeAttr(_filters.actor)}"
|
||||||
placeholder="system, api-key-name…"
|
placeholder="${_escapeAttr(t('activity_log.filter.actor.placeholder'))}"
|
||||||
oninput="activityLogOnActor(this.value)"
|
oninput="activityLogOnActor(this.value)"
|
||||||
aria-label="${escapeHtml(t('activity_log.filter.actor'))}">
|
aria-label="${escapeHtml(t('activity_log.filter.actor'))}">
|
||||||
</div>
|
</div>
|
||||||
@@ -328,7 +328,7 @@ function _renderFilterToolbar(): string {
|
|||||||
<label for="al-entity-type-input" class="al-field-label">${escapeHtml(t('activity_log.filter.entity_type'))}</label>
|
<label for="al-entity-type-input" class="al-field-label">${escapeHtml(t('activity_log.filter.entity_type'))}</label>
|
||||||
<input type="text" id="al-entity-type-input" class="al-field-input"
|
<input type="text" id="al-entity-type-input" class="al-field-input"
|
||||||
value="${_escapeAttr(_filters.entity_type)}"
|
value="${_escapeAttr(_filters.entity_type)}"
|
||||||
placeholder="output_target, led_device…"
|
placeholder="${_escapeAttr(t('activity_log.filter.entity_type.placeholder'))}"
|
||||||
oninput="activityLogOnEntityType(this.value)"
|
oninput="activityLogOnEntityType(this.value)"
|
||||||
aria-label="${escapeHtml(t('activity_log.filter.entity_type'))}">
|
aria-label="${escapeHtml(t('activity_log.filter.entity_type'))}">
|
||||||
</div>
|
</div>
|
||||||
@@ -395,6 +395,15 @@ function _renderList(): string {
|
|||||||
|
|
||||||
let _delegatedClickAttached = false;
|
let _delegatedClickAttached = false;
|
||||||
|
|
||||||
|
/** Collapse the export dropdown if open (idempotent). */
|
||||||
|
function _closeExportMenu(): void {
|
||||||
|
const wrap = document.getElementById('tab-activity_log')
|
||||||
|
?.querySelector<HTMLElement>('.al-export-wrap.open');
|
||||||
|
if (!wrap) return;
|
||||||
|
wrap.classList.remove('open');
|
||||||
|
wrap.querySelector('[data-al-export-toggle]')?.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
function _attachDelegatedClicks(): void {
|
function _attachDelegatedClicks(): void {
|
||||||
if (_delegatedClickAttached) return;
|
if (_delegatedClickAttached) return;
|
||||||
const panel = document.getElementById('tab-activity_log');
|
const panel = document.getElementById('tab-activity_log');
|
||||||
@@ -404,6 +413,24 @@ function _attachDelegatedClicks(): void {
|
|||||||
panel.addEventListener('click', (e: MouseEvent) => {
|
panel.addEventListener('click', (e: MouseEvent) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
|
|
||||||
|
// Export menu: toggle button opens/closes the CSV/JSON dropdown.
|
||||||
|
const exportToggle = target.closest<HTMLElement>('[data-al-export-toggle]');
|
||||||
|
if (exportToggle) {
|
||||||
|
const wrap = exportToggle.closest<HTMLElement>('.al-export-wrap');
|
||||||
|
const open = wrap?.classList.toggle('open') ?? false;
|
||||||
|
exportToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Export menu: a menu item's inline onclick triggers the download (it
|
||||||
|
// runs first, on the deeper element) — we just collapse the menu after.
|
||||||
|
if (target.closest('.al-export-menu')) {
|
||||||
|
_closeExportMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Any other click in the panel dismisses an open export menu, then
|
||||||
|
// continues to row / entity handling below.
|
||||||
|
_closeExportMenu();
|
||||||
|
|
||||||
// Entity navigation: click on data-entity-type button
|
// Entity navigation: click on data-entity-type button
|
||||||
const entityBtn = target.closest<HTMLElement>('button.al-entity-link[data-entity-type]');
|
const entityBtn = target.closest<HTMLElement>('button.al-entity-link[data-entity-type]');
|
||||||
if (entityBtn) {
|
if (entityBtn) {
|
||||||
@@ -424,6 +451,15 @@ function _attachDelegatedClicks(): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
panel.addEventListener('keydown', (e: KeyboardEvent) => {
|
panel.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
|
// Escape closes the export menu and restores focus to its trigger.
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const toggle = panel.querySelector<HTMLElement>('.al-export-wrap.open [data-al-export-toggle]');
|
||||||
|
if (toggle) {
|
||||||
|
_closeExportMenu();
|
||||||
|
toggle.focus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||||
const row = (e.target as HTMLElement).closest<HTMLElement>('.al-entry-row[data-toggle-id]');
|
const row = (e.target as HTMLElement).closest<HTMLElement>('.al-entry-row[data-toggle-id]');
|
||||||
if (row) {
|
if (row) {
|
||||||
@@ -488,6 +524,12 @@ async function _fetchPage(beforeSeq: number | null = null, append = false): Prom
|
|||||||
_nextBeforeSeq = page.next_before_seq;
|
_nextBeforeSeq = page.next_before_seq;
|
||||||
_hasMore = page.has_more;
|
_hasMore = page.has_more;
|
||||||
_total = page.total;
|
_total = page.total;
|
||||||
|
// Reset the loading flag BEFORE rendering: _renderList() shows the
|
||||||
|
// spinner whenever (_loading && _entries.length === 0), so a zero-result
|
||||||
|
// page (e.g. an unmatched entity-type filter, or a fresh install) would
|
||||||
|
// otherwise render the spinner here and spin forever — the finally below
|
||||||
|
// clears _loading but does not re-render.
|
||||||
|
_loading = false;
|
||||||
_updateListContainer();
|
_updateListContainer();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e && typeof e === 'object' && 'isAuth' in e) return;
|
if (e && typeof e === 'object' && 'isAuth' in e) return;
|
||||||
|
|||||||
@@ -30,9 +30,11 @@
|
|||||||
"activity_log.export.error": "Export failed.",
|
"activity_log.export.error": "Export failed.",
|
||||||
"activity_log.export.json": "Export JSON",
|
"activity_log.export.json": "Export JSON",
|
||||||
"activity_log.filter.actor": "Actor",
|
"activity_log.filter.actor": "Actor",
|
||||||
|
"activity_log.filter.actor.placeholder": "system, api-key-name…",
|
||||||
"activity_log.filter.category": "Category",
|
"activity_log.filter.category": "Category",
|
||||||
"activity_log.filter.clear": "Clear filters",
|
"activity_log.filter.clear": "Clear filters",
|
||||||
"activity_log.filter.entity_type": "Entity type",
|
"activity_log.filter.entity_type": "Entity type",
|
||||||
|
"activity_log.filter.entity_type.placeholder": "output_target, device…",
|
||||||
"activity_log.filter.search": "Search messages…",
|
"activity_log.filter.search": "Search messages…",
|
||||||
"activity_log.filter.severity": "Severity",
|
"activity_log.filter.severity": "Severity",
|
||||||
"activity_log.filter.since": "From",
|
"activity_log.filter.since": "From",
|
||||||
|
|||||||
@@ -30,9 +30,11 @@
|
|||||||
"activity_log.export.error": "Ошибка экспорта.",
|
"activity_log.export.error": "Ошибка экспорта.",
|
||||||
"activity_log.export.json": "Экспорт JSON",
|
"activity_log.export.json": "Экспорт JSON",
|
||||||
"activity_log.filter.actor": "Субъект",
|
"activity_log.filter.actor": "Субъект",
|
||||||
|
"activity_log.filter.actor.placeholder": "system, имя API-ключа…",
|
||||||
"activity_log.filter.category": "Категория",
|
"activity_log.filter.category": "Категория",
|
||||||
"activity_log.filter.clear": "Сбросить фильтры",
|
"activity_log.filter.clear": "Сбросить фильтры",
|
||||||
"activity_log.filter.entity_type": "Тип сущности",
|
"activity_log.filter.entity_type": "Тип сущности",
|
||||||
|
"activity_log.filter.entity_type.placeholder": "output_target, device…",
|
||||||
"activity_log.filter.search": "Поиск сообщений…",
|
"activity_log.filter.search": "Поиск сообщений…",
|
||||||
"activity_log.filter.severity": "Уровень",
|
"activity_log.filter.severity": "Уровень",
|
||||||
"activity_log.filter.since": "С",
|
"activity_log.filter.since": "С",
|
||||||
|
|||||||
@@ -30,9 +30,11 @@
|
|||||||
"activity_log.export.error": "导出失败。",
|
"activity_log.export.error": "导出失败。",
|
||||||
"activity_log.export.json": "导出 JSON",
|
"activity_log.export.json": "导出 JSON",
|
||||||
"activity_log.filter.actor": "操作者",
|
"activity_log.filter.actor": "操作者",
|
||||||
|
"activity_log.filter.actor.placeholder": "system、API 密钥名…",
|
||||||
"activity_log.filter.category": "类别",
|
"activity_log.filter.category": "类别",
|
||||||
"activity_log.filter.clear": "清除过滤",
|
"activity_log.filter.clear": "清除过滤",
|
||||||
"activity_log.filter.entity_type": "实体类型",
|
"activity_log.filter.entity_type": "实体类型",
|
||||||
|
"activity_log.filter.entity_type.placeholder": "output_target, device…",
|
||||||
"activity_log.filter.search": "搜索消息…",
|
"activity_log.filter.search": "搜索消息…",
|
||||||
"activity_log.filter.severity": "严重性",
|
"activity_log.filter.severity": "严重性",
|
||||||
"activity_log.filter.since": "从",
|
"activity_log.filter.since": "从",
|
||||||
|
|||||||
Reference in New Issue
Block a user