Files
MindSift/frontend/src/features/lists/SharedListsView.tsx
T
sandrodach 7c47f245e2
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
feat(sharing): remove code redeem, unify Teilen button and team/user sharing across lists and notes
2026-10-25 13:51:42 +01:00

948 lines
36 KiB
TypeScript

import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Plus,
CheckCircle2,
Circle,
Trash2,
Users,
Check,
Radio,
Sparkles,
Share2,
User as UserIcon,
X,
} from 'lucide-react';
import { ListItem, SharedList, Space, Team, User, WSMessage } from '../../lib/types';
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
interface SharedListsViewProps {
onRefreshGlobal: () => void;
currentUser?: User | null;
}
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[]>([]);
const [activeSpace, setActiveSpace] = useState<Space | null>(null);
const [lists, setLists] = useState<SharedList[]>([]);
const [activeListId, setActiveListId] = useState<string>('');
const [newItemText, setNewItemText] = useState('');
const [newItemQty, setNewItemQty] = useState('');
const [wsConnected, setWsConnected] = useState(false);
const [showNewListModal, setShowNewListModal] = useState(false);
const [newListTitle, setNewListTitle] = useState('');
const [newListIcon, setNewListIcon] = useState('🛒');
const [showNewSpaceModal, setShowNewSpaceModal] = useState(false);
const [newSpaceName, setNewSpaceName] = useState('');
const [showShareModal, setShowShareModal] = useState(false);
const [collaborators, setCollaborators] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
const [selectedTeamId, setSelectedTeamId] = useState('');
const [inviteTarget, setInviteTarget] = useState('');
const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor');
const [invitingLoading, setInvitingLoading] = useState(false);
const [shareError, setShareError] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const loadSpaces = async () => {
const sp = await api.getSpaces();
setSpaces(sp);
if (sp.length > 0) {
if (!activeSpace) {
setActiveSpace(sp[0]);
} else {
const found = sp.find((s) => s.id === activeSpace.id);
if (found) setActiveSpace(found);
}
}
};
const loadListsForSpace = async (spaceId: string) => {
const lst = await api.getSpaceLists(spaceId);
setLists(lst);
if (lst.length > 0 && (!activeListId || !lst.some((l) => l.id === activeListId))) {
setActiveListId(lst[0].id);
}
};
useEffect(() => {
loadSpaces();
}, []);
useEffect(() => {
if (!activeSpace) return;
loadListsForSpace(activeSpace.id);
// Connect Realtime WebSocket for this Space
if (wsRef.current) {
wsRef.current.close();
}
let ws: WebSocket | null = null;
try {
ws = new WebSocket(buildWebSocketURL(activeSpace.id));
wsRef.current = ws;
ws.onopen = () => {
setWsConnected(true);
};
ws.onclose = () => {
setWsConnected(false);
};
ws.onmessage = (evt) => {
try {
const msg: WSMessage = JSON.parse(evt.data);
if (msg.event === 'ITEM_CREATED' && msg.list_id) {
const newItem: ListItem = msg.payload;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msg.list_id) return l;
if (l.items.some((i) => i.id === newItem.id)) return l;
return { ...l, items: [newItem, ...l.items] };
})
);
} else if (msg.event === 'ITEM_TOGGLED' && msg.list_id) {
const updatedItem: ListItem = msg.payload;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msg.list_id) return l;
return {
...l,
items: l.items.map((i) => (i.id === updatedItem.id ? updatedItem : i)),
};
})
);
} else if (msg.event === 'ITEM_DELETED' && msg.list_id) {
const deletedId = msg.payload?.itemId;
setLists((prev) =>
prev.map((l) => {
if (l.id !== msg.list_id) return l;
return { ...l, items: l.items.filter((i) => i.id !== deletedId) };
})
);
} else if (msg.event === 'LIST_CLEARED' && msg.list_id) {
const updatedList: SharedList = msg.payload;
setLists((prev) => prev.map((l) => (l.id === msg.list_id ? updatedList : l)));
} else if (msg.event === 'LIST_CREATED' || msg.event === 'LIST_DELETED') {
loadListsForSpace(activeSpace.id);
} else if (msg.event === 'MEMBER_ADDED' || msg.event === 'MEMBER_REMOVED') {
loadSpaces();
}
} catch {
// ignore malformed frames
}
};
} catch {
setWsConnected(false);
}
return () => {
if (ws) ws.close();
};
}, [activeSpace?.id]);
useEffect(() => {
const handleGlobalWs = (e: any) => {
const msg = e.detail;
if (!msg) return;
if (
msg.event === 'SPACE_JOINED' ||
msg.event === 'SPACE_LEFT' ||
msg.event === 'MEMBER_ADDED' ||
msg.event === 'MEMBER_REMOVED'
) {
loadSpaces();
}
};
window.addEventListener('mindsift:ws', handleGlobalWs);
return () => window.removeEventListener('mindsift:ws', handleGlobalWs);
}, []);
const activeList = lists.find((l) => l.id === activeListId) || lists[0] || null;
const handleAddItem = async (e: React.FormEvent) => {
e.preventDefault();
if (!activeList || !newItemText.trim()) return;
triggerHaptic(15);
const parsed = parseListItemInput(newItemText, newItemQty);
setNewItemText('');
setNewItemQty('');
try {
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))
);
onRefreshGlobal();
} catch (err: any) {
alert(err.message || 'Fehler beim Erstellen des Listeneintrags');
}
};
const handleToggleItem = async (item: ListItem) => {
if (!activeList) return;
triggerHaptic(10);
const nextCompleted = !item.isCompleted;
setLists((prev) =>
prev.map((l) => {
if (l.id !== activeList.id) return l;
return {
...l,
items: l.items.map((i) => (i.id === item.id ? { ...i, isCompleted: nextCompleted } : i)),
};
})
);
await api.toggleListItem(activeList.id, item.id, nextCompleted);
onRefreshGlobal();
};
const handleDeleteItem = async (itemId: string) => {
if (!activeList) return;
triggerHaptic(15);
setLists((prev) =>
prev.map((l) =>
l.id === activeList.id ? { ...l, items: l.items.filter((i) => i.id !== itemId) } : l
)
);
await api.deleteListItem(activeList.id, itemId);
};
const handleClearCompleted = async () => {
if (!activeList) return;
triggerHaptic(20);
await api.clearCompletedItems(activeList.id);
await loadListsForSpace(activeSpace!.id);
};
const handleCreateList = async (e: React.FormEvent) => {
e.preventDefault();
if (!activeSpace || !newListTitle.trim()) return;
const created = await api.createList(activeSpace.id, newListTitle.trim(), newListIcon, 'shopping');
setNewListTitle('');
setShowNewListModal(false);
await loadListsForSpace(activeSpace.id);
setActiveListId(created.id);
onRefreshGlobal();
};
const handleCreateSpace = async (e: React.FormEvent) => {
e.preventDefault();
if (!newSpaceName.trim()) return;
const created = await api.createSpace(newSpaceName.trim(), '🏡', 'Geteilter Familien- & Team-Bereich');
setNewSpaceName('');
setShowNewSpaceModal(false);
const sp = await api.getSpaces();
setSpaces(sp);
setActiveSpace(created);
};
const handleAddShare = async (e: React.FormEvent) => {
e.preventDefault();
if (!activeSpace) return;
setInvitingLoading(true);
setShareError(null);
try {
if (addShareType === 'team') {
if (!selectedTeamId) {
setShareError('Bitte ein Team oder eine Gruppe auswählen');
setInvitingLoading(false);
return;
}
await api.addSpaceMember(activeSpace.id, {
teamId: selectedTeamId,
role: inviteRole,
});
setSelectedTeamId('');
} else {
const target = inviteTarget.trim();
if (!target) {
setShareError('Bitte einen Benutzer auswählen oder eingeben');
setInvitingLoading(false);
return;
}
const isId = collaborators.some((u) => u.id === target);
await api.addSpaceMember(activeSpace.id, {
userId: isId ? target : undefined,
email: !isId ? target : undefined,
role: inviteRole,
});
setInviteTarget('');
}
const sp = await api.getSpaces();
setSpaces(sp);
const updated = sp.find((s) => s.id === activeSpace.id);
if (updated) setActiveSpace(updated);
onRefreshGlobal();
} catch (err: any) {
setShareError(err.message || 'Freigabe konnte nicht hinzugefügt werden');
} finally {
setInvitingLoading(false);
}
};
const handleRemoveMember = async (targetUserId: string) => {
if (!activeSpace) return;
if (!window.confirm('Dieses Mitglied wirklich aus der Einkaufsliste / dem Bereich entfernen?')) return;
setInvitingLoading(true);
setShareError(null);
try {
await api.removeSpaceMember(activeSpace.id, targetUserId);
setActiveSpace((prev) =>
prev
? {
...prev,
members: (prev.members || []).filter((m) => m.userId !== targetUserId),
}
: prev
);
setSpaces((prev) =>
prev.map((s) =>
s.id === activeSpace.id
? {
...s,
members: (s.members || []).filter((m) => m.userId !== targetUserId),
}
: s
)
);
onRefreshGlobal();
} catch (err: any) {
setShareError(err.message || 'Mitglied konnte nicht entfernt werden');
} finally {
setInvitingLoading(false);
}
};
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 */}
<div className="px-3 py-2 panel-surface flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-1.5">
{spaces.map((sp) => (
<button
key={sp.id}
onClick={() => setActiveSpace(sp)}
className={`h-8 px-3 text-xs sm:text-sm font-medium flex items-center gap-1.5 transition-colors border rounded-btn ${
activeSpace?.id === sp.id
? 'bg-accent text-on-accent border-accent'
: 'bg-canvas text-muted hover:text-main border-border'
}`}
>
<span>{sp.icon}</span>
<span>{sp.name}</span>
</button>
))}
<button
onClick={() => setShowNewSpaceModal((prev) => !prev)}
className="btn-action h-8"
>
<Plus className="w-3.5 h-3.5 text-accent" /> Space
</button>
</div>
{/* Realtime Status & Share List Button */}
{activeSpace && (
<div className="flex items-center gap-2">
<div
className={`h-8 w-8 flex items-center justify-center rounded-btn ${
wsConnected
? 'badge-ok'
: 'badge-neutral'
}`}
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline'}
>
<Radio className={`w-4 h-4 ${wsConnected ? 'animate-pulse text-status-ok' : 'text-muted'}`} />
</div>
<button
onClick={() => {
setShowShareModal(true);
setShareError(null);
Promise.all([
api.getUsersDirectory().then(setCollaborators).catch(() => []),
api.getUserTeams().then(setTeams).catch(() => []),
]);
}}
title="Einkaufsliste teilen & verwalten"
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
>
<Share2 className="w-3.5 h-3.5 text-accent" />
<span>Teilen</span>
{activeSpace.members && activeSpace.members.length > 1 && (
<span className="badge-neutral font-mono text-[10px]">
{activeSpace.members.length}
</span>
)}
</button>
</div>
)}
</div>
{/* Create Space Modal */}
{showNewSpaceModal && (
<form
onSubmit={handleCreateSpace}
className="p-2 panel-surface flex items-center gap-2"
>
<input
type="text"
value={newSpaceName}
onChange={(e) => setNewSpaceName(e.target.value)}
placeholder="Name des neuen Familien-/Team-Spaces (z. B. Urlaub 2026)…"
className="flex-1 h-9 px-3 input-field text-sm"
autoFocus
/>
<button
type="submit"
className="btn-primary h-9 px-4 text-xs sm:text-sm"
>
Space anlegen
</button>
</form>
)}
{/* List Tabs within Active Space */}
<div className="flex flex-wrap items-center gap-1.5">
{lists.map((lst) => {
const openCount = lst.items.filter((i) => !i.isCompleted).length;
const isSelected = activeList?.id === lst.id;
return (
<button
key={lst.id}
onClick={() => setActiveListId(lst.id)}
className={`h-8 px-3 text-xs sm:text-sm font-medium flex items-center gap-1.5 transition-all border rounded-btn ${
isSelected
? 'bg-accent text-on-accent border-accent'
: 'bg-panel text-muted hover:text-main border-border'
}`}
>
<span>{lst.icon}</span>
<span>{lst.title}</span>
<span className={`px-1.5 py-0.2 text-[10px] font-semibold rounded-pill ${
isSelected ? 'bg-white/20 text-on-accent' : 'badge-neutral'
}`}>
{openCount}
</span>
</button>
);
})}
<button
onClick={() => setShowNewListModal((prev) => !prev)}
className="btn-action h-8"
>
<Plus className="w-3.5 h-3.5 text-accent" />
<span>Neue Liste</span>
</button>
</div>
{showNewListModal && (
<form
onSubmit={handleCreateList}
className="p-2 panel-surface flex flex-wrap items-center gap-2"
>
<div className="flex items-center gap-1.5">
{['🛒', '🏔️', '🍕', '🎉', '🧰', '🎁'].map((ic) => (
<button
key={ic}
type="button"
onClick={() => setNewListIcon(ic)}
className={`w-9 h-9 flex items-center justify-center rounded-btn transition-colors ${
newListIcon === ic
? 'bg-accent/20 border border-accent text-accent'
: 'bg-canvas hover:bg-panel-elevated border border-border'
}`}
>
{ic}
</button>
))}
</div>
<input
type="text"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="Titel der Liste (z. B. Baumarkt, Drogerie, Camping)…"
className="flex-1 h-9 px-3 input-field text-sm"
autoFocus
/>
<button
type="submit"
className="btn-primary h-9 px-4 text-xs sm:text-sm"
>
Liste erstellen
</button>
</form>
)}
{/* Active Shared List Card */}
{activeList ? (
<div className="p-3 panel-surface">
{/* 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 #Kühlregal oder #Kaffee)…`}
className="flex-1 h-9 px-3 input-field text-sm"
/>
<div className="flex gap-2">
<input
type="text"
value={newItemQty}
onChange={(e) => setNewItemQty(e.target.value)}
placeholder="Menge (z.B. 2x)"
className="w-32 h-9 px-3 input-field text-sm"
/>
<button
type="submit"
disabled={!newItemText.trim()}
className="btn-primary h-9 px-4 disabled:opacity-40 disabled:cursor-not-allowed text-xs sm:text-sm flex items-center gap-1.5"
>
<Plus className="w-4 h-4" />
<span>Hinzufügen</span>
</button>
</div>
</div>
</form>
{/* 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">
<Sparkles className="w-4 h-4 text-accent" />
<span>Alles erledigt. Keine offenen Punkte auf dieser Liste.</span>
</div>
) : (
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)}
className="px-3 py-2 bg-canvas hover:bg-panel-elevated flex items-center justify-between gap-3 cursor-pointer transition-colors group"
>
<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">
{displayContent}
</span>
{item.quantity && (
<span className="badge-accent">
{item.quantity}
</span>
)}
{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>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeleteItem(item.id);
}}
className="btn-icon btn-icon-danger w-6 h-6"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
);
})}
</div>
))
)}
</div>
{/* Completed Items Section */}
{completedItems.length > 0 && (
<div className="mt-3 pt-2.5 border-t border-border">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium uppercase tracking-wider text-muted">
Erledigt ({completedItems.length})
</span>
<button
onClick={handleClearCompleted}
className="btn-action text-xs"
>
Erledigte aufräumen
</button>
</div>
<div className="border border-border divide-y divide-border opacity-75 rounded-card overflow-hidden">
{completedItems.map((item) => {
const cat = getItemCategory(item);
const displayContent = getItemDisplayContent(item);
return (
<div
key={item.id}
onClick={() => 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"
>
<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">
{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>
)}
</div>
) : null}
{/* Space & Shopping List Sharing Modal */}
{showShareModal && activeSpace && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs">
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4">
{/* Modal Header */}
<div className="flex items-center justify-between pb-2 border-b border-border">
<div className="flex items-center gap-2">
<Share2 className="w-5 h-5 text-accent" />
<h3 className="text-base font-bold text-main">
Einkaufsliste teilen: {activeSpace.name}
</h3>
</div>
<button
type="button"
onClick={() => {
setShowShareModal(false);
setShareError(null);
}}
className="btn-icon"
title="Schließen"
>
<X className="w-4 h-4" />
</button>
</div>
{shareError && (
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
{shareError}
</div>
)}
{/* Sharing Form (Team / Gruppe vs. Einzelner Benutzer) */}
<form onSubmit={handleAddShare} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => {
setAddShareType('team');
setInviteTarget('');
}}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
addShareType === 'team'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Users className="w-3 h-3 text-accent" />
<span>Team / Gruppe</span>
</button>
<button
type="button"
onClick={() => {
setAddShareType('user');
setSelectedTeamId('');
}}
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
addShareType === 'user'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<UserIcon className="w-3 h-3 text-accent" />
<span>Einzelner Benutzer</span>
</button>
</div>
</div>
{addShareType === 'team' ? (
<div className="flex flex-col sm:flex-row gap-2">
<select
value={selectedTeamId}
onChange={(e) => setSelectedTeamId(e.target.value)}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs flex-1 cursor-pointer"
>
<option value="" className="bg-panel text-muted">-- Team / Gruppe auswählen --</option>
{teams.map((t) => (
<option key={t.id} value={t.id} className="bg-panel text-main">
👥 {t.name} ({t.members?.length || 1} Mitglieder)
</option>
))}
</select>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as 'editor' | 'viewer')}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full sm:w-36 cursor-pointer"
>
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
</select>
</div>
) : (
<div className="space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<select
value={inviteTarget}
onChange={(e) => setInviteTarget(e.target.value)}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs flex-1 cursor-pointer"
>
<option value="" className="bg-panel text-muted">Benutzer auswählen oder eingeben…</option>
{collaborators
.filter(
(u) =>
u.id !== currentUser?.id &&
!(activeSpace.members || []).some((m) => m.userId === u.id)
)
.map((u) => (
<option key={u.id} value={u.id} className="bg-panel text-main">
👤 {u.displayName || u.username || u.email} ({u.username ? `@${u.username}` : u.email})
</option>
))}
</select>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as 'editor' | 'viewer')}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full sm:w-36 cursor-pointer"
>
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
</select>
</div>
{(!inviteTarget || !collaborators.some((u) => u.id === inviteTarget)) && (
<input
type="text"
placeholder="Oder Benutzername / E-Mail direkt eingeben…"
value={inviteTarget}
onChange={(e) => setInviteTarget(e.target.value)}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full"
/>
)}
</div>
)}
<div className="flex justify-end pt-1">
<button
type="submit"
disabled={
invitingLoading ||
(addShareType === 'team' ? !selectedTeamId : !inviteTarget.trim())
}
className="btn-primary text-xs h-8 px-4 flex items-center gap-1.5"
>
<Plus className="w-3.5 h-3.5" />
<span>
{invitingLoading
? 'Wird geteilt…'
: addShareType === 'team'
? 'Team freigeben'
: 'Benutzer einladen'}
</span>
</button>
</div>
</form>
{/* Current Space Members List */}
<div className="space-y-2">
<div className="text-xs font-semibold text-muted uppercase tracking-wider">
Aktive Freigaben & Mitglieder ({activeSpace.members?.length || 1})
</div>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{(activeSpace.members || []).map((m) => {
const isOwner = m.role === 'owner' || m.userId === activeSpace.ownerId;
const isMe = m.userId === currentUser?.id;
const canRemove =
(activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin') &&
!isOwner;
return (
<div
key={m.userId}
className="p-2.5 bg-canvas border border-border rounded-btn flex items-center justify-between gap-3 text-xs"
>
<div className="flex items-center gap-2 min-w-0">
<div className="w-7 h-7 rounded-full bg-surface-2 flex items-center justify-center text-xs font-bold text-accent shrink-0">
{(m.displayName || m.email || '?')[0].toUpperCase()}
</div>
<div className="truncate">
<div className="font-medium text-main truncate">
{m.displayName || m.email} {isMe && <span className="text-muted">(Du)</span>}
</div>
<div className="text-[11px] text-muted truncate">{m.email}</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span
className={`text-[10px] px-2 py-0.5 rounded font-semibold ${
isOwner
? 'bg-accent/15 text-accent'
: 'bg-surface-2 text-muted'
}`}
>
{isOwner ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Leser'}
</span>
{canRemove && (
<button
type="button"
onClick={() => handleRemoveMember(m.userId)}
disabled={invitingLoading}
className="btn-icon text-muted hover:text-status-danger p-1"
title="Freigabe entfernen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
)}
</div>
);
};