Dev #2
No files matched your search
@@ -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<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => {
|
||||
const [spaces, setSpaces] = useState<Space[]>([]);
|
||||
@@ -30,7 +73,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ 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<SharedListsViewProps> = ({ 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<SharedListsViewProps> = ({ onRefreshGloba
|
||||
const openItems = activeList?.items.filter((i) => !i.isCompleted) || [];
|
||||
const completedItems = activeList?.items.filter((i) => i.isCompleted) || [];
|
||||
|
||||
const groupedOpenItems = useMemo(() => {
|
||||
const groupsMap = new Map<string, ListItem[]>();
|
||||
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 (
|
||||
<div className="w-full space-y-3">
|
||||
{/* Top Space Bar & Realtime Indicator */}
|
||||
@@ -542,14 +621,14 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
{/* Active Shared List Card */}
|
||||
{activeList ? (
|
||||
<div className="p-3 panel-surface">
|
||||
{/* Quick-Add Pill Form */}
|
||||
{/* Quick-Add Form */}
|
||||
<form onSubmit={handleAddItem} className="space-y-2 mb-3">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newItemText}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
@@ -570,28 +649,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Quick-Select Pills */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs text-muted mr-1">Kategorie:</span>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => setNewItemCategory((prev) => (prev === cat ? '' : cat))}
|
||||
className={`px-2.5 py-0.5 text-xs transition-colors border rounded-pill ${
|
||||
newItemCategory === cat
|
||||
? 'bg-accent text-on-accent border-accent font-medium'
|
||||
: 'bg-canvas text-muted hover:text-main border-border'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Open Items */}
|
||||
{/* Open Items Grouped by Hashtags / Categories */}
|
||||
<div className="border border-border divide-y divide-border rounded-card overflow-hidden">
|
||||
{openItems.length === 0 ? (
|
||||
<div className="py-6 text-center text-muted text-sm flex flex-col items-center gap-1.5">
|
||||
@@ -599,7 +659,25 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<span>Alles erledigt. Keine offenen Punkte auf dieser Liste.</span>
|
||||
</div>
|
||||
) : (
|
||||
openItems.map((item) => (
|
||||
groupedOpenItems.map((group) => (
|
||||
<div key={group.category || 'uncategorized'} className="divide-y divide-border">
|
||||
{/* Category Group Header (only when categories are in use) */}
|
||||
{group.label && (
|
||||
<div className="px-3 py-1.5 bg-surface-2/70 text-xs font-semibold text-accent flex items-center justify-between">
|
||||
<span className="flex items-center gap-1">
|
||||
<span>{group.label}</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-muted font-normal">
|
||||
{group.items.length} {group.items.length === 1 ? 'Eintrag' : 'Einträge'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group.items.map((item) => {
|
||||
const cat = getItemCategory(item);
|
||||
const displayContent = getItemDisplayContent(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleToggleItem(item)}
|
||||
@@ -608,16 +686,16 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<Circle className="w-4 h-4 text-faint group-hover:text-accent flex-shrink-0 transition-colors" />
|
||||
<span className="text-sm font-normal text-main truncate">
|
||||
{item.content}
|
||||
{displayContent}
|
||||
</span>
|
||||
{item.quantity && (
|
||||
<span className="badge-accent">
|
||||
{item.quantity}
|
||||
</span>
|
||||
)}
|
||||
{item.category && (
|
||||
<span className="hidden sm:inline px-2 py-0.2 badge-neutral text-xs">
|
||||
{item.category}
|
||||
{cat && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium text-accent bg-accent/10 border border-accent/25 rounded-full">
|
||||
#{cat}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -633,6 +711,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -653,7 +734,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
</div>
|
||||
|
||||
<div className="border border-border divide-y divide-border opacity-75 rounded-card overflow-hidden">
|
||||
{completedItems.map((item) => (
|
||||
{completedItems.map((item) => {
|
||||
const cat = getItemCategory(item);
|
||||
const displayContent = getItemDisplayContent(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleToggleItem(item)}
|
||||
@@ -662,17 +747,23 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<CheckCircle2 className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<span className="text-sm text-muted line-through truncate">
|
||||
{item.content}
|
||||
{displayContent}
|
||||
</span>
|
||||
{item.quantity && (
|
||||
<span className="text-xs text-faint">({item.quantity})</span>
|
||||
)}
|
||||
{cat && (
|
||||
<span className="text-[11px] px-1.5 py-0.2 badge-neutral text-muted rounded-full">
|
||||
#{cat}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.completedByName && (
|
||||
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user