diff --git a/frontend/src/lib/api/endpoints/recent.ts b/frontend/src/lib/api/endpoints/recent.ts index ce5caa34..77820cee 100644 --- a/frontend/src/lib/api/endpoints/recent.ts +++ b/frontend/src/lib/api/endpoints/recent.ts @@ -30,3 +30,20 @@ export async function clearRecent(): Promise { }); 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 { + 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}`); + } +} diff --git a/frontend/src/lib/styles/ported/resourceList.css b/frontend/src/lib/styles/ported/resourceList.css index 595ba4b8..47a6ecc2 100644 --- a/frontend/src/lib/styles/ported/resourceList.css +++ b/frontend/src/lib/styles/ported/resourceList.css @@ -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); } diff --git a/frontend/src/routes/recent/+page.svelte b/frontend/src/routes/recent/+page.svelte index 47190285..961e8f8b 100644 --- a/frontend/src/routes/recent/+page.svelte +++ b/frontend/src/routes/recent/+page.svelte @@ -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([]); let cursor = $state(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(); // 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(); 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); }); @@ -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')} {/snippet} + {#snippet itemActions(item)} + + + {/snippet} {#if fileViewer.component} diff --git a/frontend/static/locales/ar.json b/frontend/static/locales/ar.json index 54dfef91..bec11111 100644 --- a/frontend/static/locales/ar.json +++ b/frontend/static/locales/ar.json @@ -583,7 +583,8 @@ "empty_hint": "الملفات التي تفتحها ستظهر هنا", "empty_hidden_state": "{{n}} من العناصر الأخيرة مخفية وفقاً لتفضيلاتك", "empty_hidden_hint": "قم بإيقاف تشغيل \"إخفاء الملفات المخفية\" في ملفك الشخصي لرؤيتها.", - "loadMore": "تحميل المزيد" + "loadMore": "تحميل المزيد", + "remove_item": "إزالة من الأخيرة" }, "notifications": { "file_renamed": "تمت إعادة تسمية الملف", diff --git a/frontend/static/locales/de.json b/frontend/static/locales/de.json index 406372ad..426ff4d5 100644 --- a/frontend/static/locales/de.json +++ b/frontend/static/locales/de.json @@ -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", diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index 74281e11..dace2430 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -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", diff --git a/frontend/static/locales/es.json b/frontend/static/locales/es.json index a921a8d5..f44e243f 100644 --- a/frontend/static/locales/es.json +++ b/frontend/static/locales/es.json @@ -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", diff --git a/frontend/static/locales/fa.json b/frontend/static/locales/fa.json index baabb94e..0137754c 100644 --- a/frontend/static/locales/fa.json +++ b/frontend/static/locales/fa.json @@ -583,7 +583,8 @@ "empty_hint": "پرونده‌هایی که باز می‌کنید اینجا ظاهر می‌شوند", "empty_hidden_state": "{{n}} مورد اخیر طبق تنظیمات شما پنهان است", "empty_hidden_hint": "برای مشاهده آن‌ها \"پنهان کردن پرونده‌های پنهان\" را در پروفایل خود غیرفعال کنید.", - "loadMore": "بارگذاری بیشتر" + "loadMore": "بارگذاری بیشتر", + "remove_item": "حذف از اخیر" }, "batch": { "one_selected": "۱ مورد انتخاب شده", diff --git a/frontend/static/locales/fr.json b/frontend/static/locales/fr.json index e79b9d3f..af817840 100644 --- a/frontend/static/locales/fr.json +++ b/frontend/static/locales/fr.json @@ -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é", diff --git a/frontend/static/locales/hi.json b/frontend/static/locales/hi.json index 83e871b3..de4fc33b 100644 --- a/frontend/static/locales/hi.json +++ b/frontend/static/locales/hi.json @@ -583,7 +583,8 @@ "empty_hint": "जो फ़ाइलें आप खोलेंगे वे यहाँ दिखेंगी", "empty_hidden_state": "आपकी वरीयता के अनुसार {{n}} हाल की वस्तुएँ छिपी हुई हैं", "empty_hidden_hint": "उन्हें देखने के लिए अपनी प्रोफ़ाइल में \"छिपी फ़ाइलें छिपाएँ\" को बंद करें।", - "loadMore": "और लोड करें" + "loadMore": "और लोड करें", + "remove_item": "हाल के से हटाएँ" }, "notifications": { "file_renamed": "फ़ाइल का नाम बदला गया", diff --git a/frontend/static/locales/it.json b/frontend/static/locales/it.json index f0213557..8b0a6e4e 100644 --- a/frontend/static/locales/it.json +++ b/frontend/static/locales/it.json @@ -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", diff --git a/frontend/static/locales/ja.json b/frontend/static/locales/ja.json index c24eb809..90afc95b 100644 --- a/frontend/static/locales/ja.json +++ b/frontend/static/locales/ja.json @@ -583,7 +583,8 @@ "empty_hint": "開いたファイルがここに表示されます", "empty_hidden_state": "設定により非表示になっている最近の項目が {{n}} 件あります", "empty_hidden_hint": "プロフィールで「非表示ファイルを隠す」をオフにすると表示されます。", - "loadMore": "さらに読み込む" + "loadMore": "さらに読み込む", + "remove_item": "最近使用したものから削除" }, "notifications": { "file_renamed": "ファイル名を変更しました", diff --git a/frontend/static/locales/ko.json b/frontend/static/locales/ko.json index 366d5daa..f7247818 100644 --- a/frontend/static/locales/ko.json +++ b/frontend/static/locales/ko.json @@ -717,7 +717,8 @@ "empty_hidden_state": "설정에 따라 숨겨진 최근 항목 {{n}}개", "empty_hidden_hint": "프로필에서 \"숨겨진 파일 숨기기\"를 끄면 볼 수 있습니다.", "loadMore": "더 불러오기", - "confirm_clear": "최근 항목을 지우시겠습니까?" + "confirm_clear": "최근 항목을 지우시겠습니까?", + "remove_item": "최근에서 제거" }, "notifications": { "file_renamed": "파일 이름이 변경되었습니다", diff --git a/frontend/static/locales/nl.json b/frontend/static/locales/nl.json index 0df6d4a3..9c85988c 100644 --- a/frontend/static/locales/nl.json +++ b/frontend/static/locales/nl.json @@ -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", diff --git a/frontend/static/locales/pl.json b/frontend/static/locales/pl.json index c3623cf2..4bb34dfa 100644 --- a/frontend/static/locales/pl.json +++ b/frontend/static/locales/pl.json @@ -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", diff --git a/frontend/static/locales/pt.json b/frontend/static/locales/pt.json index 49f2d839..2cf24b96 100644 --- a/frontend/static/locales/pt.json +++ b/frontend/static/locales/pt.json @@ -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", diff --git a/frontend/static/locales/ru.json b/frontend/static/locales/ru.json index 5dec0839..7a3c6fee 100644 --- a/frontend/static/locales/ru.json +++ b/frontend/static/locales/ru.json @@ -583,7 +583,8 @@ "empty_hint": "Открытые вами файлы будут отображаться здесь", "empty_hidden_state": "Недавних элементов скрыто: {{n}}", "empty_hidden_hint": "Отключите \"Скрывать скрытые файлы\" в профиле, чтобы увидеть их.", - "loadMore": "Загрузить ещё" + "loadMore": "Загрузить ещё", + "remove_item": "Удалить из недавних" }, "notifications": { "file_renamed": "Файл переименован", diff --git a/frontend/static/locales/zh-TW.json b/frontend/static/locales/zh-TW.json index 135970b9..715eff41 100644 --- a/frontend/static/locales/zh-TW.json +++ b/frontend/static/locales/zh-TW.json @@ -583,7 +583,8 @@ "empty_hint": "您開啟的檔案將顯示在這裡", "empty_hidden_state": "根據您的偏好隱藏了 {{n}} 個最近項目", "empty_hidden_hint": "在個人資料中關閉「隱藏隱藏檔案」即可查看。", - "loadMore": "載入更多" + "loadMore": "載入更多", + "remove_item": "從最近項目中移除" }, "batch": { "one_selected": "已選擇 1 個專案", diff --git a/frontend/static/locales/zh.json b/frontend/static/locales/zh.json index 5ef8ca75..579f04cb 100644 --- a/frontend/static/locales/zh.json +++ b/frontend/static/locales/zh.json @@ -583,7 +583,8 @@ "empty_hint": "您打开的文件将显示在这里", "empty_hidden_state": "根据您的偏好隐藏了 {{n}} 个最近项目", "empty_hidden_hint": "在个人资料中关闭「隐藏隐藏文件」即可查看。", - "loadMore": "加载更多" + "loadMore": "加载更多", + "remove_item": "从最近使用中移除" }, "batch": { "one_selected": "已选择 1 个项目",