MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m3s
1200 lines
44 KiB
TypeScript
1200 lines
44 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Plus,
|
|
Trash2,
|
|
Users,
|
|
Check,
|
|
Radio,
|
|
Sparkles,
|
|
X,
|
|
Hash,
|
|
} from 'lucide-react';
|
|
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
|
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
|
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
|
|
|
interface SharedListsViewProps {
|
|
onRefreshGlobal: () => void;
|
|
currentUser?: User | null;
|
|
}
|
|
|
|
export function parseListItemInput(rawText: string, defaultQty?: string): {
|
|
content: string;
|
|
quantity: string;
|
|
category?: string;
|
|
} {
|
|
let text = rawText.trim();
|
|
const hashtagRegex = /#([a-zA-Z0-9äöüÄÖÜß_-]+)/g;
|
|
const matches = Array.from(text.matchAll(hashtagRegex));
|
|
|
|
let category: string | undefined = undefined;
|
|
if (matches.length > 0) {
|
|
category = matches[0][1];
|
|
text = text.replace(hashtagRegex, '').replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
if (!text && matches.length > 0) {
|
|
text = matches[0][1];
|
|
}
|
|
|
|
return {
|
|
content: text,
|
|
quantity: defaultQty?.trim() || '',
|
|
category,
|
|
};
|
|
}
|
|
|
|
export function getItemCategory(item: ListItem): string {
|
|
if (item.category && item.category.trim()) {
|
|
return item.category.trim().replace(/^#/, '');
|
|
}
|
|
const match = item.content.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/);
|
|
if (match) {
|
|
return match[1];
|
|
}
|
|
return '';
|
|
}
|
|
|
|
export function getItemDisplayContent(item: ListItem): string {
|
|
if (item.content.includes('#')) {
|
|
const cleaned = item.content.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, '').replace(/\s+/g, ' ').trim();
|
|
if (cleaned) return cleaned;
|
|
}
|
|
return item.content;
|
|
}
|
|
|
|
export 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 }) => {
|
|
const [lists, setLists] = useState<SharedList[]>([]);
|
|
const [activeListId, setActiveListId] = useState<string>('');
|
|
|
|
const [newItemText, setNewItemText] = useState('');
|
|
const [newItemQty, setNewItemQty] = useState('');
|
|
const [isSubmittingItem, setIsSubmittingItem] = useState(false);
|
|
|
|
const [wsConnected, setWsConnected] = useState(false);
|
|
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);
|
|
|
|
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);
|
|
|
|
const wsRef = useRef<WebSocket | null>(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
|
|
}
|
|
}
|
|
const deduplicated = all.map((l) => ({
|
|
...l,
|
|
items: deduplicateItems(l.items || []),
|
|
}));
|
|
setLists(deduplicated);
|
|
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(() => {});
|
|
}, []);
|
|
|
|
const activeList = useMemo(() => {
|
|
return lists.find((l) => l.id === activeListId) || lists[0] || null;
|
|
}, [lists, activeListId]);
|
|
|
|
useEffect(() => {
|
|
if (!activeList?.id) return;
|
|
|
|
if (wsRef.current) {
|
|
wsRef.current.close();
|
|
}
|
|
|
|
let ws: WebSocket | null = null;
|
|
try {
|
|
ws = new WebSocket(buildWebSocketURL(activeList.id));
|
|
wsRef.current = ws;
|
|
|
|
ws.onopen = () => {
|
|
setWsConnected(true);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
setWsConnected(false);
|
|
};
|
|
|
|
ws.onmessage = (evt) => {
|
|
try {
|
|
const msg: WSMessage = JSON.parse(evt.data);
|
|
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' && 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();
|
|
}
|
|
} catch {
|
|
// ignore malformed frames
|
|
}
|
|
};
|
|
} catch {
|
|
setWsConnected(false);
|
|
}
|
|
|
|
return () => {
|
|
if (ws) ws.close();
|
|
};
|
|
}, [activeList?.id]);
|
|
|
|
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();
|
|
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 handleToggleItem = async (item: ListItem) => {
|
|
if (!activeList) return;
|
|
triggerHaptic(10);
|
|
const nextCompleted = !item.isCompleted;
|
|
|
|
setLists((prev) =>
|
|
prev.map((l) => {
|
|
if (l.id !== activeList.id) return l;
|
|
return {
|
|
...l,
|
|
items: l.items.map((i) => (i.id === item.id ? { ...i, isCompleted: nextCompleted } : i)),
|
|
};
|
|
})
|
|
);
|
|
|
|
await api.toggleListItem(activeList.id, item.id, nextCompleted);
|
|
onRefreshGlobal();
|
|
};
|
|
|
|
const handleDeleteItem = async (itemId: string) => {
|
|
if (!activeList) return;
|
|
triggerHaptic(15);
|
|
setLists((prev) =>
|
|
prev.map((l) =>
|
|
l.id === activeList.id ? { ...l, items: l.items.filter((i) => i.id !== itemId) } : l
|
|
)
|
|
);
|
|
await api.deleteListItem(activeList.id, itemId);
|
|
};
|
|
|
|
const handleClearCompleted = async () => {
|
|
if (!activeList) return;
|
|
triggerHaptic(20);
|
|
await api.clearCompletedItems(activeList.id);
|
|
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 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();
|
|
let 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]);
|
|
|
|
return (
|
|
<div className="w-full space-y-3">
|
|
{/* Top Header: List Tabs & Actions */}
|
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
|
{/* 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} className="relative group flex items-center">
|
|
<button
|
|
onClick={() => setActiveListId(lst.id)}
|
|
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
|
|
isSelected
|
|
? 'bg-panel border-accent text-main'
|
|
: 'bg-canvas border-border text-muted hover:text-main'
|
|
}`}
|
|
>
|
|
<span>{lst.icon}</span>
|
|
<span>{lst.title}</span>
|
|
{openCount > 0 && (
|
|
<span className="ml-1 badge-neutral">{openCount}</span>
|
|
)}
|
|
</button>
|
|
{isSelected && lists.length > 1 && (
|
|
<button
|
|
onClick={() => handleDeleteList(lst.id)}
|
|
title="Liste löschen"
|
|
className="h-8 px-1.5 bg-panel border-y border-r border-accent text-faint hover:text-status-err"
|
|
>
|
|
<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>
|
|
);
|
|
})()}
|
|
|
|
{/* Live Sync Status */}
|
|
<div
|
|
className={`h-8 w-8 flex items-center justify-center rounded-btn border ${
|
|
wsConnected
|
|
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
|
: 'bg-canvas border-border text-muted'
|
|
}`}
|
|
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline'}
|
|
>
|
|
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{showNewListModal && (
|
|
<form
|
|
onSubmit={handleCreateList}
|
|
className="p-2 panel-surface flex flex-wrap items-center gap-2"
|
|
>
|
|
<div className="flex items-center gap-1.5">
|
|
{['🛒', '🏔️', '🍕', '🎉', '🧰', '🎁'].map((ic) => (
|
|
<button
|
|
key={ic}
|
|
type="button"
|
|
onClick={() => setNewListIcon(ic)}
|
|
className={`w-9 h-9 flex items-center justify-center rounded-btn transition-colors ${
|
|
newListIcon === ic
|
|
? 'bg-accent/20 border border-accent text-accent'
|
|
: 'bg-canvas hover:bg-panel-elevated border border-border'
|
|
}`}
|
|
>
|
|
{ic}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={newListTitle}
|
|
onChange={(e) => setNewListTitle(e.target.value)}
|
|
placeholder="Titel der Liste (z. B. Baumarkt, Drogerie, Camping)…"
|
|
className="flex-1 h-9 px-3 input-field text-sm"
|
|
autoFocus
|
|
/>
|
|
<button
|
|
type="submit"
|
|
className="btn-primary h-9 px-4 text-xs sm:text-sm"
|
|
>
|
|
Liste erstellen
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
{/* Active Shared List View */}
|
|
{activeList ? (
|
|
<div className="space-y-4">
|
|
{/* Quick-Add Form */}
|
|
<form
|
|
onSubmit={handleAddItem}
|
|
className="flex flex-col sm:flex-row items-center gap-2 panel-surface p-1.5 relative"
|
|
>
|
|
<div className="relative flex items-center gap-2 flex-1 w-full px-1.5">
|
|
<Plus className="w-4 h-4 text-accent flex-shrink-0" />
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
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={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch #Kühlregal)…`}
|
|
className="flex-1 bg-transparent py-1 text-sm text-main placeholder-faint focus:outline-none"
|
|
/>
|
|
|
|
{/* 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-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-1.5 w-full sm:w-auto justify-end px-1 sm:px-0">
|
|
<input
|
|
type="text"
|
|
value={newItemQty}
|
|
onChange={(e) => setNewItemQty(e.target.value)}
|
|
placeholder="Menge (z.B. 2x)"
|
|
className="w-28 h-7 px-2 input-field text-xs"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={isSubmittingItem || !newItemText.trim()}
|
|
className="btn-primary h-7 px-3 text-xs disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1 flex-shrink-0"
|
|
>
|
|
{isSubmittingItem ? 'Wird hinzugefügt…' : 'Hinzufügen'}
|
|
</button>
|
|
</div>
|
|
</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);
|
|
}}
|
|
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 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-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-4 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 h-3 stroke-[3]" />
|
|
</button>
|
|
|
|
<span className="text-sm font-medium text-main truncate">
|
|
{displayContent}
|
|
</span>
|
|
|
|
{item.quantity && (
|
|
<span className="badge-accent">
|
|
{item.quantity}
|
|
</span>
|
|
)}
|
|
|
|
{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>
|
|
|
|
<button
|
|
type="button"
|
|
draggable={false}
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteItem(item.id);
|
|
}}
|
|
className="btn-icon btn-icon-danger opacity-0 group-hover:opacity-100 transition-opacity"
|
|
title="Eintrag löschen"
|
|
>
|
|
<Trash2 className="w-3.5 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 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 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-4 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 h-3 stroke-[3]" />
|
|
</button>
|
|
|
|
<span className="text-sm text-faint line-through truncate">
|
|
{displayContent}
|
|
</span>
|
|
|
|
{item.quantity && (
|
|
<span className="text-xs text-faint">({item.quantity})</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{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 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
title="Löschen"
|
|
>
|
|
<Trash2 className="w-3.5 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>
|
|
);
|
|
};
|