feat: photo/video capture-date pipeline + premium UI/UX overhaul
Backend — Photos timeline now groups by real capture date instead of upload time. New MediaMetadataService (FileLifecycleHook) extracts EXIF DateTimeOriginal from images and container creation_time from videos (mov/mp4/mkv) via nom-exif, timezone-correct (OffsetTimeOriginal), persisting captured_at so the existing media_sort_date trigger takes over. Adds POST /admin/photos/metadata/reextract to backfill existing media. Falls back to upload date when no embedded date exists. Frontend — premium grid cards: combined metadata line (relative date · size, owner avatar when shared), custom selection checkbox with a clear checked state, uniform full-width 4:3 thumbnail tiles independent of filename length, centered file-type icons, and a hit-test fix so checkbox/star/kebab clicks reach the controls (the decorative thumbnail no longer captures pointer events). Notification messages internationalised across all 16 locales. Broader polish: design tokens, a11y/focus-visible states, brand + PWA assets. Chore — bump semver-compatible dependencies (cargo upgrade); add nom-exif 3.6.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
// Global command palette (Cmd / Ctrl + K).
|
||||
//
|
||||
// Lists navigation targets and key actions; running one *clicks the existing
|
||||
// control* (nav button, menu item, file input) so the palette can never drift
|
||||
// from the real behaviour. Filtering is a simple case-insensitive substring
|
||||
// match; keyboard: ↑/↓ to move, Enter to run, Esc to close.
|
||||
import { i18n } from '../core/i18n.js';
|
||||
|
||||
/** @typedef {{ label: string, icon: string, run: () => void }} Command */
|
||||
|
||||
const state = {
|
||||
/** @type {HTMLElement | null} */ overlay: null,
|
||||
/** @type {HTMLInputElement | null} */ input: null,
|
||||
/** @type {HTMLElement | null} */ list: null,
|
||||
/** @type {Command[]} */ items: [],
|
||||
/** @type {Command[]} */ filtered: [],
|
||||
index: 0,
|
||||
/** @type {HTMLElement | null} */ prevFocus: null
|
||||
};
|
||||
|
||||
/** Click an element by selector (no-op if absent). */
|
||||
function click(selector) {
|
||||
/** @type {HTMLElement | null} */ (document.querySelector(selector))?.click();
|
||||
}
|
||||
|
||||
/** Activate the sidebar nav button whose label key matches `nav.<section>`. */
|
||||
function navTo(section) {
|
||||
document.querySelectorAll('.nav-item').forEach((it) => {
|
||||
const key = it.querySelector('span[data-i18n]')?.getAttribute('data-i18n');
|
||||
if (key === `nav.${section}`) /** @type {HTMLElement} */ (it).click();
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {Command[]} */
|
||||
function buildCommands() {
|
||||
/** @type {Command[]} */
|
||||
const cmds = [
|
||||
{ label: i18n.t('nav.files'), icon: 'fa-folder', run: () => navTo('files') },
|
||||
{ label: i18n.t('nav.shared'), icon: 'fa-oxiexport', run: () => navTo('shared') },
|
||||
{ label: i18n.t('nav.sharedwithme'), icon: 'fa-oxiimport', run: () => navTo('sharedwithme') },
|
||||
{ label: i18n.t('nav.recent'), icon: 'fa-clock', run: () => navTo('recent') },
|
||||
{ label: i18n.t('nav.favorites'), icon: 'fa-star', run: () => navTo('favorites') },
|
||||
{ label: i18n.t('nav.photos'), icon: 'fa-images', run: () => navTo('photos') },
|
||||
{ label: i18n.t('nav.music'), icon: 'fa-music', run: () => navTo('music') },
|
||||
{ label: i18n.t('nav.trash'), icon: 'fa-trash', run: () => navTo('trash') },
|
||||
{ label: i18n.t('actions.upload_files'), icon: 'fa-cloud-upload-alt', run: () => click('#file-input') },
|
||||
{ label: i18n.t('user_menu.profile'), icon: 'fa-user-circle', run: () => click('#user-menu-profile') },
|
||||
{ label: i18n.t('user_menu.about'), icon: 'fa-info-circle', run: () => click('#user-menu-about') },
|
||||
{ label: i18n.t('actions.logout'), icon: 'fa-sign-out-alt', run: () => click('#user-menu-logout') }
|
||||
];
|
||||
const adminBtn = document.getElementById('user-menu-admin');
|
||||
if (adminBtn && !adminBtn.classList.contains('hidden')) {
|
||||
cmds.splice(8, 0, {
|
||||
label: i18n.t('user_menu.admin_panel'),
|
||||
icon: 'fa-cogs',
|
||||
run: () => click('#user-menu-admin')
|
||||
});
|
||||
}
|
||||
return cmds;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state.list) return;
|
||||
if (!state.filtered.length) {
|
||||
state.list.innerHTML = `<li class="cmdk-empty">${i18n.t('cmdk.no_results', 'No matching commands')}</li>`;
|
||||
return;
|
||||
}
|
||||
state.list.innerHTML = state.filtered
|
||||
.map(
|
||||
(cmd, i) => `
|
||||
<li class="cmdk-item${i === state.index ? ' is-active' : ''}" role="option" aria-selected="${i === state.index}" data-i="${i}">
|
||||
<i class="fas ${cmd.icon}" aria-hidden="true"></i>
|
||||
<span>${cmd.label}</span>
|
||||
</li>`
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function filter(query) {
|
||||
const q = query.trim().toLowerCase();
|
||||
state.filtered = q ? state.items.filter((c) => c.label.toLowerCase().includes(q)) : state.items.slice();
|
||||
state.index = 0;
|
||||
render();
|
||||
}
|
||||
|
||||
function move(delta) {
|
||||
if (!state.filtered.length) return;
|
||||
state.index = (state.index + delta + state.filtered.length) % state.filtered.length;
|
||||
render();
|
||||
state.list?.querySelector('.is-active')?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
|
||||
function runActive() {
|
||||
const cmd = state.filtered[state.index];
|
||||
close();
|
||||
cmd?.run();
|
||||
}
|
||||
|
||||
/** @param {KeyboardEvent} e */
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
move(1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
move(-1);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runActive();
|
||||
}
|
||||
}
|
||||
|
||||
function ensureOverlay() {
|
||||
if (state.overlay) return;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'cmdk-overlay hidden';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', i18n.t('cmdk.title', 'Command palette'));
|
||||
overlay.innerHTML = `
|
||||
<div class="cmdk-panel">
|
||||
<div class="cmdk-search">
|
||||
<i class="fas fa-search" aria-hidden="true"></i>
|
||||
<input type="text" class="cmdk-input" autocomplete="off" spellcheck="false" aria-label="${i18n.t('cmdk.title', 'Command palette')}">
|
||||
</div>
|
||||
<ul class="cmdk-list" role="listbox"></ul>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
state.overlay = overlay;
|
||||
state.input = /** @type {HTMLInputElement} */ (overlay.querySelector('.cmdk-input'));
|
||||
state.list = /** @type {HTMLElement} */ (overlay.querySelector('.cmdk-list'));
|
||||
state.input.placeholder = i18n.t('cmdk.placeholder', 'Type a command…');
|
||||
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) close();
|
||||
});
|
||||
overlay.addEventListener('keydown', onKeydown);
|
||||
state.input.addEventListener('input', () => filter(state.input?.value ?? ''));
|
||||
state.list.addEventListener('click', (e) => {
|
||||
const li = /** @type {HTMLElement} */ (e.target).closest('.cmdk-item');
|
||||
if (li instanceof HTMLElement) {
|
||||
state.index = Number(li.dataset.i);
|
||||
runActive();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function open() {
|
||||
ensureOverlay();
|
||||
state.prevFocus = /** @type {HTMLElement | null} */ (document.activeElement);
|
||||
state.items = buildCommands();
|
||||
if (state.input) state.input.value = '';
|
||||
filter('');
|
||||
state.overlay?.classList.remove('hidden');
|
||||
requestAnimationFrame(() => state.overlay?.classList.add('active'));
|
||||
state.input?.focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!state.overlay) return;
|
||||
state.overlay.classList.remove('active');
|
||||
state.overlay.classList.add('hidden');
|
||||
state.prevFocus?.focus?.();
|
||||
state.prevFocus = null;
|
||||
}
|
||||
|
||||
// Global shortcut: Cmd/Ctrl + K toggles the palette.
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault();
|
||||
if (state.overlay && !state.overlay.classList.contains('hidden')) close();
|
||||
else open();
|
||||
}
|
||||
});
|
||||
@@ -153,6 +153,21 @@ let _groupBy = '';
|
||||
/** Whether the current sort order is reversed. */
|
||||
let _reversed = false;
|
||||
|
||||
/**
|
||||
* Flat column sort set by clicking a list-view header. When non-empty it
|
||||
* overrides the group-by's `orderBy` and forces `_groupBy = ''`, so the list
|
||||
* is a flat sort (no swimlanes) — the Drive/Finder column-sort behaviour.
|
||||
* '' = follow the active group-by.
|
||||
* @type {string}
|
||||
*/
|
||||
let _sortField = '';
|
||||
|
||||
/** The order field the server is currently sorting by (group-by or flat). */
|
||||
function _currentOrderBy() {
|
||||
if (_sortField) return _sortField;
|
||||
return GROUP_BY_DEFS.find((d) => d.key === _groupBy)?.orderBy ?? 'name';
|
||||
}
|
||||
|
||||
// ── Group-by controller (public API, consumed by navigation.js / main.js) ───
|
||||
|
||||
/**
|
||||
@@ -177,8 +192,11 @@ const filesView = {
|
||||
* @param {string} key '' | 'type' | 'modifiedAt' | 'createdAt' | 'size'
|
||||
*/
|
||||
setGroupBy(key) {
|
||||
if (_groupBy === key) return;
|
||||
// Re-pick the same group while a flat column-sort is active → still
|
||||
// exit flat-sort and return to grouping, so don't early-return then.
|
||||
if (_groupBy === key && !_sortField) return;
|
||||
_groupBy = key;
|
||||
_sortField = ''; // grouping takes over → follow the group's order field
|
||||
viewPrefs.save('files', _groupBy, _reversed, viewPrefs.load('files').view);
|
||||
_nextCursor = null;
|
||||
_component?.clear();
|
||||
@@ -197,6 +215,29 @@ const filesView = {
|
||||
_nextCursor = null;
|
||||
_component?.clear();
|
||||
_loadPage({ isFirstPage: true });
|
||||
},
|
||||
|
||||
/**
|
||||
* Flat-sort the list by a column field (clicking a list-view header).
|
||||
* Drops any grouping (no swimlanes); re-picking the active field flips the
|
||||
* direction. Synchronous state update — read `currentSort` right after to
|
||||
* refresh the header arrow, the async reload follows.
|
||||
* @param {string} field 'name' | 'type' | 'size' | 'modified_at'
|
||||
*/
|
||||
setSortField(field) {
|
||||
const sameField = _currentOrderBy() === field;
|
||||
_reversed = sameField ? !_reversed : false;
|
||||
_sortField = field;
|
||||
_groupBy = '';
|
||||
viewPrefs.save('files', _groupBy, _reversed, viewPrefs.load('files').view);
|
||||
_nextCursor = null;
|
||||
_component?.clear();
|
||||
_loadPage({ isFirstPage: true });
|
||||
},
|
||||
|
||||
/** Current effective sort `{ field, reversed }` — for the column-header arrows. */
|
||||
get currentSort() {
|
||||
return { field: _currentOrderBy(), reversed: _reversed };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -315,7 +356,9 @@ async function _loadPage({ isFirstPage = false } = {}) {
|
||||
|
||||
try {
|
||||
const def = GROUP_BY_DEFS.find((d) => d.key === _groupBy);
|
||||
const orderBy = def?.orderBy ?? 'name';
|
||||
// Flat column-sort (`_sortField`) overrides the group's order field. When
|
||||
// it's set, `_groupBy` is '' so `def` has no keyFn → a flat list.
|
||||
const orderBy = _currentOrderBy();
|
||||
|
||||
const { items, nextCursor } = await fetchResourcesPage(app.currentPath, {
|
||||
cursor: _nextCursor,
|
||||
@@ -334,6 +377,10 @@ async function _loadPage({ isFirstPage = false } = {}) {
|
||||
|
||||
if (isFirstPage) {
|
||||
_component?.render(items, def?.keyFn, def?.labelFn);
|
||||
// Keep the list-view column-header arrow in sync however the sort
|
||||
// changed — header click, toolbar group-by, or direction toggle.
|
||||
// No-op in grid view (no sortable headers present).
|
||||
ui.updateListHeaderSort?.();
|
||||
} else {
|
||||
_component?.append(items, def?.keyFn, def?.labelFn);
|
||||
}
|
||||
@@ -399,14 +446,18 @@ async function loadFiles(options = { insertHistory: true }) {
|
||||
_groupBy = _savedPrefs.groupBy;
|
||||
_reversed = _savedPrefs.reversed;
|
||||
|
||||
// Delay spinner so fast loads avoid the flash
|
||||
// Delay the loading skeleton so fast loads avoid a flash of placeholders.
|
||||
// Skeleton cards/rows hold the layout (no empty-list flash) and mirror the
|
||||
// current view; reduced-motion is honoured via the global guard.
|
||||
const spinnerTimeout = setTimeout(() => {
|
||||
ui.showError(`
|
||||
<div class="files-loading-spinner">
|
||||
<div class="spinner"></div>
|
||||
<span>${i18n.t('files.loading')}</span>
|
||||
</div>
|
||||
`);
|
||||
const isList = app.currentView === 'list';
|
||||
const cell = isList
|
||||
? '<div class="skeleton-row"><div class="skeleton skeleton-icon"></div><div class="skeleton skeleton-line skeleton-line--medium"></div><div class="skeleton skeleton-line skeleton-line--short"></div></div>'
|
||||
: '<div class="skeleton-card"><div class="skeleton skeleton-thumb"></div><div class="skeleton skeleton-line skeleton-line--medium"></div><div class="skeleton skeleton-line skeleton-line--short"></div></div>';
|
||||
const wrapper = isList ? 'files-skeleton' : 'files-grid-view files-skeleton';
|
||||
ui.showError(
|
||||
`<div class="${wrapper}" role="status" aria-busy="true" aria-label="${i18n.t('files.loading')}">${cell.repeat(8)}</div>`
|
||||
);
|
||||
}, 100);
|
||||
|
||||
// A temporary guard: _loadPage sets _loading itself, but we need to
|
||||
|
||||
@@ -371,7 +371,7 @@ function setupActionsBarDelegation() {
|
||||
if (recent) {
|
||||
await recent.clearRecentFiles();
|
||||
await recentView.init();
|
||||
ui.showNotification('Cleanup completed', 'Recent files history has been cleared');
|
||||
ui.showNotification(i18n.t('notif.cleanupCompleted'), i18n.t('notif.cleanupCompletedBody'));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -73,16 +73,25 @@ function initSidebarToggle() {
|
||||
|
||||
if (!sidebarToggle || !sidebar || !sidebarOverlay) return;
|
||||
|
||||
/** @type {HTMLElement | null} */
|
||||
let drawerPrevFocus = null;
|
||||
|
||||
function openSidebar() {
|
||||
drawerPrevFocus = /** @type {HTMLElement | null} */ (document.activeElement);
|
||||
sidebar?.classList.add('open');
|
||||
sidebarOverlay?.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
sidebarToggle?.setAttribute('aria-expanded', 'true');
|
||||
/** @type {HTMLElement | null} */ (sidebar?.querySelector('.nav-item'))?.focus();
|
||||
}
|
||||
|
||||
function closeSidebar() {
|
||||
sidebar?.classList.remove('open');
|
||||
sidebarOverlay?.classList.remove('active');
|
||||
document.body.style.overflow = '';
|
||||
sidebarToggle?.setAttribute('aria-expanded', 'false');
|
||||
drawerPrevFocus?.focus?.();
|
||||
drawerPrevFocus = null;
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
@@ -99,6 +108,24 @@ function initSidebarToggle() {
|
||||
// Close sidebar when clicking overlay
|
||||
sidebarOverlay.addEventListener('click', closeSidebar);
|
||||
|
||||
// Trap Tab focus inside the open drawer (mobile).
|
||||
sidebar.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Tab' || !sidebar.classList.contains('open')) return;
|
||||
const f = /** @type {NodeListOf<HTMLElement>} */ (
|
||||
sidebar.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])')
|
||||
);
|
||||
if (!f.length) return;
|
||||
const first = f[0];
|
||||
const last = f[f.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// Close sidebar on escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && sidebar.classList.contains('open')) {
|
||||
@@ -155,7 +182,13 @@ function setCurrentSection(section) {
|
||||
// Update nav item active classes by finding matching item from DOM
|
||||
appElements.navItems?.forEach((item) => {
|
||||
const itemSection = getSectionFromNavItem(item);
|
||||
item.classList.toggle('active', itemSection === section);
|
||||
const isActive = itemSection === section;
|
||||
item.classList.toggle('active', isActive);
|
||||
if (isActive) {
|
||||
item.setAttribute('aria-current', 'page');
|
||||
} else {
|
||||
item.removeAttribute('aria-current');
|
||||
}
|
||||
});
|
||||
|
||||
// Update page title
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Search view orchestration logic
|
||||
*/
|
||||
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { search } from '../features/files/search.js';
|
||||
import { resolveHomeFolder } from './authSession.js';
|
||||
import { app } from './state.js';
|
||||
@@ -50,7 +51,7 @@ async function performSearch(query, sortBy) {
|
||||
search.displaySearchResults(searchResults);
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
ui.showNotification('Error', 'Error performing search');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.searchError'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+62
-7
@@ -16,7 +16,7 @@ import { wopiEditor } from '../features/files/wopiEditor.js';
|
||||
import { recent } from '../features/library/recent.js';
|
||||
import { buildBatchDownloadUrl } from '../utils/download.js';
|
||||
import { positionMenu } from '../utils/menuPosition.js';
|
||||
import { loadFiles } from './filesView.js';
|
||||
import { loadFiles, filesView } from './filesView.js';
|
||||
import { updateHistory } from './main.js';
|
||||
import { activateFilesUI, switchToFilesSection, syncViewContainers } from './navigation.js';
|
||||
import { app } from './state.js';
|
||||
@@ -497,10 +497,14 @@ const ui = {
|
||||
separator.textContent = '>';
|
||||
breadcrumb?.appendChild(separator);
|
||||
|
||||
// Segment item
|
||||
// Segment item. The home folder's raw name ("My Folder - admin")
|
||||
// is a technical default — show the friendly "Files" label for it
|
||||
// (the real name stays as the tooltip).
|
||||
const item = document.createElement('span');
|
||||
item.className = 'breadcrumb-item';
|
||||
item.textContent = segment.name;
|
||||
const isHomeFolder = app.userHomeFolderId && segment.id === app.userHomeFolderId;
|
||||
item.textContent = isHomeFolder ? i18n.t('nav.files') : segment.name;
|
||||
if (isHomeFolder) item.title = segment.name;
|
||||
item.dataset.folderId = segment.id;
|
||||
|
||||
if (!isLast) {
|
||||
@@ -1007,29 +1011,80 @@ const ui = {
|
||||
// Let ui.js delegation handle this container again
|
||||
delete filesList.dataset.managedBy;
|
||||
|
||||
// A clickable, flat-sorting column header (Drive-style). The arrow is
|
||||
// shown only on the active column; direction toggles on re-click.
|
||||
const sortCol = (field, key, label) =>
|
||||
`<button type="button" class="list-header-sort" data-sort-field="${field}">` +
|
||||
`<span data-i18n="${key}">${label}</span>` +
|
||||
`<i class="fas fa-arrow-up list-header-sort__arrow hidden" aria-hidden="true"></i></button>`;
|
||||
|
||||
filesList.innerHTML = `
|
||||
<div class="list-header">
|
||||
<div class="list-header-checkbox"><input type="checkbox" id="select-all-checkbox" title="Select all"></div>
|
||||
<div data-i18n="files.name">Name</div>
|
||||
${sortCol('name', 'files.name', 'Name')}
|
||||
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-i18n="files.owner">Owner</div>
|
||||
<div data-i18n="files.type">Type</div>
|
||||
<div data-i18n="files.size">Size</div>
|
||||
<div data-i18n="files.modified">Modified</div>
|
||||
${sortCol('type', 'files.type', 'Type')}
|
||||
${sortCol('size', 'files.size', 'Size')}
|
||||
${sortCol('modified_at', 'files.modified', 'Modified')}
|
||||
<div></div><!-- actions -->
|
||||
</div>`;
|
||||
|
||||
i18n.translateElement(filesList);
|
||||
|
||||
// Wire column-header sorting; _loadPage doesn't rebuild the header, so
|
||||
// reflect the new sort in-place after each click.
|
||||
filesList.querySelectorAll('.list-header-sort').forEach((el) => {
|
||||
el.addEventListener('click', () => {
|
||||
filesView.setSortField(/** @type {HTMLElement} */ (el).dataset.sortField || 'name');
|
||||
this.updateListHeaderSort();
|
||||
});
|
||||
});
|
||||
this.updateListHeaderSort();
|
||||
|
||||
filesList.classList.remove('hidden');
|
||||
filesContainerError?.classList.add('hidden');
|
||||
},
|
||||
|
||||
/**
|
||||
* Reflect filesView's active sort on the list-view column headers: highlight
|
||||
* the active column, point its arrow, and describe the state for AT.
|
||||
* @returns {void}
|
||||
*/
|
||||
updateListHeaderSort() {
|
||||
const { field, reversed } = filesView.currentSort;
|
||||
document.querySelectorAll('#files-list .list-header-sort').forEach((el) => {
|
||||
const btn = /** @type {HTMLElement} */ (el);
|
||||
const active = btn.dataset.sortField === field;
|
||||
btn.classList.toggle('is-active', active);
|
||||
const colName = btn.querySelector('span')?.textContent || '';
|
||||
btn.setAttribute(
|
||||
'aria-label',
|
||||
active
|
||||
? `${colName}: ${reversed ? i18n.t('sort.desc', 'descending') : i18n.t('sort.asc', 'ascending')}`
|
||||
: colName
|
||||
);
|
||||
const arrow = btn.querySelector('.list-header-sort__arrow');
|
||||
if (arrow) {
|
||||
arrow.classList.toggle('hidden', !active);
|
||||
arrow.classList.toggle('fa-arrow-down', active && reversed);
|
||||
arrow.classList.toggle('fa-arrow-up', !(active && reversed));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
showEmptyList() {
|
||||
this.showError(`
|
||||
<i class="fas fa-folder-open empty-state-icon"></i>
|
||||
<p data-i18n="files.no_files"></p>
|
||||
<p data-i18n="files.empty_hint"></p>
|
||||
<button type="button" class="btn btn-primary" id="empty-upload-btn">
|
||||
<i class="fas fa-cloud-upload-alt" aria-hidden="true"></i>
|
||||
<span data-i18n="actions.upload_files">Upload files</span>
|
||||
</button>
|
||||
`);
|
||||
document.getElementById('empty-upload-btn')?.addEventListener('click', () => {
|
||||
document.getElementById('file-input')?.click();
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { notifications } from '../core/notifications.js';
|
||||
import { announce } from '../core/liveRegion.js';
|
||||
|
||||
const uiNotifications = {
|
||||
/**
|
||||
@@ -40,6 +41,10 @@ const uiNotifications = {
|
||||
title: title || '',
|
||||
text: message || ''
|
||||
});
|
||||
|
||||
// Announce to screen readers — errors interrupt, everything else is polite.
|
||||
const spoken = [title, message].filter(Boolean).join('. ');
|
||||
announce(spoken, { assertive: iconClass === 'error' });
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* User menu, profile modal and logout logic
|
||||
*/
|
||||
|
||||
import { createUserVignette } from '../components/userVignette.js';
|
||||
import { createUserVignette, disposeVignette } from '../components/userVignette.js';
|
||||
import { getCsrfHeaders } from '../core/csrf.js';
|
||||
import { formatFileSize, formatQuotaSize } from '../core/formatters.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
@@ -183,11 +183,17 @@ function setupUserMenu() {
|
||||
function _mountAvatarVignettes(userId) {
|
||||
const avatarBtn = document.getElementById('user-avatar-btn');
|
||||
if (avatarBtn) {
|
||||
avatarBtn.replaceChildren(createUserVignette(userId, 'menu', { showName: false }));
|
||||
// Dispose the outgoing vignette's tooltip before replacing it, or its
|
||||
// body-portalled popover orphans (the "email tooltip stays stuck" bug).
|
||||
disposeVignette(/** @type {HTMLElement | null} */ (avatarBtn.firstElementChild));
|
||||
// No hover-tooltip on your own avatar — the email is already in the open
|
||||
// menu header, so it'd be redundant and overlap the notification bell.
|
||||
avatarBtn.replaceChildren(createUserVignette(userId, 'menu', { showName: false, noTooltip: true }));
|
||||
}
|
||||
|
||||
const menuHeader = document.querySelector('.user-menu-header');
|
||||
if (menuHeader) {
|
||||
disposeVignette(/** @type {HTMLElement | null} */ (menuHeader.firstElementChild));
|
||||
menuHeader.replaceChildren(createUserVignette(userId, 'xl', { showName: true, showEmail: true }));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user