feat(tasks): assign tasks in shared lists, unified people search, list avatars
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m36s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m36s
- Tasks in shared task lists can be assigned to one or more people with access (task_assignees); assignments are pruned whenever access is lost - UserAutocomplete: type-to-search everywhere, nothing before typing, max 3 results, first match preselected - Task list and list tabs: avatar stack of members and round monochrome counter - Shared list items remember who added them (list_items.created_by) - Doku/ui-patterns.md documents the shared UI patterns Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1bd38f4d50
commit
73fad1c5db
21 files changed
+598
-67
No files matched your search
@@ -3,6 +3,10 @@ import { Search, X, Check } from 'lucide-react';
|
||||
import { User } from '../lib/types';
|
||||
import { UserAvatar } from '../components/UserAvatar';
|
||||
|
||||
// Shared type-to-search for picking people (sharing, groups, task assignees). Nothing is listed until
|
||||
// something is typed, then at most MAX_RESULTS matches appear below the field. See Doku/ui-patterns.md.
|
||||
const MAX_RESULTS = 3;
|
||||
|
||||
interface UserAutocompleteProps {
|
||||
users: User[];
|
||||
selectedUser: User | null;
|
||||
@@ -42,10 +46,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
// Filter users based on query and exclusions
|
||||
const filteredUsers = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const available = users.filter((u) => !excludeUserIds.includes(u.id));
|
||||
if (!q) {
|
||||
return available.slice(0, 15);
|
||||
}
|
||||
return available
|
||||
.filter((u) => {
|
||||
const nameMatch = (u.displayName || '').toLowerCase().includes(q);
|
||||
@@ -53,8 +55,9 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
const userMatch = (u.username || '').toLowerCase().includes(q);
|
||||
return nameMatch || emailMatch || userMatch;
|
||||
})
|
||||
.slice(0, 15);
|
||||
.slice(0, MAX_RESULTS);
|
||||
}, [users, query, excludeUserIds]);
|
||||
const showResults = isOpen && query.trim() !== '';
|
||||
|
||||
const handleSelect = (user: User) => {
|
||||
onSelectUser(user);
|
||||
@@ -70,8 +73,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
if (!showResults) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
setIsOpen(true);
|
||||
}
|
||||
return;
|
||||
@@ -139,7 +142,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
setHighlightedIndex(-1);
|
||||
// First match is preselected so Enter picks it right away
|
||||
setHighlightedIndex(0);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -147,8 +151,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
className="w-full h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs placeholder:text-muted transition-colors disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-56 overflow-y-auto bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
|
||||
{showResults && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="p-3 text-xs text-muted text-center">
|
||||
Kein passender Benutzer gefunden
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
||||
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
|
||||
interface SharedListsViewProps {
|
||||
@@ -144,10 +145,32 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// People shown in tabs: everyone with access except me
|
||||
const otherMembers = (lst: SharedList): AvatarPerson[] =>
|
||||
(lst.members || [])
|
||||
.filter((m) => m.userId !== currentUser?.id)
|
||||
.map((m) => ({ id: m.userId, name: m.displayName || m.email, avatarUrl: m.avatarUrl }));
|
||||
|
||||
const activeList = useMemo(() => {
|
||||
return lists.find((l) => l.id === activeListId) || lists[0] || null;
|
||||
}, [lists, activeListId]);
|
||||
|
||||
// In shared lists every item shows who added it (older items without an author show nothing)
|
||||
const isActiveListShared = (activeList?.members?.length || 0) > 1 || (activeList?.shares?.length || 0) > 0;
|
||||
const renderItemAuthor = (item: ListItem) => {
|
||||
if (!isActiveListShared || !item.createdBy) return null;
|
||||
const member = activeList?.members?.find((m) => m.userId === item.createdBy);
|
||||
const name = member?.displayName || member?.email || item.createdByName || '';
|
||||
return (
|
||||
<AvatarStack
|
||||
people={[{ id: item.createdBy, name, avatarUrl: member?.avatarUrl }]}
|
||||
size="xxs"
|
||||
ringClass="ring-transparent"
|
||||
title={name ? `Hinzugefügt von ${name}` : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeList?.id) return;
|
||||
|
||||
@@ -838,8 +861,14 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<span>{lst.icon}</span>
|
||||
<span>{lst.title}</span>
|
||||
{openCount > 0 && (
|
||||
<span className="ml-1 badge-neutral">{openCount}</span>
|
||||
<span className="ml-1 badge-count">{openCount}</span>
|
||||
)}
|
||||
<AvatarStack
|
||||
people={otherMembers(lst)}
|
||||
size="xxs"
|
||||
ringClass={isSelected ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
|
||||
className="ml-0.5"
|
||||
/>
|
||||
</button>
|
||||
{isSelected && lists.length > 1 && (
|
||||
<button
|
||||
@@ -1267,6 +1296,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderItemAuthor(item)}
|
||||
<button
|
||||
type="button"
|
||||
draggable={false}
|
||||
@@ -1375,6 +1405,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{renderItemAuthor(item)}
|
||||
{item.completedByName && (
|
||||
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
||||
)}
|
||||
|
||||
@@ -16,8 +16,11 @@ import {
|
||||
Folder,
|
||||
Webhook,
|
||||
Lock,
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
|
||||
import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User } from '../../lib/types';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
import { MarkdownViewer } from '../../lib/markdown';
|
||||
|
||||
@@ -34,6 +37,8 @@ interface TaskDetailModalProps {
|
||||
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
|
||||
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
|
||||
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
|
||||
assignableMembers?: TaskListMember[]; // empty unless the task's list is shared
|
||||
onUpdateAssignees?: (task: Thought, assignees: string[]) => Promise<void>;
|
||||
onToggleSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
||||
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||
@@ -56,6 +61,8 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
onUpdateTaskList,
|
||||
onUpdateNotes,
|
||||
onUpdateTags,
|
||||
assignableMembers = [],
|
||||
onUpdateAssignees,
|
||||
onToggleSubtask,
|
||||
onAddSubtask,
|
||||
onDeleteSubtask,
|
||||
@@ -525,6 +532,65 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assignees (shared lists only): chips + the shared type-to-search */}
|
||||
{assignableMembers.length > 0 && (() => {
|
||||
const assigned = (task.assignees || [])
|
||||
.map((id) => assignableMembers.find((m) => m.userId === id))
|
||||
.filter((m): m is TaskListMember => !!m);
|
||||
const assignedIds = assigned.map((m) => m.userId);
|
||||
const candidates: User[] = assignableMembers.map((m) => ({
|
||||
id: m.userId,
|
||||
email: m.email,
|
||||
displayName: m.displayName,
|
||||
avatarUrl: m.avatarUrl,
|
||||
role: 'member',
|
||||
isActive: true,
|
||||
createdAt: '',
|
||||
}));
|
||||
return (
|
||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||
<UserPlus className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Zugewiesen:</span>
|
||||
</label>
|
||||
{assigned.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{assigned.map((m) => (
|
||||
<span
|
||||
key={m.userId}
|
||||
className="inline-flex items-center gap-1.5 pl-0.5 pr-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main"
|
||||
>
|
||||
<UserAvatar name={m.displayName || m.email} avatarUrl={m.avatarUrl || ''} size="xxs" showTitle={false} />
|
||||
<span>{m.displayName || m.email}</span>
|
||||
{!readOnly && onUpdateAssignees && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateAssignees(task, assignedIds.filter((id) => id !== m.userId))}
|
||||
className="text-muted hover:text-status-err"
|
||||
title="Zuweisung entfernen"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
readOnly && <p className="text-xs text-muted">Niemandem zugewiesen</p>
|
||||
)}
|
||||
{!readOnly && onUpdateAssignees && assigned.length < assignableMembers.length && (
|
||||
<UserAutocomplete
|
||||
users={candidates}
|
||||
selectedUser={null}
|
||||
onSelectUser={(u) => u && onUpdateAssignees(task, [...assignedIds, u.id])}
|
||||
excludeUserIds={assignedIds}
|
||||
placeholder="Person zuweisen (Name tippen)…"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Hashtags Section */}
|
||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||
|
||||
@@ -26,23 +26,14 @@ import {
|
||||
Filter,
|
||||
Webhook,
|
||||
} from 'lucide-react';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn, PriorityLevel } from '../../lib/types';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { TaskDetailModal } from './TaskDetailModal';
|
||||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
|
||||
const getInitials = (name?: string): string => {
|
||||
if (!name) return '?';
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
};
|
||||
|
||||
interface TasksViewProps {
|
||||
currentUser?: User | null;
|
||||
onRefreshStats: () => void;
|
||||
@@ -245,6 +236,42 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const isTaskReadOnly = (task: Thought) =>
|
||||
!!task.taskListId && safeTaskLists.find((l) => l.id === task.taskListId)?.permission === 'viewer';
|
||||
|
||||
// Assigning only makes sense in shared lists (someone besides the owner); members also filter out
|
||||
// assignees who lost access before the task payload caught up
|
||||
const membersByList = useMemo(() => {
|
||||
const m = new Map<string, TaskListMember[]>();
|
||||
for (const tl of safeTaskLists) m.set(tl.id, tl.members || []);
|
||||
return m;
|
||||
}, [safeTaskLists]);
|
||||
const assignableMembers = (task: Thought): TaskListMember[] => {
|
||||
const members = (task.taskListId && membersByList.get(task.taskListId)) || [];
|
||||
return members.length > 1 ? members : [];
|
||||
};
|
||||
const memberToPerson = (m: TaskListMember): AvatarPerson => ({
|
||||
id: m.userId,
|
||||
name: m.displayName || m.email,
|
||||
avatarUrl: m.avatarUrl,
|
||||
});
|
||||
const getAssigneePeople = (task: Thought): AvatarPerson[] => {
|
||||
const members = assignableMembers(task);
|
||||
return (task.assignees || [])
|
||||
.map((id) => members.find((m) => m.userId === id))
|
||||
.filter((m): m is TaskListMember => !!m)
|
||||
.map(memberToPerson);
|
||||
};
|
||||
const renderAssignees = (task: Thought, size: 'xxs' | 'xs') => {
|
||||
const people = getAssigneePeople(task);
|
||||
if (people.length === 0) return null;
|
||||
return (
|
||||
<AvatarStack
|
||||
people={people}
|
||||
size={size}
|
||||
ringClass="ring-[var(--bg-panel)]"
|
||||
title={`Zugewiesen: ${people.map((p) => p.name).join(', ')}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Active Kanban Columns (from selected list, or global custom, or default)
|
||||
const activeColumns: KanbanColumn[] = useMemo(() => {
|
||||
if (
|
||||
@@ -473,6 +500,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
await api.updateThought(task.id, { notes: newNotes });
|
||||
};
|
||||
|
||||
const handleUpdateTaskAssignees = async (task: Thought, assignees: string[]) => {
|
||||
triggerHaptic(10);
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees } : t)));
|
||||
try {
|
||||
const updated = await api.updateThought(task.id, { assignees });
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: updated.assignees || [] } : t)));
|
||||
} catch {
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: task.assignees } : t)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTaskTags = async (task: Thought, newTags: string[]) => {
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, tags: newTags } : t)));
|
||||
await api.updateThought(task.id, { tags: newTags });
|
||||
@@ -1283,6 +1321,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderAssignees(task, 'xs')}
|
||||
|
||||
{/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */}
|
||||
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
@@ -1355,7 +1395,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<span>{tl.icon}</span>
|
||||
<span>{tl.title}</span>
|
||||
{tl.tasksCount !== undefined && tl.tasksCount > 0 && (
|
||||
<span className="ml-1 badge-neutral">{tl.tasksCount}</span>
|
||||
<span className="ml-1 badge-count">{tl.tasksCount}</span>
|
||||
)}
|
||||
{(() => {
|
||||
const isShared = (tl.shares && tl.shares.length > 0) || Boolean(tl.teamId) || (tl.members && tl.members.length > 1);
|
||||
@@ -1363,17 +1403,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
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-full overflow-hidden bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center shrink-0">
|
||||
{m.avatarUrl ? (
|
||||
<img src={m.avatarUrl} alt="" className="w-full h-full object-cover rounded-full" />
|
||||
) : (
|
||||
getInitials(m.displayName || m.email)[0]
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<AvatarStack
|
||||
people={otherMembers.map(memberToPerson)}
|
||||
size="xxs"
|
||||
ringClass={selectedListId === tl.id ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
|
||||
className="ml-0.5"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -2113,6 +2148,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onUpdateTaskList={handleUpdateTaskTaskList}
|
||||
onUpdateNotes={handleUpdateTaskNotes}
|
||||
onUpdateTags={handleUpdateTaskTags}
|
||||
assignableMembers={detailTask ? assignableMembers(detailTask) : []}
|
||||
onUpdateAssignees={handleUpdateTaskAssignees}
|
||||
onToggleSubtask={handleToggleSubtask}
|
||||
onAddSubtask={handleAddSubtask}
|
||||
onDeleteSubtask={handleDeleteSubtask}
|
||||
@@ -2315,6 +2352,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
#{tag}
|
||||
</button>
|
||||
))}
|
||||
{getAssigneePeople(task).length > 0 && (
|
||||
<span className="ml-auto">{renderAssignees(task, 'xxs')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column Navigation controls: Move Left / Move Right */}
|
||||
|
||||
@@ -293,6 +293,22 @@ body {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Round count bubble: same shape as the nav bar counters, but monochrome (tabs of task lists & lists) */
|
||||
.badge-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--text-muted);
|
||||
color: var(--bg-panel);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.badge-ok {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -362,6 +362,7 @@ export const api = {
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
tags?: string[];
|
||||
assignees?: string[];
|
||||
}
|
||||
): Promise<Thought> {
|
||||
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
||||
|
||||
@@ -62,6 +62,7 @@ export interface Thought {
|
||||
kanbanStatus?: string;
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
assignees?: string[]; // user IDs, only people with access to the task list
|
||||
alarmSent?: boolean;
|
||||
sortOrder?: number;
|
||||
createdAt: string;
|
||||
@@ -349,6 +350,8 @@ export interface ListItem {
|
||||
isCompleted: boolean;
|
||||
completedBy?: string;
|
||||
completedByName?: string;
|
||||
createdBy?: string;
|
||||
createdByName?: string;
|
||||
sortOrder: number;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
@@ -378,6 +381,7 @@ export interface SharedList {
|
||||
listType: 'shopping' | 'checklist' | 'vacation';
|
||||
items: ListItem[];
|
||||
shares?: ListShare[];
|
||||
members?: TaskListMember[]; // owner + everyone the list is shared with
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user