feat(shared-with-me): show the sharer
This commit is contained in:
@@ -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>
|
||||
@@ -360,6 +360,19 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* List-view rows are fixed 56px (VirtualList `rowHeight={56}`), and a
|
||||
full UserVignette stacks avatar (32px) + name (20px) + email (15px)
|
||||
→ ~40px block that doesn't visually fit the flex-centered cell —
|
||||
the email tail was cropped. Hide the email in dense-row contexts
|
||||
(Ed 2026-07-26); the identity signal remains (avatar + name), and
|
||||
the email survives in the ShareDialog / recipient pickers where
|
||||
UserVignette was originally scaled for. Grid view's owner cell is
|
||||
unaffected (it never renders a UserVignette — the sharer surfaces
|
||||
through the `.file-icon__badge` slot instead). */
|
||||
.files-list-view .file-item .owner-cell .uv__email {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Expand the grid track as soon as at least one owner cell is visible. */
|
||||
.files-list-view:has(.owner-cell:not(.hidden)) {
|
||||
--files-list-columns: 36px minmax(200px, 2fr) 120px 100px 110px 130px 200px;
|
||||
@@ -409,7 +422,10 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.files-list-view .file-item .name-cell span {
|
||||
/* Same narrowing rationale as the grid view rule below — target only
|
||||
the name text span, not every descendant span, so `.file-icon__badge`
|
||||
overlay contents (avatars, chips) aren't sized as text. */
|
||||
.files-list-view .file-item .name-cell__text {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1036,11 +1052,16 @@
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.files-grid-view .file-item .name-cell span {
|
||||
/* Narrowed from the pre-fix `.name-cell span` (descendant selector) to
|
||||
the specific name-text span. The broad rule caught EVERY span inside
|
||||
`.name-cell` — including the `.file-icon__badge` overlay and, inside
|
||||
it, `<UserAvatar>`'s `.ua` wrapper — and slapped 8 px of top/bottom
|
||||
padding on them, which crushed the badge's `<img>` from 22 × 22 down
|
||||
to 22 × 6 (Ed 2026-07-26). Text ellipsis / padding stays on the text
|
||||
span alone; overlay children in `.file-icon` are unaffected. */
|
||||
.files-grid-view .file-item .name-cell__text {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
/* Ellipsis must live on the span (the text node), not the flex parent,
|
||||
or long names clip with no "…". */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
type GroupByDef,
|
||||
type ItemContext
|
||||
} from '$lib/components/ResourceList.svelte';
|
||||
import UserAvatar from '$lib/components/UserAvatar.svelte';
|
||||
import { t } from '$lib/i18n/index.svelte';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
|
||||
@@ -259,7 +260,43 @@
|
||||
cursor = undefined;
|
||||
load(true, orderBy, rev);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{#snippet cardOverlay(_item, ctx)}
|
||||
<!--
|
||||
Sharer avatar anchored to the bottom-right of the CARD (not
|
||||
the file-icon). Uses ResourceList's `cardOverlay` slot,
|
||||
which renders inside `.file-item` as a sibling of
|
||||
`.action-cell` — same positioned ancestor, so the offset
|
||||
matches the top-right cluster exactly. Grid view only;
|
||||
list view surfaces the sharer through the owner column.
|
||||
|
||||
Positioning lives in the `<style>` block below; the snippet
|
||||
just emits the chip and lets CSS place it. `ctx.ownerId`
|
||||
(seeded from `granted_by` in `load()`) is the source of
|
||||
truth for "who shared this."
|
||||
-->
|
||||
{#if ctx?.ownerId}
|
||||
<span class="shared-with-me__sharer">
|
||||
<UserAvatar userId={ctx.ownerId} size={30} />
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet bucketLabel(_key, label)}
|
||||
<!--
|
||||
"Shared by" swimlane header — prefix the label with the
|
||||
sharer's avatar so the group is visually anchored to a
|
||||
person, not just a UUID-ish name. Other group-by dimensions
|
||||
(`type`, `sharedAt`, or the flat `Name` sort) don't have
|
||||
an ownerId as the key, so render only the plain label —
|
||||
`_key` for those is a category / date-bucket / empty string
|
||||
that has no avatar equivalent.
|
||||
-->
|
||||
{#if groupBy === 'sharedBy' && _key}
|
||||
<UserAvatar userId={_key} size={30} />
|
||||
{/if}
|
||||
<span class="rl-swimlane-header__text">{label}</span>
|
||||
{/snippet}
|
||||
</ResourceList>
|
||||
|
||||
{#if fileViewer.component}
|
||||
{@const FileViewer = fileViewer.component}
|
||||
@@ -267,6 +304,35 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/*
|
||||
* Sharer avatar chip — anchored to the bottom-right of the card
|
||||
* (grid view). Rendered inside `.file-item` via ResourceList's
|
||||
* `cardOverlay` snippet slot, so `position: absolute` climbs to
|
||||
* `.file-item`'s own `position: relative` (the same containing
|
||||
* block `.action-cell` uses in the top-right). The `right` +
|
||||
* `bottom` offset matches `.action-cell`'s `top` + `right`
|
||||
* offset so the two chips sit in the same vertical column at
|
||||
* opposite corners — Ed 2026-07-26.
|
||||
*
|
||||
* The wrapper is `:global` so the scoped-CSS renaming Svelte
|
||||
* applies to the `<span>` this snippet emits doesn't strip our
|
||||
* selector. Only fires inside the grid view — the parent
|
||||
* `{@render cardOverlay}` in ResourceList already gates on
|
||||
* `filesStore.viewMode === 'grid'`, so no need to gate here.
|
||||
*/
|
||||
:global(.files-grid-view .file-item .shared-with-me__sharer) {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
/* Pushed 15 px closer to the card bottom so the chip sits at
|
||||
the same vertical band as the item title instead of hovering
|
||||
above it (Ed 2026-07-26). `calc(var(--space-3) + 8px)` was
|
||||
20 px from the bottom edge, right in the thumbnail area
|
||||
overlapping the title's top; 5 px anchors the chip cleanly
|
||||
at the card's bottom-right corner. */
|
||||
bottom: 5px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.upgrade-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user