Merge pull request #641 from EdouardVanbelle/refactor/front-resource-list
feat(fileDto, folderDto): add is_favorite + is_shared
This commit is contained in:
@@ -29,9 +29,7 @@ function fakeRes(opts: { status: number; body?: ResourcePage }): Response {
|
||||
|
||||
const emptyListing = (): FolderListing => ({
|
||||
folders: [],
|
||||
files: [],
|
||||
favoriteIds: [],
|
||||
sharedIds: []
|
||||
files: []
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -56,7 +54,6 @@ describe('fetchFolderListing (cursor-paginated /resources)', () => {
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.listing?.folders.map((f) => f.id)).toEqual(['d1']);
|
||||
expect(r.listing?.files.map((f) => f.id)).toEqual(['x1']);
|
||||
expect(r.listing?.favoriteIds).toEqual([]);
|
||||
expect(vi.mocked(apiFetch).mock.calls[0][0]).toContain('/api/folders/f1/resources');
|
||||
});
|
||||
|
||||
@@ -124,9 +121,7 @@ describe('folder name cache (breadcrumbs)', () => {
|
||||
it("learns its children's names from a cached listing", () => {
|
||||
cacheFolder('nc-parent', {
|
||||
folders: [folder('nc-a', 'Alpha'), folder('nc-b', 'Beta')],
|
||||
files: [],
|
||||
favoriteIds: [],
|
||||
sharedIds: []
|
||||
files: []
|
||||
});
|
||||
expect(getFolderName('nc-a')).toBe('Alpha');
|
||||
expect(getFolderName('nc-b')).toBe('Beta');
|
||||
|
||||
@@ -13,10 +13,9 @@ const NO_CACHE: RequestInit = {
|
||||
export interface FolderListing {
|
||||
folders: FolderItem[];
|
||||
files: FileItem[];
|
||||
/** Ids in this listing the caller has favorited (server-computed badge set). */
|
||||
favoriteIds: string[];
|
||||
/** Ids in this listing the caller has an outgoing share/grant on. */
|
||||
sharedIds: string[];
|
||||
// Legacy `favoriteIds` / `sharedIds` fields are gone — the same
|
||||
// signals now live inline on every `FileItem` / `FolderItem`
|
||||
// (`is_favorite`, `is_shared`). Consumers read those directly.
|
||||
}
|
||||
|
||||
/** Result of a (possibly conditional) listing fetch. */
|
||||
@@ -213,13 +212,13 @@ export async function fetchFolderListing(
|
||||
files.push(...page.files);
|
||||
cursor = page.nextCursor;
|
||||
} while (cursor);
|
||||
return { status: 200, listing: { folders, files, favoriteIds: [], sharedIds: [] } };
|
||||
return { status: 200, listing: { folders, files } };
|
||||
}
|
||||
|
||||
/** Non-conditional listing fetch (e.g. the move-dialog folder tree). */
|
||||
export async function listFolder(folderId: string, forceRefresh = false): Promise<FolderListing> {
|
||||
const res = await fetchFolderListing(folderId, { forceRefresh });
|
||||
return res.listing ?? { folders: [], files: [], favoriteIds: [], sharedIds: [] };
|
||||
return res.listing ?? { folders: [], files: [] };
|
||||
}
|
||||
|
||||
export async function createFolder(name: string, parentId: string | null): Promise<FolderItem> {
|
||||
|
||||
@@ -89,6 +89,21 @@ export function expiryToIso(date: string | null | undefined): string | null {
|
||||
return date ? new Date(`${date}T00:00:00Z`).toISOString() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Today's date in YYYY-MM-DD form (local time zone). Used as the `min`
|
||||
* attribute on grant / share expiry date inputs so the native picker
|
||||
* refuses to select a past date. Callers should also validate the
|
||||
* changed value in their `onchange` handler as a belt-and-braces guard
|
||||
* (some browsers still let scripted / paste input bypass `min`).
|
||||
*/
|
||||
export function todayIso(): string {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export function fetchGrantsForResource(type: GrantResourceType, id: string): Promise<Grant[]> {
|
||||
const params = new URLSearchParams({ resource_type: type, resource_id: id });
|
||||
return apiJson<Grant[]>(`/api/grants?${params}`, { credentials: 'same-origin' });
|
||||
|
||||
@@ -48,6 +48,21 @@ export interface FolderItem {
|
||||
* leading segment being a drive-root folder id.
|
||||
*/
|
||||
drive_id: string;
|
||||
/**
|
||||
* Caller-scoped: `true` when the requesting user has favorited
|
||||
* this folder. Always present on the wire — never null, never
|
||||
* absent — per the backend enrichment contract. `ResourceList`
|
||||
* renders the fav-star chip natively from this field.
|
||||
*/
|
||||
is_favorite: boolean;
|
||||
/**
|
||||
* Resource-scoped: `true` when the folder has any
|
||||
* `storage.role_grants` entry (link share via `subject_type =
|
||||
* 'token'`, user grant, group grant, any role). "Someone was
|
||||
* given access to this beyond drive membership." Always present
|
||||
* on the wire.
|
||||
*/
|
||||
is_shared: boolean;
|
||||
}
|
||||
|
||||
export interface FileItem {
|
||||
@@ -70,6 +85,10 @@ export interface FileItem {
|
||||
sort_date: number;
|
||||
etag: string;
|
||||
content_hash: string;
|
||||
/** See `FolderItem.is_favorite` — same wire contract. */
|
||||
is_favorite: boolean;
|
||||
/** See `FolderItem.is_shared` — same wire contract. */
|
||||
is_shared: boolean;
|
||||
/** Search-only: plain-text fragment around a content match. */
|
||||
snippet?: string;
|
||||
/** Search-only: "name" or "content". */
|
||||
|
||||
@@ -69,9 +69,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
m(listFolder).mockResolvedValue({
|
||||
folders: [folder('sub1', 'Sub')],
|
||||
files: [],
|
||||
favoriteIds: [],
|
||||
sharedIds: []
|
||||
files: []
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -119,13 +119,6 @@
|
||||
* fields (`modified_at`, `created_by`).
|
||||
*/
|
||||
contextMap?: Map<string, ItemContext>;
|
||||
/**
|
||||
* Set of item ids the caller considers "favorite". When
|
||||
* provided, the star widget renders next to each row and
|
||||
* `onfavorite` is invoked on click. Kept as an external Set so
|
||||
* the page owns the source of truth (e.g. the favorites store).
|
||||
*/
|
||||
favoriteIds?: Set<string>;
|
||||
/**
|
||||
* Resolve `userId → display name`. Optional; when absent
|
||||
* `UserVignette` falls back to its own internal resolution.
|
||||
@@ -213,6 +206,14 @@
|
||||
onopen?: (item: FileItem | FolderItem) => void;
|
||||
/** Per-item favorite star toggle. */
|
||||
onfavorite?: (item: FileItem | FolderItem) => void;
|
||||
/**
|
||||
* Per-item share affordance — opens the page's ShareDialog for
|
||||
* this row. Wired symmetrically to `onfavorite`: the button lives
|
||||
* in `.action-cell`, its `.active` styling tracks `item.is_shared`,
|
||||
* and rows that are shared keep the button visible in list view
|
||||
* even when the row isn't hovered.
|
||||
*/
|
||||
onshared?: (item: FileItem | FolderItem) => void;
|
||||
/** Selection changed (set of selected item ids). */
|
||||
onselectionchange?: (ids: Set<string>) => void;
|
||||
/**
|
||||
@@ -353,7 +354,6 @@
|
||||
title,
|
||||
items,
|
||||
contextMap,
|
||||
favoriteIds,
|
||||
resolveOwnerName,
|
||||
loading = false,
|
||||
error = null,
|
||||
@@ -383,6 +383,7 @@
|
||||
onreload,
|
||||
onopen,
|
||||
onfavorite,
|
||||
onshared,
|
||||
onselectionchange,
|
||||
oncontextmenu: onContextMenuOverride,
|
||||
menuPrepare,
|
||||
@@ -463,7 +464,11 @@
|
||||
// gate below. Feeds both the list-view column track and the header
|
||||
// row's trailing placeholder so the layout stays in sync.
|
||||
const hasActionCell = $derived(
|
||||
!!onfavorite || !!itemActions || !!onContextMenuOverride || !!contextActions?.length
|
||||
!!onfavorite ||
|
||||
!!onshared ||
|
||||
!!itemActions ||
|
||||
!!onContextMenuOverride ||
|
||||
!!contextActions?.length
|
||||
);
|
||||
|
||||
// Build the list-view column track from the enabled cells.
|
||||
@@ -978,7 +983,6 @@
|
||||
{#snippet row(item: FileItem | FolderItem)}
|
||||
{@const kind = isFile(item) ? 'file' : 'folder'}
|
||||
{@const iconName = kind === 'folder' ? 'folder' : iconNameFromClass(iconClassOf(item))}
|
||||
{@const isFav = favoriteIds?.has(item.id) ?? false}
|
||||
{@const ctx = ctxOf(item.id)}
|
||||
{@const ownerId = ownerIdOf(item)}
|
||||
{@const dateVal = dateOf(item)}
|
||||
@@ -1060,11 +1064,11 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<!-- Row badge (e.g. /trash's expiration chip) sits absolutely
|
||||
inside `.file-icon` — one DOM location, one render, works
|
||||
for both views. Because it's positioned absolutely it
|
||||
never affects the row/card height; the surrounding
|
||||
layout can't stretch to accommodate it. -->
|
||||
<!-- `rowBadge` snippet — page-specific extension slot (e.g.
|
||||
`/trash`'s expiration chip). `is_favorite` / `is_shared`
|
||||
are NOT rendered here — both are surfaced as buttons in
|
||||
`.action-cell` so the two flags share a single visual
|
||||
grammar (button whose `.active` styling tracks the flag). -->
|
||||
{#if rowBadge}
|
||||
<span class="file-icon__badge">{@render rowBadge(item, ctx)}</span>
|
||||
{/if}
|
||||
@@ -1106,31 +1110,55 @@
|
||||
</div>
|
||||
<!--
|
||||
Every row that surfaces an action puts everything into a single
|
||||
`.action-cell` — the shared `ported/resourceList.css` styles both
|
||||
the favorite-star and the `.file-actions` kebab expecting them to
|
||||
live inside `.action-cell` (grid view uses the corner-overlay CSS
|
||||
to float `.file-actions` into the top-right; list view flexes them
|
||||
inline). The cell renders when ANY of favorite / itemActions /
|
||||
context-menu is enabled; a row with none of those still lays out
|
||||
cleanly because the columns collapse via the grid track.
|
||||
`.action-cell` — the shared `ported/resourceList.css` styles the
|
||||
favorite-star, the shared-button, and the `.file-actions` kebab
|
||||
expecting them to live inside `.action-cell` (grid view uses the
|
||||
corner-overlay CSS to float them into the top-right; list view
|
||||
flexes them inline). The cell renders when ANY of favorite /
|
||||
shared / itemActions / context-menu is enabled; a row with none
|
||||
of those still lays out cleanly because the columns collapse via
|
||||
the grid track.
|
||||
|
||||
Fav-star and shared-button share the same interaction grammar:
|
||||
each toggles/opens the corresponding affordance, each carries an
|
||||
`.active` class that tracks its DTO flag, and (via CSS in
|
||||
`ported/resourceList.css`) each stays visible in list view even
|
||||
without a row hover when its `.active` flag is set — so a
|
||||
favorited or shared row is discoverable at a glance without the
|
||||
user having to mouse over it.
|
||||
-->
|
||||
{#if onfavorite || itemActions || onContextMenuOverride || contextActions?.length}
|
||||
{#if hasActionCell}
|
||||
<div class="action-cell">
|
||||
{#if onshared}
|
||||
<button
|
||||
class="shared-button"
|
||||
class:active={item.is_shared}
|
||||
data-testid={`resource-list-shared-${item.id}-btn`}
|
||||
title={item.is_shared ? t('files.shared', 'Shared') : t('files.share', 'Share')}
|
||||
aria-pressed={item.is_shared}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onshared(item);
|
||||
}}
|
||||
>
|
||||
<Icon name="oxiexport" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if onfavorite}
|
||||
<button
|
||||
class="favorite-star"
|
||||
class:active={isFav}
|
||||
class:active={item.is_favorite}
|
||||
data-testid={`resource-list-favorite-${item.id}-btn`}
|
||||
title={isFav
|
||||
title={item.is_favorite
|
||||
? t('files.unfavorite', 'Remove favorite')
|
||||
: t('files.favorite', 'Add favorite')}
|
||||
aria-pressed={isFav}
|
||||
aria-pressed={item.is_favorite}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onfavorite(item);
|
||||
}}
|
||||
>
|
||||
<Icon name={isFav ? 'star' : 'star-outline'} />
|
||||
<Icon name={item.is_favorite ? 'star' : 'star-outline'} />
|
||||
</button>
|
||||
{/if}
|
||||
{#if itemActions}{@render itemActions(item)}{/if}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
fetchGrantsForResource,
|
||||
notifyGrantRecipient,
|
||||
revokeGrant,
|
||||
todayIso,
|
||||
updateGrantRole,
|
||||
type Grant,
|
||||
type GrantSubject,
|
||||
@@ -378,15 +379,33 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- ── Reusable expiry chip ─────────────────────────────────────────────── -->
|
||||
<!-- ── Reusable expiry chip ───────────────────────────────────────────────
|
||||
|
||||
Both branches drive a `<input type="date">` and open the native
|
||||
picker via `HTMLInputElement.showPicker()`. The previous invisible-
|
||||
overlay trick (an `opacity: 0` input covering a chip label) was
|
||||
unreliable — some browsers refuse to open the picker for a hidden
|
||||
input, which is why "Set expiry" appeared inert. `showPicker()`
|
||||
is the modern, explicit path and works from a button click.
|
||||
|
||||
`min={todayIso()}` (from `../../api/endpoints/grants`) hard-caps
|
||||
the picker to today-or-later so a past date can't be selected.
|
||||
The `onchange` handler mirrors the same check as a belt-and-braces
|
||||
guard against the min attribute being ignored. -->
|
||||
{#snippet expiryChip(value: string | null, onchange: (v: string | null) => void)}
|
||||
{@const today = todayIso()}
|
||||
<span class="chip-edit">
|
||||
{#if value}
|
||||
<input
|
||||
class="chip-edit__date"
|
||||
type="date"
|
||||
value={value ?? ''}
|
||||
onchange={(e) => onchange((e.currentTarget as HTMLInputElement).value || null)}
|
||||
min={today}
|
||||
onchange={(e) => {
|
||||
const v = (e.currentTarget as HTMLInputElement).value;
|
||||
if (v && v < today) return;
|
||||
onchange(v || null);
|
||||
}}
|
||||
aria-label={t('share.expiry', 'Expiry')}
|
||||
/>
|
||||
<button
|
||||
@@ -396,16 +415,34 @@
|
||||
aria-label={t('actions.clear', 'Clear')}>×</button
|
||||
>
|
||||
{:else}
|
||||
<label class="chip chip--ghost">
|
||||
<Icon name="infinity" />
|
||||
<span>{t('share.noExpiry', 'No expiry')}</span>
|
||||
<input
|
||||
class="chip-edit__date chip-edit__date--hidden"
|
||||
type="date"
|
||||
onchange={(e) => onchange((e.currentTarget as HTMLInputElement).value || null)}
|
||||
<span class="chip-edit__ghost">
|
||||
<button
|
||||
type="button"
|
||||
class="chip chip--ghost"
|
||||
aria-label={t('share.set_expiry', 'Set expiry')}
|
||||
onclick={(e) => {
|
||||
const picker = (e.currentTarget as HTMLElement)
|
||||
.nextElementSibling as HTMLInputElement | null;
|
||||
picker?.showPicker?.();
|
||||
picker?.focus();
|
||||
}}
|
||||
>
|
||||
<Icon name="infinity" />
|
||||
<span>{t('share.noExpiry', 'No expiry')}</span>
|
||||
</button>
|
||||
<input
|
||||
class="chip-edit__date chip-edit__date--offscreen"
|
||||
type="date"
|
||||
min={today}
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
onchange={(e) => {
|
||||
const v = (e.currentTarget as HTMLInputElement).value;
|
||||
if (v && v < today) return;
|
||||
onchange(v || null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
@@ -584,7 +621,12 @@
|
||||
type="date"
|
||||
data-testid="share-dialog-link-expires-input"
|
||||
value={expiresAt ?? ''}
|
||||
onchange={(e) => (expiresAt = e.currentTarget.value || null)}
|
||||
min={todayIso()}
|
||||
onchange={(e) => {
|
||||
const v = e.currentTarget.value;
|
||||
if (v && v < todayIso()) return;
|
||||
expiresAt = v || null;
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -892,7 +934,19 @@
|
||||
|
||||
.chip--ghost {
|
||||
border-style: dashed;
|
||||
color: var(--color-text-muted);
|
||||
/* WCAG-friendly foreground on both light and dark surfaces —
|
||||
`--color-text-muted` was under the minimum AA contrast ratio,
|
||||
making "No expiry" hard to read. Use the subtle-but-not-muted
|
||||
text token instead, and give the ghost chip a low-tint
|
||||
background so it visually separates from the modal body. */
|
||||
color: var(--color-text-subtle);
|
||||
background: var(--color-bg-input);
|
||||
}
|
||||
|
||||
.chip--ghost:hover,
|
||||
.chip--ghost:focus-visible {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.chip-edit__date {
|
||||
@@ -904,11 +958,24 @@
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.chip-edit__date--hidden {
|
||||
/* Positions the hidden `<input type="date">` off-screen (no `display:
|
||||
none` — `showPicker()` refuses to open on a display:none input in
|
||||
several browsers). The button next to it invokes `showPicker()`
|
||||
programmatically. */
|
||||
.chip-edit__ghost {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chip-edit__date--offscreen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chip-edit__clear {
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock('$lib/api/endpoints/grants', () => ({
|
||||
fetchGrantsForResource: vi.fn(),
|
||||
notifyGrantRecipient: vi.fn(),
|
||||
revokeGrant: vi.fn(),
|
||||
todayIso: () => '2026-07-22',
|
||||
updateGrantRole: vi.fn()
|
||||
}));
|
||||
vi.mock('$lib/api/endpoints/recipients', () => ({
|
||||
|
||||
@@ -43,6 +43,8 @@ interface TestFile {
|
||||
sort_date: number;
|
||||
etag: string;
|
||||
content_hash: string;
|
||||
is_favorite: boolean;
|
||||
is_shared: boolean;
|
||||
}
|
||||
|
||||
function fileItem(i: number): TestFile {
|
||||
@@ -63,7 +65,9 @@ function fileItem(i: number): TestFile {
|
||||
size_formatted: '4 B',
|
||||
sort_date: 0,
|
||||
etag: 'e',
|
||||
content_hash: 'h'
|
||||
content_hash: 'h',
|
||||
is_favorite: false,
|
||||
is_shared: false
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -178,10 +178,6 @@
|
||||
background-color: var(--color-warning-ring);
|
||||
}
|
||||
|
||||
.file-badge-shared {
|
||||
color: var(--color-badge-blue-text);
|
||||
}
|
||||
|
||||
.file-item .file-icon > i,
|
||||
.file-item .file-icon > svg {
|
||||
position: absolute;
|
||||
@@ -460,7 +456,17 @@
|
||||
}
|
||||
|
||||
.files-list-view .file-item .action-cell {
|
||||
/* Right-justified flex row: gives every list-view action button
|
||||
(shared, fav, broom, kebab, trash's restore/delete, …) a
|
||||
predictable horizontal cluster with a stable gap. The legacy
|
||||
`display: inline` on individual buttons ignored `width`/`height`,
|
||||
so once /recent grew a 4th button the mix of `inline` + `inline-
|
||||
flex` (`.btn-action`) started wrapping onto pseudo-rows that
|
||||
read as a broken grid. Flex flattens the mix into one line. */
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -493,27 +499,44 @@
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
/* could be visible if we want */
|
||||
.files-list-view .file-item .action-cell button.favorite-star {
|
||||
display: none;
|
||||
/* Fav-star + shared-button share the same visibility rule: hidden on
|
||||
quiet rows, visible on row hover, and — crucially — always visible
|
||||
when their `.active` class is set. That's what lets a favorited or
|
||||
shared row be discoverable at a glance in list view without the
|
||||
user having to mouse over it.
|
||||
|
||||
We use `visibility: hidden` (not `display: none`) so a hidden
|
||||
button still reserves its slot in the action cell. Otherwise a row
|
||||
that's shared-but-not-favorited would slide its shared icon into
|
||||
the fav-star's column, breaking vertical alignment across rows. */
|
||||
.files-list-view .file-item .action-cell button.favorite-star,
|
||||
.files-list-view .file-item .action-cell button.shared-button {
|
||||
visibility: hidden;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.files-list-view .file-item:hover .action-cell button.favorite-star {
|
||||
display: inline;
|
||||
.files-list-view .file-item:hover .action-cell button.favorite-star,
|
||||
.files-list-view .file-item:hover .action-cell button.shared-button,
|
||||
.files-list-view .file-item .action-cell button.favorite-star.active,
|
||||
.files-list-view .file-item .action-cell button.shared-button.active {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Reveal the kebab on hover for cleaner rows — but only on hover-capable
|
||||
devices, so touch users (no hover) keep it always tappable. Stays visible
|
||||
on keyboard focus within the row. */
|
||||
on keyboard focus within the row. Applies to both list and grid views
|
||||
because both keep the kebab inside `.action-cell`. */
|
||||
@media (hover: hover) {
|
||||
.files-list-view .file-item .action-cell button.file-actions {
|
||||
.files-list-view .file-item .action-cell button.file-actions,
|
||||
.files-grid-view .file-item .action-cell button.file-actions {
|
||||
opacity: 0;
|
||||
transition: opacity var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.files-list-view .file-item:hover .action-cell button.file-actions,
|
||||
.files-list-view .file-item:focus-within .action-cell button.file-actions {
|
||||
.files-list-view .file-item:focus-within .action-cell button.file-actions,
|
||||
.files-grid-view .file-item:hover .action-cell button.file-actions,
|
||||
.files-grid-view .file-item:focus-within .action-cell button.file-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -737,26 +760,95 @@
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: var(--space-1);
|
||||
opacity: 0;
|
||||
transition: opacity var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.files-grid-view .file-item:hover .action-cell,
|
||||
.files-grid-view .file-item:focus-within .action-cell,
|
||||
.files-grid-view .file-item .action-cell:has(.favorite-star.active) {
|
||||
opacity: 1;
|
||||
/* Full-width top action bar — only applies when the row surfaces at
|
||||
least one state chip (shared or favorite). Trash (no shared / no
|
||||
favorite) falls through to the horizontal corner cluster above,
|
||||
preserving its Restore / Delete / kebab layout.
|
||||
|
||||
Layout target:
|
||||
┌────────────────────────────────────────────┐
|
||||
│[chk] [kebab] [btn-action…] [shared] [fav] │ ← single row
|
||||
│ │
|
||||
│ (thumbnail) │
|
||||
│ │
|
||||
└────────────────────────────────────────────┘
|
||||
|
||||
The checkbox stays where it is (its own `.checkbox-cell` absolute at
|
||||
top-left); the action-cell spans horizontally next to it so the two
|
||||
clusters read as one row. `justify-content: space-between` + `order`
|
||||
splits the flex row into a left group (kebab + btn-actions) and a
|
||||
right group (shared + favorite) without fighting DOM order — the
|
||||
markup still has shared, favorite, itemActions, kebab in that
|
||||
sequence so list-view's inline right-aligned flow is unchanged. */
|
||||
.files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) {
|
||||
/* Start right after the checkbox column (26px chip + inline gap)
|
||||
so the left group aligns visually with the checkbox row. */
|
||||
left: calc(var(--space-3) + 8px + 26px + var(--space-2));
|
||||
right: calc(var(--space-3) + 8px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
/* The favorite state is already shown by the corner star button, so the inline
|
||||
name-cell favorite badge is redundant on grid cards. */
|
||||
.files-grid-view .file-item .name-cell .item-badge {
|
||||
display: none;
|
||||
.files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) .file-actions {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) .btn-action {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
/* The first "right group" item eats all remaining horizontal space via
|
||||
`margin-left: auto`, which is the flexbox idiom for splitting a row
|
||||
into left+right clusters without wrapping the two groups in extra
|
||||
containers. When both buttons are wired, shared is first (`order: 3`)
|
||||
and takes the push; fav follows with the normal gap. When only fav
|
||||
is wired (`/shared-with-me` — recipient side, no share affordance),
|
||||
fav is the sole right-group item and takes the push instead. */
|
||||
.files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) .shared-button {
|
||||
order: 3;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.files-grid-view .file-item .action-cell:has(.shared-button, .favorite-star) .favorite-star {
|
||||
order: 4;
|
||||
}
|
||||
|
||||
.files-grid-view
|
||||
.file-item
|
||||
.action-cell:has(.favorite-star):not(:has(.shared-button))
|
||||
.favorite-star {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Per-button visibility (mirrors list view): each button is independently
|
||||
gated by its own `.active` flag OR row-hover. This prevents a favorited
|
||||
row from also lighting up the shared button (and vice versa) — the cell
|
||||
used to reveal all its children together via a single opacity toggle. */
|
||||
.files-grid-view .file-item .action-cell .favorite-star,
|
||||
.files-grid-view .file-item .action-cell .shared-button {
|
||||
visibility: hidden;
|
||||
transition: visibility var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.files-grid-view .file-item:hover .action-cell .favorite-star,
|
||||
.files-grid-view .file-item:hover .action-cell .shared-button,
|
||||
.files-grid-view .file-item:focus-within .action-cell .favorite-star,
|
||||
.files-grid-view .file-item:focus-within .action-cell .shared-button,
|
||||
.files-grid-view .file-item .action-cell .favorite-star.active,
|
||||
.files-grid-view .file-item .action-cell .shared-button.active {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Chip visuals for anything inside the corner cluster — the kebab, the star,
|
||||
any `.btn-action`. Uniform 30x30 scrim pill so they line up in the flex row. */
|
||||
the shared button, any `.btn-action`. Uniform 30x30 scrim pill so they
|
||||
line up in the flex row. */
|
||||
.files-grid-view .file-item .action-cell .file-actions,
|
||||
.files-grid-view .file-item .action-cell .favorite-star,
|
||||
.files-grid-view .file-item .action-cell .shared-button,
|
||||
.files-grid-view .file-item .action-cell .btn-action {
|
||||
position: static;
|
||||
width: 30px;
|
||||
@@ -792,36 +884,15 @@
|
||||
border: 2px dashed var(--color-warning-border);
|
||||
}
|
||||
|
||||
/* "Shared" indicator — top-left of the thumbnail. Sits below the checkbox
|
||||
(which only appears on hover), so the two never both compete for the eye. */
|
||||
.files-grid-view .file-item .file-badge-shared {
|
||||
position: absolute;
|
||||
top: calc(var(--space-3) + 8px);
|
||||
left: calc(var(--space-3) + 8px);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-full);
|
||||
border: none;
|
||||
background: var(--color-scrim-control);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
box-shadow: 0 1px 3px var(--color-shadow-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9;
|
||||
font-size: var(--text-2xs);
|
||||
padding: 0;
|
||||
line-height: var(--leading-none);
|
||||
}
|
||||
|
||||
/* Favorite star — visual overrides only. Position, hover-reveal, chip
|
||||
geometry all come from the shared corner-cluster rule on
|
||||
`.files-grid-view .file-item .action-cell`. What's left here is just
|
||||
the star's per-state colour: subtle at rest, active-gold when the
|
||||
item is a favorite. `.active` still bumps the parent cluster's
|
||||
opacity so an unhovered card can still show its star. */
|
||||
.files-grid-view .file-item button.favorite-star {
|
||||
/* Favorite star + shared button — visual overrides only. Position,
|
||||
hover-reveal, chip geometry all come from the shared corner-cluster
|
||||
rule on `.files-grid-view .file-item .action-cell`. What's left
|
||||
here is just the per-state colour: subtle at rest, saturated when
|
||||
the item's flag is set. `.active` on either button also bumps the
|
||||
parent cluster's opacity (via `:has()` above) so an unhovered card
|
||||
still shows its favorited/shared state. */
|
||||
.files-grid-view .file-item button.favorite-star,
|
||||
.files-grid-view .file-item button.shared-button {
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 15px;
|
||||
line-height: var(--leading-none);
|
||||
@@ -839,6 +910,11 @@
|
||||
color: var(--color-star-active);
|
||||
}
|
||||
|
||||
.files-grid-view .file-item button.shared-button:hover,
|
||||
.files-grid-view .file-item button.shared-button.active {
|
||||
color: var(--color-badge-blue-text);
|
||||
}
|
||||
|
||||
.files-grid-view .file-item .name-cell {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--weight-medium);
|
||||
@@ -1201,6 +1277,24 @@
|
||||
color: var(--color-text-dark);
|
||||
}
|
||||
|
||||
/* Opt-in modifier: hide the button until the row is hovered / focused.
|
||||
Used by `/recent`'s per-row broom (a history-management action that
|
||||
shouldn't distract from the row content at rest). Trash's Restore /
|
||||
Delete stay on the plain `.btn-action` — those are the reason the
|
||||
user opened trash, and hiding them would fail Fitts' law. */
|
||||
.files-list-view .file-item .action-cell .btn-action--hover,
|
||||
.files-grid-view .file-item .action-cell .btn-action--hover {
|
||||
visibility: hidden;
|
||||
transition: visibility var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.files-list-view .file-item:hover .action-cell .btn-action--hover,
|
||||
.files-list-view .file-item:focus-within .action-cell .btn-action--hover,
|
||||
.files-grid-view .file-item:hover .action-cell .btn-action--hover,
|
||||
.files-grid-view .file-item:focus-within .action-cell .btn-action--hover {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* 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
|
||||
|
||||
@@ -38,6 +38,10 @@ export function minimalPhotoItem(id: string): FileItem {
|
||||
size_formatted: '',
|
||||
sort_date: 0,
|
||||
etag: '',
|
||||
content_hash: ''
|
||||
content_hash: '',
|
||||
// Stub item — never wired to a live server response, so the
|
||||
// two required wire flags default to the safe "not set" value.
|
||||
is_favorite: false,
|
||||
is_shared: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { primeContextPage } from '$lib/utils/listContext';
|
||||
import Button from '$lib/components/Button.svelte';
|
||||
import { useOwnerCache } from '$lib/composables/useOwnerCache.svelte';
|
||||
@@ -50,22 +50,15 @@
|
||||
// action surfaces don't filter, algorithmic surfaces do.
|
||||
//
|
||||
// ResourceList consumes raw `FileItem | FolderItem`; the favorites
|
||||
// envelope contributes `favorited_at` via `date` in contextMap. All
|
||||
// items on this page are favorites — pass every id in `favoriteIds`
|
||||
// so the star widget lights up universally.
|
||||
// envelope contributes `favorited_at` via `date` in contextMap.
|
||||
// Every item on this page has `is_favorite: true` by construction
|
||||
// (the listing SQL hardcodes `TRUE AS is_favorite`), so the star
|
||||
// widget lights up universally without any shadow set here.
|
||||
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>();
|
||||
// Persistent reactive set, updated in place per page (add the fresh page's
|
||||
// ids; clear on reset) instead of rebuilding a brand-new SvelteSet over the
|
||||
// whole accumulated list on every infinite-scroll page — that was O(N²)
|
||||
// across a drain and, being a new instance each page, invalidated every
|
||||
// mounted star reader. Every item on this page is a favorite, and removed
|
||||
// items are no longer rendered, so the set only needs to be a superset of
|
||||
// the displayed ids (benches/ROUND14.md §F2, mirrors recent's shipped shape).
|
||||
const favoriteIds = new SvelteSet<string>();
|
||||
|
||||
const groupBys: GroupByDef[] = [
|
||||
{ key: '', label: t('files.name', 'Name'), orderBy: 'name', icon: 'arrow-up-a-z' },
|
||||
@@ -114,10 +107,6 @@
|
||||
resourceTypes: ['file', 'folder']
|
||||
});
|
||||
raw = reset ? page.items : [...raw, ...page.items];
|
||||
// Keep the persistent favoriteIds set in sync incrementally: clear on
|
||||
// reset, then add only this page's ids (benches/ROUND14.md §F2).
|
||||
if (reset) favoriteIds.clear();
|
||||
for (const it of page.items) favoriteIds.add(it.resource.id);
|
||||
primeContextPage(contextMap, reset, page.items, (it) => [
|
||||
it.resource.id,
|
||||
{ date: it.favorited_at }
|
||||
@@ -164,7 +153,6 @@
|
||||
try {
|
||||
await removeFavorite(kind, item.id);
|
||||
raw = raw.filter((i) => i.resource.id !== item.id);
|
||||
favoriteIds.delete(item.id);
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
}
|
||||
@@ -317,7 +305,6 @@
|
||||
title={t('nav.favorites', 'Favorites')}
|
||||
{items}
|
||||
{contextMap}
|
||||
{favoriteIds}
|
||||
resolveOwnerName={(id) => owners.name(id)}
|
||||
{loading}
|
||||
{error}
|
||||
@@ -328,6 +315,10 @@
|
||||
onloadmore={() => load(false, orderByForGroup())}
|
||||
onopen={open}
|
||||
onfavorite={unfavorite}
|
||||
onshared={(item) => {
|
||||
shareTarget = { id: item.id, name: item.name, kind: kindOf(item) };
|
||||
shareOpen = true;
|
||||
}}
|
||||
showOwner
|
||||
showPath
|
||||
dateLabel={t('files.col_added', 'Added')}
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
return drivesStore.findByRootFolderId(pathSegments[0] ?? null);
|
||||
});
|
||||
|
||||
let listing = $state<FolderListing>({ folders: [], files: [], favoriteIds: [], sharedIds: [] });
|
||||
let listing = $state<FolderListing>({ folders: [], files: [] });
|
||||
// Server-order accumulator — items in the exact sequence the backend
|
||||
// returned across pages, honouring `sortField`+`reversed` on the wire.
|
||||
// Under order_by=name/type/size the server puts folders first then files;
|
||||
@@ -176,14 +176,13 @@
|
||||
let actionTarget = $state<ActionTarget | null>(null);
|
||||
let moveItems = $state<ActionTarget[] | null>(null);
|
||||
|
||||
// Favorite + shared badge sets for the current folder, seeded directly from
|
||||
// the listing response (server-computed, scoped to these items — no extra
|
||||
// per-navigation fetch) and updated optimistically on mutation.
|
||||
// `SvelteSet` mutated in place: a toggle costs O(1) instead of copying
|
||||
// the whole set, and every other present-key `.has()` reader is spared
|
||||
// (measured in selectionPatterns.bench.test.ts).
|
||||
const favoriteIds = new SvelteSet<string>();
|
||||
const sharedIds = new SvelteSet<string>();
|
||||
// Favorite / shared state now lives inline on every `FileItem` /
|
||||
// `FolderItem` DTO (`is_favorite`, `is_shared` — see
|
||||
// `frontend/src/lib/api/types.ts`). Populated by the backend
|
||||
// listing SQL (per-row `EXISTS`) and single-item enrichment
|
||||
// helper. The row-badge snippet and star gate read these
|
||||
// fields directly; the toggle path mutates the item in place
|
||||
// inside `orderedItems`. No more `SvelteSet` shadowing.
|
||||
|
||||
function openMove(kind: ItemType, id: string, name: string) {
|
||||
actionTarget = { id, name, kind };
|
||||
@@ -203,17 +202,20 @@
|
||||
}
|
||||
|
||||
async function toggleFavorite(kind: ItemType, id: string) {
|
||||
const isFav = favoriteIds.has(id);
|
||||
// Optimistic toggle, reverted on failure.
|
||||
if (isFav) favoriteIds.delete(id);
|
||||
else favoriteIds.add(id);
|
||||
// Server-authoritative `is_favorite` lives on every
|
||||
// `FileItem`/`FolderItem` DTO. Read → optimistic flip → server
|
||||
// call → revert on failure. All state changes happen in-place
|
||||
// on the item inside `orderedItems`; there is no shadow set.
|
||||
const item = orderedItems.find((it) => it.id === id);
|
||||
if (!item) return; // row scrolled off / navigated away mid-click
|
||||
const wasFav = item.is_favorite;
|
||||
item.is_favorite = !wasFav;
|
||||
try {
|
||||
if (isFav) await removeFavorite(kind, id);
|
||||
if (wasFav) await removeFavorite(kind, id);
|
||||
else await addFavorite(kind, id);
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
if (isFav) favoriteIds.add(id);
|
||||
else favoriteIds.delete(id);
|
||||
item.is_favorite = wasFav;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,7 +297,7 @@
|
||||
// Reset paging state: previous folder's cursor is meaningless here,
|
||||
// and mixing its rows with the new folder's would flash a wrong list.
|
||||
pageCursor = undefined;
|
||||
listing = { folders: [], files: [], favoriteIds: [], sharedIds: [] };
|
||||
listing = { folders: [], files: [] };
|
||||
orderedItems = [];
|
||||
loading = true;
|
||||
|
||||
@@ -336,17 +338,13 @@
|
||||
if (reset) {
|
||||
listing = {
|
||||
folders: page.folders,
|
||||
files: page.files,
|
||||
favoriteIds: [],
|
||||
sharedIds: []
|
||||
files: page.files
|
||||
};
|
||||
orderedItems = page.items;
|
||||
} else {
|
||||
listing = {
|
||||
folders: [...listing.folders, ...page.folders],
|
||||
files: [...listing.files, ...page.files],
|
||||
favoriteIds: listing.favoriteIds,
|
||||
sharedIds: listing.sharedIds
|
||||
files: [...listing.files, ...page.files]
|
||||
};
|
||||
orderedItems = [...orderedItems, ...page.items];
|
||||
}
|
||||
@@ -1009,7 +1007,13 @@
|
||||
|
||||
/** Batch add the selection to favorites — single /api/favorites/batch call. */
|
||||
async function batchFavorites() {
|
||||
const items = selectionTargets().filter((it) => !favoriteIds.has(it.id));
|
||||
// Build an id → item index so the "already favorite" filter is
|
||||
// O(1) per selection member instead of an O(N·M) scan. Reused
|
||||
// after success to flip `is_favorite` in place on each row.
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- ephemeral local index, discarded before any reactive read
|
||||
const byId = new Map<string, FileItem | FolderItem>();
|
||||
for (const it of orderedItems) byId.set(it.id, it);
|
||||
const items = selectionTargets().filter((it) => !(byId.get(it.id)?.is_favorite ?? false));
|
||||
if (items.length === 0) {
|
||||
ui.notify(t('files.already_favorites', 'All selected items are already favorites'), 'info');
|
||||
clearSelection();
|
||||
@@ -1025,7 +1029,10 @@
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
||||
for (const it of items) favoriteIds.add(it.id);
|
||||
for (const it of items) {
|
||||
const row = byId.get(it.id);
|
||||
if (row) row.is_favorite = true;
|
||||
}
|
||||
ui.notify(t('files.added_favorites', 'Added to favorites'), 'success');
|
||||
clearSelection();
|
||||
} catch (e) {
|
||||
@@ -1779,7 +1786,6 @@
|
||||
<ResourceList
|
||||
title={t('nav.files', 'Files')}
|
||||
items={rlItems}
|
||||
{favoriteIds}
|
||||
emptyText={hiddenCount > 0
|
||||
? t('files.empty_hidden_title', { n: hiddenCount }, '{{n}} hidden item(s) in this folder')
|
||||
: t('files.empty_title', 'This folder is empty')}
|
||||
@@ -1812,6 +1818,7 @@
|
||||
}}
|
||||
onopen={rlOnOpen}
|
||||
onfavorite={rlOnFavorite}
|
||||
onshared={(item) => openShare(isFile(item) ? 'file' : 'folder', item.id, item.name)}
|
||||
oncontextmenu={rlOnContextMenu}
|
||||
onselectionchange={(ids) => replaceSet(selected, ids)}
|
||||
isDraggable={rlIsDraggable}
|
||||
@@ -1993,19 +2000,6 @@
|
||||
<span>{t('common.delete', 'Delete')}</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet rowBadge(item)}
|
||||
{#if favoriteIds.has(item.id)}
|
||||
<span class="item-badge item-badge--fav" title={t('files.favorited', 'Favorite')}>
|
||||
<Icon name="star" />
|
||||
</span>
|
||||
{/if}
|
||||
{#if sharedIds.has(item.id)}
|
||||
<span class="file-badge file-badge-shared" title={t('files.shared', 'Shared')}>
|
||||
<Icon name="oxiexport" />
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ResourceList>
|
||||
</div>
|
||||
|
||||
@@ -2024,7 +2018,16 @@
|
||||
{/if}
|
||||
{#if shareDialog.component}
|
||||
{@const ShareDialog = shareDialog.component}
|
||||
<ShareDialog bind:open={shareOpen} item={actionTarget} onshared={(id) => sharedIds.add(id)} />
|
||||
<ShareDialog
|
||||
bind:open={shareOpen}
|
||||
item={actionTarget}
|
||||
onshared={(id) => {
|
||||
// Optimistic in-place flip so the shared chip appears on
|
||||
// the row without waiting for the next listing refetch.
|
||||
const row = orderedItems.find((it) => it.id === id);
|
||||
if (row) row.is_shared = true;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if fileViewer.component}
|
||||
{@const FileViewer = fileViewer.component}
|
||||
@@ -2182,7 +2185,7 @@
|
||||
}}
|
||||
>
|
||||
<Icon name="star" />
|
||||
{favoriteIds.has(ctxTarget.id)
|
||||
{orderedItems.find((it) => it.id === ctxTarget!.id)?.is_favorite
|
||||
? t('files.unfavorite', 'Remove favorite')
|
||||
: t('files.favorite', 'Add favorite')}
|
||||
</button>
|
||||
@@ -2214,27 +2217,21 @@
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.item-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: var(--space-1);
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.item-badge--fav {
|
||||
color: var(--color-warning-text, var(--color-accent));
|
||||
}
|
||||
|
||||
/* Scrim + menu must sit ABOVE `.page-sticky-header` (breadcrumb +
|
||||
action bar, `z-index: var(--z-sticky)` = 100) so a click on the
|
||||
breadcrumb or the action bar closes the menu. Otherwise the
|
||||
sticky header covers the scrim and swallows the outside-click.
|
||||
Aligns with ResourceList's built-in menu (`.rl-ctx-scrim` uses
|
||||
`1000` / `.rl-ctx-menu` uses `1001`). */
|
||||
.ctx-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 90;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.ctx-menu {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
z-index: 1001;
|
||||
min-width: 12rem;
|
||||
padding: var(--space-1);
|
||||
background: var(--color-bg-surface);
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
import {
|
||||
addFavorite,
|
||||
dateBucket,
|
||||
removeFavorite,
|
||||
resolveOwnerName,
|
||||
sizeBucket,
|
||||
typeLabel
|
||||
@@ -314,24 +315,36 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
// "Add to favorites" — /recent doesn't track per-row favorite
|
||||
// state (the star widget was replaced by the broom), so the
|
||||
// entry always reads "Add" and the backend swallows duplicate
|
||||
// adds idempotently. If the user wants to un-favorite, they
|
||||
// navigate to /favorites and use the row menu there. Placed
|
||||
// between Move and Rename to match the canonical context-menu
|
||||
// order on `/files`.
|
||||
key: 'favorite',
|
||||
key: 'favorite_add',
|
||||
label: t('files.favorite', 'Add favorite'),
|
||||
icon: 'star',
|
||||
run: (item) => {
|
||||
void addFavorite(kindOf(item), item.id).catch(errorToast);
|
||||
}
|
||||
visible: (item) => !item.is_favorite,
|
||||
run: toggleFavorite
|
||||
},
|
||||
{
|
||||
key: 'favorite_remove',
|
||||
label: t('files.unfavorite', 'Remove favorite'),
|
||||
icon: 'star',
|
||||
visible: (item) => item.is_favorite,
|
||||
run: toggleFavorite
|
||||
},
|
||||
{ key: 'rename', label: t('common.rename', 'Rename'), icon: 'pen', run: rename },
|
||||
{ key: 'delete', label: t('common.delete', 'Delete'), icon: 'trash', danger: true, run: remove }
|
||||
];
|
||||
|
||||
async function toggleFavorite(item: FileItem | FolderItem) {
|
||||
const kind = kindOf(item);
|
||||
const wasFav = item.is_favorite;
|
||||
item.is_favorite = !wasFav;
|
||||
try {
|
||||
if (wasFav) await removeFavorite(kind, item.id);
|
||||
else await addFavorite(kind, item.id);
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
item.is_favorite = wasFav;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Selection + batch ─────────────────────────────────────────────────────
|
||||
// Selected items arrive via the batchActions snippet param —
|
||||
// ResourceList already derives them (O(selection), not O(N)); a
|
||||
@@ -372,6 +385,11 @@
|
||||
hasMore={!!cursor}
|
||||
onloadmore={() => load(false, orderByForGroup())}
|
||||
onopen={open}
|
||||
onfavorite={toggleFavorite}
|
||||
onshared={(item) => {
|
||||
shareTarget = { id: item.id, name: item.name, kind: kindOf(item) };
|
||||
shareOpen = true;
|
||||
}}
|
||||
showOwner
|
||||
showPath
|
||||
dateLabel={t('files.col_opened', 'Opened')}
|
||||
@@ -436,7 +454,7 @@
|
||||
buttons at the row's action-cell.
|
||||
-->
|
||||
<button
|
||||
class="btn-action"
|
||||
class="btn-action btn-action--hover"
|
||||
data-testid={`recent-remove-btn-${item.id}`}
|
||||
title={t('recent.remove_item', 'Remove from recent')}
|
||||
aria-label={t('recent.remove_item', 'Remove from recent')}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import {
|
||||
addFavorite,
|
||||
dateBucket,
|
||||
removeFavorite,
|
||||
resolveOwnerName,
|
||||
typeLabel
|
||||
} from '$lib/api/endpoints/favorites';
|
||||
@@ -161,11 +162,23 @@
|
||||
// folders show "Download as ZIP" (server-side archive). Two entries
|
||||
// with `visible?` predicates rather than one label that changes,
|
||||
// so the `.icon` reads correctly per kind too.
|
||||
//
|
||||
// The favorite entry stays "Add to favorites" only: un-favoriting
|
||||
// from here would need per-row favorite-state tracking which this
|
||||
// view doesn't carry — users toggle off from /favorites' own row
|
||||
// menu. Backend swallows duplicate `addFavorite` calls idempotently.
|
||||
async function toggleFavorite(item: FileItem | FolderItem) {
|
||||
const kind = isFile(item) ? 'file' : 'folder';
|
||||
const wasFav = item.is_favorite;
|
||||
item.is_favorite = !wasFav;
|
||||
try {
|
||||
if (wasFav) await removeFavorite(kind, item.id);
|
||||
else await addFavorite(kind, item.id);
|
||||
} catch (e) {
|
||||
errorToast(e);
|
||||
item.is_favorite = wasFav;
|
||||
}
|
||||
}
|
||||
|
||||
// Favorite entry mirrors the star toggle in the row action cell —
|
||||
// same wording, same behavior. Context-menu label flips based on
|
||||
// `item.is_favorite` so keyboard users get the same state read as
|
||||
// the button-hovering ones.
|
||||
const contextActions: ContextAction[] = [
|
||||
{
|
||||
key: 'download',
|
||||
@@ -182,12 +195,18 @@
|
||||
run: downloadItem
|
||||
},
|
||||
{
|
||||
key: 'favorite',
|
||||
key: 'favorite_add',
|
||||
label: t('files.favorite', 'Add favorite'),
|
||||
icon: 'star',
|
||||
run: (item) => {
|
||||
void addFavorite(isFile(item) ? 'file' : 'folder', item.id).catch(errorToast);
|
||||
}
|
||||
visible: (item) => !item.is_favorite,
|
||||
run: toggleFavorite
|
||||
},
|
||||
{
|
||||
key: 'favorite_remove',
|
||||
label: t('files.unfavorite', 'Remove favorite'),
|
||||
icon: 'star',
|
||||
visible: (item) => item.is_favorite,
|
||||
run: toggleFavorite
|
||||
}
|
||||
];
|
||||
|
||||
@@ -235,6 +254,7 @@
|
||||
bind:reversed
|
||||
onloadmore={() => load(false, orderByForGroup())}
|
||||
onopen={open}
|
||||
onfavorite={toggleFavorite}
|
||||
onreload={(orderBy, rev) => {
|
||||
cursor = undefined;
|
||||
load(true, orderBy, rev);
|
||||
|
||||
Reference in New Issue
Block a user