visual continunity

This commit is contained in:
Bradley Nelson
2026-06-17 22:07:18 -06:00
parent daa3010458
commit 89e14f8f9e
89 changed files with 19249 additions and 1367 deletions
+345 -39
View File
@@ -1,10 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
addGroupMember,
addUserMember,
createGroup,
deleteGroup,
listGroups,
groupDisplayName,
groupIconName,
INTERNAL_GROUP_ID,
listGroupsPage,
listMembers,
removeGroupMember,
removeUserMember,
@@ -12,20 +16,43 @@
type GroupItem,
type GroupMember
} from '$lib/api/endpoints/groups';
import {
ensureResolvers,
resolveRecipient,
searchRecipients,
type Recipient
} from '$lib/api/endpoints/recipients';
import Icon from '$lib/icons/Icon.svelte';
import { t } from '$lib/i18n/index.svelte';
import { promptDialog } from '$lib/stores/dialogs.svelte';
import { ui } from '$lib/stores/ui.svelte';
const PAGE_SIZE = 50;
let groups = $state<GroupItem[]>([]);
let total = $state(0);
let loading = $state(false);
let loadingMore = $state(false);
let error = $state<string | null>(null);
let expandedId = $state<string | null>(null);
let members = $state<GroupMember[]>([]);
let resolverReady = $state(false);
const hasMore = $derived(groups.length < total);
// Add-member combobox state (scoped to the expanded group)
let addQuery = $state('');
let addResults = $state<Recipient[]>([]);
let addBusy = $state(false);
let searchTimer: ReturnType<typeof setTimeout> | null = null;
async function load() {
loading = true;
error = null;
try {
groups = await listGroups();
const page = await listGroupsPage(PAGE_SIZE, 0);
groups = page.items;
total = page.total;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
} finally {
@@ -33,11 +60,42 @@
}
}
async function loadMore() {
loadingMore = true;
try {
const page = await listGroupsPage(PAGE_SIZE, groups.length);
groups = [...groups, ...page.items];
total = page.total;
} catch (e) {
report(e);
} finally {
loadingMore = false;
}
}
function report(e: unknown) {
ui.notify(e instanceof Error ? e.message : String(e), 'error');
}
/** Localised "(N members)" label. Project i18n has no plural rules, so we
* branch on the three named forms ourselves. */
function memberCountLabel(count: number): string {
if (count === 0) return t('groups.member_count_zero', 'no members');
if (count === 1) return t('groups.member_count_one', '1 member');
return t('groups.member_count_other', { count }, '{{count}} members');
}
/** Display info for a member row; falls back to the raw id until caches load. */
function memberInfo(m: GroupMember): { label: string; sublabel?: string } {
// resolverReady gates re-resolution once the caches are populated.
void resolverReady;
const r = resolveRecipient(m.kind, m.id);
return { label: r.label, sublabel: r.sublabel };
}
async function expand(g: GroupItem) {
addQuery = '';
addResults = [];
if (expandedId === g.id) {
expandedId = null;
return;
@@ -52,10 +110,14 @@
}
async function onCreate() {
const name = prompt(t('groups.new_prompt', 'New group name'));
if (!name) return;
const name = await promptDialog({
title: t('groups.create_dialog_title', 'New group'),
placeholder: t('groups.name_placeholder', 'engineering'),
confirmText: t('actions.create', 'Create')
});
if (!name || !name.trim()) return;
try {
await createGroup(name);
await createGroup(name.trim());
await load();
} catch (e) {
report(e);
@@ -63,10 +125,15 @@
}
async function onRename(g: GroupItem) {
const name = prompt(t('groups.rename_prompt', 'New name'), g.name);
if (!name || name === g.name) return;
const name = await promptDialog({
title: t('groups.edit_dialog_title', 'Rename group'),
defaultValue: g.name,
placeholder: t('groups.name_placeholder', 'engineering'),
confirmText: t('actions.rename', 'Rename')
});
if (!name || !name.trim() || name.trim() === g.name) return;
try {
await renameGroup(g.id, name);
await renameGroup(g.id, name.trim());
await load();
} catch (e) {
report(e);
@@ -74,7 +141,25 @@
}
async function onDelete(g: GroupItem) {
if (!confirm(t('groups.confirm_delete', { name: g.name }, 'Delete group "{{name}}"?'))) return;
// Typed-name confirmation: the user must type the exact group name.
const typed = await promptDialog({
title: t('groups.delete_group', 'Delete group'),
message: t(
'groups.delete_confirm',
{ name: g.name },
'Delete the group "{{name}}"? Type the group name to confirm.'
),
placeholder: g.name,
confirmText: t('actions.delete', 'Delete')
});
if (typed === null) return;
if (typed !== g.name) {
ui.notify(
t('groups.delete_confirm_mismatch', 'Type the group name exactly to confirm.'),
'error'
);
return;
}
try {
await deleteGroup(g.id);
if (expandedId === g.id) expandedId = null;
@@ -84,11 +169,34 @@
}
}
async function onAddMember(g: GroupItem) {
const userId = prompt(t('groups.add_member_prompt', 'User ID to add'));
if (!userId) return;
function onAddQuery() {
if (searchTimer) clearTimeout(searchTimer);
const q = addQuery;
if (!q.trim()) {
addResults = [];
return;
}
searchTimer = setTimeout(async () => {
addBusy = true;
try {
const all = await searchRecipients(q);
// Don't offer the group as a member of itself, or current members.
const existing = new Set(members.map((m) => m.id));
addResults = all.filter((r) => r.id !== expandedId && !existing.has(r.id));
} catch (e) {
report(e);
} finally {
addBusy = false;
}
}, 200);
}
async function pickMember(g: GroupItem, r: Recipient) {
try {
await addUserMember(g.id, userId);
if (r.type === 'group') await addGroupMember(g.id, r.id);
else await addUserMember(g.id, r.id);
addQuery = '';
addResults = [];
members = await listMembers(g.id);
} catch (e) {
report(e);
@@ -97,15 +205,23 @@
async function onRemoveMember(groupId: string, m: GroupMember) {
try {
if (m.user_id) await removeUserMember(groupId, m.user_id);
else if (m.group_id) await removeGroupMember(groupId, m.group_id);
if (m.kind === 'user') await removeUserMember(groupId, m.id);
else await removeGroupMember(groupId, m.id);
members = await listMembers(groupId);
} catch (e) {
report(e);
}
}
onMount(load);
onMount(async () => {
await load();
try {
await ensureResolvers();
resolverReady = true;
} catch {
/* names fall back to ids */
}
});
</script>
<svelte:head><title>{t('nav.groups', 'Groups')} · OxiCloud</title></svelte:head>
@@ -129,40 +245,101 @@
<li class="group">
<div class="group__row">
<button class="group__name" onclick={() => expand(g)}>
{g.name}
{#if g.member_count != null}<span class="muted">({g.member_count})</span>{/if}
<span class="avatar"><Icon name={groupIconName(g)} /></span>
<span class="group__text">
<span class="group__title">
{groupDisplayName(g)}
{#if g.is_virtual}<span class="badge badge--system"
>{t('groups.virtual_badge', 'System')}</span
>{/if}
</span>
{#if g.description}<span class="muted">{g.description}</span>{/if}
{#if !g.is_virtual && g.member_count != null}
<span class="muted">{memberCountLabel(g.member_count)}</span>
{/if}
</span>
</button>
<div class="group__actions">
<button class="link-btn" onclick={() => onRename(g)}
>{t('common.rename', 'Rename')}</button
>
<button class="link-btn link-btn--danger" onclick={() => onDelete(g)}>
{t('common.delete', 'Delete')}
</button>
</div>
{#if g.can_manage !== false && !g.is_virtual}
<div class="group__actions">
<button class="link-btn" onclick={() => onRename(g)}
>{t('common.rename', 'Rename')}</button
>
<button class="link-btn link-btn--danger" onclick={() => onDelete(g)}>
{t('common.delete', 'Delete')}
</button>
</div>
{/if}
</div>
{#if expandedId === g.id}
<div class="members">
<div class="members__head">
<h2>{t('groups.members', 'Members')}</h2>
<button class="link-btn" onclick={() => onAddMember(g)}>
{t('groups.add_member', 'Add member')}
</button>
</div>
{#if g.can_manage !== false && !g.is_virtual}
<div class="add-member">
<input
class="add-member__input"
placeholder={t('groups.add_member_search', 'Search users or groups to add…')}
bind:value={addQuery}
oninput={onAddQuery}
/>
{#if addBusy}
<p class="muted">{t('common.loading', 'Loading…')}</p>
{:else if addResults.length > 0}
<ul class="add-member__results">
{#each addResults as r (r.type + r.id)}
<li>
<button class="add-member__opt" onclick={() => pickMember(g, r)}>
<span class="avatar avatar--sm">
<Icon name={r.type === 'group' ? 'user-group' : 'user'} />
</span>
<span class="vignette__text">
<span class="vignette__name">{r.label}</span>
{#if r.sublabel}<span class="muted">{r.sublabel}</span>{/if}
</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{:else if g.id === INTERNAL_GROUP_ID}
<p class="muted">
{t('groups.virtual_internal_explanation', 'Every internal user on this server.')}
</p>
{/if}
{#if members.length === 0}
<p class="muted">{t('groups.no_members', 'No members.')}</p>
{#if !(g.id === INTERNAL_GROUP_ID)}
<p class="muted">{t('groups.no_members', 'No members.')}</p>
{/if}
{:else}
<ul class="members__list">
{#each members as m (m.user_id ?? m.group_id)}
<li>
<span>{m.email ?? m.name ?? m.user_id ?? m.group_id}</span>
<button
class="link-btn link-btn--danger"
onclick={() => onRemoveMember(g.id, m)}
>
{t('common.remove', 'Remove')}
</button>
{#each members as m (m.kind + m.id)}
{@const info = memberInfo(m)}
<li class="vignette">
<span class="avatar avatar--sm">
<Icon name={m.kind === 'group' ? 'user-group' : 'user'} />
</span>
<span class="vignette__text">
<span class="vignette__name">
{info.label}
{#if m.kind === 'group'}<span class="badge badge--nested"
>{t('groups.nested', 'Group')}</span
>{/if}
</span>
{#if info.sublabel}<span class="muted">{info.sublabel}</span>{/if}
</span>
{#if g.can_manage !== false && !g.is_virtual}
<button
class="link-btn link-btn--danger"
onclick={() => onRemoveMember(g.id, m)}
>
{t('common.remove', 'Remove')}
</button>
{/if}
</li>
{/each}
</ul>
@@ -172,6 +349,12 @@
</li>
{/each}
</ul>
{#if hasMore}
<button class="btn load-more" disabled={loadingMore} onclick={loadMore}>
{loadingMore ? t('common.loading', 'Loading…') : t('groups.load_more', 'Load more')}
</button>
{/if}
{/if}
</main>
@@ -216,19 +399,40 @@
align-items: center;
justify-content: space-between;
padding: 0.75rem;
gap: 0.5rem;
}
.group__name {
display: flex;
align-items: center;
gap: var(--space-2);
background: none;
border: none;
color: var(--color-text);
cursor: pointer;
font-size: 1rem;
text-align: left;
flex: 1;
min-width: 0;
}
.group__text {
display: flex;
flex-direction: column;
gap: 0.125rem;
min-width: 0;
}
.group__title {
display: flex;
align-items: center;
gap: var(--space-2);
}
.group__actions {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.members {
@@ -262,6 +466,104 @@
justify-content: space-between;
}
.vignette {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
}
.vignette__text {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
text-align: left;
}
.vignette__name {
display: flex;
align-items: center;
gap: var(--space-2);
}
.avatar {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--color-accent-bg, var(--color-bg-muted));
color: var(--color-accent-text, var(--color-text));
font-size: var(--text-xs, 0.75rem);
flex-shrink: 0;
}
.avatar--sm {
width: 1.6rem;
height: 1.6rem;
}
.badge {
display: inline-block;
padding: 0.05rem 0.4rem;
border-radius: var(--radius-sm);
font-size: var(--text-xs, 0.7rem);
font-weight: var(--weight-semibold, 600);
}
.badge--system {
background: var(--color-warning-bg);
color: var(--color-warning-text);
}
.badge--nested {
background: var(--color-info-bg);
color: var(--color-info-text);
}
.add-member {
position: relative;
margin: var(--space-2) 0;
}
.add-member__input {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-input);
color: var(--color-text);
}
.add-member__results {
list-style: none;
margin: var(--space-1) 0 0;
padding: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-bg-surface);
max-height: 16rem;
overflow: auto;
}
.add-member__opt {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
background: none;
color: var(--color-text);
cursor: pointer;
text-align: left;
}
.add-member__opt:hover {
background: var(--color-bg-hover);
}
.muted {
color: var(--color-text-muted);
font-size: 0.8125rem;
@@ -292,6 +594,10 @@
border-color: transparent;
}
.load-more {
align-self: center;
}
.link-btn {
background: none;
border: none;