Files
Oxicloud/static/js/app/commandPalette.js
T
DioCrafts 81a93a489b 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>
2026-06-15 00:24:27 +02:00

178 lines
6.7 KiB
JavaScript

// 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();
}
});