perf(frontend): load favorite/share badges once per session, not per navigation
The files browser re-fetched the first 200 favorites AND the first 200 outgoing shares on every folder navigation (two round-trips each time) just to render the star / shared badges — work that grew with how much the user browsed, for data that barely changes. Move both id sets into a session-scoped `badges` store: `ensureLoaded()` fetches once (concurrent callers share one in-flight request) and every later navigation reads from cache, so browsing costs zero extra requests. Mutations keep the cache in sync optimistically: - favorite toggle / batch-favorite → `setFavorite` (revert on failure), - share creation → `markShared`, wired through a new optional `onshared` callback on ShareDialog (fired when a grant or public link is created). This also makes the shared badge appear immediately instead of only after re-navigating. Net effect per session: badge fetches drop from O(navigations) × 2 to 2 total. The 200-item ceiling is unchanged from before; the fully-correct fix is per-item flags on the listing endpoint (a backend change, noted in the store). Verified: new badges store unit tests (load-once, concurrent de-dupe, optimistic favorite/share, reset) and a headless render of the real files route in list and grid (virtualization intact, no runtime errors). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8Vb9QHmLZnEMzHz7MrFy6
This commit is contained in:
@@ -43,9 +43,11 @@
|
|||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
item: Target | null;
|
item: Target | null;
|
||||||
|
/** Fired with the item id when an outgoing share (grant or link) is created. */
|
||||||
|
onshared?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { open = $bindable(false), item }: Props = $props();
|
let { open = $bindable(false), item, onshared }: Props = $props();
|
||||||
|
|
||||||
let tab = $state<'people' | 'link'>('people');
|
let tab = $state<'people' | 'link'>('people');
|
||||||
let directoryAvailable = $state(true);
|
let directoryAvailable = $state(true);
|
||||||
@@ -159,6 +161,7 @@
|
|||||||
query = '';
|
query = '';
|
||||||
results = [];
|
results = [];
|
||||||
summarizeNotifications(res.notification.outcomes);
|
summarizeNotifications(res.notification.outcomes);
|
||||||
|
onshared?.(item.id);
|
||||||
await loadGrants();
|
await loadGrants();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast(e);
|
errorToast(e);
|
||||||
@@ -280,6 +283,7 @@
|
|||||||
newLinkName = '';
|
newLinkName = '';
|
||||||
password = '';
|
password = '';
|
||||||
expiresAt = null;
|
expiresAt = null;
|
||||||
|
onshared?.(item.id);
|
||||||
await loadShares();
|
await loadShares();
|
||||||
ui.notify(t('share.created', 'Public link created'), 'success');
|
ui.notify(t('share.created', 'Public link created'), 'success');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* Session-scoped favorite / outgoing-share badge sets.
|
||||||
|
*
|
||||||
|
* The files browser shows a star (favorite) and a link (shared) badge per row.
|
||||||
|
* Previously every folder navigation re-fetched the first 200 favorites AND the
|
||||||
|
* first 200 shares — two round-trips per navigation, for data that barely
|
||||||
|
* changes. This caches both id sets once per session (`ensureLoaded`, deduped)
|
||||||
|
* and keeps them in sync via optimistic mutations from the views that toggle
|
||||||
|
* them, so navigating folders costs zero extra requests.
|
||||||
|
*
|
||||||
|
* (The 200-item ceiling is inherited from the previous implementation; the truly
|
||||||
|
* complete fix is to have the listing endpoint return per-item flags, a backend
|
||||||
|
* change tracked separately.)
|
||||||
|
*/
|
||||||
|
import { fetchFavoritesPage } from '$lib/api/endpoints/favorites';
|
||||||
|
import { fetchMyShares } from '$lib/api/endpoints/grants';
|
||||||
|
|
||||||
|
class BadgesStore {
|
||||||
|
#favorites = $state<Set<string>>(new Set());
|
||||||
|
#shared = $state<Set<string>>(new Set());
|
||||||
|
#loaded = false;
|
||||||
|
#inflight: Promise<void> | null = null;
|
||||||
|
|
||||||
|
isFavorite(id: string): boolean {
|
||||||
|
return this.#favorites.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
isShared(id: string): boolean {
|
||||||
|
return this.#shared.has(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load both id sets once per session. Concurrent callers share one fetch. */
|
||||||
|
ensureLoaded(): Promise<void> {
|
||||||
|
if (this.#loaded) return Promise.resolve();
|
||||||
|
if (this.#inflight) return this.#inflight;
|
||||||
|
this.#inflight = (async () => {
|
||||||
|
const [favs, shares] = await Promise.all([
|
||||||
|
fetchFavoritesPage({ limit: 200 }).catch(() => null),
|
||||||
|
fetchMyShares({ limit: 200 }).catch(() => null)
|
||||||
|
]);
|
||||||
|
if (favs) this.#favorites = new Set(favs.items.map((f) => f.resource.id));
|
||||||
|
if (shares) this.#shared = new Set(shares.items.map((s) => s.resource.id));
|
||||||
|
this.#loaded = true;
|
||||||
|
this.#inflight = null;
|
||||||
|
})();
|
||||||
|
return this.#inflight;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Optimistically reflect a favorite toggle (no refetch). */
|
||||||
|
setFavorite(id: string, on: boolean): void {
|
||||||
|
if (on === this.#favorites.has(id)) return;
|
||||||
|
const next = new Set(this.#favorites);
|
||||||
|
if (on) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
this.#favorites = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark an item as having an outgoing share (after one is created). */
|
||||||
|
markShared(id: string): void {
|
||||||
|
if (this.#shared.has(id)) return;
|
||||||
|
this.#shared = new Set(this.#shared).add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop the cache (e.g. on logout) so the next session reloads fresh. */
|
||||||
|
reset(): void {
|
||||||
|
this.#favorites = new Set();
|
||||||
|
this.#shared = new Set();
|
||||||
|
this.#loaded = false;
|
||||||
|
this.#inflight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const badges = new BadgesStore();
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$lib/api/endpoints/favorites', () => ({ fetchFavoritesPage: vi.fn() }));
|
||||||
|
vi.mock('$lib/api/endpoints/grants', () => ({ fetchMyShares: vi.fn() }));
|
||||||
|
|
||||||
|
import { fetchFavoritesPage } from '$lib/api/endpoints/favorites';
|
||||||
|
import { fetchMyShares } from '$lib/api/endpoints/grants';
|
||||||
|
import { badges } from './badges.svelte';
|
||||||
|
|
||||||
|
const favPage = (...ids: string[]) =>
|
||||||
|
({ items: ids.map((id) => ({ resource: { id } })) }) as unknown as Awaited<
|
||||||
|
ReturnType<typeof fetchFavoritesPage>
|
||||||
|
>;
|
||||||
|
const sharePage = (...ids: string[]) =>
|
||||||
|
({ items: ids.map((id) => ({ resource: { id } })) }) as unknown as Awaited<
|
||||||
|
ReturnType<typeof fetchMyShares>
|
||||||
|
>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(fetchFavoritesPage).mockResolvedValue(favPage('f1', 'f2'));
|
||||||
|
vi.mocked(fetchMyShares).mockResolvedValue(sharePage('s1'));
|
||||||
|
badges.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('badges store', () => {
|
||||||
|
it('loads once and serves every later navigation from cache', async () => {
|
||||||
|
// Five "folder navigations" each call ensureLoaded.
|
||||||
|
for (let i = 0; i < 5; i++) await badges.ensureLoaded();
|
||||||
|
|
||||||
|
expect(fetchFavoritesPage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMyShares).toHaveBeenCalledTimes(1);
|
||||||
|
expect(badges.isFavorite('f1')).toBe(true);
|
||||||
|
expect(badges.isFavorite('f2')).toBe(true);
|
||||||
|
expect(badges.isShared('s1')).toBe(true);
|
||||||
|
expect(badges.isFavorite('nope')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('collapses concurrent loads into a single fetch', async () => {
|
||||||
|
await Promise.all([
|
||||||
|
badges.ensureLoaded(),
|
||||||
|
badges.ensureLoaded(),
|
||||||
|
badges.ensureLoaded(),
|
||||||
|
badges.ensureLoaded()
|
||||||
|
]);
|
||||||
|
expect(fetchFavoritesPage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchMyShares).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects favorite toggles optimistically without refetching', async () => {
|
||||||
|
await badges.ensureLoaded();
|
||||||
|
badges.setFavorite('x', true);
|
||||||
|
expect(badges.isFavorite('x')).toBe(true);
|
||||||
|
badges.setFavorite('x', false);
|
||||||
|
expect(badges.isFavorite('x')).toBe(false);
|
||||||
|
// No extra network for optimistic updates.
|
||||||
|
expect(fetchFavoritesPage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks an item shared after a share is created', async () => {
|
||||||
|
await badges.ensureLoaded();
|
||||||
|
expect(badges.isShared('new')).toBe(false);
|
||||||
|
badges.markShared('new');
|
||||||
|
expect(badges.isShared('new')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reset() clears the cache and allows a fresh reload', async () => {
|
||||||
|
await badges.ensureLoaded();
|
||||||
|
expect(fetchFavoritesPage).toHaveBeenCalledTimes(1);
|
||||||
|
badges.reset();
|
||||||
|
expect(badges.isFavorite('f1')).toBe(false);
|
||||||
|
await badges.ensureLoaded();
|
||||||
|
expect(fetchFavoritesPage).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -25,8 +25,7 @@
|
|||||||
} from '$lib/api/endpoints/files';
|
} from '$lib/api/endpoints/files';
|
||||||
import { folderZipUrl } from '$lib/api/endpoints/folders';
|
import { folderZipUrl } from '$lib/api/endpoints/folders';
|
||||||
import { tryDeltaUpload } from '$lib/api/endpoints/deltaUpload';
|
import { tryDeltaUpload } from '$lib/api/endpoints/deltaUpload';
|
||||||
import { addFavorite, fetchFavoritesPage, removeFavorite } from '$lib/api/endpoints/favorites';
|
import { addFavorite, removeFavorite } from '$lib/api/endpoints/favorites';
|
||||||
import { fetchMyShares } from '$lib/api/endpoints/grants';
|
|
||||||
import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
|
import { canEditWithWopi, getEditorUrlWithFallback } from '$lib/api/endpoints/wopi';
|
||||||
import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music';
|
import { addTracks, createPlaylist, listPlaylists } from '$lib/api/endpoints/music';
|
||||||
import { apiFetch } from '$lib/api/client';
|
import { apiFetch } from '$lib/api/client';
|
||||||
@@ -40,6 +39,7 @@
|
|||||||
import WopiEditor from '$lib/components/WopiEditor.svelte';
|
import WopiEditor from '$lib/components/WopiEditor.svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
import { confirmDialog, promptDialog } from '$lib/stores/dialogs.svelte';
|
||||||
|
import { badges } from '$lib/stores/badges.svelte';
|
||||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||||
import { session } from '$lib/stores/session.svelte';
|
import { session } from '$lib/stores/session.svelte';
|
||||||
import { ui } from '$lib/stores/ui.svelte';
|
import { ui } from '$lib/stores/ui.svelte';
|
||||||
@@ -80,10 +80,6 @@
|
|||||||
let actionTarget = $state<ActionTarget | null>(null);
|
let actionTarget = $state<ActionTarget | null>(null);
|
||||||
let moveItems = $state<ActionTarget[] | null>(null);
|
let moveItems = $state<ActionTarget[] | null>(null);
|
||||||
|
|
||||||
// Favorite + shared badges for items in the current folder.
|
|
||||||
let favoriteIds = $state<Set<string>>(new Set());
|
|
||||||
let sharedIds = $state<Set<string>>(new Set());
|
|
||||||
|
|
||||||
function openMove(kind: ItemType, id: string, name: string) {
|
function openMove(kind: ItemType, id: string, name: string) {
|
||||||
actionTarget = { id, name, kind };
|
actionTarget = { id, name, kind };
|
||||||
moveItems = null;
|
moveItems = null;
|
||||||
@@ -101,33 +97,16 @@
|
|||||||
shareOpen = true;
|
shareOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load favorite + outgoing-share id sets so items can show badges. */
|
|
||||||
async function loadBadges() {
|
|
||||||
try {
|
|
||||||
const [favs, shares] = await Promise.all([
|
|
||||||
fetchFavoritesPage({ limit: 200 }).catch(() => null),
|
|
||||||
fetchMyShares({ limit: 200 }).catch(() => null)
|
|
||||||
]);
|
|
||||||
favoriteIds = new Set((favs?.items ?? []).map((f) => f.resource.id));
|
|
||||||
sharedIds = new Set((shares?.items ?? []).map((s) => s.resource.id));
|
|
||||||
} catch {
|
|
||||||
/* badges are best-effort */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleFavorite(kind: ItemType, id: string) {
|
async function toggleFavorite(kind: ItemType, id: string) {
|
||||||
const isFav = favoriteIds.has(id);
|
const isFav = badges.isFavorite(id);
|
||||||
// Optimistic toggle, reconcile on failure.
|
// Optimistic toggle, reverted on failure.
|
||||||
const next = new Set(favoriteIds);
|
badges.setFavorite(id, !isFav);
|
||||||
if (isFav) next.delete(id);
|
|
||||||
else next.add(id);
|
|
||||||
favoriteIds = next;
|
|
||||||
try {
|
try {
|
||||||
if (isFav) await removeFavorite(kind, id);
|
if (isFav) await removeFavorite(kind, id);
|
||||||
else await addFavorite(kind, id);
|
else await addFavorite(kind, id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast(e);
|
errorToast(e);
|
||||||
await loadBadges();
|
badges.setFavorite(id, isFav);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +147,7 @@
|
|||||||
const [data, trail] = await Promise.all([listFolder(folderId), buildCrumbs(pathSegments)]);
|
const [data, trail] = await Promise.all([listFolder(folderId), buildCrumbs(pathSegments)]);
|
||||||
listing = data;
|
listing = data;
|
||||||
crumbs = trail;
|
crumbs = trail;
|
||||||
void loadBadges();
|
void badges.ensureLoaded();
|
||||||
maybeOpenDeepLink();
|
maybeOpenDeepLink();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 403 → friendly message rather than the raw "Forbidden" error string.
|
// 403 → friendly message rather than the raw "Forbidden" error string.
|
||||||
@@ -444,7 +423,7 @@
|
|||||||
|
|
||||||
/** Batch add the selection to favorites — single /api/favorites/batch call. */
|
/** Batch add the selection to favorites — single /api/favorites/batch call. */
|
||||||
async function batchFavorites() {
|
async function batchFavorites() {
|
||||||
const items = selectionTargets().filter((it) => !favoriteIds.has(it.id));
|
const items = selectionTargets().filter((it) => !badges.isFavorite(it.id));
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
ui.notify(t('files.already_favorites', 'All selected items are already favorites'), 'info');
|
ui.notify(t('files.already_favorites', 'All selected items are already favorites'), 'info');
|
||||||
clearSelection();
|
clearSelection();
|
||||||
@@ -460,10 +439,9 @@
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
||||||
favoriteIds = new Set([...favoriteIds, ...items.map((it) => it.id)]);
|
for (const it of items) badges.setFavorite(it.id, true);
|
||||||
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
|
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
|
||||||
clearSelection();
|
clearSelection();
|
||||||
void loadBadges();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast(e);
|
errorToast(e);
|
||||||
}
|
}
|
||||||
@@ -1218,13 +1196,13 @@
|
|||||||
<div class="name-cell">
|
<div class="name-cell">
|
||||||
<div class="file-icon"><Icon name="folder" /></div>
|
<div class="file-icon"><Icon name="folder" /></div>
|
||||||
<span title={folder.name}>{folder.name}</span>
|
<span title={folder.name}>{folder.name}</span>
|
||||||
{#if favoriteIds.has(folder.id)}<div
|
{#if badges.isFavorite(folder.id)}<div
|
||||||
class="item-badge item-badge--fav"
|
class="item-badge item-badge--fav"
|
||||||
title={t('files.favorited', 'Favorite')}
|
title={t('files.favorited', 'Favorite')}
|
||||||
>
|
>
|
||||||
<Icon name="star" />
|
<Icon name="star" />
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
{#if sharedIds.has(folder.id)}<div
|
{#if badges.isShared(folder.id)}<div
|
||||||
class="file-badge file-badge-shared"
|
class="file-badge file-badge-shared"
|
||||||
title={t('files.shared', 'Shared')}
|
title={t('files.shared', 'Shared')}
|
||||||
>
|
>
|
||||||
@@ -1241,15 +1219,15 @@
|
|||||||
<div class="action-cell">
|
<div class="action-cell">
|
||||||
<button
|
<button
|
||||||
class="favorite-star"
|
class="favorite-star"
|
||||||
class:active={favoriteIds.has(folder.id)}
|
class:active={badges.isFavorite(folder.id)}
|
||||||
title={favoriteIds.has(folder.id)
|
title={badges.isFavorite(folder.id)
|
||||||
? t('files.unfavorite', 'Remove favorite')
|
? t('files.unfavorite', 'Remove favorite')
|
||||||
: t('files.favorite', 'Add favorite')}
|
: t('files.favorite', 'Add favorite')}
|
||||||
aria-pressed={favoriteIds.has(folder.id)}
|
aria-pressed={badges.isFavorite(folder.id)}
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void toggleFavorite('folder', folder.id);
|
void toggleFavorite('folder', folder.id);
|
||||||
}}><Icon name={favoriteIds.has(folder.id) ? 'star' : 'star-outline'} /></button
|
}}><Icon name={badges.isFavorite(folder.id) ? 'star' : 'star-outline'} /></button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="btn-action"
|
class="btn-action"
|
||||||
@@ -1336,13 +1314,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<span title={file.name}>{file.name}</span>
|
<span title={file.name}>{file.name}</span>
|
||||||
{#if favoriteIds.has(file.id)}<div
|
{#if badges.isFavorite(file.id)}<div
|
||||||
class="item-badge item-badge--fav"
|
class="item-badge item-badge--fav"
|
||||||
title={t('files.favorited', 'Favorite')}
|
title={t('files.favorited', 'Favorite')}
|
||||||
>
|
>
|
||||||
<Icon name="star" />
|
<Icon name="star" />
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
{#if sharedIds.has(file.id)}<div
|
{#if badges.isShared(file.id)}<div
|
||||||
class="file-badge file-badge-shared"
|
class="file-badge file-badge-shared"
|
||||||
title={t('files.shared', 'Shared')}
|
title={t('files.shared', 'Shared')}
|
||||||
>
|
>
|
||||||
@@ -1360,15 +1338,15 @@
|
|||||||
<div class="action-cell">
|
<div class="action-cell">
|
||||||
<button
|
<button
|
||||||
class="favorite-star"
|
class="favorite-star"
|
||||||
class:active={favoriteIds.has(file.id)}
|
class:active={badges.isFavorite(file.id)}
|
||||||
title={favoriteIds.has(file.id)
|
title={badges.isFavorite(file.id)
|
||||||
? t('files.unfavorite', 'Remove favorite')
|
? t('files.unfavorite', 'Remove favorite')
|
||||||
: t('files.favorite', 'Add favorite')}
|
: t('files.favorite', 'Add favorite')}
|
||||||
aria-pressed={favoriteIds.has(file.id)}
|
aria-pressed={badges.isFavorite(file.id)}
|
||||||
onclick={(e) => {
|
onclick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
void toggleFavorite('file', file.id);
|
void toggleFavorite('file', file.id);
|
||||||
}}><Icon name={favoriteIds.has(file.id) ? 'star' : 'star-outline'} /></button
|
}}><Icon name={badges.isFavorite(file.id) ? 'star' : 'star-outline'} /></button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="btn-action"
|
class="btn-action"
|
||||||
@@ -1431,7 +1409,7 @@
|
|||||||
void load();
|
void load();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<ShareDialog bind:open={shareOpen} item={actionTarget} />
|
<ShareDialog bind:open={shareOpen} item={actionTarget} onshared={(id) => badges.markShared(id)} />
|
||||||
<FileViewer bind:open={viewerOpen} file={viewerFile} />
|
<FileViewer bind:open={viewerOpen} file={viewerFile} />
|
||||||
<WopiEditor
|
<WopiEditor
|
||||||
bind:open={wopiOpen}
|
bind:open={wopiOpen}
|
||||||
@@ -1562,7 +1540,7 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Icon name="star" />
|
<Icon name="star" />
|
||||||
{favoriteIds.has(ctxTarget.id)
|
{badges.isFavorite(ctxTarget.id)
|
||||||
? t('files.unfavorite', 'Remove favorite')
|
? t('files.unfavorite', 'Remove favorite')
|
||||||
: t('files.favorite', 'Add favorite')}
|
: t('files.favorite', 'Add favorite')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user