feat(shared-with-me): show the sharer

This commit is contained in:
Edouard Vanbelle
2026-07-26 23:40:58 +02:00
parent 3b31b8911b
commit 785d922243
4 changed files with 286 additions and 7 deletions
@@ -163,6 +163,17 @@
* the action is meaningful for and returns nothing otherwise.
*/
bucketAction?: Snippet<[string]>;
/**
* Optional custom renderer for the swimlane header label. Receives
* the bucket key + the default label string (from `labelOf`, or
* the key itself if no `labelOf`). Pages that want rich header
* content (e.g. `/shared-with-me` prefixing the "Shared by X"
* header with the sharer's avatar) use this; pages happy with
* a plain text label leave it undefined and ResourceList renders
* `{section.label}` as before. The default label is passed too
* so pages don't have to re-invoke `labelOf` themselves.
*/
bucketLabel?: Snippet<[string, string]>;
/** Show the owner column + vignette (list view) and hover tooltip. */
showOwner?: boolean;
/**
@@ -248,6 +259,20 @@
* don't have to piggyback on the bar.
*/
itemActions?: Snippet<[FileItem | FolderItem]>;
/**
* Free-form overlay rendered inside `.file-item` (as a sibling of
* `.action-cell`), so the page can absolute-position content
* anywhere on the card. Used by `/shared-with-me` to anchor the
* sharer avatar at the bottom-right of the card — the pre-fix
* `rowBadge` slot rendered inside `.file-icon` (a positioned
* ancestor), which couldn't align with `.action-cell`'s
* `.file-item`-scoped coordinates. The page provides its own
* absolute-positioning CSS via a scoped style block.
*
* Fires in grid view only — list view has explicit columns
* (owner cell, etc.) for the same information.
*/
cardOverlay?: Snippet<[FileItem | FolderItem, ItemContext | undefined]>;
/**
* Action-bar left cluster — always-visible page action buttons
* (Upload / New folder / Empty trash / Clear recent / …). Swaps
@@ -385,6 +410,7 @@
dateLabel,
dateCell,
bucketAction,
bucketLabel,
showOwner = false,
ownerLabel,
showViewToggle = true,
@@ -402,6 +428,7 @@
oncontextmenu: onContextMenuOverride,
menuPrepare,
itemActions,
cardOverlay,
actions,
batchActions,
rowBadge,
@@ -1240,6 +1267,17 @@
{/if}
</div>
{/if}
<!--
Page-provided card overlay — grid view only. Fires as the
last child of `.file-item` (which is already `position:
relative` to anchor `.action-cell`), so the page can
absolute-position content anywhere inside the card without
creating a new positioned ancestor. See `/shared-with-me`
for the sharer-avatar consumer.
-->
{#if cardOverlay && filesStore.viewMode === 'grid'}
{@render cardOverlay(item, ctx)}
{/if}
</div>
{/snippet}
@@ -1370,7 +1408,13 @@
{#each sections as section (section.key)}
{#if section.label}
<div class="rl-swimlane-header" role="rowheader">
<span class="rl-swimlane-header__label">{section.label}</span>
<span class="rl-swimlane-header__label">
{#if bucketLabel}
{@render bucketLabel(section.key, section.label)}
{:else}
{section.label}
{/if}
</span>
{#if bucketAction}
<span class="rl-swimlane-header__action">
{@render bucketAction(section.key)}
@@ -1395,7 +1439,13 @@
{#each sections as section (section.key)}
{#if section.label}
<div class="rl-swimlane-header rl-swimlane-header--grid" role="rowheader">
<span class="rl-swimlane-header__label">{section.label}</span>
<span class="rl-swimlane-header__label">
{#if bucketLabel}
{@render bucketLabel(section.key, section.label)}
{:else}
{section.label}
{/if}
</span>
{#if bucketAction}
<span class="rl-swimlane-header__action">
{@render bucketAction(section.key)}
@@ -1680,6 +1730,17 @@
align-items: center;
}
/* Label slot — inline-flex + gap so pages injecting rich content
via the `bucketLabel` snippet (e.g. `/shared-with-me` prefixing
with a sharer avatar) render avatar-then-text on one baseline
without hand-tuned spacing. Plain-text labels (no snippet) still
look identical — flex on a single text node is a no-op. */
.rl-swimlane-header__label {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
/* Grouped-grid container: a vertical stack of (header + its own windowed
card grid) per section. Not `.files-grid-view` — the grid is on each
VirtualList's inner window, so this outer element just stacks. */
@@ -0,0 +1,131 @@
<script lang="ts">
/**
* Avatar-only chip — no name / email, just the circle. Extracted from
* `<UserVignette>` (which pairs avatar + name + email for share
* dialogs / owner cells) so surfaces that only have room for the
* avatar (grid-card overlay, swimlane header prefix) don't drag the
* text half in with them.
*
* Same visual grammar as UserVignette: uploaded photo when present,
* otherwise coloured initials from `userInitials()` + `avatarColorIndex()`.
* Same lazy `resolveUser` fetch (cached) so external-user avatars
* populate without a per-mount round-trip.
*/
import { resolveUser, type ResolvedUser } from '$lib/api/endpoints/users';
import { userInitials, avatarColorIndex } from '$lib/utils/avatar';
interface Props {
userId: string;
/** Fallback label if `resolveUser` fails or is still resolving. */
fallbackLabel?: string;
/** Pixel size — defaults to 24 (badge / swimlane scale). */
size?: number;
/**
* Optional title text override. Defaults to the resolved display
* name so browsers show a tooltip on hover. Pass empty string
* to suppress the tooltip.
*/
title?: string;
}
let { userId, fallbackLabel, size = 24, title }: Props = $props();
let resolved = $state<ResolvedUser | null>(null);
$effect(() => {
let alive = true;
resolved = null;
void resolveUser(userId).then((u) => {
if (alive) resolved = u;
});
return () => {
alive = false;
};
});
const label = $derived(resolved?.name ?? fallbackLabel ?? userId);
const image = $derived(resolved?.image ?? null);
const colorIndex = $derived(avatarColorIndex(userId));
const initials = $derived(userInitials(label));
const tooltip = $derived(title !== undefined ? title : label);
</script>
<span class="ua" style:width={`${size}px`} style:height={`${size}px`} title={tooltip || undefined}>
{#if image}
<img class="ua__photo" src={image} alt="" />
{:else}
<span class="ua__initials ua__initials--c{colorIndex}" style:font-size={`${size * 0.42}px`}>
{initials}
</span>
{/if}
</span>
<style>
.ua {
/* `inline-block` (not `inline-flex`) so children with
`width/height: 100%` reliably fill the box. Ed 2026-07-26:
the previous `inline-flex + align-items: center` shape gave
the `<img>` a cross-axis intrinsic height (~6 px in a 22 px
box); `align-self: stretch` on the img wasn't enough to
defeat the parent's alignment rule in some contexts. Block
sizing sidesteps flex-alignment quirks entirely.
`vertical-align: middle` aligns the chip with adjacent
text (swimlane header prefix, etc.). */
display: inline-block;
position: relative;
flex-shrink: 0;
border-radius: var(--radius-full);
overflow: hidden;
vertical-align: middle;
}
.ua__photo {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.ua__initials {
/* `display: flex` (block-level) — NOT `inline-flex`. An inline-
flex child inside the `.ua` `inline-block` parent participates
in inline layout, gets baseline-aligned, and rendered outside
its parent's box for text with descenders (Ed 2026-07-26).
Block-flex fills the container unambiguously. */
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
color: var(--color-on-accent);
font-weight: var(--weight-semibold);
text-transform: uppercase;
}
/* Colour buckets mirror the shared avatar palette used by
`AppShell` / `UserVignette` — 5 slots (see `avatarColorIndex()`
which does `Math.abs(hash) % 5`). Same badge tokens so a user's
avatar renders in the identical colour across surfaces. */
.ua__initials--c0 {
background: var(--color-badge-indigo-bg);
color: var(--color-badge-indigo-text);
}
.ua__initials--c1 {
background: var(--color-badge-green-bg);
color: var(--color-badge-green-text);
}
.ua__initials--c2 {
background: var(--color-badge-orange-bg);
color: var(--color-badge-orange-text);
}
.ua__initials--c3 {
background: var(--color-badge-blue-bg);
color: var(--color-badge-blue-text);
}
.ua__initials--c4 {
background: var(--color-badge-amber-bg);
color: var(--color-badge-amber-text);
}
</style>