diff --git a/static/css/components/resourceList.css b/static/css/components/resourceList.css index 94ee1e0e..5b280ce7 100644 --- a/static/css/components/resourceList.css +++ b/static/css/components/resourceList.css @@ -41,6 +41,45 @@ background-color: var(--color-item); } +/* Brief pulse on rows that were just optimistically inserted (e.g. + newly created folder, upload completion, drag-drop move into the + current folder). Pure CSS so timing is deterministic — the JS adds + the class, the animation auto-clears the background, and a + single `animationend` listener removes the class. + + `scroll-margin-top` reserves space above the row for the sticky + page header (`.page-sticky-header` ≈ 80 px). Without it, + `scrollIntoView({ block: 'nearest' })` aligns the row's top edge + against the viewport's top edge — which the sticky header is + currently covering — so the user only sees the row's bottom edge. + `scroll-margin-bottom` gives a touch of breathing room when the + scroll happens to land the row near the viewport bottom. */ +.file-item.resource-row--just-added { + animation: resource-row-just-added 1.5s ease-out; + scroll-margin-top: 100px; + scroll-margin-bottom: 24px; +} + +@keyframes resource-row-just-added { + 0% { + background-color: var(--color-success-bg); + } + + 100% { + background-color: transparent; + } +} + +/* Client-only "New" swimlane created on the fly by `addItem()` when + the view is grouped. Subtler styling than a natural-group lane: the + user understands the pin is temporary (it dissolves on next full + reload), so we don't want the bar to dominate the list. The pinned + placement at the top of the container is what makes it + discoverable; the header just confirms the intent. */ +.resource-list__swimlane-group--just-added > .resource-list__swimlane-header { + color: var(--color-success-text); +} + .file-item.selected { background-color: var(--color-item-selected); } diff --git a/static/js/app/filesView.js b/static/js/app/filesView.js index d25d4d13..5f7893be 100644 --- a/static/js/app/filesView.js +++ b/static/js/app/filesView.js @@ -366,9 +366,17 @@ async function _loadPage({ isFirstPage = false } = {}) { function addItem(item) { const component = _ensureComponent(); if (!component) return; - // Reveal the list if the empty-state is showing - ui.resetFilesList(); - component.addItem(item); + // Hide the empty-state placeholder if it's currently showing — + // creating a folder in an empty directory should reveal the new + // row, not display both states side-by-side. (The component will + // un-hide `#files-list` itself when the item is inserted.) + document.getElementById('files-container-error')?.classList.add('hidden'); + // Hand the item to the component so it can place it in the right + // swimlane (when the current view is grouped) and pulse-highlight + // + smooth-scroll it into view. We deliberately do NOT call + // `ui.resetFilesList()` here — that wipes the rendered DOM, + // defeating the whole point of an optimistic single-item insert. + component.addItem(item, { scroll: true, highlight: true }); } /** diff --git a/static/js/components/resourceList.js b/static/js/components/resourceList.js index 56e39b8b..0578be27 100644 --- a/static/js/components/resourceList.js +++ b/static/js/components/resourceList.js @@ -28,6 +28,15 @@ import { createUserVignette } from './userVignette.js'; * @import {FileItem, FolderItem} from '../core/types.js' */ +/** + * Reusable swimlane key for the client-side "just added" lane that + * `addItem()` opens at the top of the list in grouped views. Distinct + * from any natural group key the server might produce so the lookup + * can't collide with a real bucket whose label happens to read "New". + * @type {string} + */ +const JUST_ADDED_KEY = '__oxicloud_just_added__'; + /** * @typedef {Object} CustomAction * @property {string} iconHtml - Inner HTML for the button icon (e.g. ``). @@ -131,6 +140,16 @@ export class ResourceListComponent { */ this._lastGroupEl = null; + /** + * Optional grouping-key resolver stored between `render()` / `append()` + * calls so `addItem()` can place a new row in the correct swimlane + * without the caller having to re-supply it. `undefined` means the + * current view is flat (no group-by); `null` is never stored — only + * function or `undefined`. + * @type {((item: FileItem|FolderItem) => string|null) | undefined} + */ + this._groupFn = undefined; + /** * Optional label-resolver stored between `render()` and `append()` calls. * @type {((key: string) => string) | undefined} @@ -184,6 +203,7 @@ export class ResourceListComponent { // Reset group tracking for the fresh render this._lastGroupKey = undefined; this._lastGroupEl = null; + this._groupFn = groupFn; this._groupLabelFn = groupLabelFn; this._headerNodeFn = headerNodeFn; @@ -205,7 +225,13 @@ export class ResourceListComponent { * @param {((key: string) => HTMLElement)=} headerNodeFn */ append(items, groupFn, groupLabelFn, headerNodeFn) { - this._appendItems(items, groupFn, groupLabelFn ?? this._groupLabelFn, headerNodeFn ?? this._headerNodeFn); + // Persist the latest non-undefined callbacks so `addItem()` can + // reuse them without the caller having to re-supply them on every + // optimistic insertion. + if (groupFn !== undefined) this._groupFn = groupFn; + if (groupLabelFn !== undefined) this._groupLabelFn = groupLabelFn; + if (headerNodeFn !== undefined) this._headerNodeFn = headerNodeFn; + this._appendItems(items, groupFn ?? this._groupFn, groupLabelFn ?? this._groupLabelFn, headerNodeFn ?? this._headerNodeFn); } /** Remove all items (but keep `.list-header` if present). */ @@ -218,6 +244,9 @@ export class ResourceListComponent { this._lastClickedIndex = -1; this._lastGroupKey = undefined; this._lastGroupEl = null; + this._groupFn = undefined; + this._groupLabelFn = undefined; + this._headerNodeFn = undefined; // Hand delegation back to ui.js delete this._container.dataset.managedBy; } @@ -276,16 +305,128 @@ export class ResourceListComponent { /** * Append a single item, skipping silently if already present (duplicate guard). * Clears the empty-state placeholder when the first item is added. + * + * Group-by aware: if the current view is grouped, the row goes into + * a dedicated **"New" swimlane pinned at the top of the list** that + * is created on first call and reused across subsequent inserts in + * the same session. This deliberately sidesteps re-computing the + * item's natural bucket on the client: + * + * - Different group-by dimensions (date, type, size, …) would each + * need their own resolver, and date-bucket math is sensitive to + * clock skew between client and server. + * - Cross-swimlane sort-position is impossible to mirror exactly + * without re-implementing the server's tiebreaker chain. + * + * The "New" lane is purely client-side and dissolves on the next + * full reload (when the server's authoritative grouping reasserts). + * Predictable and uniform across every group-by mode. + * * @param {FileItem|FolderItem} item + * @param {{ scroll?: boolean, highlight?: boolean }} [opts] + * - `scroll`: smooth-scroll the new row into view. The + * `.resource-row--just-added` class also sets `scroll-margin` + * so the sticky page header doesn't cover the row. + * - `highlight`: flash a brief CSS pulse on the new row so the + * user can spot it amid similar siblings. + * @returns {HTMLElement | null} The inserted row, or `null` when the + * item was deduped. */ - addItem(item) { - if (this._items.has(item.id)) return; + addItem(item, opts = {}) { + if (this._items.has(item.id)) return null; // Also guard against stale DOM remnants not tracked in _items const isFile = 'mime_type' in item; const attr = isFile ? `data-file-id="${item.id}"` : `data-folder-id="${item.id}"`; - if (this._container.querySelector(`.file-item[${attr}]`)) return; + if (this._container.querySelector(`.file-item[${attr}]`)) return null; this._container.classList.remove('hidden'); - this._appendItems([item]); + + /** @type {HTMLElement | null} */ + let row = null; + + if (this._groupFn) { + // Grouped view → drop the new row into the top-of-list + // "New" swimlane, creating it on first call. + const lane = this._ensureJustAddedLane(); + this._items.set(item.id, item); + row = isFile ? this._createFileItem(/** @type {FileItem} */ (item)) : this._createFolderItem(/** @type {FolderItem} */ (item)); + lane.appendChild(row); + } else { + // Flat list (no grouping) — append at the end like before. + this._appendItems([item]); + row = /** @type {HTMLElement | null} */ (this._container.querySelector(`.file-item[${attr}]`)); + } + + if (!row) return null; + + if (opts.highlight) { + row.classList.add('resource-row--just-added'); + // Self-cleaning: drop the class once the keyframe completes + // so a future re-render starts from a neutral baseline. + row.addEventListener('animationend', () => row?.classList.remove('resource-row--just-added'), { once: true }); + } + if (opts.scroll) { + // `block: 'nearest'` is a no-op when the row is already in + // view. `.resource-row--just-added` sets `scroll-margin-top` + // so the sticky page header doesn't clip the row when the + // scroll lands. + row.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + + return row; + } + + /** + * Ensure the top-of-list "New" swimlane exists and return its + * wrapper. Created on first call after a render; reused across + * subsequent `addItem()` calls in the same session. The lane + * dissolves on the next `render()` / `clear()`, at which point + * the server's authoritative grouping reasserts. + * + * Header label uses i18n key `groupby.justAdded` with an English + * fallback so views that haven't translated it still read sensibly. + * + * @returns {HTMLElement} + */ + _ensureJustAddedLane() { + const existing = this._findLaneByKey(JUST_ADDED_KEY); + if (existing) return existing; + + const lane = document.createElement('div'); + lane.className = 'resource-list__swimlane-group resource-list__swimlane-group--just-added'; + lane.dataset.groupKey = JUST_ADDED_KEY; + + const header = document.createElement('div'); + header.className = 'resource-list__swimlane-header'; + header.dataset.swimlaneHeader = 'true'; + header.textContent = i18n.t('groupby.justAdded', 'New'); + lane.appendChild(header); + + // Insert at the very top of the container, immediately after + // the optional `.list-header` row, so the affordance is + // discoverable and the row's scroll-into-view brings the + // swimlane header into view too. + const listHeader = this._container.querySelector('.list-header'); + if (listHeader?.nextSibling) { + this._container.insertBefore(lane, listHeader.nextSibling); + } else if (listHeader) { + this._container.appendChild(lane); + } else { + this._container.prepend(lane); + } + return lane; + } + + /** + * Locate an on-screen swimlane wrapper by its group key. Returns + * `null` when no swimlane currently matches. + * + * @param {string} key + * @returns {HTMLElement | null} + */ + _findLaneByKey(key) { + // CSS.escape covers arbitrary key shapes (dates with colons, + // UUIDs with dashes, etc.) so the attribute selector is safe. + return /** @type {HTMLElement | null} */ (this._container.querySelector(`.resource-list__swimlane-group[data-group-key="${CSS.escape(String(key))}"]`)); } /** @@ -404,6 +545,11 @@ export class ResourceListComponent { if (key !== null) { fragmentGroup = document.createElement('div'); fragmentGroup.className = 'resource-list__swimlane-group'; + // Stamp the group key on the wrapper so `addItem()` + // can locate this swimlane later via + // `_findLaneByKey()` and append into it without a + // full re-render. + fragmentGroup.dataset.groupKey = key; fragmentGroup.appendChild(this._createGroupHeader(key, groupLabelFn, headerNodeFn)); fragment.appendChild(fragmentGroup); } diff --git a/static/locales/ar.json b/static/locales/ar.json index e19778d3..fa1cb9c3 100644 --- a/static/locales/ar.json +++ b/static/locales/ar.json @@ -835,7 +835,8 @@ "size": "الحجم", "favoriteDate": "تاريخ المفضلة", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "جديد" }, "dateBucket": { "today": "اليوم", diff --git a/static/locales/de.json b/static/locales/de.json index 8b3fb33e..984cadf2 100644 --- a/static/locales/de.json +++ b/static/locales/de.json @@ -835,7 +835,8 @@ "size": "Größe", "favoriteDate": "Datum der Markierung", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "Neu" }, "dateBucket": { "today": "Heute", diff --git a/static/locales/en.json b/static/locales/en.json index 4ed01aef..a606b0fa 100644 --- a/static/locales/en.json +++ b/static/locales/en.json @@ -845,7 +845,8 @@ "accessedAt": "Accessed date", "modifiedAt": "Modified date", "createdAt": "Created date", - "size": "Size" + "size": "Size", + "justAdded": "New" }, "dateBucket": { "today": "Today", diff --git a/static/locales/es.json b/static/locales/es.json index 06b99b5b..01657b03 100644 --- a/static/locales/es.json +++ b/static/locales/es.json @@ -835,7 +835,8 @@ "size": "Tamaño", "favoriteDate": "Fecha de favorito", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "Nuevo" }, "dateBucket": { "today": "Hoy", diff --git a/static/locales/fa.json b/static/locales/fa.json index 9c385666..61ab2a56 100644 --- a/static/locales/fa.json +++ b/static/locales/fa.json @@ -835,7 +835,8 @@ "size": "اندازه", "favoriteDate": "تاریخ مورد علاقه", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "جدید" }, "dateBucket": { "today": "امروز", diff --git a/static/locales/fr.json b/static/locales/fr.json index ef1e7296..51c4afa5 100644 --- a/static/locales/fr.json +++ b/static/locales/fr.json @@ -835,7 +835,8 @@ "createdAt": "Date de création", "size": "Taille", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "Nouveau" }, "dateBucket": { "today": "Aujourd'hui", diff --git a/static/locales/hi.json b/static/locales/hi.json index 9e604d36..62b4dda1 100644 --- a/static/locales/hi.json +++ b/static/locales/hi.json @@ -835,7 +835,8 @@ "size": "आकार", "favoriteDate": "पसंदीदा की तारीख", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "नया" }, "dateBucket": { "today": "आज", diff --git a/static/locales/it.json b/static/locales/it.json index 759eb053..0d3860d2 100644 --- a/static/locales/it.json +++ b/static/locales/it.json @@ -835,7 +835,8 @@ "size": "Dimensione", "favoriteDate": "Data preferito", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "Nuovo" }, "dateBucket": { "today": "Oggi", diff --git a/static/locales/ja.json b/static/locales/ja.json index 7e7388c8..84debb3f 100644 --- a/static/locales/ja.json +++ b/static/locales/ja.json @@ -835,7 +835,8 @@ "size": "サイズ", "favoriteDate": "お気に入り登録日", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "新規" }, "dateBucket": { "today": "今日", diff --git a/static/locales/ko.json b/static/locales/ko.json index 3cdee5ef..345cbe13 100644 --- a/static/locales/ko.json +++ b/static/locales/ko.json @@ -835,7 +835,8 @@ "size": "크기", "favoriteDate": "즐겨찾기 날짜", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "새 항목" }, "dateBucket": { "today": "오늘", diff --git a/static/locales/nl.json b/static/locales/nl.json index 8a5f7f90..33f93ec1 100644 --- a/static/locales/nl.json +++ b/static/locales/nl.json @@ -835,7 +835,8 @@ "size": "Grootte", "favoriteDate": "Favoritendatum", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "Nieuw" }, "dateBucket": { "today": "Vandaag", diff --git a/static/locales/pl.json b/static/locales/pl.json index 6ce9b41a..e9fff114 100644 --- a/static/locales/pl.json +++ b/static/locales/pl.json @@ -835,7 +835,8 @@ "size": "Rozmiar", "favoriteDate": "Data dodania do ulubionych", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "Nowe" }, "dateBucket": { "today": "Dzisiaj", diff --git a/static/locales/pt.json b/static/locales/pt.json index cba6ffa4..358ba879 100644 --- a/static/locales/pt.json +++ b/static/locales/pt.json @@ -835,7 +835,8 @@ "size": "Tamanho", "favoriteDate": "Data de favorito", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "Novo" }, "dateBucket": { "today": "Hoje", diff --git a/static/locales/ru.json b/static/locales/ru.json index c6d6d802..c2b1cfec 100644 --- a/static/locales/ru.json +++ b/static/locales/ru.json @@ -835,7 +835,8 @@ "size": "Размер", "favoriteDate": "Дата добавления в избранное", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "Новые" }, "dateBucket": { "today": "Сегодня", diff --git a/static/locales/zh-TW.json b/static/locales/zh-TW.json index 45516f41..5d98739c 100644 --- a/static/locales/zh-TW.json +++ b/static/locales/zh-TW.json @@ -835,7 +835,8 @@ "size": "大小", "favoriteDate": "收藏日期", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "新增" }, "dateBucket": { "today": "今天", diff --git a/static/locales/zh.json b/static/locales/zh.json index 70323529..428ce842 100644 --- a/static/locales/zh.json +++ b/static/locales/zh.json @@ -835,7 +835,8 @@ "size": "大小", "favoriteDate": "收藏日期", "byFiles": "By files", - "sharedWith": "Shared with" + "sharedWith": "Shared with", + "justAdded": "新建" }, "dateBucket": { "today": "今天",