feat(lists): add direct user invitation to shared lists and streamline live-sync indicator
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 56s

This commit is contained in:
sandrodach committed 2026-10-25 11:05:03 +01:00
1 parent 6e09888a44
commit 7bc5fbe001
7 files changed
+580 -29

No files matched your search

+2 -2
View File
@@ -217,7 +217,7 @@ export const App: React.FC = () => {
if (!msg) return;
if (
typeof msg.event === 'string' &&
(msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_'))
(msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_') || msg.event.startsWith('SPACE_'))
) {
api.getMe().then((res) => {
setStats(res.stats);
@@ -593,7 +593,7 @@ export const App: React.FC = () => {
)}
{activeTab === 'lists' && (
<SharedListsView onRefreshGlobal={refreshAll} />
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
)}
{activeTab === 'user_settings' && (
+293 -21
View File
@@ -10,17 +10,19 @@ import {
Radio,
Sparkles,
UserPlus,
X,
} from 'lucide-react';
import { ListItem, SharedList, Space, WSMessage } from '../../lib/types';
import { ListItem, SharedList, Space, User, WSMessage } from '../../lib/types';
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
interface SharedListsViewProps {
onRefreshGlobal: () => void;
currentUser?: User | null;
}
const CATEGORIES = ['Obst & Gemüse', 'Kühlregal', 'Vorrat & Konserven', 'Backwaren', 'Getränke', 'Drogerie', 'Sonstiges'];
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal }) => {
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => {
const [spaces, setSpaces] = useState<Space[]>([]);
const [activeSpace, setActiveSpace] = useState<Space | null>(null);
const [lists, setLists] = useState<SharedList[]>([]);
@@ -31,7 +33,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [newItemCategory, setNewItemCategory] = useState('');
const [wsConnected, setWsConnected] = useState(false);
const [onlineUsers, setOnlineUsers] = useState(1);
const [copiedInvite, setCopiedInvite] = useState(false);
const [showNewListModal, setShowNewListModal] = useState(false);
@@ -43,13 +44,25 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [showNewSpaceModal, setShowNewSpaceModal] = useState(false);
const [newSpaceName, setNewSpaceName] = useState('');
const [showMembersModal, setShowMembersModal] = useState(false);
const [collaborators, setCollaborators] = useState<User[]>([]);
const [inviteTarget, setInviteTarget] = useState('');
const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor');
const [invitingLoading, setInvitingLoading] = useState(false);
const [inviteError, setInviteError] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const loadSpaces = async () => {
const sp = await api.getSpaces();
setSpaces(sp);
if (sp.length > 0 && !activeSpace) {
setActiveSpace(sp[0]);
if (sp.length > 0) {
if (!activeSpace) {
setActiveSpace(sp[0]);
} else {
const found = sp.find((s) => s.id === activeSpace.id);
if (found) setActiveSpace(found);
}
}
};
@@ -90,9 +103,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
ws.onmessage = (evt) => {
try {
const msg: WSMessage = JSON.parse(evt.data);
if (msg.event === 'USER_PRESENCE' && msg.payload?.onlineCount) {
setOnlineUsers(msg.payload.onlineCount);
} else if (msg.event === 'ITEM_CREATED' && msg.list_id) {
if (msg.event === 'ITEM_CREATED' && msg.list_id) {
const newItem: ListItem = msg.payload;
setLists((prev) =>
prev.map((l) => {
@@ -125,6 +136,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
setLists((prev) => prev.map((l) => (l.id === msg.list_id ? updatedList : l)));
} else if (msg.event === 'LIST_CREATED' || msg.event === 'LIST_DELETED') {
loadListsForSpace(activeSpace.id);
} else if (msg.event === 'MEMBER_ADDED' || msg.event === 'MEMBER_REMOVED') {
loadSpaces();
}
} catch {
// ignore malformed frames
@@ -139,6 +152,23 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
};
}, [activeSpace?.id]);
useEffect(() => {
const handleGlobalWs = (e: any) => {
const msg = e.detail;
if (!msg) return;
if (
msg.event === 'SPACE_JOINED' ||
msg.event === 'SPACE_LEFT' ||
msg.event === 'MEMBER_ADDED' ||
msg.event === 'MEMBER_REMOVED'
) {
loadSpaces();
}
};
window.addEventListener('mindsift:ws', handleGlobalWs);
return () => window.removeEventListener('mindsift:ws', handleGlobalWs);
}, []);
const activeList = lists.find((l) => l.id === activeListId) || lists[0] || null;
const handleAddItem = async (e: React.FormEvent) => {
@@ -245,6 +275,80 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
setTimeout(() => setCopiedInvite(false), 2000);
};
const handleAddMember = async (e: React.FormEvent) => {
e.preventDefault();
if (!activeSpace) return;
const target = inviteTarget.trim();
if (!target) return;
setInvitingLoading(true);
setInviteError(null);
try {
const isId = collaborators.some((u) => u.id === target);
const newMember = await api.addSpaceMember(activeSpace.id, {
userId: isId ? target : undefined,
email: !isId ? target : undefined,
role: inviteRole,
});
setActiveSpace((prev) =>
prev
? {
...prev,
members: [...(prev.members || []).filter((m) => m.userId !== newMember.userId), newMember],
}
: prev
);
setSpaces((prev) =>
prev.map((s) =>
s.id === activeSpace.id
? {
...s,
members: [...(s.members || []).filter((m) => m.userId !== newMember.userId), newMember],
}
: s
)
);
setInviteTarget('');
onRefreshGlobal();
} catch (err: any) {
setInviteError(err.message || 'Mitglied konnte nicht eingeladen werden');
} finally {
setInvitingLoading(false);
}
};
const handleRemoveMember = async (targetUserId: string) => {
if (!activeSpace) return;
if (!window.confirm('Dieses Mitglied wirklich aus der Einkaufsliste / dem Bereich entfernen?')) return;
setInvitingLoading(true);
setInviteError(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
)
);
onRefreshGlobal();
} catch (err: any) {
setInviteError(err.message || 'Mitglied konnte nicht entfernt werden');
} finally {
setInvitingLoading(false);
}
};
const openItems = activeList?.items.filter((i) => !i.isCompleted) || [];
const completedItems = activeList?.items.filter((i) => i.isCompleted) || [];
@@ -283,31 +387,34 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</button>
</div>
{/* Realtime Status & Invite Code Pill */}
{/* Realtime Status & Invite User Button */}
{activeSpace && (
<div className="flex items-center gap-2">
<div
className={`h-8 px-3 text-xs flex items-center gap-1.5 rounded-btn ${
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-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
<span>{wsConnected ? `Echtzeit-Sync (${onlineUsers} aktiv)` : 'Offline-Puffer'}</span>
<Radio className={`w-4 h-4 ${wsConnected ? 'animate-pulse text-status-ok' : 'text-muted'}`} />
</div>
<button
onClick={copyInviteCode}
title="Einladungscode für Partner oder Familie kopieren"
className="btn-action h-8"
onClick={() => {
setShowMembersModal(true);
api.getUsersDirectory().then(setCollaborators).catch(() => {});
}}
title="User zu dieser Einkaufsliste einladen & verwalten"
className="btn-action h-8 flex items-center gap-1.5"
>
<Users className="w-3.5 h-3.5 text-accent" />
<span>Code: {activeSpace.inviteCode}</span>
{copiedInvite ? (
<Check className="w-3.5 h-3.5 text-status-ok" />
) : (
<Copy className="w-3 h-3 text-muted" />
<UserPlus className="w-3.5 h-3.5 text-accent" />
<span>User einladen</span>
{activeSpace.members && activeSpace.members.length > 1 && (
<span className="ml-1 px-1.5 py-0.2 bg-surface-2 text-main text-[10px] font-semibold rounded-full border border-border">
{activeSpace.members.length}
</span>
)}
</button>
</div>
@@ -571,6 +678,171 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
)}
</div>
) : null}
{/* Space & Shopping List Members Modal */}
{showMembersModal && 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">
<Users 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={() => {
setShowMembersModal(false);
setInviteError(null);
}}
className="btn-icon"
title="Schließen"
>
<X className="w-4 h-4" />
</button>
</div>
{inviteError && (
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
{inviteError}
</div>
)}
{/* Invite User Form */}
<form onSubmit={handleAddMember} className="space-y-2.5 p-3 bg-canvas border border-border rounded-card">
<div className="text-xs font-semibold text-main">User direkt einladen</div>
<div className="flex flex-col sm:flex-row gap-2">
<select
value={inviteTarget}
onChange={(e) => setInviteTarget(e.target.value)}
className="input-base text-xs flex-1"
>
<option value="">User auswählen oder Name/E-Mail 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}>
{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-base text-xs w-full sm:w-36"
>
<option value="editor">Bearbeiten</option>
<option value="viewer">Nur Lesen</option>
</select>
</div>
{/* Direct Username / Email input if user not selected from list */}
{(!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-base text-xs w-full"
/>
)}
<div className="flex justify-end pt-1">
<button
type="submit"
disabled={invitingLoading || !inviteTarget.trim()}
className="btn-primary text-xs h-8 px-4 flex items-center gap-1.5"
>
<UserPlus className="w-3.5 h-3.5" />
<span>{invitingLoading ? 'Wird hinzugefügt…' : 'Einladen'}</span>
</button>
</div>
</form>
{/* Current Space Members List */}
<div className="space-y-2">
<div className="text-xs font-semibold text-muted uppercase tracking-wider">
Mitglieder in dieser Liste ({activeSpace.members?.length || 1})
</div>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{(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="Mitglied entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Invite Code Alternative */}
<div className="pt-2 border-t border-border flex items-center justify-between text-xs text-muted">
<span>Alternativ per Code beitreten:</span>
<button
type="button"
onClick={copyInviteCode}
className="btn-action h-7 text-xs flex items-center gap-1.5"
title="Code kopieren"
>
<span>Code: {activeSpace.inviteCode}</span>
{copiedInvite ? (
<Check className="w-3 h-3 text-status-ok" />
) : (
<Copy className="w-3 h-3 text-muted" />
)}
</button>
</div>
</div>
</div>
)}
</div>
);
};
+14
View File
@@ -11,6 +11,7 @@ import {
PriorityLevel,
SharedList,
Space,
SpaceMember,
SystemSettings,
Team,
TaskList,
@@ -517,6 +518,19 @@ export const api = {
});
},
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; role?: string }): Promise<SpaceMember> {
return request<SpaceMember>(`/api/v1/spaces/${spaceId}/members`, {
method: 'POST',
body: JSON.stringify(target),
});
},
async removeSpaceMember(spaceId: string, userId: string): Promise<void> {
await request(`/api/v1/spaces/${spaceId}/members/${userId}`, {
method: 'DELETE',
});
},
async getSpaceLists(spaceId: string): Promise<SharedList[]> {
const res = await request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`);
return ensureArray(res).map((l) => ({