feat: unify sharing modal and clean up team share duplication in lists
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 57s

This commit is contained in:
sandrodach committed 2026-10-25 17:41:18 +01:00
1 parent d45b3cd0b3
commit 177b69234c
6 files changed
+558 -767

No files matched your search

+75 -248
View File
@@ -16,6 +16,7 @@ import {
import { ListItem, SharedList, Space, Team, User, WSMessage } from '../../lib/types';
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
import { UserAutocomplete } from '../UserAutocomplete';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
interface SharedListsViewProps {
onRefreshGlobal: () => void;
@@ -738,255 +739,81 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div>
) : null}
{/* Space & Shopping List Sharing Modal */}
{/* Unified Space & Shopping List Sharing Modal */}
{showShareModal && activeSpace && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs">
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4">
{/* Modal Header */}
<div className="flex items-center justify-between pb-2 border-b border-border">
<div className="flex items-center gap-2">
<Share2 className="w-5 h-5 text-accent" />
<h3 className="text-base font-bold text-main">
Einkaufsliste teilen: {activeSpace.name}
</h3>
</div>
<button
type="button"
onClick={() => {
setShowShareModal(false);
setShareError(null);
}}
className="btn-icon"
title="Schließen"
>
<X className="w-4 h-4" />
</button>
</div>
{shareError && (
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
{shareError}
</div>
)}
{/* Sharing Form (Team / Gruppe vs. Einzelner Benutzer) */}
<form onSubmit={handleAddShare} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => {
setAddShareType('team');
setInviteTarget('');
}}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
addShareType === 'team'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Users className="w-3 h-3 text-accent" />
<span>Team / Gruppe</span>
</button>
<button
type="button"
onClick={() => {
setAddShareType('user');
setSelectedTeamId('');
}}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
addShareType === 'user'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<UserIcon className="w-3 h-3 text-accent" />
<span>Einzelner Benutzer</span>
</button>
</div>
</div>
{addShareType === 'team' ? (
<div className="flex flex-col sm:flex-row gap-2">
<select
value={selectedTeamId}
onChange={(e) => setSelectedTeamId(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">-- Team / Gruppe auswählen --</option>
{teams.map((t) => (
<option key={t.id} value={t.id} className="bg-panel text-main">
👥 {t.name} ({t.members?.length || 1} Mitglieder)
</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>
) : (
<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>
)}
<div className="flex justify-end pt-1">
<button
type="submit"
disabled={
invitingLoading ||
(addShareType === 'team' ? !selectedTeamId : !selectedShareUser)
}
className="btn-primary text-xs h-8 px-4 flex items-center gap-1.5"
>
<Plus className="w-3.5 h-3.5" />
<span>
{invitingLoading
? 'Wird geteilt…'
: addShareType === 'team'
? 'Team freigeben'
: 'Benutzer einladen'}
</span>
</button>
</div>
</form>
{/* 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.shares || []).filter((s) => s.targetType === 'team').length) +
(activeSpace.members?.length || 1)}
)
</div>
<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;
const canRemove =
(activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin') &&
!isOwner;
return (
<div
key={m.userId}
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-full bg-surface-2 flex items-center justify-center text-xs font-bold text-accent shrink-0">
{(m.displayName || m.email || '?')[0].toUpperCase()}
</div>
<div className="truncate">
<div className="font-medium text-main truncate">
{m.displayName || m.email} {isMe && <span className="text-muted">(Du)</span>}
</div>
<div className="text-[11px] text-muted truncate">{m.email}</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span
className={`text-[10px] px-2 py-0.5 rounded font-semibold ${
isOwner
? 'bg-accent/15 text-accent'
: 'bg-surface-2 text-muted'
}`}
>
{isOwner ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Leser'}
</span>
{canRemove && (
<button
type="button"
onClick={() => handleRemoveMember(m.userId)}
disabled={invitingLoading}
className="btn-icon text-muted hover:text-status-danger p-1"
title="Freigabe entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
<UnifiedShareModal
isOpen={showShareModal}
onClose={() => setShowShareModal(false)}
title={`Einkaufsliste teilen: ${activeSpace.name}`}
resourceName={activeSpace.name}
resourceIcon={activeSpace.icon || '🛒'}
isOwner={activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin'}
currentUserId={currentUser?.id}
shares={[
...(activeSpace.shares || [])
.filter((s) => s.targetType === 'team')
.map((s) => ({
id: s.id,
targetType: 'team' as const,
targetId: s.targetId,
targetName: s.targetName || 'Gruppe',
permission: s.role === 'viewer' ? ('viewer' as const) : ('editor' as const),
role: s.role,
createdAt: s.createdAt,
})),
...(activeSpace.members || [])
.filter((m) => m.userId !== activeSpace.ownerId)
.map((m) => ({
id: m.userId,
targetType: 'user' as const,
targetId: m.userId,
targetName: m.displayName || m.email,
targetEmail: m.email,
permission: m.role === 'viewer' ? ('viewer' as const) : ('editor' as const),
role: m.role,
createdAt: m.joinedAt,
})),
]}
teams={teams}
collaborators={collaborators}
allowRoleSelection={true}
onAddShare={async (targetType, targetId, permission) => {
if (targetType === 'team') {
await api.addSpaceMember(activeSpace.id, {
teamId: targetId,
role: permission,
});
} else {
await api.addSpaceMember(activeSpace.id, {
userId: targetId,
role: permission,
});
}
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();
}}
onRemoveShare={async (shareId) => {
await api.removeSpaceMember(activeSpace.id, shareId);
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();
}}
/>
)}
</div>
);