Files
inkwell/frontend/src/components/GroupList.vue
T
bvandeusenandClaude Opus 5.5 8c45725a2a DRY pass #2, batch 8, F17: a person and an account have one shape each (#5372)
Web: GroupList's Person was adapters/repo's Member field for field, and
AccountList's Account was the session store's User; both now import them.
The bridge's Identity stays, as the Tauri mirror of the core's struct.

Server: auth._serialize_user and accounts' _serialize_account wrote the same
four fields. serialize.serialize_user is serialize_person plus is_admin, and
the account list adds created_at to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:11:29 -04:00

201 lines
6.6 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { api } from "../api/client";
import type { Member } from "../adapters/repo";
import { errorMessage } from "../api/errors";
import BaseButton from "./BaseButton.vue";
import BaseInput from "./BaseInput.vue";
import Icon from "./Icon.vue";
import { useRowAction, useLoad } from "../composables/useAction";
// Admin: groups of people a note can be shared with in one go (#5177). A group is
// the instance's; anyone can share with it, and a note shared with it reaches whoever
// is in it at the time, so adding or removing someone here changes what they see.
interface Group {
id: string;
name: string;
members: Member[];
}
const groups = ref<Group[]>([]);
const accounts = ref<Member[]>([]);
const { loading, error, load } = useLoad(async () => {
const [g, a] = await Promise.all([
api.get<{ groups: Group[] }>("/api/groups"),
api.get<{ accounts: Member[] }>("/api/accounts"),
]);
groups.value = g.groups;
accounts.value = a.accounts;
}, "Couldn't load groups.");
const name = ref("");
const creating = ref(false);
const { busy, act } = useRowAction();
// Which group's "Add someone" picker holds which account id.
const picks = ref<Record<string, string>>({});
const byName = (a: Group, b: Group) => a.name.localeCompare(b.name);
function outside(group: Group): Member[] {
const inside = new Set(group.members.map((m) => m.id));
return accounts.value.filter((a) => !inside.has(a.id));
}
const empty = computed(() => !loading.value && !groups.value.length);
function replace(updated: Group) {
groups.value = groups.value.map((g) => (g.id === updated.id ? updated : g)).sort(byName);
}
async function create() {
if (!name.value.trim()) return;
creating.value = true;
error.value = "";
try {
const made = await api.post<Group>("/api/groups", { name: name.value.trim() });
groups.value = [...groups.value, made].sort(byName);
name.value = "";
} catch (e) {
error.value = errorMessage(e, "Couldn't create the group.");
} finally {
creating.value = false;
}
}
function rename(group: Group) {
const next = window.prompt("Rename the group", group.name)?.trim();
if (!next || next === group.name) return;
void act(
group.id,
async () => replace(await api.patch<Group>(`/api/groups/${group.id}`, { name: next })),
"Couldn't rename it.",
);
}
function remove(group: Group) {
if (!window.confirm(`Delete "${group.name}"? Notes shared with it stop being shared with its members.`)) return;
void act(
group.id,
async () => {
await api.del(`/api/groups/${group.id}`);
groups.value = groups.value.filter((g) => g.id !== group.id);
},
"Couldn't delete it.",
);
}
function addMember(group: Group) {
const userId = picks.value[group.id];
if (!userId) return;
void act(
group.id,
async () => {
replace(await api.post<Group>(`/api/groups/${group.id}/members`, { user_id: userId }));
picks.value[group.id] = "";
},
"Couldn't add them.",
);
}
function removeMember(group: Group, person: Member) {
void act(
group.id,
async () => replace(await api.del<Group>(`/api/groups/${group.id}/members/${person.id}`)),
"Couldn't remove them.",
);
}
onMounted(() => {
void load();
});
</script>
<template>
<section class="flex flex-col gap-5">
<h2 class="section-label">Groups</h2>
<form class="flex flex-wrap items-end gap-3" @submit.prevent="create">
<BaseInput id="group-name" v-model="name" label="New group" placeholder="Family" class="min-w-48 flex-1" />
<BaseButton type="submit" :loading="creating" :disabled="!name.trim()">Create group</BaseButton>
</form>
<p v-if="error" class="form-error">{{ error }}</p>
<div v-if="loading" class="list-empty">Loading…</div>
<p v-else-if="empty" class="text-sm text-neutral-400">No groups yet.</p>
<ul v-else class="flex flex-col gap-2">
<li
v-for="g in groups"
:key="g.id"
class="flex flex-col gap-3 rounded-xl border border-neutral-200 px-4 py-3 dark:border-neutral-800"
>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<p class="truncate text-sm font-medium text-neutral-800 dark:text-neutral-100">{{ g.name }}</p>
<p class="text-xs text-neutral-400">
{{ g.members.length }} {{ g.members.length === 1 ? "person" : "people" }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<button
type="button"
class="btn-sm"
:disabled="busy === g.id"
@click="rename(g)"
>
Rename
</button>
<button
type="button"
class="btn-sm-danger"
:disabled="busy === g.id"
@click="remove(g)"
>
Delete
</button>
</div>
</div>
<ul v-if="g.members.length" class="flex flex-wrap gap-1.5">
<li
v-for="m in g.members"
:key="m.id"
class="flex items-center gap-1 rounded-full bg-neutral-100 py-0.5 pl-2.5 pr-1 text-xs text-neutral-700 dark:bg-neutral-800 dark:text-neutral-200"
:title="m.email"
>
{{ m.display_name || m.email }}
<button
type="button"
class="rounded-full p-0.5 text-neutral-400 hover:text-red-500 disabled:opacity-50"
:aria-label="`Remove ${m.display_name || m.email} from ${g.name}`"
:disabled="busy === g.id"
@click="removeMember(g, m)"
>
<Icon name="close" />
</button>
</li>
</ul>
<form v-if="outside(g).length" class="flex flex-wrap items-center gap-2" @submit.prevent="addMember(g)">
<select
v-model="picks[g.id]"
class="min-w-0 flex-1 rounded-md border border-neutral-300 bg-white px-2 py-1.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-800"
:aria-label="`Add someone to ${g.name}`"
:disabled="busy === g.id"
>
<option :value="undefined" disabled>Add someone…</option>
<option v-for="a in outside(g)" :key="a.id" :value="a.id">{{ a.display_name }} ({{ a.email }})</option>
</select>
<button
type="submit"
class="btn-sm py-1.5"
:disabled="busy === g.id || !picks[g.id]"
>
Add
</button>
</form>
</li>
</ul>
</section>
</template>