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:
DioCrafts
2026-06-15 00:17:17 +02:00
parent dac299fea6
commit 81a93a489b
129 changed files with 8194 additions and 2473 deletions
+177
View File
@@ -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();
}
});
+60 -9
View File
@@ -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
+1 -1
View File
@@ -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:
+34 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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();
});
},
/**
+5
View File
@@ -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;
}
};
+8 -2
View File
@@ -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 }));
}
}
+39
View File
@@ -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);
},
+45 -5
View File
@@ -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;
}
+29 -3
View File
@@ -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);
}
}
+39
View File
@@ -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);
});
+39 -3
View File
@@ -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,
+61
View File
@@ -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;
});
}
+125
View File
@@ -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;
}
});
}
+10 -10
View File
@@ -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'));
}
},
+3 -3
View File
@@ -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'));
}
},
+29 -29
View File
@@ -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`);
}
};
+5 -4
View File
@@ -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;
}
}
+2 -1
View File
@@ -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'));
}
}
+23 -1
View File
@@ -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>';
+4 -3
View File
@@ -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;
},
+2 -9
View File
@@ -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 ── */
+2 -8
View File
@@ -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 ─────────────────────────────────────────────────────────────