refactor: remove legacy spaces and migrate lists to standalone resources with direct sharing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s

This commit is contained in:
sandrodach committed 2026-10-25 18:17:17 +01:00
1 parent 177b69234c
commit 0d6c603e9c
9 files changed
+616 -950

No files matched your search

+99 -220
View File
@@ -9,13 +9,10 @@ import {
Radio,
Sparkles,
Share,
Share2,
User as UserIcon,
X,
} from 'lucide-react';
import { ListItem, SharedList, Space, Team, User, WSMessage } from '../../lib/types';
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
import { UserAutocomplete } from '../UserAutocomplete';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
interface SharedListsViewProps {
@@ -69,8 +66,6 @@ export function getItemDisplayContent(item: ListItem): string {
}
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[]>([]);
const [activeListId, setActiveListId] = useState<string>('');
@@ -85,61 +80,47 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [showShareModal, setShowShareModal] = useState(false);
const [collaborators, setCollaborators] = useState<User[]>([]);
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);
const [shareError, setShareError] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const loadSpaces = async () => {
let sp = await api.getSpaces();
if (sp.length === 0) {
try {
const created = await api.createSpace('Meine Listen', '🛒', 'Persönliche und geteilte Einkaufslisten');
sp = [created];
} catch {
// fallback
const loadLists = async () => {
try {
let all = await api.getAllLists();
if (all.length === 0) {
try {
const created = await api.createList('Wocheneinkauf', '🛒', 'shopping');
all = [created];
} catch {
// fallback
}
}
}
setSpaces(sp);
if (sp.length > 0) {
if (!activeSpace) {
setActiveSpace(sp[0]);
} else {
const found = sp.find((s) => s.id === activeSpace.id);
if (found) setActiveSpace(found);
setLists(all);
if (all.length > 0 && (!activeListId || !all.some((l) => l.id === activeListId))) {
setActiveListId(all[0].id);
}
}
};
const loadListsForSpace = async (spaceId: string) => {
const lst = await api.getSpaceLists(spaceId);
setLists(lst);
if (lst.length > 0 && (!activeListId || !lst.some((l) => l.id === activeListId))) {
setActiveListId(lst[0].id);
} catch {
// ignore
}
};
useEffect(() => {
loadSpaces();
loadLists();
}, []);
const activeList = useMemo(() => {
return lists.find((l) => l.id === activeListId) || lists[0] || null;
}, [lists, activeListId]);
useEffect(() => {
if (!activeSpace) return;
loadListsForSpace(activeSpace.id);
if (!activeList?.id) return;
// Connect Realtime WebSocket for this Space
if (wsRef.current) {
wsRef.current.close();
}
let ws: WebSocket | null = null;
try {
ws = new WebSocket(buildWebSocketURL(activeSpace.id));
ws = new WebSocket(buildWebSocketURL(activeList.id));
wsRef.current = ws;
ws.onopen = () => {
@@ -153,41 +134,49 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
ws.onmessage = (evt) => {
try {
const msg: WSMessage = JSON.parse(evt.data);
if (msg.event === 'ITEM_CREATED' && msg.list_id) {
const msgListId = msg.listId || (msg as any).list_id;
if (msg.event === 'ITEM_CREATED' && msgListId) {
const newItem: ListItem = msg.payload;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msg.list_id) return l;
if (l.id !== msgListId) return l;
if (l.items.some((i) => i.id === newItem.id)) return l;
return { ...l, items: [newItem, ...l.items] };
})
);
} else if (msg.event === 'ITEM_TOGGLED' && msg.list_id) {
} else if (msg.event === 'ITEM_TOGGLED' && msgListId) {
const updatedItem: ListItem = msg.payload;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msg.list_id) return l;
if (l.id !== msgListId) return l;
return {
...l,
items: l.items.map((i) => (i.id === updatedItem.id ? updatedItem : i)),
};
})
);
} else if (msg.event === 'ITEM_DELETED' && msg.list_id) {
} else if (msg.event === 'ITEM_DELETED' && msgListId) {
const deletedId = msg.payload?.itemId;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msg.list_id) return l;
if (l.id !== msgListId) return l;
return { ...l, items: l.items.filter((i) => i.id !== deletedId) };
})
);
} else if (msg.event === 'LIST_CLEARED' && msg.list_id) {
const updatedList: SharedList = msg.payload;
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();
} else if (msg.event === 'LIST_CLEARED' && msgListId) {
setLists((prev) =>
prev.map((l) => {
if (l.id !== msgListId) return l;
return { ...l, items: l.items.filter((i) => !i.isCompleted) };
})
);
} else if (
msg.event === 'LIST_CREATED' ||
msg.event === 'LIST_DELETED' ||
msg.event === 'LIST_SHARED' ||
msg.event === 'LIST_UNSHARED'
) {
loadLists();
}
} catch {
// ignore malformed frames
@@ -200,27 +189,25 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
return () => {
if (ws) ws.close();
};
}, [activeSpace?.id]);
}, [activeList?.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'
msg.event === 'LIST_SHARED' ||
msg.event === 'LIST_UNSHARED' ||
msg.event === 'LIST_CREATED' ||
msg.event === 'LIST_DELETED'
) {
loadSpaces();
loadLists();
}
};
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) => {
e.preventDefault();
if (!activeList || !newItemText.trim()) return;
@@ -275,16 +262,20 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
if (!activeList) return;
triggerHaptic(20);
await api.clearCompletedItems(activeList.id);
await loadListsForSpace(activeSpace!.id);
setLists((prev) =>
prev.map((l) =>
l.id === activeList.id ? { ...l, items: l.items.filter((i) => !i.isCompleted) } : l
)
);
};
const handleCreateList = async (e: React.FormEvent) => {
e.preventDefault();
if (!activeSpace || !newListTitle.trim()) return;
const created = await api.createList(activeSpace.id, newListTitle.trim(), newListIcon, 'shopping');
if (!newListTitle.trim()) return;
const created = await api.createList(newListTitle.trim(), newListIcon, 'shopping');
setNewListTitle('');
setShowNewListModal(false);
await loadListsForSpace(activeSpace.id);
await loadLists();
setActiveListId(created.id);
onRefreshGlobal();
};
@@ -293,9 +284,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
if (!window.confirm('Diese Liste wirklich löschen?')) return;
try {
await api.deleteList(listId);
if (activeSpace) {
await loadListsForSpace(activeSpace.id);
}
await loadLists();
onRefreshGlobal();
} catch (err: any) {
alert(err.message || 'Liste konnte nicht gelöscht werden');
@@ -303,96 +292,22 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
};
const openShareModal = async () => {
if (!activeSpace) return;
if (!activeList) 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([]),
api.getListShares(activeList.id).catch(() => []),
]);
setCollaborators(usersList);
setTeams(teamsList);
setActiveSpace((prev) => (prev ? { ...prev, shares: sharesList } : prev));
setLists((prev) => prev.map((l) => (l.id === activeList.id ? { ...l, shares: sharesList } : l)));
} catch {
// ignore
}
};
const handleAddShare = async (e: React.FormEvent) => {
e.preventDefault();
if (!activeSpace) return;
setInvitingLoading(true);
setShareError(null);
try {
if (addShareType === 'team') {
if (!selectedTeamId) {
setShareError('Bitte ein Team oder eine Gruppe auswählen');
setInvitingLoading(false);
return;
}
await api.addSpaceMember(activeSpace.id, {
teamId: selectedTeamId,
role: inviteRole,
});
setSelectedTeamId('');
} else {
if (!selectedShareUser) {
setShareError('Bitte einen bestehenden Benutzer auswählen');
setInvitingLoading(false);
return;
}
await api.addSpaceMember(activeSpace.id, {
userId: selectedShareUser.id,
role: inviteRole,
});
setSelectedShareUser(null);
}
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 || 'Freigabe konnte nicht hinzugefügt werden');
} finally {
setInvitingLoading(false);
}
};
const handleRemoveMember = async (targetId: string) => {
if (!activeSpace) return;
if (!window.confirm('Freigabe wirklich aufheben?')) return;
setInvitingLoading(true);
setShareError(null);
try {
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 || 'Freigabe konnte nicht entfernt werden');
} finally {
setInvitingLoading(false);
}
};
const openItems = activeList?.items.filter((i) => !i.isCompleted) || [];
const completedItems = activeList?.items.filter((i) => i.isCompleted) || [];
@@ -486,16 +401,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{/* 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);
const ownerMember = activeSpace.members?.find((m) => m.role === 'owner' || m.userId === activeSpace.ownerId);
const ownerDisplayName = activeSpace.ownerName || ownerMember?.displayName || ownerMember?.email || 'Eigentümer';
{activeList && (() => {
const isOwner = activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin');
const sharesCount = activeList.shares?.length || 0;
const isShared = sharesCount > 0;
const ownerDisplayName = activeList.ownerName || 'Eigentümer';
return (
<div className="flex items-center gap-1.5">
@@ -508,7 +418,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
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>
<span>Geteilt ({sharesCount})</span>
</button>
) : (
<button
@@ -527,7 +437,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
type="button"
onClick={openShareModal}
className="btn-icon"
title="Einkaufsliste teilen und Berechtigungen"
title="Liste teilen und Berechtigungen"
>
<Share className="w-3.5 h-3.5" />
</button>
@@ -739,78 +649,47 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div>
) : null}
{/* Unified Space & Shopping List Sharing Modal */}
{showShareModal && activeSpace && (
{/* Unified Shopping List Sharing Modal */}
{showShareModal && activeList && (
<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'}
title={`Liste teilen: ${activeList.title}`}
resourceName={activeList.title}
resourceIcon={activeList.icon || '🛒'}
isOwner={activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || 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,
})),
]}
shares={(activeList.shares || []).map((s) => ({
id: s.id,
targetType: s.targetType,
targetId: s.targetId,
targetName: s.targetName || (s.targetType === 'team' ? 'Gruppe' : 'Benutzer'),
targetEmail: s.targetEmail,
permission: s.role === 'viewer' ? ('viewer' as const) : ('editor' as const),
role: s.role,
createdAt: s.createdAt,
}))}
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 });
}
await api.addListShare(activeList.id, {
targetType,
targetId,
role: permission,
});
const freshShares = await api.getListShares(activeList.id);
setLists((prev) =>
prev.map((l) => (l.id === activeList.id ? { ...l, shares: freshShares } : l))
);
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 });
}
await api.removeListShare(activeList.id, shareId);
const freshShares = await api.getListShares(activeList.id);
setLists((prev) =>
prev.map((l) => (l.id === activeList.id ? { ...l, shares: freshShares } : l))
);
onRefreshGlobal();
}}
/>