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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 18s
This commit is contained in:
1 parent
6d07352e80
commit
354129b2d4
10 files changed
+1049
-393
No files matched your search
@@ -719,6 +719,7 @@ export const App: React.FC = () => {
|
||||
|
||||
{activeTab === 'tasks' && (
|
||||
<TasksView
|
||||
currentUser={user}
|
||||
onRefreshStats={refreshAll}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
refreshTrigger={refreshRevision}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user