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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user