From 8e1e9fe2011e12fef1b7306e0aa1827aa428bba9 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Wed, 6 May 2026 17:43:30 +0200 Subject: [PATCH 1/5] show item path on Recent + Favorites, add go to parent folder, fix Folder browsing in Favorites --- src/application/dtos/favorites_dto.rs | 5 ++ src/application/dtos/recent_dto.rs | 5 ++ .../pg/favorites_pg_repository.rs | 10 ++- .../pg/recent_items_pg_repository.rs | 10 ++- static/css/base/variables.css | 2 + static/css/components/pathTooltip.css | 25 ++++++ static/css/layout/sidebar.css | 2 +- static/index.html | 1 + static/js/app/ui.js | 21 ++++- static/js/features/files/contextMenus.js | 18 +++++ static/js/features/library/favorites.js | 10 ++- static/js/features/library/recent.js | 8 +- static/js/features/pathTooltip.js | 78 +++++++++++++++++++ static/locales/ar.json | 3 +- static/locales/de.json | 3 +- static/locales/en.json | 3 +- static/locales/es.json | 3 +- static/locales/fa.json | 3 +- static/locales/fr.json | 3 +- static/locales/hi.json | 3 +- static/locales/it.json | 3 +- static/locales/ja.json | 3 +- static/locales/ko.json | 3 +- static/locales/nl.json | 3 +- static/locales/pl.json | 3 +- static/locales/pt.json | 3 +- static/locales/ru.json | 3 +- static/locales/zh.json | 3 +- 28 files changed, 217 insertions(+), 23 deletions(-) create mode 100644 static/css/components/pathTooltip.css create mode 100644 static/js/features/pathTooltip.js diff --git a/src/application/dtos/favorites_dto.rs b/src/application/dtos/favorites_dto.rs index 21a473d5..545cef0a 100644 --- a/src/application/dtos/favorites_dto.rs +++ b/src/application/dtos/favorites_dto.rs @@ -46,6 +46,11 @@ pub struct FavoriteItemDto { #[serde(skip_serializing_if = "Option::is_none")] pub modified_at: Option>, + /// Full human-readable path (e.g. "Documents/Work" for a folder, + /// "Documents/Work/report.pdf" for a file) + #[serde(skip_serializing_if = "Option::is_none")] + pub item_path: Option, + // ── Pre-computed display fields ── /// FontAwesome icon CSS class (e.g. "fas fa-file-image", "fas fa-folder") pub icon_class: String, diff --git a/src/application/dtos/recent_dto.rs b/src/application/dtos/recent_dto.rs index 85dec184..0f03cf83 100644 --- a/src/application/dtos/recent_dto.rs +++ b/src/application/dtos/recent_dto.rs @@ -42,6 +42,11 @@ pub struct RecentItemDto { #[serde(skip_serializing_if = "Option::is_none")] pub parent_id: Option, + /// Full human-readable path (e.g. "Documents/Work" for a folder, + /// "Documents/Work/report.pdf" for a file) + #[serde(skip_serializing_if = "Option::is_none")] + pub item_path: Option, + // ── Pre-computed display fields ── /// FontAwesome icon CSS class (e.g. "fas fa-file-image", "fas fa-folder") pub icon_class: String, diff --git a/src/infrastructure/repositories/pg/favorites_pg_repository.rs b/src/infrastructure/repositories/pg/favorites_pg_repository.rs index 800cb847..b705810e 100644 --- a/src/infrastructure/repositories/pg/favorites_pg_repository.rs +++ b/src/infrastructure/repositories/pg/favorites_pg_repository.rs @@ -33,10 +33,17 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { f.size AS "item_size", f.mime_type AS "item_mime_type", COALESCE(f.folder_id::TEXT, fld.parent_id::TEXT) AS "parent_id", - COALESCE(f.updated_at, fld.updated_at) AS "modified_at" + COALESCE(f.updated_at, fld.updated_at) AS "modified_at", + CASE + WHEN uf.item_type = 'folder' THEN fld.path + WHEN uf.item_type = 'file' THEN COALESCE(pfld.path || '/' || f.name, f.name) + ELSE NULL + END AS "item_path" FROM auth.user_favorites uf LEFT JOIN storage.files f ON uf.item_type = 'file' AND f.id = uf.item_id::UUID + LEFT JOIN storage.folders pfld ON uf.item_type = 'file' + AND pfld.id = f.folder_id LEFT JOIN storage.folders fld ON uf.item_type = 'folder' AND fld.id = uf.item_id::UUID WHERE uf.user_id = $1 @@ -70,6 +77,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { item_mime_type: row.try_get("item_mime_type").ok(), parent_id: row.try_get("parent_id").ok(), modified_at: row.try_get("modified_at").ok(), + item_path: row.try_get("item_path").ok(), // Temporary defaults; with_display_fields() computes the real values icon_class: String::new(), icon_special_class: String::new(), diff --git a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs index 759d82fe..867331b7 100644 --- a/src/infrastructure/repositories/pg/recent_items_pg_repository.rs +++ b/src/infrastructure/repositories/pg/recent_items_pg_repository.rs @@ -31,10 +31,17 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { COALESCE(f.name, fld.name) AS "item_name", f.size AS "item_size", f.mime_type AS "item_mime_type", - COALESCE(f.folder_id::TEXT, fld.parent_id::TEXT) AS "parent_id" + COALESCE(f.folder_id::TEXT, fld.parent_id::TEXT) AS "parent_id", + CASE + WHEN ur.item_type = 'folder' THEN fld.path + WHEN ur.item_type = 'file' THEN COALESCE(pfld.path || '/' || f.name, f.name) + ELSE NULL + END AS "item_path" FROM auth.user_recent_files ur LEFT JOIN storage.files f ON ur.item_type = 'file' AND f.id = ur.item_id::UUID + LEFT JOIN storage.folders pfld ON ur.item_type = 'file' + AND pfld.id = f.folder_id LEFT JOIN storage.folders fld ON ur.item_type = 'folder' AND fld.id = ur.item_id::UUID WHERE ur.user_id = $1 @@ -68,6 +75,7 @@ impl RecentItemsRepositoryPort for RecentItemsPgRepository { item_size: row.try_get("item_size").ok(), item_mime_type: row.try_get("item_mime_type").ok(), parent_id: row.try_get("parent_id").ok(), + item_path: row.try_get("item_path").ok(), // Temporary defaults; with_display_fields() computes the real values icon_class: String::new(), icon_special_class: String::new(), diff --git a/static/css/base/variables.css b/static/css/base/variables.css index 73f73ffa..3cbd8dcd 100644 --- a/static/css/base/variables.css +++ b/static/css/base/variables.css @@ -1,4 +1,6 @@ :root { + --sidebar-width: 250px; + /* Backgrounds */ --color-bg-page: #f5f7fa; --color-bg-surface: #ffffff; diff --git a/static/css/components/pathTooltip.css b/static/css/components/pathTooltip.css new file mode 100644 index 00000000..575c0ff8 --- /dev/null +++ b/static/css/components/pathTooltip.css @@ -0,0 +1,25 @@ +.path-tooltip { + position: fixed; + bottom: 8px; + left: calc(var(--sidebar-width) + 8px); + z-index: 5; + + max-width: 120ch; + padding: 4px 10px; + border-radius: 3px; + + background-color: var(--color-bg-subtle); + border: 1px solid var(--color-border-faint); + color: var(--color-text-muted); + font-size: 0.75rem; + font-family: monospace; + + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + pointer-events: none; + + /* avoid blinking when pointer change items */ + transition: display 0.2s allow-discrete; +} diff --git a/static/css/layout/sidebar.css b/static/css/layout/sidebar.css index daecb3d2..fd8ad252 100644 --- a/static/css/layout/sidebar.css +++ b/static/css/layout/sidebar.css @@ -1,6 +1,6 @@ /* Sidebar */ .sidebar { - width: 250px; + width: var(--sidebar-width); background: linear-gradient(180deg, var(--color-sidebar-bg-from) 0%, var(--color-sidebar-bg-to) 100%); color: var(--color-sidebar-text-active); display: flex; diff --git a/static/index.html b/static/index.html index 22ddc143..3d5037de 100644 --- a/static/index.html +++ b/static/index.html @@ -17,6 +17,7 @@ + diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 3f4b86b0..f8cb49bc 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -20,7 +20,7 @@ import { thumbnail } from '../features/thumbnail.js'; import { sharedView } from '../views/shared/sharedView.js'; import { loadFiles } from './filesView.js'; import { updateHistory } from './main.js'; -import { syncViewContainers } from './navigation.js'; +import { switchToFilesSection, syncViewContainers } from './navigation.js'; import { app } from './state.js'; import { uiFileTypes } from './uiFileTypes.js'; import { uiNotifications } from './uiNotifications.js'; @@ -62,6 +62,7 @@ const ui = { `; document.body.appendChild(folderMenu); + i18n.translateElement(folderMenu); } // File context menu @@ -82,6 +83,9 @@ const ui = {
Download
+
Add to favorites @@ -105,6 +109,7 @@ const ui = {
`; document.body.appendChild(fileMenu); + i18n.translateElement(fileMenu); } // Rename dialog — modern @@ -912,6 +917,12 @@ const ui = { const navigateFolder = (card) => { const folderId = card.dataset.folderId; const folderName = card.dataset.folderName; + if (app.currentSection === 'favorites' || app.currentSection === 'recent') { + switchToFilesSection(); + app.currentPath = folderId; + loadFiles(); + return; + } app.breadcrumbPath.push({ id: folderId, name: folderName }); app.currentPath = folderId; this.updateBreadcrumb(); @@ -1012,6 +1023,9 @@ const ui = { if (contextMenus && typeof contextMenus.syncAddToPlaylistOption === 'function') { contextMenus.syncAddToPlaylistOption(); } + if (contextMenus && typeof contextMenus.syncOpenParentFolderOption === 'function') { + contextMenus.syncOpenParentFolderOption(); + } if (menu) { menu.style.left = `${e.pageX}px`; menu.style.top = `${e.pageY}px`; @@ -1295,6 +1309,7 @@ const ui = { el.dataset.folderId = folder.id; el.dataset.folderName = folder.name; el.dataset.parentId = folder.parent_id || ''; + if (folder.path) el.dataset.path = folder.path; const isFav = favorites?.isFavorite(folder.id, 'folder'); const isShared = sharedView.isShared(folder.id, 'folder'); @@ -1345,6 +1360,7 @@ const ui = { el.dataset.fileId = file.id; el.dataset.fileName = file.name; el.dataset.folderId = file.folder_id || ''; + if (file.path) el.dataset.path = file.path; el.setAttribute('draggable', 'true'); el.innerHTML = ` @@ -1557,6 +1573,9 @@ function showContextMenuAtElement(triggerElement, menuId) { if (contextMenus && typeof contextMenus.syncAddToPlaylistOption === 'function') { contextMenus.syncAddToPlaylistOption(); } + if (contextMenus && typeof contextMenus.syncOpenParentFolderOption === 'function') { + contextMenus.syncOpenParentFolderOption(); + } menu.style.left = `${left}px`; menu.style.top = `${top}px`; diff --git a/static/js/features/files/contextMenus.js b/static/js/features/files/contextMenus.js index f322d365..bd47d5db 100644 --- a/static/js/features/files/contextMenus.js +++ b/static/js/features/files/contextMenus.js @@ -5,6 +5,7 @@ import { resolveHomeFolder } from '../../app/authSession.js'; import { loadFiles } from '../../app/filesView.js'; +import { switchToFilesSection } from '../../app/navigation.js'; import { app } from '../../app/state.js'; import { showConfirmDialog, ui } from '../../app/ui.js'; import { getCsrfHeaders } from '../../core/csrf.js'; @@ -64,6 +65,13 @@ const contextMenus = { } }, + syncOpenParentFolderOption() { + const option = document.getElementById('open-parent-folder-option'); + if (!option) return; + const folderId = app?.contextMenuTargetFile?.folder_id; + option.classList.toggle('hidden', !folderId); + }, + syncAddToPlaylistOption() { const option = document.getElementById('add-to-playlist-option'); if (!option) return; @@ -198,6 +206,16 @@ const contextMenus = { ui.closeFileContextMenu(); }); + document.getElementById('open-parent-folder-option').addEventListener('click', () => { + const folderId = app.contextMenuTargetFile?.folder_id; + ui.closeFileContextMenu(); + if (folderId) { + switchToFilesSection(); + app.currentPath = folderId; + loadFiles(); + } + }); + document.getElementById('favorite-file-option').addEventListener('click', async () => { if (app.contextMenuTargetFile) { const file = app.contextMenuTargetFile; diff --git a/static/js/features/library/favorites.js b/static/js/features/library/favorites.js index 7fb19e63..0979ce39 100644 --- a/static/js/features/library/favorites.js +++ b/static/js/features/library/favorites.js @@ -10,6 +10,7 @@ import { ui } from '../../app/ui.js'; import { getCsrfHeaders } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; import { multiSelect } from '../files/multiSelect.js'; +import * as pathTooltip from '../pathTooltip.js'; const favorites = { /** @type {Map} key = "file:" | "folder:" */ @@ -190,7 +191,8 @@ const favorites = { id: item.item_id, name: item.item_name || item.item_id, parent_id: item.parent_id || '', - modified_at: item.modified_at || item.created_at + modified_at: item.modified_at || item.created_at, + path: item.item_path || '' }); } else { files.push({ @@ -203,12 +205,16 @@ const favorites = { category: item.category, size: item.item_size || 0, size_formatted: item.size_formatted, - modified_at: item.modified_at || item.created_at + modified_at: item.modified_at || item.created_at, + path: item.item_path || '' }); } } if (folders.length) ui.renderFolders(folders); if (files.length) ui.renderFiles(files); + + const filesList = document.getElementById('files-list'); + if (filesList) pathTooltip.init(filesList); } catch (error) { console.error('Error displaying favorites:', error); if (ui?.showNotification) { diff --git a/static/js/features/library/recent.js b/static/js/features/library/recent.js index 833b33a7..ad4b833b 100644 --- a/static/js/features/library/recent.js +++ b/static/js/features/library/recent.js @@ -10,6 +10,7 @@ import { ui } from '../../app/ui.js'; import { getCsrfHeaders } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; import { multiSelect } from '../files/multiSelect.js'; +import * as pathTooltip from '../pathTooltip.js'; const recent = { /** Maximum items to request from the server */ @@ -128,7 +129,8 @@ const recent = { id: item.item_id, name: item.item_name || item.item_id, parent_id: item.parent_id || '', - modified_at: item.accessed_at + modified_at: item.accessed_at, + path: item.item_path || '' }); } else { files.push({ @@ -141,12 +143,14 @@ const recent = { category: item.category, size: item.item_size || 0, size_formatted: item.size_formatted, - modified_at: item.accessed_at + modified_at: item.accessed_at, + path: item.item_path || '' }); } } if (folders.length) ui.renderFolders(folders); if (files.length) ui.renderFiles(files); + if (filesList) pathTooltip.init(filesList); } catch (error) { console.error('Error displaying recent files:', error); if (ui?.showNotification) { diff --git a/static/js/features/pathTooltip.js b/static/js/features/pathTooltip.js new file mode 100644 index 00000000..4af11ab6 --- /dev/null +++ b/static/js/features/pathTooltip.js @@ -0,0 +1,78 @@ +/** + * Path tooltip — shows the full path of a hovered file/folder item + * in an overlay at the bottom-left of the content area. + * + * Usage: call init(container) after rendering items, destroy(container) on teardown. + * Only file-item elements with a data-path attribute trigger the tooltip. + */ + +/** @type {HTMLElement|null} */ +let _tooltip = null; + +function _getOrCreateTooltip() { + if (_tooltip) return _tooltip; + _tooltip = document.getElementById('path-tooltip'); + if (!_tooltip) { + _tooltip = document.createElement('div'); + _tooltip.id = 'path-tooltip'; + _tooltip.className = 'path-tooltip hidden'; + document.querySelector('.main-content')?.appendChild(_tooltip); + } + return _tooltip; +} + +/** + * @param {MouseEvent} e + */ +function _onEnter(e) { + const item = /** @type {HTMLElement} */ (e.currentTarget); + const path = item.dataset.path; + if (!path) return; + + const tooltip = _getOrCreateTooltip(); + tooltip.textContent = path; + tooltip.classList.remove('hidden'); +} + +function _onLeave() { + _tooltip?.classList.add('hidden'); +} + +/** @type {WeakMap} */ +const _listeners = new WeakMap(); + +/** + * Attach path tooltip listeners to all file-item elements inside container. + * @param {HTMLElement} container + */ +function init(container) { + const items = container.querySelectorAll('.file-item[data-path]'); + items.forEach((item) => { + const el = /** @type {HTMLElement} */ (item); + const enter = (e) => _onEnter(e); + const leave = () => _onLeave(); + el.addEventListener('mouseenter', enter); + el.addEventListener('mouseleave', leave); + _listeners.set(el, { enter, leave }); + }); +} + +/** + * Remove path tooltip listeners from all file-item elements inside container. + * @param {HTMLElement} container + */ +function destroy(container) { + const items = container.querySelectorAll('.file-item'); + items.forEach((item) => { + const el = /** @type {HTMLElement} */ (item); + const fns = _listeners.get(el); + if (fns) { + el.removeEventListener('mouseenter', fns.enter); + el.removeEventListener('mouseleave', fns.leave); + _listeners.delete(el); + } + }); + _onLeave(); +} + +export { destroy, init }; diff --git a/static/locales/ar.json b/static/locales/ar.json index a70ef652..9b4a9d1e 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -116,7 +116,8 @@ "search_btn": "بحث", "close": "إغلاق", "delete_permanently": "حذف نهائياً", - "empty_trash": "تفريغ سلة المهملات" + "empty_trash": "تفريغ سلة المهملات", + "open_parent_folder": "الانتقال إلى المجلد الأصلي" }, "user_menu": { "appearance": "المظهر", diff --git a/static/locales/de.json b/static/locales/de.json index e470e34b..d7574ae5 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -116,7 +116,8 @@ "search_btn": "Suchen", "close": "Schließen", "delete_permanently": "Endgültig löschen", - "empty_trash": "Papierkorb leeren" + "empty_trash": "Papierkorb leeren", + "open_parent_folder": "Zum übergeordneten Ordner" }, "user_menu": { "appearance": "Erscheinungsbild", diff --git a/static/locales/en.json b/static/locales/en.json index c70b250c..d39c2aa3 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -116,7 +116,8 @@ "search_btn": "Search", "close": "Close", "delete_permanently": "Delete permanently", - "empty_trash": "Empty trash" + "empty_trash": "Empty trash", + "open_parent_folder": "Go to parent folder" }, "user_menu": { "appearance": "Appearance", diff --git a/static/locales/es.json b/static/locales/es.json index 0c776c09..26d7bab7 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -235,7 +235,8 @@ "search_btn": "Buscar", "close": "Cerrar", "delete_permanently": "Eliminar permanentemente", - "empty_trash": "Vaciar papelera" + "empty_trash": "Vaciar papelera", + "open_parent_folder": "Ir a la carpeta padre" }, "user_menu": { "appearance": "Apariencia", diff --git a/static/locales/fa.json b/static/locales/fa.json index 023bc2d8..0ce76f48 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -116,7 +116,8 @@ "search_btn": "جست‌و‌جو", "close": "بستن", "delete_permanently": "Delete permanently", - "empty_trash": "Empty trash" + "empty_trash": "Empty trash", + "open_parent_folder": "رفتن به پوشه والد" }, "user_menu": { "appearance": "ظاهر", diff --git a/static/locales/fr.json b/static/locales/fr.json index 27ffae81..abfae6ed 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -116,7 +116,8 @@ "search_btn": "Rechercher", "close": "Fermer", "delete_permanently": "Supprimer définitivement", - "empty_trash": "Vider la corbeille" + "empty_trash": "Vider la corbeille", + "open_parent_folder": "Aller au dossier parent" }, "user_menu": { "appearance": "Apparence", diff --git a/static/locales/hi.json b/static/locales/hi.json index 36d79903..360e3e33 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -116,7 +116,8 @@ "search_btn": "खोजें", "close": "बंद करें", "delete_permanently": "स्थायी रूप से हटाएँ", - "empty_trash": "रद्दी खाली करें" + "empty_trash": "रद्दी खाली करें", + "open_parent_folder": "मूल फ़ोल्डर पर जाएं" }, "user_menu": { "appearance": "दिखावट", diff --git a/static/locales/it.json b/static/locales/it.json index 0e64b1d8..f11ff4d0 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -116,7 +116,8 @@ "search_btn": "Cerca", "close": "Chiudi", "delete_permanently": "Elimina definitivamente", - "empty_trash": "Svuota il cestino" + "empty_trash": "Svuota il cestino", + "open_parent_folder": "Vai alla cartella padre" }, "user_menu": { "appearance": "Aspetto", diff --git a/static/locales/ja.json b/static/locales/ja.json index a6406a34..8744589f 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -116,7 +116,8 @@ "search_btn": "検索", "close": "閉じる", "delete_permanently": "完全に削除", - "empty_trash": "ゴミ箱を空にする" + "empty_trash": "ゴミ箱を空にする", + "open_parent_folder": "親フォルダへ移動" }, "user_menu": { "appearance": "外観", diff --git a/static/locales/ko.json b/static/locales/ko.json index c2256ea7..80a3663e 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -116,7 +116,8 @@ "search_btn": "검색", "close": "닫기", "delete_permanently": "영구 삭제", - "empty_trash": "휴지통 비우기" + "empty_trash": "휴지통 비우기", + "open_parent_folder": "상위 폴더로 이동" }, "user_menu": { "appearance": "외관", diff --git a/static/locales/nl.json b/static/locales/nl.json index 68f836cc..8e582422 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -116,7 +116,8 @@ "search_btn": "Zoeken", "close": "Sluiten", "delete_permanently": "Permanent verwijderen", - "empty_trash": "Prullenbak legen" + "empty_trash": "Prullenbak legen", + "open_parent_folder": "Naar bovenliggende map" }, "user_menu": { "appearance": "Weergave", diff --git a/static/locales/pl.json b/static/locales/pl.json index d5b13014..b746fbdc 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -116,7 +116,8 @@ "search_btn": "Szukaj", "close": "Zamknij", "delete_permanently": "Usuń trwale", - "empty_trash": "Opróżnij kosz" + "empty_trash": "Opróżnij kosz", + "open_parent_folder": "Przejdź do folderu nadrzędnego" }, "user_menu": { "appearance": "Wygląd", diff --git a/static/locales/pt.json b/static/locales/pt.json index dc55c3ba..ced7be8e 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -116,7 +116,8 @@ "search_btn": "Pesquisar", "close": "Fechar", "delete_permanently": "Excluir permanentemente", - "empty_trash": "Esvaziar lixeira" + "empty_trash": "Esvaziar lixeira", + "open_parent_folder": "Ir para a pasta pai" }, "user_menu": { "appearance": "Aparência", diff --git a/static/locales/ru.json b/static/locales/ru.json index d14b3185..0524a294 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -116,7 +116,8 @@ "search_btn": "Найти", "close": "Закрыть", "delete_permanently": "Удалить навсегда", - "empty_trash": "Очистить корзину" + "empty_trash": "Очистить корзину", + "open_parent_folder": "Перейти в родительскую папку" }, "user_menu": { "appearance": "Оформление", diff --git a/static/locales/zh.json b/static/locales/zh.json index 9223f058..c8eb1aae 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -116,7 +116,8 @@ "search_btn": "搜索", "close": "关闭", "delete_permanently": "Delete permanently", - "empty_trash": "Empty trash" + "empty_trash": "Empty trash", + "open_parent_folder": "转到父文件夹" }, "user_menu": { "appearance": "外观", From a69dde35cebaf173ffda83106cbf9e984d0c2738 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 7 May 2026 00:42:26 +0200 Subject: [PATCH 2/5] fix(ui): handle any session expired and trigger transparently a refresh token this change replace original window.fetch by a wrapper that check any 401 response, is so it will request a refresh token this solve current issue with Favorites & Recent sections that give blank page when token is expired - exclusion of requests to other domain (401 will not be handled here) - security with shares /api/s is not handled - check with CSRF, no risk --- static/js/app/filesView.js | 4 +- static/js/app/main.js | 4 + static/js/core/fetchWrapper.js | 108 ++++++++++++++++++++++++ static/js/features/library/favorites.js | 7 +- 4 files changed, 115 insertions(+), 8 deletions(-) create mode 100644 static/js/core/fetchWrapper.js diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index 41ff4771..9bc94db5 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -182,8 +182,8 @@ async function loadFiles(options = { insertHistory: true }) { // not required anymore clearTimeout(loadingFiles); - if (response.status === 401 || response.status === 403) { - console.warn('Auth error when loading files, showing empty list'); + if (response.status === 403) { + console.warn('Forbidden when loading files'); // FIXME: i18n ui.showError(`

Could not load files

`); return; diff --git a/static/js/app/main.js b/static/js/app/main.js index 10621899..5a59521f 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -3,6 +3,10 @@ * This file contains the core functionality, initialization and state management */ +import { installFetchInterceptor } from '../core/fetchWrapper.js'; + +installFetchInterceptor(); + import { formatFileSize, formatQuotaSize } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { Modal } from '../core/modal.js'; diff --git a/static/js/core/fetchWrapper.js b/static/js/core/fetchWrapper.js new file mode 100644 index 00000000..13839d33 --- /dev/null +++ b/static/js/core/fetchWrapper.js @@ -0,0 +1,108 @@ +/** + * Global fetch interceptor for transparent 401 → token-refresh → retry. + * + * WHY a global interceptor instead of a per-call wrapper: + * Every authenticated API call in the app needs the same 401 handling. + * Replacing each `fetch(...)` call individually is error-prone (easy to + * miss one) and creates noise across every module. Patching `window.fetch` + * once here means all existing and future calls are covered automatically. + * + * WHY _originalFetch must be used everywhere inside this module: + * `_refresh()` itself calls `/api/auth/refresh`. If it used `window.fetch` + * (the patched version), a 401 on the refresh endpoint would call `_refresh()` + * again, which would call `window.fetch` again — infinite recursion. The same + * applies to the interceptor's own initial call and the retry: they must all + * bypass the interceptor by using the captured `_originalFetch` directly. + * + * WHY /api/auth/ endpoints are excluded from the retry logic: + * login, logout, refresh, and /me are the auth primitives themselves. + * A 401 on these means credentials are genuinely invalid — retrying after + * a refresh makes no sense and would loop. + * + * WHY cross-origin requests bypass the interceptor entirely: + * A 401 from an external service (e.g. a third-party library calling its own + * API) has nothing to do with OxiCloud's session. Attempting a token refresh + * and redirecting to /login in response would be catastrophic. Only same-origin + * requests go through the refresh-and-retry path. + * + * Call `installFetchInterceptor()` once at app startup (before any fetch). + */ + +import { getCsrfHeaders } from './csrf.js'; + +const REFRESH_ENDPOINT = '/api/auth/refresh'; +const USER_DATA_KEY = 'oxicloud_user'; + +/** Captured before patching — the only safe fetch inside this module. */ +let _originalFetch = window.fetch.bind(window); + +/** Deduplicates concurrent refresh attempts into a single in-flight promise. */ +let _refreshInFlight = null; + +async function _refresh() { + if (_refreshInFlight) return _refreshInFlight; + + console.log(`requesting a refresh token`); + + // Must use _originalFetch to avoid re-entering the interceptor. + _refreshInFlight = (async () => { + try { + const r = await _originalFetch(REFRESH_ENDPOINT, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() }, + body: '{}' + }); + return r.ok; + } catch { + return false; + } finally { + _refreshInFlight = null; + } + })(); + + return _refreshInFlight; +} + +function installFetchInterceptor() { + // Capture the real fetch before overwriting it. + _originalFetch = window.fetch.bind(window); + + window.fetch = async (url, options) => { + // Use _originalFetch for the actual network call — NOT window.fetch — + // so this interceptor does not call itself recursively. + const response = await _originalFetch(url, options); + + if (response.status !== 401) return response; + + const urlStr = typeof url === 'string' ? url : url instanceof URL ? url.href : (url.url ?? ''); + + // Cross-origin: a 401 from an external service is none of our business. + // Pass it through untouched so the caller can handle it themselves. + try { + if (new URL(urlStr, window.location.origin).origin !== window.location.origin) { + return response; + } + } catch { + return response; + } + + // Auth endpoints must bypass retry: a 401 on /api/auth/* means the + // credentials themselves are invalid; retrying would cause a loop. + // Public share endpoints (/api/s/) use 401 to mean "password required", + // not "session expired" — intercepting them would wrongly redirect to login. + if (urlStr.includes('/api/auth/') || urlStr.includes('/api/s/')) return response; + + const refreshed = await _refresh(); + if (!refreshed) { + localStorage.removeItem(USER_DATA_KEY); + window.location.href = '/login?source=session_expired'; + throw new Error('Session expired'); + } + + // Retry with _originalFetch for the same reason as above. + return _originalFetch(url, options); + }; +} + +export { installFetchInterceptor }; diff --git a/static/js/features/library/favorites.js b/static/js/features/library/favorites.js index 7fb19e63..4a8281c1 100644 --- a/static/js/features/library/favorites.js +++ b/static/js/features/library/favorites.js @@ -64,7 +64,6 @@ const favorites = { if (!response.ok) { console.warn(`Favorites API returned ${response.status}`); - this._ready = true; return; } @@ -78,7 +77,6 @@ const favorites = { console.log(`Favorites cache loaded: ${this._cache.size} items`); } catch (err) { console.error('Error fetching favorites:', err); - this._ready = true; } }, @@ -161,10 +159,7 @@ const favorites = { */ async displayFavorites() { try { - // Ensure cache is fresh - if (!this._ready) { - await this._fetchFromServer(); - } + await this._fetchFromServer(); ui.resetFilesList(); // ensure also list visible & error hidden // wire buttons & select-all-checkbox as list header has changed in ui.resetFilesList() From b90fa6f6193c09aa8b1eb87afcbfb4258eea33ff Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 7 May 2026 09:30:09 +0200 Subject: [PATCH 3/5] security: prevent re-use of refresh token (reduce surface for any stolen token) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: session hardening Refresh token rotation with theft detection (family_id) - Added family_id column to auth.sessions (migration 20260507000000_session_family.sql) grouping all tokens issued from the same login into a family - On refresh, the new session inherits the parent's family_id - If a revoked token is replayed (indicates the token was stolen after rotation), the entire family is immediately invalidated and a warning is logged — forcing re-authentication on all devices SameSite=Strict on refresh cookie - Access cookie stays SameSite=Lax (needed for top-level navigation) - Refresh cookie upgraded to SameSite=Strict — it is only ever used for explicit POST to /api/auth/refresh, never via cross-site navigation Refresh token TTL: 30 days → 7 days - With rotation, active sessions auto-renew and effectively never expire - Inactive sessions expire after 7 days instead of 30, reducing the theft window --- migrations/20260507000000_session_family.sql | 19 ++++++ src/application/ports/auth_ports.rs | 3 + .../services/auth_application_service.rs | 32 ++++++++-- .../services/device_auth_service.rs | 1 + src/common/config.rs | 6 +- src/domain/entities/session.rs | 11 ++++ src/domain/repositories/session_repository.rs | 3 + .../repositories/pg/session_pg_repository.rs | 62 +++++++++++++++---- src/interfaces/api/cookie_auth.rs | 14 +++-- src/main.rs | 11 ++++ 10 files changed, 135 insertions(+), 27 deletions(-) create mode 100644 migrations/20260507000000_session_family.sql diff --git a/migrations/20260507000000_session_family.sql b/migrations/20260507000000_session_family.sql new file mode 100644 index 00000000..1e90fa34 --- /dev/null +++ b/migrations/20260507000000_session_family.sql @@ -0,0 +1,19 @@ +-- Add token family tracking to sessions. +-- +-- family_id groups all refresh tokens issued from the same original login. +-- When a rotation detects a revoked token being replayed (possible theft), +-- the entire family is invalidated — forcing re-authentication on all devices +-- that shared that login event. +-- +-- Existing sessions are seeded with family_id = id (each is its own family). + +ALTER TABLE auth.sessions + ADD COLUMN family_id UUID; + +UPDATE auth.sessions + SET family_id = id; + +ALTER TABLE auth.sessions + ALTER COLUMN family_id SET NOT NULL; + +CREATE INDEX idx_sessions_family_id ON auth.sessions(family_id); diff --git a/src/application/ports/auth_ports.rs b/src/application/ports/auth_ports.rs index 55ce1549..e652d865 100644 --- a/src/application/ports/auth_ports.rs +++ b/src/application/ports/auth_ports.rs @@ -202,6 +202,9 @@ pub trait SessionStoragePort: Send + Sync + 'static { /// Revokes all sessions of a user async fn revoke_all_user_sessions(&self, user_id: Uuid) -> Result; + + /// Revokes all sessions in a token family (used when replay of a revoked token is detected) + async fn revoke_session_family(&self, family_id: Uuid) -> Result; } // ============================================================================ diff --git a/src/application/services/auth_application_service.rs b/src/application/services/auth_application_service.rs index 8aaf8687..aaecf820 100644 --- a/src/application/services/auth_application_service.rs +++ b/src/application/services/auth_application_service.rs @@ -410,13 +410,14 @@ impl AuthApplicationService { let refresh_token = self.token_service.generate_refresh_token(); - // Save session + // Save session — new login starts a new token family let session = Session::new( user.id(), refresh_token.clone(), None, // IP (can be added from the HTTP layer) None, // User-Agent (can be added from the HTTP layer) self.token_service.refresh_token_expiry_days(), + Uuid::new_v4(), ); self.session_storage.create_session(session).await?; @@ -484,8 +485,25 @@ impl AuthApplicationService { .get_session_by_refresh_token(&dto.refresh_token) .await?; - // Check if the session is expired or revoked - if session.is_expired() || session.is_revoked() { + // Reuse detection: a revoked token being replayed indicates the token was + // stolen after rotation. Invalidate the entire family to protect all devices. + if session.is_revoked() { + tracing::warn!( + user_id = %session.user_id(), + family_id = %session.family_id(), + "Refresh token reuse detected — revoking entire token family" + ); + self.session_storage + .revoke_session_family(session.family_id()) + .await?; + return Err(DomainError::new( + ErrorKind::AccessDenied, + "Auth", + "Session expired or invalid", + )); + } + + if session.is_expired() { return Err(DomainError::new( ErrorKind::AccessDenied, "Auth", @@ -505,21 +523,22 @@ impl AuthApplicationService { )); } - // Revoke current session + // Revoke current session before issuing the next token in the family self.session_storage.revoke_session(session.id()).await?; // Generate new tokens let access_token = self.token_service.generate_access_token(&user)?; - let new_refresh_token = self.token_service.generate_refresh_token(); - // Create new session + // New session inherits the family_id so reuse of any ancestor triggers + // full-family revocation let new_session = Session::new( user.id(), new_refresh_token.clone(), None, None, self.token_service.refresh_token_expiry_days(), + session.family_id(), ); self.session_storage.create_session(new_session).await?; @@ -1245,6 +1264,7 @@ impl AuthApplicationService { None, None, self.token_service.refresh_token_expiry_days(), + Uuid::new_v4(), ); self.session_storage.create_session(session).await?; diff --git a/src/application/services/device_auth_service.rs b/src/application/services/device_auth_service.rs index aa047071..1553ca75 100644 --- a/src/application/services/device_auth_service.rs +++ b/src/application/services/device_auth_service.rs @@ -186,6 +186,7 @@ impl DeviceAuthService { None, // ip_address Some(format!("device:{}", dc.client_name())), // user_agent self.token_service.refresh_token_expiry_days(), + Uuid::new_v4(), ); self.session_storage.create_session(session).await?; diff --git a/src/common/config.rs b/src/common/config.rs index dc2a43d7..428ef70c 100644 --- a/src/common/config.rs +++ b/src/common/config.rs @@ -450,9 +450,9 @@ impl Default for AuthConfig { // to set OXICLOUD_JWT_SECRET in production. The from_env() method // will validate this and warn/panic if not configured. jwt_secret: String::new(), - access_token_expiry_secs: 3600, // 1 hour - refresh_token_expiry_secs: 2592000, // 30 days - hash_memory_cost: 65536, // 64 MiB + access_token_expiry_secs: 3600, // 1 hour + refresh_token_expiry_secs: 604800, // 7 days — with rotation, active sessions auto-renew + hash_memory_cost: 65536, // 64 MiB hash_time_cost: 3, hash_parallelism: 2, rate_limit: RateLimitConfig::default(), diff --git a/src/domain/entities/session.rs b/src/domain/entities/session.rs index 17e33be0..fefa05d3 100644 --- a/src/domain/entities/session.rs +++ b/src/domain/entities/session.rs @@ -11,6 +11,9 @@ pub struct Session { user_agent: Option, created_at: DateTime, revoked: bool, + /// Groups all tokens issued from the same original login. + /// Replaying a revoked token from this family triggers full-family revocation. + family_id: Uuid, } impl Session { @@ -20,6 +23,7 @@ impl Session { ip_address: Option, user_agent: Option, expires_in_days: i64, + family_id: Uuid, ) -> Self { if refresh_token.is_empty() { panic!("Session refresh_token cannot be empty"); @@ -35,6 +39,7 @@ impl Session { user_agent, created_at: now, revoked: false, + family_id, } } @@ -48,6 +53,7 @@ impl Session { user_agent: Option, created_at: DateTime, revoked: bool, + family_id: Uuid, ) -> Self { Self { id, @@ -58,6 +64,7 @@ impl Session { user_agent, created_at, revoked, + family_id, } } @@ -101,4 +108,8 @@ impl Session { pub fn revoke(&mut self) { self.revoked = true; } + + pub fn family_id(&self) -> Uuid { + self.family_id + } } diff --git a/src/domain/repositories/session_repository.rs b/src/domain/repositories/session_repository.rs index 84966d74..4ca17c84 100644 --- a/src/domain/repositories/session_repository.rs +++ b/src/domain/repositories/session_repository.rs @@ -52,6 +52,9 @@ pub trait SessionRepository: Send + Sync + 'static { /// Revokes all sessions for a user async fn revoke_all_user_sessions(&self, user_id: Uuid) -> SessionRepositoryResult; + /// Revokes all sessions in a token family (theft response) + async fn revoke_session_family(&self, family_id: Uuid) -> SessionRepositoryResult; + /// Deletes expired sessions async fn delete_expired_sessions(&self) -> SessionRepositoryResult; } diff --git a/src/infrastructure/repositories/pg/session_pg_repository.rs b/src/infrastructure/repositories/pg/session_pg_repository.rs index 2bd45e8d..65676be6 100644 --- a/src/infrastructure/repositories/pg/session_pg_repository.rs +++ b/src/infrastructure/repositories/pg/session_pg_repository.rs @@ -51,10 +51,10 @@ impl SessionRepository for SessionPgRepository { sqlx::query( r#" INSERT INTO auth.sessions ( - id, user_id, refresh_token, expires_at, - ip_address, user_agent, created_at, revoked + id, user_id, refresh_token, expires_at, + ip_address, user_agent, created_at, revoked, family_id ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8 + $1, $2, $3, $4, $5, $6, $7, $8, $9 ) "#, ) @@ -66,6 +66,7 @@ impl SessionRepository for SessionPgRepository { .bind(session_clone.user_agent()) .bind(session_clone.created_at()) .bind(session_clone.is_revoked()) + .bind(session_clone.family_id()) .execute(&mut **tx) .await .map_err(Self::map_sqlx_error)?; @@ -108,9 +109,9 @@ impl SessionRepository for SessionPgRepository { async fn get_session_by_id(&self, id: Uuid) -> SessionRepositoryResult { let row = sqlx::query( r#" - SELECT - id, user_id, refresh_token, expires_at, - ip_address, user_agent, created_at, revoked + SELECT + id, user_id, refresh_token, expires_at, + ip_address, user_agent, created_at, revoked, family_id FROM auth.sessions WHERE id = $1 "#, @@ -129,19 +130,21 @@ impl SessionRepository for SessionPgRepository { row.get("user_agent"), row.get("created_at"), row.get("revoked"), + row.get("family_id"), )) } - /// Gets a session by refresh token + /// Gets a session by refresh token — returns revoked sessions too so the + /// application layer can distinguish "not found" from "replayed revoked token". async fn get_session_by_refresh_token( &self, refresh_token: &str, ) -> SessionRepositoryResult { let row = sqlx::query( r#" - SELECT - id, user_id, refresh_token, expires_at, - ip_address, user_agent, created_at, revoked + SELECT + id, user_id, refresh_token, expires_at, + ip_address, user_agent, created_at, revoked, family_id FROM auth.sessions WHERE refresh_token = $1 "#, @@ -160,6 +163,7 @@ impl SessionRepository for SessionPgRepository { row.get("user_agent"), row.get("created_at"), row.get("revoked"), + row.get("family_id"), )) } @@ -170,9 +174,9 @@ impl SessionRepository for SessionPgRepository { ) -> SessionRepositoryResult> { let rows = sqlx::query( r#" - SELECT - id, user_id, refresh_token, expires_at, - ip_address, user_agent, created_at, revoked + SELECT + id, user_id, refresh_token, expires_at, + ip_address, user_agent, created_at, revoked, family_id FROM auth.sessions WHERE user_id = $1 ORDER BY created_at DESC @@ -195,6 +199,7 @@ impl SessionRepository for SessionPgRepository { row.get("user_agent"), row.get("created_at"), row.get("revoked"), + row.get("family_id"), ) }) .collect(); @@ -270,6 +275,31 @@ impl SessionRepository for SessionPgRepository { .await } + /// Revokes all sessions in a token family (theft response) + async fn revoke_session_family(&self, family_id: Uuid) -> SessionRepositoryResult { + let result = sqlx::query( + r#" + UPDATE auth.sessions + SET revoked = true + WHERE family_id = $1 AND revoked = false + "#, + ) + .bind(family_id) + .execute(&*self.pool) + .await + .map_err(Self::map_sqlx_error)?; + + let affected = result.rows_affected(); + if affected > 0 { + tracing::warn!( + "Token reuse detected: revoked {} session(s) in family {}", + affected, + family_id + ); + } + Ok(affected) + } + /// Deletes expired sessions async fn delete_expired_sessions(&self) -> SessionRepositoryResult { let now = Utc::now(); @@ -317,4 +347,10 @@ impl SessionStoragePort for SessionPgRepository { .await .map_err(DomainError::from) } + + async fn revoke_session_family(&self, family_id: Uuid) -> Result { + SessionRepository::revoke_session_family(self, family_id) + .await + .map_err(DomainError::from) + } } diff --git a/src/interfaces/api/cookie_auth.rs b/src/interfaces/api/cookie_auth.rs index 63fd9d66..950e9d21 100644 --- a/src/interfaces/api/cookie_auth.rs +++ b/src/interfaces/api/cookie_auth.rs @@ -43,7 +43,7 @@ fn cookie_secure() -> bool { let secure = v == "true" || v == "1"; if !secure { tracing::warn!( - "OXICLOUD_COOKIE_SECURE is explicitly disabled — \ + "⚠️ SECURITY: OXICLOUD_COOKIE_SECURE is explicitly disabled — \ cookies will be sent over plain HTTP. \ Do NOT use this in production." ); @@ -55,7 +55,7 @@ fn cookie_secure() -> bool { Ok(url) if url.starts_with("https") => true, Ok(url) if url.starts_with("http://") => { tracing::info!( - "OXICLOUD_BASE_URL is HTTP — cookie Secure flag is OFF. \ + "⚠️ SECURITY: OXICLOUD_BASE_URL is HTTP — cookie Secure flag is OFF. \ Set OXICLOUD_COOKIE_SECURE=true to override if your proxy terminates TLS." ); false @@ -63,7 +63,7 @@ fn cookie_secure() -> bool { _ => { // Default to false for compatibility with HTTP deployments tracing::info!( - "OXICLOUD_BASE_URL not set — defaulting to non-secure cookies \ + "⚠️ SECURITY: OXICLOUD_BASE_URL not set — defaulting to non-secure cookies \ for HTTP compatibility. Set OXICLOUD_COOKIE_SECURE=true for HTTPS deployments." ); false @@ -72,9 +72,11 @@ fn cookie_secure() -> bool { } /// Build a `Set-Cookie` header value. -fn build_cookie(name: &str, value: &str, path: &str, max_age_secs: i64) -> String { +fn build_cookie(name: &str, value: &str, path: &str, max_age_secs: i64, same_site: &str) -> String { let secure = if cookie_secure() { "; Secure" } else { "" }; - format!("{name}={value}; HttpOnly; SameSite=Lax; Path={path}; Max-Age={max_age_secs}{secure}",) + format!( + "{name}={value}; HttpOnly; SameSite={same_site}; Path={path}; Max-Age={max_age_secs}{secure}", + ) } /// Append `Set-Cookie` headers for both access and refresh tokens. @@ -96,6 +98,7 @@ pub fn append_auth_cookies( access_token, "/", access_expiry_secs, + "Lax", // Lax: cookie is sent on top-level navigations (links from other sites) )) { headers.append(SET_COOKIE, val); } @@ -104,6 +107,7 @@ pub fn append_auth_cookies( refresh_token, "/api/auth", refresh_expiry_secs, + "Strict", // Strict: refresh endpoint is never reached via cross-site navigation )) { headers.append(SET_COOKIE, val); } diff --git a/src/main.rs b/src/main.rs index 38206a5d..155d1fec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -468,6 +468,17 @@ async fn main() -> Result<(), Box> { HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), )); + // Warn once at startup if auth cookies are not Secure. + // HttpOnly + SameSite protection is nullified over plain HTTP because tokens + // travel in cleartext and can be intercepted by a network observer. + if !crate::interfaces::api::cookie_auth::is_cookie_secure() { + tracing::warn!( + "⚠️ SECURITY: auth cookies are NOT marked Secure. \ + Tokens will be transmitted in plaintext over HTTP. \ + Set OXICLOUD_COOKIE_SECURE=true for any HTTPS deployment." + ); + } + // Start server — tuned socket for low-latency responses let addr = SocketAddr::from(([0, 0, 0, 0], config.server_port)); tracing::info!("Starting OxiCloud server on http://{}", addr); From 93d9a210132c8b3e490d5c6821fb9ea0ae212036 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Thu, 7 May 2026 13:54:59 +0200 Subject: [PATCH 4/5] fix(ui): admin panel, profile: restore missing icons library --- static/js/app/main.js | 3 ++ static/js/core/icons.js | 28 +++++++++---------- static/js/views/admin/admin.js | 2 ++ .../js/views/device-verify/device-verify.js | 3 ++ static/js/views/profile/profile.js | 2 ++ static/js/views/public/publicShare.js | 4 +++ tools/check-icons.py | 18 ++++++------ 7 files changed, 37 insertions(+), 23 deletions(-) diff --git a/static/js/app/main.js b/static/js/app/main.js index 10621899..30ebdcb5 100644 --- a/static/js/app/main.js +++ b/static/js/app/main.js @@ -5,6 +5,7 @@ import { formatFileSize, formatQuotaSize } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; +import { oxiIconsInit } from '../core/icons.js'; import { Modal } from '../core/modal.js'; import { fileOps } from '../features/files/fileOperations.js'; import { multiSelect } from '../features/files/multiSelect.js'; @@ -334,6 +335,8 @@ function switchSectionTo(section) { * Initialize the application */ function initApp() { + oxiIconsInit(); + // Cache DOM elements cacheElements(); diff --git a/static/js/core/icons.js b/static/js/core/icons.js index ecdc6470..0d4c3ea2 100644 --- a/static/js/core/icons.js +++ b/static/js/core/icons.js @@ -15,7 +15,7 @@ // All icons use viewBox="0 0 {width} 512" and fill="currentColor". // Keys use FA5 class names (without "fa-" prefix) for backward compatibility. -const _ICONS = { +const OxiIcons = { 'arrow-left': [ 448, 'M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l160 160c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L109.2 288 416 288c17.7 0 32-14.3 32-32s-14.3-32-32-32l-306.7 0L214.6 118.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-160 160z' @@ -430,7 +430,7 @@ const _ICONS = { * @returns {string} SVG markup string, or empty string if icon not found */ function oxiIcon(name, extraClass) { - const entry = _ICONS[name]; + const entry = OxiIcons[name]; if (!entry) return ''; const [w, d] = entry; const cls = extraClass ? `oxi-icon ${extraClass}` : 'oxi-icon'; @@ -477,13 +477,13 @@ function replaceIconsInElement(container) { } // Use outline variant if available and element uses "far" - if (isRegular && _ICONS[`${iconName}-outline`]) { + if (isRegular && OxiIcons[`${iconName}-outline`]) { iconName = `${iconName}-outline`; } - if (!iconName || !_ICONS[iconName]) continue; + if (!iconName || !OxiIcons[iconName]) continue; - const [w, d] = _ICONS[iconName]; + const [w, d] = OxiIcons[iconName]; // Build SVG element const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); @@ -510,14 +510,7 @@ function replaceIconsInElement(container) { } } -// ── Expose globally ──────────────────────────────────────────── -export { oxiIcon, replaceIconsInElement }; -export const OxiIcons = _ICONS; - -// ── Auto-replace: MutationObserver bridge ────────────────────── -// Watches the DOM for new elements and converts them -// to inline SVGs automatically. Debounced to at most once per frame. -(function autoReplace() { +function oxiIconsInit() { let raf = 0; const scan = () => { raf = 0; @@ -541,4 +534,11 @@ export const OxiIcons = _ICONS; } } }).observe(document.documentElement, { childList: true, subtree: true }); -})(); +} + +// ── Expose globally ──────────────────────────────────────────── +export { OxiIcons, oxiIcon, oxiIconsInit, replaceIconsInElement }; + +// ── Auto-replace: MutationObserver bridge ────────────────────── +// Watches the DOM for new elements and converts them +// to inline SVGs automatically. Debounced to at most once per frame. diff --git a/static/js/views/admin/admin.js b/static/js/views/admin/admin.js index 739927e5..49e487d2 100644 --- a/static/js/views/admin/admin.js +++ b/static/js/views/admin/admin.js @@ -1,6 +1,7 @@ import { getCsrfHeaders } from '../../core/csrf.js'; import { escapeHtml } from '../../core/formatters.js'; import { i18n } from '../../core/i18n.js'; +import { oxiIconsInit } from '../../core/icons.js'; const API = '/api'; let currentAdminId = ''; @@ -1041,6 +1042,7 @@ async function completeMigration() { async function init() { try { + oxiIconsInit(); const me = await fetch(`${API}/auth/me`, { headers: headers(), credentials: 'same-origin' diff --git a/static/js/views/device-verify/device-verify.js b/static/js/views/device-verify/device-verify.js index 3e0eb523..702c49c5 100644 --- a/static/js/views/device-verify/device-verify.js +++ b/static/js/views/device-verify/device-verify.js @@ -1,5 +1,6 @@ // device-verify.js — Extracted from inline