feat(drive): use GroupVignette for group as member
This commit is contained in:
@@ -0,0 +1,151 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Identity chip for a group subject in share/member lists: neutral group
|
||||||
|
* avatar, display name, "N member(s)" sublabel, and — on hover — the
|
||||||
|
* expanded list of user members surfaced as a native `title` tooltip.
|
||||||
|
*
|
||||||
|
* Mirrors <UserVignette> so the two chip shapes align across share and
|
||||||
|
* membership surfaces. Uses the preloaded group name cache
|
||||||
|
* (`resolveRecipient` / `ensureResolvers` in `endpoints/recipients.ts`)
|
||||||
|
* so the name lands synchronously when the parent has already primed
|
||||||
|
* the cache; falls back to the group id while resolving.
|
||||||
|
*
|
||||||
|
* Members are fetched lazily on mount via `listMembers` and cached
|
||||||
|
* per-id at the module level so multiple chips for the same group
|
||||||
|
* share one round-trip. Nested group members (kind === 'group') expand
|
||||||
|
* one level and surface as "+ group X" lines in the tooltip; deeper
|
||||||
|
* expansion isn't attempted here — the read-only summary would get
|
||||||
|
* unwieldy and the AuthZ engine expands transitively at check-time.
|
||||||
|
*/
|
||||||
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
|
import { resolveRecipient } from '$lib/api/endpoints/recipients';
|
||||||
|
import { resolveUser } from '$lib/api/endpoints/users';
|
||||||
|
import { listMembers, type GroupMember } from '$lib/api/endpoints/groups';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
groupId: string;
|
||||||
|
}
|
||||||
|
let { groupId }: Props = $props();
|
||||||
|
|
||||||
|
const label = $derived(resolveRecipient('group', groupId).label);
|
||||||
|
|
||||||
|
// Module-scoped cache of resolved members per group. Chips render N
|
||||||
|
// times on a busy /shared page; the shared cache avoids N × HTTP.
|
||||||
|
const memberCache = new Map<string, Promise<GroupMember[]>>();
|
||||||
|
function loadMembers(id: string): Promise<GroupMember[]> {
|
||||||
|
let p = memberCache.get(id);
|
||||||
|
if (!p) {
|
||||||
|
p = listMembers(id).catch(() => [] as GroupMember[]);
|
||||||
|
memberCache.set(id, p);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
let members = $state<GroupMember[]>([]);
|
||||||
|
let memberNames = $state<string[]>([]);
|
||||||
|
let membersLoaded = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
let alive = true;
|
||||||
|
members = [];
|
||||||
|
memberNames = [];
|
||||||
|
membersLoaded = false;
|
||||||
|
void loadMembers(groupId).then(async (list) => {
|
||||||
|
if (!alive) return;
|
||||||
|
members = list;
|
||||||
|
// Resolve user members to display names; group members show
|
||||||
|
// as `+ <group name>` via the recipient cache. All resolutions
|
||||||
|
// happen in parallel; each `resolveUser` is itself cached.
|
||||||
|
const names = await Promise.all(
|
||||||
|
list.map(async (m) => {
|
||||||
|
if (m.kind === 'user') {
|
||||||
|
const u = await resolveUser(m.id).catch(() => null);
|
||||||
|
return u?.name ?? u?.email ?? m.id;
|
||||||
|
}
|
||||||
|
return `+ ${resolveRecipient('group', m.id).label}`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (!alive) return;
|
||||||
|
memberNames = names;
|
||||||
|
membersLoaded = true;
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberCount = $derived(members.length);
|
||||||
|
|
||||||
|
// Native `title` tooltip carries the member list. Cheap, works on
|
||||||
|
// keyboard focus, no popover machinery needed for a read-only chip.
|
||||||
|
const titleText = $derived(
|
||||||
|
!membersLoaded
|
||||||
|
? label
|
||||||
|
: memberNames.length === 0
|
||||||
|
? `${label} — ${t('group.members_empty', 'No members')}`
|
||||||
|
: `${label}\n${memberNames.join('\n')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const sublabel = $derived(
|
||||||
|
membersLoaded
|
||||||
|
? t(
|
||||||
|
'group.member_count',
|
||||||
|
{ n: memberCount },
|
||||||
|
memberCount === 1 ? '1 member' : `${memberCount} members`
|
||||||
|
)
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="gv" title={titleText}>
|
||||||
|
<span class="gv__avatar" aria-hidden="true">
|
||||||
|
<Icon name="users" />
|
||||||
|
</span>
|
||||||
|
<span class="gv__text">
|
||||||
|
<span class="gv__name">{label}</span>
|
||||||
|
{#if sublabel}<span class="gv__sub">{sublabel}</span>{/if}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.gv {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.gv__avatar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-bg-muted);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.gv__text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.gv__name {
|
||||||
|
font-weight: var(--weight-semibold, 600);
|
||||||
|
color: var(--color-text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.gv__sub {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -14,6 +14,8 @@
|
|||||||
import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte';
|
import ReadOnlyBanner from '$lib/components/ReadOnlyBanner.svelte';
|
||||||
import ShareDialog from '$lib/components/ShareDialog.svelte';
|
import ShareDialog from '$lib/components/ShareDialog.svelte';
|
||||||
import UserVignette from '$lib/components/UserVignette.svelte';
|
import UserVignette from '$lib/components/UserVignette.svelte';
|
||||||
|
import GroupVignette from '$lib/components/GroupVignette.svelte';
|
||||||
|
import { ensureResolvers } from '$lib/api/endpoints/recipients';
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
import Icon from '$lib/icons/Icon.svelte';
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
|
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
|
||||||
@@ -192,6 +194,10 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void drivesStore.load();
|
void drivesStore.load();
|
||||||
|
// Preload the recipient caches (users + groups) so the members
|
||||||
|
// list can render group names + user labels synchronously. Both
|
||||||
|
// caches are module-level and shared across surfaces.
|
||||||
|
void ensureResolvers();
|
||||||
});
|
});
|
||||||
|
|
||||||
// SvelteKit reuses this component when navigating between
|
// SvelteKit reuses this component when navigating between
|
||||||
@@ -368,10 +374,7 @@
|
|||||||
{#if m.subject.type === 'user'}
|
{#if m.subject.type === 'user'}
|
||||||
<UserVignette userId={m.subject.id} />
|
<UserVignette userId={m.subject.id} />
|
||||||
{:else if m.subject.type === 'group'}
|
{:else if m.subject.type === 'group'}
|
||||||
<span class="members__group">
|
<GroupVignette groupId={m.subject.id} />
|
||||||
<Icon name="users" />
|
|
||||||
<span class="mono">{m.subject.id}</span>
|
|
||||||
</span>
|
|
||||||
{:else}
|
{:else}
|
||||||
<span class="members__token">
|
<span class="members__token">
|
||||||
<Icon name="link" />
|
<Icon name="link" />
|
||||||
|
|||||||
Reference in New Issue
Block a user