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 ShareDialog from '$lib/components/ShareDialog.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 { t } from '$lib/i18n/index.svelte';
|
||||
import { drives as drivesStore, driveIcon } from '$lib/stores/drives.svelte';
|
||||
@@ -192,6 +194,10 @@
|
||||
|
||||
onMount(() => {
|
||||
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
|
||||
@@ -368,10 +374,7 @@
|
||||
{#if m.subject.type === 'user'}
|
||||
<UserVignette userId={m.subject.id} />
|
||||
{:else if m.subject.type === 'group'}
|
||||
<span class="members__group">
|
||||
<Icon name="users" />
|
||||
<span class="mono">{m.subject.id}</span>
|
||||
</span>
|
||||
<GroupVignette groupId={m.subject.id} />
|
||||
{:else}
|
||||
<span class="members__token">
|
||||
<Icon name="link" />
|
||||
|
||||
Reference in New Issue
Block a user