MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1575 lines
61 KiB
TypeScript
1575 lines
61 KiB
TypeScript
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>
|
||
);
|
||
};
|