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
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
This commit is contained in:
1 parent
177b69234c
commit
0d6c603e9c
9 files changed
+616
-950
No files matched your search
@@ -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();
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user