First draft
MindSift Multi-Arch Docker Build / Lint, Build & Unit Tests (push) Successful in 2m7s
MindSift Multi-Arch Docker Build / Build & Push Docker Image (push) Failing after 29s

This commit is contained in:
sandrodach committed 2026-10-24 16:56:44 +02:00
1 parent aba52f2b3c
commit 84bc6884b0
37 files changed
+6697 -27

No files matched your search

@@ -0,0 +1,587 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Plus,
CheckCircle2,
Circle,
Trash2,
Users,
Copy,
Check,
Radio,
Sparkles,
UserPlus,
} from 'lucide-react';
import { ListItem, SharedList, Space, WSMessage } from '../../lib/types';
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
interface SharedListsViewProps {
onRefreshGlobal: () => void;
}
const CATEGORIES = ['Obst & Gemüse', 'Kühlregal', 'Vorrat & Konserven', 'Backwaren', 'Getränke', 'Drogerie', 'Sonstiges'];
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal }) => {
const [spaces, setSpaces] = useState<Space[]>([]);
const [activeSpace, setActiveSpace] = useState<Space | null>(null);
const [lists, setLists] = useState<SharedList[]>([]);
const [activeListId, setActiveListId] = useState<string>('');
const [newItemText, setNewItemText] = useState('');
const [newItemQty, setNewItemQty] = useState('');
const [newItemCategory, setNewItemCategory] = useState('');
const [wsConnected, setWsConnected] = useState(false);
const [onlineUsers, setOnlineUsers] = useState(1);
const [copiedInvite, setCopiedInvite] = useState(false);
const [showNewListModal, setShowNewListModal] = useState(false);
const [newListTitle, setNewListTitle] = useState('');
const [newListIcon, setNewListIcon] = useState('🛒');
const [showJoinModal, setShowJoinModal] = useState(false);
const [joinCode, setJoinCode] = useState('');
const [showNewSpaceModal, setShowNewSpaceModal] = useState(false);
const [newSpaceName, setNewSpaceName] = useState('');
const wsRef = useRef<WebSocket | null>(null);
const loadSpaces = async () => {
const sp = await api.getSpaces();
setSpaces(sp);
if (sp.length > 0 && !activeSpace) {
setActiveSpace(sp[0]);
}
};
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);
}
};
useEffect(() => {
loadSpaces();
}, []);
useEffect(() => {
if (!activeSpace) return;
loadListsForSpace(activeSpace.id);
// Connect Realtime WebSocket for this Space
if (wsRef.current) {
wsRef.current.close();
}
let ws: WebSocket | null = null;
try {
ws = new WebSocket(buildWebSocketURL(activeSpace.id));
wsRef.current = ws;
ws.onopen = () => {
setWsConnected(true);
};
ws.onclose = () => {
setWsConnected(false);
};
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) {
const newItem: ListItem = msg.payload;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msg.list_id) 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) {
const updatedItem: ListItem = msg.payload;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msg.list_id) return l;
return {
...l,
items: l.items.map((i) => (i.id === updatedItem.id ? updatedItem : i)),
};
})
);
} else if (msg.event === 'ITEM_DELETED' && msg.list_id) {
const deletedId = msg.payload?.itemId;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msg.list_id) 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);
}
} catch {
// ignore malformed frames
}
};
} catch {
setWsConnected(false);
}
return () => {
if (ws) ws.close();
};
}, [activeSpace?.id]);
const activeList = lists.find((l) => l.id === activeListId) || lists[0] || null;
const handleAddItem = async (e: React.FormEvent) => {
e.preventDefault();
if (!activeList || !newItemText.trim()) return;
triggerHaptic(15);
const text = newItemText.trim();
const qty = newItemQty.trim();
const cat = newItemCategory;
setNewItemText('');
setNewItemQty('');
const created = await api.createListItem(activeList.id, text, cat, qty);
setLists((prev) =>
prev.map((l) => {
if (l.id !== activeList.id) return l;
if (l.items.some((i) => i.id === created.id)) return l;
return { ...l, items: [created, ...l.items] };
})
);
};
const handleToggleItem = async (item: ListItem) => {
if (!activeList) return;
triggerHaptic(18);
const nextCompleted = !item.isCompleted;
// Optimistic UI update (BRAIN.md Section 3)
setLists((prev) =>
prev.map((l) => {
if (l.id !== activeList.id) return l;
return {
...l,
items: l.items.map((i) =>
i.id === item.id ? { ...i, isCompleted: nextCompleted } : i
),
};
})
);
try {
const updated = await api.toggleListItem(activeList.id, item.id, nextCompleted);
setLists((prev) =>
prev.map((l) => {
if (l.id !== activeList.id) return l;
return {
...l,
items: l.items.map((i) => (i.id === item.id ? updated : i)),
};
})
);
} catch {
// Rollback on error
loadListsForSpace(activeSpace!.id);
}
};
const handleDeleteItem = async (itemId: string) => {
if (!activeList) return;
triggerHaptic(15);
setLists((prev) =>
prev.map((l) =>
l.id === activeList.id ? { ...l, items: l.items.filter((i) => i.id !== itemId) } : l
)
);
await api.deleteListItem(activeList.id, itemId);
};
const handleClearCompleted = async () => {
if (!activeList) return;
triggerHaptic(20);
await api.clearCompletedItems(activeList.id);
await loadListsForSpace(activeSpace!.id);
};
const handleCreateList = async (e: React.FormEvent) => {
e.preventDefault();
if (!activeSpace || !newListTitle.trim()) return;
const created = await api.createList(activeSpace.id, newListTitle.trim(), newListIcon, 'shopping');
setNewListTitle('');
setShowNewListModal(false);
await loadListsForSpace(activeSpace.id);
setActiveListId(created.id);
onRefreshGlobal();
};
const handleCreateSpace = async (e: React.FormEvent) => {
e.preventDefault();
if (!newSpaceName.trim()) return;
const created = await api.createSpace(newSpaceName.trim(), '🏡', 'Geteilter Familien- & Team-Bereich');
setNewSpaceName('');
setShowNewSpaceModal(false);
const sp = await api.getSpaces();
setSpaces(sp);
setActiveSpace(created);
};
const handleJoinSpace = async (e: React.FormEvent) => {
e.preventDefault();
if (!joinCode.trim()) return;
try {
const joined = await api.joinSpace(joinCode.trim());
setJoinCode('');
setShowJoinModal(false);
const sp = await api.getSpaces();
setSpaces(sp);
setActiveSpace(joined);
} catch (err: any) {
alert(err.message || 'Einladungscode ungültig');
}
};
const copyInviteCode = () => {
if (!activeSpace) return;
navigator.clipboard.writeText(activeSpace.inviteCode);
setCopiedInvite(true);
setTimeout(() => setCopiedInvite(false), 2000);
};
const openItems = activeList?.items.filter((i) => !i.isCompleted) || [];
const completedItems = activeList?.items.filter((i) => i.isCompleted) || [];
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Top Space Bar & Realtime Indicator */}
<div className="p-4 sm:p-5 rounded-3xl bg-[#1e1f20] gemini-border flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
{spaces.map((sp) => (
<button
key={sp.id}
onClick={() => setActiveSpace(sp)}
className={`h-10 px-4 rounded-full text-xs sm:text-sm font-medium flex items-center gap-2 transition-all ${
activeSpace?.id === sp.id
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold shadow-sm'
: 'bg-[#131314] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border'
}`}
>
<span>{sp.icon}</span>
<span>{sp.name}</span>
</button>
))}
<button
onClick={() => setShowNewSpaceModal((prev) => !prev)}
className="h-10 px-3.5 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#8ab4f8] flex items-center gap-1"
>
<Plus className="w-3.5 h-3.5" /> Space
</button>
<button
onClick={() => setShowJoinModal((prev) => !prev)}
className="h-10 px-3.5 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#c58af9] flex items-center gap-1"
>
<UserPlus className="w-3.5 h-3.5" /> Code einlösen
</button>
</div>
{/* Realtime Status & Invite Code Pill */}
{activeSpace && (
<div className="flex items-center gap-2">
<div
className={`h-8 px-3 rounded-full text-xs flex items-center gap-1.5 ${
wsConnected
? 'bg-[#81c995]/15 text-[#81c995] border border-[#81c995]/30'
: 'bg-[#282a2c] text-[#9aa0a6]'
}`}
>
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
<span>{wsConnected ? `Echtzeit-Sync (${onlineUsers} aktiv)` : 'Offline-Puffer'}</span>
</div>
<button
onClick={copyInviteCode}
title="Einladungscode für Partner oder Familie kopieren"
className="h-8 px-3 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#e3e3e3] flex items-center gap-1.5"
>
<Users className="w-3.5 h-3.5 text-[#8ab4f8]" />
<span>Code: {activeSpace.inviteCode}</span>
{copiedInvite ? (
<Check className="w-3.5 h-3.5 text-[#81c995]" />
) : (
<Copy className="w-3 h-3 text-[#9aa0a6]" />
)}
</button>
</div>
)}
</div>
{/* Create or Join Space Modals */}
{showNewSpaceModal && (
<form
onSubmit={handleCreateSpace}
className="p-4 rounded-3xl bg-[#131314] gemini-border flex items-center gap-3"
>
<input
type="text"
value={newSpaceName}
onChange={(e) => setNewSpaceName(e.target.value)}
placeholder="Name des neuen Familien-/Team-Spaces (z. B. Sommerurlaub 2027)…"
className="flex-1 h-10 px-4 rounded-full bg-[#1e1f20] text-sm text-[#e3e3e3] gemini-border focus:outline-none"
autoFocus
/>
<button
type="submit"
className="h-10 px-5 rounded-full bg-[#8ab4f8] text-[#0b0f19] font-semibold text-xs sm:text-sm"
>
Space anlegen
</button>
</form>
)}
{showJoinModal && (
<form
onSubmit={handleJoinSpace}
className="p-4 rounded-3xl bg-[#131314] gemini-border flex items-center gap-3"
>
<input
type="text"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value)}
placeholder="Invite-Code des Partners eingeben…"
className="flex-1 h-10 px-4 rounded-full bg-[#1e1f20] text-sm text-[#e3e3e3] gemini-border focus:outline-none uppercase"
autoFocus
/>
<button
type="submit"
className="h-10 px-5 rounded-full bg-[#c58af9] text-[#0b0f19] font-semibold text-xs sm:text-sm"
>
Beitreten
</button>
</form>
)}
{/* List Tabs within Active Space */}
<div className="flex flex-wrap items-center gap-2">
{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-11 px-5 rounded-full text-sm font-medium flex items-center gap-2 transition-all ${
isSelected
? 'bg-[#1e1f20] text-[#8ab4f8] border border-[#8ab4f8]/50 shadow-gemini-glow'
: 'bg-[#131314] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border'
}`}
>
<span>{lst.icon}</span>
<span>{lst.title}</span>
<span className="px-2 py-0.5 rounded-full bg-[#0b0f19] text-xs text-[#8ab4f8]">
{openCount}
</span>
</button>
);
})}
<button
onClick={() => setShowNewListModal((prev) => !prev)}
className="h-11 px-4 rounded-full bg-[#131314] hover:bg-[#1e1f20] gemini-border text-xs sm:text-sm text-[#e3e3e3] flex items-center gap-1.5"
>
<Plus className="w-4 h-4 text-[#8ab4f8]" />
<span>Neue Liste</span>
</button>
</div>
{showNewListModal && (
<form
onSubmit={handleCreateList}
className="p-4 rounded-3xl bg-[#131314] gemini-border flex flex-wrap items-center gap-3"
>
<div className="flex items-center gap-1.5">
{['🛒', '🏔️', '🍕', '🎉', '🧰', '🎁'].map((ic) => (
<button
key={ic}
type="button"
onClick={() => setNewListIcon(ic)}
className={`w-9 h-9 rounded-full flex items-center justify-center ${
newListIcon === ic ? 'bg-[#8ab4f8]/25 border border-[#8ab4f8]' : 'bg-[#1e1f20]'
}`}
>
{ic}
</button>
))}
</div>
<input
type="text"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="Titel der Liste (z. B. Baumarkt, Drogerie, Camping)…"
className="flex-1 h-10 px-4 rounded-full bg-[#1e1f20] text-sm text-[#e3e3e3] gemini-border focus:outline-none"
autoFocus
/>
<button
type="submit"
className="h-10 px-5 rounded-full bg-[#8ab4f8] text-[#0b0f19] font-semibold text-xs sm:text-sm"
>
Liste erstellen
</button>
</form>
)}
{/* Active Shared List Card */}
{activeList ? (
<div className="p-6 sm:p-8 rounded-3xl bg-[#1e1f20] gemini-border shadow-card-depth">
{/* Quick-Add Pill Form */}
<form onSubmit={handleAddItem} className="space-y-3 mb-6">
<div className="flex flex-col sm:flex-row gap-2.5">
<input
type="text"
value={newItemText}
onChange={(e) => setNewItemText(e.target.value)}
placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch)…`}
className="flex-1 h-12 px-5 rounded-full bg-[#131314] text-sm sm:text-base text-[#e3e3e3] placeholder-[#9aa0a6] gemini-border focus:outline-none focus:border-[#8ab4f8]"
/>
<div className="flex gap-2">
<input
type="text"
value={newItemQty}
onChange={(e) => setNewItemQty(e.target.value)}
placeholder="Menge (z.B. 2x)"
className="w-32 h-12 px-4 rounded-full bg-[#131314] text-sm text-[#e3e3e3] placeholder-[#9aa0a6] gemini-border focus:outline-none focus:border-[#8ab4f8]"
/>
<button
type="submit"
disabled={!newItemText.trim()}
className="h-12 px-6 rounded-full bg-[#8ab4f8] disabled:bg-[#282a2c] text-[#0b0f19] disabled:text-[#5f6368] font-semibold text-sm flex items-center gap-1.5 transition-colors"
>
<Plus className="w-4 h-4" />
<span>Hinzufügen</span>
</button>
</div>
</div>
{/* Category Quick-Select Pills */}
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-[#9aa0a6] mr-1">Kategorie:</span>
{CATEGORIES.map((cat) => (
<button
key={cat}
type="button"
onClick={() => setNewItemCategory((prev) => (prev === cat ? '' : cat))}
className={`px-3 py-1 rounded-full text-xs transition-colors ${
newItemCategory === cat
? 'bg-[#8ab4f8]/20 text-[#8ab4f8] border border-[#8ab4f8]/40'
: 'bg-[#131314] text-[#9aa0a6] hover:text-[#e3e3e3]'
}`}
>
{cat}
</button>
))}
</div>
</form>
{/* Open Items */}
<div className="space-y-2">
{openItems.length === 0 ? (
<div className="py-8 text-center text-[#9aa0a6] text-sm flex flex-col items-center gap-2">
<Sparkles className="w-6 h-6 text-[#81c995]" />
<span>Alles erledigt! Keine offenen Punkte auf dieser Liste.</span>
</div>
) : (
openItems.map((item) => (
<div
key={item.id}
onClick={() => handleToggleItem(item)}
className="p-4 rounded-2xl bg-[#131314] hover:bg-[#242628] gemini-border flex items-center justify-between gap-3 cursor-pointer transition-all active:scale-[0.99]"
>
<div className="flex items-center gap-3.5 min-w-0">
<Circle className="w-5 h-5 text-[#8ab4f8] flex-shrink-0" />
<span className="text-base font-medium text-[#e3e3e3] truncate">
{item.content}
</span>
{item.quantity && (
<span className="px-2.5 py-0.5 rounded-full bg-[#1e1f20] text-[#8ab4f8] text-xs font-medium">
{item.quantity}
</span>
)}
{item.category && (
<span className="hidden sm:inline px-2.5 py-0.5 rounded-full bg-[#1e1f20] text-[#9aa0a6] text-xs">
{item.category}
</span>
)}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeleteItem(item.id);
}}
className="w-8 h-8 rounded-full hover:bg-[#f28b82]/20 text-[#5f6368] hover:text-[#f28b82] flex items-center justify-center"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
{/* Completed Items Section */}
{completedItems.length > 0 && (
<div className="mt-8 pt-6 border-t border-white/[0.06]">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-[#9aa0a6]">
Bereits im Einkaufswagen / Erledigt ({completedItems.length})
</span>
<button
onClick={handleClearCompleted}
className="px-3.5 py-1 rounded-full bg-[#131314] hover:bg-[#f28b82]/20 text-xs text-[#9aa0a6] hover:text-[#f28b82] transition-colors"
>
Erledigte aufräumen
</button>
</div>
<div className="space-y-2 opacity-70">
{completedItems.map((item) => (
<div
key={item.id}
onClick={() => handleToggleItem(item)}
className="p-3.5 rounded-2xl bg-[#131314]/60 gemini-border flex items-center justify-between gap-3 cursor-pointer"
>
<div className="flex items-center gap-3 min-w-0">
<CheckCircle2 className="w-5 h-5 text-[#81c995] flex-shrink-0" />
<span className="text-sm text-[#9aa0a6] line-through truncate">
{item.content}
</span>
{item.quantity && (
<span className="text-xs text-[#5f6368]">({item.quantity})</span>
)}
</div>
{item.completedByName && (
<span className="text-xs text-[#5f6368]">✓ {item.completedByName}</span>
)}
</div>
))}
</div>
</div>
)}
</div>
) : null}
</div>
);
};