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

+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;