From 8365608bd58d92d6d8d431457fb702c04c59590b Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 25 May 2026 22:34:45 +0200 Subject: [PATCH] feat(list view): show the owner of the File or Folder in list view --- src/application/dtos/favorites_dto.rs | 5 + .../pg/favorites_pg_repository.rs | 4 +- static/css/base/variables.css | 8 ++ static/css/components/filesView.css | 25 ++++- static/css/components/shareModal.css | 28 +----- static/css/components/userVignette.css | 97 +++++++++++++++++++ static/css/main.css | 1 + static/js/app/filesView.js | 1 + static/js/app/navigation.js | 12 +++ static/js/app/ui.js | 52 ++++++++++ static/js/components/shareModal.js | 37 ++++--- static/js/components/userVignette.js | 85 ++++++++++++++++ static/js/core/types.js | 1 + static/js/features/library/favorites.js | 8 +- static/js/model/systemUsers.js | 17 ++++ .../js/views/sharedWithMe/sharedWithMeView.js | 3 + 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-TW.json | 3 +- static/locales/zh.json | 3 +- 32 files changed, 365 insertions(+), 67 deletions(-) create mode 100644 static/css/components/userVignette.css create mode 100644 static/js/components/userVignette.js diff --git a/src/application/dtos/favorites_dto.rs b/src/application/dtos/favorites_dto.rs index 545cef0a..9a6f4234 100644 --- a/src/application/dtos/favorites_dto.rs +++ b/src/application/dtos/favorites_dto.rs @@ -51,6 +51,11 @@ pub struct FavoriteItemDto { #[serde(skip_serializing_if = "Option::is_none")] pub item_path: Option, + /// UUID of the file/folder's actual owner (may differ from `user_id` when + /// the item was shared and then favourited by another user). + #[serde(skip_serializing_if = "Option::is_none")] + pub owner_id: 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 b705810e..220c9fdd 100644 --- a/src/infrastructure/repositories/pg/favorites_pg_repository.rs +++ b/src/infrastructure/repositories/pg/favorites_pg_repository.rs @@ -38,7 +38,8 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { 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" + END AS "item_path", + COALESCE(f.user_id, fld.user_id)::TEXT AS "owner_id" FROM auth.user_favorites uf LEFT JOIN storage.files f ON uf.item_type = 'file' AND f.id = uf.item_id::UUID @@ -78,6 +79,7 @@ impl FavoritesRepositoryPort for FavoritesPgRepository { parent_id: row.try_get("parent_id").ok(), modified_at: row.try_get("modified_at").ok(), item_path: row.try_get("item_path").ok(), + owner_id: row.try_get("owner_id").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 de55355f..212a5fce 100644 --- a/static/css/base/variables.css +++ b/static/css/base/variables.css @@ -388,6 +388,12 @@ --color-badge-success-fill: #047857; --color-badge-success-fill-dark: #064e27; --color-badge-success-fill-faint: #f0fdf4; + --color-badge-green-bg: #ecfdf5; + --color-badge-green-text: #065f46; + + /* Status badge — light mode (orange/coral) */ + --color-badge-orange-bg: #fff5f3; + --color-badge-orange-text: #ff5e3a; /* Status badge — light mode (error/red) */ --color-badge-error-border: #fecaca; @@ -395,6 +401,8 @@ /* Status badge — light mode (warning/amber) */ --color-badge-warning-text: #92400e; --color-badge-warning-border: #fde68a; + --color-badge-amber-bg: #fef3c7; + --color-badge-amber-text: #f59e0b; /* Status badge — light mode (indigo/purple) */ --color-badge-indigo-bg: #ede9fe; diff --git a/static/css/components/filesView.css b/static/css/components/filesView.css index 72472450..3a129f6d 100644 --- a/static/css/components/filesView.css +++ b/static/css/components/filesView.css @@ -100,12 +100,32 @@ min-width: 0; } -.list-header > div:nth-child(4), +/* Size column: always nth-child(5) because .owner-cell is always in the DOM + (even when hidden via display:none, it still occupies a child slot). */ +.list-header > div:nth-child(5), .files-list-view .file-item .size-cell { justify-self: end; text-align: right; } +/* ── Owner column ─────────────────────────────────────────── */ + +/* Styles applied whenever the cell is visible (hidden class absent). + The .hidden utility class (display:none !important) keeps it invisible + by default — it is stamped directly in the HTML templates. */ +.owner-cell { + color: var(--color-text-secondary); + font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Expand the grid track as soon as at least one owner cell is visible. */ +.files-list-view:has(.owner-cell:not(.hidden)) { + --files-list-columns: 36px minmax(200px, 2fr) 120px 100px 110px 130px 72px; +} + .files-list-view { --files-list-columns: 36px minmax(200px, 2fr) 100px 110px 130px 72px; display: flex; @@ -271,7 +291,8 @@ /* element hidden on grid view */ .files-grid-view .file-item .date-cell, -.files-grid-view .file-item .size-cell { +.files-grid-view .file-item .size-cell, +.files-grid-view .file-item .owner-cell { display: none; } /* Selection checkbox */ diff --git a/static/css/components/shareModal.css b/static/css/components/shareModal.css index d219a84a..af314901 100644 --- a/static/css/components/shareModal.css +++ b/static/css/components/shareModal.css @@ -334,32 +334,8 @@ color: var(--color-error-text); } -/* ── Avatar colour palette (cycled by memberIndex % 5) ──────────────────────── */ - -.smd-avatar--0 { - background: var(--color-badge-indigo-bg); - color: var(--color-badge-indigo-text); -} - -.smd-avatar--1 { - background: var(--color-badge-success-bg); - color: var(--color-badge-success-text); -} - -.smd-avatar--2 { - background: var(--color-accent-tint); - color: var(--color-accent); -} - -.smd-avatar--3 { - background: var(--color-badge-blue-bg); - color: var(--color-badge-blue-text); -} - -.smd-avatar--4 { - background: var(--color-warning-bg-light); - color: var(--color-warning-text-amber); -} +/* ── Avatar colour palette ───────────────────────────────────────────────────── */ +/* Colours are provided by .uv-color-0..4 in userVignette.css (shared palette). */ /* ── Fallback when user-directory is unavailable ────────────────────────────── */ diff --git a/static/css/components/userVignette.css b/static/css/components/userVignette.css new file mode 100644 index 00000000..af0e7f1c --- /dev/null +++ b/static/css/components/userVignette.css @@ -0,0 +1,97 @@ +/* ── User Vignette — avatar circle + name inline component ──────────────────── + * + * Reusable component that pairs a coloured initials circle with a display + * name resolved asynchronously. Used in: + * • Owner column (list view, SharedWithMe & Favorites sections) + * • ShareModal member rows, chips, suggestion items + * + * Sizes: --xs (20 px) · --sm (24 px) · --md (32 px) · --lg (40 px) + * Colours: .uv-color-0..4 (applied by JS via _colorIndex(userId) % 5) + * + * All colours use CSS custom properties — no raw hex / rgb / named values. + * ─────────────────────────────────────────────────────────────────────────── */ + +.user-vignette { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + overflow: hidden; +} + +.user-vignette__avatar { + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + flex-shrink: 0; + /* Default size = --sm; overridden by size modifier below */ + width: 24px; + height: 24px; + font-size: 10px; +} + +.user-vignette__name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + color: var(--color-text-secondary); +} + +/* ── Size variants ─────────────────────────────────────────────────────────── */ + +.user-vignette--xs .user-vignette__avatar { + width: 20px; + height: 20px; + font-size: 9px; +} + +.user-vignette--sm .user-vignette__avatar { + width: 24px; + height: 24px; + font-size: 10px; +} + +.user-vignette--md .user-vignette__avatar { + width: 32px; + height: 32px; + font-size: 12px; +} + +.user-vignette--lg .user-vignette__avatar { + width: 40px; + height: 40px; + font-size: 15px; +} + +/* ── Colour palette ────────────────────────────────────────────────────────── */ + +/* Colours are shared with the ShareModal avatar palette. + Each class maps to a distinct hue via design-token variables. */ + +.uv-color-0 { + background: var(--color-badge-indigo-bg); + color: var(--color-badge-indigo-text); +} + +.uv-color-1 { + background: var(--color-badge-green-bg); + color: var(--color-badge-green-text); +} + +.uv-color-2 { + background: var(--color-badge-orange-bg); + color: var(--color-badge-orange-text); +} + +.uv-color-3 { + background: var(--color-badge-blue-bg); + color: var(--color-badge-blue-text); +} + +.uv-color-4 { + background: var(--color-badge-amber-bg); + color: var(--color-badge-amber-text); +} diff --git a/static/css/main.css b/static/css/main.css index eb2cd53e..de5d36bc 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -19,6 +19,7 @@ @import url("./components/modals.css"); @import url("./components/shareDialog.css"); @import url("./components/shareModal.css"); +@import url("./components/userVignette.css"); @import url("./components/uploadDropdown.css"); @import url("./components/notifications.css"); @import url("./components/userMenu.css"); diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index 317ee069..ddff0609 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -233,6 +233,7 @@ async function loadFiles(options = { insertHistory: true }) { } else { ui.renderFolders(folderList); ui.renderFiles(fileList); + ui.resolveOwnerCells(); // check if a file was provided if (app.viewFile) { diff --git a/static/js/app/navigation.js b/static/js/app/navigation.js index df0e2002..08ac0f33 100644 --- a/static/js/app/navigation.js +++ b/static/js/app/navigation.js @@ -164,6 +164,9 @@ function setCurrentSection(section) { sharedWithMeView.hide(); } + // Reset owner column — sections that need it re-enable it explicitly below. + ui.setOwnerColumnVisible(false); + // Hide photosView when switching to any other section if (section !== 'photos' && photosView) { photosView.hide(); @@ -211,6 +214,9 @@ function switchToSharedWithMeSection() { // Show actions-bar with view toggle (no upload / new-folder in this view) setActionsBarMode('sharedwithme'); + // Show the Owner column — names are resolved async after render. + ui.setOwnerColumnVisible(true); + // Show the standard files container and respect grid/list preference toggleFileContainer(true); syncViewContainers(); @@ -227,6 +233,9 @@ function switchToFilesSection() { // Set actions bar mode setActionsBarMode('files', true); + // Show owner column in the Files section + ui.setOwnerColumnVisible(true); + // Show breadcrumb (only in Files view) const breadcrumb = document.querySelector('.breadcrumb'); breadcrumb?.classList.remove('hidden'); @@ -258,6 +267,9 @@ function switchToFavoritesSection() { // Set actions bar mode setActionsBarMode('favorites'); + // Show the Owner column — names are resolved async after render. + ui.setOwnerColumnVisible(true); + // Hide breadcrumb (only shown in Files view) const breadcrumb = document.querySelector('.breadcrumb'); breadcrumb?.classList.add('hidden'); diff --git a/static/js/app/ui.js b/static/js/app/ui.js index d965502c..cb9edce0 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -6,6 +6,7 @@ // @ts-check import { shareModal } from '../components/shareModal.js'; +import { createUserVignette } from '../components/userVignette.js'; import { escapeHtml, formatDateTime, formatFileSize } from '../core/formatters.js'; import { i18n } from '../core/i18n.js'; import { OxiIcons } from '../core/icons.js'; @@ -18,6 +19,7 @@ import { favorites } from '../features/library/favorites.js'; import { recent } from '../features/library/recent.js'; import { thumbnail } from '../features/thumbnail.js'; import { grants } from '../model/grants.js'; +import { systemUsers } from '../model/systemUsers.js'; import { loadFiles } from './filesView.js'; import { updateHistory } from './main.js'; import { activateFilesUI, switchToFilesSection, syncViewContainers } from './navigation.js'; @@ -37,6 +39,12 @@ const ui = { /** @type {HTMLDivElement | null} */ draggedItems: null, + /** + * Whether the Owner column is currently visible. + * Tracked so that newly rendered items can stamp the correct initial class. + */ + _ownerVisible: false, + /** * Initialize context menus and dialogs */ @@ -436,6 +444,47 @@ const ui = { syncViewContainers(); }, + /** + * Show or hide the Owner column. When hidden, no name-resolution calls are made. + * Sections that show owner (SharedWithMe, Favorites) pass `true`; all others `false`. + * @param {boolean} visible + */ + setOwnerColumnVisible(visible) { + this._ownerVisible = visible; + document + .getElementById('files-list') + ?.querySelectorAll('.owner-cell') + .forEach((cell) => { + cell.classList.toggle('hidden', !visible); + }); + }, + + /** + * Asynchronously fill every un-resolved `.owner-cell` in the current list with + * the display name for its `data-owner-id` attribute. + * + * Call this after `renderFiles()` / `renderFolders()` in sections where the owner + * column is visible. Idempotent: cells already stamped with `data-owner-resolved` + * are skipped (safe to call on each "Load more" page append). + * + * When the column is hidden nothing calls this function, so `systemUsers` is never + * touched and no address-book requests are issued. + * + * @returns {Promise} + */ + async resolveOwnerCells() { + const filesList = document.getElementById('files-list'); + const cells = /** @type {NodeListOf} */ (filesList?.querySelectorAll('.owner-cell[data-owner-id]:not([data-owner-resolved])')); + if (!cells?.length) return; + systemUsers.prefetch(); // warm cache once (idempotent, fire-and-forget) + for (const cell of cells) { + const id = cell.dataset.ownerId; + cell.dataset.ownerResolved = '1'; + if (!id) continue; + cell.replaceChildren(createUserVignette(id, 'sm')); + } + }, + /** * Update breadcrumb navigation from the breadcrumbPath array. * Renders: Home > folder1 > folder2 > ... @@ -1236,6 +1285,7 @@ const ui = {
+
${i18n.t('files.file_types.folder')}
--
${formattedDate}
@@ -1290,6 +1340,7 @@ const ui = {
+
${typeLabel}
${fileSize}
${formattedDate}
@@ -1329,6 +1380,7 @@ const ui = {
Name
+
Owner
Type
Size
Modified
diff --git a/static/js/components/shareModal.js b/static/js/components/shareModal.js index 1662dfae..971ad123 100644 --- a/static/js/components/shareModal.js +++ b/static/js/components/shareModal.js @@ -21,6 +21,7 @@ import { addressBook, SYSTEM_BOOK_ID } from '../model/addressBook.js'; import { grants } from '../model/grants.js'; import { systemUsers } from '../model/systemUsers.js'; import { Modal } from './modal.js'; +import { _colorIndex, _initials } from './userVignette.js'; /** @import {FileItem, FolderItem, Grant, ContactItem, MemberEntry, LinkEntry, DraftLink, ShareRoleEnum} from '../core/types.js' */ @@ -73,17 +74,6 @@ function _buildMembers(grantList) { return members; } -/** - * Get initials for an avatar (up to 2 chars). - * @param {string} name - * @returns {string} - */ -function _initials(name) { - const parts = name.trim().split(/\s+/); - if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); - return name.slice(0, 2).toUpperCase(); -} - // ── Component ────────────────────────────────────────────────────────────────── const shareModal = { @@ -291,7 +281,16 @@ const shareModal = { } debounce = setTimeout(async () => { const results = await addressBook.searchContacts(q, [SYSTEM_BOOK_ID]); - this._renderSuggestions(dropdown, results.slice(0, 8), (contact) => { + // Filter out the currently logged-in user — they cannot share with themselves + const currentUserId = (() => { + try { + return /** @type {{id?:string}} */ (JSON.parse(localStorage.getItem('oxicloud_user') ?? '{}'))?.id ?? null; + } catch { + return null; + } + })(); + const filtered = currentUserId ? results.filter((c) => c.id !== currentUserId) : results; + this._renderSuggestions(dropdown, filtered.slice(0, 8), (contact) => { this._stageUser(contact, input, dropdown, addBtn); }); }, 200); @@ -332,13 +331,13 @@ const shareModal = { container.classList.add('hidden'); return; } - results.forEach((c, i) => { + results.forEach((c) => { const item = document.createElement('div'); item.className = 'smd-suggestion-item'; item.tabIndex = 0; const avatar = document.createElement('div'); - avatar.className = `smd-suggestion-avatar smd-avatar--${i % 5}`; + avatar.className = `smd-suggestion-avatar uv-color-${_colorIndex(c.id)}`; const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8); avatar.textContent = _initials(displayName); @@ -411,12 +410,12 @@ const shareModal = { */ _renderChipsInto(container) { container.replaceChildren(); - this._stagedUsers.forEach((c, i) => { + this._stagedUsers.forEach((c) => { const chip = document.createElement('div'); chip.className = 'smd-chip'; const avatar = document.createElement('div'); - avatar.className = `smd-chip-avatar smd-avatar--${i % 5}`; + avatar.className = `smd-chip-avatar uv-color-${_colorIndex(c.id)}`; const displayName = [c.first_name, c.last_name].filter(Boolean).join(' ') || c.full_name || c.id.slice(0, 8); avatar.textContent = _initials(displayName); @@ -516,15 +515,15 @@ const shareModal = { /** * @param {MemberEntry} entry - * @param {number} idx + * @param {number} _idx (unused — color is now derived deterministically from userId) * @returns {HTMLElement} */ - _buildMemberRow(entry, idx) { + _buildMemberRow(entry, _idx) { const row = document.createElement('div'); row.className = 'smd-member-row'; const avatar = document.createElement('div'); - avatar.className = `smd-member-avatar smd-avatar--${idx % 5}`; + avatar.className = `smd-member-avatar uv-color-${_colorIndex(entry.grant.subject.id)}`; // Resolve display name async systemUsers.getDisplayName(entry.grant.subject.id).then((name) => { diff --git a/static/js/components/userVignette.js b/static/js/components/userVignette.js new file mode 100644 index 00000000..5ea47462 --- /dev/null +++ b/static/js/components/userVignette.js @@ -0,0 +1,85 @@ +// @ts-check + +/** + * UserVignette — reusable user avatar + name inline component. + * + * Renders a coloured circle with initials (or photo when available) alongside + * an asynchronously-resolved display name. Used in: + * • Owner column (list view) via `ui.resolveOwnerCells()` + * • ShareModal member rows / chips / suggestion items + * + * Usage: + * import { createUserVignette } from './userVignette.js'; + * cell.replaceChildren(createUserVignette(userId, 'sm')); + */ + +import { systemUsers } from '../model/systemUsers.js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** + * Get initials for an avatar (1-2 characters). + * @param {string} name + * @returns {string} + */ +export function _initials(name) { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + return name.slice(0, 2).toUpperCase(); +} + +/** + * Deterministic color index 0-4 derived from a userId string. + * Same userId always maps to the same color across all components. + * @param {string} userId + * @returns {number} + */ +export function _colorIndex(userId) { + let hash = 0; + for (let i = 0; i < userId.length; i++) { + hash = (hash * 31 + userId.charCodeAt(i)) | 0; + } + return Math.abs(hash) % 5; +} + +// ── Component ────────────────────────────────────────────────────────────────── + +/** + * @typedef {'xs'|'sm'|'md'|'lg'} VignetteSize + */ + +/** + * Create a user vignette element: a coloured initials circle + async-resolved + * display name span. The element is returned immediately with a short-UUID + * placeholder; the name resolves in the background via `systemUsers`. + * + * @param {string} userId UUID of the user + * @param {VignetteSize} [size='sm'] + * @returns {HTMLElement} + */ +export function createUserVignette(userId, size = 'sm') { + const colorIdx = _colorIndex(userId); + + const wrapper = /** @type {HTMLElement} */ (document.createElement('span')); + wrapper.className = `user-vignette user-vignette--${size}`; + + const avatar = document.createElement('span'); + avatar.className = `user-vignette__avatar uv-color-${colorIdx}`; + // Temporary placeholder: first two chars of UUID + avatar.textContent = userId.slice(0, 2).toUpperCase(); + + const nameEl = document.createElement('span'); + nameEl.className = 'user-vignette__name'; + nameEl.textContent = `${userId.slice(0, 8)}…`; + + wrapper.appendChild(avatar); + wrapper.appendChild(nameEl); + + // Resolve full name asynchronously and update both avatar initials and name + systemUsers.getDisplayName(userId).then((name) => { + avatar.textContent = _initials(name); + nameEl.textContent = name; + }); + + return wrapper; +} diff --git a/static/js/core/types.js b/static/js/core/types.js index 9500dd9a..04963e6d 100644 --- a/static/js/core/types.js +++ b/static/js/core/types.js @@ -103,6 +103,7 @@ * @property {String} icon_special_class * @property {String} category * @property {String} size_formatted + * @property {string|null} owner_id UUID of the file/folder's actual owner */ /** diff --git a/static/js/features/library/favorites.js b/static/js/features/library/favorites.js index 4e12c9a2..86939cc7 100644 --- a/static/js/features/library/favorites.js +++ b/static/js/features/library/favorites.js @@ -201,7 +201,7 @@ const favorites = { const files = []; for (const item of this._cache.values()) { - // TODO: cast objects, but for that need to review user_id vs owner_id... + // owner_id comes from the backend JOIN (actual file/folder owner, not the favoriter) if (item.item_type === 'folder') { folders.push( // FIXME: better to grab the real values @@ -215,7 +215,7 @@ const favorites = { created_at: item.created_at, icon_class: item.icon_class, icon_special_class: item.icon_special_class, - owner_id: item.user_id, + owner_id: item.owner_id ?? '', is_root: false } ); @@ -234,7 +234,7 @@ const favorites = { size_formatted: item.size_formatted, modified_at: item.modified_at || item.created_at, path: item.item_path || '', - owner_id: item.user_id, + owner_id: item.owner_id ?? '', created_at: item.created_at, sort_date: item.created_at } @@ -246,6 +246,8 @@ const favorites = { const filesList = document.getElementById('files-list'); if (filesList) pathTooltip.init(filesList); + + await ui.resolveOwnerCells(); } catch (error) { console.error('Error displaying favorites:', error); if (ui?.showNotification) { diff --git a/static/js/model/systemUsers.js b/static/js/model/systemUsers.js index fb035ccf..ca34ec38 100644 --- a/static/js/model/systemUsers.js +++ b/static/js/model/systemUsers.js @@ -37,12 +37,29 @@ function _nameFor(c) { /** * Ensure the index is built (idempotent). + * After loading contacts from the system address book, the current user + * (from localStorage) is injected so owner cells resolve correctly even + * when the server-side address book does not include the logged-in user. * @returns {Promise} */ async function _ensureIndex() { if (_index !== null) return; const contacts = await addressBook.listContacts(SYSTEM_BOOK_ID); _index = new Map(contacts.map((c) => [c.id, _nameFor(c)])); + + // Inject the current user if they are not already in the index + try { + const raw = localStorage.getItem('oxicloud_user'); + if (raw) { + const u = /** @type {{id?:string, display_name?:string, username?:string, email?:string}} */ (JSON.parse(raw)); + if (u?.id && !_index.has(u.id)) { + const name = u.display_name || u.username || u.email || `${u.id.slice(0, 8)}…`; + _index.set(u.id, name); + } + } + } catch { + // localStorage not available or JSON is invalid — silently skip + } } // ── Public API ──────────────────────────────────────────────────────────────── diff --git a/static/js/views/sharedWithMe/sharedWithMeView.js b/static/js/views/sharedWithMe/sharedWithMeView.js index bcde3ac8..8e0bc0f5 100644 --- a/static/js/views/sharedWithMe/sharedWithMeView.js +++ b/static/js/views/sharedWithMe/sharedWithMeView.js @@ -112,6 +112,9 @@ const sharedWithMeView = { ownerTooltip.init(filesList); } + // Fill the Owner column cells (idempotent: skips already-resolved rows). + await ui.resolveOwnerCells(); + this._setLoadMoreVisible(!!this._nextCursor); } catch (err) { ui.showError(` diff --git a/static/locales/ar.json b/static/locales/ar.json index 19affe25..42b9c9f3 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -270,7 +270,8 @@ "archive": "أرشيف", "installer": "مثبّت", "code": "كود" - } + }, + "owner": "المالك" }, "dialogs": { "rename_folder": "إعادة تسمية المجلد", diff --git a/static/locales/de.json b/static/locales/de.json index 781199f3..c58a5cb8 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -270,7 +270,8 @@ "archive": "Archiv", "installer": "Installationsdatei", "code": "Code" - } + }, + "owner": "Eigentümer" }, "dialogs": { "rename_folder": "Ordner umbenennen", diff --git a/static/locales/en.json b/static/locales/en.json index 302cfaae..b587fd77 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -270,7 +270,8 @@ "archive": "Archive", "installer": "Installer", "code": "Code" - } + }, + "owner": "Owner" }, "dialogs": { "rename_folder": "Rename folder", diff --git a/static/locales/es.json b/static/locales/es.json index e5081c3f..34f0253a 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -270,7 +270,8 @@ "archive": "Archivo comprimido", "installer": "Instalador", "code": "Código" - } + }, + "owner": "Propietario" }, "dialogs": { "rename_folder": "Renombrar carpeta", diff --git a/static/locales/fa.json b/static/locales/fa.json index 226a9c0c..7fee57f6 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -270,7 +270,8 @@ "archive": "بایگانی", "installer": "نصب‌کننده", "code": "کد" - } + }, + "owner": "مالک" }, "dialogs": { "rename_folder": "تغییر نام پوشه", diff --git a/static/locales/fr.json b/static/locales/fr.json index 40479502..311033ce 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -270,7 +270,8 @@ "archive": "Archive", "installer": "Installateur", "code": "Code" - } + }, + "owner": "Propriétaire" }, "dialogs": { "rename_folder": "Renommer le dossier", diff --git a/static/locales/hi.json b/static/locales/hi.json index 97f86c2b..983abc1d 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -270,7 +270,8 @@ "archive": "संग्रह", "installer": "इंस्टॉलर", "code": "कोड" - } + }, + "owner": "स्वामी" }, "dialogs": { "rename_folder": "फ़ोल्डर का नाम बदलें", diff --git a/static/locales/it.json b/static/locales/it.json index 7a89ba83..5c507919 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -270,7 +270,8 @@ "archive": "Archivio", "installer": "Programma di installazione", "code": "Codice" - } + }, + "owner": "Proprietario" }, "dialogs": { "rename_folder": "Rinomina cartella", diff --git a/static/locales/ja.json b/static/locales/ja.json index 544cc8c9..5d64cdfe 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -270,7 +270,8 @@ "archive": "アーカイブ", "installer": "インストーラー", "code": "コード" - } + }, + "owner": "オーナー" }, "dialogs": { "rename_folder": "フォルダ名を変更", diff --git a/static/locales/ko.json b/static/locales/ko.json index 52b18e08..3aa86c9f 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -270,7 +270,8 @@ "archive": "아카이브", "installer": "설치 프로그램", "code": "코드" - } + }, + "owner": "소유자" }, "dialogs": { "rename_folder": "폴더 이름 변경", diff --git a/static/locales/nl.json b/static/locales/nl.json index 37b1afd8..a8a8f6f0 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -270,7 +270,8 @@ "archive": "Archief", "installer": "Installatiebestand", "code": "Code" - } + }, + "owner": "Eigenaar" }, "dialogs": { "rename_folder": "Map hernoemen", diff --git a/static/locales/pl.json b/static/locales/pl.json index 0e11ada7..99165d60 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -270,7 +270,8 @@ "archive": "Archiwum", "installer": "Instalator", "code": "Kod" - } + }, + "owner": "Właściciel" }, "dialogs": { "rename_folder": "Zmień nazwę folderu", diff --git a/static/locales/pt.json b/static/locales/pt.json index e81d69c2..048fd7a4 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -270,7 +270,8 @@ "archive": "Arquivo compactado", "installer": "Instalador", "code": "Código" - } + }, + "owner": "Proprietário" }, "dialogs": { "rename_folder": "Renomear pasta", diff --git a/static/locales/ru.json b/static/locales/ru.json index 02f496b3..af9e1972 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -270,7 +270,8 @@ "archive": "Архив", "installer": "Установщик", "code": "Код" - } + }, + "owner": "Владелец" }, "dialogs": { "rename_folder": "Переименовать папку", diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index 3313bfde..7e2eb3ee 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -270,7 +270,8 @@ "archive": "壓縮檔案", "installer": "安裝程式", "code": "程式碼" - } + }, + "owner": "擁有者" }, "dialogs": { "rename_folder": "重新命名資料夾", diff --git a/static/locales/zh.json b/static/locales/zh.json index 1efe5b0e..b24b2492 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -270,7 +270,8 @@ "archive": "压缩文件", "installer": "安装程序", "code": "代码" - } + }, + "owner": "所有者" }, "dialogs": { "rename_folder": "重命名文件夹",