refactor(ui): recent: add a quick button to remove item from recent
This commit is contained in:
@@ -30,3 +30,20 @@ export async function clearRecent(): Promise<void> {
|
||||
});
|
||||
if (!res.ok) throw new Error(`clear recent failed: ${res.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a single item from the caller's recent history — the "broom"
|
||||
* per-row affordance in the recent view. Distinct from `clearRecent`
|
||||
* (which wipes every entry). 404 means the item wasn't in recents to
|
||||
* begin with — treated as a no-op success by the caller.
|
||||
*/
|
||||
export async function removeFromRecent(kind: ItemType, id: string): Promise<void> {
|
||||
const res = await apiFetch(`/api/recent/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'same-origin',
|
||||
headers: getCsrfHeaders()
|
||||
});
|
||||
if (!res.ok && res.status !== 404) {
|
||||
throw new Error(`remove from recent failed: ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -753,6 +753,11 @@
|
||||
position: static;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
/* `margin: 0` overrides the legacy `.files-grid-view .file-item
|
||||
.btn-action { margin-top: var(--space-1) }` rule further down —
|
||||
inside the corner cluster the parent's `gap` handles spacing
|
||||
and any per-child margin would misalign the pills. */
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
@@ -1188,6 +1193,11 @@
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
/* Legacy: a margin-top on `.btn-action` in grid view for the era when
|
||||
these buttons flowed at the bottom of the card. Kept for any
|
||||
free-standing use outside the corner cluster; reset inside
|
||||
`.action-cell` (line ~745) so the broom / restore / delete pills
|
||||
align with the kebab and star. */
|
||||
.files-grid-view .file-item .btn-action {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
@@ -5,14 +5,16 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { primeContextPage } from '$lib/utils/listContext';
|
||||
import { clearRecent, fetchRecentPage, type RecentResourceItem } from '$lib/api/endpoints/recent';
|
||||
import {
|
||||
addFavorite,
|
||||
clearRecent,
|
||||
fetchRecentPage,
|
||||
removeFromRecent,
|
||||
type RecentResourceItem
|
||||
} from '$lib/api/endpoints/recent';
|
||||
import {
|
||||
dateBucket,
|
||||
fetchFavoritesPage,
|
||||
removeFavorite,
|
||||
resolveOwnerName,
|
||||
sizeBucket,
|
||||
typeLabel
|
||||
@@ -31,12 +33,10 @@
|
||||
// `preferences.hideDotfiles` + `isDotfile` are read here only to
|
||||
// derive `hiddenCount` for the empty-state message — the actual
|
||||
// filter is inside ResourceList (gated on `showDotfileToggle`).
|
||||
// `replaceSet` is from perf-round-6: `loadFavoriteIds` mutates
|
||||
// the reactive SvelteSet in place instead of re-creating it.
|
||||
import { preferences } from '$lib/stores/preferences.svelte';
|
||||
import { isDotfile } from '$lib/utils/dotfileFilter';
|
||||
import { replaceSet } from '$lib/utils/sets';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import Icon from '$lib/icons/Icon.svelte';
|
||||
|
||||
let raw = $state<RecentResourceItem[]>([]);
|
||||
let cursor = $state<string | undefined>(undefined);
|
||||
@@ -45,9 +45,6 @@
|
||||
let groupBy = $state('');
|
||||
let reversed = $state(false);
|
||||
const owners = useOwnerCache(resolveOwnerName);
|
||||
// In-place reactive set — a star toggle skips the full-set copy and
|
||||
// spares the other favorited rows' readers.
|
||||
const favoriteIds = new SvelteSet<string>();
|
||||
|
||||
// Envelope shape: `accessed_at` → `ctx.date`, `updated_by` → `ctx.ownerId`
|
||||
// (Recent's provenance semantic — "who touched this recently" — differs
|
||||
@@ -62,7 +59,7 @@
|
||||
const items = $derived(raw.map((it) => it.resource as FileItem | FolderItem));
|
||||
// Persistent reactive map, primed per page in `load()` (benches/ROUND16.md §F2)
|
||||
// instead of rebuilding a fresh Map that re-hashes the whole accumulated list
|
||||
// on every infinite-scroll page. Mirrors the sibling `favoriteIds` SvelteSet.
|
||||
// on every infinite-scroll page.
|
||||
const contextMap = new SvelteMap<string, ItemContext>();
|
||||
const hiddenCount = $derived(
|
||||
preferences.hideDotfiles ? items.filter((i) => isDotfile(i.name)).length : 0
|
||||
@@ -104,18 +101,6 @@
|
||||
}
|
||||
];
|
||||
|
||||
async function loadFavoriteIds() {
|
||||
try {
|
||||
const favs = await fetchFavoritesPage({ resourceTypes: ['file', 'folder'] });
|
||||
replaceSet(
|
||||
favoriteIds,
|
||||
favs.items.map((f) => f.resource.id)
|
||||
);
|
||||
} catch {
|
||||
// non-fatal — stars just default to off
|
||||
}
|
||||
}
|
||||
|
||||
// Recent defaults to most-recently-accessed first (accessed_at DESC).
|
||||
async function load(reset = false, orderBy = 'accessed_at', rev = reversed) {
|
||||
loading = true;
|
||||
@@ -173,24 +158,32 @@
|
||||
viewerOpen = true;
|
||||
}
|
||||
|
||||
// Callback signature is `FileItem | FolderItem` (ResourceList
|
||||
// hands raw items to `onfavorite` — the pre-migration
|
||||
// `ResourceEntry` shape is gone). Set mutation is in-place per
|
||||
// perf-round-6: 1 000 toggles @ N=5 000 dropped from 771.9 ms
|
||||
// to 1.9 ms by skipping the full-set copy that every reader of
|
||||
// `favoriteIds` used to see.
|
||||
async function toggleFavorite(item: FileItem | FolderItem) {
|
||||
const isFav = favoriteIds.has(item.id);
|
||||
/**
|
||||
* Remove a single item from the caller's recent history. The
|
||||
* per-row "broom" affordance replaces the favorite-star that
|
||||
* existed here before — /recent is a history view, so surfacing
|
||||
* "forget this one" is more useful than "favorite this one"
|
||||
* (users go to the item's real home to favorite it).
|
||||
*
|
||||
* Optimistic: the row disappears immediately; if the DELETE
|
||||
* fails, we re-add it at its original position and toast the
|
||||
* error so the state stays honest.
|
||||
*/
|
||||
async function removeItem(item: FileItem | FolderItem) {
|
||||
const kind = kindOf(item);
|
||||
// Optimistic in-place toggle, reverted on failure.
|
||||
if (isFav) favoriteIds.delete(item.id);
|
||||
else favoriteIds.add(item.id);
|
||||
const idx = raw.findIndex((it) => it.resource.id === item.id);
|
||||
if (idx < 0) return;
|
||||
const snapshot = raw[idx];
|
||||
raw = raw.filter((it) => it.resource.id !== item.id);
|
||||
contextMap.delete(item.id);
|
||||
try {
|
||||
if (isFav) await removeFavorite(kind, item.id);
|
||||
else await addFavorite(kind, item.id);
|
||||
await removeFromRecent(kind, item.id);
|
||||
} catch (e) {
|
||||
if (isFav) favoriteIds.add(item.id);
|
||||
else favoriteIds.delete(item.id);
|
||||
raw = [...raw.slice(0, idx), snapshot, ...raw.slice(idx)];
|
||||
contextMap.set(item.id, {
|
||||
date: snapshot.accessed_at,
|
||||
ownerId: snapshot.resource.updated_by ?? null
|
||||
});
|
||||
errorToast(e);
|
||||
}
|
||||
}
|
||||
@@ -325,7 +318,6 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void loadFavoriteIds();
|
||||
void load(true);
|
||||
});
|
||||
</script>
|
||||
@@ -336,7 +328,6 @@
|
||||
title={t('nav.recent', 'Recent')}
|
||||
{items}
|
||||
{contextMap}
|
||||
{favoriteIds}
|
||||
resolveOwnerName={(id) => owners.name(id)}
|
||||
{loading}
|
||||
{error}
|
||||
@@ -354,7 +345,6 @@
|
||||
hasMore={!!cursor}
|
||||
onloadmore={() => load(false, orderByForGroup())}
|
||||
onopen={open}
|
||||
onfavorite={toggleFavorite}
|
||||
showOwner
|
||||
showPath
|
||||
showDotfileToggle
|
||||
@@ -397,6 +387,30 @@
|
||||
onclick={() => batchDelete(sel)}>{t('common.delete', 'Delete')}</Button
|
||||
>
|
||||
{/snippet}
|
||||
{#snippet itemActions(item)}
|
||||
<!--
|
||||
Per-row "broom" — remove this single item from the recent
|
||||
history. Replaces the favorite star; on a history view a
|
||||
"forget this one" affordance is more useful than a
|
||||
favorite gesture. Grid view: the shared corner-cluster
|
||||
CSS turns this into a 30x30 scrim pill sitting next to
|
||||
the kebab in the top-right of the card. List view: same
|
||||
`.btn-action` treatment as trash's Restore / Delete
|
||||
buttons at the row's action-cell.
|
||||
-->
|
||||
<button
|
||||
class="btn-action"
|
||||
data-testid={`recent-remove-btn-${item.id}`}
|
||||
title={t('recent.remove_item', 'Remove from recent')}
|
||||
aria-label={t('recent.remove_item', 'Remove from recent')}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
void removeItem(item);
|
||||
}}
|
||||
>
|
||||
<Icon name="broom" />
|
||||
</button>
|
||||
{/snippet}
|
||||
</ResourceList>
|
||||
|
||||
{#if fileViewer.component}
|
||||
|
||||
@@ -583,7 +583,8 @@
|
||||
"empty_hint": "الملفات التي تفتحها ستظهر هنا",
|
||||
"empty_hidden_state": "{{n}} من العناصر الأخيرة مخفية وفقاً لتفضيلاتك",
|
||||
"empty_hidden_hint": "قم بإيقاف تشغيل \"إخفاء الملفات المخفية\" في ملفك الشخصي لرؤيتها.",
|
||||
"loadMore": "تحميل المزيد"
|
||||
"loadMore": "تحميل المزيد",
|
||||
"remove_item": "إزالة من الأخيرة"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "تمت إعادة تسمية الملف",
|
||||
|
||||
@@ -583,7 +583,8 @@
|
||||
"empty_hint": "Dateien, die Sie öffnen, werden hier angezeigt",
|
||||
"empty_hidden_state": "{{n}} zuletzt verwendete(s) Element(e) durch Ihre Einstellung ausgeblendet",
|
||||
"empty_hidden_hint": "Deaktivieren Sie \"Verborgene Dateien ausblenden\" in Ihrem Profil, um sie anzuzeigen.",
|
||||
"loadMore": "Mehr laden"
|
||||
"loadMore": "Mehr laden",
|
||||
"remove_item": "Aus zuletzt verwendet entfernen"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "Datei umbenannt",
|
||||
|
||||
@@ -754,7 +754,8 @@
|
||||
"empty_hidden_state": "{{n}} recent item(s) hidden by your dotfile preference",
|
||||
"empty_hidden_hint": "Turn off \"Hide dotfiles\" in your profile to see them.",
|
||||
"loadMore": "Load more",
|
||||
"confirm_clear": "Clear your recent items?"
|
||||
"confirm_clear": "Clear your recent items?",
|
||||
"remove_item": "Remove from recent"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "File renamed",
|
||||
|
||||
@@ -588,7 +588,8 @@
|
||||
"empty_hint": "Los archivos que abras aparecerán aquí",
|
||||
"empty_hidden_state": "{{n}} elemento(s) reciente(s) oculto(s) por tu preferencia",
|
||||
"empty_hidden_hint": "Desactiva \"Ocultar archivos ocultos\" en tu perfil para verlos.",
|
||||
"loadMore": "Cargar más"
|
||||
"loadMore": "Cargar más",
|
||||
"remove_item": "Quitar de recientes"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "Archivo renombrado",
|
||||
|
||||
@@ -583,7 +583,8 @@
|
||||
"empty_hint": "پروندههایی که باز میکنید اینجا ظاهر میشوند",
|
||||
"empty_hidden_state": "{{n}} مورد اخیر طبق تنظیمات شما پنهان است",
|
||||
"empty_hidden_hint": "برای مشاهده آنها \"پنهان کردن پروندههای پنهان\" را در پروفایل خود غیرفعال کنید.",
|
||||
"loadMore": "بارگذاری بیشتر"
|
||||
"loadMore": "بارگذاری بیشتر",
|
||||
"remove_item": "حذف از اخیر"
|
||||
},
|
||||
"batch": {
|
||||
"one_selected": "۱ مورد انتخاب شده",
|
||||
|
||||
@@ -583,7 +583,8 @@
|
||||
"empty_hint": "Les fichiers que vous ouvrez apparaîtront ici",
|
||||
"empty_hidden_state": "{{n}} élément(s) récent(s) masqué(s) par votre préférence",
|
||||
"empty_hidden_hint": "Désactivez \"Masquer les fichiers\" dans votre profil pour les voir.",
|
||||
"loadMore": "Charger plus"
|
||||
"loadMore": "Charger plus",
|
||||
"remove_item": "Retirer des récents"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "Fichier renommé",
|
||||
|
||||
@@ -583,7 +583,8 @@
|
||||
"empty_hint": "जो फ़ाइलें आप खोलेंगे वे यहाँ दिखेंगी",
|
||||
"empty_hidden_state": "आपकी वरीयता के अनुसार {{n}} हाल की वस्तुएँ छिपी हुई हैं",
|
||||
"empty_hidden_hint": "उन्हें देखने के लिए अपनी प्रोफ़ाइल में \"छिपी फ़ाइलें छिपाएँ\" को बंद करें।",
|
||||
"loadMore": "और लोड करें"
|
||||
"loadMore": "और लोड करें",
|
||||
"remove_item": "हाल के से हटाएँ"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "फ़ाइल का नाम बदला गया",
|
||||
|
||||
@@ -583,7 +583,8 @@
|
||||
"empty_hint": "I file che apri appariranno qui",
|
||||
"empty_hidden_state": "{{n}} elemento/i recente/i nascosto/i dalla tua preferenza",
|
||||
"empty_hidden_hint": "Disattiva \"Nascondi i file nascosti\" nel tuo profilo per vederli.",
|
||||
"loadMore": "Carica altri"
|
||||
"loadMore": "Carica altri",
|
||||
"remove_item": "Rimuovi dai recenti"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "File rinominato",
|
||||
|
||||
@@ -583,7 +583,8 @@
|
||||
"empty_hint": "開いたファイルがここに表示されます",
|
||||
"empty_hidden_state": "設定により非表示になっている最近の項目が {{n}} 件あります",
|
||||
"empty_hidden_hint": "プロフィールで「非表示ファイルを隠す」をオフにすると表示されます。",
|
||||
"loadMore": "さらに読み込む"
|
||||
"loadMore": "さらに読み込む",
|
||||
"remove_item": "最近使用したものから削除"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "ファイル名を変更しました",
|
||||
|
||||
@@ -717,7 +717,8 @@
|
||||
"empty_hidden_state": "설정에 따라 숨겨진 최근 항목 {{n}}개",
|
||||
"empty_hidden_hint": "프로필에서 \"숨겨진 파일 숨기기\"를 끄면 볼 수 있습니다.",
|
||||
"loadMore": "더 불러오기",
|
||||
"confirm_clear": "최근 항목을 지우시겠습니까?"
|
||||
"confirm_clear": "최근 항목을 지우시겠습니까?",
|
||||
"remove_item": "최근에서 제거"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "파일 이름이 변경되었습니다",
|
||||
|
||||
@@ -583,7 +583,8 @@
|
||||
"empty_hint": "Bestanden die je opent verschijnen hier",
|
||||
"empty_hidden_state": "{{n}} recent(e) item(s) verborgen door je voorkeur",
|
||||
"empty_hidden_hint": "Schakel \"Verborgen bestanden verbergen\" uit in je profiel om ze te zien.",
|
||||
"loadMore": "Meer laden"
|
||||
"loadMore": "Meer laden",
|
||||
"remove_item": "Uit recent verwijderen"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "Bestand hernoemd",
|
||||
|
||||
@@ -583,7 +583,8 @@
|
||||
"empty_hint": "Otwarte pliki pojawią się tutaj",
|
||||
"empty_hidden_state": "{{n}} ostatnich elementów ukrytych zgodnie z Twoją preferencją",
|
||||
"empty_hidden_hint": "Wyłącz \"Ukryj ukryte pliki\" w swoim profilu, aby je zobaczyć.",
|
||||
"loadMore": "Załaduj więcej"
|
||||
"loadMore": "Załaduj więcej",
|
||||
"remove_item": "Usuń z ostatnich"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "Zmieniono nazwę pliku",
|
||||
|
||||
@@ -583,7 +583,8 @@
|
||||
"empty_hint": "Os arquivos que você abrir aparecerão aqui",
|
||||
"empty_hidden_state": "{{n}} item(ns) recente(s) oculto(s) pela sua preferência",
|
||||
"empty_hidden_hint": "Desative \"Ocultar arquivos ocultos\" no seu perfil para vê-los.",
|
||||
"loadMore": "Carregar mais"
|
||||
"loadMore": "Carregar mais",
|
||||
"remove_item": "Remover dos recentes"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "Arquivo renomeado",
|
||||
|
||||
@@ -583,7 +583,8 @@
|
||||
"empty_hint": "Открытые вами файлы будут отображаться здесь",
|
||||
"empty_hidden_state": "Недавних элементов скрыто: {{n}}",
|
||||
"empty_hidden_hint": "Отключите \"Скрывать скрытые файлы\" в профиле, чтобы увидеть их.",
|
||||
"loadMore": "Загрузить ещё"
|
||||
"loadMore": "Загрузить ещё",
|
||||
"remove_item": "Удалить из недавних"
|
||||
},
|
||||
"notifications": {
|
||||
"file_renamed": "Файл переименован",
|
||||
|
||||
@@ -583,7 +583,8 @@
|
||||
"empty_hint": "您開啟的檔案將顯示在這裡",
|
||||
"empty_hidden_state": "根據您的偏好隱藏了 {{n}} 個最近項目",
|
||||
"empty_hidden_hint": "在個人資料中關閉「隱藏隱藏檔案」即可查看。",
|
||||
"loadMore": "載入更多"
|
||||
"loadMore": "載入更多",
|
||||
"remove_item": "從最近項目中移除"
|
||||
},
|
||||
"batch": {
|
||||
"one_selected": "已選擇 1 個專案",
|
||||
|
||||
@@ -583,7 +583,8 @@
|
||||
"empty_hint": "您打开的文件将显示在这里",
|
||||
"empty_hidden_state": "根据您的偏好隐藏了 {{n}} 个最近项目",
|
||||
"empty_hidden_hint": "在个人资料中关闭「隐藏隐藏文件」即可查看。",
|
||||
"loadMore": "加载更多"
|
||||
"loadMore": "加载更多",
|
||||
"remove_item": "从最近使用中移除"
|
||||
},
|
||||
"batch": {
|
||||
"one_selected": "已选择 1 个项目",
|
||||
|
||||
Reference in New Issue
Block a user