From c0661cddaebf60533c52f6c1647a561f006cc9d3 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 18:37:11 +0100 Subject: [PATCH] feat(lists): case-insensitive alphabetical hashtag sorting and autocomplete --- .../src/features/lists/SharedListsView.tsx | 231 +++++++++++++++++- 1 file changed, 219 insertions(+), 12 deletions(-) diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 473049f..403189d 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -8,6 +8,7 @@ import { Sparkles, Share, X, + Hash, } from 'lucide-react'; import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types'; import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api'; @@ -79,6 +80,12 @@ export const SharedListsView: React.FC = ({ onRefreshGloba const [collaborators, setCollaborators] = useState([]); const [teams, setTeams] = useState([]); + const [cursorPosition, setCursorPosition] = useState(null); + const [suggestionIndex, setSuggestionIndex] = useState(0); + const [showSuggestions, setShowSuggestions] = useState(false); + const [workspaceTags, setWorkspaceTags] = useState([]); + const inputRef = useRef(null); + const wsRef = useRef(null); const loadLists = async () => { @@ -103,6 +110,12 @@ export const SharedListsView: React.FC = ({ onRefreshGloba useEffect(() => { loadLists(); + api + .getWorkspaceTags() + .then((tags) => { + setWorkspaceTags(tags.map((t) => t.tag.replace(/^#/, ''))); + }) + .catch(() => {}); }, []); const activeList = useMemo(() => { @@ -211,9 +224,11 @@ export const SharedListsView: React.FC = ({ onRefreshGloba if (!activeList || !newItemText.trim()) return; 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); @@ -306,11 +321,139 @@ export const SharedListsView: React.FC = ({ onRefreshGloba } }; + const allAvailableHashtags = useMemo(() => { + const tagMap = new Map(); + + // 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) => { + 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 openItems = activeList?.items.filter((i) => !i.isCompleted) || []; const completedItems = activeList?.items.filter((i) => i.isCompleted) || []; const groupedOpenItems = useMemo(() => { const groupsMap = new Map(); + const canonicalMap = new Map(); const uncategorized: ListItem[] = []; for (const item of openItems) { @@ -318,23 +461,35 @@ export const SharedListsView: React.FC = ({ onRefreshGloba if (!cat) { uncategorized.push(item); } else { - const list = groupsMap.get(cat) || []; + const key = cat.toLowerCase(); + const list = groupsMap.get(key) || []; list.push(item); - groupsMap.set(cat, list); + 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 sortedCategories = Array.from(groupsMap.keys()).sort((a, b) => - a.localeCompare(b, 'de', { sensitivity: 'base' }) - ); + 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 cat of sortedCategories) { + for (const key of sortedKeys) { + const displayLabel = canonicalMap.get(key) || key; result.push({ - category: cat, - label: `#${cat}`, - items: groupsMap.get(cat)!, + category: key, + label: `#${displayLabel}`, + items: groupsMap.get(key)!, }); } @@ -501,17 +656,69 @@ export const SharedListsView: React.FC = ({ onRefreshGloba {/* Quick-Add Form */}
-
+
setNewItemText(e.target.value)} + 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 && ( +
+
+ Hashtags +
+ {matchingSuggestions.map((tag, idx) => { + const isSelected = idx === suggestionIndex; + return ( + + ); + })} +
+ )}