MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
948 lines
36 KiB
TypeScript
948 lines
36 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Plus,
|
|
CheckCircle2,
|
|
Circle,
|
|
Trash2,
|
|
Users,
|
|
Check,
|
|
Radio,
|
|
Sparkles,
|
|
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';
|
|
|
|
interface SharedListsViewProps {
|
|
onRefreshGlobal: () => void;
|
|
currentUser?: User | null;
|
|
}
|
|
|
|
export function parseListItemInput(rawText: string, defaultQty?: string): {
|
|
content: string;
|
|
quantity: string;
|
|
category?: string;
|
|
} {
|
|
let text = rawText.trim();
|
|
const hashtagRegex = /#([a-zA-Z0-9äöüÄÖÜß_-]+)/g;
|
|
const matches = Array.from(text.matchAll(hashtagRegex));
|
|
|
|
let category: string | undefined = undefined;
|
|
if (matches.length > 0) {
|
|
category = matches[0][1];
|
|
text = text.replace(hashtagRegex, '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
if (!text && matches.length > 0) {
|
|
text = matches[0][1];
|
|
}
|
|
|
|
return {
|
|
content: text,
|
|
quantity: defaultQty?.trim() || '',
|
|
category,
|
|
};
|
|
}
|
|
|
|
export function getItemCategory(item: ListItem): string {
|
|
if (item.category && item.category.trim()) {
|
|
return item.category.trim().replace(/^#/, '');
|
|
}
|
|
const match = item.content.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/);
|
|
if (match) {
|
|
return match[1];
|
|
}
|
|
return '';
|
|
}
|
|
|
|
export function getItemDisplayContent(item: ListItem): string {
|
|
if (item.content.includes('#')) {
|
|
const cleaned = item.content.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, '').replace(/\s+/g, ' ').trim();
|
|
if (cleaned) return cleaned;
|
|
}
|
|
return item.content;
|
|
}
|
|
|
|
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>('');
|
|
|
|
const [newItemText, setNewItemText] = useState('');
|
|
const [newItemQty, setNewItemQty] = useState('');
|
|
|
|
const [wsConnected, setWsConnected] = useState(false);
|
|
const [showNewListModal, setShowNewListModal] = useState(false);
|
|
const [newListTitle, setNewListTitle] = useState('');
|
|
const [newListIcon, setNewListIcon] = useState('🛒');
|
|
|
|
const [showNewSpaceModal, setShowNewSpaceModal] = useState(false);
|
|
const [newSpaceName, setNewSpaceName] = useState('');
|
|
|
|
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 [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 () => {
|
|
const sp = await api.getSpaces();
|
|
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);
|
|
}
|
|
}
|
|
};
|
|
|
|
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 === '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);
|
|
} else if (msg.event === 'MEMBER_ADDED' || msg.event === 'MEMBER_REMOVED') {
|
|
loadSpaces();
|
|
}
|
|
} catch {
|
|
// ignore malformed frames
|
|
}
|
|
};
|
|
} catch {
|
|
setWsConnected(false);
|
|
}
|
|
|
|
return () => {
|
|
if (ws) ws.close();
|
|
};
|
|
}, [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) => {
|
|
e.preventDefault();
|
|
if (!activeList || !newItemText.trim()) return;
|
|
triggerHaptic(15);
|
|
|
|
const parsed = parseListItemInput(newItemText, newItemQty);
|
|
setNewItemText('');
|
|
setNewItemQty('');
|
|
|
|
try {
|
|
const item = await api.createListItem(activeList.id, parsed.content, parsed.category, parsed.quantity);
|
|
setLists((prev) =>
|
|
prev.map((l) => (l.id === activeList.id ? { ...l, items: [item, ...l.items] } : l))
|
|
);
|
|
onRefreshGlobal();
|
|
} catch (err: any) {
|
|
alert(err.message || 'Fehler beim Erstellen des Listeneintrags');
|
|
}
|
|
};
|
|
|
|
const handleToggleItem = async (item: ListItem) => {
|
|
if (!activeList) return;
|
|
triggerHaptic(10);
|
|
const nextCompleted = !item.isCompleted;
|
|
|
|
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)),
|
|
};
|
|
})
|
|
);
|
|
|
|
await api.toggleListItem(activeList.id, item.id, nextCompleted);
|
|
onRefreshGlobal();
|
|
};
|
|
|
|
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 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 {
|
|
const target = inviteTarget.trim();
|
|
if (!target) {
|
|
setShareError('Bitte einen Benutzer auswählen oder eingeben');
|
|
setInvitingLoading(false);
|
|
return;
|
|
}
|
|
const isId = collaborators.some((u) => u.id === target);
|
|
await api.addSpaceMember(activeSpace.id, {
|
|
userId: isId ? target : undefined,
|
|
email: !isId ? target : undefined,
|
|
role: inviteRole,
|
|
});
|
|
setInviteTarget('');
|
|
}
|
|
|
|
const sp = await api.getSpaces();
|
|
setSpaces(sp);
|
|
const updated = sp.find((s) => s.id === activeSpace.id);
|
|
if (updated) setActiveSpace(updated);
|
|
onRefreshGlobal();
|
|
} catch (err: any) {
|
|
setShareError(err.message || 'Freigabe konnte nicht hinzugefügt 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);
|
|
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
|
|
)
|
|
);
|
|
onRefreshGlobal();
|
|
} catch (err: any) {
|
|
setShareError(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) || [];
|
|
|
|
const groupedOpenItems = useMemo(() => {
|
|
const groupsMap = new Map<string, ListItem[]>();
|
|
const uncategorized: ListItem[] = [];
|
|
|
|
for (const item of openItems) {
|
|
const cat = getItemCategory(item);
|
|
if (!cat) {
|
|
uncategorized.push(item);
|
|
} else {
|
|
const list = groupsMap.get(cat) || [];
|
|
list.push(item);
|
|
groupsMap.set(cat, list);
|
|
}
|
|
}
|
|
|
|
const sortedCategories = Array.from(groupsMap.keys()).sort((a, b) =>
|
|
a.localeCompare(b, 'de', { sensitivity: 'base' })
|
|
);
|
|
|
|
const result: { category: string; label: string; items: ListItem[] }[] = [];
|
|
|
|
for (const cat of sortedCategories) {
|
|
result.push({
|
|
category: cat,
|
|
label: `#${cat}`,
|
|
items: groupsMap.get(cat)!,
|
|
});
|
|
}
|
|
|
|
if (uncategorized.length > 0) {
|
|
result.push({
|
|
category: '',
|
|
label: result.length > 0 ? 'Allgemein' : '',
|
|
items: uncategorized,
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}, [openItems]);
|
|
|
|
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.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 ${
|
|
activeSpace?.id === sp.id
|
|
? 'bg-accent text-on-accent border-accent'
|
|
: 'bg-canvas text-muted hover:text-main border-border'
|
|
}`}
|
|
>
|
|
<span>{sp.icon}</span>
|
|
<span>{sp.name}</span>
|
|
</button>
|
|
))}
|
|
|
|
<button
|
|
onClick={() => setShowNewSpaceModal((prev) => !prev)}
|
|
className="btn-action h-8"
|
|
>
|
|
<Plus className="w-3.5 h-3.5 text-accent" /> Space
|
|
</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 */}
|
|
{showNewSpaceModal && (
|
|
<form
|
|
onSubmit={handleCreateSpace}
|
|
className="p-2 panel-surface flex items-center gap-2"
|
|
>
|
|
<input
|
|
type="text"
|
|
value={newSpaceName}
|
|
onChange={(e) => setNewSpaceName(e.target.value)}
|
|
placeholder="Name des neuen Familien-/Team-Spaces (z. B. Urlaub 2026)…"
|
|
className="flex-1 h-9 px-3 input-field text-sm"
|
|
autoFocus
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="btn-primary h-9 px-4 text-xs sm:text-sm"
|
|
>
|
|
Space anlegen
|
|
</button>
|
|
</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>
|
|
);
|
|
})}
|
|
|
|
<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>
|
|
</div>
|
|
|
|
{showNewListModal && (
|
|
<form
|
|
onSubmit={handleCreateList}
|
|
className="p-2 panel-surface flex flex-wrap items-center gap-2"
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
{['🛒', '🏔️', '🍕', '🎉', '🧰', '🎁'].map((ic) => (
|
|
<button
|
|
key={ic}
|
|
type="button"
|
|
onClick={() => setNewListIcon(ic)}
|
|
className={`w-9 h-9 flex items-center justify-center rounded-btn transition-colors ${
|
|
newListIcon === ic
|
|
? 'bg-accent/20 border border-accent text-accent'
|
|
: 'bg-canvas hover:bg-panel-elevated border border-border'
|
|
}`}
|
|
>
|
|
{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-9 px-3 input-field text-sm"
|
|
autoFocus
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="btn-primary h-9 px-4 text-xs sm:text-sm"
|
|
>
|
|
Liste erstellen
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
{/* Active Shared List Card */}
|
|
{activeList ? (
|
|
<div className="p-3 panel-surface">
|
|
{/* Quick-Add Form */}
|
|
<form onSubmit={handleAddItem} className="space-y-2 mb-3">
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<input
|
|
type="text"
|
|
value={newItemText}
|
|
onChange={(e) => setNewItemText(e.target.value)}
|
|
placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch #Kühlregal oder #Kaffee)…`}
|
|
className="flex-1 h-9 px-3 input-field text-sm"
|
|
/>
|
|
<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-9 px-3 input-field text-sm"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={!newItemText.trim()}
|
|
className="btn-primary h-9 px-4 disabled:opacity-40 disabled:cursor-not-allowed text-xs sm:text-sm flex items-center gap-1.5"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
<span>Hinzufügen</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
{/* Open Items Grouped by Hashtags / Categories */}
|
|
<div className="border border-border divide-y divide-border rounded-card overflow-hidden">
|
|
{openItems.length === 0 ? (
|
|
<div className="py-6 text-center text-muted text-sm flex flex-col items-center gap-1.5">
|
|
<Sparkles className="w-4 h-4 text-accent" />
|
|
<span>Alles erledigt. Keine offenen Punkte auf dieser Liste.</span>
|
|
</div>
|
|
) : (
|
|
groupedOpenItems.map((group) => (
|
|
<div key={group.category || 'uncategorized'} className="divide-y divide-border">
|
|
{/* Category Group Header (only when categories are in use) */}
|
|
{group.label && (
|
|
<div className="px-3 py-1.5 bg-surface-2/70 text-xs font-semibold text-accent flex items-center justify-between">
|
|
<span className="flex items-center gap-1">
|
|
<span>{group.label}</span>
|
|
</span>
|
|
<span className="text-[10px] text-muted font-normal">
|
|
{group.items.length} {group.items.length === 1 ? 'Eintrag' : 'Einträge'}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{group.items.map((item) => {
|
|
const cat = getItemCategory(item);
|
|
const displayContent = getItemDisplayContent(item);
|
|
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
onClick={() => handleToggleItem(item)}
|
|
className="px-3 py-2 bg-canvas hover:bg-panel-elevated flex items-center justify-between gap-3 cursor-pointer transition-colors group"
|
|
>
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
<Circle className="w-4 h-4 text-faint group-hover:text-accent flex-shrink-0 transition-colors" />
|
|
<span className="text-sm font-normal text-main truncate">
|
|
{displayContent}
|
|
</span>
|
|
{item.quantity && (
|
|
<span className="badge-accent">
|
|
{item.quantity}
|
|
</span>
|
|
)}
|
|
{cat && (
|
|
<span className="px-2 py-0.5 text-xs font-medium text-accent bg-accent/10 border border-accent/25 rounded-full">
|
|
#{cat}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteItem(item.id);
|
|
}}
|
|
className="btn-icon btn-icon-danger w-6 h-6"
|
|
>
|
|
<Trash2 className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Completed Items Section */}
|
|
{completedItems.length > 0 && (
|
|
<div className="mt-3 pt-2.5 border-t border-border">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-xs font-medium uppercase tracking-wider text-muted">
|
|
Erledigt ({completedItems.length})
|
|
</span>
|
|
<button
|
|
onClick={handleClearCompleted}
|
|
className="btn-action text-xs"
|
|
>
|
|
Erledigte aufräumen
|
|
</button>
|
|
</div>
|
|
|
|
<div className="border border-border divide-y divide-border opacity-75 rounded-card overflow-hidden">
|
|
{completedItems.map((item) => {
|
|
const cat = getItemCategory(item);
|
|
const displayContent = getItemDisplayContent(item);
|
|
|
|
return (
|
|
<div
|
|
key={item.id}
|
|
onClick={() => handleToggleItem(item)}
|
|
className="px-3 py-1.5 bg-canvas/50 hover:bg-canvas/80 flex items-center justify-between gap-3 cursor-pointer transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
<CheckCircle2 className="w-4 h-4 text-accent flex-shrink-0" />
|
|
<span className="text-sm text-muted line-through truncate">
|
|
{displayContent}
|
|
</span>
|
|
{item.quantity && (
|
|
<span className="text-xs text-faint">({item.quantity})</span>
|
|
)}
|
|
{cat && (
|
|
<span className="text-[11px] px-1.5 py-0.2 badge-neutral text-muted rounded-full">
|
|
#{cat}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{item.completedByName && (
|
|
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Space & Shopping List Sharing Modal */}
|
|
{showShareModal && 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">
|
|
<Share2 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={() => {
|
|
setShowShareModal(false);
|
|
setShareError(null);
|
|
}}
|
|
className="btn-icon"
|
|
title="Schließen"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{shareError && (
|
|
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
|
|
{shareError}
|
|
</div>
|
|
)}
|
|
|
|
{/* Sharing Form (Team / Gruppe vs. Einzelner Benutzer) */}
|
|
<form onSubmit={handleAddShare} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setAddShareType('team');
|
|
setInviteTarget('');
|
|
}}
|
|
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
|
|
addShareType === 'team'
|
|
? 'bg-panel border-accent text-main'
|
|
: 'bg-canvas border-border text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
<Users className="w-3 h-3 text-accent" />
|
|
<span>Team / Gruppe</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setAddShareType('user');
|
|
setSelectedTeamId('');
|
|
}}
|
|
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
|
|
addShareType === 'user'
|
|
? 'bg-panel border-accent text-main'
|
|
: 'bg-canvas border-border text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
<UserIcon className="w-3 h-3 text-accent" />
|
|
<span>Einzelner Benutzer</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{addShareType === 'team' ? (
|
|
<div className="flex flex-col sm:flex-row gap-2">
|
|
<select
|
|
value={selectedTeamId}
|
|
onChange={(e) => setSelectedTeamId(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">-- Team / Gruppe auswählen --</option>
|
|
{teams.map((t) => (
|
|
<option key={t.id} value={t.id} className="bg-panel text-main">
|
|
👥 {t.name} ({t.members?.length || 1} Mitglieder)
|
|
</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>
|
|
) : (
|
|
<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>
|
|
)}
|
|
|
|
<div className="flex justify-end pt-1">
|
|
<button
|
|
type="submit"
|
|
disabled={
|
|
invitingLoading ||
|
|
(addShareType === 'team' ? !selectedTeamId : !inviteTarget.trim())
|
|
}
|
|
className="btn-primary text-xs h-8 px-4 flex items-center gap-1.5"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" />
|
|
<span>
|
|
{invitingLoading
|
|
? 'Wird geteilt…'
|
|
: addShareType === 'team'
|
|
? 'Team freigeben'
|
|
: 'Benutzer 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">
|
|
Aktive Freigaben & Mitglieder ({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="Freigabe entfernen"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|