diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index abd3a56..fe16a33 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Plus, CheckCircle2, @@ -20,7 +20,50 @@ interface SharedListsViewProps { currentUser?: User | null; } -const CATEGORIES = ['Obst & Gemüse', 'Kühlregal', 'Vorrat & Konserven', 'Backwaren', 'Getränke', 'Drogerie', 'Sonstiges']; +export function parseListItemInput(rawText: string, defaultQty?: string): { + content: string; + quantity: string; + category?: string; +} { + let text = rawText.trim(); + const hashtagRegex = /#([a-zA-Z0-9äöüÄÖÜß_-]+)/g; + const matches = Array.from(text.matchAll(hashtagRegex)); + + let category: string | undefined = undefined; + if (matches.length > 0) { + category = matches[0][1]; + text = text.replace(hashtagRegex, '').replace(/\s+/g, ' ').trim(); + } + + if (!text && matches.length > 0) { + text = matches[0][1]; + } + + return { + content: text, + quantity: defaultQty?.trim() || '', + category, + }; +} + +export function getItemCategory(item: ListItem): string { + if (item.category && item.category.trim()) { + return item.category.trim().replace(/^#/, ''); + } + const match = item.content.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/); + if (match) { + return match[1]; + } + return ''; +} + +export function getItemDisplayContent(item: ListItem): string { + if (item.content.includes('#')) { + const cleaned = item.content.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, '').replace(/\s+/g, ' ').trim(); + if (cleaned) return cleaned; + } + return item.content; +} export const SharedListsView: React.FC = ({ onRefreshGlobal, currentUser }) => { const [spaces, setSpaces] = useState([]); @@ -30,7 +73,6 @@ export const SharedListsView: React.FC = ({ onRefreshGloba const [newItemText, setNewItemText] = useState(''); const [newItemQty, setNewItemQty] = useState(''); - const [newItemCategory, setNewItemCategory] = useState(''); const [wsConnected, setWsConnected] = useState(false); const [copiedInvite, setCopiedInvite] = useState(false); @@ -176,15 +218,12 @@ export const SharedListsView: React.FC = ({ onRefreshGloba if (!activeList || !newItemText.trim()) return; triggerHaptic(15); - const text = newItemText.trim(); - const qty = newItemQty.trim(); - const cat = newItemCategory || undefined; - + const parsed = parseListItemInput(newItemText, newItemQty); setNewItemText(''); setNewItemQty(''); try { - const item = await api.createListItem(activeList.id, text, qty, cat); + const item = await api.createListItem(activeList.id, parsed.content, parsed.category, parsed.quantity); setLists((prev) => prev.map((l) => (l.id === activeList.id ? { ...l, items: [item, ...l.items] } : l)) ); @@ -352,6 +391,46 @@ export const SharedListsView: React.FC = ({ onRefreshGloba const openItems = activeList?.items.filter((i) => !i.isCompleted) || []; const completedItems = activeList?.items.filter((i) => i.isCompleted) || []; + const groupedOpenItems = useMemo(() => { + const groupsMap = new Map(); + const uncategorized: ListItem[] = []; + + for (const item of openItems) { + const cat = getItemCategory(item); + if (!cat) { + uncategorized.push(item); + } else { + const list = groupsMap.get(cat) || []; + list.push(item); + groupsMap.set(cat, list); + } + } + + const sortedCategories = Array.from(groupsMap.keys()).sort((a, b) => + a.localeCompare(b, 'de', { sensitivity: 'base' }) + ); + + const result: { category: string; label: string; items: ListItem[] }[] = []; + + for (const cat of sortedCategories) { + result.push({ + category: cat, + label: `#${cat}`, + items: groupsMap.get(cat)!, + }); + } + + if (uncategorized.length > 0) { + result.push({ + category: '', + label: result.length > 0 ? 'Allgemein' : '', + items: uncategorized, + }); + } + + return result; + }, [openItems]); + return (
{/* Top Space Bar & Realtime Indicator */} @@ -542,14 +621,14 @@ export const SharedListsView: React.FC = ({ onRefreshGloba {/* Active Shared List Card */} {activeList ? (
- {/* Quick-Add Pill Form */} + {/* Quick-Add Form */}
setNewItemText(e.target.value)} - placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch)…`} + placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch #Kühlregal oder #Kaffee)…`} className="flex-1 h-9 px-3 input-field text-sm" />
@@ -570,28 +649,9 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
- - {/* Category Quick-Select Pills */} -
- Kategorie: - {CATEGORIES.map((cat) => ( - - ))} -
- {/* Open Items */} + {/* Open Items Grouped by Hashtags / Categories */}
{openItems.length === 0 ? (
@@ -599,39 +659,60 @@ export const SharedListsView: React.FC = ({ onRefreshGloba Alles erledigt. Keine offenen Punkte auf dieser Liste.
) : ( - openItems.map((item) => ( -
handleToggleItem(item)} - className="px-3 py-2 bg-canvas hover:bg-panel-elevated flex items-center justify-between gap-3 cursor-pointer transition-colors group" - > -
- - - {item.content} - - {item.quantity && ( - - {item.quantity} + groupedOpenItems.map((group) => ( +
+ {/* Category Group Header (only when categories are in use) */} + {group.label && ( +
+ + {group.label} - )} - {item.category && ( - - {item.category} + + {group.items.length} {group.items.length === 1 ? 'Eintrag' : 'Einträge'} - )} -
+
+ )} - + {group.items.map((item) => { + const cat = getItemCategory(item); + const displayContent = getItemDisplayContent(item); + + return ( +
handleToggleItem(item)} + className="px-3 py-2 bg-canvas hover:bg-panel-elevated flex items-center justify-between gap-3 cursor-pointer transition-colors group" + > +
+ + + {displayContent} + + {item.quantity && ( + + {item.quantity} + + )} + {cat && ( + + #{cat} + + )} +
+ + +
+ ); + })}
)) )} @@ -653,26 +734,36 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
- {completedItems.map((item) => ( -
handleToggleItem(item)} - className="px-3 py-1.5 bg-canvas/50 hover:bg-canvas/80 flex items-center justify-between gap-3 cursor-pointer transition-colors" - > -
- - - {item.content} - - {item.quantity && ( - ({item.quantity}) + {completedItems.map((item) => { + const cat = getItemCategory(item); + const displayContent = getItemDisplayContent(item); + + return ( +
handleToggleItem(item)} + className="px-3 py-1.5 bg-canvas/50 hover:bg-canvas/80 flex items-center justify-between gap-3 cursor-pointer transition-colors" + > +
+ + + {displayContent} + + {item.quantity && ( + ({item.quantity}) + )} + {cat && ( + + #{cat} + + )} +
+ {item.completedByName && ( + ✓ {item.completedByName} )}
- {item.completedByName && ( - ✓ {item.completedByName} - )} -
- ))} + ); + })}
)} diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 26ba589..5299047 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "strings" "sync" "time" @@ -1887,12 +1888,28 @@ func (s *Store) CreateListItem(listID, content, category, quantity string, sortO if content == "" { return nil, errors.New("item content cannot be empty") } + + category = strings.TrimSpace(category) + // Auto-extract hashtag if category is empty + if category == "" && strings.Contains(content, "#") { + re := regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`) + if m := re.FindStringSubmatch(content); len(m) > 1 { + category = m[1] + cleaned := strings.TrimSpace(re.ReplaceAllString(content, "")) + if cleaned != "" { + content = cleaned + } + } + } else if category != "" { + category = strings.TrimPrefix(category, "#") + } + now := domain.NowISO() it := &domain.ListItem{ ID: domain.NewID("itm"), ListID: listID, Content: content, - Category: strings.TrimSpace(category), + Category: category, Quantity: strings.TrimSpace(quantity), IsCompleted: false, SortOrder: sortOrder,