Merge pull request #641 from EdouardVanbelle/refactor/front-resource-list

feat(fileDto, folderDto): add is_favorite + is_shared
This commit is contained in:
Dionisio Pozo
2026-07-22 06:51:28 +02:00
committed by GitHub
50 changed files with 1140 additions and 287 deletions
@@ -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');
+5 -6
View File
@@ -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> {
+15
View File
@@ -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' });
+19
View File
@@ -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: []
});
});
+55 -27
View File
@@ -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}
+82 -15
View File
@@ -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
};
}
+147 -53
View File
@@ -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
+5 -1
View File
@@ -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
};
}