style.css gains the classes the views spelled out in full: .section-label (17 sites), .hint (20), .form-error (13), .alert-error (5), .row-card (5), .list-empty (5), .field (5), .page-shell (3), and the small row action .btn-sm (4) / .btn-sm-danger (3). Only exact runs moved, so nothing renders differently; spacing a site adds beyond a run stays a utility beside it. Kept: the Reminders and Timeline small buttons. They carry no text colour and inherit it, so putting them on .btn-sm would recolour them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
7.6 KiB
Vue
196 lines
7.6 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from "vue";
|
|
import { repo } from "../adapters";
|
|
import type { Directory, NoteShare, SharePermission, ShareTarget } from "../adapters/repo";
|
|
import { errorMessage } from "../api/errors";
|
|
import { useNotesStore } from "../stores/notes";
|
|
import BaseModal from "./BaseModal.vue";
|
|
import Icon from "./Icon.vue";
|
|
|
|
// Share a note with other people on this instance (#5174). The owner picks a member,
|
|
// or a group the admin made (#5177), and says whether they may only read it or also
|
|
// change its text. Only the owner opens this; the server refuses everyone else
|
|
// regardless.
|
|
|
|
const props = defineProps<{ noteId: string }>();
|
|
const emit = defineEmits<{ (e: "close"): void }>();
|
|
const notes = useNotesStore();
|
|
|
|
const PERMISSIONS: { value: SharePermission; label: string }[] = [
|
|
{ value: "view", label: "Can view" },
|
|
{ value: "edit", label: "Can edit" },
|
|
];
|
|
|
|
const directory = ref<Directory>({ members: [], groups: [] });
|
|
const shares = ref<NoteShare[]>([]);
|
|
const loading = ref(true);
|
|
const loadError = ref("");
|
|
const error = ref("");
|
|
const busy = ref(false);
|
|
// "u:<id>" for a person, "g:<id>" for a group: one picker lists both.
|
|
const pick = ref("");
|
|
const permission = ref<SharePermission>("view");
|
|
|
|
/** The people and groups it isn't shared with yet. */
|
|
const availablePeople = computed(() => {
|
|
const taken = new Set(shares.value.map((s) => s.member?.id));
|
|
return directory.value.members.filter((m) => !taken.has(m.id));
|
|
});
|
|
const availableGroups = computed(() => {
|
|
const taken = new Set(shares.value.map((s) => s.group?.id));
|
|
return directory.value.groups.filter((g) => !taken.has(g.id));
|
|
});
|
|
const anyone = computed(() => directory.value.members.length + directory.value.groups.length > 0);
|
|
const anyoneLeft = computed(() => availablePeople.value.length + availableGroups.value.length > 0);
|
|
|
|
function targetOf(share: NoteShare): ShareTarget {
|
|
return share.group ? { group_id: share.group.id } : { user_id: share.member?.id ?? "" };
|
|
}
|
|
|
|
function nameOf(share: NoteShare): string {
|
|
return share.group?.name ?? share.member?.display_name ?? "";
|
|
}
|
|
|
|
function detailOf(share: NoteShare): string {
|
|
if (!share.group) return share.member?.email ?? "";
|
|
const n = share.group.member_count;
|
|
return `Group · ${n} ${n === 1 ? "person" : "people"}`;
|
|
}
|
|
|
|
async function load() {
|
|
loading.value = true;
|
|
loadError.value = "";
|
|
try {
|
|
[directory.value, shares.value] = await Promise.all([repo.shares.directory(), repo.shares.list(props.noteId)]);
|
|
} catch (e) {
|
|
loadError.value = errorMessage(e, "Couldn't load who this is shared with.");
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function run(fn: () => Promise<NoteShare[]>, fallback: string) {
|
|
busy.value = true;
|
|
error.value = "";
|
|
try {
|
|
shares.value = await fn();
|
|
notes.setShared(props.noteId, shares.value.length > 0);
|
|
} catch (e) {
|
|
error.value = errorMessage(e, fallback);
|
|
} finally {
|
|
busy.value = false;
|
|
}
|
|
}
|
|
|
|
async function add() {
|
|
if (!pick.value) return;
|
|
const id = pick.value.slice(2);
|
|
const target: ShareTarget = pick.value.startsWith("g:") ? { group_id: id } : { user_id: id };
|
|
await run(() => repo.shares.share(props.noteId, target, permission.value), "Couldn't share the note.");
|
|
if (!error.value) pick.value = "";
|
|
}
|
|
|
|
function change(share: NoteShare, next: SharePermission) {
|
|
void run(() => repo.shares.share(props.noteId, targetOf(share), next), "Couldn't change that.");
|
|
}
|
|
|
|
function remove(share: NoteShare) {
|
|
void run(() => repo.shares.unshare(props.noteId, share.id), "Couldn't stop sharing.");
|
|
}
|
|
|
|
const selectClass =
|
|
"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 disabled:opacity-60 dark:border-neutral-700 dark:bg-neutral-800";
|
|
|
|
onMounted(load);
|
|
</script>
|
|
|
|
<template>
|
|
<BaseModal panel-class="w-full max-w-md shadow-xl" @close="emit('close')">
|
|
<div class="flex items-center justify-between border-b border-neutral-100 px-4 py-3 dark:border-neutral-800">
|
|
<h2 class="text-sm font-semibold">Share note</h2>
|
|
<button type="button" class="icon-btn" aria-label="Close" @click="emit('close')"><Icon name="close" /></button>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-4 p-4">
|
|
<p v-if="loading" class="py-4 text-center text-sm text-neutral-400">Loading…</p>
|
|
|
|
<div v-else-if="loadError" class="py-4 text-center">
|
|
<p class="form-error">{{ loadError }}</p>
|
|
<button
|
|
type="button"
|
|
class="mt-2 rounded-md border border-neutral-300 px-3 py-1 text-sm hover:bg-neutral-100 dark:border-neutral-700 dark:hover:bg-neutral-800"
|
|
@click="load"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<p v-if="!anyone" class="hint">
|
|
No one else has an account here yet. An admin can invite people from Settings.
|
|
</p>
|
|
|
|
<form v-else-if="anyoneLeft" class="flex flex-wrap items-center gap-2" @submit.prevent="add">
|
|
<select v-model="pick" :class="[selectClass, 'min-w-0 flex-1']" aria-label="Person or group" :disabled="busy">
|
|
<option value="" disabled>Choose someone…</option>
|
|
<optgroup v-if="availablePeople.length" label="People">
|
|
<option v-for="m in availablePeople" :key="m.id" :value="`u:${m.id}`">
|
|
{{ m.display_name }} ({{ m.email }})
|
|
</option>
|
|
</optgroup>
|
|
<optgroup v-if="availableGroups.length" label="Groups">
|
|
<option v-for="g in availableGroups" :key="g.id" :value="`g:${g.id}`">
|
|
{{ g.name }} ({{ g.member_count }})
|
|
</option>
|
|
</optgroup>
|
|
</select>
|
|
<select v-model="permission" :class="selectClass" aria-label="Permission" :disabled="busy">
|
|
<option v-for="p in PERMISSIONS" :key="p.value" :value="p.value">{{ p.label }}</option>
|
|
</select>
|
|
<button
|
|
type="submit"
|
|
class="rounded-md bg-brand px-3 py-1.5 text-sm font-semibold text-neutral-900 hover:brightness-95 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:opacity-60"
|
|
:disabled="busy || !pick"
|
|
>
|
|
Share
|
|
</button>
|
|
</form>
|
|
|
|
<p v-if="error" role="alert" class="form-error">{{ error }}</p>
|
|
|
|
<div class="flex flex-col gap-1">
|
|
<p class="section-label">Shared with</p>
|
|
<p v-if="!shares.length" class="text-sm text-neutral-400">Only you.</p>
|
|
<ul v-else class="flex flex-col gap-1">
|
|
<li v-for="s in shares" :key="s.id" class="flex items-center gap-2 rounded-lg px-1 py-1">
|
|
<div class="min-w-0 flex-1">
|
|
<p class="truncate text-sm text-neutral-800 dark:text-neutral-100">{{ nameOf(s) }}</p>
|
|
<p class="truncate text-xs text-neutral-400">{{ detailOf(s) }}</p>
|
|
</div>
|
|
<select
|
|
:value="s.permission"
|
|
:class="selectClass"
|
|
:aria-label="`Permission for ${nameOf(s)}`"
|
|
:disabled="busy"
|
|
@change="change(s, ($event.target as HTMLSelectElement).value as SharePermission)"
|
|
>
|
|
<option v-for="p in PERMISSIONS" :key="p.value" :value="p.value">{{ p.label }}</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
:title="`Stop sharing with ${nameOf(s)}`"
|
|
:aria-label="`Stop sharing with ${nameOf(s)}`"
|
|
:disabled="busy"
|
|
@click="remove(s)"
|
|
>
|
|
<Icon name="close" />
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</BaseModal>
|
|
</template>
|