Files
MindSift/frontend/src/features/lists/SharedListsView.tsx
T
sandrodachandClaude Opus 5.5 c43c99dc0c
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
feat(mobile): list items and new notes via a sheet opened by "+"
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:41:44 +01:00

1575 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Plus,
Trash2,
Users,
Check,
Sparkles,
X,
ShoppingCart,
CloudOff,
} from 'lucide-react';
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
import { readViewSession, writeViewSession } from '../../lib/viewSession';
import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge';
import { FormSheet } from '../../components/FormSheet';
import { useMobileActions } from '../../context/MobileActionContext';
interface SharedListsViewProps {
onRefreshGlobal: () => void;
currentUser?: User | null;
initialListId?: string | 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 function deduplicateItems(items: ListItem[]): ListItem[] {
const seen = new Set<string>();
const result: ListItem[] = [];
for (const item of items) {
if (item && item.id && !seen.has(item.id)) {
seen.add(item.id);
result.push(item);
}
}
return result;
}
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
const { setGroups, setPrimaryAction } = useMobileActions();
const [lists, setLists] = useState<SharedList[]>([]);
// The open list survives switching to other tabs (like the workspace's open note)
const [activeListId, setActiveListId] = useState<string>(
() => initialListId || readViewSession<{ listId: string }>('lists').listId || ''
);
useEffect(() => {
if (activeListId) writeViewSession('lists', { listId: activeListId });
}, [activeListId]);
useEffect(() => {
if (initialListId) {
setActiveListId(initialListId);
}
}, [initialListId]);
const [newItemText, setNewItemText] = useState('');
const [newItemQty, setNewItemQty] = useState('');
const [isSubmittingItem, setIsSubmittingItem] = useState(false);
const [editingQtyItemId, setEditingQtyItemId] = useState<string | null>(null);
const [editingQtyValue, setEditingQtyValue] = useState('');
const [wsConnected, setWsConnected] = useState(false);
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none');
const [showNewListModal, setShowNewListModal] = useState(false);
const [newListTitle, setNewListTitle] = useState('');
const [newListIcon, setNewListIcon] = useState('🛒');
const [showShareModal, setShowShareModal] = useState(false);
const [collaborators, setCollaborators] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
const [suggestionIndex, setSuggestionIndex] = useState<number>(0);
const [showSuggestions, setShowSuggestions] = useState<boolean>(false);
const [workspaceTags, setWorkspaceTags] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement | null>(null);
// Phones: the add form lives in a sheet opened by "+" instead of taking room above the list
const [isPhone, setIsPhone] = useState(() => typeof window !== 'undefined' && window.innerWidth < 640);
const [showAddSheet, setShowAddSheet] = useState(false);
useEffect(() => {
const onResize = () => setIsPhone(window.innerWidth < 640);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const [draggingItemId, setDraggingItemId] = useState<string | null>(null);
const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null);
const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState<string | null>(null);
// List tab a dragged entry hovers over (dropping moves it into that list)
const [dragOverListTab, setDragOverListTab] = useState<string | null>(null);
const realtimeConnRef = useRef<any>(null);
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
}
}
// Entries added offline and not sent yet stay visible (also after a reload without connection)
const pending = pendingListItems();
const deduplicated = all.map((l) => ({
...l,
items: deduplicateItems([
...pending.filter((p) => p.listId === l.id).map((p) => ({ ...p, isCompleted: false, sortOrder: 0 }) as ListItem),
...(l.items || []),
]),
}));
setLists(deduplicated);
if (initialListId && deduplicated.some((l) => l.id === initialListId)) {
setActiveListId(initialListId);
} else if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) {
setActiveListId(deduplicated[0].id);
}
} catch {
// ignore
}
};
useEffect(() => {
loadLists();
api
.getWorkspaceTags()
.then((tags) => {
setWorkspaceTags(tags.map((t) => t.tag.replace(/^#/, '')));
})
.catch(() => {});
}, []);
// People shown in tabs: everyone with access except me
const otherMembers = (lst: SharedList): AvatarPerson[] =>
(lst.members || [])
.filter((m) => m.userId !== currentUser?.id)
.map((m) => ({ id: m.userId, name: m.displayName || m.email, avatarUrl: m.avatarUrl }));
const activeList = useMemo(() => {
return lists.find((l) => l.id === activeListId) || lists[0] || null;
}, [lists, activeListId]);
// In shared lists every item shows who added it (older items without an author show nothing)
const isActiveListShared = (activeList?.members?.length || 0) > 1 || (activeList?.shares?.length || 0) > 0;
const renderItemAuthor = (item: ListItem) => {
if (!isActiveListShared || !item.createdBy) return null;
const member = activeList?.members?.find((m) => m.userId === item.createdBy);
const name = member?.displayName || member?.email || item.createdByName || '';
return (
<AvatarStack
people={[{ id: item.createdBy, name, avatarUrl: member?.avatarUrl }]}
size="xxs"
ringClass="ring-transparent"
title={name ? `Hinzugefügt von ${name}` : undefined}
/>
);
};
useEffect(() => {
if (!activeList?.id) return;
if (realtimeConnRef.current) {
try {
realtimeConnRef.current.close();
} catch {}
realtimeConnRef.current = null;
}
const conn = createRealtimeConnection({
listId: activeList.id,
onStatusChange: (status) => {
setWsConnected(status.connected);
setRealtimeTransport(status.transport);
},
onMessage: (msg: WSMessage) => {
const msgListId = msg.listId || (msg as any).list_id;
if (msg.event === 'ITEM_CREATED' && msgListId) {
const newItem: ListItem = msg.payload;
if (!newItem || !newItem.id) return;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msgListId) return l;
if (l.items.some((i) => i.id === newItem.id)) return l;
return { ...l, items: deduplicateItems([newItem, ...l.items]) };
})
);
} else if ((msg.event === 'ITEM_TOGGLED' || msg.event === 'ITEM_UPDATED') && msgListId) {
const updatedItem: ListItem = msg.payload;
setLists((prev) =>
prev.map((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' && msgListId) {
const deletedId = msg.payload?.itemId;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msgListId) return l;
return { ...l, items: l.items.filter((i) => i.id !== deletedId) };
})
);
} 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' ||
msg.event === 'ITEM_REORDERED' ||
msg.event === 'LIST_UPDATED'
) {
loadLists();
}
},
});
realtimeConnRef.current = conn;
return () => {
if (realtimeConnRef.current) {
try {
realtimeConnRef.current.close();
} catch {}
realtimeConnRef.current = null;
}
};
}, [activeList?.id]);
// Back online: once the outbox has sent the entries added offline, show the server's version
const listsRef = useRef(lists);
listsRef.current = lists;
const loadListsRef = useRef(loadLists);
loadListsRef.current = loadLists;
useEffect(() => {
const onOutbox = () => {
const shown = listsRef.current.some((l) => l.items.some((i) => i.id.startsWith(OFFLINE_ITEM_PREFIX)));
const queued = new Set(pendingListItems().map((p) => p.id));
const sent = listsRef.current.some((l) => l.items.some((i) => i.id.startsWith(OFFLINE_ITEM_PREFIX) && !queued.has(i.id)));
if (shown && sent) void loadListsRef.current();
};
window.addEventListener('mindsift:outbox-changed', onOutbox);
return () => window.removeEventListener('mindsift:outbox-changed', onOutbox);
}, []);
useEffect(() => {
const handleGlobalWs = (e: any) => {
const msg = e.detail;
if (!msg) return;
if (
msg.event === 'LIST_SHARED' ||
msg.event === 'LIST_UNSHARED' ||
msg.event === 'LIST_CREATED' ||
msg.event === 'LIST_DELETED' ||
msg.event === 'ITEM_REORDERED' ||
msg.event === 'LIST_UPDATED'
) {
loadLists();
}
};
window.addEventListener('mindsift:ws', handleGlobalWs);
return () => window.removeEventListener('mindsift:ws', handleGlobalWs);
}, []);
const handleAddItem = async (e: React.FormEvent) => {
e.preventDefault();
// Keep the field (and the phone keyboard) for the next item: refocus inside the tap
inputRef.current?.focus({ preventScroll: true });
if (!activeList || !newItemText.trim() || isSubmittingItem) return;
setIsSubmittingItem(true);
triggerHaptic(15);
setShowSuggestions(false);
const parsed = parseListItemInput(newItemText, newItemQty);
setNewItemText('');
setNewItemQty('');
setCursorPosition(null);
try {
const item = await api.createListItem(activeList.id, parsed.content, parsed.category, parsed.quantity);
setLists((prev) =>
prev.map((l) => {
if (l.id !== activeList.id) return l;
if (l.items.some((i) => i.id === item.id)) return l;
return { ...l, items: deduplicateItems([item, ...l.items]) };
})
);
onRefreshGlobal();
} catch (err: any) {
alert(err.message || 'Fehler beim Erstellen des Listeneintrags');
} finally {
setIsSubmittingItem(false);
}
};
const handleSaveQuantity = async (itemId: string, quantity: string) => {
setEditingQtyItemId(null);
if (!activeList) return;
const trimmed = quantity.trim();
try {
const updated = await api.updateListItem(activeList.id, itemId, { quantity: trimmed });
setLists((prev) =>
prev.map((l) => {
if (l.id !== activeList.id) return l;
return {
...l,
items: l.items.map((i) => (i.id === itemId ? { ...i, quantity: updated.quantity } : i)),
};
})
);
} catch (err: any) {
console.error('Fehler beim Aktualisieren der Menge', err);
}
};
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);
onRefreshGlobal();
};
const handleClearCompleted = async () => {
if (!activeList) return;
triggerHaptic(20);
await api.clearCompletedItems(activeList.id);
setLists((prev) =>
prev.map((l) =>
l.id === activeList.id ? { ...l, items: l.items.filter((i) => !i.isCompleted) } : l
)
);
};
const handleItemDragStart = (e: React.DragEvent, item: ListItem) => {
e.stopPropagation();
e.dataTransfer.setData('application/mindsift-list-item-id', item.id);
e.dataTransfer.setData('text/plain', item.id);
e.dataTransfer.effectAllowed = 'move';
setDraggingItemId(item.id);
};
const canWriteList = (lst: SharedList) => lst.isOwner || lst.role !== 'viewer';
// An entry dragged onto another list's tab lands at the top of that list, like a new entry
const handleDropOnListTab = async (e: React.DragEvent, target: SharedList) => {
e.preventDefault();
const itemId = e.dataTransfer.getData('application/mindsift-list-item-id') || draggingItemId;
setDraggingItemId(null);
setDragOverItemTarget(null);
setDragOverCategoryTarget(null);
setDragOverListTab(null);
const source = activeList;
const item = source?.items.find((i) => i.id === itemId);
if (!source || !item || target.id === source.id || !canWriteList(target)) return;
triggerHaptic(15);
setLists((prev) =>
prev.map((l) => {
if (l.id === source.id) return { ...l, items: l.items.filter((i) => i.id !== item.id) };
if (l.id === target.id) return { ...l, items: deduplicateItems([{ ...item, listId: target.id }, ...l.items]) };
return l;
})
);
try {
await api.moveListItem(source.id, item.id, target.id);
} catch {
loadLists();
}
onRefreshGlobal?.();
};
const handleItemDragOver = (e: React.DragEvent, targetItemId: string) => {
e.preventDefault();
e.stopPropagation();
if (!draggingItemId || draggingItemId === targetItemId) return;
const rect = e.currentTarget.getBoundingClientRect();
const position = e.clientY - rect.top < rect.height / 2 ? 'before' : 'after';
if (
!dragOverItemTarget ||
dragOverItemTarget.itemId !== targetItemId ||
dragOverItemTarget.position !== position
) {
setDragOverItemTarget({ itemId: targetItemId, position });
}
};
const handleItemDragLeave = (e: React.DragEvent, targetItemId: string) => {
e.stopPropagation();
if (dragOverItemTarget?.itemId === targetItemId) {
setDragOverItemTarget(null);
}
};
const handleDropOnItem = async (e: React.DragEvent, targetItem: ListItem, targetCategory: string) => {
e.preventDefault();
e.stopPropagation();
const sourceId =
e.dataTransfer.getData('application/mindsift-list-item-id') ||
e.dataTransfer.getData('text/plain') ||
draggingItemId;
const position = dragOverItemTarget?.position || 'after';
setDraggingItemId(null);
setDragOverItemTarget(null);
setDragOverCategoryTarget(null);
if (!activeList || !sourceId || sourceId === targetItem.id) return;
triggerHaptic(15);
const sourceItem = activeList.items.find((i) => i.id === sourceId);
if (!sourceItem) return;
const targetCatNormalized = targetCategory.trim();
const isCategoryChanged = (sourceItem.category || '').toLowerCase() !== targetCatNormalized.toLowerCase();
let newCategory = sourceItem.category;
let newContent = sourceItem.content;
if (isCategoryChanged) {
newCategory = targetCatNormalized;
if (!targetCatNormalized) {
newContent = newContent.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, '').replace(/\s+/g, ' ').trim();
} else if (newContent.includes('#')) {
newContent = newContent.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, `#${targetCatNormalized}`).trim();
}
}
const remainingItems = activeList.items.filter((i) => i.id !== sourceId);
const targetIndex = remainingItems.findIndex((i) => i.id === targetItem.id);
if (targetIndex === -1) return;
const insertIndex = position === 'before' ? targetIndex : targetIndex + 1;
const updatedSourceItem: ListItem = {
...sourceItem,
category: newCategory,
content: newContent,
};
const newItems = [...remainingItems];
newItems.splice(insertIndex, 0, updatedSourceItem);
const reorderPayload: { id: string; sortOrder: number; category?: string; content?: string }[] = [];
newItems.forEach((it, idx) => {
it.sortOrder = idx;
if (it.id === sourceId) {
reorderPayload.push({
id: it.id,
sortOrder: idx,
category: newCategory,
content: newContent,
});
} else {
reorderPayload.push({
id: it.id,
sortOrder: idx,
});
}
});
setLists((prev) =>
prev.map((l) => (l.id === activeList.id ? { ...l, items: newItems } : l))
);
try {
await api.reorderListItems(activeList.id, reorderPayload);
} catch {
loadLists();
}
};
const handleDropOnCategoryHeader = async (e: React.DragEvent, targetCategory: string) => {
e.preventDefault();
e.stopPropagation();
const sourceId =
e.dataTransfer.getData('application/mindsift-list-item-id') ||
e.dataTransfer.getData('text/plain') ||
draggingItemId;
setDraggingItemId(null);
setDragOverItemTarget(null);
setDragOverCategoryTarget(null);
if (!activeList || !sourceId) return;
triggerHaptic(15);
const sourceItem = activeList.items.find((i) => i.id === sourceId);
if (!sourceItem) return;
const targetCatNormalized = targetCategory.trim();
const newCategory = targetCatNormalized;
let newContent = sourceItem.content;
if (!targetCatNormalized) {
newContent = newContent.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, '').replace(/\s+/g, ' ').trim();
} else if (newContent.includes('#')) {
newContent = newContent.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, `#${targetCatNormalized}`).trim();
}
const updatedSourceItem: ListItem = {
...sourceItem,
category: newCategory,
content: newContent,
};
const remainingItems = activeList.items.filter((i) => i.id !== sourceId);
const newItems = [updatedSourceItem, ...remainingItems];
const reorderPayload = newItems.map((it, idx) => {
it.sortOrder = idx;
if (it.id === sourceId) {
return {
id: it.id,
sortOrder: idx,
category: newCategory,
content: newContent,
};
}
return { id: it.id, sortOrder: idx };
});
setLists((prev) =>
prev.map((l) => (l.id === activeList.id ? { ...l, items: newItems } : l))
);
try {
await api.reorderListItems(activeList.id, reorderPayload);
} catch {
loadLists();
}
};
const handleCreateList = async (e: React.FormEvent) => {
e.preventDefault();
if (!newListTitle.trim()) return;
const created = await api.createList(newListTitle.trim(), newListIcon, 'shopping');
setNewListTitle('');
setShowNewListModal(false);
await loadLists();
setActiveListId(created.id);
onRefreshGlobal();
};
const handleDeleteList = async (listId: string) => {
if (!window.confirm('Diese Liste wirklich löschen?')) return;
try {
await api.deleteList(listId);
await loadLists();
onRefreshGlobal();
} catch (err: any) {
alert(err.message || 'Liste konnte nicht gelöscht werden');
}
};
const openShareModal = async () => {
if (!activeList) return;
setShowShareModal(true);
try {
const [usersList, teamsList, sharesList] = await Promise.all([
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
api.getUserTeams().catch(() => []),
api.getListShares(activeList.id).catch(() => []),
]);
setCollaborators(usersList);
setTeams(teamsList);
setLists((prev) => prev.map((l) => (l.id === activeList.id ? { ...l, shares: sharesList } : l)));
} catch {
// ignore
}
};
const allAvailableHashtags = useMemo(() => {
const tagMap = new Map<string, string>();
// 1. Collect from all lists and items (open and completed)
for (const l of lists) {
for (const item of l.items) {
if (item.category && item.category.trim()) {
const raw = item.category.trim().replace(/^#/, '');
if (raw) {
const lower = raw.toLowerCase();
const existing = tagMap.get(lower);
const formatted = raw.charAt(0).toUpperCase() + raw.slice(1);
if (!existing || (raw[0] === raw[0].toUpperCase() && existing[0] === existing[0].toLowerCase())) {
tagMap.set(lower, formatted);
}
}
}
if (item.content && item.content.includes('#')) {
const matches = item.content.matchAll(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
for (const m of matches) {
const raw = m[1];
const lower = raw.toLowerCase();
const existing = tagMap.get(lower);
const formatted = raw.charAt(0).toUpperCase() + raw.slice(1);
if (!existing || (raw[0] === raw[0].toUpperCase() && existing[0] === existing[0].toLowerCase())) {
tagMap.set(lower, formatted);
}
}
}
}
}
// 2. Also incorporate workspace tags
for (const t of workspaceTags) {
const raw = t.trim().replace(/^#/, '');
if (raw) {
const lower = raw.toLowerCase();
if (!tagMap.has(lower)) {
tagMap.set(lower, raw.charAt(0).toUpperCase() + raw.slice(1));
}
}
}
return Array.from(tagMap.values()).sort((a, b) =>
a.localeCompare(b, 'de', { sensitivity: 'base' })
);
}, [lists, workspaceTags]);
const activeHashtagQuery = useMemo(() => {
if (!showSuggestions) return null;
const pos = cursorPosition !== null ? cursorPosition : newItemText.length;
const textBefore = newItemText.slice(0, pos);
const match = textBefore.match(/(?:^|\s)#([a-zA-Z0-9äöüÄÖÜß_-]*)$/);
if (!match) return null;
return {
query: match[1],
matchIndex: match.index! + (match[0].startsWith(' ') ? 1 : 0),
cursorPos: pos,
};
}, [newItemText, cursorPosition, showSuggestions]);
const matchingSuggestions = useMemo(() => {
if (!activeHashtagQuery) return [];
const q = activeHashtagQuery.query.toLowerCase();
if (!q) {
return allAvailableHashtags;
}
const startsWithMatches: string[] = [];
const containsMatches: string[] = [];
for (const tag of allAvailableHashtags) {
const lower = tag.toLowerCase();
if (lower.startsWith(q)) {
startsWithMatches.push(tag);
} else if (lower.includes(q)) {
containsMatches.push(tag);
}
}
return [...startsWithMatches, ...containsMatches];
}, [activeHashtagQuery, allAvailableHashtags]);
const applySuggestion = (tag: string) => {
if (!activeHashtagQuery) return;
const before = newItemText.slice(0, activeHashtagQuery.matchIndex);
const after = newItemText.slice(activeHashtagQuery.cursorPos);
const newText = `${before}#${tag} ${after}`;
setNewItemText(newText);
setShowSuggestions(false);
setSuggestionIndex(0);
const newCursor = activeHashtagQuery.matchIndex + tag.length + 2; // '#' + tag + ' '
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.setSelectionRange(newCursor, newCursor);
setCursorPosition(newCursor);
}
}, 10);
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (showSuggestions && activeHashtagQuery && matchingSuggestions.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSuggestionIndex((prev) => (prev + 1) % matchingSuggestions.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSuggestionIndex((prev) => (prev - 1 + matchingSuggestions.length) % matchingSuggestions.length);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const selected = matchingSuggestions[suggestionIndex] || matchingSuggestions[0];
if (selected) {
applySuggestion(selected);
}
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setShowSuggestions(false);
return;
}
}
};
const safeActiveItems = useMemo(() => deduplicateItems(activeList?.items || []), [activeList?.items]);
const openItems = useMemo(() => safeActiveItems.filter((i) => !i.isCompleted), [safeActiveItems]);
const completedItems = useMemo(() => safeActiveItems.filter((i) => i.isCompleted), [safeActiveItems]);
const groupedOpenItems = useMemo(() => {
const groupsMap = new Map<string, ListItem[]>();
const canonicalMap = new Map<string, string>();
const uncategorized: ListItem[] = [];
for (const item of openItems) {
const cat = getItemCategory(item);
if (!cat) {
uncategorized.push(item);
} else {
const key = cat.toLowerCase();
const list = groupsMap.get(key) || [];
list.push(item);
groupsMap.set(key, list);
const currentCanonical = canonicalMap.get(key);
if (!currentCanonical) {
const formatted = cat.charAt(0).toUpperCase() + cat.slice(1);
canonicalMap.set(key, formatted);
} else if (cat[0] === cat[0].toUpperCase() && currentCanonical[0] === currentCanonical[0].toLowerCase()) {
canonicalMap.set(key, cat);
}
}
}
const sortedKeys = Array.from(groupsMap.keys()).sort((a, b) => {
const labelA = canonicalMap.get(a) || a;
const labelB = canonicalMap.get(b) || b;
return labelA.localeCompare(labelB, 'de', { sensitivity: 'base' });
});
const result: { category: string; label: string; items: ListItem[] }[] = [];
for (const key of sortedKeys) {
const displayLabel = canonicalMap.get(key) || key;
result.push({
category: key,
label: `#${displayLabel}`,
items: groupsMap.get(key)!,
});
}
if (uncategorized.length > 0) {
result.push({
category: '',
label: result.length > 0 ? 'Allgemein' : '',
items: uncategorized,
});
}
return result;
}, [openItems]);
const focusNewItemInput = () => {
if (window.innerWidth < 640) {
setShowAddSheet(true);
return;
}
inputRef.current?.focus({ preventScroll: true });
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
// Register context-sensitive mobile menu groups behind the MindSift logo
useEffect(() => {
setPrimaryAction(() => {
// Focus right inside the tap: iOS only opens the keyboard for focus() during the user gesture
focusNewItemInput();
});
setGroups([
{
id: 'lists-actions',
title: 'Aktionen',
items: [
{
id: 'list-new-item',
label: 'Neuen Eintrag hinzufügen',
icon: <Plus className="w-4 h-4 text-accent" />,
variant: 'accent',
onClick: () => {
focusNewItemInput();
},
},
{
id: 'list-new-list',
label: 'Neue Einkaufsliste erstellen',
icon: <Plus className="w-4 h-4" />,
onClick: () => setShowNewListModal(true),
},
...(activeList
? [
{
id: 'list-share',
label: `Liste "${activeList.title}" teilen`,
icon: <Users className="w-4 h-4" />,
onClick: () => openShareModal(),
},
]
: []),
...(completedItems.length > 0
? [
{
id: 'list-clear-completed',
label: `Erledigte aufräumen (${completedItems.length})`,
icon: <Sparkles className="w-4 h-4" />,
onClick: () => handleClearCompleted(),
},
]
: []),
...(activeList && lists.length > 1
? [
{
id: 'list-delete',
label: `Liste "${activeList.title}" löschen`,
icon: <Trash2 className="w-4 h-4" />,
variant: 'danger' as const,
onClick: () => handleDeleteList(activeList.id),
},
]
: []),
],
},
{
id: 'lists-switcher',
title: 'Einkaufslisten wechseln',
items: lists.map((lst) => {
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
return {
id: `select-list-${lst.id}`,
label: lst.title,
icon: <span className="text-base">{lst.icon}</span>,
badge: openCount > 0 ? openCount : undefined,
active: activeList?.id === lst.id,
onClick: () => setActiveListId(lst.id),
};
}),
},
]);
}, [lists, activeList, completedItems.length, setGroups, setPrimaryAction]);
// The add form's fields: inline on the desktop, on phones in a sheet that "+" opens (no fixed field
// taking room above the list); the sheet brings its own buttons
const renderAddItemFields = (withButton: boolean) =>
activeList ? (
<>
{/* The item is the main field: a real input like the quantity next to it, with the + inside */}
<div className="relative flex-1 w-full min-w-0">
<Plus className="w-4 h-4 text-accent absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
ref={inputRef}
type="text"
autoFocus={!withButton}
value={newItemText}
onChange={(e) => {
setNewItemText(e.target.value);
setCursorPosition(e.target.selectionStart);
setShowSuggestions(true);
setSuggestionIndex(0);
}}
onKeyDown={handleInputKeyDown}
onSelect={(e) => {
setCursorPosition(e.currentTarget.selectionStart);
}}
onFocus={(e) => {
setCursorPosition(e.target.selectionStart);
setShowSuggestions(true);
}}
onBlur={() => {
setTimeout(() => setShowSuggestions(false), 200);
}}
placeholder={`Was soll auf „${activeList.title}“? (z. B. Hafermilch #Kühlregal)`}
// 16px on phones: iOS zooms into smaller fields and the layout jumps under the keyboard
className="w-full h-11 sm:h-9 pl-9 pr-3 input-field text-base sm:text-sm"
/>
{/* Hashtag Autocomplete Suggestions Dropdown */}
{showSuggestions && activeHashtagQuery && matchingSuggestions.length > 0 && (
<div className="absolute left-6 top-full mt-2 w-64 max-h-56 overflow-y-auto bg-panel-elevated border border-border rounded-btn shadow-xl py-1 z-50 animate-in fade-in zoom-in-95 duration-100">
<div className="px-2.5 py-1 text-[10px] uppercase font-semibold text-faint tracking-wider border-b border-border/50">
Hashtags
</div>
{matchingSuggestions.map((tag, idx) => {
const isSelected = idx === suggestionIndex;
return (
<button
key={tag}
type="button"
onMouseDown={(e) => {
e.preventDefault();
applySuggestion(tag);
}}
className={`w-full text-left px-3 py-2 sm:py-1.5 text-xs flex items-center justify-between transition-colors ${
isSelected
? 'bg-accent/20 text-accent font-medium'
: 'text-main hover:bg-panel-hover'
}`}
>
<span className="flex items-center gap-1.5">
<span className="text-accent font-mono font-semibold">#</span>
<span>{tag}</span>
</span>
{isSelected && (
<span className="text-[10px] text-faint font-mono">↵</span>
)}
</button>
);
})}
</div>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<input
type="text"
inputMode="decimal"
value={newItemQty}
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
placeholder="Menge"
aria-label="Menge (optional)"
className="w-24 sm:w-20 h-11 sm:h-9 px-2.5 input-field text-base sm:text-sm"
/>
{withButton && (
<button
type="submit"
disabled={isSubmittingItem || !newItemText.trim()}
className="btn-primary flex-1 sm:flex-none h-11 sm:h-9 px-4 text-sm sm:text-xs disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-1 flex-shrink-0"
>
{isSubmittingItem ? 'Wird hinzugefügt…' : 'Hinzufügen'}
</button>
)}
</div>
</>
) : null;
// Connection pill in the same look as the notes' header
const livePill = (
<span
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel text-muted"
title={
!wsConnected
? 'Keine Live-Verbindung – Änderungen anderer erscheinen nach dem Neuladen'
: realtimeTransport === 'sse'
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
: 'Live-Verbindung über WebSocket'
}
>
<span className={`w-1.5 h-1.5 rounded-full ${wsConnected ? 'bg-status-ok' : 'bg-status-warn'}`} />
{wsConnected ? 'Live' : 'Offline'}
</span>
);
return (
<div className="w-full space-y-3">
{/* Top Header: List Tabs & Actions (Desktop) */}
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2 !mt-0">
{/* List Tabs */}
<div className="flex flex-wrap items-center gap-1">
{lists.map((lst) => {
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
const isSelected = activeList?.id === lst.id;
return (
<div
key={lst.id}
onDragOver={(e) => {
if (!draggingItemId || isSelected || !canWriteList(lst)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverListTab !== lst.id) setDragOverListTab(lst.id);
}}
onDragLeave={() => {
if (dragOverListTab === lst.id) setDragOverListTab(null);
}}
onDrop={(e) => handleDropOnListTab(e, lst)}
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
isSelected ? 'bg-panel border-accent text-main' : 'bg-canvas border-border text-muted hover:text-main'
} ${isSelected && lists.length > 1 ? 'pr-1' : ''} ${
dragOverListTab === lst.id ? 'ring-2 ring-accent/60 bg-accent/10 text-main' : ''
}`}
>
<button
onClick={() => setActiveListId(lst.id)}
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
>
<span>{lst.icon}</span>
{/* Counter on the label, like in the nav bar */}
<span className={`relative inline-flex items-center ${openCount > 0 ? 'pr-3' : ''}`}>
{lst.title}
<CountBadge count={openCount} position="corner" className="-top-0.5 -right-2" />
</span>
<AvatarStack
people={otherMembers(lst)}
size="xxs"
ringClass={isSelected ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
className="ml-0.5"
/>
</button>
{isSelected && lists.length > 1 && (
<button
onClick={() => handleDeleteList(lst.id)}
title="Liste löschen"
className="h-6 w-6 text-faint hover:text-status-err hover:bg-status-err/10 rounded-btn flex items-center justify-center transition-colors cursor-pointer"
>
<X className="w-3 h-3" />
</button>
)}
</div>
);
})}
<button
onClick={() => setShowNewListModal((prev) => !prev)}
className="h-8 px-2.5 bg-canvas hover:bg-panel border border-border border-dashed rounded-btn text-xs text-muted hover:text-main flex items-center gap-1 transition-colors"
title="Neue Liste anlegen"
>
<Plus className="w-3.5 h-3.5" />
<span>Neue Liste</span>
</button>
</div>
{/* Right Header Section: Geteilt Badge, Icon-Only Share Button & Live Sync */}
<div className="flex items-center gap-2">
{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">
{isShared && (
isOwner ? (
<button
type="button"
onClick={openShareModal}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt ({sharesCount})</span>
</button>
) : (
<button
type="button"
onClick={openShareModal}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {ownerDisplayName}</span>
</button>
)
)}
<button
type="button"
onClick={openShareModal}
className="btn-icon"
title="Liste teilen und Berechtigungen"
>
<Users className="w-3.5 h-3.5" />
</button>
</div>
);
})()}
{livePill}
</div>
</div>
{/* Mobile Compact Header Bar (List switching & actions live in MindSift Logo Menu) */}
{/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-main truncate">
{activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'}
</span>
{openItems.length > 0 && (
<span className="badge-neutral text-[11px] flex-shrink-0">
{openItems.length} offen
</span>
)}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{activeList && (
<button
type="button"
onClick={openShareModal}
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center"
title="Liste teilen"
>
<Users className="w-4 h-4 text-accent" />
</button>
)}
{livePill}
</div>
</div>
{showNewListModal && (
<FormSheet
title="Neue Einkaufsliste erstellen"
icon={<ShoppingCart className="w-4 h-4 text-accent" />}
onClose={() => setShowNewListModal(false)}
onSubmit={handleCreateList}
>
<div>
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
<div className="flex items-center gap-1.5">
<input
type="text"
value={newListIcon}
onChange={(e) => setNewListIcon(e.target.value)}
maxLength={2}
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
title="Icon (Emoji)"
/>
<input
type="text"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
autoFocus
required
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs"
/>
</div>
</div>
</FormSheet>
)}
{/* Active Shared List View */}
{activeList ? (
<div className="space-y-4">
{/* Phones: "+" opens the add form as a sheet; it stays open for the next item */}
{isPhone && showAddSheet && (
<FormSheet
title={`Auf „${activeList.title}“`}
icon={<ShoppingCart className="w-4 h-4 text-accent" />}
onClose={() => setShowAddSheet(false)}
onSubmit={handleAddItem}
submitLabel="Hinzufügen"
busy={isSubmittingItem}
>
<div className="flex flex-col gap-2 relative">{renderAddItemFields(false)}</div>
</FormSheet>
)}
{/* Quick-Add Form (desktop; on phones it is the sheet above) */}
{!isPhone && (
<form
onSubmit={handleAddItem}
className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 panel-surface p-2 relative"
>
{renderAddItemFields(true)}
</form>
)}
{/* Open Items Grouped by Hashtags / Categories */}
{openItems.length === 0 ? (
<div className="p-8 text-center text-muted text-sm space-y-1 panel-surface">
<p className="font-medium text-main flex items-center justify-center gap-1.5">
<Sparkles className="w-4 h-4 text-accent" />
<span>Alles erledigt</span>
</p>
<p className="text-xs text-faint">Keine offenen Punkte auf dieser Liste.</p>
</div>
) : (
<div className="space-y-4">
{groupedOpenItems.map((group) => (
<div key={group.category || 'uncategorized'} className="space-y-1.5">
{/* Category Group Header: white text directly on canvas background */}
{group.label && (
<div className="flex items-center justify-between px-1">
<span className="text-xs font-semibold text-main tracking-wide">
{group.label}
</span>
<span className="badge-neutral font-mono text-[10px]">
{group.items.length}
</span>
</div>
)}
{/* Lighter panel-surface card container with items */}
<div
onDragOver={(e) => {
e.preventDefault();
if (draggingItemId) setDragOverCategoryTarget(group.category);
}}
onDragLeave={() => {
if (dragOverCategoryTarget === group.category) setDragOverCategoryTarget(null);
}}
onDrop={(e) => handleDropOnCategoryHeader(e, group.category)}
className={`panel-surface divide-y divide-border overflow-hidden transition-all ${
dragOverCategoryTarget === group.category && !dragOverItemTarget
? 'ring-2 ring-accent/60 bg-accent/5'
: ''
}`}
>
{group.items.map((item) => {
const cat = getItemCategory(item);
const displayContent = getItemDisplayContent(item);
const isDragging = draggingItemId === item.id;
const isTarget = dragOverItemTarget?.itemId === item.id;
return (
<div
key={item.id}
draggable
onDragStart={(e) => handleItemDragStart(e, item)}
onDragEnd={() => {
setDraggingItemId(null);
setDragOverItemTarget(null);
setDragOverCategoryTarget(null);
setDragOverListTab(null);
}}
onDragOver={(e) => handleItemDragOver(e, item.id)}
onDragLeave={(e) => handleItemDragLeave(e, item.id)}
onDrop={(e) => handleDropOnItem(e, item, group.category)}
onClick={() => handleToggleItem(item)}
className={`px-3.5 py-3.5 sm:px-3 sm:py-2.5 flex items-center justify-between gap-3 cursor-pointer cursor-grab active:cursor-grabbing select-none transition-colors hover:bg-panel-hover group relative ${
isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
}`}
>
{/* Drop insertion line indicator */}
{isTarget && dragOverItemTarget.position === 'before' && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
)}
{isTarget && dragOverItemTarget.position === 'after' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
)}
<div className="flex items-center gap-3 sm:gap-2.5 min-w-0 flex-1">
{/* Eckiges Abhaken */}
<button
type="button"
draggable={false}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
handleToggleItem(item);
}}
className={`w-5 h-5 sm:w-4 sm:h-4 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${
item.isCompleted
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
title={item.isCompleted ? 'Als offen markieren' : 'Als erledigt abhaken'}
>
<Check className="w-3.5 h-3.5 sm:w-3 sm:h-3 stroke-[3]" />
</button>
<span className="text-sm font-medium text-main truncate">
{displayContent}
</span>
{item.id.startsWith(OFFLINE_ITEM_PREFIX) && (
<span title="Offline hinzugefügt – wird gesendet, sobald die Verbindung zurück ist">
<CloudOff className="w-3.5 h-3.5 text-faint shrink-0" />
</span>
)}
{editingQtyItemId === item.id ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
type="text"
autoFocus
value={editingQtyValue}
onChange={(e) => setEditingQtyValue(e.target.value.replace(/[^0-9.,xX]/g, ''))}
onKeyDown={async (e) => {
if (e.key === 'Enter') {
e.preventDefault();
await handleSaveQuantity(item.id, editingQtyValue);
} else if (e.key === 'Escape') {
setEditingQtyItemId(null);
}
}}
onBlur={async () => {
await handleSaveQuantity(item.id, editingQtyValue);
}}
className="w-16 h-8 sm:h-6 px-1.5 text-base sm:text-xs input-field font-semibold text-accent text-center"
placeholder="Menge"
/>
</div>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setEditingQtyItemId(item.id);
setEditingQtyValue(item.quantity || '');
}}
className={`text-xs px-2 py-0.5 rounded-full transition-colors ${
item.quantity
? 'badge-accent cursor-pointer hover:brightness-125'
: 'opacity-0 group-hover:opacity-60 hover:!opacity-100 text-muted hover:text-accent border border-dashed border-border/70 hover:border-accent text-[11px]'
}`}
title="Menge bearbeiten"
>
{item.quantity || '+ Menge'}
</button>
)}
{cat && !group.label && (
<span className="px-1.5 py-0.5 text-[10px] font-medium text-accent bg-accent/10 border border-accent/25 rounded-md">
#{cat}
</span>
)}
</div>
{renderItemAuthor(item)}
<button
type="button"
draggable={false}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
handleDeleteItem(item.id);
}}
className="btn-icon btn-icon-danger p-2 sm:p-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
title="Eintrag löschen"
>
<Trash2 className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
</button>
</div>
);
})}
</div>
</div>
))}
</div>
)}
{/* Completed Items Section */}
{completedItems.length > 0 && (
<div className="space-y-1.5 pt-2">
<div className="flex items-center justify-between px-1">
<span className="text-xs font-semibold text-muted uppercase tracking-wider">
Erledigt ({completedItems.length})
</span>
<button
onClick={handleClearCompleted}
className="btn-action min-h-[36px] sm:min-h-0 text-xs"
>
Erledigte aufräumen
</button>
</div>
<div className="panel-surface divide-y divide-border opacity-70 overflow-hidden">
{completedItems.map((item) => {
const displayContent = getItemDisplayContent(item);
return (
<div
key={item.id}
onClick={() => handleToggleItem(item)}
className="px-3.5 py-3 sm:px-3 sm:py-2 flex items-center justify-between gap-3 cursor-pointer hover:bg-panel-hover transition-colors group"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{/* Eckiges Abhaken */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleToggleItem(item);
}}
className="w-5 h-5 sm:w-4 sm:h-4 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border bg-status-ok border-status-ok text-on-accent"
title="Als offen markieren"
>
<Check className="w-3.5 h-3.5 sm:w-3 sm:h-3 stroke-[3]" />
</button>
<span className="text-sm text-faint line-through truncate">
{displayContent}
</span>
{editingQtyItemId === item.id ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
<input
type="text"
autoFocus
value={editingQtyValue}
onChange={(e) => setEditingQtyValue(e.target.value.replace(/[^0-9.,xX]/g, ''))}
onKeyDown={async (e) => {
if (e.key === 'Enter') {
e.preventDefault();
await handleSaveQuantity(item.id, editingQtyValue);
} else if (e.key === 'Escape') {
setEditingQtyItemId(null);
}
}}
onBlur={async () => {
await handleSaveQuantity(item.id, editingQtyValue);
}}
className="w-16 h-8 sm:h-6 px-1.5 text-base sm:text-xs input-field font-semibold text-accent text-center"
placeholder="Menge"
/>
</div>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setEditingQtyItemId(item.id);
setEditingQtyValue(item.quantity || '');
}}
className={`text-xs px-1.5 py-0.5 rounded transition-colors ${
item.quantity
? 'text-faint hover:text-main cursor-pointer'
: 'opacity-0 group-hover:opacity-60 text-faint hover:text-main text-[11px]'
}`}
title="Menge bearbeiten"
>
{item.quantity ? `(${item.quantity})` : '+ Menge'}
</button>
)}
</div>
<div className="flex items-center gap-2">
{renderItemAuthor(item)}
{item.completedByName && (
<span className="text-xs text-faint">✓ {item.completedByName}</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeleteItem(item.id);
}}
className="btn-icon btn-icon-danger p-2 sm:p-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity"
title="Löschen"
>
<Trash2 className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
</button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
) : null}
{/* Unified Shopping List Sharing Modal */}
{showShareModal && activeList && (
<UnifiedShareModal
isOpen={showShareModal}
onClose={() => setShowShareModal(false)}
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={(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) => {
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.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();
}}
/>
)}
</div>
);
};