feat(breadcrumb): show the granter (sharer) in the breadcrumb

This commit is contained in:
Edouard Vanbelle
2026-07-27 00:14:12 +02:00
parent 785d922243
commit e487af6e7e
7 changed files with 264 additions and 29 deletions
+47 -3
View File
@@ -298,8 +298,34 @@ export interface SearchResourcesResponse {
export type DriveKind = 'personal' | 'shared';
/** Role-keyed share strength. Matches `Role` in the backend authz model. */
export type DriveRole = 'owner' | 'editor' | 'contributor' | 'commenter' | 'viewer';
/**
* Full role set from `storage.grant_role` — every value that can appear
* on a `role_grants` row regardless of `resource_type` (drive, folder,
* file, playlist, calendar, address_book, …). Use this for folder-level
* and file-level `caller_role` fields where all five values are valid.
* Matches `RoleDto` in the backend.
*/
export type GrantRole = 'owner' | 'editor' | 'contributor' | 'commenter' | 'viewer';
/**
* Role assignable at DRIVE scope — a strict subset of `GrantRole`.
* Drives only meaningfully take the three management-ladder tiers:
* - `owner` — full control (rename, delete, quota, membership).
* - `editor` — can create/modify content anywhere in the drive.
* - `viewer` — read-only access to the whole drive.
*
* `contributor` (create-in-folder-without-touching-siblings) and
* `commenter` (react without modifying) are folder/file-scope
* semantics: they describe fine-grained access to a specific item,
* not to a whole drive. Grants of those roles happen at folder or
* file scope via a separate `role_grants` row, not at the drive
* boundary. Do NOT widen this type without a matching backend
* check — the DB ENUM permits all 5 today, so the constraint is
* conventional.
*
* Use `GrantRole` for folder/file-level `caller_role` fields.
*/
export type DriveRole = 'owner' | 'editor' | 'viewer';
/** Subject of a grant. Mirrors `SubjectDto`. */
export type SubjectKind = 'user' | 'group' | 'token';
@@ -465,8 +491,26 @@ export interface AccessSource {
kind: AccessSourceKind;
/** Populated when `kind === 'drive'`. */
drive?: AccessSourceDrive;
/** Optional grantee info for shares / group grants. */
/**
* SHARER — the user who created the grant that gave the caller
* access at the boundary (`role_grants.granted_by`). Kind is always
* `'user'` today (a group can't perform an action), but the type
* stays open in case a future model permits it. Null when the
* boundary can't be resolved to a single grant (e.g. `token`).
*/
subject?: AccessSourceSubject;
/**
* Caller's role via the boundary grant (`role_grants.role` on the
* same row that carries `granted_by`). Lets the FE render permission-
* aware affordances at the ancestor scope. Reflects the boundary grant
* only — aggregate effective role via other channels may be stronger.
* Null on `token` access.
*
* Typed as `GrantRole` (not `DriveRole`): the boundary can be a
* folder-level share where all five role_grant values are valid,
* not just the drive-scoped subset.
*/
caller_role?: GrantRole | null;
}
/**
@@ -3,6 +3,7 @@
import { getFolderAncestors } from '$lib/api/endpoints/folders';
import type { AccessSource, FolderAncestor, FolderAncestorsResponse } from '$lib/api/types';
import Icon from '$lib/icons/Icon.svelte';
import UserAvatar from '$lib/components/UserAvatar.svelte';
import { t } from '$lib/i18n/index.svelte';
/**
@@ -87,6 +88,26 @@
: []
);
/**
* Index of the crumb that carries the ACTUAL grant giving the
* caller access — the "boundary" crumb. Rendered with an inline
* sharer avatar (or group chip) so the breadcrumb answers both
* "how did I get here?" (root chip icon) AND "who shared this?"
* (avatar on the specific granted folder) — Ed's 2026-07-27 UX call.
*
* - `direct_share`: always index 0 of `visibleCrumbs` (the
* endpoint's walk stops at the granted folder, so the topmost
* accessible ancestor IS the share boundary).
* - `drive`: null — the grant lives on the drive itself, not on
* any visible folder. The drive-root chip already carries the
* drive name; adding a subject chip to a sub-folder would be
* semantically misleading (that sub-folder wasn't the grant).
* - `token`: null — no user-facing subject to render.
*/
const boundaryCrumbIndex = $derived<number | null>(
chain?.access_source.kind === 'direct_share' && visibleCrumbs.length > 0 ? 0 : null
);
// ── Root-icon derivation ────────────────────────────────────────────
// One icon per `access_source.kind`. Personal drives use the home
// glyph (they're the caller's own storage — signalling "home base");
@@ -285,10 +306,12 @@
`onDrop` handler. Absent everywhere except `/files`.
-->
{@const isLeaf = i === visibleCrumbs.length - 1}
{@const isBoundary = i === boundaryCrumbIndex}
<a
href={resolve(`/files/${c.id}`)}
class="breadcrumb-item breadcrumb-link"
class:breadcrumb-current={isLeaf}
class:breadcrumb-boundary={isBoundary}
class:drop-target={onDrop != null && dropTargetId === c.id}
data-testid={isLeaf ? `folder-breadcrumb-current-${c.id}` : `folder-breadcrumb-${c.id}`}
ondragover={onDrop
@@ -311,7 +334,35 @@
}
: undefined}
>
{c.name}
<!--
Sharer decoration on the BOUNDARY crumb (the topmost
accessible ancestor that carries the actual grant).
Only rendered for `direct_share` — drive-kind grants
live on the drive itself and the drive-root chip
already carries that context. Ed's 2026-07-27 design:
[share-alt] > folder[avatar] > sub …
so the root chip keeps the access-CHANNEL semantic
(share / drive / link) and the person/group chip
attaches to the folder that WAS shared.
-->
{#if isBoundary && chain?.access_source.subject}
{@const subj = chain.access_source.subject}
{#if subj.kind === 'user'}
<UserAvatar userId={subj.id} size={18} />
{:else}
<span
class="breadcrumb-group-chip"
title={t(
'breadcrumb.subject.group_tooltip',
{ name: subj.name ?? '' },
'Shared with group: {{name}}'
)}
>
<Icon name="users" />
</span>
{/if}
{/if}
<span class="breadcrumb-crumb-name">{c.name}</span>
</a>
{/each}
</nav>
@@ -335,9 +386,38 @@
anchor → highlight clears (Ed's 2026-07-26 report). Pointer-events
off on children collapses the whole chip to a single drag target;
drop still lands because the anchor's own handlers stay live.
Intermediate crumbs don't need this (they contain only a text
node — no child element to cross into). */
.breadcrumb-home > * {
Boundary crumbs (with an inline avatar/group chip child) get the
same treatment — same drop-flicker mechanism, one child element to
cross into. Plain intermediate crumbs are pure text nodes and
don't need it, but the selector is harmless there. */
.breadcrumb-home > *,
.breadcrumb-link > * {
pointer-events: none;
}
/* Boundary crumbs align the inline sharer decoration (avatar or
group chip) with the folder-name text on the vertical midline —
inline-flex + baseline gap. Non-boundary crumbs stay inline (no
flex overhead) so wide breadcrumbs still wrap the same way. */
.breadcrumb-boundary {
display: inline-flex;
align-items: center;
gap: var(--space-1);
}
/* Group-subject chip — small `users` glyph in a subtle badge, sits
next to the folder name on the boundary crumb. Matches the size
footprint of the inline UserAvatar (18px) so user- vs group-shared
crumbs feel visually consistent. */
.breadcrumb-group-chip {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-bg-subtle);
color: var(--color-text-secondary);
font-size: 11px;
}
</style>
@@ -10,9 +10,12 @@
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
import { ui } from '$lib/stores/ui.svelte';
// A drive accepts new items only if the caller can Create on its root.
// Owner / Editor / Contributor cover that; Commenter + Viewer cannot.
const WRITABLE_ROLES: readonly DriveRole[] = ['owner', 'editor', 'contributor'] as const;
// A drive accepts new items only if the caller can Create on its
// root. Drive-scope roles are the management-ladder subset —
// Owner / Editor / Viewer — so writability collapses to the top two;
// Viewer cannot. `contributor`/`commenter` don't appear at drive
// scope (folder/file-scope semantics), so they're not in `DriveRole`.
const WRITABLE_ROLES: readonly DriveRole[] = ['owner', 'editor'] as const;
function isWritable(d: Drive): boolean {
return d.caller_role != null && WRITABLE_ROLES.includes(d.caller_role);
}
@@ -125,10 +125,6 @@
return t('drive.role.owner', 'Owner');
case 'editor':
return t('drive.role.editor', 'Editor');
case 'contributor':
return t('drive.role.contributor', 'Contributor');
case 'commenter':
return t('drive.role.commenter', 'Commenter');
case 'viewer':
return t('drive.role.viewer', 'Viewer');
}