Dev #3
No files matched your search
@@ -719,6 +719,7 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
{activeTab === 'tasks' && (
|
{activeTab === 'tasks' && (
|
||||||
<TasksView
|
<TasksView
|
||||||
|
currentUser={user}
|
||||||
onRefreshStats={refreshAll}
|
onRefreshStats={refreshAll}
|
||||||
onOpenDocument={handleOpenDocument}
|
onOpenDocument={handleOpenDocument}
|
||||||
refreshTrigger={refreshRevision}
|
refreshTrigger={refreshRevision}
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Search, X, Check } from 'lucide-react';
|
||||||
|
import { User } from '../lib/types';
|
||||||
|
|
||||||
|
interface UserAutocompleteProps {
|
||||||
|
users: User[];
|
||||||
|
selectedUser: User | null;
|
||||||
|
onSelectUser: (user: User | null) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
excludeUserIds?: string[];
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||||
|
users,
|
||||||
|
selectedUser,
|
||||||
|
onSelectUser,
|
||||||
|
placeholder = 'Benutzer suchen (Name, @Benutzername oder E-Mail)…',
|
||||||
|
disabled = false,
|
||||||
|
excludeUserIds = [],
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter users based on query and exclusions
|
||||||
|
const filteredUsers = React.useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
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);
|
||||||
|
const emailMatch = (u.email || '').toLowerCase().includes(q);
|
||||||
|
const userMatch = (u.username || '').toLowerCase().includes(q);
|
||||||
|
return nameMatch || emailMatch || userMatch;
|
||||||
|
})
|
||||||
|
.slice(0, 15);
|
||||||
|
}, [users, query, excludeUserIds]);
|
||||||
|
|
||||||
|
const handleSelect = (user: User) => {
|
||||||
|
onSelectUser(user);
|
||||||
|
setQuery('');
|
||||||
|
setIsOpen(false);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
onSelectUser(null);
|
||||||
|
setQuery('');
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||||
|
setIsOpen(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex((prev) =>
|
||||||
|
prev < filteredUsers.length - 1 ? prev + 1 : 0
|
||||||
|
);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedIndex((prev) =>
|
||||||
|
prev > 0 ? prev - 1 : filteredUsers.length - 1
|
||||||
|
);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (highlightedIndex >= 0 && highlightedIndex < filteredUsers.length) {
|
||||||
|
handleSelect(filteredUsers[highlightedIndex]);
|
||||||
|
}
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={`relative ${className}`}>
|
||||||
|
{selectedUser ? (
|
||||||
|
<div className="h-8 px-2.5 bg-panel-elevated border border-accent/40 rounded-btn flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
|
||||||
|
{(selectedUser.displayName || selectedUser.email || '?')[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-main truncate">
|
||||||
|
{selectedUser.displayName || selectedUser.email}
|
||||||
|
</span>
|
||||||
|
{selectedUser.username && (
|
||||||
|
<span className="text-[11px] text-muted truncate">
|
||||||
|
@{selectedUser.username}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedUser.email && selectedUser.displayName && (
|
||||||
|
<span className="text-[11px] text-muted truncate hidden sm:inline">
|
||||||
|
({selectedUser.email})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!disabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className="text-muted hover:text-main p-0.5 rounded hover:bg-canvas transition-colors"
|
||||||
|
title="Auswahl aufheben"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-3.5 h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
setHighlightedIndex(-1);
|
||||||
|
}}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
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">
|
||||||
|
{filteredUsers.length === 0 ? (
|
||||||
|
<div className="p-3 text-xs text-muted text-center">
|
||||||
|
Kein passender Benutzer gefunden
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredUsers.map((u, idx) => {
|
||||||
|
const isHighlighted = idx === highlightedIndex;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(u)}
|
||||||
|
onMouseEnter={() => setHighlightedIndex(idx)}
|
||||||
|
className={`w-full px-2.5 py-1.5 flex items-center justify-between gap-2 text-left text-xs transition-colors cursor-pointer ${
|
||||||
|
isHighlighted
|
||||||
|
? 'bg-accent/15 text-main font-medium'
|
||||||
|
: 'hover:bg-panel-elevated text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
|
||||||
|
{(u.displayName || u.email || '?')[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="truncate">
|
||||||
|
<span className="font-semibold text-main">
|
||||||
|
{u.displayName || u.email}
|
||||||
|
</span>
|
||||||
|
{u.username && (
|
||||||
|
<span className="ml-1 text-[11px] text-muted">
|
||||||
|
@{u.username}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{u.email && u.displayName && (
|
||||||
|
<span className="ml-1.5 text-[10px] text-muted truncate">
|
||||||
|
{u.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isHighlighted && (
|
||||||
|
<Check className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,12 +8,14 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
Radio,
|
Radio,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Share,
|
||||||
Share2,
|
Share2,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ListItem, SharedList, Space, Team, User, WSMessage } from '../../lib/types';
|
import { ListItem, SharedList, Space, Team, User, WSMessage } from '../../lib/types';
|
||||||
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
|
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
|
||||||
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
|
|
||||||
interface SharedListsViewProps {
|
interface SharedListsViewProps {
|
||||||
onRefreshGlobal: () => void;
|
onRefreshGlobal: () => void;
|
||||||
@@ -87,6 +89,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
const [teams, setTeams] = useState<Team[]>([]);
|
const [teams, setTeams] = useState<Team[]>([]);
|
||||||
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
|
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
|
||||||
const [selectedTeamId, setSelectedTeamId] = useState('');
|
const [selectedTeamId, setSelectedTeamId] = useState('');
|
||||||
|
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
|
||||||
const [inviteTarget, setInviteTarget] = useState('');
|
const [inviteTarget, setInviteTarget] = useState('');
|
||||||
const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor');
|
const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor');
|
||||||
const [invitingLoading, setInvitingLoading] = useState(false);
|
const [invitingLoading, setInvitingLoading] = useState(false);
|
||||||
@@ -291,6 +294,26 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
setActiveSpace(created);
|
setActiveSpace(created);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openShareModal = async () => {
|
||||||
|
if (!activeSpace) return;
|
||||||
|
setShowShareModal(true);
|
||||||
|
setShareError(null);
|
||||||
|
setSelectedTeamId('');
|
||||||
|
setSelectedShareUser(null);
|
||||||
|
try {
|
||||||
|
const [usersList, teamsList, sharesList] = await Promise.all([
|
||||||
|
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
|
||||||
|
api.getUserTeams().catch(() => []),
|
||||||
|
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
setCollaborators(usersList);
|
||||||
|
setTeams(teamsList);
|
||||||
|
setActiveSpace((prev) => (prev ? { ...prev, shares: sharesList } : prev));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddShare = async (e: React.FormEvent) => {
|
const handleAddShare = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!activeSpace) return;
|
if (!activeSpace) return;
|
||||||
@@ -309,25 +332,27 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
});
|
});
|
||||||
setSelectedTeamId('');
|
setSelectedTeamId('');
|
||||||
} else {
|
} else {
|
||||||
const target = inviteTarget.trim();
|
if (!selectedShareUser) {
|
||||||
if (!target) {
|
setShareError('Bitte einen bestehenden Benutzer auswählen');
|
||||||
setShareError('Bitte einen Benutzer auswählen oder eingeben');
|
|
||||||
setInvitingLoading(false);
|
setInvitingLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isId = collaborators.some((u) => u.id === target);
|
|
||||||
await api.addSpaceMember(activeSpace.id, {
|
await api.addSpaceMember(activeSpace.id, {
|
||||||
userId: isId ? target : undefined,
|
userId: selectedShareUser.id,
|
||||||
email: !isId ? target : undefined,
|
|
||||||
role: inviteRole,
|
role: inviteRole,
|
||||||
});
|
});
|
||||||
setInviteTarget('');
|
setSelectedShareUser(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sp = await api.getSpaces();
|
const [sp, freshShares] = await Promise.all([
|
||||||
|
api.getSpaces(),
|
||||||
|
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
|
||||||
|
]);
|
||||||
setSpaces(sp);
|
setSpaces(sp);
|
||||||
const updated = sp.find((s) => s.id === activeSpace.id);
|
const updated = sp.find((s) => s.id === activeSpace.id);
|
||||||
if (updated) setActiveSpace(updated);
|
if (updated) {
|
||||||
|
setActiveSpace({ ...updated, shares: freshShares });
|
||||||
|
}
|
||||||
onRefreshGlobal();
|
onRefreshGlobal();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setShareError(err.message || 'Freigabe konnte nicht hinzugefügt werden');
|
setShareError(err.message || 'Freigabe konnte nicht hinzugefügt werden');
|
||||||
@@ -336,34 +361,25 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveMember = async (targetUserId: string) => {
|
const handleRemoveMember = async (targetId: string) => {
|
||||||
if (!activeSpace) return;
|
if (!activeSpace) return;
|
||||||
if (!window.confirm('Dieses Mitglied wirklich aus der Einkaufsliste / dem Bereich entfernen?')) return;
|
if (!window.confirm('Freigabe wirklich aufheben?')) return;
|
||||||
setInvitingLoading(true);
|
setInvitingLoading(true);
|
||||||
setShareError(null);
|
setShareError(null);
|
||||||
try {
|
try {
|
||||||
await api.removeSpaceMember(activeSpace.id, targetUserId);
|
await api.removeSpaceMember(activeSpace.id, targetId);
|
||||||
setActiveSpace((prev) =>
|
const [sp, freshShares] = await Promise.all([
|
||||||
prev
|
api.getSpaces(),
|
||||||
? {
|
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
|
||||||
...prev,
|
]);
|
||||||
members: (prev.members || []).filter((m) => m.userId !== targetUserId),
|
setSpaces(sp);
|
||||||
}
|
const updated = sp.find((s) => s.id === activeSpace.id);
|
||||||
: prev
|
if (updated) {
|
||||||
);
|
setActiveSpace({ ...updated, shares: freshShares });
|
||||||
setSpaces((prev) =>
|
}
|
||||||
prev.map((s) =>
|
|
||||||
s.id === activeSpace.id
|
|
||||||
? {
|
|
||||||
...s,
|
|
||||||
members: (s.members || []).filter((m) => m.userId !== targetUserId),
|
|
||||||
}
|
|
||||||
: s
|
|
||||||
)
|
|
||||||
);
|
|
||||||
onRefreshGlobal();
|
onRefreshGlobal();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setShareError(err.message || 'Mitglied konnte nicht entfernt werden');
|
setShareError(err.message || 'Freigabe konnte nicht entfernt werden');
|
||||||
} finally {
|
} finally {
|
||||||
setInvitingLoading(false);
|
setInvitingLoading(false);
|
||||||
}
|
}
|
||||||
@@ -414,17 +430,18 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-3">
|
<div className="w-full space-y-3">
|
||||||
{/* Top Space Bar & Realtime Indicator */}
|
{/* Spaces Bar */}
|
||||||
<div className="px-3 py-2 panel-surface flex flex-wrap items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-2 text-xs">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex items-center gap-1.5 overflow-x-auto">
|
||||||
|
<span className="text-muted text-[11px] font-medium mr-1">Bereich:</span>
|
||||||
{spaces.map((sp) => (
|
{spaces.map((sp) => (
|
||||||
<button
|
<button
|
||||||
key={sp.id}
|
key={sp.id}
|
||||||
onClick={() => setActiveSpace(sp)}
|
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 ${
|
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 transition-colors border ${
|
||||||
activeSpace?.id === sp.id
|
activeSpace?.id === sp.id
|
||||||
? 'bg-accent text-on-accent border-accent'
|
? 'bg-accent/15 border-accent/40 text-accent font-semibold'
|
||||||
: 'bg-canvas text-muted hover:text-main border-border'
|
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{sp.icon}</span>
|
<span>{sp.icon}</span>
|
||||||
@@ -434,48 +451,13 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNewSpaceModal((prev) => !prev)}
|
onClick={() => setShowNewSpaceModal((prev) => !prev)}
|
||||||
className="btn-action h-8"
|
className="h-7 px-2 text-xs text-muted hover:text-main hover:bg-panel rounded-btn flex items-center gap-1 transition-colors border border-dashed border-border"
|
||||||
|
title="Neuen Bereich (Space) anlegen"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5 text-accent" /> Space
|
<Plus className="w-3 h-3 text-accent" />
|
||||||
|
<span>Space</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Create Space Modal */}
|
{/* Create Space Modal */}
|
||||||
@@ -501,39 +483,103 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* List Tabs within Active Space */}
|
{/* Top Header: List Tabs & Actions */}
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
||||||
{lists.map((lst) => {
|
{/* List Tabs */}
|
||||||
const openCount = lst.items.filter((i) => !i.isCompleted).length;
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
const isSelected = activeList?.id === lst.id;
|
{lists.map((lst) => {
|
||||||
return (
|
const openCount = lst.items.filter((i) => !i.isCompleted).length;
|
||||||
<button
|
const isSelected = activeList?.id === lst.id;
|
||||||
key={lst.id}
|
return (
|
||||||
onClick={() => setActiveListId(lst.id)}
|
<button
|
||||||
className={`h-8 px-3 text-xs sm:text-sm font-medium flex items-center gap-1.5 transition-all border rounded-btn ${
|
key={lst.id}
|
||||||
isSelected
|
onClick={() => setActiveListId(lst.id)}
|
||||||
? 'bg-accent text-on-accent border-accent'
|
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
|
||||||
: 'bg-panel text-muted hover:text-main border-border'
|
isSelected
|
||||||
}`}
|
? 'bg-panel border-accent text-main'
|
||||||
>
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
<span>{lst.icon}</span>
|
}`}
|
||||||
<span>{lst.title}</span>
|
>
|
||||||
<span className={`px-1.5 py-0.2 text-[10px] font-semibold rounded-pill ${
|
<span>{lst.icon}</span>
|
||||||
isSelected ? 'bg-white/20 text-on-accent' : 'badge-neutral'
|
<span>{lst.title}</span>
|
||||||
}`}>
|
{openCount > 0 && (
|
||||||
{openCount}
|
<span className="ml-1 badge-neutral">{openCount}</span>
|
||||||
</span>
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNewListModal((prev) => !prev)}
|
onClick={() => setShowNewListModal((prev) => !prev)}
|
||||||
className="btn-action h-8"
|
className="h-8 px-2.5 bg-canvas hover:bg-panel border border-border border-dashed rounded-btn text-xs text-muted hover:text-main flex items-center gap-1 transition-colors"
|
||||||
>
|
title="Neue Liste anlegen"
|
||||||
<Plus className="w-3.5 h-3.5 text-accent" />
|
>
|
||||||
<span>Neue Liste</span>
|
<Plus className="w-3.5 h-3.5" />
|
||||||
</button>
|
<span>Neue Liste</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Header Section: Geteilt Badge, Icon-Only Share Button & Live Sync */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{activeSpace && (() => {
|
||||||
|
const isOwner = !activeSpace.ownerId || activeSpace.ownerId === currentUser?.id;
|
||||||
|
const sharesCount =
|
||||||
|
(activeSpace.shares?.length || 0) +
|
||||||
|
(activeSpace.members?.filter((m) => m.userId !== activeSpace.ownerId).length || 0);
|
||||||
|
const isShared =
|
||||||
|
(activeSpace.shares && activeSpace.shares.length > 0) ||
|
||||||
|
(activeSpace.members && activeSpace.members.length > 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isShared && (
|
||||||
|
isOwner ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openShareModal}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Users className="w-2.5 h-2.5" />
|
||||||
|
<span>Geteilt ({sharesCount > 0 ? sharesCount : activeSpace.members?.length || 1})</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openShareModal}
|
||||||
|
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 {activeSpace.ownerName || 'Eigentümer'}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openShareModal}
|
||||||
|
className="btn-icon"
|
||||||
|
title="Einkaufsliste teilen und Berechtigungen"
|
||||||
|
>
|
||||||
|
<Share className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Live Sync Status */}
|
||||||
|
<div
|
||||||
|
className={`h-8 w-8 flex items-center justify-center rounded-btn border ${
|
||||||
|
wsConnected
|
||||||
|
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
||||||
|
: 'bg-canvas border-border text-muted'
|
||||||
|
}`}
|
||||||
|
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline'}
|
||||||
|
>
|
||||||
|
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showNewListModal && (
|
{showNewListModal && (
|
||||||
@@ -819,45 +865,31 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex-1">
|
||||||
<select
|
<UserAutocomplete
|
||||||
value={inviteTarget}
|
users={collaborators}
|
||||||
onChange={(e) => setInviteTarget(e.target.value)}
|
selectedUser={selectedShareUser}
|
||||||
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"
|
onSelectUser={setSelectedShareUser}
|
||||||
>
|
excludeUserIds={[
|
||||||
<option value="" className="bg-panel text-muted">Benutzer auswählen oder eingeben…</option>
|
currentUser?.id || '',
|
||||||
{collaborators
|
...(activeSpace.members || []).map((m) => m.userId),
|
||||||
.filter(
|
...(activeSpace.shares || [])
|
||||||
(u) =>
|
.filter((s) => s.targetType === 'user')
|
||||||
u.id !== currentUser?.id &&
|
.map((s) => s.targetId),
|
||||||
!(activeSpace.members || []).some((m) => m.userId === u.id)
|
].filter(Boolean)}
|
||||||
)
|
disabled={invitingLoading}
|
||||||
.map((u) => (
|
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
||||||
<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>
|
||||||
|
<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 shrink-0"
|
||||||
|
>
|
||||||
|
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||||
|
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -866,7 +898,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={
|
disabled={
|
||||||
invitingLoading ||
|
invitingLoading ||
|
||||||
(addShareType === 'team' ? !selectedTeamId : !inviteTarget.trim())
|
(addShareType === 'team' ? !selectedTeamId : !selectedShareUser)
|
||||||
}
|
}
|
||||||
className="btn-primary text-xs h-8 px-4 flex items-center gap-1.5"
|
className="btn-primary text-xs h-8 px-4 flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
@@ -882,12 +914,60 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{/* Current Space Members List */}
|
{/* Current Space Members & Team Shares List */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs font-semibold text-muted uppercase tracking-wider">
|
<div className="text-xs font-semibold text-muted uppercase tracking-wider">
|
||||||
Aktive Freigaben & Mitglieder ({activeSpace.members?.length || 1})
|
Aktive Freigaben & Mitglieder (
|
||||||
|
{((activeSpace.shares || []).filter((s) => s.targetType === 'team').length) +
|
||||||
|
(activeSpace.members?.length || 1)}
|
||||||
|
)
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
<div className="space-y-1.5 max-h-56 overflow-y-auto">
|
||||||
|
{/* Team Shares */}
|
||||||
|
{(activeSpace.shares || [])
|
||||||
|
.filter((s) => s.targetType === 'team')
|
||||||
|
.map((sh) => {
|
||||||
|
const canRemove =
|
||||||
|
activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin';
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={sh.id}
|
||||||
|
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-btn bg-accent/20 text-accent flex items-center justify-center shrink-0">
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
</div>
|
||||||
|
<div className="truncate">
|
||||||
|
<div className="font-medium text-main truncate flex items-center gap-1.5">
|
||||||
|
<span>{sh.targetName || 'Team'}</span>
|
||||||
|
<span className="badge-neutral text-[9px] uppercase px-1 py-0.2">Gruppe</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-muted truncate">Freigabe für die Gruppe</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="text-[10px] px-2 py-0.5 rounded font-semibold bg-surface-2 text-muted">
|
||||||
|
{sh.role === 'editor' ? 'Bearbeiter' : 'Leser'}
|
||||||
|
</span>
|
||||||
|
{canRemove && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveMember(sh.id)}
|
||||||
|
disabled={invitingLoading}
|
||||||
|
className="btn-icon text-muted hover:text-status-danger p-1"
|
||||||
|
title="Team-Freigabe entfernen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Individual Members */}
|
||||||
{(activeSpace.members || []).map((m) => {
|
{(activeSpace.members || []).map((m) => {
|
||||||
const isOwner = m.role === 'owner' || m.userId === activeSpace.ownerId;
|
const isOwner = m.role === 'owner' || m.userId === activeSpace.ownerId;
|
||||||
const isMe = m.userId === currentUser?.id;
|
const isMe = m.userId === currentUser?.id;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
Edit3,
|
Edit3,
|
||||||
X,
|
X,
|
||||||
Layers,
|
Layers,
|
||||||
|
Share,
|
||||||
Share2,
|
Share2,
|
||||||
Users,
|
Users,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
@@ -29,6 +30,7 @@ import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn } f
|
|||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
import { TaskDetailModal } from './TaskDetailModal';
|
import { TaskDetailModal } from './TaskDetailModal';
|
||||||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
||||||
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
|
|
||||||
const getInitials = (name?: string): string => {
|
const getInitials = (name?: string): string => {
|
||||||
if (!name) return '?';
|
if (!name) return '?';
|
||||||
@@ -40,12 +42,14 @@ const getInitials = (name?: string): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface TasksViewProps {
|
interface TasksViewProps {
|
||||||
|
currentUser?: User | null;
|
||||||
onRefreshStats: () => void;
|
onRefreshStats: () => void;
|
||||||
onOpenDocument: (docId: string) => void;
|
onOpenDocument: (docId: string) => void;
|
||||||
refreshTrigger?: number;
|
refreshTrigger?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TasksView: React.FC<TasksViewProps> = ({
|
export const TasksView: React.FC<TasksViewProps> = ({
|
||||||
|
currentUser,
|
||||||
onRefreshStats,
|
onRefreshStats,
|
||||||
onOpenDocument,
|
onOpenDocument,
|
||||||
refreshTrigger = 0,
|
refreshTrigger = 0,
|
||||||
@@ -75,6 +79,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
|
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
|
||||||
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
|
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
|
||||||
const [selectedShareTargetId, setSelectedShareTargetId] = useState('');
|
const [selectedShareTargetId, setSelectedShareTargetId] = useState('');
|
||||||
|
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
|
||||||
const [shareLoading, setShareLoading] = useState(false);
|
const [shareLoading, setShareLoading] = useState(false);
|
||||||
|
|
||||||
// Detail modal state
|
// Detail modal state
|
||||||
@@ -577,26 +582,47 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenShareModal = (tl: TaskList) => {
|
const handleOpenShareModal = async (tl: TaskList) => {
|
||||||
setSharingTaskList(tl);
|
setSharingTaskList(tl);
|
||||||
setSelectedShareTargetId('');
|
setSelectedShareTargetId('');
|
||||||
|
setSelectedShareUser(null);
|
||||||
setShowShareListModal(true);
|
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 () => {
|
const handleAddShare = async () => {
|
||||||
if (!sharingTaskList || !selectedShareTargetId) return;
|
if (!sharingTaskList) return;
|
||||||
|
const targetId = addShareType === 'team' ? selectedShareTargetId : selectedShareUser?.id;
|
||||||
|
if (!targetId) return;
|
||||||
setShareLoading(true);
|
setShareLoading(true);
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
try {
|
try {
|
||||||
await api.addTaskListShare(sharingTaskList.id, {
|
await api.addTaskListShare(sharingTaskList.id, {
|
||||||
targetType: addShareType,
|
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);
|
setTaskLists(freshLists);
|
||||||
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
||||||
if (updated) setSharingTaskList(updated);
|
if (updated) {
|
||||||
|
setSharingTaskList({ ...updated, shares: freshShares });
|
||||||
|
}
|
||||||
setSelectedShareTargetId('');
|
setSelectedShareTargetId('');
|
||||||
|
setSelectedShareUser(null);
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Fehler beim Hinzufügen der Freigabe');
|
alert(err.message || 'Fehler beim Hinzufügen der Freigabe');
|
||||||
@@ -607,14 +633,20 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
|
|
||||||
const handleRemoveShare = async (shareId: string) => {
|
const handleRemoveShare = async (shareId: string) => {
|
||||||
if (!sharingTaskList) return;
|
if (!sharingTaskList) return;
|
||||||
|
if (!window.confirm('Freigabe wirklich aufheben?')) return;
|
||||||
setShareLoading(true);
|
setShareLoading(true);
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
try {
|
try {
|
||||||
await api.removeTaskListShare(sharingTaskList.id, shareId);
|
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);
|
setTaskLists(freshLists);
|
||||||
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
||||||
if (updated) setSharingTaskList(updated);
|
if (updated) {
|
||||||
|
setSharingTaskList({ ...updated, shares: freshShares });
|
||||||
|
}
|
||||||
await loadData();
|
await loadData();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message || 'Fehler beim Entfernen der Freigabe');
|
alert(err.message || 'Fehler beim Entfernen der Freigabe');
|
||||||
@@ -869,19 +901,27 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
{tl.tasksCount !== undefined && tl.tasksCount > 0 && (
|
{tl.tasksCount !== undefined && tl.tasksCount > 0 && (
|
||||||
<span className="ml-1 badge-neutral">{tl.tasksCount}</span>
|
<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)`}>
|
const isShared = (tl.shares && tl.shares.length > 0) || Boolean(tl.teamId) || (tl.members && tl.members.length > 1);
|
||||||
{tl.members.slice(0, 2).map((m) => (
|
if (!isShared) return null;
|
||||||
<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">
|
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
|
||||||
{getInitials(m.displayName || m.email)[0]}
|
if (otherMembers.length > 0) {
|
||||||
</span>
|
return (
|
||||||
))}
|
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
|
||||||
</div>
|
{otherMembers.slice(0, 2).map((m) => (
|
||||||
) : (tl.teamId || (tl.shares && tl.shares.length > 0)) ? (
|
<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">
|
||||||
<span title="Geteilt mit Team oder Benutzern">
|
{getInitials(m.displayName || m.email)[0]}
|
||||||
<Share2 className="w-2.5 h-2.5 text-accent ml-0.5" />
|
</span>
|
||||||
</span>
|
))}
|
||||||
) : null}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span title="Geteilt mit Team oder Benutzern">
|
||||||
|
<Users className="w-2.5 h-2.5 text-accent ml-0.5" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</button>
|
</button>
|
||||||
{selectedListId === tl.id && (
|
{selectedListId === tl.id && (
|
||||||
<button
|
<button
|
||||||
@@ -905,55 +945,52 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
{currentSelectedList && (
|
{currentSelectedList && (() => {
|
||||||
<div className="flex items-center gap-1.5">
|
const isOwner = !currentSelectedList.ownerId || currentSelectedList.ownerId === currentUser?.id;
|
||||||
<button
|
const sharesCount = (currentSelectedList.shares?.length || 0) > 0
|
||||||
type="button"
|
? currentSelectedList.shares.length
|
||||||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
: (currentSelectedList.members?.filter((m) => m.userId !== currentSelectedList.ownerId).length || 0);
|
||||||
className="btn-action h-8 px-2.5 flex items-center gap-1.5 text-xs"
|
const isShared = sharesCount > 0 || Boolean(currentSelectedList.teamId) || ((currentSelectedList.members?.length || 0) > 1);
|
||||||
title={`Liste '${currentSelectedList.title}' mit Team oder Nutzern teilen`}
|
|
||||||
>
|
|
||||||
<Users className="w-3.5 h-3.5 text-accent" />
|
|
||||||
<span>Teilen</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Avatar Stack */}
|
return (
|
||||||
{currentSelectedList.members && currentSelectedList.members.length > 0 ? (
|
<div className="flex items-center gap-1.5">
|
||||||
<div
|
{isShared && (
|
||||||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
isOwner ? (
|
||||||
className="flex items-center -space-x-1.5 cursor-pointer hover:opacity-90 transition-opacity pl-0.5"
|
<button
|
||||||
title={`Geteilt mit ${currentSelectedList.members.length} Mitgliedern (${currentSelectedList.teamName || 'Team'}). Klick zum Verwalten.`}
|
type="button"
|
||||||
>
|
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||||||
{currentSelectedList.members.slice(0, 3).map((m) => (
|
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"
|
||||||
<div
|
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
|
||||||
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})`}
|
|
||||||
>
|
>
|
||||||
{getInitials(m.displayName || m.email)}
|
<Users className="w-2.5 h-2.5" />
|
||||||
</div>
|
<span>Geteilt ({sharesCount > 0 ? sharesCount : currentSelectedList.members?.length || 1})</span>
|
||||||
))}
|
</button>
|
||||||
{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">
|
<button
|
||||||
+{currentSelectedList.members.length - 3}
|
type="button"
|
||||||
</div>
|
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"
|
||||||
</div>
|
title="Teilen & Berechtigungen öffnen"
|
||||||
) : (
|
>
|
||||||
<div
|
<Users className="w-2.5 h-2.5" />
|
||||||
|
<span>Geteilt von {currentSelectedList.ownerName || 'Eigentümer'}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||||||
className="flex items-center cursor-pointer pl-0.5"
|
className="btn-icon"
|
||||||
title="Privat (nur du). Klick zum Teilen."
|
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">
|
<Share className="w-3.5 h-3.5" />
|
||||||
🔒
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
})()}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* View Switcher: List vs Kanban */}
|
{/* View Switcher: List vs Kanban */}
|
||||||
<div className="flex border border-border rounded-btn bg-canvas overflow-hidden">
|
<div className="flex border border-border rounded-btn bg-canvas overflow-hidden">
|
||||||
@@ -1291,7 +1328,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddShareType('team');
|
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 ${
|
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
|
||||||
addShareType === 'team'
|
addShareType === 'team'
|
||||||
@@ -1336,26 +1373,27 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<div className="flex-1">
|
||||||
value={selectedShareTargetId}
|
<UserAutocomplete
|
||||||
onChange={(e) => setSelectedShareTargetId(e.target.value)}
|
users={safeCollaborators}
|
||||||
className="flex-1 h-8 px-2.5 input-field text-xs bg-panel"
|
selectedUser={selectedShareUser}
|
||||||
>
|
onSelectUser={setSelectedShareUser}
|
||||||
<option value="">-- Benutzer auswählen --</option>
|
excludeUserIds={[
|
||||||
{safeCollaborators
|
currentUser?.id || '',
|
||||||
.filter((c) => !(sharingTaskList.shares || []).some((s) => s.targetType === 'user' && s.targetId === c.id))
|
...(sharingTaskList.shares || [])
|
||||||
.map((c) => (
|
.filter((s) => s.targetType === 'user')
|
||||||
<option key={c.id} value={c.id}>
|
.map((s) => s.targetId),
|
||||||
👤 {c.displayName || c.email} ({c.email})
|
].filter(Boolean)}
|
||||||
</option>
|
disabled={shareLoading}
|
||||||
))}
|
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
||||||
</select>
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleAddShare}
|
onClick={handleAddShare}
|
||||||
disabled={!selectedShareTargetId || shareLoading}
|
disabled={shareLoading || (addShareType === 'team' ? !selectedShareTargetId : !selectedShareUser)}
|
||||||
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-50"
|
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" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
<span>Freigeben</span>
|
<span>Freigeben</span>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails, TagSummary, Team, User } from '../../lib/types';
|
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails, TagSummary, Team, User } from '../../lib/types';
|
||||||
import { api, getToken, triggerHaptic } from '../../lib/api';
|
import { api, getToken, triggerHaptic } from '../../lib/api';
|
||||||
import { CodeBlockItem } from './CodeBlockItem';
|
import { CodeBlockItem } from './CodeBlockItem';
|
||||||
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
|
|
||||||
interface WorkspaceViewProps {
|
interface WorkspaceViewProps {
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
@@ -182,7 +183,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const [shareTeams, setShareTeams] = useState<Team[]>([]);
|
const [shareTeams, setShareTeams] = useState<Team[]>([]);
|
||||||
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
|
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
|
||||||
const [selectedShareTeamId, setSelectedShareTeamId] = useState('');
|
const [selectedShareTeamId, setSelectedShareTeamId] = useState('');
|
||||||
const [shareTargetEmailOrId, setShareTargetEmailOrId] = useState('');
|
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
|
||||||
const [sharePermission, setSharePermission] = useState<'read' | 'write'>('read');
|
const [sharePermission, setSharePermission] = useState<'read' | 'write'>('read');
|
||||||
const [sharingLoading, setSharingLoading] = useState(false);
|
const [sharingLoading, setSharingLoading] = useState(false);
|
||||||
const [shareError, setShareError] = useState<string | null>(null);
|
const [shareError, setShareError] = useState<string | null>(null);
|
||||||
@@ -625,10 +626,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setShowShareModal(true);
|
setShowShareModal(true);
|
||||||
setShareError(null);
|
setShareError(null);
|
||||||
setSharingLoading(true);
|
setSharingLoading(true);
|
||||||
|
setSelectedShareUser(null);
|
||||||
|
setSelectedShareTeamId('');
|
||||||
try {
|
try {
|
||||||
const [sharesList, usersList, teamsList] = await Promise.all([
|
const [sharesList, usersList, teamsList] = await Promise.all([
|
||||||
api.getDocShares(selectedDoc.id).catch(() => []),
|
api.getDocShares(selectedDoc.id).catch(() => []),
|
||||||
api.getCollaborators().catch(() => []),
|
api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []),
|
||||||
api.getUserTeams().catch(() => []),
|
api.getUserTeams().catch(() => []),
|
||||||
]);
|
]);
|
||||||
setDocShares(sharesList);
|
setDocShares(sharesList);
|
||||||
@@ -657,19 +660,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
});
|
});
|
||||||
setSelectedShareTeamId('');
|
setSelectedShareTeamId('');
|
||||||
} else {
|
} else {
|
||||||
const target = shareTargetEmailOrId.trim();
|
if (!selectedShareUser) {
|
||||||
if (!target) {
|
setShareError('Bitte einen bestehenden Benutzer auswählen');
|
||||||
setShareError('Bitte einen Benutzer auswählen oder E-Mail eingeben');
|
|
||||||
setSharingLoading(false);
|
setSharingLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const isId = collaborators.some((u) => u.id === target);
|
|
||||||
await api.createDocShare(selectedDoc.id, {
|
await api.createDocShare(selectedDoc.id, {
|
||||||
userId: isId ? target : undefined,
|
userId: selectedShareUser.id,
|
||||||
email: !isId ? target : undefined,
|
|
||||||
permission: sharePermission,
|
permission: sharePermission,
|
||||||
});
|
});
|
||||||
setShareTargetEmailOrId('');
|
setSelectedShareUser(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sharesList = await api.getDocShares(selectedDoc.id).catch(() => []);
|
const sharesList = await api.getDocShares(selectedDoc.id).catch(() => []);
|
||||||
@@ -715,18 +715,18 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveShare = async (targetUserId: string) => {
|
const handleRemoveShare = async (targetId: string) => {
|
||||||
if (!selectedDoc) return;
|
if (!selectedDoc) return;
|
||||||
if (!window.confirm('Freigabe für diesen Benutzer wirklich aufheben?')) return;
|
if (!window.confirm('Freigabe wirklich aufheben?')) return;
|
||||||
setSharingLoading(true);
|
setSharingLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.deleteDocShare(selectedDoc.id, targetUserId);
|
await api.deleteDocShare(selectedDoc.id, targetId);
|
||||||
setDocShares((prev) => prev.filter((s) => s.userId !== targetUserId));
|
setDocShares((prev) => prev.filter((s) => (s.targetId || s.userId || s.id) !== targetId && s.userId !== targetId));
|
||||||
setSelectedDoc((prev) =>
|
setSelectedDoc((prev) =>
|
||||||
prev
|
prev
|
||||||
? {
|
? {
|
||||||
...prev,
|
...prev,
|
||||||
sharedWith: (prev.sharedWith || []).filter((s) => s.userId !== targetUserId),
|
sharedWith: (prev.sharedWith || []).filter((s) => (s.targetId || s.userId || s.id) !== targetId && s.userId !== targetId),
|
||||||
}
|
}
|
||||||
: prev
|
: prev
|
||||||
);
|
);
|
||||||
@@ -4403,7 +4403,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAddShareType('team');
|
setAddShareType('team');
|
||||||
setShareTargetEmailOrId('');
|
setSelectedShareUser(null);
|
||||||
}}
|
}}
|
||||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
|
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
|
||||||
addShareType === 'team'
|
addShareType === 'team'
|
||||||
@@ -4456,42 +4456,28 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="flex-1">
|
||||||
<select
|
<UserAutocomplete
|
||||||
value={shareTargetEmailOrId}
|
users={collaborators}
|
||||||
onChange={(e) => setShareTargetEmailOrId(e.target.value)}
|
selectedUser={selectedShareUser}
|
||||||
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"
|
onSelectUser={setSelectedShareUser}
|
||||||
>
|
excludeUserIds={[
|
||||||
<option value="" className="bg-panel text-muted">Nutzer auswählen oder Email eingeben…</option>
|
currentUser?.id || '',
|
||||||
{collaborators
|
...docShares.map((s) => s.targetId || s.userId).filter(Boolean),
|
||||||
.filter((u) => u.id !== currentUser?.id && !docShares.some((s) => s.userId === u.id))
|
]}
|
||||||
.map((u) => (
|
disabled={sharingLoading}
|
||||||
<option key={u.id} value={u.id} className="bg-panel text-main">
|
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
||||||
👤 {u.displayName || u.email} ({u.email})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={sharePermission}
|
|
||||||
onChange={(e) => setSharePermission(e.target.value as 'read' | 'write')}
|
|
||||||
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-40 cursor-pointer"
|
|
||||||
>
|
|
||||||
<option value="read" className="bg-panel text-main">Nur Lesen (read)</option>
|
|
||||||
<option value="write" className="bg-panel text-main">Schreiben (write)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Direct Email fallback if user not in list */}
|
|
||||||
{(!shareTargetEmailOrId || !collaborators.some((u) => u.id === shareTargetEmailOrId)) && (
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
placeholder="Oder E-Mail-Adresse direkt eingeben…"
|
|
||||||
value={shareTargetEmailOrId}
|
|
||||||
onChange={(e) => setShareTargetEmailOrId(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>
|
||||||
|
<select
|
||||||
|
value={sharePermission}
|
||||||
|
onChange={(e) => setSharePermission(e.target.value as 'read' | 'write')}
|
||||||
|
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-40 cursor-pointer shrink-0"
|
||||||
|
>
|
||||||
|
<option value="read" className="bg-panel text-main">Nur Lesen (read)</option>
|
||||||
|
<option value="write" className="bg-panel text-main">Schreiben (write)</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -4500,7 +4486,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
type="submit"
|
type="submit"
|
||||||
disabled={
|
disabled={
|
||||||
sharingLoading ||
|
sharingLoading ||
|
||||||
(addShareType === 'team' ? !selectedShareTeamId : !shareTargetEmailOrId.trim())
|
(addShareType === 'team' ? !selectedShareTeamId : !selectedShareUser)
|
||||||
}
|
}
|
||||||
className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5"
|
className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5"
|
||||||
>
|
>
|
||||||
@@ -4525,43 +4511,68 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5 max-h-56 overflow-y-auto">
|
<div className="space-y-1.5 max-h-56 overflow-y-auto">
|
||||||
{docShares.map((share) => (
|
{docShares.map((share) => {
|
||||||
<div
|
const isTeam = share.targetType === 'team' || Boolean(share.teamId) || String(share.targetId || '').startsWith('team_');
|
||||||
key={share.id || share.userId}
|
const targetKey = share.targetId || share.userId || share.id;
|
||||||
className="p-2.5 bg-panel-elevated border border-border rounded-card flex items-center justify-between gap-3 text-xs"
|
return (
|
||||||
>
|
<div
|
||||||
<div className="min-w-0">
|
key={share.id || targetKey}
|
||||||
<div className="font-semibold text-main truncate">
|
className="p-2.5 bg-panel-elevated border border-border rounded-card flex items-center justify-between gap-3 text-xs"
|
||||||
{share.displayName || share.userEmail || share.userId}
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{isTeam ? (
|
||||||
|
<div className="w-6 h-6 rounded-btn bg-accent/20 text-accent flex items-center justify-center shrink-0">
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-6 h-6 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
|
||||||
|
{(share.displayName || share.userEmail || share.targetName || '?')[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="font-semibold text-main truncate flex items-center gap-1.5">
|
||||||
|
<span>{share.displayName || share.targetName || share.userEmail || share.userId}</span>
|
||||||
|
{isTeam && (
|
||||||
|
<span className="badge-neutral text-[9px] uppercase px-1 py-0.2">Gruppe</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isTeam && share.userEmail && share.displayName && (
|
||||||
|
<div className="text-[11px] text-muted truncate">{share.userEmail}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{share.userEmail && share.displayName && (
|
|
||||||
<div className="text-[11px] text-muted truncate">{share.userEmail}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<select
|
{!isTeam && (
|
||||||
value={share.permission}
|
<select
|
||||||
onChange={(e) => handleUpdateSharePerm(share.userId, e.target.value as 'read' | 'write')}
|
value={share.permission}
|
||||||
className="input-field h-7 px-2 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-[11px] cursor-pointer"
|
onChange={(e) => handleUpdateSharePerm(share.userId, e.target.value as 'read' | 'write')}
|
||||||
disabled={sharingLoading}
|
className="input-field h-7 px-2 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-[11px] cursor-pointer"
|
||||||
>
|
disabled={sharingLoading}
|
||||||
<option value="read" className="bg-panel text-main">Nur Lesen</option>
|
>
|
||||||
<option value="write" className="bg-panel text-main">Schreiben</option>
|
<option value="read" className="bg-panel text-main">Nur Lesen</option>
|
||||||
</select>
|
<option value="write" className="bg-panel text-main">Schreiben</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{isTeam && (
|
||||||
|
<span className="badge-neutral text-[10px]">
|
||||||
|
{share.permission === 'write' ? 'Schreiben' : 'Nur Lesen'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemoveShare(share.userId)}
|
onClick={() => handleRemoveShare(targetKey)}
|
||||||
className="btn-icon btn-icon-danger h-7 w-7"
|
className="btn-icon btn-icon-danger h-7 w-7"
|
||||||
title="Freigabe entziehen"
|
title="Freigabe entziehen"
|
||||||
disabled={sharingLoading}
|
disabled={sharingLoading}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -589,9 +589,15 @@ export const api = {
|
|||||||
return ensureArray(res).map((s) => ({
|
return ensureArray(res).map((s) => ({
|
||||||
...s,
|
...s,
|
||||||
members: ensureArray(s.members),
|
members: ensureArray(s.members),
|
||||||
|
shares: ensureArray(s.shares),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getSpaceShares(spaceId: string): Promise<SpaceShare[]> {
|
||||||
|
const res = await request<SpaceShare[]>(`/api/v1/spaces/${spaceId}/shares`);
|
||||||
|
return ensureArray(res);
|
||||||
|
},
|
||||||
|
|
||||||
async createSpace(name: string, icon: string, description: string): Promise<Space> {
|
async createSpace(name: string, icon: string, description: string): Promise<Space> {
|
||||||
return request<Space>('/api/v1/spaces', {
|
return request<Space>('/api/v1/spaces', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -606,8 +612,8 @@ export const api = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise<SpaceMember> {
|
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise<any> {
|
||||||
return request<SpaceMember>(`/api/v1/spaces/${spaceId}/members`, {
|
return request<any>(`/api/v1/spaces/${spaceId}/members`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(target),
|
body: JSON.stringify(target),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -162,7 +162,10 @@ export interface DocumentBlock {
|
|||||||
export interface DocShare {
|
export interface DocShare {
|
||||||
id: string;
|
id: string;
|
||||||
docId: string;
|
docId: string;
|
||||||
userId: string;
|
targetType?: 'user' | 'team';
|
||||||
|
targetId?: string;
|
||||||
|
targetName?: string;
|
||||||
|
userId?: string;
|
||||||
userEmail?: string;
|
userEmail?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
permission: 'read' | 'write';
|
permission: 'read' | 'write';
|
||||||
@@ -238,6 +241,17 @@ export interface SpaceMember {
|
|||||||
joinedAt: string;
|
joinedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SpaceShare {
|
||||||
|
id: string;
|
||||||
|
spaceId: string;
|
||||||
|
targetType: 'team' | 'user';
|
||||||
|
targetId: string;
|
||||||
|
targetName?: string;
|
||||||
|
role: 'editor' | 'viewer';
|
||||||
|
createdBy?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Space {
|
export interface Space {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -247,6 +261,7 @@ export interface Space {
|
|||||||
isPersonal: boolean;
|
isPersonal: boolean;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
members?: SpaceMember[];
|
members?: SpaceMember[];
|
||||||
|
shares?: SpaceShare[];
|
||||||
listsCount: number;
|
listsCount: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-34
@@ -129,6 +129,7 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Get("/api/v1/spaces", s.handleGetSpaces)
|
r.Get("/api/v1/spaces", s.handleGetSpaces)
|
||||||
r.Post("/api/v1/spaces", s.handleCreateSpace)
|
r.Post("/api/v1/spaces", s.handleCreateSpace)
|
||||||
r.Post("/api/v1/spaces/join", s.handleJoinSpace)
|
r.Post("/api/v1/spaces/join", s.handleJoinSpace)
|
||||||
|
r.Get("/api/v1/spaces/{id}/shares", s.handleGetSpaceShares)
|
||||||
r.Post("/api/v1/spaces/{id}/members", s.handleAddSpaceMember)
|
r.Post("/api/v1/spaces/{id}/members", s.handleAddSpaceMember)
|
||||||
r.Delete("/api/v1/spaces/{id}/members/{userId}", s.handleRemoveSpaceMember)
|
r.Delete("/api/v1/spaces/{id}/members/{userId}", s.handleRemoveSpaceMember)
|
||||||
r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists)
|
r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists)
|
||||||
@@ -953,28 +954,17 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.TeamID != "" {
|
if req.TeamID != "" {
|
||||||
members, err := s.store.GetTeamMembers(req.TeamID)
|
share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Team nicht gefunden")
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var lastShare *domain.DocShare
|
members, _ := s.store.GetTeamMembers(req.TeamID)
|
||||||
for _, tm := range members {
|
for _, tm := range members {
|
||||||
if tm.UserID == userID {
|
s.wsHub.BroadcastToUser(tm.UserID, "DOC_SHARED", share)
|
||||||
continue
|
|
||||||
}
|
|
||||||
share, err := s.store.CreateDocShare(userID, docID, tm.UserID, req.Permission)
|
|
||||||
if err == nil && share != nil {
|
|
||||||
lastShare = share
|
|
||||||
s.wsHub.BroadcastToUser(share.UserID, "DOC_SHARED", share)
|
|
||||||
s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if lastShare != nil {
|
|
||||||
writeJSON(w, http.StatusCreated, lastShare)
|
|
||||||
} else {
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
|
||||||
}
|
}
|
||||||
|
s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share)
|
||||||
|
writeJSON(w, http.StatusCreated, share)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1153,25 +1143,17 @@ func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if req.TeamID != "" {
|
if req.TeamID != "" {
|
||||||
members, err := s.store.GetTeamMembers(req.TeamID)
|
share, err := s.store.CreateSpaceTeamShare(currentUserID, spaceID, req.TeamID, req.Role)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, "Team nicht gefunden")
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var lastMember *domain.SpaceMember
|
members, _ := s.store.GetTeamMembers(req.TeamID)
|
||||||
for _, tm := range members {
|
for _, tm := range members {
|
||||||
member, err := s.store.AddSpaceMember(currentUserID, spaceID, tm.UserID, req.Role)
|
s.wsHub.BroadcastToUser(tm.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID})
|
||||||
if err == nil && member != nil {
|
|
||||||
lastMember = member
|
|
||||||
s.wsHub.BroadcastToUser(member.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID})
|
|
||||||
s.wsHub.Broadcast(spaceID, "", "MEMBER_ADDED", member)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if lastMember != nil {
|
|
||||||
writeJSON(w, http.StatusCreated, lastMember)
|
|
||||||
} else {
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
|
||||||
}
|
}
|
||||||
|
s.wsHub.Broadcast(spaceID, "", "SPACE_SHARED", share)
|
||||||
|
writeJSON(w, http.StatusCreated, share)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1196,18 +1178,31 @@ func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusCreated, member)
|
writeJSON(w, http.StatusCreated, member)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetSpaceShares(w http.ResponseWriter, r *http.Request) {
|
||||||
|
spaceID := chi.URLParam(r, "id")
|
||||||
|
shares, err := s.store.GetSpaceShares(spaceID)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, shares)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) {
|
||||||
currentUserID := auth.GetUserID(r.Context())
|
currentUserID := auth.GetUserID(r.Context())
|
||||||
spaceID := chi.URLParam(r, "id")
|
spaceID := chi.URLParam(r, "id")
|
||||||
targetUserID := chi.URLParam(r, "userId")
|
targetUserID := chi.URLParam(r, "userId")
|
||||||
|
|
||||||
if err := s.store.RemoveSpaceMember(currentUserID, spaceID, targetUserID); err != nil {
|
// If prefixed with sm_, strip it for individual space member removal
|
||||||
|
cleanTargetID := strings.TrimPrefix(targetUserID, "sm_")
|
||||||
|
|
||||||
|
if err := s.store.RemoveSpaceMember(currentUserID, spaceID, cleanTargetID); err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.wsHub.BroadcastToUser(targetUserID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID})
|
s.wsHub.BroadcastToUser(cleanTargetID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID})
|
||||||
s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": targetUserID})
|
s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": cleanTargetID})
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,7 +254,10 @@ type DocumentBlock struct {
|
|||||||
type DocShare struct {
|
type DocShare struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
DocID string `json:"docId"`
|
DocID string `json:"docId"`
|
||||||
UserID string `json:"userId"`
|
TargetType string `json:"targetType,omitempty"` // user | team
|
||||||
|
TargetID string `json:"targetId,omitempty"`
|
||||||
|
TargetName string `json:"targetName,omitempty"`
|
||||||
|
UserID string `json:"userId,omitempty"`
|
||||||
UserEmail string `json:"userEmail,omitempty"`
|
UserEmail string `json:"userEmail,omitempty"`
|
||||||
DisplayName string `json:"displayName,omitempty"`
|
DisplayName string `json:"displayName,omitempty"`
|
||||||
Permission string `json:"permission"` // read | write
|
Permission string `json:"permission"` // read | write
|
||||||
@@ -330,6 +333,17 @@ type SpaceMember struct {
|
|||||||
JoinedAt string `json:"joinedAt"`
|
JoinedAt string `json:"joinedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SpaceShare struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
SpaceID string `json:"spaceId"`
|
||||||
|
TargetType string `json:"targetType"` // team | user
|
||||||
|
TargetID string `json:"targetId"`
|
||||||
|
TargetName string `json:"targetName,omitempty"`
|
||||||
|
Role string `json:"role"` // editor | viewer
|
||||||
|
CreatedBy string `json:"createdBy"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
type Space struct {
|
type Space struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -339,6 +353,7 @@ type Space struct {
|
|||||||
IsPersonal bool `json:"isPersonal"`
|
IsPersonal bool `json:"isPersonal"`
|
||||||
OwnerID string `json:"ownerId"`
|
OwnerID string `json:"ownerId"`
|
||||||
Members []SpaceMember `json:"members"`
|
Members []SpaceMember `json:"members"`
|
||||||
|
Shares []SpaceShare `json:"shares,omitempty"`
|
||||||
ListsCount int `json:"listsCount"`
|
ListsCount int `json:"listsCount"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|||||||
+330
-40
@@ -337,6 +337,14 @@ func (s *Store) migrate() error {
|
|||||||
"CREATE TABLE IF NOT EXISTS task_list_shares (id TEXT PRIMARY KEY, task_list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(task_list_id, target_type, target_id));",
|
"CREATE TABLE IF NOT EXISTS task_list_shares (id TEXT PRIMARY KEY, task_list_id TEXT NOT NULL, target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')), target_id TEXT NOT NULL, created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(task_list_id, target_type, target_id));",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_target ON task_list_shares(target_type, target_id);",
|
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_target ON task_list_shares(target_type, target_id);",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_list ON task_list_shares(task_list_id);",
|
"CREATE INDEX IF NOT EXISTS idx_task_list_shares_list ON task_list_shares(task_list_id);",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS doc_team_shares (id TEXT PRIMARY KEY, doc_id TEXT NOT NULL, team_id TEXT NOT NULL, permission TEXT NOT NULL CHECK(permission IN ('read', 'write')), created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(doc_id, team_id));",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_doc_team_shares_team ON doc_team_shares(team_id);",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_doc_team_shares_doc ON doc_team_shares(doc_id);",
|
||||||
|
|
||||||
|
"CREATE TABLE IF NOT EXISTS space_team_shares (id TEXT PRIMARY KEY, space_id TEXT NOT NULL, team_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'editor', created_at TEXT NOT NULL, created_by TEXT NOT NULL, UNIQUE(space_id, team_id));",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_space_team_shares_team ON space_team_shares(team_id);",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_space_team_shares_space ON space_team_shares(space_id);",
|
||||||
}
|
}
|
||||||
for _, q := range columnMigrations {
|
for _, q := range columnMigrations {
|
||||||
_, _ = s.db.Exec(q)
|
_, _ = s.db.Exec(q)
|
||||||
@@ -1331,19 +1339,26 @@ func (s *Store) GetDocuments(userID string) ([]domain.Document, error) {
|
|||||||
}
|
}
|
||||||
_ = rows.Close()
|
_ = rows.Close()
|
||||||
|
|
||||||
// 2. Fetch documents shared with the user (with per-user is_pinned from doc_pins)
|
// 2. Fetch documents shared with the user directly or via teams (with per-user is_pinned from doc_pins)
|
||||||
sharedRows, err := s.db.Query(
|
sharedRows, err := s.db.Query(
|
||||||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||||||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||||||
d.is_archived, d.created_at, d.updated_at,
|
d.is_archived, d.created_at, d.updated_at,
|
||||||
s.permission, u.display_name
|
COALESCE(s.permission, dts.permission, 'read') AS permission, u.display_name
|
||||||
FROM documents d
|
FROM documents d
|
||||||
JOIN doc_shares s ON d.id = s.doc_id
|
|
||||||
JOIN users u ON d.user_id = u.id
|
JOIN users u ON d.user_id = u.id
|
||||||
|
LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ?
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT dts2.doc_id, dts2.permission
|
||||||
|
FROM doc_team_shares dts2
|
||||||
|
JOIN team_members tm ON tm.team_id = dts2.team_id
|
||||||
|
WHERE tm.user_id = ?
|
||||||
|
) dts ON d.id = dts.doc_id
|
||||||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||||||
WHERE s.user_id = ? AND d.is_archived = 0
|
WHERE (s.user_id = ? OR dts.doc_id IS NOT NULL) AND d.is_archived = 0 AND d.user_id != ?
|
||||||
|
GROUP BY d.id
|
||||||
ORDER BY is_pinned DESC, d.updated_at DESC`,
|
ORDER BY is_pinned DESC, d.updated_at DESC`,
|
||||||
userID, userID,
|
userID, userID, userID, userID, userID,
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for sharedRows.Next() {
|
for sharedRows.Next() {
|
||||||
@@ -1404,18 +1419,25 @@ func (s *Store) GetDocumentByID(userID, docID string) (*domain.Document, error)
|
|||||||
return doc, nil
|
return doc, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try shared document (with per-user is_pinned)
|
// Try shared document (direct or team share, with per-user is_pinned)
|
||||||
sharedRow := s.db.QueryRow(
|
sharedRow := s.db.QueryRow(
|
||||||
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
`SELECT d.id, d.user_id, d.space_id, d.title, d.icon, d.content, d.blocks_json, d.tags, d.folder_path,
|
||||||
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
CASE WHEN p.doc_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned,
|
||||||
d.is_archived, d.created_at, d.updated_at,
|
d.is_archived, d.created_at, d.updated_at,
|
||||||
s.permission, u.display_name
|
COALESCE(s.permission, dts.permission, 'read') AS permission, u.display_name
|
||||||
FROM documents d
|
FROM documents d
|
||||||
JOIN doc_shares s ON d.id = s.doc_id
|
|
||||||
JOIN users u ON d.user_id = u.id
|
JOIN users u ON d.user_id = u.id
|
||||||
|
LEFT JOIN doc_shares s ON d.id = s.doc_id AND s.user_id = ?
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT dts2.doc_id, dts2.permission
|
||||||
|
FROM doc_team_shares dts2
|
||||||
|
JOIN team_members tm ON tm.team_id = dts2.team_id
|
||||||
|
WHERE tm.user_id = ?
|
||||||
|
) dts ON d.id = dts.doc_id
|
||||||
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
LEFT JOIN doc_pins p ON p.doc_id = d.id AND p.user_id = ?
|
||||||
WHERE d.id = ? AND s.user_id = ?`,
|
WHERE d.id = ? AND (s.user_id = ? OR dts.doc_id IS NOT NULL)
|
||||||
userID, docID, userID,
|
LIMIT 1`,
|
||||||
|
userID, userID, userID, docID, userID,
|
||||||
)
|
)
|
||||||
d := &domain.Document{}
|
d := &domain.Document{}
|
||||||
var blocksStr, tagsStr string
|
var blocksStr, tagsStr string
|
||||||
@@ -1630,11 +1652,17 @@ func (s *Store) GetWorkspaceTags(userID string) ([]domain.TagSummary, error) {
|
|||||||
FROM doc_block_tags t
|
FROM doc_block_tags t
|
||||||
JOIN documents d ON t.doc_id = d.id
|
JOIN documents d ON t.doc_id = d.id
|
||||||
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
|
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
|
||||||
WHERE (d.user_id = ? OR s.user_id = ?)
|
LEFT JOIN (
|
||||||
|
SELECT dts2.doc_id
|
||||||
|
FROM doc_team_shares dts2
|
||||||
|
JOIN team_members tm ON tm.team_id = dts2.team_id
|
||||||
|
WHERE tm.user_id = ?
|
||||||
|
) dts ON d.id = dts.doc_id
|
||||||
|
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL)
|
||||||
AND d.is_archived = 0
|
AND d.is_archived = 0
|
||||||
GROUP BY t.tag
|
GROUP BY t.tag
|
||||||
ORDER BY count DESC, t.tag ASC`,
|
ORDER BY count DESC, t.tag ASC`,
|
||||||
userID, userID, userID,
|
userID, userID, userID, userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1686,16 +1714,22 @@ func (s *Store) GetTagDetails(userID, tag string) (*domain.TagDetails, error) {
|
|||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id,
|
`SELECT t.block_id, d.id, d.title, d.icon, d.folder_path, d.blocks_json, d.updated_at, d.user_id,
|
||||||
COALESCE(u.display_name, '') as owner_name,
|
COALESCE(u.display_name, '') as owner_name,
|
||||||
COALESCE(s.permission, '') as shared_permission
|
COALESCE(s.permission, dts.permission, '') as shared_permission
|
||||||
FROM doc_block_tags t
|
FROM doc_block_tags t
|
||||||
JOIN documents d ON t.doc_id = d.id
|
JOIN documents d ON t.doc_id = d.id
|
||||||
LEFT JOIN users u ON d.user_id = u.id
|
LEFT JOIN users u ON d.user_id = u.id
|
||||||
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
|
LEFT JOIN doc_shares s ON s.doc_id = d.id AND s.user_id = ?
|
||||||
WHERE (d.user_id = ? OR s.user_id = ?)
|
LEFT JOIN (
|
||||||
|
SELECT dts2.doc_id, dts2.permission
|
||||||
|
FROM doc_team_shares dts2
|
||||||
|
JOIN team_members tm ON tm.team_id = dts2.team_id
|
||||||
|
WHERE tm.user_id = ?
|
||||||
|
) dts ON d.id = dts.doc_id
|
||||||
|
WHERE (d.user_id = ? OR s.user_id = ? OR dts.doc_id IS NOT NULL)
|
||||||
AND t.tag = ?
|
AND t.tag = ?
|
||||||
AND d.is_archived = 0
|
AND d.is_archived = 0
|
||||||
ORDER BY d.updated_at DESC`,
|
ORDER BY d.updated_at DESC`,
|
||||||
userID, userID, userID, cleanTag,
|
userID, userID, userID, userID, cleanTag,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1946,7 +1980,10 @@ func (s *Store) DeleteFolder(userID, folderPath string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
|
func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
|
||||||
rows, err := s.db.Query(
|
shares := make([]domain.DocShare, 0)
|
||||||
|
|
||||||
|
// 1. Direct user shares
|
||||||
|
uRows, err := s.db.Query(
|
||||||
`SELECT s.id, s.doc_id, s.user_id, u.email, u.display_name, s.permission, s.created_by, s.created_at
|
`SELECT s.id, s.doc_id, s.user_id, u.email, u.display_name, s.permission, s.created_by, s.created_at
|
||||||
FROM doc_shares s
|
FROM doc_shares s
|
||||||
JOIN users u ON s.user_id = u.id
|
JOIN users u ON s.user_id = u.id
|
||||||
@@ -1954,19 +1991,89 @@ func (s *Store) GetDocShares(docID string) ([]domain.DocShare, error) {
|
|||||||
ORDER BY s.created_at ASC`,
|
ORDER BY s.created_at ASC`,
|
||||||
docID,
|
docID,
|
||||||
)
|
)
|
||||||
|
if err == nil {
|
||||||
|
for uRows.Next() {
|
||||||
|
var sh domain.DocShare
|
||||||
|
if err := uRows.Scan(&sh.ID, &sh.DocID, &sh.UserID, &sh.UserEmail, &sh.DisplayName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
||||||
|
sh.TargetType = "user"
|
||||||
|
sh.TargetID = sh.UserID
|
||||||
|
sh.TargetName = sh.DisplayName
|
||||||
|
shares = append(shares, sh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = uRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Team shares
|
||||||
|
tRows, err := s.db.Query(
|
||||||
|
`SELECT s.id, s.doc_id, s.team_id, t.name, s.permission, s.created_by, s.created_at
|
||||||
|
FROM doc_team_shares s
|
||||||
|
JOIN teams t ON s.team_id = t.id
|
||||||
|
WHERE s.doc_id = ?
|
||||||
|
ORDER BY s.created_at ASC`,
|
||||||
|
docID,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
for tRows.Next() {
|
||||||
|
var sh domain.DocShare
|
||||||
|
var teamID, teamName string
|
||||||
|
if err := tRows.Scan(&sh.ID, &sh.DocID, &teamID, &teamName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
||||||
|
sh.TargetType = "team"
|
||||||
|
sh.TargetID = teamID
|
||||||
|
sh.TargetName = teamName
|
||||||
|
sh.DisplayName = teamName
|
||||||
|
shares = append(shares, sh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = tRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return shares, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateDocTeamShare(currentUserID, docID, teamID, permission string) (*domain.DocShare, error) {
|
||||||
|
doc, err := s.GetDocumentByID(currentUserID, docID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
if !doc.IsOwner && doc.Permission != "write" {
|
||||||
|
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten")
|
||||||
shares := make([]domain.DocShare, 0)
|
|
||||||
for rows.Next() {
|
|
||||||
var sh domain.DocShare
|
|
||||||
if err := rows.Scan(&sh.ID, &sh.DocID, &sh.UserID, &sh.UserEmail, &sh.DisplayName, &sh.Permission, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
|
||||||
shares = append(shares, sh)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return shares, nil
|
|
||||||
|
cleanTeamID := strings.TrimSpace(teamID)
|
||||||
|
var teamName string
|
||||||
|
err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("team nicht gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
if permission != "read" && permission != "write" {
|
||||||
|
permission = "read"
|
||||||
|
}
|
||||||
|
|
||||||
|
now := domain.NowISO()
|
||||||
|
shareID := domain.NewID("share")
|
||||||
|
_, err = s.db.Exec(
|
||||||
|
`INSERT INTO doc_team_shares (id, doc_id, team_id, permission, created_at, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(doc_id, team_id) DO UPDATE SET permission = excluded.permission`,
|
||||||
|
shareID, docID, cleanTeamID, permission, now, currentUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.DocShare{
|
||||||
|
ID: shareID,
|
||||||
|
DocID: docID,
|
||||||
|
TargetType: "team",
|
||||||
|
TargetID: cleanTeamID,
|
||||||
|
TargetName: teamName,
|
||||||
|
DisplayName: teamName,
|
||||||
|
Permission: permission,
|
||||||
|
CreatedBy: currentUserID,
|
||||||
|
CreatedAt: now,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permission string) (*domain.DocShare, error) {
|
func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permission string) (*domain.DocShare, error) {
|
||||||
@@ -1978,13 +2085,19 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
|
|||||||
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten")
|
return nil, fmt.Errorf("nur der Eigentümer oder Bearbeiter kann Freigaben verwalten")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
||||||
|
|
||||||
|
// If identifier looks like a team or is prefixed with team_, route to CreateDocTeamShare
|
||||||
|
if strings.HasPrefix(cleanTarget, "team_") {
|
||||||
|
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
|
||||||
|
}
|
||||||
|
|
||||||
if permission != "read" && permission != "write" {
|
if permission != "read" && permission != "write" {
|
||||||
permission = "read"
|
permission = "read"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve target user by ID, username, or Email
|
// Resolve target user by ID, username, or Email
|
||||||
var targetUser *domain.User
|
var targetUser *domain.User
|
||||||
cleanTarget := strings.TrimSpace(targetUserIdentifier)
|
|
||||||
if strings.HasPrefix(cleanTarget, "usr_") {
|
if strings.HasPrefix(cleanTarget, "usr_") {
|
||||||
targetUser, _ = s.GetUserByID(cleanTarget)
|
targetUser, _ = s.GetUserByID(cleanTarget)
|
||||||
}
|
}
|
||||||
@@ -1995,6 +2108,11 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
|
|||||||
targetUser, err = s.GetUserByID(cleanTarget)
|
targetUser, err = s.GetUserByID(cleanTarget)
|
||||||
}
|
}
|
||||||
if err != nil || targetUser == nil {
|
if err != nil || targetUser == nil {
|
||||||
|
// Check if it's a team ID that doesn't start with team_
|
||||||
|
var tName string
|
||||||
|
if s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTarget).Scan(&tName) == nil {
|
||||||
|
return s.CreateDocTeamShare(currentUserID, docID, cleanTarget, permission)
|
||||||
|
}
|
||||||
return nil, fmt.Errorf("benutzer nicht gefunden")
|
return nil, fmt.Errorf("benutzer nicht gefunden")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2017,6 +2135,9 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
|
|||||||
return &domain.DocShare{
|
return &domain.DocShare{
|
||||||
ID: shareID,
|
ID: shareID,
|
||||||
DocID: docID,
|
DocID: docID,
|
||||||
|
TargetType: "user",
|
||||||
|
TargetID: targetUser.ID,
|
||||||
|
TargetName: targetUser.DisplayName,
|
||||||
UserID: targetUser.ID,
|
UserID: targetUser.ID,
|
||||||
UserEmail: targetUser.Email,
|
UserEmail: targetUser.Email,
|
||||||
DisplayName: targetUser.DisplayName,
|
DisplayName: targetUser.DisplayName,
|
||||||
@@ -2026,16 +2147,26 @@ func (s *Store) CreateDocShare(currentUserID, docID, targetUserIdentifier, permi
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteDocShare(currentUserID, docID, targetUserID string) error {
|
func (s *Store) DeleteDocShare(currentUserID, docID, targetID string) error {
|
||||||
doc, err := s.GetDocumentByID(currentUserID, docID)
|
doc, err := s.GetDocumentByID(currentUserID, docID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !doc.IsOwner && doc.Permission != "write" && currentUserID != targetUserID {
|
if !doc.IsOwner && doc.Permission != "write" && currentUserID != targetID {
|
||||||
return fmt.Errorf("keine Berechtigung zum Entfernen der Freigabe")
|
return fmt.Errorf("keine Berechtigung zum Entfernen der Freigabe")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ? AND user_id = ?`, docID, targetUserID)
|
cleanTarget := strings.TrimSpace(targetID)
|
||||||
|
// Try deleting team share first
|
||||||
|
res, err := s.db.Exec(`DELETE FROM doc_team_shares WHERE doc_id = ? AND (id = ? OR team_id = ?)`, docID, cleanTarget, cleanTarget)
|
||||||
|
if err == nil {
|
||||||
|
if rows, _ := res.RowsAffected(); rows > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to user share
|
||||||
|
_, err = s.db.Exec(`DELETE FROM doc_shares WHERE doc_id = ? AND (id = ? OR user_id = ?)`, docID, cleanTarget, cleanTarget)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2186,7 +2317,12 @@ func (s *Store) AddSpaceMember(currentUserID, spaceID, targetUserIdentifier, rol
|
|||||||
return nil, errors.New("benutzer nicht gefunden")
|
return nil, errors.New("benutzer nicht gefunden")
|
||||||
}
|
}
|
||||||
|
|
||||||
if role != "owner" && role != "editor" && role != "viewer" {
|
// Protect existing owner from role downgrade!
|
||||||
|
var existingRole string
|
||||||
|
_ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUser.ID).Scan(&existingRole)
|
||||||
|
if existingRole == "owner" {
|
||||||
|
role = "owner"
|
||||||
|
} else if role != "owner" && role != "editor" && role != "viewer" {
|
||||||
role = "editor"
|
role = "editor"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2210,6 +2346,135 @@ func (s *Store) AddSpaceMember(currentUserID, spaceID, targetUserIdentifier, rol
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) CreateSpaceTeamShare(currentUserID, spaceID, teamID, role string) (*domain.SpaceShare, error) {
|
||||||
|
var myRole string
|
||||||
|
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
|
||||||
|
if err != nil || (myRole != "owner" && myRole != "editor") {
|
||||||
|
return nil, errors.New("keine Berechtigung für diesen Bereich")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTeamID := strings.TrimSpace(teamID)
|
||||||
|
var teamName string
|
||||||
|
err = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, cleanTeamID).Scan(&teamName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New("team nicht gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
if role != "owner" && role != "editor" && role != "viewer" {
|
||||||
|
role = "editor"
|
||||||
|
}
|
||||||
|
|
||||||
|
now := domain.NowISO()
|
||||||
|
shareID := domain.NewID("spshare")
|
||||||
|
_, err = s.db.Exec(
|
||||||
|
`INSERT INTO space_team_shares (id, space_id, team_id, role, created_at, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(space_id, team_id) DO UPDATE SET role = excluded.role`,
|
||||||
|
shareID, spaceID, cleanTeamID, role, now, currentUserID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add team members as space members, but preserve owner role!
|
||||||
|
members, _ := s.GetTeamMembers(cleanTeamID)
|
||||||
|
for _, tm := range members {
|
||||||
|
var curRole string
|
||||||
|
_ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, tm.UserID).Scan(&curRole)
|
||||||
|
if curRole == "owner" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, _ = s.db.Exec(
|
||||||
|
`INSERT INTO space_members (space_id, user_id, role, joined_at) VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(space_id, user_id) DO UPDATE SET role = excluded.role`,
|
||||||
|
spaceID, tm.UserID, role, now,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &domain.SpaceShare{
|
||||||
|
ID: shareID,
|
||||||
|
SpaceID: spaceID,
|
||||||
|
TargetType: "team",
|
||||||
|
TargetID: cleanTeamID,
|
||||||
|
TargetName: teamName,
|
||||||
|
Role: role,
|
||||||
|
CreatedBy: currentUserID,
|
||||||
|
CreatedAt: now,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetSpaceShares(spaceID string) ([]domain.SpaceShare, error) {
|
||||||
|
shares := make([]domain.SpaceShare, 0)
|
||||||
|
|
||||||
|
// 1. Team shares
|
||||||
|
tRows, err := s.db.Query(
|
||||||
|
`SELECT s.id, s.space_id, s.team_id, t.name, s.role, s.created_by, s.created_at
|
||||||
|
FROM space_team_shares s
|
||||||
|
JOIN teams t ON s.team_id = t.id
|
||||||
|
WHERE s.space_id = ?
|
||||||
|
ORDER BY s.created_at ASC`,
|
||||||
|
spaceID,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
for tRows.Next() {
|
||||||
|
var sh domain.SpaceShare
|
||||||
|
if err := tRows.Scan(&sh.ID, &sh.SpaceID, &sh.TargetID, &sh.TargetName, &sh.Role, &sh.CreatedBy, &sh.CreatedAt); err == nil {
|
||||||
|
sh.TargetType = "team"
|
||||||
|
shares = append(shares, sh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = tRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Individual invited members (non-owner members)
|
||||||
|
var ownerID string
|
||||||
|
_ = s.db.QueryRow(`SELECT owner_id FROM spaces WHERE id = ?`, spaceID).Scan(&ownerID)
|
||||||
|
|
||||||
|
mRows, err := s.db.Query(
|
||||||
|
`SELECT sm.user_id, u.display_name, u.email, sm.role, sm.joined_at
|
||||||
|
FROM space_members sm
|
||||||
|
JOIN users u ON u.id = sm.user_id
|
||||||
|
WHERE sm.space_id = ? AND sm.user_id != ?
|
||||||
|
ORDER BY sm.joined_at ASC`,
|
||||||
|
spaceID, ownerID,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
for mRows.Next() {
|
||||||
|
var uid, dName, email, role, joinedAt string
|
||||||
|
if err := mRows.Scan(&uid, &dName, &email, &role, &joinedAt); err == nil {
|
||||||
|
name := dName
|
||||||
|
if name == "" {
|
||||||
|
name = email
|
||||||
|
}
|
||||||
|
shares = append(shares, domain.SpaceShare{
|
||||||
|
ID: "sm_" + uid,
|
||||||
|
SpaceID: spaceID,
|
||||||
|
TargetType: "user",
|
||||||
|
TargetID: uid,
|
||||||
|
TargetName: name,
|
||||||
|
Role: role,
|
||||||
|
CreatedAt: joinedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = mRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return shares, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) RemoveSpaceTeamShare(currentUserID, spaceID, targetID string) error {
|
||||||
|
var myRole string
|
||||||
|
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
|
||||||
|
if err != nil || (myRole != "owner" && myRole != "editor") {
|
||||||
|
return errors.New("keine Berechtigung zum Entfernen der Freigabe")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTarget := strings.TrimSpace(targetID)
|
||||||
|
_, err = s.db.Exec(`DELETE FROM space_team_shares WHERE space_id = ? AND (id = ? OR team_id = ?)`, spaceID, cleanTarget, cleanTarget)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) error {
|
func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) error {
|
||||||
var myRole string
|
var myRole string
|
||||||
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
|
err := s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, currentUserID).Scan(&myRole)
|
||||||
@@ -2217,12 +2482,17 @@ func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) e
|
|||||||
return errors.New("keine Berechtigung für diesen Bereich")
|
return errors.New("keine Berechtigung für diesen Bereich")
|
||||||
}
|
}
|
||||||
|
|
||||||
if currentUserID != targetUserID && myRole != "owner" {
|
cleanTarget := strings.TrimSpace(targetUserID)
|
||||||
|
if strings.HasPrefix(cleanTarget, "team_") || strings.HasPrefix(cleanTarget, "spshare_") {
|
||||||
|
return s.RemoveSpaceTeamShare(currentUserID, spaceID, cleanTarget)
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentUserID != cleanTarget && myRole != "owner" {
|
||||||
return errors.New("nur der Eigentümer kann andere Mitglieder entfernen")
|
return errors.New("nur der Eigentümer kann andere Mitglieder entfernen")
|
||||||
}
|
}
|
||||||
|
|
||||||
var targetRole string
|
var targetRole string
|
||||||
_ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUserID).Scan(&targetRole)
|
_ = s.db.QueryRow(`SELECT role FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, cleanTarget).Scan(&targetRole)
|
||||||
if targetRole == "owner" {
|
if targetRole == "owner" {
|
||||||
var ownerCount int
|
var ownerCount int
|
||||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM space_members WHERE space_id = ? AND role = 'owner'`, spaceID).Scan(&ownerCount)
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM space_members WHERE space_id = ? AND role = 'owner'`, spaceID).Scan(&ownerCount)
|
||||||
@@ -2231,7 +2501,7 @@ func (s *Store) RemoveSpaceMember(currentUserID, spaceID, targetUserID string) e
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = s.db.Exec(`DELETE FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, targetUserID)
|
_, err = s.db.Exec(`DELETE FROM space_members WHERE space_id = ? AND user_id = ?`, spaceID, cleanTarget)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2239,10 +2509,12 @@ func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) {
|
|||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT s.id, s.name, s.icon, s.description, s.invite_code, s.is_personal, s.owner_id, s.created_at
|
`SELECT s.id, s.name, s.icon, s.description, s.invite_code, s.is_personal, s.owner_id, s.created_at
|
||||||
FROM spaces s
|
FROM spaces s
|
||||||
JOIN space_members sm ON sm.space_id = s.id
|
LEFT JOIN space_members sm ON sm.space_id = s.id AND sm.user_id = ?
|
||||||
WHERE sm.user_id = ?
|
LEFT JOIN space_team_shares sts ON sts.space_id = s.id AND sts.team_id IN (SELECT team_id FROM team_members WHERE user_id = ?)
|
||||||
|
WHERE sm.user_id = ? OR sts.team_id IS NOT NULL
|
||||||
|
GROUP BY s.id
|
||||||
ORDER BY s.created_at ASC`,
|
ORDER BY s.created_at ASC`,
|
||||||
userID,
|
userID, userID, userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -2261,6 +2533,7 @@ func (s *Store) GetSpacesForUser(userID string) ([]domain.Space, error) {
|
|||||||
|
|
||||||
for i := range spaces {
|
for i := range spaces {
|
||||||
spaces[i].Members, _ = s.GetSpaceMembers(spaces[i].ID)
|
spaces[i].Members, _ = s.GetSpaceMembers(spaces[i].ID)
|
||||||
|
spaces[i].Shares, _ = s.GetSpaceShares(spaces[i].ID)
|
||||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaces[i].ID).Scan(&spaces[i].ListsCount)
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists WHERE space_id = ?`, spaces[i].ID).Scan(&spaces[i].ListsCount)
|
||||||
}
|
}
|
||||||
return spaces, nil
|
return spaces, nil
|
||||||
@@ -2729,12 +3002,15 @@ func (s *Store) AddTaskListShare(taskListID, targetType, targetID, createdBy str
|
|||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, created_at, created_by)
|
`INSERT INTO task_list_shares (id, task_list_id, target_type, target_id, created_at, created_by)
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(task_list_id, target_type, target_id) DO NOTHING`,
|
ON CONFLICT(task_list_id, target_type, target_id) DO UPDATE SET created_at = excluded.created_at`,
|
||||||
share.ID, share.TaskListID, share.TargetType, share.TargetID, share.CreatedAt, createdBy,
|
share.ID, share.TaskListID, share.TargetType, share.TargetID, share.CreatedAt, createdBy,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
_ = s.db.QueryRow(`SELECT id, created_at FROM task_list_shares WHERE task_list_id = ? AND target_type = ? AND target_id = ?`,
|
||||||
|
taskListID, targetType, targetID).Scan(&share.ID, &share.CreatedAt)
|
||||||
|
|
||||||
if targetType == "team" {
|
if targetType == "team" {
|
||||||
_, _ = s.db.Exec(`UPDATE task_lists SET team_id = ? WHERE id = ? AND (team_id = '' OR team_id IS NULL)`, targetID, taskListID)
|
_, _ = s.db.Exec(`UPDATE task_lists SET team_id = ? WHERE id = ? AND (team_id = '' OR team_id IS NULL)`, targetID, taskListID)
|
||||||
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName)
|
_ = s.db.QueryRow(`SELECT name FROM teams WHERE id = ?`, targetID).Scan(&share.TargetName)
|
||||||
@@ -3181,14 +3457,28 @@ func (s *Store) GetTeamsForOrg(orgID string) ([]domain.Team, error) {
|
|||||||
return teams, nil
|
return teams, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) GetTeamByID(teamID string) (*domain.Team, error) {
|
||||||
|
var t domain.Team
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT id, org_id, name, description, created_at FROM teams WHERE id = ?`,
|
||||||
|
teamID,
|
||||||
|
).Scan(&t.ID, &t.OrgID, &t.Name, &t.Description, &t.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
t.Members, _ = s.GetTeamMembers(t.ID)
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) {
|
func (s *Store) GetTeamsForUser(userID string) ([]domain.Team, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT DISTINCT t.id, t.org_id, t.name, t.description, t.created_at
|
`SELECT DISTINCT t.id, t.org_id, t.name, t.description, t.created_at
|
||||||
FROM teams t
|
FROM teams t
|
||||||
JOIN team_members tm ON tm.team_id = t.id
|
LEFT JOIN team_members tm ON tm.team_id = t.id
|
||||||
WHERE tm.user_id = ?
|
LEFT JOIN organizations o ON o.id = t.org_id
|
||||||
|
WHERE tm.user_id = ? OR o.owner_id = ?
|
||||||
ORDER BY t.created_at ASC`,
|
ORDER BY t.created_at ASC`,
|
||||||
userID,
|
userID, userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return []domain.Team{}, err
|
return []domain.Team{}, err
|
||||||
|
|||||||
Reference in new issue
Block a user