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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,10 @@ const Modal = {
|
||||
/** @private */
|
||||
_savedBodyHTML: '',
|
||||
|
||||
// Element focused before the modal opened — focus returns here on close.
|
||||
/** @private @type {HTMLElement|null} */
|
||||
_previousFocus: null,
|
||||
|
||||
/**
|
||||
* Initialize modal system
|
||||
*/
|
||||
@@ -80,6 +84,11 @@ const Modal = {
|
||||
}
|
||||
});
|
||||
|
||||
// Trap Tab focus within the dialog while it is open.
|
||||
this.overlay.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') this._trapFocus(e);
|
||||
});
|
||||
|
||||
// Clear inline error as soon as the user starts typing
|
||||
this.input?.addEventListener('input', () => this.clearError());
|
||||
|
||||
@@ -227,12 +236,38 @@ const Modal = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Keep Tab focus cycling within the dialog container (focus trap).
|
||||
* @private
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
_trapFocus(e) {
|
||||
const container = this.overlay?.querySelector('.modal-container');
|
||||
if (!container) return;
|
||||
const focusables = /** @type {NodeListOf<HTMLElement>} */ (
|
||||
container.querySelectorAll(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
);
|
||||
if (!focusables.length) return;
|
||||
const first = focusables[0];
|
||||
const last = focusables[focusables.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Open the modal
|
||||
*/
|
||||
open() {
|
||||
if (!this.overlay) return;
|
||||
|
||||
this._previousFocus = /** @type {HTMLElement|null} */ (document.activeElement);
|
||||
this.confirmBtn.disabled = false;
|
||||
|
||||
// Show overlay
|
||||
@@ -295,6 +330,10 @@ const Modal = {
|
||||
this._panelMode = false;
|
||||
this._savedBodyHTML = '';
|
||||
}
|
||||
|
||||
// Return focus to whatever was focused before the modal opened.
|
||||
this._previousFocus?.focus?.();
|
||||
this._previousFocus = null;
|
||||
}, 200);
|
||||
},
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
// @ts-check
|
||||
|
||||
import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js';
|
||||
import { escapeHtml, formatDateShort, formatDateTime, formatFileSize, formatRelativeTime } from '../core/formatters.js';
|
||||
import { i18n } from '../core/i18n.js';
|
||||
import { systemUsers } from '../model/systemUsers.js';
|
||||
import { buildResourceIcon } from './resourceIcon.js';
|
||||
@@ -37,6 +37,23 @@ import { createUserVignette } from './userVignette.js';
|
||||
*/
|
||||
const JUST_ADDED_KEY = '__oxicloud_just_added__';
|
||||
|
||||
/**
|
||||
* Recency label for a grid card's metadata line: a relative "time ago"
|
||||
* ("hace 3 días") within the last ~30 days — the dominant retrieval cue under
|
||||
* a thumbnail — collapsing to a compact absolute date ("13 jun 2026") beyond
|
||||
* that, so stale files don't read as a vague "hace 8 meses".
|
||||
* @param {number|string|Date|null|undefined} value Unix seconds, ISO string or Date.
|
||||
* @returns {string}
|
||||
*/
|
||||
function gridMetaDate(value) {
|
||||
if (!value) return '';
|
||||
const date =
|
||||
value instanceof Date ? value : new Date(typeof value === 'number' && value < 1e12 ? value * 1000 : value);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const ageDays = (Date.now() - date.getTime()) / 86_400_000;
|
||||
return ageDays > 30 ? formatDateShort(value) : formatRelativeTime(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} CustomAction
|
||||
* @property {string} iconHtml - Inner HTML for the button icon (e.g. `<i class="fas fa-undo"></i>`).
|
||||
@@ -681,16 +698,24 @@ export class ResourceListComponent {
|
||||
const isFav = cfg.isFavorite ? cfg.isFavorite(folder.id, 'folder') : false;
|
||||
const isShared = cfg.isShared ? cfg.isShared(folder.id, 'folder') : false;
|
||||
const dateVal = /** @type {Record<string,string>} */ (/** @type {unknown} */ (folder))[cfg.dateField] ?? folder.modified_at;
|
||||
const formattedDate = cfg.dateFormatter ? cfg.dateFormatter(dateVal) : formatDateTime(new Date(dateVal));
|
||||
// Pass the raw value (Unix seconds) straight to formatDateTime — it does
|
||||
// the seconds→ms conversion. Wrapping it in new Date() first treated the
|
||||
// seconds value as milliseconds → every date showed as Jan 1970.
|
||||
const formattedDate = cfg.dateFormatter ? cfg.dateFormatter(dateVal) : formatDateTime(dateVal);
|
||||
const relDate = gridMetaDate(dateVal);
|
||||
|
||||
el.innerHTML = `
|
||||
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
|
||||
<div class="name-cell">
|
||||
<div class="resource-icon-slot"></div>
|
||||
<span>${escapeHtml(folder.name)}</span>
|
||||
<span title="${escapeHtml(folder.name)}">${escapeHtml(folder.name)}</span>
|
||||
${cfg.showFavorite ? `<div class="file-badge file-badge-favorite${isFav ? '' : ' hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>` : ''}
|
||||
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
|
||||
</div>
|
||||
<div class="grid-meta" title="${escapeHtml(formattedDate)}">
|
||||
<span class="grid-meta__date">${escapeHtml(relDate)}</span>
|
||||
${isShared && folder.owner_id ? `<span class="grid-meta__owner-slot" data-owner-id="${escapeHtml(folder.owner_id)}"></span>` : ''}
|
||||
</div>
|
||||
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(folder.owner_id || '')}"></div>
|
||||
${cfg.showPath ? `<div class="path-cell" title="${escapeHtml(folder.path || '')}">${escapeHtml(folder.path || '')}</div>` : ''}
|
||||
${cfg.showType ? `<div class="type-cell">${labels.folderTypeLabel}</div>` : ''}
|
||||
@@ -704,6 +729,9 @@ export class ResourceListComponent {
|
||||
`;
|
||||
|
||||
el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(folder, 'folder'));
|
||||
if (isShared && folder.owner_id) {
|
||||
el.querySelector('.grid-meta__owner-slot')?.replaceWith(createUserVignette(folder.owner_id, 'xs', { showName: false }));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -718,7 +746,11 @@ export class ResourceListComponent {
|
||||
const typeLabel = labels.fileTypeLabel(file.category || '');
|
||||
const fileSize = file.size_formatted || formatFileSize(file.size);
|
||||
const dateVal = /** @type {Record<string,string>} */ (/** @type {unknown} */ (file))[cfg.dateField] ?? file.modified_at;
|
||||
const formattedDate = cfg.dateFormatter ? cfg.dateFormatter(dateVal) : formatDateTime(new Date(dateVal));
|
||||
// Pass the raw value (Unix seconds) straight to formatDateTime — it does
|
||||
// the seconds→ms conversion. Wrapping it in new Date() first treated the
|
||||
// seconds value as milliseconds → every date showed as Jan 1970.
|
||||
const formattedDate = cfg.dateFormatter ? cfg.dateFormatter(dateVal) : formatDateTime(dateVal);
|
||||
const relDate = gridMetaDate(dateVal);
|
||||
const isFav = cfg.isFavorite ? cfg.isFavorite(file.id, 'file') : false;
|
||||
const isShared = cfg.isShared ? cfg.isShared(file.id, 'file') : false;
|
||||
|
||||
@@ -736,11 +768,16 @@ export class ResourceListComponent {
|
||||
${cfg.selectable ? '<div class="checkbox-cell"><input type="checkbox" class="item-checkbox"></div>' : ''}
|
||||
<div class="name-cell">
|
||||
<div class="resource-icon-slot"></div>
|
||||
<span>${escapeHtml(file.name)}</span>
|
||||
<span title="${escapeHtml(file.name)}">${escapeHtml(file.name)}</span>
|
||||
${cfg.showFavorite ? `<div class="file-badge file-badge-favorite${isFav ? '' : ' hidden'}"><i class="fas fa-star favorite-star-inline"></i></div>` : ''}
|
||||
${cfg.showShareBadge ? `<div class="file-badge file-badge-shared${isShared ? '' : ' hidden'}"><i class="fas fa-oxiexport"></i></div>` : ''}
|
||||
${file.snippet ? `<span class="file-item__snippet" title="${escapeHtml(file.snippet)}">${escapeHtml(file.snippet)}</span>` : ''}
|
||||
</div>
|
||||
<div class="grid-meta" title="${escapeHtml(formattedDate)}">
|
||||
<span class="grid-meta__date">${escapeHtml(relDate)}</span>
|
||||
<span class="grid-meta__size">${escapeHtml(fileSize)}</span>
|
||||
${isShared && file.owner_id ? `<span class="grid-meta__owner-slot" data-owner-id="${escapeHtml(file.owner_id)}"></span>` : ''}
|
||||
</div>
|
||||
<div class="owner-cell${this._ownerVisible ? '' : ' hidden'}" data-owner-id="${escapeHtml(file.owner_id || '')}"></div>
|
||||
${cfg.showPath ? `<div class="path-cell" title="${escapeHtml(file.path || '')}">${escapeHtml(file.path || '')}</div>` : ''}
|
||||
${cfg.showType ? `<div class="type-cell">${typeLabel}</div>` : ''}
|
||||
@@ -754,6 +791,9 @@ export class ResourceListComponent {
|
||||
`;
|
||||
|
||||
el.querySelector('.resource-icon-slot')?.replaceWith(buildResourceIcon(file, 'file'));
|
||||
if (isShared && file.owner_id) {
|
||||
el.querySelector('.grid-meta__owner-slot')?.replaceWith(createUserVignette(file.owner_id, 'xs', { showName: false }));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,10 @@ function _applyPhoto(avatar, photoUrl, name) {
|
||||
* When true (and showName is true), the primary email address is shown below
|
||||
* the name in a lighter style. Name and email are wrapped in a
|
||||
* `.user-vignette__info` column. Has no effect when showName is false.
|
||||
* @property {boolean} [noTooltip=false]
|
||||
* When true, skip the email hover-tooltip. Use for the user's own avatar in
|
||||
* the toolbar — the email is already shown in the open menu header, so the
|
||||
* tooltip is redundant (and would overlap the bell).
|
||||
* @property {boolean} [showOrigin=true]
|
||||
* When true (the default), an `is_external` badge overlays the
|
||||
* bottom-right of the avatar for external users only — internal
|
||||
@@ -108,7 +112,14 @@ function _applyPhoto(avatar, photoUrl, name) {
|
||||
* @param {VignetteOptions} [options]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function createUserVignette(userId, size = 'sm', { showName = true, showEmail = false, showOrigin = true } = {}) {
|
||||
// Tracks the hover-tooltip cleanup for each vignette so a caller that
|
||||
// re-renders the vignette (e.g. replaceChildren on a storage update) can
|
||||
// dispose the previous tooltip — otherwise its body-portalled popover orphans
|
||||
// and, if it was visible at re-render time, stays stuck on screen.
|
||||
/** @type {WeakMap<HTMLElement, () => void>} */
|
||||
const _tooltipCleanups = new WeakMap();
|
||||
|
||||
export function createUserVignette(userId, size = 'sm', { showName = true, showEmail = false, showOrigin = true, noTooltip = false } = {}) {
|
||||
const colorIdx = _colorIndex(userId);
|
||||
|
||||
const wrapper = /** @type {HTMLElement} */ (document.createElement('span'));
|
||||
@@ -182,9 +193,9 @@ export function createUserVignette(userId, size = 'sm', { showName = true, showE
|
||||
// than the native `title` attribute's ~500–1500 ms wait).
|
||||
// `aria-label` is set in parallel so screen readers still get
|
||||
// the email — popover content is mouse/keyboard-hover only.
|
||||
if (email && !showEmail) {
|
||||
if (email && !showEmail && !noTooltip) {
|
||||
wrapper.setAttribute('aria-label', email);
|
||||
attachTooltip(wrapper, email);
|
||||
_tooltipCleanups.set(wrapper, attachTooltip(wrapper, email));
|
||||
}
|
||||
if (photo) {
|
||||
_applyPhoto(avatar, photo, name);
|
||||
@@ -214,3 +225,18 @@ export function createUserVignette(userId, size = 'sm', { showName = true, showE
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose a vignette before discarding it: tears down its hover-tooltip and
|
||||
* removes the body-portalled popover so nothing orphans on re-render.
|
||||
* Safe to call on any element (no-op if it has no tracked tooltip).
|
||||
* @param {HTMLElement | null} el
|
||||
*/
|
||||
export function disposeVignette(el) {
|
||||
if (!el) return;
|
||||
const cleanup = _tooltipCleanups.get(el);
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
_tooltipCleanups.delete(el);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Global error boundary.
|
||||
//
|
||||
// Surfaces uncaught errors and unhandled promise rejections as a terse,
|
||||
// throttled toast instead of failing silently — the full detail always goes to
|
||||
// the console. Intentionally dependency-free so an error handler can never
|
||||
// itself fail to load.
|
||||
|
||||
let lastShown = 0;
|
||||
|
||||
/** @param {string} message */
|
||||
function showToast(message) {
|
||||
const now = Date.now();
|
||||
if (now - lastShown < 4000) return; // throttle bursts of related errors
|
||||
lastShown = now;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'error-toast';
|
||||
el.setAttribute('role', 'alert');
|
||||
el.textContent = message;
|
||||
document.body.appendChild(el);
|
||||
|
||||
requestAnimationFrame(() => el.classList.add('is-visible'));
|
||||
setTimeout(() => {
|
||||
el.classList.remove('is-visible');
|
||||
setTimeout(() => el.remove(), 300);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
const GENERIC = 'Something went wrong. Please try again.';
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
console.error('[error-boundary]', event.error || event.message);
|
||||
showToast(GENERIC);
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
console.error('[error-boundary] unhandled rejection:', event.reason);
|
||||
showToast(GENERIC);
|
||||
});
|
||||
@@ -27,8 +27,11 @@ function formatFileSize(bytes) {
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const num = new Intl.NumberFormat(i18n.getCurrentLocale(), { maximumFractionDigits: 2 }).format(
|
||||
bytes / k ** i
|
||||
);
|
||||
|
||||
return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
|
||||
return `${num} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
/// Formats a byte count for quota display. When bytes is 0, returns "∞" (unlimited).
|
||||
@@ -58,7 +61,8 @@ function formatDateTime(value) {
|
||||
dateValue = new Date(value);
|
||||
}
|
||||
if (Number.isNaN(dateValue.getTime())) return String(value);
|
||||
return `${dateValue.toLocaleDateString()} ${dateValue.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||
const loc = i18n.getCurrentLocale();
|
||||
return `${dateValue.toLocaleDateString(loc)} ${dateValue.toLocaleTimeString(loc, { hour: '2-digit', minute: '2-digit' })}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,13 +74,44 @@ function formatDateShort(value) {
|
||||
if (!value) return 'N/A';
|
||||
const dateValue = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
|
||||
if (Number.isNaN(dateValue.getTime())) return String(value);
|
||||
return dateValue.toLocaleDateString(undefined, {
|
||||
return dateValue.toLocaleDateString(i18n.getCurrentLocale(), {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Locale-aware "time ago" via Intl.RelativeTimeFormat (replaces the per-view
|
||||
* hand-rolled timeAgo helpers).
|
||||
* @param {Date | number | string | null} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatRelativeTime(value) {
|
||||
if (!value) return '';
|
||||
const date =
|
||||
value instanceof Date
|
||||
? value
|
||||
: new Date(typeof value === 'number' && value < 1e12 ? value * 1000 : value);
|
||||
if (Number.isNaN(date.getTime())) return String(value);
|
||||
const diffSec = Math.round((date.getTime() - Date.now()) / 1000);
|
||||
const abs = Math.abs(diffSec);
|
||||
const rtf = new Intl.RelativeTimeFormat(i18n.getCurrentLocale(), { numeric: 'auto' });
|
||||
/** @type {Array<[Intl.RelativeTimeFormatUnit, number]>} */
|
||||
const units = [
|
||||
['year', 31536000],
|
||||
['month', 2592000],
|
||||
['week', 604800],
|
||||
['day', 86400],
|
||||
['hour', 3600],
|
||||
['minute', 60]
|
||||
];
|
||||
for (const [unit, secs] of units) {
|
||||
if (abs >= secs) return rtf.format(Math.round(diffSec / secs), unit);
|
||||
}
|
||||
return rtf.format(diffSec, 'second');
|
||||
}
|
||||
|
||||
const TEXT_TYPES = [
|
||||
'application/json',
|
||||
'application/xml',
|
||||
@@ -363,6 +398,7 @@ export {
|
||||
formatExpiryDate,
|
||||
formatFileSize,
|
||||
formatQuotaSize,
|
||||
formatRelativeTime,
|
||||
isEmailValid,
|
||||
isTextViewable,
|
||||
normalizeDateBucket,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Global ARIA live region.
|
||||
*
|
||||
* Announces transient UI messages (toasts, notifications) to assistive
|
||||
* technology without any visual footprint. Screen-reader users otherwise
|
||||
* miss feedback that sighted users see in the notification bell / toasts.
|
||||
*
|
||||
* Dependency-free; the region is created lazily on first use so importing
|
||||
* this module has no DOM side effect until something is actually announced.
|
||||
*
|
||||
* @module core/liveRegion
|
||||
*/
|
||||
|
||||
/** @type {HTMLElement | null} */
|
||||
let politeRegion = null;
|
||||
/** @type {HTMLElement | null} */
|
||||
let assertiveRegion = null;
|
||||
|
||||
/**
|
||||
* Find or create the visually-hidden live region for the given urgency.
|
||||
* @param {boolean} assertive
|
||||
* @returns {HTMLElement | null}
|
||||
*/
|
||||
function ensureRegion(assertive) {
|
||||
if (!document.body) return null;
|
||||
if (assertive && assertiveRegion) return assertiveRegion;
|
||||
if (!assertive && politeRegion) return politeRegion;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.className = 'sr-only';
|
||||
el.id = assertive ? 'a11y-live-assertive' : 'a11y-live-polite';
|
||||
el.setAttribute('aria-live', assertive ? 'assertive' : 'polite');
|
||||
el.setAttribute('aria-atomic', 'true');
|
||||
el.setAttribute('role', assertive ? 'alert' : 'status');
|
||||
document.body.appendChild(el);
|
||||
|
||||
if (assertive) assertiveRegion = el;
|
||||
else politeRegion = el;
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce a message to screen readers.
|
||||
*
|
||||
* @param {string} message - text to announce (empty/whitespace is ignored)
|
||||
* @param {{ assertive?: boolean }} [opts] - `assertive: true` interrupts the
|
||||
* current speech (use for errors); default is polite.
|
||||
*/
|
||||
export function announce(message, opts = {}) {
|
||||
const msg = String(message ?? '').trim();
|
||||
if (!msg) return;
|
||||
const region = ensureRegion(Boolean(opts.assertive));
|
||||
if (!region) return;
|
||||
|
||||
// Clear first, then set on the next frame so the DOM mutation is registered
|
||||
// as a change even when the same message is announced twice in a row.
|
||||
region.textContent = '';
|
||||
requestAnimationFrame(() => {
|
||||
region.textContent = msg;
|
||||
});
|
||||
}
|
||||
@@ -705,6 +705,9 @@ async function showInitialPanel() {
|
||||
showPanel(loginPanel);
|
||||
hidePanel(registerPanel);
|
||||
hidePanel(adminSetupPanel);
|
||||
// Cursor ready on the identifier field (panels start hidden, so a static
|
||||
// `autofocus` attribute wouldn't fire — focus once the panel is shown).
|
||||
/** @type {HTMLInputElement | null} */ (document.getElementById('login-username'))?.focus();
|
||||
|
||||
// Hide the admin setup link if admin already exists
|
||||
const showAdminSetupLink = document.getElementById('show-admin-setup');
|
||||
@@ -807,6 +810,7 @@ function initLoginElements() {
|
||||
showPanel(loginPanel);
|
||||
hidePanel(registerPanel);
|
||||
hidePanel(adminSetupPanel);
|
||||
/** @type {HTMLInputElement | null} */ (document.getElementById('login-username'))?.focus();
|
||||
});
|
||||
|
||||
document.getElementById('show-admin-setup').addEventListener('click', () => {
|
||||
@@ -821,9 +825,119 @@ function initLoginElements() {
|
||||
hidePanel(adminSetupPanel);
|
||||
});
|
||||
|
||||
// UX affordances: password reveal, magic-link disclosure, live match feedback,
|
||||
// Caps-Lock warning on password fields.
|
||||
initPasswordToggles();
|
||||
initMagicLinkDisclosure();
|
||||
initPasswordMatch('register-password', 'register-password-confirm', 'register-match');
|
||||
initPasswordMatch('admin-password', 'admin-password-confirm', 'admin-match');
|
||||
['login-password', 'register-password', 'admin-password'].forEach((id) =>
|
||||
initCapsLockWarning(/** @type {HTMLInputElement | null} */ (document.getElementById(id)))
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire every password show/hide toggle. Each button sits next to the
|
||||
* <input> it controls inside an `.auth-input-wrap`.
|
||||
*/
|
||||
function initPasswordToggles() {
|
||||
document.querySelectorAll('[data-pw-toggle]').forEach((btn) => {
|
||||
const wrap = btn.closest('.auth-input-wrap');
|
||||
const input = /** @type {HTMLInputElement | null} */ (wrap?.querySelector('input') ?? null);
|
||||
if (!input) return;
|
||||
const sync = () => {
|
||||
const shown = input.type === 'text';
|
||||
btn.setAttribute('aria-pressed', String(shown));
|
||||
btn.setAttribute(
|
||||
'aria-label',
|
||||
shown ? i18n.t('auth.hidePassword', 'Hide password') : i18n.t('auth.showPassword', 'Show password')
|
||||
);
|
||||
};
|
||||
sync();
|
||||
btn.addEventListener('click', () => {
|
||||
input.type = input.type === 'password' ? 'text' : 'password';
|
||||
sync();
|
||||
input.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a "Caps Lock is on" hint under a password field while the key is active
|
||||
* and the field is focused — saves a failed-login round-trip.
|
||||
* @param {HTMLInputElement | null} input
|
||||
*/
|
||||
function initCapsLockWarning(input) {
|
||||
if (!input) return;
|
||||
const warn = document.createElement('div');
|
||||
warn.className = 'auth-caps-warning hidden';
|
||||
warn.setAttribute('role', 'status');
|
||||
const icon = document.createElement('i');
|
||||
icon.className = 'fas fa-arrow-up';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
const text = document.createElement('span');
|
||||
text.textContent = i18n.t('auth.capsLock', 'Caps Lock is on');
|
||||
warn.append(icon, text);
|
||||
input.closest('.auth-input-group')?.appendChild(warn);
|
||||
|
||||
/** @param {KeyboardEvent} e */
|
||||
const sync = (e) => {
|
||||
const on = typeof e.getModifierState === 'function' && e.getModifierState('CapsLock');
|
||||
warn.classList.toggle('hidden', !(on && document.activeElement === input));
|
||||
};
|
||||
input.addEventListener('keydown', sync);
|
||||
input.addEventListener('keyup', sync);
|
||||
input.addEventListener('blur', () => warn.classList.add('hidden'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Progressive disclosure: keep the magic-link form collapsed behind a quiet
|
||||
* toggle so the login screen leads with a single primary path.
|
||||
*/
|
||||
function initMagicLinkDisclosure() {
|
||||
const toggle = document.getElementById('magic-link-toggle');
|
||||
const reveal = document.getElementById('magic-link-reveal');
|
||||
if (!toggle || !reveal) return;
|
||||
toggle.addEventListener('click', () => {
|
||||
// classList.toggle returns true when the class is now PRESENT (collapsed).
|
||||
const open = reveal.classList.toggle('hidden') === false;
|
||||
toggle.setAttribute('aria-expanded', String(open));
|
||||
if (open) {
|
||||
const email = /** @type {HTMLInputElement | null} */ (document.getElementById('magic-link-email'));
|
||||
email?.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Live "passwords match" feedback rendered under a confirm field.
|
||||
* @param {string} passId
|
||||
* @param {string} confirmId
|
||||
* @param {string} indicatorId
|
||||
*/
|
||||
function initPasswordMatch(passId, confirmId, indicatorId) {
|
||||
const pass = /** @type {HTMLInputElement | null} */ (document.getElementById(passId));
|
||||
const confirmEl = /** @type {HTMLInputElement | null} */ (document.getElementById(confirmId));
|
||||
const out = document.getElementById(indicatorId);
|
||||
if (!pass || !confirmEl || !out) return;
|
||||
const update = () => {
|
||||
if (!confirmEl.value) {
|
||||
out.className = 'auth-match';
|
||||
out.textContent = '';
|
||||
return;
|
||||
}
|
||||
const ok = pass.value === confirmEl.value;
|
||||
out.className = `auth-match show ${ok ? 'auth-match--ok' : 'auth-match--bad'}`;
|
||||
out.textContent = ok
|
||||
? i18n.t('auth.passwordsMatch', 'Passwords match')
|
||||
: i18n.t('auth.passwords_mismatch', "Passwords don't match");
|
||||
};
|
||||
pass.addEventListener('input', update);
|
||||
confirmEl.addEventListener('input', update);
|
||||
}
|
||||
|
||||
// Initialize login elements if on login page
|
||||
const isLoginPage = initLoginElements();
|
||||
|
||||
@@ -993,6 +1107,13 @@ if (isLoginPage && loginForm) {
|
||||
const username = inputVal('login-username');
|
||||
const password = inputVal('login-password');
|
||||
|
||||
const loginSubmit = /** @type {HTMLButtonElement | null} */ (
|
||||
document.getElementById('login-submit')
|
||||
);
|
||||
loginSubmit?.classList.add('is-loading');
|
||||
loginSubmit?.setAttribute('aria-busy', 'true');
|
||||
if (loginSubmit) loginSubmit.disabled = true;
|
||||
|
||||
try {
|
||||
const data = await login(username, password);
|
||||
|
||||
@@ -1030,6 +1151,10 @@ if (isLoginPage && loginForm) {
|
||||
} catch (error) {
|
||||
loginError.textContent = errMessage(error) || 'Error logging in';
|
||||
loginError.style.display = 'block';
|
||||
} finally {
|
||||
loginSubmit?.classList.remove('is-loading');
|
||||
loginSubmit?.removeAttribute('aria-busy');
|
||||
if (loginSubmit) loginSubmit.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -202,15 +202,15 @@ const batchToolbar = {
|
||||
showBatchResult(action, result) {
|
||||
if (action === 'copy') {
|
||||
if (result.errors > 0) {
|
||||
ui.showNotification('Batch copy', `${result.success} copied, ${result.errors} failed`);
|
||||
ui.showNotification(i18n.t('notif.batchCopy'), i18n.t('notif.batchCopyBody', { success: result.success, errors: result.errors }));
|
||||
} else {
|
||||
ui.showNotification('Items copied', `${result.success} item${result.success !== 1 ? 's' : ''} copied successfully`);
|
||||
ui.showNotification(i18n.t('notif.itemsCopied'), i18n.t('notif.itemsCopiedBody', { count: result.success }));
|
||||
}
|
||||
} else {
|
||||
if (result.errors > 0) {
|
||||
ui.showNotification('Batch move', `${result.success} moved, ${result.errors} failed`);
|
||||
ui.showNotification(i18n.t('notif.batchMove'), i18n.t('notif.batchMoveBody', { success: result.success, errors: result.errors }));
|
||||
} else {
|
||||
ui.showNotification('Items moved', `${result.success} item${result.success !== 1 ? 's' : ''} moved successfully`);
|
||||
ui.showNotification(i18n.t('notif.itemsMoved'), i18n.t('notif.itemsMovedBody', { count: result.success }));
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -421,13 +421,13 @@ const batchToolbar = {
|
||||
loadFiles();
|
||||
|
||||
if (errors > 0) {
|
||||
ui.showNotification('Batch delete', `${success} moved to trash, ${errors} failed`);
|
||||
ui.showNotification(i18n.t('notif.batchDelete'), i18n.t('notif.batchDeleteBody', { success, errors }));
|
||||
} else {
|
||||
ui.showNotification('Moved to trash', `${success} item${success !== 1 ? 's' : ''} moved to trash`);
|
||||
ui.showNotification(i18n.t('notif.movedToTrash'), i18n.t('notif.movedToTrashBody', { count: success }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Batch trash error:', e);
|
||||
ui.showNotification('Error', 'Could not move items to trash');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.trashItemsError'));
|
||||
this.clear();
|
||||
loadFiles();
|
||||
}
|
||||
@@ -458,7 +458,7 @@ const batchToolbar = {
|
||||
const items = this.items;
|
||||
if (items.length === 0) return;
|
||||
|
||||
ui.showNotification('Preparing download', 'Creating ZIP archive...');
|
||||
ui.showNotification(i18n.t('notif.preparingDownload'), i18n.t('notif.preparingDownloadBody'));
|
||||
|
||||
const fileIds = items.filter((i) => i.type === 'file').map((i) => i.id);
|
||||
const folderIds = items.filter((i) => i.type === 'folder').map((i) => i.id);
|
||||
@@ -495,7 +495,7 @@ const batchToolbar = {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (e) {
|
||||
console.error('Batch download error:', e);
|
||||
ui.showNotification('Error', 'Could not download selected items');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.downloadItemsError'));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -539,7 +539,7 @@ const batchToolbar = {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Batch favorites error:', e);
|
||||
ui.showNotification('Error', 'Could not add items to favorites');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.favoritesAddError'));
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -744,14 +744,14 @@ const contextMenus = {
|
||||
const shareUrl = app.notificationShareUrl;
|
||||
|
||||
if (!email || !shareUrl) {
|
||||
ui.showNotification('Error', 'Please enter a valid email address');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.invalidEmail'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
ui.showNotification('Error', 'Please enter a valid email address');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.invalidEmail'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -760,7 +760,7 @@ const contextMenus = {
|
||||
document.getElementById('notification-dialog')?.classList.add('hidden');
|
||||
} catch (error) {
|
||||
console.error('Error sending notification:', error);
|
||||
ui.showNotification('Error', 'Could not send notification');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.notificationSendError'));
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -853,7 +853,7 @@ const fileOps = {
|
||||
// — no reload needed since the backend already confirmed creation.
|
||||
filesViewAddItem(folder);
|
||||
|
||||
ui.showNotification('Folder created', `"${name}" created successfully`);
|
||||
ui.showNotification(i18n.t('notif.folderCreated'), i18n.t('notif.folderCreatedBody', { name }));
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
console.error('Create folder error:', errorText);
|
||||
@@ -894,7 +894,7 @@ const fileOps = {
|
||||
if (response.ok) {
|
||||
// Reload files after moving
|
||||
await loadFiles();
|
||||
ui.showNotification('File moved', 'File moved successfully');
|
||||
ui.showNotification(i18n.t('notif.fileMoved'), i18n.t('notif.fileMovedBody'));
|
||||
return true;
|
||||
} else {
|
||||
let errorMessage = 'Unknown error';
|
||||
@@ -904,12 +904,12 @@ const fileOps = {
|
||||
} catch (_e) {
|
||||
errorMessage = 'Error processing server response';
|
||||
}
|
||||
ui.showNotification('Error', `Error moving the file: ${errorMessage}`);
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.fileMoveError', { error: errorMessage }));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error moving file:', error);
|
||||
ui.showNotification('Error', 'Error moving the file');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.fileMoveErrorGeneric'));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -938,7 +938,7 @@ const fileOps = {
|
||||
invalidateFolderMeta(folderId);
|
||||
// Reload files after moving
|
||||
await loadFiles();
|
||||
ui.showNotification('Folder moved', 'Folder moved successfully');
|
||||
ui.showNotification(i18n.t('notif.folderMoved'), i18n.t('notif.folderMovedBody'));
|
||||
return true;
|
||||
} else {
|
||||
let errorMessage = 'Unknown error';
|
||||
@@ -948,12 +948,12 @@ const fileOps = {
|
||||
} catch (_e) {
|
||||
errorMessage = 'Error processing server response';
|
||||
}
|
||||
ui.showNotification('Error', `Error moving the folder: ${errorMessage}`);
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.folderMoveError', { error: errorMessage }));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error moving folder:', error);
|
||||
ui.showNotification('Error', 'Error moving the folder');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.folderMoveErrorGeneric'));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -1037,7 +1037,7 @@ const fileOps = {
|
||||
await response.json();
|
||||
// Reload files after copying
|
||||
await loadFiles();
|
||||
ui.showNotification('File copied', 'File copied successfully');
|
||||
ui.showNotification(i18n.t('notif.fileCopied'), i18n.t('notif.fileCopiedBody'));
|
||||
return true;
|
||||
} else {
|
||||
let errorMessage = 'Unknown error';
|
||||
@@ -1047,12 +1047,12 @@ const fileOps = {
|
||||
} catch (_e) {
|
||||
errorMessage = 'Error processing server response';
|
||||
}
|
||||
ui.showNotification('Error', `Error copying the file: ${errorMessage}`);
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.fileCopyError', { error: errorMessage }));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error copying file:', error);
|
||||
ui.showNotification('Error', 'Error copying the file');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.fileCopyErrorGeneric'));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -1195,7 +1195,7 @@ const fileOps = {
|
||||
if (response.ok) {
|
||||
// Name changed — drop the cached breadcrumb metadata
|
||||
invalidateFolderMeta(folderId);
|
||||
ui.showNotification('Folder renamed', `Folder renamed to "${newName}"`);
|
||||
ui.showNotification(i18n.t('notif.folderRenamed'), i18n.t('notif.folderRenamedBody', { name: newName }));
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
console.error('Error response:', errorText);
|
||||
@@ -1239,7 +1239,7 @@ const fileOps = {
|
||||
|
||||
if (response.ok) {
|
||||
loadFiles();
|
||||
ui.showNotification('File moved to trash', `"${fileName}" moved to trash`);
|
||||
ui.showNotification(i18n.t('notif.fileTrashed'), i18n.t('notif.fileTrashedBody', { name: fileName }));
|
||||
return true;
|
||||
} else {
|
||||
// Fallback to direct deletion if trash fails
|
||||
@@ -1250,16 +1250,16 @@ const fileOps = {
|
||||
|
||||
if (fallbackResponse.ok) {
|
||||
loadFiles();
|
||||
ui.showNotification('File deleted', `"${fileName}" deleted successfully`);
|
||||
ui.showNotification(i18n.t('notif.fileDeleted'), i18n.t('notif.fileDeletedBody', { name: fileName }));
|
||||
return true;
|
||||
} else {
|
||||
ui.showNotification('Error', 'Error deleting the file');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.fileDeleteError'));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting file:', error);
|
||||
ui.showNotification('Error', 'Error deleting the file');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.fileDeleteError'));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -1292,7 +1292,7 @@ const fileOps = {
|
||||
ui.updateBreadcrumb();
|
||||
}
|
||||
loadFiles();
|
||||
ui.showNotification('Folder moved to trash', `"${folderName}" moved to trash`);
|
||||
ui.showNotification(i18n.t('notif.folderTrashed'), i18n.t('notif.folderTrashedBody', { name: folderName }));
|
||||
return true;
|
||||
} else {
|
||||
// Fallback to direct deletion if trash fails
|
||||
@@ -1308,16 +1308,16 @@ const fileOps = {
|
||||
ui.updateBreadcrumb();
|
||||
}
|
||||
loadFiles();
|
||||
ui.showNotification('Folder deleted', `"${folderName}" deleted successfully`);
|
||||
ui.showNotification(i18n.t('notif.folderDeleted'), i18n.t('notif.folderDeletedBody', { name: folderName }));
|
||||
return true;
|
||||
} else {
|
||||
ui.showNotification('Error', 'Error deleting the folder');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.folderDeleteError'));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting folder:', error);
|
||||
ui.showNotification('Error', 'Error deleting the folder');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.folderDeleteError'));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -1339,15 +1339,15 @@ const fileOps = {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
ui.showNotification('Item restored', 'Item restored successfully');
|
||||
ui.showNotification(i18n.t('notif.itemRestored'), i18n.t('notif.itemRestoredBody'));
|
||||
return true;
|
||||
} else {
|
||||
ui.showNotification('Error', 'Error restoring the item');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.itemRestoreError'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error restoring item from trash:', error);
|
||||
ui.showNotification('Error', 'Error restoring the item');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.itemRestoreError'));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -1372,15 +1372,15 @@ const fileOps = {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
ui.showNotification('Item deleted', 'Item permanently deleted');
|
||||
ui.showNotification(i18n.t('notif.itemDeleted'), i18n.t('notif.itemDeletedBody'));
|
||||
return true;
|
||||
} else {
|
||||
ui.showNotification('Error', 'Error deleting the item');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.itemDeleteError'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting item permanently:', error);
|
||||
ui.showNotification('Error', 'Error deleting the item');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.itemDeleteError'));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -1404,15 +1404,15 @@ const fileOps = {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
ui.showNotification('Trash emptied', 'The trash has been emptied successfully');
|
||||
ui.showNotification(i18n.t('notif.trashEmptied'), i18n.t('notif.trashEmptiedBody'));
|
||||
return true;
|
||||
} else {
|
||||
ui.showNotification('Error', 'Error emptying the trash');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.trashEmptyError'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error emptying trash:', error);
|
||||
ui.showNotification('Error', 'Error emptying the trash');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.trashEmptyError'));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -1435,7 +1435,7 @@ const fileOps = {
|
||||
async downloadFolder(folderId, folderName) {
|
||||
// Show notification to user (the server still has to assemble the
|
||||
// ZIP before the browser's own download UI takes over).
|
||||
ui.showNotification('Preparing download', 'Preparing the folder for download...');
|
||||
ui.showNotification(i18n.t('notif.preparingDownload'), i18n.t('notif.preparingDownloadBody'));
|
||||
triggerBrowserDownload(`/api/folders/${folderId}/download?format=zip`, `${folderName}.zip`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { loadFiles, renderItems } from '../../app/filesView.js';
|
||||
import { app } from '../../app/state.js';
|
||||
import { ui } from '../../app/ui.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { getAuthHeaders } from './fileOperations.js';
|
||||
|
||||
/**
|
||||
@@ -69,7 +70,7 @@ const search = {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error performing search:', error);
|
||||
ui.showNotification('Error', 'Error performing search');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.searchError'));
|
||||
return {
|
||||
files: [],
|
||||
folders: [],
|
||||
@@ -216,15 +217,15 @@ const search = {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
ui.showNotification('Cache cleared', 'Search cache cleared successfully');
|
||||
ui.showNotification(i18n.t('notif.cacheCleared'), i18n.t('notif.cacheClearedBody'));
|
||||
return true;
|
||||
} else {
|
||||
ui.showNotification('Error', 'Error clearing search cache');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.cacheClearError'));
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing search cache:', error);
|
||||
ui.showNotification('Error', 'Error clearing search cache');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.cacheClearError'));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { loadFiles } from '../../app/filesView.js';
|
||||
import { ui } from '../../app/ui.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
|
||||
class WopiEditor {
|
||||
constructor() {
|
||||
@@ -54,7 +55,7 @@ class WopiEditor {
|
||||
window.open(hostUrl, '_blank');
|
||||
} catch (error) {
|
||||
console.error('Failed to open WOPI editor in tab:', error);
|
||||
ui.showNotification('Could not open the document editor.', 'error');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.wopiOpenError'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -181,6 +181,7 @@ const photosView = {
|
||||
html += '<div class="photos-sentinel"></div>';
|
||||
this._container.innerHTML = html;
|
||||
this._container.onclick = (e) => this._handleClick(e);
|
||||
this._fadeInTiles();
|
||||
this._renderedCount = this.items.length;
|
||||
this._observeSentinel();
|
||||
this._setupVideoThumbnails();
|
||||
@@ -237,6 +238,7 @@ const photosView = {
|
||||
this._renderedCount = this.items.length;
|
||||
this._observeSentinel();
|
||||
this._setupVideoThumbnails(startIndex);
|
||||
this._fadeInTiles();
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -250,12 +252,32 @@ const photosView = {
|
||||
const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`;
|
||||
let h = `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}" data-name="${this._escAttr(file.name)}">`;
|
||||
h += `<div class="photo-check"><i class="fas fa-check"></i></div>`;
|
||||
h += `<img src="${thumbUrl}" loading="lazy" alt="${this._escAttr(file.name)}">`;
|
||||
const srcset = cachedThumb
|
||||
? ''
|
||||
: ` srcset="/api/files/${file.id}/thumbnail/icon 150w, /api/files/${file.id}/thumbnail/preview 400w, /api/files/${file.id}/thumbnail/large 800w" sizes="(max-width: 768px) 33vw, 200px"`;
|
||||
h += `<img src="${thumbUrl}"${srcset} loading="lazy" decoding="async" alt="${this._escAttr(file.name)}">`;
|
||||
if (isVideo) h += `<div class="video-badge"><i class="fas fa-play"></i></div>`;
|
||||
h += `</div>`;
|
||||
return h;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fade tiles in as their thumbnails finish loading (kills the pop-in).
|
||||
* Idempotent — only wires images not already marked loaded.
|
||||
*/
|
||||
_fadeInTiles() {
|
||||
this._container?.querySelectorAll('.photo-tile img:not(.is-loaded)').forEach((el) => {
|
||||
const img = /** @type {HTMLImageElement} */ (el);
|
||||
if (img.complete) {
|
||||
img.classList.add('is-loaded');
|
||||
} else {
|
||||
const done = () => img.classList.add('is-loaded');
|
||||
img.addEventListener('load', done, { once: true });
|
||||
img.addEventListener('error', done, { once: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/** (Re-)observe the sentinel element for infinite scroll */
|
||||
_observeSentinel() {
|
||||
this._destroyObserver();
|
||||
|
||||
@@ -157,6 +157,17 @@ export const photosLightbox = {
|
||||
requestAnimationFrame(() => el.classList.add('active'));
|
||||
},
|
||||
|
||||
/** Preload the immediate prev/next thumbnails so navigation is instant. */
|
||||
_preloadNeighbors() {
|
||||
[this.index - 1, this.index + 1].forEach((i) => {
|
||||
const it = this.items[i];
|
||||
if (it && !it.mime_type?.startsWith('video/')) {
|
||||
const pre = new Image();
|
||||
pre.src = this._thumbUrl(it);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/** Display the current item */
|
||||
_show() {
|
||||
if (!this._overlay || this.index < 0) return;
|
||||
@@ -193,6 +204,9 @@ export const photosLightbox = {
|
||||
const fullResIcon = fullResBtn.querySelector('i');
|
||||
if (fullResIcon) fullResIcon.className = 'fas fa-expand';
|
||||
|
||||
// Preload neighbours so prev/next is instant.
|
||||
this._preloadNeighbors();
|
||||
|
||||
// Load content
|
||||
content.innerHTML = '<div class="photos-loading"><i class="fas fa-spinner"></i></div>';
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { switchToSharedSection } from '../../app/navigation.js';
|
||||
import { ui } from '../../app/ui.js';
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { formatDateTime } from '../../core/formatters.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
|
||||
/**
|
||||
* @import {CreateShare, ShareItem, UpdateShare} from '../../core/types.js'
|
||||
@@ -169,11 +170,11 @@ const fileSharing = {
|
||||
async copyLinkToClipboard(url) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
ui.showNotification('Link copied', 'Link copied to clipboard');
|
||||
ui.showNotification(i18n.t('notif.linkCopied'), i18n.t('notif.linkCopiedBody'));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error copying to clipboard:', error);
|
||||
ui.showNotification('Error', 'Could not copy link');
|
||||
ui.showNotification(i18n.t('notif.errorTitle'), i18n.t('notif.linkCopyError'));
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -198,7 +199,7 @@ const fileSharing = {
|
||||
async sendShareNotification(shareUrl, recipientEmail, _message = '') {
|
||||
// TODO: implement backend endpoint for email notifications
|
||||
console.log(`Share notification for ${shareUrl} sent to ${recipientEmail}`);
|
||||
ui.showNotification('Notification sent', `Notification sent to ${recipientEmail}`);
|
||||
ui.showNotification(i18n.t('notif.notificationSent'), i18n.t('notif.notificationSentBody', { email: recipientEmail }));
|
||||
return true;
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { escapeHtml } from '../../core/formatters.js';
|
||||
import { escapeHtml, formatRelativeTime } from '../../core/formatters.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { oxiIconsInit } from '../../core/icons.js';
|
||||
|
||||
@@ -63,14 +63,7 @@ function formatBytes(bytes) {
|
||||
/** @param {string|null} dateStr */
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return i18n.t('admin.never');
|
||||
const d = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const secs = Math.floor((now.getTime() - d.getTime()) / 1000);
|
||||
if (secs < 60) return i18n.t('admin.just_now');
|
||||
if (secs < 3600) return i18n.t('admin.minutes_ago', { n: Math.floor(secs / 60) });
|
||||
if (secs < 86400) return i18n.t('admin.hours_ago', { n: Math.floor(secs / 3600) });
|
||||
if (secs < 2592000) return i18n.t('admin.days_ago', { n: Math.floor(secs / 86400) });
|
||||
return d.toLocaleDateString();
|
||||
return formatRelativeTime(dateStr);
|
||||
}
|
||||
|
||||
/* ── Custom confirm modal ── */
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getCsrfHeaders } from '../../core/csrf.js';
|
||||
import { installFetchInterceptor } from '../../core/fetchWrapper.js';
|
||||
import { i18n } from '../../core/i18n.js';
|
||||
import { formatRelativeTime } from '../../core/formatters.js';
|
||||
import { oxiIconsInit } from '../../core/icons.js';
|
||||
import { resizeImageToDataUrl } from '../../utils/imageResize.js';
|
||||
|
||||
@@ -32,14 +33,7 @@ function formatBytes(bytes) {
|
||||
/** @param {string | null | undefined} dateStr */
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return i18n.t('profile.never');
|
||||
const d = new Date(dateStr);
|
||||
const now = Date.now();
|
||||
const secs = Math.floor((now - d.valueOf()) / 1000);
|
||||
if (secs < 60) return i18n.t('profile.just_now');
|
||||
if (secs < 3600) return i18n.t('profile.minutes_ago', { n: Math.floor(secs / 60) });
|
||||
if (secs < 86400) return i18n.t('profile.hours_ago', { n: Math.floor(secs / 3600) });
|
||||
if (secs < 2592000) return i18n.t('profile.days_ago', { n: Math.floor(secs / 86400) });
|
||||
return d.toLocaleDateString();
|
||||
return formatRelativeTime(dateStr);
|
||||
}
|
||||
|
||||
// ── Avatar helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user