feat: unify sharing UI, redesign lists header, fix team sharing, and add user autocomplete
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 18s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 18s
This commit is contained in:
1 parent
6d07352e80
commit
354129b2d4
10 files changed
+1049
-393
No files matched your search
@@ -13,6 +13,7 @@ import {
|
||||
Edit3,
|
||||
X,
|
||||
Layers,
|
||||
Share,
|
||||
Share2,
|
||||
Users,
|
||||
CheckSquare,
|
||||
@@ -29,6 +30,7 @@ import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn } f
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { TaskDetailModal } from './TaskDetailModal';
|
||||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
|
||||
const getInitials = (name?: string): string => {
|
||||
if (!name) return '?';
|
||||
@@ -40,12 +42,14 @@ const getInitials = (name?: string): string => {
|
||||
};
|
||||
|
||||
interface TasksViewProps {
|
||||
currentUser?: User | null;
|
||||
onRefreshStats: () => void;
|
||||
onOpenDocument: (docId: string) => void;
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
export const TasksView: React.FC<TasksViewProps> = ({
|
||||
currentUser,
|
||||
onRefreshStats,
|
||||
onOpenDocument,
|
||||
refreshTrigger = 0,
|
||||
@@ -75,6 +79,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
|
||||
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
|
||||
const [selectedShareTargetId, setSelectedShareTargetId] = useState('');
|
||||
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
|
||||
const [shareLoading, setShareLoading] = useState(false);
|
||||
|
||||
// Detail modal state
|
||||
@@ -577,26 +582,47 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenShareModal = (tl: TaskList) => {
|
||||
const handleOpenShareModal = async (tl: TaskList) => {
|
||||
setSharingTaskList(tl);
|
||||
setSelectedShareTargetId('');
|
||||
setSelectedShareUser(null);
|
||||
setShowShareListModal(true);
|
||||
try {
|
||||
const [freshTeams, freshUsers, freshShares] = await Promise.all([
|
||||
api.getUserTeams().catch(() => []),
|
||||
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
|
||||
api.getTaskListShares(tl.id).catch(() => []),
|
||||
]);
|
||||
setTeams(freshTeams);
|
||||
setCollaborators(freshUsers);
|
||||
setSharingTaskList((prev) => (prev && prev.id === tl.id ? { ...prev, shares: freshShares } : prev));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddShare = async () => {
|
||||
if (!sharingTaskList || !selectedShareTargetId) return;
|
||||
if (!sharingTaskList) return;
|
||||
const targetId = addShareType === 'team' ? selectedShareTargetId : selectedShareUser?.id;
|
||||
if (!targetId) return;
|
||||
setShareLoading(true);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
await api.addTaskListShare(sharingTaskList.id, {
|
||||
targetType: addShareType,
|
||||
targetId: selectedShareTargetId,
|
||||
targetId: targetId,
|
||||
});
|
||||
const freshLists = await api.getTaskLists();
|
||||
const [freshLists, freshShares] = await Promise.all([
|
||||
api.getTaskLists(),
|
||||
api.getTaskListShares(sharingTaskList.id).catch(() => []),
|
||||
]);
|
||||
setTaskLists(freshLists);
|
||||
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
||||
if (updated) setSharingTaskList(updated);
|
||||
if (updated) {
|
||||
setSharingTaskList({ ...updated, shares: freshShares });
|
||||
}
|
||||
setSelectedShareTargetId('');
|
||||
setSelectedShareUser(null);
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Hinzufügen der Freigabe');
|
||||
@@ -607,14 +633,20 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
const handleRemoveShare = async (shareId: string) => {
|
||||
if (!sharingTaskList) return;
|
||||
if (!window.confirm('Freigabe wirklich aufheben?')) return;
|
||||
setShareLoading(true);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
await api.removeTaskListShare(sharingTaskList.id, shareId);
|
||||
const freshLists = await api.getTaskLists();
|
||||
const [freshLists, freshShares] = await Promise.all([
|
||||
api.getTaskLists(),
|
||||
api.getTaskListShares(sharingTaskList.id).catch(() => []),
|
||||
]);
|
||||
setTaskLists(freshLists);
|
||||
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
||||
if (updated) setSharingTaskList(updated);
|
||||
if (updated) {
|
||||
setSharingTaskList({ ...updated, shares: freshShares });
|
||||
}
|
||||
await loadData();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Entfernen der Freigabe');
|
||||
@@ -869,19 +901,27 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
{tl.tasksCount !== undefined && tl.tasksCount > 0 && (
|
||||
<span className="ml-1 badge-neutral">{tl.tasksCount}</span>
|
||||
)}
|
||||
{tl.members && tl.members.length > 0 ? (
|
||||
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members.length} Mitglieder)`}>
|
||||
{tl.members.slice(0, 2).map((m) => (
|
||||
<span key={m.userId} className="w-3.5 h-3.5 rounded-pill bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center">
|
||||
{getInitials(m.displayName || m.email)[0]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (tl.teamId || (tl.shares && tl.shares.length > 0)) ? (
|
||||
<span title="Geteilt mit Team oder Benutzern">
|
||||
<Share2 className="w-2.5 h-2.5 text-accent ml-0.5" />
|
||||
</span>
|
||||
) : null}
|
||||
{(() => {
|
||||
const isShared = (tl.shares && tl.shares.length > 0) || Boolean(tl.teamId) || (tl.members && tl.members.length > 1);
|
||||
if (!isShared) return null;
|
||||
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
|
||||
if (otherMembers.length > 0) {
|
||||
return (
|
||||
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
|
||||
{otherMembers.slice(0, 2).map((m) => (
|
||||
<span key={m.userId} className="w-3.5 h-3.5 rounded-pill bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center">
|
||||
{getInitials(m.displayName || m.email)[0]}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span title="Geteilt mit Team oder Benutzern">
|
||||
<Users className="w-2.5 h-2.5 text-accent ml-0.5" />
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</button>
|
||||
{selectedListId === tl.id && (
|
||||
<button
|
||||
@@ -905,55 +945,52 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Header Section: Sharing, Avatar Stack & View Switcher */}
|
||||
{/* Right Header Section: Sharing Badge, Icon-Only Button & View Switcher */}
|
||||
<div className="flex items-center gap-2">
|
||||
{currentSelectedList && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||||
className="btn-action h-8 px-2.5 flex items-center gap-1.5 text-xs"
|
||||
title={`Liste '${currentSelectedList.title}' mit Team oder Nutzern teilen`}
|
||||
>
|
||||
<Users className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Teilen</span>
|
||||
</button>
|
||||
{currentSelectedList && (() => {
|
||||
const isOwner = !currentSelectedList.ownerId || currentSelectedList.ownerId === currentUser?.id;
|
||||
const sharesCount = (currentSelectedList.shares?.length || 0) > 0
|
||||
? currentSelectedList.shares.length
|
||||
: (currentSelectedList.members?.filter((m) => m.userId !== currentSelectedList.ownerId).length || 0);
|
||||
const isShared = sharesCount > 0 || Boolean(currentSelectedList.teamId) || ((currentSelectedList.members?.length || 0) > 1);
|
||||
|
||||
{/* Avatar Stack */}
|
||||
{currentSelectedList.members && currentSelectedList.members.length > 0 ? (
|
||||
<div
|
||||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||||
className="flex items-center -space-x-1.5 cursor-pointer hover:opacity-90 transition-opacity pl-0.5"
|
||||
title={`Geteilt mit ${currentSelectedList.members.length} Mitgliedern (${currentSelectedList.teamName || 'Team'}). Klick zum Verwalten.`}
|
||||
>
|
||||
{currentSelectedList.members.slice(0, 3).map((m) => (
|
||||
<div
|
||||
key={m.userId}
|
||||
className="w-7 h-7 rounded-pill border-2 border-panel bg-accent/20 text-accent flex items-center justify-center text-[10px] font-bold shadow-sm"
|
||||
title={`${m.displayName || m.email} (${m.role})`}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isShared && (
|
||||
isOwner ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
|
||||
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
|
||||
>
|
||||
{getInitials(m.displayName || m.email)}
|
||||
</div>
|
||||
))}
|
||||
{currentSelectedList.members.length > 3 && (
|
||||
<div className="w-7 h-7 rounded-pill border-2 border-panel bg-accent text-on-accent flex items-center justify-center text-[10px] font-bold shadow-sm">
|
||||
+{currentSelectedList.members.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span>Geteilt ({sharesCount > 0 ? sharesCount : currentSelectedList.members?.length || 1})</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
|
||||
title="Teilen & Berechtigungen öffnen"
|
||||
>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span>Geteilt von {currentSelectedList.ownerName || 'Eigentümer'}</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||||
className="flex items-center cursor-pointer pl-0.5"
|
||||
title="Privat (nur du). Klick zum Teilen."
|
||||
className="btn-icon"
|
||||
title="Aufgabenliste teilen und Berechtigungen"
|
||||
>
|
||||
<div className="w-7 h-7 rounded-pill border border-dashed border-border bg-canvas text-muted hover:text-accent hover:border-accent flex items-center justify-center text-[11px] font-bold transition-colors">
|
||||
🔒
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Share className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* View Switcher: List vs Kanban */}
|
||||
<div className="flex border border-border rounded-btn bg-canvas overflow-hidden">
|
||||
@@ -1291,7 +1328,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddShareType('team');
|
||||
setSelectedShareTargetId('');
|
||||
setSelectedShareUser(null);
|
||||
}}
|
||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
|
||||
addShareType === 'team'
|
||||
@@ -1336,26 +1373,27 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
value={selectedShareTargetId}
|
||||
onChange={(e) => setSelectedShareTargetId(e.target.value)}
|
||||
className="flex-1 h-8 px-2.5 input-field text-xs bg-panel"
|
||||
>
|
||||
<option value="">-- Benutzer auswählen --</option>
|
||||
{safeCollaborators
|
||||
.filter((c) => !(sharingTaskList.shares || []).some((s) => s.targetType === 'user' && s.targetId === c.id))
|
||||
.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
👤 {c.displayName || c.email} ({c.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="flex-1">
|
||||
<UserAutocomplete
|
||||
users={safeCollaborators}
|
||||
selectedUser={selectedShareUser}
|
||||
onSelectUser={setSelectedShareUser}
|
||||
excludeUserIds={[
|
||||
currentUser?.id || '',
|
||||
...(sharingTaskList.shares || [])
|
||||
.filter((s) => s.targetType === 'user')
|
||||
.map((s) => s.targetId),
|
||||
].filter(Boolean)}
|
||||
disabled={shareLoading}
|
||||
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddShare}
|
||||
disabled={!selectedShareTargetId || shareLoading}
|
||||
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-50"
|
||||
disabled={shareLoading || (addShareType === 'team' ? !selectedShareTargetId : !selectedShareUser)}
|
||||
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-50 shrink-0"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Freigeben</span>
|
||||
|
||||
Reference in new issue
Block a user