Dev #4
@@ -8,6 +8,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
Share,
|
Share,
|
||||||
X,
|
X,
|
||||||
|
Hash,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
||||||
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
|
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
|
||||||
@@ -79,6 +80,12 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
const [collaborators, setCollaborators] = useState<User[]>([]);
|
const [collaborators, setCollaborators] = useState<User[]>([]);
|
||||||
const [teams, setTeams] = useState<Team[]>([]);
|
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 wsRef = useRef<WebSocket | null>(null);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
const loadLists = async () => {
|
const loadLists = async () => {
|
||||||
@@ -103,6 +110,12 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLists();
|
loadLists();
|
||||||
|
api
|
||||||
|
.getWorkspaceTags()
|
||||||
|
.then((tags) => {
|
||||||
|
setWorkspaceTags(tags.map((t) => t.tag.replace(/^#/, '')));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const activeList = useMemo(() => {
|
const activeList = useMemo(() => {
|
||||||
@@ -211,9 +224,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
if (!activeList || !newItemText.trim()) return;
|
if (!activeList || !newItemText.trim()) return;
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
|
|
||||||
|
setShowSuggestions(false);
|
||||||
const parsed = parseListItemInput(newItemText, newItemQty);
|
const parsed = parseListItemInput(newItemText, newItemQty);
|
||||||
setNewItemText('');
|
setNewItemText('');
|
||||||
setNewItemQty('');
|
setNewItemQty('');
|
||||||
|
setCursorPosition(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = await api.createListItem(activeList.id, parsed.content, parsed.category, parsed.quantity);
|
const item = await api.createListItem(activeList.id, parsed.content, parsed.category, parsed.quantity);
|
||||||
@@ -306,11 +321,139 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 openItems = activeList?.items.filter((i) => !i.isCompleted) || [];
|
const openItems = activeList?.items.filter((i) => !i.isCompleted) || [];
|
||||||
const completedItems = activeList?.items.filter((i) => i.isCompleted) || [];
|
const completedItems = activeList?.items.filter((i) => i.isCompleted) || [];
|
||||||
|
|
||||||
const groupedOpenItems = useMemo(() => {
|
const groupedOpenItems = useMemo(() => {
|
||||||
const groupsMap = new Map<string, ListItem[]>();
|
const groupsMap = new Map<string, ListItem[]>();
|
||||||
|
const canonicalMap = new Map<string, string>();
|
||||||
const uncategorized: ListItem[] = [];
|
const uncategorized: ListItem[] = [];
|
||||||
|
|
||||||
for (const item of openItems) {
|
for (const item of openItems) {
|
||||||
@@ -318,23 +461,35 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
if (!cat) {
|
if (!cat) {
|
||||||
uncategorized.push(item);
|
uncategorized.push(item);
|
||||||
} else {
|
} else {
|
||||||
const list = groupsMap.get(cat) || [];
|
const key = cat.toLowerCase();
|
||||||
|
const list = groupsMap.get(key) || [];
|
||||||
list.push(item);
|
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) =>
|
const sortedKeys = Array.from(groupsMap.keys()).sort((a, b) => {
|
||||||
a.localeCompare(b, 'de', { sensitivity: 'base' })
|
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[] }[] = [];
|
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({
|
result.push({
|
||||||
category: cat,
|
category: key,
|
||||||
label: `#${cat}`,
|
label: `#${displayLabel}`,
|
||||||
items: groupsMap.get(cat)!,
|
items: groupsMap.get(key)!,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,17 +656,69 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
{/* Quick-Add Form */}
|
{/* Quick-Add Form */}
|
||||||
<form
|
<form
|
||||||
onSubmit={handleAddItem}
|
onSubmit={handleAddItem}
|
||||||
className="flex flex-col sm:flex-row items-center gap-2 panel-surface p-1.5"
|
className="flex flex-col sm:flex-row items-center gap-2 panel-surface p-1.5 relative"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 flex-1 w-full px-1.5">
|
<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" />
|
<Plus className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
<input
|
<input
|
||||||
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={newItemText}
|
value={newItemText}
|
||||||
onChange={(e) => 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)…`}
|
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"
|
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>
|
||||||
<div className="flex items-center gap-1.5 w-full sm:w-auto justify-end px-1 sm:px-0">
|
<div className="flex items-center gap-1.5 w-full sm:w-auto justify-end px-1 sm:px-0">
|
||||||
<input
|
<input
|
||||||
|
|||||||
Reference in new issue
Block a user