Tasks without a list come first, then the lists in tab order. Single lists keep no grouping by default and do not offer it. The open task list, view and grouping, like the open shopping list, are now remembered per device instead of per tab. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1580 lines
62 KiB
TypeScript
1580 lines
62 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 } from '../../lib/api';
|
||
import { readViewMemory, writeViewMemory } 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 { ListSwitcher } from '../../components/ListSwitcher';
|
||
import { LivePill } from '../../components/LivePill';
|
||
import { isPhoneWidth } from '../../lib/breakpoints';
|
||
import { OriginBadge } from '../../components/OriginBadge';
|
||
import { VoiceInputButton } from '../../components/VoiceInputButton';
|
||
import { FormSheet } from '../../components/FormSheet';
|
||
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
||
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 is remembered on this device (other tabs of the app, new tab, restart)
|
||
const [activeListId, setActiveListId] = useState<string>(
|
||
() => initialListId || readViewMemory<{ listId: string }>('lists').listId || ''
|
||
);
|
||
useEffect(() => {
|
||
if (activeListId) writeViewMemory('lists', { listId: activeListId });
|
||
}, [activeListId]);
|
||
|
||
useEffect(() => {
|
||
if (initialListId) {
|
||
setActiveListId(initialListId);
|
||
}
|
||
}, [initialListId]);
|
||
|
||
const [newItemText, setNewItemText] = useState('');
|
||
const [newItemQty, setNewItemQty] = useState('');
|
||
const [itemVoiceError, setItemVoiceError] = useState<string | null>(null);
|
||
const [isSubmittingItem, setIsSubmittingItem] = useState(false);
|
||
const [editingQtyItemId, setEditingQtyItemId] = useState<string | null>(null);
|
||
const [editingQtyValue, setEditingQtyValue] = useState('');
|
||
|
||
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(isPhoneWidth);
|
||
const [showAddSheet, setShowAddSheet] = useState(false);
|
||
useEffect(() => {
|
||
const onResize = () => setIsPhone(isPhoneWidth());
|
||
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,
|
||
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 (isPhoneWidth()) {
|
||
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={itemVoiceError || `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-11 sm:pr-9 input-field text-base sm:text-sm"
|
||
/>
|
||
<VoiceInputButton
|
||
value={newItemText}
|
||
onChange={setNewItemText}
|
||
focusRef={inputRef}
|
||
onError={(message) => {
|
||
setItemVoiceError(message);
|
||
window.setTimeout(() => setItemVoiceError(null), 4000);
|
||
}}
|
||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||
/>
|
||
|
||
{/* 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;
|
||
|
||
|
||
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="inline-flex items-center">
|
||
{lst.title}
|
||
<CountBadge count={openCount} position="text" />
|
||
</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 like the notes: status (live, shared) | actions */}
|
||
<div className="flex items-center gap-2">
|
||
<LivePill />
|
||
{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>
|
||
)
|
||
)}
|
||
|
||
<span className="w-px h-5 bg-border mx-0.5" aria-hidden />
|
||
<button
|
||
type="button"
|
||
onClick={openShareModal}
|
||
className="btn-icon"
|
||
title="Liste teilen und Berechtigungen"
|
||
>
|
||
<Users className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
);
|
||
})()}
|
||
</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">
|
||
{activeList ? (
|
||
<ListSwitcher
|
||
label={[activeList.icon, activeList.title].filter(Boolean).join(' ')}
|
||
items={lists.map((lst) => ({
|
||
id: lst.id,
|
||
title: lst.title,
|
||
icon: lst.icon,
|
||
count: deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length,
|
||
}))}
|
||
activeId={activeList.id}
|
||
onSelect={setActiveListId}
|
||
/>
|
||
) : (
|
||
<span className="text-sm font-semibold text-main truncate">🛒 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">
|
||
<LivePill />
|
||
{activeList && (
|
||
<>
|
||
<span className="w-px h-5 bg-border" aria-hidden />
|
||
<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>
|
||
</>
|
||
)}
|
||
</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">
|
||
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} />
|
||
<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>
|
||
|
||
<OriginBadge origin={item.origin} createdAt={item.createdAt} size="xs" />
|
||
{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">
|
||
<OriginBadge origin={item.origin} createdAt={item.createdAt} size="xs" />
|
||
{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 || undefined}
|
||
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>
|
||
);
|
||
};
|