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 {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -20,7 +20,50 @@ interface SharedListsViewProps {
|
|||||||
currentUser?: User | null;
|
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 }) => {
|
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => {
|
||||||
const [spaces, setSpaces] = useState<Space[]>([]);
|
const [spaces, setSpaces] = useState<Space[]>([]);
|
||||||
@@ -30,7 +73,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
|
|
||||||
const [newItemText, setNewItemText] = useState('');
|
const [newItemText, setNewItemText] = useState('');
|
||||||
const [newItemQty, setNewItemQty] = useState('');
|
const [newItemQty, setNewItemQty] = useState('');
|
||||||
const [newItemCategory, setNewItemCategory] = useState('');
|
|
||||||
|
|
||||||
const [wsConnected, setWsConnected] = useState(false);
|
const [wsConnected, setWsConnected] = useState(false);
|
||||||
const [copiedInvite, setCopiedInvite] = useState(false);
|
const [copiedInvite, setCopiedInvite] = useState(false);
|
||||||
@@ -176,15 +218,12 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
if (!activeList || !newItemText.trim()) return;
|
if (!activeList || !newItemText.trim()) return;
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
|
|
||||||
const text = newItemText.trim();
|
const parsed = parseListItemInput(newItemText, newItemQty);
|
||||||
const qty = newItemQty.trim();
|
|
||||||
const cat = newItemCategory || undefined;
|
|
||||||
|
|
||||||
setNewItemText('');
|
setNewItemText('');
|
||||||
setNewItemQty('');
|
setNewItemQty('');
|
||||||
|
|
||||||
try {
|
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) =>
|
setLists((prev) =>
|
||||||
prev.map((l) => (l.id === activeList.id ? { ...l, items: [item, ...l.items] } : l))
|
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 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 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 (
|
return (
|
||||||
<div className="w-full space-y-3">
|
<div className="w-full space-y-3">
|
||||||
{/* Top Space Bar & Realtime Indicator */}
|
{/* Top Space Bar & Realtime Indicator */}
|
||||||
@@ -542,14 +621,14 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
{/* Active Shared List Card */}
|
{/* Active Shared List Card */}
|
||||||
{activeList ? (
|
{activeList ? (
|
||||||
<div className="p-3 panel-surface">
|
<div className="p-3 panel-surface">
|
||||||
{/* Quick-Add Pill Form */}
|
{/* Quick-Add Form */}
|
||||||
<form onSubmit={handleAddItem} className="space-y-2 mb-3">
|
<form onSubmit={handleAddItem} className="space-y-2 mb-3">
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newItemText}
|
value={newItemText}
|
||||||
onChange={(e) => setNewItemText(e.target.value)}
|
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"
|
className="flex-1 h-9 px-3 input-field text-sm"
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -570,28 +649,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</form>
|
||||||
|
|
||||||
{/* Open Items */}
|
{/* Open Items Grouped by Hashtags / Categories */}
|
||||||
<div className="border border-border divide-y divide-border rounded-card overflow-hidden">
|
<div className="border border-border divide-y divide-border rounded-card overflow-hidden">
|
||||||
{openItems.length === 0 ? (
|
{openItems.length === 0 ? (
|
||||||
<div className="py-6 text-center text-muted text-sm flex flex-col items-center gap-1.5">
|
<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>
|
<span>Alles erledigt. Keine offenen Punkte auf dieser Liste.</span>
|
||||||
</div>
|
</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
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => handleToggleItem(item)}
|
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">
|
<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" />
|
<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">
|
<span className="text-sm font-normal text-main truncate">
|
||||||
{item.content}
|
{displayContent}
|
||||||
</span>
|
</span>
|
||||||
{item.quantity && (
|
{item.quantity && (
|
||||||
<span className="badge-accent">
|
<span className="badge-accent">
|
||||||
{item.quantity}
|
{item.quantity}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{item.category && (
|
{cat && (
|
||||||
<span className="hidden sm:inline px-2 py-0.2 badge-neutral text-xs">
|
<span className="px-2 py-0.5 text-xs font-medium text-accent bg-accent/10 border border-accent/25 rounded-full">
|
||||||
{item.category}
|
#{cat}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -633,6 +711,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -653,7 +734,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border border-border divide-y divide-border opacity-75 rounded-card overflow-hidden">
|
<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
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => handleToggleItem(item)}
|
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">
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
<CheckCircle2 className="w-4 h-4 text-accent flex-shrink-0" />
|
<CheckCircle2 className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
<span className="text-sm text-muted line-through truncate">
|
<span className="text-sm text-muted line-through truncate">
|
||||||
{item.content}
|
{displayContent}
|
||||||
</span>
|
</span>
|
||||||
{item.quantity && (
|
{item.quantity && (
|
||||||
<span className="text-xs text-faint">({item.quantity})</span>
|
<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>
|
</div>
|
||||||
{item.completedByName && (
|
{item.completedByName && (
|
||||||
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -1887,12 +1888,28 @@ func (s *Store) CreateListItem(listID, content, category, quantity string, sortO
|
|||||||
if content == "" {
|
if content == "" {
|
||||||
return nil, errors.New("item content cannot be empty")
|
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()
|
now := domain.NowISO()
|
||||||
it := &domain.ListItem{
|
it := &domain.ListItem{
|
||||||
ID: domain.NewID("itm"),
|
ID: domain.NewID("itm"),
|
||||||
ListID: listID,
|
ListID: listID,
|
||||||
Content: content,
|
Content: content,
|
||||||
Category: strings.TrimSpace(category),
|
Category: category,
|
||||||
Quantity: strings.TrimSpace(quantity),
|
Quantity: strings.TrimSpace(quantity),
|
||||||
IsCompleted: false,
|
IsCompleted: false,
|
||||||
SortOrder: sortOrder,
|
SortOrder: sortOrder,
|
||||||
|
|||||||
Reference in new issue
Block a user