feat(ui:items): uploading an item with a swimlane display
restore the legacy display with new element uploaded, when swimlane is in place
as the sort is done by server side just add new element in a "new elements" swimlane.
if user continue to scroll down in cursor/pages and item is found from server,
UI remove it from the new element and restore the position
other option: is user refresh it's page, server will restore the natural order
This commit is contained in:
@@ -1239,14 +1239,16 @@
|
||||
<div class="files-list-view" style="--files-list-columns: {columns}">
|
||||
{#if listHeaderOverride}{@render listHeaderOverride()}{:else}{@render listHeader()}{/if}
|
||||
{#each sections as section (section.key)}
|
||||
<div class="rl-swimlane-header" role="rowheader">
|
||||
<span class="rl-swimlane-header__label">{section.label}</span>
|
||||
{#if bucketAction}
|
||||
<span class="rl-swimlane-header__action">
|
||||
{@render bucketAction(section.key)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if section.label}
|
||||
<div class="rl-swimlane-header" role="rowheader">
|
||||
<span class="rl-swimlane-header__label">{section.label}</span>
|
||||
{#if bucketAction}
|
||||
<span class="rl-swimlane-header__action">
|
||||
{@render bucketAction(section.key)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Window each section's rows so a large grouped list (e.g. a big
|
||||
trash, grouped by remaining days) doesn't mount every row. -->
|
||||
<VirtualList items={section.rows} rowHeight={56} key={(e) => e.id} {row} />
|
||||
@@ -1262,14 +1264,16 @@
|
||||
(benches/ROUND13.md §V1). -->
|
||||
<div class="rl-grouped-grid">
|
||||
{#each sections as section (section.key)}
|
||||
<div class="rl-swimlane-header rl-swimlane-header--grid" role="rowheader">
|
||||
<span class="rl-swimlane-header__label">{section.label}</span>
|
||||
{#if bucketAction}
|
||||
<span class="rl-swimlane-header__action">
|
||||
{@render bucketAction(section.key)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if section.label}
|
||||
<div class="rl-swimlane-header rl-swimlane-header--grid" role="rowheader">
|
||||
<span class="rl-swimlane-header__label">{section.label}</span>
|
||||
{#if bucketAction}
|
||||
<span class="rl-swimlane-header__action">
|
||||
{@render bucketAction(section.key)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<VirtualList
|
||||
items={section.rows}
|
||||
columns={gridCols}
|
||||
|
||||
@@ -121,6 +121,26 @@
|
||||
// enqueue two concurrent next-page fetches on the same cursor.
|
||||
let loadingMore = $state(false);
|
||||
|
||||
// ── Transient "New elements" swimlane ────────────────────────────
|
||||
// Server sort is authoritative, so an uploaded file lands wherever
|
||||
// its sort key places it — possibly halfway down the list or on an
|
||||
// unloaded page. To confirm "your upload worked", we detect items
|
||||
// that appeared on the current page after a mutation (upload / new
|
||||
// folder / dropped tree) and hoist them into a first-class swimlane
|
||||
// at the top of the list. The swimlane clears on next folder nav.
|
||||
//
|
||||
// Detection strategy: caller of `reloadAndTrackNew()` snapshots the
|
||||
// current id set, runs `reload()`, and diffs the freshly-loaded
|
||||
// page 1 against the snapshot — every new id joins `newlyAdded`.
|
||||
//
|
||||
// Known limitation: server-sort places the new item on an unloaded
|
||||
// page (e.g. sort=size on a 5 000-item folder and the new file is
|
||||
// mid-size). It won't appear in the swimlane until the user scrolls
|
||||
// far enough for that page to load. Threading the returned FileItem
|
||||
// out of the 5-layer upload stack would fix this — deferred until
|
||||
// the diff approach proves insufficient.
|
||||
const newlyAdded = new SvelteSet<string>();
|
||||
|
||||
// Dotfile hide filter is now applied inside `rlItems` (below) directly
|
||||
// on the server-ordered accumulator, so a single filter pass feeds
|
||||
// ResourceList. Selection / batch ops iterate ResourceList's own
|
||||
@@ -371,6 +391,36 @@
|
||||
await load(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload + populate the "new elements" swimlane with anything that
|
||||
* appeared on page 1 after the mutation.
|
||||
*
|
||||
* Called from mutation paths that ADD items (upload / dropped tree /
|
||||
* create-folder). Renames, deletes, moves use plain `reload()`
|
||||
* — nothing new to hoist.
|
||||
*/
|
||||
async function reloadAndTrackNew(): Promise<void> {
|
||||
const before = new SvelteSet<string>();
|
||||
for (const it of orderedItems) before.add(it.id);
|
||||
await reload();
|
||||
// `reload()` resets `pageCursor` + fetches page 1 fresh, so
|
||||
// `orderedItems` is now the freshly-loaded page. Every id that
|
||||
// wasn't there before this reload joins the swimlane.
|
||||
newlyAdded.clear();
|
||||
for (const it of orderedItems) if (!before.has(it.id)) newlyAdded.add(it.id);
|
||||
// Scroll the page back to the top so the freshly-hoisted "New
|
||||
// elements" swimlane is visible without the user having to hunt
|
||||
// for it — the whole point of the swimlane is to confirm "your
|
||||
// upload landed". Only fires when we actually detected new items,
|
||||
// so a bare reload doesn't yank the user's scroll position.
|
||||
// Smooth scroll for the visual continuity — instant would feel
|
||||
// like the page reloaded. `scrollTo` at (0, 0) is a no-op if
|
||||
// the user was already at the top; no jitter cost.
|
||||
if (newlyAdded.size > 0 && typeof window !== 'undefined') {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
|
||||
function openFolder(folder: FolderItem) {
|
||||
goto(resolve(`/files/${[...pathSegments, folder.id].join('/')}`));
|
||||
}
|
||||
@@ -384,7 +434,7 @@
|
||||
if (!name) return;
|
||||
try {
|
||||
await createFolder(name, currentId);
|
||||
await reload();
|
||||
await reloadAndTrackNew();
|
||||
// Vanish-warning: user just made a `.folder` and it's
|
||||
// already hidden by their preference — otherwise the new
|
||||
// folder would appear to have not been created. Third hook
|
||||
@@ -668,7 +718,7 @@
|
||||
} else {
|
||||
finishUpload(nid, 0, 0, 0, skipped.length);
|
||||
}
|
||||
await reload();
|
||||
await reloadAndTrackNew();
|
||||
// Storage usage changed server-side — pull the fresh figure so the
|
||||
// "Almacenamiento" bar moves off its login value instead of 0%.
|
||||
void session.refresh();
|
||||
@@ -1375,7 +1425,7 @@
|
||||
|
||||
const { savedBytes, failures } = await uploadAll(items, nid, label);
|
||||
finishUpload(nid, savedBytes, failures, total, skipped.length);
|
||||
await reload();
|
||||
await reloadAndTrackNew();
|
||||
void session.refresh();
|
||||
} catch (err) {
|
||||
ui.finishProgress(nid, errorMessage(err), 'error');
|
||||
@@ -1418,7 +1468,25 @@
|
||||
// necessary. Under order_by=name/type/size the server puts folders
|
||||
// first then files; under modified_at/created_at they interleave —
|
||||
// preserving the accumulator order is what surfaces that correctly.
|
||||
const rlItems = $derived(filterDotfiles(orderedItems, preferences.hideDotfiles));
|
||||
//
|
||||
// Hoist step: items in `newlyAdded` (populated by `reloadAndTrackNew`
|
||||
// after an upload / create / dropped tree) are pulled OUT of their
|
||||
// natural-order position and PREPENDED to the list, so the
|
||||
// "__new__" bucket rendered by the composed groupBy below appears
|
||||
// at the top of the swimlanes regardless of what sort/group the
|
||||
// user has active. First-appearance bucketing in
|
||||
// `buildResourceSections` keys off the item order in the input list.
|
||||
const rlItems = $derived.by<Array<FileItem | FolderItem>>(() => {
|
||||
const filtered = filterDotfiles(orderedItems, preferences.hideDotfiles);
|
||||
if (newlyAdded.size === 0) return filtered;
|
||||
const hoisted: Array<FileItem | FolderItem> = [];
|
||||
const rest: Array<FileItem | FolderItem> = [];
|
||||
for (const it of filtered) {
|
||||
if (newlyAdded.has(it.id)) hoisted.push(it);
|
||||
else rest.push(it);
|
||||
}
|
||||
return [...hoisted, ...rest];
|
||||
});
|
||||
|
||||
// Group-by state (bound to <ResourceList>). Kept as a `string` prop
|
||||
// value; the current `sortField` mirrors from the picked group's
|
||||
@@ -1429,40 +1497,75 @@
|
||||
// modifiedAt / createdAt). The `orderBy` values are what the
|
||||
// GROUP_BYS toolbar emits, so <ResourceList>'s onreload gets the
|
||||
// legacy `sortField` name and can drive the same sort path.
|
||||
//
|
||||
// Every dimension composes a `__new__` branch on top of its natural
|
||||
// `bucketOf` so that whenever the transient "new elements" swimlane
|
||||
// is active, hoisted items get their own bucket-first-in-order
|
||||
// regardless of the user's chosen group. On the default `''` (flat)
|
||||
// dimension the wrapped `bucketOf` returns the empty string for
|
||||
// non-new items — that renders as one unlabeled section (header
|
||||
// suppressed by ResourceList when `label === ''`), preserving the
|
||||
// current flat-list look with just the "New elements" header on
|
||||
// top. `labelForNew` renders the localised header.
|
||||
const NEW_KEY = '__new__';
|
||||
const labelForNew = $derived(t('files.new_elements', 'New elements'));
|
||||
const wrapNew =
|
||||
<T extends FileItem | FolderItem>(inner?: (item: T) => string | null) =>
|
||||
(item: T): string | null => {
|
||||
if (newlyAdded.has(item.id)) return NEW_KEY;
|
||||
return inner ? inner(item) : '';
|
||||
};
|
||||
const wrapLabel =
|
||||
(inner?: (key: string) => string) =>
|
||||
(key: string): string => {
|
||||
if (key === NEW_KEY) return labelForNew;
|
||||
return inner ? inner(key) : key;
|
||||
};
|
||||
const rlGroupBys = $derived<RLGroupByDef[]>([
|
||||
{ key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' },
|
||||
{
|
||||
key: '',
|
||||
label: t('files.name', 'Name'),
|
||||
orderBy: 'name',
|
||||
icon: 'arrow-up-a-z',
|
||||
// Only synthesize a bucketOf when the swimlane is active; when
|
||||
// no new items exist we want the plain flat-list rendering
|
||||
// (no bucketing pass at all).
|
||||
bucketOf: newlyAdded.size > 0 ? wrapNew() : undefined,
|
||||
labelOf: newlyAdded.size > 0 ? wrapLabel() : undefined
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
label: t('groupby.type', 'Type'),
|
||||
orderBy: 'type',
|
||||
icon: 'layer-group',
|
||||
bucketOf: (item) =>
|
||||
isFile(item) ? typeLabel(item.category) : t('files.file_types.folder', 'Folders'),
|
||||
labelOf: (k) => k
|
||||
bucketOf: wrapNew((item) =>
|
||||
isFile(item) ? typeLabel(item.category) : t('files.file_types.folder', 'Folders')
|
||||
),
|
||||
labelOf: wrapLabel((k) => k)
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
label: t('groupby.size', 'Size'),
|
||||
orderBy: 'size',
|
||||
icon: 'layer-group',
|
||||
bucketOf: (item) => (isFile(item) ? sizeBucket(item.size ?? 0) : sizeBucket(-1)),
|
||||
labelOf: (k) => k
|
||||
bucketOf: wrapNew((item) => (isFile(item) ? sizeBucket(item.size ?? 0) : sizeBucket(-1))),
|
||||
labelOf: wrapLabel((k) => k)
|
||||
},
|
||||
{
|
||||
key: 'modifiedAt',
|
||||
label: t('groupby.modifiedAt', 'Modified date'),
|
||||
orderBy: 'modified_at',
|
||||
icon: 'layer-group',
|
||||
bucketOf: (item) => dateBucket(item.modified_at),
|
||||
labelOf: (k) => k
|
||||
bucketOf: wrapNew((item) => dateBucket(item.modified_at)),
|
||||
labelOf: wrapLabel((k) => k)
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: t('groupby.createdAt', 'Created date'),
|
||||
orderBy: 'created_at',
|
||||
icon: 'layer-group',
|
||||
bucketOf: (item) => dateBucket(item.created_at),
|
||||
labelOf: (k) => k
|
||||
bucketOf: wrapNew((item) => dateBucket(item.created_at)),
|
||||
labelOf: wrapLabel((k) => k)
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -1533,6 +1636,11 @@
|
||||
void sortField;
|
||||
void reversed;
|
||||
untrack(() => {
|
||||
// Route/sort change → drop the transient "new elements"
|
||||
// swimlane. It's a per-folder confirmation of "here's what
|
||||
// you just added"; carrying it across folders would surface
|
||||
// stale ids that don't belong to the new listing.
|
||||
newlyAdded.clear();
|
||||
void load(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
"col_added": "أضيف",
|
||||
"col_created_by": "أنشئ بواسطة",
|
||||
"col_opened": "افتُح",
|
||||
"col_path": "الموقع"
|
||||
"col_path": "الموقع",
|
||||
"new_elements": "عناصر جديدة"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "إعادة تسمية المجلد",
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
"col_added": "Hinzugefügt",
|
||||
"col_created_by": "Erstellt von",
|
||||
"col_opened": "Geöffnet",
|
||||
"col_path": "Speicherort"
|
||||
"col_path": "Speicherort",
|
||||
"new_elements": "Neue Elemente"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Ordner umbenennen",
|
||||
|
||||
@@ -504,6 +504,7 @@
|
||||
"moved": "Moved",
|
||||
"new_folder": "New folder",
|
||||
"new_folder_prompt": "New folder name",
|
||||
"new_elements": "New elements",
|
||||
"no_home": "No home folder available.",
|
||||
"no_preview": "No preview available for this file type.",
|
||||
"no_subfolders": "No subfolders here.",
|
||||
|
||||
@@ -387,7 +387,8 @@
|
||||
"col_added": "Añadido",
|
||||
"col_created_by": "Creado por",
|
||||
"col_opened": "Abierto",
|
||||
"col_path": "Ubicación"
|
||||
"col_path": "Ubicación",
|
||||
"new_elements": "Nuevos elementos"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Renombrar carpeta",
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
"col_added": "افزوده شده",
|
||||
"col_created_by": "ایجاد شده توسط",
|
||||
"col_opened": "باز شده",
|
||||
"col_path": "مکان"
|
||||
"col_path": "مکان",
|
||||
"new_elements": "موارد جدید"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "تغییر نام پوشه",
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
"col_added": "Ajouté",
|
||||
"col_created_by": "Créé par",
|
||||
"col_opened": "Ouvert",
|
||||
"col_path": "Emplacement"
|
||||
"col_path": "Emplacement",
|
||||
"new_elements": "Nouveaux éléments"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Renommer le dossier",
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
"col_added": "जोड़ा गया",
|
||||
"col_created_by": "द्वारा बनाया गया",
|
||||
"col_opened": "खोला गया",
|
||||
"col_path": "स्थान"
|
||||
"col_path": "स्थान",
|
||||
"new_elements": "नए तत्व"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "फ़ोल्डर का नाम बदलें",
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
"col_added": "Aggiunto",
|
||||
"col_created_by": "Creato da",
|
||||
"col_opened": "Aperto",
|
||||
"col_path": "Posizione"
|
||||
"col_path": "Posizione",
|
||||
"new_elements": "Nuovi elementi"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Rinomina cartella",
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
"col_added": "追加日",
|
||||
"col_created_by": "作成者",
|
||||
"col_opened": "アクセス日時",
|
||||
"col_path": "場所"
|
||||
"col_path": "場所",
|
||||
"new_elements": "新しいアイテム"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "フォルダ名を変更",
|
||||
|
||||
@@ -471,6 +471,7 @@
|
||||
"move_title": "\"{{name}}\" 이동",
|
||||
"moved": "이동됨",
|
||||
"new_folder_prompt": "새 폴더 이름",
|
||||
"new_elements": "새 항목",
|
||||
"no_home": "홈 폴더를 사용할 수 없습니다.",
|
||||
"no_preview": "이 파일 형식은 미리보기를 지원하지 않습니다.",
|
||||
"no_subfolders": "하위 폴더가 없습니다.",
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
"col_added": "Toegevoegd",
|
||||
"col_created_by": "Gemaakt door",
|
||||
"col_opened": "Geopend",
|
||||
"col_path": "Locatie"
|
||||
"col_path": "Locatie",
|
||||
"new_elements": "Nieuwe items"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Map hernoemen",
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
"col_added": "Dodano",
|
||||
"col_created_by": "Utworzone przez",
|
||||
"col_opened": "Otwarte",
|
||||
"col_path": "Lokalizacja"
|
||||
"col_path": "Lokalizacja",
|
||||
"new_elements": "Nowe elementy"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Zmień nazwę folderu",
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
"col_added": "Adicionado",
|
||||
"col_created_by": "Criado por",
|
||||
"col_opened": "Aberto",
|
||||
"col_path": "Localização"
|
||||
"col_path": "Localização",
|
||||
"new_elements": "Novos itens"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Renomear pasta",
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
"col_added": "Добавлено",
|
||||
"col_created_by": "Создано",
|
||||
"col_opened": "Открыт",
|
||||
"col_path": "Расположение"
|
||||
"col_path": "Расположение",
|
||||
"new_elements": "Новые элементы"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "Переименовать папку",
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
"col_added": "新增日期",
|
||||
"col_created_by": "建立者",
|
||||
"col_opened": "開啟日期",
|
||||
"col_path": "位置"
|
||||
"col_path": "位置",
|
||||
"new_elements": "新項目"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "重新命名資料夾",
|
||||
|
||||
@@ -382,7 +382,8 @@
|
||||
"col_added": "添加日期",
|
||||
"col_created_by": "创建者",
|
||||
"col_opened": "打开日期",
|
||||
"col_path": "位置"
|
||||
"col_path": "位置",
|
||||
"new_elements": "新元素"
|
||||
},
|
||||
"dialogs": {
|
||||
"rename_folder": "重命名文件夹",
|
||||
|
||||
Reference in New Issue
Block a user