mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-14 12:28:18 +02:00
feat: add sharing UI components and integrate into NoteCard/NoteEditor
- Create ShareDialog with user search autocomplete, collaborator list, and remove - Create CollaboratorPopover for hover/click indicator on shared notes - Add collaborator indicator to NoteCard (always visible Users icon) - Replace trash with Leave action for non-owner shared notes - Add share button to NoteEditor toolbar (UserPlus → Users when shared) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
bbacc5f6b3
commit
346256a628
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Collaborator } from '$lib/types/index.js';
|
||||||
|
import Users from 'lucide-svelte/icons/users';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
collaborators: Collaborator[];
|
||||||
|
ownerName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { collaborators, ownerName }: Props = $props();
|
||||||
|
|
||||||
|
let showPopover = $state(false);
|
||||||
|
let hoverTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
function handleMouseEnter() {
|
||||||
|
hoverTimeout = setTimeout(() => {
|
||||||
|
showPopover = true;
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
clearTimeout(hoverTimeout);
|
||||||
|
showPopover = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(e: Event) {
|
||||||
|
e.stopPropagation();
|
||||||
|
showPopover = !showPopover;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="relative inline-flex"
|
||||||
|
onmouseenter={handleMouseEnter}
|
||||||
|
onmouseleave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-sm p-1 text-[var(--text-muted)] hover:text-[var(--primary)]"
|
||||||
|
title="Shared note"
|
||||||
|
onclick={handleClick}
|
||||||
|
data-testid="collaborator-indicator"
|
||||||
|
>
|
||||||
|
<Users class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if showPopover}
|
||||||
|
<div
|
||||||
|
class="absolute left-0 bottom-full z-20 mb-1 min-w-[160px] rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] p-2 shadow-[var(--card-shadow)] animate-[fade-in_150ms_ease-out]"
|
||||||
|
data-testid="collaborator-popover"
|
||||||
|
>
|
||||||
|
<p class="mb-1 text-xs font-semibold text-[var(--text-muted)]">Shared with</p>
|
||||||
|
<ul class="space-y-0.5">
|
||||||
|
{#if ownerName}
|
||||||
|
<li class="text-xs text-[var(--text)]">
|
||||||
|
{ownerName} <span class="text-[var(--text-muted)]">(Owner)</span>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#each collaborators as collab}
|
||||||
|
<li class="text-xs text-[var(--text)]">
|
||||||
|
{collab.displayName || collab.email}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||||
import { renderMarkdown } from '$lib/utils/markdown.js';
|
import { renderMarkdown } from '$lib/utils/markdown.js';
|
||||||
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, currentFilter } from '$lib/stores/notes.js';
|
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, updateNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
|
||||||
import TagChip from './TagChip.svelte';
|
import TagChip from './TagChip.svelte';
|
||||||
import ImageLightbox from './ImageLightbox.svelte';
|
import ImageLightbox from './ImageLightbox.svelte';
|
||||||
|
import CollaboratorPopover from './CollaboratorPopover.svelte';
|
||||||
import type { Note } from '$lib/types/index.js';
|
import type { Note } from '$lib/types/index.js';
|
||||||
import Undo2 from 'lucide-svelte/icons/undo-2';
|
import Undo2 from 'lucide-svelte/icons/undo-2';
|
||||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||||
import Bookmark from 'lucide-svelte/icons/bookmark';
|
import Bookmark from 'lucide-svelte/icons/bookmark';
|
||||||
import Archive from 'lucide-svelte/icons/archive';
|
import Archive from 'lucide-svelte/icons/archive';
|
||||||
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
|
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
|
||||||
|
import UserMinus from 'lucide-svelte/icons/user-minus';
|
||||||
|
|
||||||
interface ChecklistItem {
|
interface ChecklistItem {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -112,16 +114,25 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Status indicators (top-right) -->
|
||||||
|
<div class="absolute top-1.5 right-1.5 flex items-center gap-0.5">
|
||||||
|
{#if note.isShared && note.collaborators}
|
||||||
|
<CollaboratorPopover
|
||||||
|
collaborators={note.collaborators}
|
||||||
|
ownerName={note.isOwner ? 'You' : undefined}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{#if note.pinned}
|
{#if note.pinned}
|
||||||
<button
|
<button
|
||||||
onclick={stop(() => togglePin(note.id, note.pinned))}
|
onclick={stop(() => togglePin(note.id, note.pinned))}
|
||||||
class="absolute top-1.5 right-1.5 rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
|
||||||
title="Unpin"
|
title="Unpin"
|
||||||
data-testid="pin-indicator"
|
data-testid="pin-indicator"
|
||||||
>
|
>
|
||||||
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if note.title}
|
{#if note.title}
|
||||||
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">{note.title}</h3>
|
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">{note.title}</h3>
|
||||||
@@ -208,6 +219,16 @@
|
|||||||
<Archive class="h-4 w-4" />
|
<Archive class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if note.isShared && !note.isOwner}
|
||||||
|
<button
|
||||||
|
onclick={stop(() => leaveNote(note.id))}
|
||||||
|
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||||
|
title="Leave note"
|
||||||
|
data-testid="leave-btn"
|
||||||
|
>
|
||||||
|
<UserMinus class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
<button
|
<button
|
||||||
onclick={stop(() => trashNote(note.id))}
|
onclick={stop(() => trashNote(note.id))}
|
||||||
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
|
||||||
@@ -217,6 +238,7 @@
|
|||||||
<Trash2 class="h-4 w-4" />
|
<Trash2 class="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if lightboxSrc}
|
{#if lightboxSrc}
|
||||||
|
|||||||
@@ -4,16 +4,20 @@
|
|||||||
import FormattingToolbar from './FormattingToolbar.svelte';
|
import FormattingToolbar from './FormattingToolbar.svelte';
|
||||||
import TiptapEditor from './TiptapEditor.svelte';
|
import TiptapEditor from './TiptapEditor.svelte';
|
||||||
import ImageUpload from './ImageUpload.svelte';
|
import ImageUpload from './ImageUpload.svelte';
|
||||||
|
import ShareDialog from './ShareDialog.svelte';
|
||||||
import { updateNote, createNote } from '$lib/stores/notes.js';
|
import { updateNote, createNote } from '$lib/stores/notes.js';
|
||||||
|
import { notes } from '$lib/stores/notes.js';
|
||||||
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
import { NOTE_COLORS } from '$lib/utils/colors.js';
|
||||||
import type { Editor } from '@tiptap/core';
|
import type { Editor } from '@tiptap/core';
|
||||||
import type { Note, NoteColor, Attachment } from '$lib/types/index.js';
|
import type { Note, NoteColor, Attachment, Collaborator } from '$lib/types/index.js';
|
||||||
import Palette from 'lucide-svelte/icons/palette';
|
import Palette from 'lucide-svelte/icons/palette';
|
||||||
import SquareCheck from 'lucide-svelte/icons/square-check';
|
import SquareCheck from 'lucide-svelte/icons/square-check';
|
||||||
import ImageIcon from 'lucide-svelte/icons/image';
|
import ImageIcon from 'lucide-svelte/icons/image';
|
||||||
import Type from 'lucide-svelte/icons/type';
|
import Type from 'lucide-svelte/icons/type';
|
||||||
import FileCode from 'lucide-svelte/icons/file-code';
|
import FileCode from 'lucide-svelte/icons/file-code';
|
||||||
import FileText from 'lucide-svelte/icons/file-text';
|
import FileText from 'lucide-svelte/icons/file-text';
|
||||||
|
import UserPlus from 'lucide-svelte/icons/user-plus';
|
||||||
|
import Users from 'lucide-svelte/icons/users';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
note: Note | null;
|
note: Note | null;
|
||||||
@@ -48,6 +52,36 @@
|
|||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
let attachmentsList = $state<Attachment[]>(note?.attachments ?? []);
|
||||||
|
|
||||||
|
let showShareDialog = $state(false);
|
||||||
|
// svelte-ignore state_referenced_locally
|
||||||
|
let collaboratorsList = $state<Collaborator[]>(note?.collaborators ?? []);
|
||||||
|
const isOwner = $derived(note?.isOwner !== false);
|
||||||
|
const isShared = $derived(collaboratorsList.length > 0);
|
||||||
|
// Use displayName from first user or 'You' for owner
|
||||||
|
const ownerName = $derived(isOwner ? 'You' : 'Owner');
|
||||||
|
|
||||||
|
function handleCollaboratorsUpdate(updated: Collaborator[]) {
|
||||||
|
collaboratorsList = updated;
|
||||||
|
// Update the note in the store
|
||||||
|
if (noteId) {
|
||||||
|
notes.update((list) =>
|
||||||
|
list.map((n) =>
|
||||||
|
n.id === noteId
|
||||||
|
? { ...n, collaborators: updated, isShared: updated.length > 0 }
|
||||||
|
: n
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleShareDialog() {
|
||||||
|
if (currentlyNew) {
|
||||||
|
const id = await autoSave();
|
||||||
|
if (!id) return;
|
||||||
|
}
|
||||||
|
showShareDialog = !showShareDialog;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
|
// Fetch attachments for existing notes if not pre-populated (e.g. loaded from IDB)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
|
if (noteId && !currentlyNew && (!note?.attachments || note.attachments.length === 0)) {
|
||||||
@@ -285,6 +319,26 @@
|
|||||||
<FileCode class="h-5 w-5" />
|
<FileCode class="h-5 w-5" />
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Share button (owner only for new shares, all for viewing) -->
|
||||||
|
{#if isOwner}
|
||||||
|
<button
|
||||||
|
onclick={toggleShareDialog}
|
||||||
|
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
|
||||||
|
title="Share note"
|
||||||
|
data-testid="share-toggle"
|
||||||
|
>
|
||||||
|
{#if isShared}
|
||||||
|
<Users class="h-5 w-5 text-[var(--primary)]" />
|
||||||
|
{:else}
|
||||||
|
<UserPlus class="h-5 w-5" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else if isShared}
|
||||||
|
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" title="Shared note">
|
||||||
|
<Users class="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -297,3 +351,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if showShareDialog && noteId}
|
||||||
|
<ShareDialog
|
||||||
|
noteId={noteId}
|
||||||
|
collaborators={collaboratorsList}
|
||||||
|
{ownerName}
|
||||||
|
onClose={() => (showShareDialog = false)}
|
||||||
|
onUpdate={handleCollaboratorsUpdate}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Collaborator } from '$lib/types/index.js';
|
||||||
|
import X from 'lucide-svelte/icons/x';
|
||||||
|
import UserMinus from 'lucide-svelte/icons/user-minus';
|
||||||
|
import Search from 'lucide-svelte/icons/search';
|
||||||
|
import { showToast } from '$lib/stores/toast.js';
|
||||||
|
|
||||||
|
interface UserResult {
|
||||||
|
id: number;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
noteId: string;
|
||||||
|
collaborators: Collaborator[];
|
||||||
|
ownerName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onUpdate: (collaborators: Collaborator[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { noteId, collaborators, ownerName, onClose, onUpdate }: Props = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let searchResults = $state<UserResult[]>([]);
|
||||||
|
let isSearching = $state(false);
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
function handleSearch(query: string) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
if (!query.trim()) {
|
||||||
|
searchResults = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchTimeout = setTimeout(async () => {
|
||||||
|
isSearching = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/users/search?q=${encodeURIComponent(query)}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const users: UserResult[] = await res.json();
|
||||||
|
// Filter out existing collaborators
|
||||||
|
const collabIds = new Set(collaborators.map((c) => c.userId));
|
||||||
|
searchResults = users.filter((u) => !collabIds.has(u.id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Search failed silently
|
||||||
|
} finally {
|
||||||
|
isSearching = false;
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
handleSearch(searchQuery);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function addCollaborator(user: UserResult) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${noteId}/collaborators`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: user.id })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const updated: Collaborator[] = await res.json();
|
||||||
|
onUpdate(updated);
|
||||||
|
searchQuery = '';
|
||||||
|
searchResults = [];
|
||||||
|
showToast(`Shared with ${user.displayName || user.email}`, 'success');
|
||||||
|
} else {
|
||||||
|
const err = await res.json().catch(() => ({ message: 'Failed to share' }));
|
||||||
|
showToast(err.message || 'Failed to share', 'error');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to share note', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeCollaborator(userId: number) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/notes/${noteId}/collaborators?userId=${userId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const updated = collaborators.filter((c) => c.userId !== userId);
|
||||||
|
onUpdate(updated);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast('Failed to remove collaborator', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-20 pb-10 animate-[fade-in_150ms_ease-out]"
|
||||||
|
onclick={onClose}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
data-testid="share-dialog-overlay"
|
||||||
|
>
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="mx-4 w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)] animate-[pop-in_150ms_ease-out]"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
data-testid="share-dialog"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-[var(--border-subtle)] px-4 py-3">
|
||||||
|
<h2 class="text-sm font-semibold text-[var(--text)]">Share note</h2>
|
||||||
|
<button
|
||||||
|
onclick={onClose}
|
||||||
|
class="rounded-sm p-1 hover:bg-[var(--border)]/10"
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search input -->
|
||||||
|
<div class="relative px-4 pt-3">
|
||||||
|
<div class="relative">
|
||||||
|
<Search class="absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--text-muted)]" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search users..."
|
||||||
|
bind:value={searchQuery}
|
||||||
|
class="w-full rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-base)] py-2 pl-8 pr-3 text-sm text-[var(--text)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--primary)]"
|
||||||
|
data-testid="share-search-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search results dropdown -->
|
||||||
|
{#if searchResults.length > 0}
|
||||||
|
<ul class="absolute left-4 right-4 z-10 mt-1 max-h-40 overflow-y-auto rounded-sm border border-[var(--border-subtle)] bg-[var(--bg-surface)] shadow-[var(--card-shadow)]" data-testid="share-search-results">
|
||||||
|
{#each searchResults as user}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-2 text-left text-sm text-[var(--text)] hover:bg-[var(--primary)]/10"
|
||||||
|
onclick={() => addCollaborator(user)}
|
||||||
|
data-testid="share-user-result"
|
||||||
|
>
|
||||||
|
<span class="font-medium">{user.displayName || 'User'}</span>
|
||||||
|
{#if user.email}
|
||||||
|
<span class="ml-1 text-[var(--text-muted)]">{user.email}</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current collaborators -->
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<p class="mb-2 text-xs font-semibold text-[var(--text-muted)]">People with access</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<!-- Owner -->
|
||||||
|
<li class="flex items-center justify-between rounded-sm px-2 py-1.5">
|
||||||
|
<span class="text-sm text-[var(--text)]">
|
||||||
|
{ownerName}
|
||||||
|
<span class="text-xs text-[var(--text-muted)]">(Owner)</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<!-- Collaborators -->
|
||||||
|
{#each collaborators as collab}
|
||||||
|
<li class="flex items-center justify-between rounded-sm px-2 py-1.5 hover:bg-[var(--border)]/10" data-testid="share-collaborator">
|
||||||
|
<span class="text-sm text-[var(--text)]">
|
||||||
|
{collab.displayName || collab.email}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onclick={() => removeCollaborator(collab.userId)}
|
||||||
|
class="rounded-sm p-1 text-[var(--text-muted)] hover:text-[var(--destructive)]"
|
||||||
|
title="Remove"
|
||||||
|
data-testid="remove-collaborator-btn"
|
||||||
|
>
|
||||||
|
<UserMinus class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user