feat: unify sharing UI, redesign lists header, fix team sharing, and add user autocomplete
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 18s

This commit is contained in:
sandrodach committed 2026-10-25 16:04:08 +01:00
1 parent 6d07352e80
commit 354129b2d4
10 files changed
+1049 -393

No files matched your search

+1
View File
@@ -719,6 +719,7 @@ export const App: React.FC = () => {
{activeTab === 'tasks' && (
<TasksView
currentUser={user}
onRefreshStats={refreshAll}
onOpenDocument={handleOpenDocument}
refreshTrigger={refreshRevision}
+205
View File
@@ -0,0 +1,205 @@
import React, { useState, useEffect, useRef } from 'react';
import { Search, X, Check } from 'lucide-react';
import { User } from '../lib/types';
interface UserAutocompleteProps {
users: User[];
selectedUser: User | null;
onSelectUser: (user: User | null) => void;
placeholder?: string;
disabled?: boolean;
excludeUserIds?: string[];
className?: string;
}
export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
users,
selectedUser,
onSelectUser,
placeholder = 'Benutzer suchen (Name, @Benutzername oder E-Mail)…',
disabled = false,
excludeUserIds = [],
className = '',
}) => {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Filter users based on query and exclusions
const filteredUsers = React.useMemo(() => {
const q = query.trim().toLowerCase();
const available = users.filter((u) => !excludeUserIds.includes(u.id));
if (!q) {
return available.slice(0, 15);
}
return available
.filter((u) => {
const nameMatch = (u.displayName || '').toLowerCase().includes(q);
const emailMatch = (u.email || '').toLowerCase().includes(q);
const userMatch = (u.username || '').toLowerCase().includes(q);
return nameMatch || emailMatch || userMatch;
})
.slice(0, 15);
}, [users, query, excludeUserIds]);
const handleSelect = (user: User) => {
onSelectUser(user);
setQuery('');
setIsOpen(false);
setHighlightedIndex(-1);
};
const handleClear = () => {
onSelectUser(null);
setQuery('');
setTimeout(() => inputRef.current?.focus(), 0);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'Enter') {
setIsOpen(true);
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightedIndex((prev) =>
prev < filteredUsers.length - 1 ? prev + 1 : 0
);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIndex((prev) =>
prev > 0 ? prev - 1 : filteredUsers.length - 1
);
} else if (e.key === 'Enter') {
e.preventDefault();
if (highlightedIndex >= 0 && highlightedIndex < filteredUsers.length) {
handleSelect(filteredUsers[highlightedIndex]);
}
} else if (e.key === 'Escape') {
setIsOpen(false);
}
};
return (
<div ref={containerRef} className={`relative ${className}`}>
{selectedUser ? (
<div className="h-8 px-2.5 bg-panel-elevated border border-accent/40 rounded-btn flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2 min-w-0">
<div className="w-5 h-5 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
{(selectedUser.displayName || selectedUser.email || '?')[0].toUpperCase()}
</div>
<span className="font-semibold text-main truncate">
{selectedUser.displayName || selectedUser.email}
</span>
{selectedUser.username && (
<span className="text-[11px] text-muted truncate">
@{selectedUser.username}
</span>
)}
{selectedUser.email && selectedUser.displayName && (
<span className="text-[11px] text-muted truncate hidden sm:inline">
({selectedUser.email})
</span>
)}
</div>
{!disabled && (
<button
type="button"
onClick={handleClear}
className="text-muted hover:text-main p-0.5 rounded hover:bg-canvas transition-colors"
title="Auswahl aufheben"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
) : (
<div className="relative">
<Search className="w-3.5 h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
ref={inputRef}
type="text"
value={query}
disabled={disabled}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
setHighlightedIndex(-1);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-full h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs placeholder:text-muted transition-colors disabled:opacity-50"
/>
{isOpen && (
<div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-56 overflow-y-auto bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
{filteredUsers.length === 0 ? (
<div className="p-3 text-xs text-muted text-center">
Kein passender Benutzer gefunden
</div>
) : (
filteredUsers.map((u, idx) => {
const isHighlighted = idx === highlightedIndex;
return (
<button
key={u.id}
type="button"
onClick={() => handleSelect(u)}
onMouseEnter={() => setHighlightedIndex(idx)}
className={`w-full px-2.5 py-1.5 flex items-center justify-between gap-2 text-left text-xs transition-colors cursor-pointer ${
isHighlighted
? 'bg-accent/15 text-main font-medium'
: 'hover:bg-panel-elevated text-main'
}`}
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-5 h-5 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
{(u.displayName || u.email || '?')[0].toUpperCase()}
</div>
<div className="truncate">
<span className="font-semibold text-main">
{u.displayName || u.email}
</span>
{u.username && (
<span className="ml-1 text-[11px] text-muted">
@{u.username}
</span>
)}
{u.email && u.displayName && (
<span className="ml-1.5 text-[10px] text-muted truncate">
{u.email}
</span>
)}
</div>
</div>
{isHighlighted && (
<Check className="w-3.5 h-3.5 text-accent shrink-0" />
)}
</button>
);
})
)}
</div>
)}
</div>
)}
</div>
);
};
+230 -150
View File
@@ -8,12 +8,14 @@ import {
Check,
Radio,
Sparkles,
Share,
Share2,
User as UserIcon,
X,
} from 'lucide-react';
import { ListItem, SharedList, Space, Team, User, WSMessage } from '../../lib/types';
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
import { UserAutocomplete } from '../UserAutocomplete';
interface SharedListsViewProps {
onRefreshGlobal: () => void;
@@ -87,6 +89,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [teams, setTeams] = useState<Team[]>([]);
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
const [selectedTeamId, setSelectedTeamId] = useState('');
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
const [inviteTarget, setInviteTarget] = useState('');
const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor');
const [invitingLoading, setInvitingLoading] = useState(false);
@@ -291,6 +294,26 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
setActiveSpace(created);
};
const openShareModal = async () => {
if (!activeSpace) return;
setShowShareModal(true);
setShareError(null);
setSelectedTeamId('');
setSelectedShareUser(null);
try {
const [usersList, teamsList, sharesList] = await Promise.all([
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
api.getUserTeams().catch(() => []),
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
]);
setCollaborators(usersList);
setTeams(teamsList);
setActiveSpace((prev) => (prev ? { ...prev, shares: sharesList } : prev));
} catch {
// ignore
}
};
const handleAddShare = async (e: React.FormEvent) => {
e.preventDefault();
if (!activeSpace) return;
@@ -309,25 +332,27 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
});
setSelectedTeamId('');
} else {
const target = inviteTarget.trim();
if (!target) {
setShareError('Bitte einen Benutzer auswählen oder eingeben');
if (!selectedShareUser) {
setShareError('Bitte einen bestehenden Benutzer auswählen');
setInvitingLoading(false);
return;
}
const isId = collaborators.some((u) => u.id === target);
await api.addSpaceMember(activeSpace.id, {
userId: isId ? target : undefined,
email: !isId ? target : undefined,
userId: selectedShareUser.id,
role: inviteRole,
});
setInviteTarget('');
setSelectedShareUser(null);
}
const sp = await api.getSpaces();
const [sp, freshShares] = await Promise.all([
api.getSpaces(),
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
]);
setSpaces(sp);
const updated = sp.find((s) => s.id === activeSpace.id);
if (updated) setActiveSpace(updated);
if (updated) {
setActiveSpace({ ...updated, shares: freshShares });
}
onRefreshGlobal();
} catch (err: any) {
setShareError(err.message || 'Freigabe konnte nicht hinzugefügt werden');
@@ -336,34 +361,25 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
}
};
const handleRemoveMember = async (targetUserId: string) => {
const handleRemoveMember = async (targetId: string) => {
if (!activeSpace) return;
if (!window.confirm('Dieses Mitglied wirklich aus der Einkaufsliste / dem Bereich entfernen?')) return;
if (!window.confirm('Freigabe wirklich aufheben?')) return;
setInvitingLoading(true);
setShareError(null);
try {
await api.removeSpaceMember(activeSpace.id, targetUserId);
setActiveSpace((prev) =>
prev
? {
...prev,
members: (prev.members || []).filter((m) => m.userId !== targetUserId),
}
: prev
);
setSpaces((prev) =>
prev.map((s) =>
s.id === activeSpace.id
? {
...s,
members: (s.members || []).filter((m) => m.userId !== targetUserId),
}
: s
)
);
await api.removeSpaceMember(activeSpace.id, targetId);
const [sp, freshShares] = await Promise.all([
api.getSpaces(),
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
]);
setSpaces(sp);
const updated = sp.find((s) => s.id === activeSpace.id);
if (updated) {
setActiveSpace({ ...updated, shares: freshShares });
}
onRefreshGlobal();
} catch (err: any) {
setShareError(err.message || 'Mitglied konnte nicht entfernt werden');
setShareError(err.message || 'Freigabe konnte nicht entfernt werden');
} finally {
setInvitingLoading(false);
}
@@ -414,17 +430,18 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
return (
<div className="w-full space-y-3">
{/* Top Space Bar & Realtime Indicator */}
<div className="px-3 py-2 panel-surface flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-1.5">
{/* Spaces Bar */}
<div className="flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-1.5 overflow-x-auto">
<span className="text-muted text-[11px] font-medium mr-1">Bereich:</span>
{spaces.map((sp) => (
<button
key={sp.id}
onClick={() => setActiveSpace(sp)}
className={`h-8 px-3 text-xs sm:text-sm font-medium flex items-center gap-1.5 transition-colors border rounded-btn ${
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 transition-colors border ${
activeSpace?.id === sp.id
? 'bg-accent text-on-accent border-accent'
: 'bg-canvas text-muted hover:text-main border-border'
? 'bg-accent/15 border-accent/40 text-accent font-semibold'
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel'
}`}
>
<span>{sp.icon}</span>
@@ -434,48 +451,13 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<button
onClick={() => setShowNewSpaceModal((prev) => !prev)}
className="btn-action h-8"
className="h-7 px-2 text-xs text-muted hover:text-main hover:bg-panel rounded-btn flex items-center gap-1 transition-colors border border-dashed border-border"
title="Neuen Bereich (Space) anlegen"
>
<Plus className="w-3.5 h-3.5 text-accent" /> Space
<Plus className="w-3 h-3 text-accent" />
<span>Space</span>
</button>
</div>
{/* Realtime Status & Share List Button */}
{activeSpace && (
<div className="flex items-center gap-2">
<div
className={`h-8 w-8 flex items-center justify-center rounded-btn ${
wsConnected
? 'badge-ok'
: 'badge-neutral'
}`}
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline'}
>
<Radio className={`w-4 h-4 ${wsConnected ? 'animate-pulse text-status-ok' : 'text-muted'}`} />
</div>
<button
onClick={() => {
setShowShareModal(true);
setShareError(null);
Promise.all([
api.getUsersDirectory().then(setCollaborators).catch(() => []),
api.getUserTeams().then(setTeams).catch(() => []),
]);
}}
title="Einkaufsliste teilen & verwalten"
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
>
<Share2 className="w-3.5 h-3.5 text-accent" />
<span>Teilen</span>
{activeSpace.members && activeSpace.members.length > 1 && (
<span className="badge-neutral font-mono text-[10px]">
{activeSpace.members.length}
</span>
)}
</button>
</div>
)}
</div>
{/* Create Space Modal */}
@@ -501,39 +483,103 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</form>
)}
{/* List Tabs within Active Space */}
<div className="flex flex-wrap items-center gap-1.5">
{lists.map((lst) => {
const openCount = lst.items.filter((i) => !i.isCompleted).length;
const isSelected = activeList?.id === lst.id;
return (
<button
key={lst.id}
onClick={() => setActiveListId(lst.id)}
className={`h-8 px-3 text-xs sm:text-sm font-medium flex items-center gap-1.5 transition-all border rounded-btn ${
isSelected
? 'bg-accent text-on-accent border-accent'
: 'bg-panel text-muted hover:text-main border-border'
}`}
>
<span>{lst.icon}</span>
<span>{lst.title}</span>
<span className={`px-1.5 py-0.2 text-[10px] font-semibold rounded-pill ${
isSelected ? 'bg-white/20 text-on-accent' : 'badge-neutral'
}`}>
{openCount}
</span>
</button>
);
})}
{/* Top Header: List Tabs & Actions */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
{/* List Tabs */}
<div className="flex flex-wrap items-center gap-1">
{lists.map((lst) => {
const openCount = lst.items.filter((i) => !i.isCompleted).length;
const isSelected = activeList?.id === lst.id;
return (
<button
key={lst.id}
onClick={() => setActiveListId(lst.id)}
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
isSelected
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span>{lst.icon}</span>
<span>{lst.title}</span>
{openCount > 0 && (
<span className="ml-1 badge-neutral">{openCount}</span>
)}
</button>
);
})}
<button
onClick={() => setShowNewListModal((prev) => !prev)}
className="btn-action h-8"
>
<Plus className="w-3.5 h-3.5 text-accent" />
<span>Neue Liste</span>
</button>
<button
onClick={() => setShowNewListModal((prev) => !prev)}
className="h-8 px-2.5 bg-canvas hover:bg-panel border border-border border-dashed rounded-btn text-xs text-muted hover:text-main flex items-center gap-1 transition-colors"
title="Neue Liste anlegen"
>
<Plus className="w-3.5 h-3.5" />
<span>Neue Liste</span>
</button>
</div>
{/* Right Header Section: Geteilt Badge, Icon-Only Share Button & Live Sync */}
<div className="flex items-center gap-2">
{activeSpace && (() => {
const isOwner = !activeSpace.ownerId || activeSpace.ownerId === currentUser?.id;
const sharesCount =
(activeSpace.shares?.length || 0) +
(activeSpace.members?.filter((m) => m.userId !== activeSpace.ownerId).length || 0);
const isShared =
(activeSpace.shares && activeSpace.shares.length > 0) ||
(activeSpace.members && activeSpace.members.length > 1);
return (
<div className="flex items-center gap-1.5">
{isShared && (
isOwner ? (
<button
type="button"
onClick={openShareModal}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt ({sharesCount > 0 ? sharesCount : activeSpace.members?.length || 1})</span>
</button>
) : (
<button
type="button"
onClick={openShareModal}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {activeSpace.ownerName || 'Eigentümer'}</span>
</button>
)
)}
<button
type="button"
onClick={openShareModal}
className="btn-icon"
title="Einkaufsliste teilen und Berechtigungen"
>
<Share className="w-3.5 h-3.5" />
</button>
</div>
);
})()}
{/* Live Sync Status */}
<div
className={`h-8 w-8 flex items-center justify-center rounded-btn border ${
wsConnected
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
: 'bg-canvas border-border text-muted'
}`}
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline'}
>
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
</div>
</div>
</div>
{showNewListModal && (
@@ -819,45 +865,31 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</select>
</div>
) : (
<div className="space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<select
value={inviteTarget}
onChange={(e) => setInviteTarget(e.target.value)}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs flex-1 cursor-pointer"
>
<option value="" className="bg-panel text-muted">Benutzer auswählen oder eingeben…</option>
{collaborators
.filter(
(u) =>
u.id !== currentUser?.id &&
!(activeSpace.members || []).some((m) => m.userId === u.id)
)
.map((u) => (
<option key={u.id} value={u.id} className="bg-panel text-main">
👤 {u.displayName || u.username || u.email} ({u.username ? `@${u.username}` : u.email})
</option>
))}
</select>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as 'editor' | 'viewer')}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full sm:w-36 cursor-pointer"
>
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
</select>
</div>
{(!inviteTarget || !collaborators.some((u) => u.id === inviteTarget)) && (
<input
type="text"
placeholder="Oder Benutzername / E-Mail direkt eingeben…"
value={inviteTarget}
onChange={(e) => setInviteTarget(e.target.value)}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full"
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex-1">
<UserAutocomplete
users={collaborators}
selectedUser={selectedShareUser}
onSelectUser={setSelectedShareUser}
excludeUserIds={[
currentUser?.id || '',
...(activeSpace.members || []).map((m) => m.userId),
...(activeSpace.shares || [])
.filter((s) => s.targetType === 'user')
.map((s) => s.targetId),
].filter(Boolean)}
disabled={invitingLoading}
placeholder="Benutzer suchen (Name oder @Benutzername)…"
/>
)}
</div>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as 'editor' | 'viewer')}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full sm:w-36 cursor-pointer shrink-0"
>
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
</select>
</div>
)}
@@ -866,7 +898,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
type="submit"
disabled={
invitingLoading ||
(addShareType === 'team' ? !selectedTeamId : !inviteTarget.trim())
(addShareType === 'team' ? !selectedTeamId : !selectedShareUser)
}
className="btn-primary text-xs h-8 px-4 flex items-center gap-1.5"
>
@@ -882,12 +914,60 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div>
</form>
{/* Current Space Members List */}
{/* Current Space Members & Team Shares List */}
<div className="space-y-2">
<div className="text-xs font-semibold text-muted uppercase tracking-wider">
Aktive Freigaben & Mitglieder ({activeSpace.members?.length || 1})
Aktive Freigaben & Mitglieder (
{((activeSpace.shares || []).filter((s) => s.targetType === 'team').length) +
(activeSpace.members?.length || 1)}
)
</div>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
<div className="space-y-1.5 max-h-56 overflow-y-auto">
{/* Team Shares */}
{(activeSpace.shares || [])
.filter((s) => s.targetType === 'team')
.map((sh) => {
const canRemove =
activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin';
return (
<div
key={sh.id}
className="p-2.5 bg-canvas border border-border rounded-btn flex items-center justify-between gap-3 text-xs"
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-7 h-7 rounded-btn bg-accent/20 text-accent flex items-center justify-center shrink-0">
<Users className="w-3.5 h-3.5" />
</div>
<div className="truncate">
<div className="font-medium text-main truncate flex items-center gap-1.5">
<span>{sh.targetName || 'Team'}</span>
<span className="badge-neutral text-[9px] uppercase px-1 py-0.2">Gruppe</span>
</div>
<div className="text-[11px] text-muted truncate">Freigabe für die Gruppe</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-[10px] px-2 py-0.5 rounded font-semibold bg-surface-2 text-muted">
{sh.role === 'editor' ? 'Bearbeiter' : 'Leser'}
</span>
{canRemove && (
<button
type="button"
onClick={() => handleRemoveMember(sh.id)}
disabled={invitingLoading}
className="btn-icon text-muted hover:text-status-danger p-1"
title="Team-Freigabe entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
})}
{/* Individual Members */}
{(activeSpace.members || []).map((m) => {
const isOwner = m.role === 'owner' || m.userId === activeSpace.ownerId;
const isMe = m.userId === currentUser?.id;
+119 -81
View File
@@ -13,6 +13,7 @@ import {
Edit3,
X,
Layers,
Share,
Share2,
Users,
CheckSquare,
@@ -29,6 +30,7 @@ import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn } f
import { api, triggerHaptic } from '../../lib/api';
import { TaskDetailModal } from './TaskDetailModal';
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
import { UserAutocomplete } from '../UserAutocomplete';
const getInitials = (name?: string): string => {
if (!name) return '?';
@@ -40,12 +42,14 @@ const getInitials = (name?: string): string => {
};
interface TasksViewProps {
currentUser?: User | null;
onRefreshStats: () => void;
onOpenDocument: (docId: string) => void;
refreshTrigger?: number;
}
export const TasksView: React.FC<TasksViewProps> = ({
currentUser,
onRefreshStats,
onOpenDocument,
refreshTrigger = 0,
@@ -75,6 +79,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
const [selectedShareTargetId, setSelectedShareTargetId] = useState('');
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
const [shareLoading, setShareLoading] = useState(false);
// Detail modal state
@@ -577,26 +582,47 @@ export const TasksView: React.FC<TasksViewProps> = ({
}
};
const handleOpenShareModal = (tl: TaskList) => {
const handleOpenShareModal = async (tl: TaskList) => {
setSharingTaskList(tl);
setSelectedShareTargetId('');
setSelectedShareUser(null);
setShowShareListModal(true);
try {
const [freshTeams, freshUsers, freshShares] = await Promise.all([
api.getUserTeams().catch(() => []),
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
api.getTaskListShares(tl.id).catch(() => []),
]);
setTeams(freshTeams);
setCollaborators(freshUsers);
setSharingTaskList((prev) => (prev && prev.id === tl.id ? { ...prev, shares: freshShares } : prev));
} catch {
// ignore
}
};
const handleAddShare = async () => {
if (!sharingTaskList || !selectedShareTargetId) return;
if (!sharingTaskList) return;
const targetId = addShareType === 'team' ? selectedShareTargetId : selectedShareUser?.id;
if (!targetId) return;
setShareLoading(true);
triggerHaptic(15);
try {
await api.addTaskListShare(sharingTaskList.id, {
targetType: addShareType,
targetId: selectedShareTargetId,
targetId: targetId,
});
const freshLists = await api.getTaskLists();
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
api.getTaskListShares(sharingTaskList.id).catch(() => []),
]);
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) setSharingTaskList(updated);
if (updated) {
setSharingTaskList({ ...updated, shares: freshShares });
}
setSelectedShareTargetId('');
setSelectedShareUser(null);
await loadData();
} catch (err: any) {
alert(err.message || 'Fehler beim Hinzufügen der Freigabe');
@@ -607,14 +633,20 @@ export const TasksView: React.FC<TasksViewProps> = ({
const handleRemoveShare = async (shareId: string) => {
if (!sharingTaskList) return;
if (!window.confirm('Freigabe wirklich aufheben?')) return;
setShareLoading(true);
triggerHaptic(15);
try {
await api.removeTaskListShare(sharingTaskList.id, shareId);
const freshLists = await api.getTaskLists();
const [freshLists, freshShares] = await Promise.all([
api.getTaskLists(),
api.getTaskListShares(sharingTaskList.id).catch(() => []),
]);
setTaskLists(freshLists);
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
if (updated) setSharingTaskList(updated);
if (updated) {
setSharingTaskList({ ...updated, shares: freshShares });
}
await loadData();
} catch (err: any) {
alert(err.message || 'Fehler beim Entfernen der Freigabe');
@@ -869,19 +901,27 @@ export const TasksView: React.FC<TasksViewProps> = ({
{tl.tasksCount !== undefined && tl.tasksCount > 0 && (
<span className="ml-1 badge-neutral">{tl.tasksCount}</span>
)}
{tl.members && tl.members.length > 0 ? (
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members.length} Mitglieder)`}>
{tl.members.slice(0, 2).map((m) => (
<span key={m.userId} className="w-3.5 h-3.5 rounded-pill bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center">
{getInitials(m.displayName || m.email)[0]}
</span>
))}
</div>
) : (tl.teamId || (tl.shares && tl.shares.length > 0)) ? (
<span title="Geteilt mit Team oder Benutzern">
<Share2 className="w-2.5 h-2.5 text-accent ml-0.5" />
</span>
) : null}
{(() => {
const isShared = (tl.shares && tl.shares.length > 0) || Boolean(tl.teamId) || (tl.members && tl.members.length > 1);
if (!isShared) return null;
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
if (otherMembers.length > 0) {
return (
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
{otherMembers.slice(0, 2).map((m) => (
<span key={m.userId} className="w-3.5 h-3.5 rounded-pill bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center">
{getInitials(m.displayName || m.email)[0]}
</span>
))}
</div>
);
}
return (
<span title="Geteilt mit Team oder Benutzern">
<Users className="w-2.5 h-2.5 text-accent ml-0.5" />
</span>
);
})()}
</button>
{selectedListId === tl.id && (
<button
@@ -905,55 +945,52 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button>
</div>
{/* Right Header Section: Sharing, Avatar Stack & View Switcher */}
{/* Right Header Section: Sharing Badge, Icon-Only Button & View Switcher */}
<div className="flex items-center gap-2">
{currentSelectedList && (
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="btn-action h-8 px-2.5 flex items-center gap-1.5 text-xs"
title={`Liste '${currentSelectedList.title}' mit Team oder Nutzern teilen`}
>
<Users className="w-3.5 h-3.5 text-accent" />
<span>Teilen</span>
</button>
{currentSelectedList && (() => {
const isOwner = !currentSelectedList.ownerId || currentSelectedList.ownerId === currentUser?.id;
const sharesCount = (currentSelectedList.shares?.length || 0) > 0
? currentSelectedList.shares.length
: (currentSelectedList.members?.filter((m) => m.userId !== currentSelectedList.ownerId).length || 0);
const isShared = sharesCount > 0 || Boolean(currentSelectedList.teamId) || ((currentSelectedList.members?.length || 0) > 1);
{/* Avatar Stack */}
{currentSelectedList.members && currentSelectedList.members.length > 0 ? (
<div
onClick={() => handleOpenShareModal(currentSelectedList)}
className="flex items-center -space-x-1.5 cursor-pointer hover:opacity-90 transition-opacity pl-0.5"
title={`Geteilt mit ${currentSelectedList.members.length} Mitgliedern (${currentSelectedList.teamName || 'Team'}). Klick zum Verwalten.`}
>
{currentSelectedList.members.slice(0, 3).map((m) => (
<div
key={m.userId}
className="w-7 h-7 rounded-pill border-2 border-panel bg-accent/20 text-accent flex items-center justify-center text-[10px] font-bold shadow-sm"
title={`${m.displayName || m.email} (${m.role})`}
return (
<div className="flex items-center gap-1.5">
{isShared && (
isOwner ? (
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
{getInitials(m.displayName || m.email)}
</div>
))}
{currentSelectedList.members.length > 3 && (
<div className="w-7 h-7 rounded-pill border-2 border-panel bg-accent text-on-accent flex items-center justify-center text-[10px] font-bold shadow-sm">
+{currentSelectedList.members.length - 3}
</div>
)}
</div>
) : (
<div
<Users className="w-2.5 h-2.5" />
<span>Geteilt ({sharesCount > 0 ? sharesCount : currentSelectedList.members?.length || 1})</span>
</button>
) : (
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {currentSelectedList.ownerName || 'Eigentümer'}</span>
</button>
)
)}
<button
type="button"
onClick={() => handleOpenShareModal(currentSelectedList)}
className="flex items-center cursor-pointer pl-0.5"
title="Privat (nur du). Klick zum Teilen."
className="btn-icon"
title="Aufgabenliste teilen und Berechtigungen"
>
<div className="w-7 h-7 rounded-pill border border-dashed border-border bg-canvas text-muted hover:text-accent hover:border-accent flex items-center justify-center text-[11px] font-bold transition-colors">
🔒
</div>
</div>
)}
</div>
)}
<Share className="w-3.5 h-3.5" />
</button>
</div>
);
})()}
{/* View Switcher: List vs Kanban */}
<div className="flex border border-border rounded-btn bg-canvas overflow-hidden">
@@ -1291,7 +1328,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
type="button"
onClick={() => {
setAddShareType('team');
setSelectedShareTargetId('');
setSelectedShareUser(null);
}}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
addShareType === 'team'
@@ -1336,26 +1373,27 @@ export const TasksView: React.FC<TasksViewProps> = ({
))}
</select>
) : (
<select
value={selectedShareTargetId}
onChange={(e) => setSelectedShareTargetId(e.target.value)}
className="flex-1 h-8 px-2.5 input-field text-xs bg-panel"
>
<option value="">-- Benutzer auswählen --</option>
{safeCollaborators
.filter((c) => !(sharingTaskList.shares || []).some((s) => s.targetType === 'user' && s.targetId === c.id))
.map((c) => (
<option key={c.id} value={c.id}>
👤 {c.displayName || c.email} ({c.email})
</option>
))}
</select>
<div className="flex-1">
<UserAutocomplete
users={safeCollaborators}
selectedUser={selectedShareUser}
onSelectUser={setSelectedShareUser}
excludeUserIds={[
currentUser?.id || '',
...(sharingTaskList.shares || [])
.filter((s) => s.targetType === 'user')
.map((s) => s.targetId),
].filter(Boolean)}
disabled={shareLoading}
placeholder="Benutzer suchen (Name oder @Benutzername)…"
/>
</div>
)}
<button
type="button"
onClick={handleAddShare}
disabled={!selectedShareTargetId || shareLoading}
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-50"
disabled={shareLoading || (addShareType === 'team' ? !selectedShareTargetId : !selectedShareUser)}
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-50 shrink-0"
>
<Plus className="w-3.5 h-3.5" />
<span>Freigeben</span>
@@ -53,6 +53,7 @@ import {
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails, TagSummary, Team, User } from '../../lib/types';
import { api, getToken, triggerHaptic } from '../../lib/api';
import { CodeBlockItem } from './CodeBlockItem';
import { UserAutocomplete } from '../UserAutocomplete';
interface WorkspaceViewProps {
currentUser?: User | null;
@@ -182,7 +183,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [shareTeams, setShareTeams] = useState<Team[]>([]);
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
const [selectedShareTeamId, setSelectedShareTeamId] = useState('');
const [shareTargetEmailOrId, setShareTargetEmailOrId] = useState('');
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
const [sharePermission, setSharePermission] = useState<'read' | 'write'>('read');
const [sharingLoading, setSharingLoading] = useState(false);
const [shareError, setShareError] = useState<string | null>(null);
@@ -625,10 +626,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
setShowShareModal(true);
setShareError(null);
setSharingLoading(true);
setSelectedShareUser(null);
setSelectedShareTeamId('');
try {
const [sharesList, usersList, teamsList] = await Promise.all([
api.getDocShares(selectedDoc.id).catch(() => []),
api.getCollaborators().catch(() => []),
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
api.getUserTeams().catch(() => []),
]);
setDocShares(sharesList);
@@ -657,19 +660,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
});
setSelectedShareTeamId('');
} else {
const target = shareTargetEmailOrId.trim();
if (!target) {
setShareError('Bitte einen Benutzer auswählen oder E-Mail eingeben');
if (!selectedShareUser) {
setShareError('Bitte einen bestehenden Benutzer auswählen');
setSharingLoading(false);
return;
}
const isId = collaborators.some((u) => u.id === target);
await api.createDocShare(selectedDoc.id, {
userId: isId ? target : undefined,
email: !isId ? target : undefined,
userId: selectedShareUser.id,
permission: sharePermission,
});
setShareTargetEmailOrId('');
setSelectedShareUser(null);
}
const sharesList = await api.getDocShares(selectedDoc.id).catch(() => []);
@@ -715,18 +715,18 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
};
const handleRemoveShare = async (targetUserId: string) => {
const handleRemoveShare = async (targetId: string) => {
if (!selectedDoc) return;
if (!window.confirm('Freigabe für diesen Benutzer wirklich aufheben?')) return;
if (!window.confirm('Freigabe wirklich aufheben?')) return;
setSharingLoading(true);
try {
await api.deleteDocShare(selectedDoc.id, targetUserId);
setDocShares((prev) => prev.filter((s) => s.userId !== targetUserId));
await api.deleteDocShare(selectedDoc.id, targetId);
setDocShares((prev) => prev.filter((s) => (s.targetId || s.userId || s.id) !== targetId && s.userId !== targetId));
setSelectedDoc((prev) =>
prev
? {
...prev,
sharedWith: (prev.sharedWith || []).filter((s) => s.userId !== targetUserId),
sharedWith: (prev.sharedWith || []).filter((s) => (s.targetId || s.userId || s.id) !== targetId && s.userId !== targetId),
}
: prev
);
@@ -4403,7 +4403,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
type="button"
onClick={() => {
setAddShareType('team');
setShareTargetEmailOrId('');
setSelectedShareUser(null);
}}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
addShareType === 'team'
@@ -4456,42 +4456,28 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</select>
</div>
) : (
<div className="space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<select
value={shareTargetEmailOrId}
onChange={(e) => setShareTargetEmailOrId(e.target.value)}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs flex-1 cursor-pointer"
>
<option value="" className="bg-panel text-muted">Nutzer auswählen oder Email eingeben…</option>
{collaborators
.filter((u) => u.id !== currentUser?.id && !docShares.some((s) => s.userId === u.id))
.map((u) => (
<option key={u.id} value={u.id} className="bg-panel text-main">
👤 {u.displayName || u.email} ({u.email})
</option>
))}
</select>
<select
value={sharePermission}
onChange={(e) => setSharePermission(e.target.value as 'read' | 'write')}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full sm:w-40 cursor-pointer"
>
<option value="read" className="bg-panel text-main">Nur Lesen (read)</option>
<option value="write" className="bg-panel text-main">Schreiben (write)</option>
</select>
</div>
{/* Direct Email fallback if user not in list */}
{(!shareTargetEmailOrId || !collaborators.some((u) => u.id === shareTargetEmailOrId)) && (
<input
type="email"
placeholder="Oder E-Mail-Adresse direkt eingeben…"
value={shareTargetEmailOrId}
onChange={(e) => setShareTargetEmailOrId(e.target.value)}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full"
<div className="flex flex-col sm:flex-row gap-2">
<div className="flex-1">
<UserAutocomplete
users={collaborators}
selectedUser={selectedShareUser}
onSelectUser={setSelectedShareUser}
excludeUserIds={[
currentUser?.id || '',
...docShares.map((s) => s.targetId || s.userId).filter(Boolean),
]}
disabled={sharingLoading}
placeholder="Benutzer suchen (Name oder @Benutzername)…"
/>
)}
</div>
<select
value={sharePermission}
onChange={(e) => setSharePermission(e.target.value as 'read' | 'write')}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full sm:w-40 cursor-pointer shrink-0"
>
<option value="read" className="bg-panel text-main">Nur Lesen (read)</option>
<option value="write" className="bg-panel text-main">Schreiben (write)</option>
</select>
</div>
)}
@@ -4500,7 +4486,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
type="submit"
disabled={
sharingLoading ||
(addShareType === 'team' ? !selectedShareTeamId : !shareTargetEmailOrId.trim())
(addShareType === 'team' ? !selectedShareTeamId : !selectedShareUser)
}
className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5"
>
@@ -4525,43 +4511,68 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</div>
) : (
<div className="space-y-1.5 max-h-56 overflow-y-auto">
{docShares.map((share) => (
<div
key={share.id || share.userId}
className="p-2.5 bg-panel-elevated border border-border rounded-card flex items-center justify-between gap-3 text-xs"
>
<div className="min-w-0">
<div className="font-semibold text-main truncate">
{share.displayName || share.userEmail || share.userId}
{docShares.map((share) => {
const isTeam = share.targetType === 'team' || Boolean(share.teamId) || String(share.targetId || '').startsWith('team_');
const targetKey = share.targetId || share.userId || share.id;
return (
<div
key={share.id || targetKey}
className="p-2.5 bg-panel-elevated border border-border rounded-card flex items-center justify-between gap-3 text-xs"
>
<div className="flex items-center gap-2 min-w-0">
{isTeam ? (
<div className="w-6 h-6 rounded-btn bg-accent/20 text-accent flex items-center justify-center shrink-0">
<Users className="w-3.5 h-3.5" />
</div>
) : (
<div className="w-6 h-6 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
{(share.displayName || share.userEmail || share.targetName || '?')[0].toUpperCase()}
</div>
)}
<div className="min-w-0">
<div className="font-semibold text-main truncate flex items-center gap-1.5">
<span>{share.displayName || share.targetName || share.userEmail || share.userId}</span>
{isTeam && (
<span className="badge-neutral text-[9px] uppercase px-1 py-0.2">Gruppe</span>
)}
</div>
{!isTeam && share.userEmail && share.displayName && (
<div className="text-[11px] text-muted truncate">{share.userEmail}</div>
)}
</div>
</div>
{share.userEmail && share.displayName && (
<div className="text-[11px] text-muted truncate">{share.userEmail}</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<select
value={share.permission}
onChange={(e) => handleUpdateSharePerm(share.userId, e.target.value as 'read' | 'write')}
className="input-field h-7 px-2 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-[11px] cursor-pointer"
disabled={sharingLoading}
>
<option value="read" className="bg-panel text-main">Nur Lesen</option>
<option value="write" className="bg-panel text-main">Schreiben</option>
</select>
<div className="flex items-center gap-2 flex-shrink-0">
{!isTeam && (
<select
value={share.permission}
onChange={(e) => handleUpdateSharePerm(share.userId, e.target.value as 'read' | 'write')}
className="input-field h-7 px-2 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-[11px] cursor-pointer"
disabled={sharingLoading}
>
<option value="read" className="bg-panel text-main">Nur Lesen</option>
<option value="write" className="bg-panel text-main">Schreiben</option>
</select>
)}
{isTeam && (
<span className="badge-neutral text-[10px]">
{share.permission === 'write' ? 'Schreiben' : 'Nur Lesen'}
</span>
)}
<button
type="button"
onClick={() => handleRemoveShare(share.userId)}
className="btn-icon btn-icon-danger h-7 w-7"
title="Freigabe entziehen"
disabled={sharingLoading}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleRemoveShare(targetKey)}
className="btn-icon btn-icon-danger h-7 w-7"
title="Freigabe entziehen"
disabled={sharingLoading}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
))}
);
})}
</div>
)}
</div>
+8 -2
View File
@@ -589,9 +589,15 @@ export const api = {
return ensureArray(res).map((s) => ({
...s,
members: ensureArray(s.members),
shares: ensureArray(s.shares),
}));
},
async getSpaceShares(spaceId: string): Promise<SpaceShare[]> {
const res = await request<SpaceShare[]>(`/api/v1/spaces/${spaceId}/shares`);
return ensureArray(res);
},
async createSpace(name: string, icon: string, description: string): Promise<Space> {
return request<Space>('/api/v1/spaces', {
method: 'POST',
@@ -606,8 +612,8 @@ export const api = {
});
},
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise<SpaceMember> {
return request<SpaceMember>(`/api/v1/spaces/${spaceId}/members`, {
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise<any> {
return request<any>(`/api/v1/spaces/${spaceId}/members`, {
method: 'POST',
body: JSON.stringify(target),
});
+16 -1
View File
@@ -162,7 +162,10 @@ export interface DocumentBlock {
export interface DocShare {
id: string;
docId: string;
userId: string;
targetType?: 'user' | 'team';
targetId?: string;
targetName?: string;
userId?: string;
userEmail?: string;
displayName?: string;
permission: 'read' | 'write';
@@ -238,6 +241,17 @@ export interface SpaceMember {
joinedAt: string;
}
export interface SpaceShare {
id: string;
spaceId: string;
targetType: 'team' | 'user';
targetId: string;
targetName?: string;
role: 'editor' | 'viewer';
createdBy?: string;
createdAt: string;
}
export interface Space {
id: string;
name: string;
@@ -247,6 +261,7 @@ export interface Space {
isPersonal: boolean;
ownerId: string;
members?: SpaceMember[];
shares?: SpaceShare[];
listsCount: number;
createdAt: string;
}