diff --git a/examples/bench_dto_map.rs b/examples/bench_dto_map.rs index 5ec030e9..ae4f5d04 100644 --- a/examples/bench_dto_map.rs +++ b/examples/bench_dto_map.rs @@ -154,6 +154,8 @@ mod before { etag, created_by: parts.created_by, updated_by: parts.updated_by, + is_favorite: false, + is_shared: false, } } @@ -179,6 +181,8 @@ mod before { etag, created_by: folder.created_by(), updated_by: folder.updated_by(), + is_favorite: false, + is_shared: false, } } } diff --git a/examples/bench_nc_session.rs b/examples/bench_nc_session.rs index 3b298dc8..01077915 100644 --- a/examples/bench_nc_session.rs +++ b/examples/bench_nc_session.rs @@ -111,6 +111,8 @@ fn fixture_folder() -> FolderDto { category: Arc::from("Folder"), created_by: None, updated_by: None, + is_favorite: false, + is_shared: false, } } diff --git a/examples/bench_resource_row_map.rs b/examples/bench_resource_row_map.rs index 64077274..5345bd7f 100644 --- a/examples/bench_resource_row_map.rs +++ b/examples/bench_resource_row_map.rs @@ -97,6 +97,8 @@ fn rows(n: usize) -> Vec { }, created_by: Some(Uuid::new_v4()), updated_by: Some(Uuid::new_v4()), + is_favorite: false, + is_shared: false, sort_str: format!("row {i}"), type_order: 0, folder_first: if is_folder { 0 } else { 1 }, @@ -130,6 +132,8 @@ fn map_before(rows: Vec) -> Vec { category: intern_display("Folder"), created_by: None, updated_by: None, + is_favorite: false, + is_shared: false, }; (dto.name, dto.icon_class, dto.category) } else { @@ -163,6 +167,8 @@ fn map_before(rows: Vec) -> Vec { etag, created_by: None, updated_by: None, + is_favorite: false, + is_shared: false, }; (dto.name, dto.icon_class, dto.category) } @@ -191,6 +197,8 @@ fn map_after(rows: Vec) -> Vec { category: intern_display("Folder"), created_by: None, updated_by: None, + is_favorite: false, + is_shared: false, }; (dto.name, dto.icon_class, dto.category) } else { @@ -227,6 +235,8 @@ fn map_after(rows: Vec) -> Vec { etag, created_by: None, updated_by: None, + is_favorite: false, + is_shared: false, }; (dto.name, dto.icon_class, dto.category) } @@ -266,6 +276,8 @@ fn fav_rows(n: usize) -> Vec { }, created_by: Some(Uuid::new_v4()), updated_by: Some(Uuid::new_v4()), + is_favorite: true, + is_shared: false, is_owner: true, favorited_at: ts, path: Some(format!("Documents/Work/item-{i:05}")), @@ -314,6 +326,8 @@ fn fav_map_before(rows: Vec) -> Vec { category: intern_display("Folder"), created_by: None, updated_by: None, + is_favorite: false, + is_shared: false, }; ( dto.name, @@ -353,6 +367,8 @@ fn fav_map_before(rows: Vec) -> Vec { etag, created_by: None, updated_by: None, + is_favorite: false, + is_shared: false, }; ( dto.name, @@ -393,6 +409,8 @@ fn fav_map_after(rows: Vec) -> Vec { category: intern_display("Folder"), created_by: None, updated_by: None, + is_favorite: false, + is_shared: false, }; ( dto.name, @@ -435,6 +453,8 @@ fn fav_map_after(rows: Vec) -> Vec { etag, created_by: None, updated_by: None, + is_favorite: false, + is_shared: false, }; ( dto.name, diff --git a/examples/bench_search_enrich.rs b/examples/bench_search_enrich.rs index 99efc057..e4302512 100644 --- a/examples/bench_search_enrich.rs +++ b/examples/bench_search_enrich.rs @@ -242,6 +242,8 @@ mod before { etag, created_by: None, updated_by: None, + is_favorite: false, + is_shared: false, } } } @@ -297,6 +299,8 @@ fn folder_dtos(n: usize) -> Vec { category: Arc::from("Folder"), created_by: None, updated_by: None, + is_favorite: false, + is_shared: false, }) .collect() } diff --git a/frontend/src/lib/api/endpoints/folders.test.ts b/frontend/src/lib/api/endpoints/folders.test.ts index 621a9850..5f9c5da1 100644 --- a/frontend/src/lib/api/endpoints/folders.test.ts +++ b/frontend/src/lib/api/endpoints/folders.test.ts @@ -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'); diff --git a/frontend/src/lib/api/endpoints/folders.ts b/frontend/src/lib/api/endpoints/folders.ts index 0ceb996e..f955633c 100644 --- a/frontend/src/lib/api/endpoints/folders.ts +++ b/frontend/src/lib/api/endpoints/folders.ts @@ -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 { 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 { diff --git a/frontend/src/lib/api/endpoints/grants.ts b/frontend/src/lib/api/endpoints/grants.ts index ad6abd25..6b7c97e7 100644 --- a/frontend/src/lib/api/endpoints/grants.ts +++ b/frontend/src/lib/api/endpoints/grants.ts @@ -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 { const params = new URLSearchParams({ resource_type: type, resource_id: id }); return apiJson(`/api/grants?${params}`, { credentials: 'same-origin' }); diff --git a/frontend/src/lib/api/types.ts b/frontend/src/lib/api/types.ts index 052e1b17..458589e4 100644 --- a/frontend/src/lib/api/types.ts +++ b/frontend/src/lib/api/types.ts @@ -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". */ diff --git a/frontend/src/lib/components/MoveDialog.test.ts b/frontend/src/lib/components/MoveDialog.test.ts index 7896210e..7219c515 100644 --- a/frontend/src/lib/components/MoveDialog.test.ts +++ b/frontend/src/lib/components/MoveDialog.test.ts @@ -69,9 +69,7 @@ beforeEach(() => { vi.clearAllMocks(); m(listFolder).mockResolvedValue({ folders: [folder('sub1', 'Sub')], - files: [], - favoriteIds: [], - sharedIds: [] + files: [] }); }); diff --git a/frontend/src/lib/components/ResourceList.svelte b/frontend/src/lib/components/ResourceList.svelte index 3914150c..ec7ef9a5 100644 --- a/frontend/src/lib/components/ResourceList.svelte +++ b/frontend/src/lib/components/ResourceList.svelte @@ -119,13 +119,6 @@ * fields (`modified_at`, `created_by`). */ contextMap?: Map; - /** - * 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; /** * 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) => 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} - + {#if rowBadge} {@render rowBadge(item, ctx)} {/if} @@ -1106,31 +1110,55 @@ - {#if onfavorite || itemActions || onContextMenuOverride || contextActions?.length} + {#if hasActionCell}
+ {#if onshared} + + {/if} {#if onfavorite} {/if} {#if itemActions}{@render itemActions(item)}{/if} diff --git a/frontend/src/lib/components/ShareDialog.svelte b/frontend/src/lib/components/ShareDialog.svelte index 31e32ddd..dfb4849b 100644 --- a/frontend/src/lib/components/ShareDialog.svelte +++ b/frontend/src/lib/components/ShareDialog.svelte @@ -14,6 +14,7 @@ fetchGrantsForResource, notifyGrantRecipient, revokeGrant, + todayIso, updateGrantRole, type Grant, type GrantSubject, @@ -378,15 +379,33 @@ }); - + {#snippet expiryChip(value: string | null, onchange: (v: string | null) => void)} + {@const today = todayIso()} {#if value} 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')} /> {:else} - + {/if} {/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; + }} />
@@ -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 `` 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 { diff --git a/frontend/src/lib/components/ShareDialog.test.ts b/frontend/src/lib/components/ShareDialog.test.ts index 0530cac7..a3ea552a 100644 --- a/frontend/src/lib/components/ShareDialog.test.ts +++ b/frontend/src/lib/components/ShareDialog.test.ts @@ -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', () => ({ diff --git a/frontend/src/lib/components/round13.bench.test.ts b/frontend/src/lib/components/round13.bench.test.ts index 33dbbf87..218bf21f 100644 --- a/frontend/src/lib/components/round13.bench.test.ts +++ b/frontend/src/lib/components/round13.bench.test.ts @@ -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 }; } diff --git a/frontend/src/lib/styles/ported/resourceList.css b/frontend/src/lib/styles/ported/resourceList.css index 1de08423..996227a4 100644 --- a/frontend/src/lib/styles/ported/resourceList.css +++ b/frontend/src/lib/styles/ported/resourceList.css @@ -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 diff --git a/frontend/src/lib/utils/media.ts b/frontend/src/lib/utils/media.ts index e8c72b0f..92f5d402 100644 --- a/frontend/src/lib/utils/media.ts +++ b/frontend/src/lib/utils/media.ts @@ -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 }; } diff --git a/frontend/src/routes/favorites/+page.svelte b/frontend/src/routes/favorites/+page.svelte index 895d18f7..f1b1a180 100644 --- a/frontend/src/routes/favorites/+page.svelte +++ b/frontend/src/routes/favorites/+page.svelte @@ -1,5 +1,5 @@