Dev #3
No files matched your search
@@ -0,0 +1,356 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
User as UserIcon,
|
||||||
|
X,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Lock,
|
||||||
|
Share2,
|
||||||
|
Check,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { User, Team, UnifiedShare } from '../lib/types';
|
||||||
|
import { UserAutocomplete } from '../features/UserAutocomplete';
|
||||||
|
|
||||||
|
export interface UnifiedShareModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
resourceName: string;
|
||||||
|
resourceIcon?: string | React.ReactNode;
|
||||||
|
isOwner: boolean;
|
||||||
|
currentUserId?: string;
|
||||||
|
shares: UnifiedShare[];
|
||||||
|
teams: Team[];
|
||||||
|
collaborators: User[];
|
||||||
|
allowRoleSelection?: boolean; // Default true (editor vs viewer)
|
||||||
|
onAddShare: (targetType: 'team' | 'user', targetId: string, permission: 'editor' | 'viewer') => Promise<void>;
|
||||||
|
onRemoveShare: (shareId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
title = 'Freigeben & Berechtigungen',
|
||||||
|
resourceName,
|
||||||
|
resourceIcon,
|
||||||
|
isOwner,
|
||||||
|
currentUserId,
|
||||||
|
shares,
|
||||||
|
teams,
|
||||||
|
collaborators,
|
||||||
|
allowRoleSelection = true,
|
||||||
|
onAddShare,
|
||||||
|
onRemoveShare,
|
||||||
|
}) => {
|
||||||
|
const [shareType, setShareType] = useState<'team' | 'user'>('team');
|
||||||
|
const [selectedTeamId, setSelectedTeamId] = useState('');
|
||||||
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
|
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>('editor');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleAdd = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
const targetId = shareType === 'team' ? selectedTeamId : selectedUser?.id;
|
||||||
|
if (!targetId) {
|
||||||
|
setError(shareType === 'team' ? 'Bitte eine Gruppe auswählen' : 'Bitte einen Benutzer auswählen');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onAddShare(shareType, targetId, selectedPermission);
|
||||||
|
setSelectedTeamId('');
|
||||||
|
setSelectedUser(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || 'Fehler beim Hinzufügen der Freigabe');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = async (shareId: string) => {
|
||||||
|
if (!window.confirm('Möchtest du diese Freigabe wirklich aufheben?')) return;
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onRemoveShare(shareId);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || 'Fehler beim Entfernen der Freigabe');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const excludedUserIds = [
|
||||||
|
...(currentUserId ? [currentUserId] : []),
|
||||||
|
...shares.filter((s) => s.targetType === 'user').map((s) => s.targetId),
|
||||||
|
];
|
||||||
|
|
||||||
|
const availableTeams = teams.filter(
|
||||||
|
(t) => !shares.some((s) => s.targetType === 'team' && s.targetId === t.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4 animate-in fade-in zoom-in-95 duration-150">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-border">
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<div className="w-8 h-8 rounded-btn bg-accent/15 text-accent flex items-center justify-center shrink-0">
|
||||||
|
{resourceIcon ? (
|
||||||
|
typeof resourceIcon === 'string' ? (
|
||||||
|
<span className="text-base">{resourceIcon}</span>
|
||||||
|
) : (
|
||||||
|
resourceIcon
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Share2 className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-sm font-bold text-main truncate">{title}</h3>
|
||||||
|
<p className="text-[11px] text-muted truncate">
|
||||||
|
Objekt: <span className="font-semibold text-main">{resourceName}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 text-muted hover:text-main rounded-btn hover:bg-panel transition-colors cursor-pointer"
|
||||||
|
title="Schließen"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Share Section (Only if owner / manager) */}
|
||||||
|
{isOwner ? (
|
||||||
|
<form onSubmit={handleAdd} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShareType('team');
|
||||||
|
setSelectedUser(null);
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
|
||||||
|
shareType === 'team'
|
||||||
|
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
|
||||||
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
<span>Gruppe</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShareType('user');
|
||||||
|
setSelectedTeamId('');
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
|
||||||
|
shareType === 'user'
|
||||||
|
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
|
||||||
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<UserIcon className="w-3.5 h-3.5" />
|
||||||
|
<span>Benutzer</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shareType === 'team' ? (
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<select
|
||||||
|
value={selectedTeamId}
|
||||||
|
onChange={(e) => setSelectedTeamId(e.target.value)}
|
||||||
|
className="input-field bg-panel h-8 px-2.5 text-xs flex-1 cursor-pointer"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<option value="" className="bg-panel text-muted">-- Gruppe auswählen --</option>
|
||||||
|
{availableTeams.map((t) => (
|
||||||
|
<option key={t.id} value={t.id} className="bg-panel text-main">
|
||||||
|
👥 {t.name} ({t.members?.length || 1} Mitglieder)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{allowRoleSelection && (
|
||||||
|
<select
|
||||||
|
value={selectedPermission}
|
||||||
|
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
||||||
|
className="input-field bg-panel h-8 px-2.5 text-xs w-full sm:w-36 cursor-pointer shrink-0"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||||
|
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<UserAutocomplete
|
||||||
|
users={collaborators}
|
||||||
|
selectedUser={selectedUser}
|
||||||
|
onSelectUser={setSelectedUser}
|
||||||
|
excludeUserIds={excludedUserIds}
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{allowRoleSelection && (
|
||||||
|
<select
|
||||||
|
value={selectedPermission}
|
||||||
|
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
||||||
|
className="input-field bg-panel h-8 px-2.5 text-xs w-full sm:w-36 cursor-pointer shrink-0"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||||
|
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || (shareType === 'team' ? !selectedTeamId : !selectedUser)}
|
||||||
|
className="btn-primary text-xs h-8 px-3.5 flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
<span>
|
||||||
|
{loading
|
||||||
|
? 'Wird gespeichert…'
|
||||||
|
: shareType === 'team'
|
||||||
|
? 'Gruppe freigeben'
|
||||||
|
: 'Benutzer hinzufügen'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="p-3 bg-panel-elevated border border-border rounded-card text-xs text-muted flex items-center gap-2">
|
||||||
|
<Lock className="w-4 h-4 text-faint shrink-0" />
|
||||||
|
<span>Nur der Eigentümer kann neue Freigaben einrichten.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Shares List */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5">
|
||||||
|
<span>Aktive Freigaben</span>
|
||||||
|
<span className="font-mono text-[11px] font-normal lowercase">
|
||||||
|
{shares.length} {shares.length === 1 ? 'Freigabe' : 'Freigaben'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-0.5">
|
||||||
|
{shares.length === 0 ? (
|
||||||
|
<div className="p-4 bg-canvas border border-border rounded-card text-xs text-muted flex items-center gap-2.5 justify-center text-center">
|
||||||
|
<Lock className="w-4 h-4 text-faint shrink-0" />
|
||||||
|
<span>Dieses Element ist aktuell privat (nur für dich sichtbar).</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
shares.map((sh) => {
|
||||||
|
const isTeam = sh.targetType === 'team';
|
||||||
|
const roleLabel =
|
||||||
|
sh.permission === 'viewer' || sh.role === 'viewer' ? 'Leser' : 'Bearbeiter';
|
||||||
|
|
||||||
|
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 hover:border-border/80 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<div
|
||||||
|
className={`w-7 h-7 rounded-btn flex items-center justify-center shrink-0 ${
|
||||||
|
isTeam
|
||||||
|
? 'bg-accent/15 text-accent'
|
||||||
|
: 'bg-surface-2 text-main border border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isTeam ? <Users className="w-3.5 h-3.5" /> : <UserIcon 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 className="truncate">{sh.targetName || (isTeam ? 'Gruppe' : sh.targetEmail || 'Benutzer')}</span>
|
||||||
|
<span
|
||||||
|
className={`text-[9px] uppercase font-semibold px-1 py-0.2 rounded ${
|
||||||
|
isTeam ? 'badge-neutral' : 'bg-surface-2 text-faint'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isTeam ? 'Gruppe' : 'Benutzer'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{sh.targetEmail && sh.targetEmail !== sh.targetName && (
|
||||||
|
<div className="text-[11px] text-muted truncate">{sh.targetEmail}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] px-2 py-0.5 rounded font-semibold ${
|
||||||
|
roleLabel === 'Bearbeiter'
|
||||||
|
? 'bg-accent/15 text-accent border border-accent/30'
|
||||||
|
: 'bg-surface-2 text-muted border border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{roleLabel}
|
||||||
|
</span>
|
||||||
|
{isOwner && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemove(sh.id)}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-1 text-faint hover:text-status-err rounded transition-colors cursor-pointer"
|
||||||
|
title="Freigabe entfernen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="pt-2 border-t border-border flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="btn-action text-xs h-8 px-4 cursor-pointer"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
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';
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
|
|
||||||
interface SharedListsViewProps {
|
interface SharedListsViewProps {
|
||||||
onRefreshGlobal: () => void;
|
onRefreshGlobal: () => void;
|
||||||
@@ -738,255 +739,81 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Space & Shopping List Sharing Modal */}
|
{/* Unified Space & Shopping List Sharing Modal */}
|
||||||
{showShareModal && activeSpace && (
|
{showShareModal && activeSpace && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs">
|
<UnifiedShareModal
|
||||||
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4">
|
isOpen={showShareModal}
|
||||||
{/* Modal Header */}
|
onClose={() => setShowShareModal(false)}
|
||||||
<div className="flex items-center justify-between pb-2 border-b border-border">
|
title={`Einkaufsliste teilen: ${activeSpace.name}`}
|
||||||
<div className="flex items-center gap-2">
|
resourceName={activeSpace.name}
|
||||||
<Share2 className="w-5 h-5 text-accent" />
|
resourceIcon={activeSpace.icon || '🛒'}
|
||||||
<h3 className="text-base font-bold text-main">
|
isOwner={activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin'}
|
||||||
Einkaufsliste teilen: {activeSpace.name}
|
currentUserId={currentUser?.id}
|
||||||
</h3>
|
shares={[
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setShowShareModal(false);
|
|
||||||
setShareError(null);
|
|
||||||
}}
|
|
||||||
className="btn-icon"
|
|
||||||
title="Schließen"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{shareError && (
|
|
||||||
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
|
|
||||||
{shareError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sharing Form (Team / Gruppe vs. Einzelner Benutzer) */}
|
|
||||||
<form onSubmit={handleAddShare} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setAddShareType('team');
|
|
||||||
setInviteTarget('');
|
|
||||||
}}
|
|
||||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
|
|
||||||
addShareType === 'team'
|
|
||||||
? 'bg-panel border-accent text-main'
|
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Users className="w-3 h-3 text-accent" />
|
|
||||||
<span>Team / Gruppe</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setAddShareType('user');
|
|
||||||
setSelectedTeamId('');
|
|
||||||
}}
|
|
||||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
|
|
||||||
addShareType === 'user'
|
|
||||||
? 'bg-panel border-accent text-main'
|
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<UserIcon className="w-3 h-3 text-accent" />
|
|
||||||
<span>Einzelner Benutzer</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{addShareType === 'team' ? (
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
|
||||||
<select
|
|
||||||
value={selectedTeamId}
|
|
||||||
onChange={(e) => setSelectedTeamId(e.target.value)}
|
|
||||||
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs flex-1 cursor-pointer"
|
|
||||||
>
|
|
||||||
<option value="" className="bg-panel text-muted">-- Team / Gruppe auswählen --</option>
|
|
||||||
{teams.map((t) => (
|
|
||||||
<option key={t.id} value={t.id} className="bg-panel text-main">
|
|
||||||
👥 {t.name} ({t.members?.length || 1} Mitglieder)
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={inviteRole}
|
|
||||||
onChange={(e) => setInviteRole(e.target.value as 'editor' | 'viewer')}
|
|
||||||
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full sm:w-36 cursor-pointer"
|
|
||||||
>
|
|
||||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
|
||||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<UserAutocomplete
|
|
||||||
users={collaborators}
|
|
||||||
selectedUser={selectedShareUser}
|
|
||||||
onSelectUser={setSelectedShareUser}
|
|
||||||
excludeUserIds={[
|
|
||||||
currentUser?.id || '',
|
|
||||||
...(activeSpace.members || []).map((m) => m.userId),
|
|
||||||
...(activeSpace.shares || [])
|
...(activeSpace.shares || [])
|
||||||
.filter((s) => s.targetType === 'user')
|
|
||||||
.map((s) => s.targetId),
|
|
||||||
].filter(Boolean)}
|
|
||||||
disabled={invitingLoading}
|
|
||||||
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
|
||||||
/>
|
|
||||||
</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 className="flex justify-end pt-1">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
invitingLoading ||
|
|
||||||
(addShareType === 'team' ? !selectedTeamId : !selectedShareUser)
|
|
||||||
}
|
|
||||||
className="btn-primary text-xs h-8 px-4 flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" />
|
|
||||||
<span>
|
|
||||||
{invitingLoading
|
|
||||||
? 'Wird geteilt…'
|
|
||||||
: addShareType === 'team'
|
|
||||||
? 'Team freigeben'
|
|
||||||
: 'Benutzer einladen'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Current Space Members & Team Shares List */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-xs font-semibold text-muted uppercase tracking-wider">
|
|
||||||
Aktive Freigaben & Mitglieder (
|
|
||||||
{((activeSpace.shares || []).filter((s) => s.targetType === 'team').length) +
|
|
||||||
(activeSpace.members?.length || 1)}
|
|
||||||
)
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5 max-h-56 overflow-y-auto">
|
|
||||||
{/* Team Shares */}
|
|
||||||
{(activeSpace.shares || [])
|
|
||||||
.filter((s) => s.targetType === 'team')
|
.filter((s) => s.targetType === 'team')
|
||||||
.map((sh) => {
|
.map((s) => ({
|
||||||
const canRemove =
|
id: s.id,
|
||||||
activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin';
|
targetType: 'team' as const,
|
||||||
return (
|
targetId: s.targetId,
|
||||||
<div
|
targetName: s.targetName || 'Gruppe',
|
||||||
key={sh.id}
|
permission: s.role === 'viewer' ? ('viewer' as const) : ('editor' as const),
|
||||||
className="p-2.5 bg-canvas border border-border rounded-btn flex items-center justify-between gap-3 text-xs"
|
role: s.role,
|
||||||
>
|
createdAt: s.createdAt,
|
||||||
<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">
|
...(activeSpace.members || [])
|
||||||
<Users className="w-3.5 h-3.5" />
|
.filter((m) => m.userId !== activeSpace.ownerId)
|
||||||
</div>
|
.map((m) => ({
|
||||||
<div className="truncate">
|
id: m.userId,
|
||||||
<div className="font-medium text-main truncate flex items-center gap-1.5">
|
targetType: 'user' as const,
|
||||||
<span>{sh.targetName || 'Team'}</span>
|
targetId: m.userId,
|
||||||
<span className="badge-neutral text-[9px] uppercase px-1 py-0.2">Gruppe</span>
|
targetName: m.displayName || m.email,
|
||||||
</div>
|
targetEmail: m.email,
|
||||||
<div className="text-[11px] text-muted truncate">Freigabe für die Gruppe</div>
|
permission: m.role === 'viewer' ? ('viewer' as const) : ('editor' as const),
|
||||||
</div>
|
role: m.role,
|
||||||
</div>
|
createdAt: m.joinedAt,
|
||||||
|
})),
|
||||||
<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">
|
teams={teams}
|
||||||
{sh.role === 'editor' ? 'Bearbeiter' : 'Leser'}
|
collaborators={collaborators}
|
||||||
</span>
|
allowRoleSelection={true}
|
||||||
{canRemove && (
|
onAddShare={async (targetType, targetId, permission) => {
|
||||||
<button
|
if (targetType === 'team') {
|
||||||
type="button"
|
await api.addSpaceMember(activeSpace.id, {
|
||||||
onClick={() => handleRemoveMember(sh.id)}
|
teamId: targetId,
|
||||||
disabled={invitingLoading}
|
role: permission,
|
||||||
className="btn-icon text-muted hover:text-status-danger p-1"
|
});
|
||||||
title="Team-Freigabe entfernen"
|
} else {
|
||||||
>
|
await api.addSpaceMember(activeSpace.id, {
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
userId: targetId,
|
||||||
</button>
|
role: permission,
|
||||||
)}
|
});
|
||||||
</div>
|
}
|
||||||
</div>
|
const [sp, freshShares] = await Promise.all([
|
||||||
);
|
api.getSpaces(),
|
||||||
})}
|
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
|
||||||
|
]);
|
||||||
{/* Individual Members */}
|
setSpaces(sp);
|
||||||
{(activeSpace.members || []).map((m) => {
|
const updated = sp.find((s) => s.id === activeSpace.id);
|
||||||
const isOwner = m.role === 'owner' || m.userId === activeSpace.ownerId;
|
if (updated) {
|
||||||
const isMe = m.userId === currentUser?.id;
|
setActiveSpace({ ...updated, shares: freshShares });
|
||||||
const canRemove =
|
}
|
||||||
(activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin') &&
|
onRefreshGlobal();
|
||||||
!isOwner;
|
}}
|
||||||
|
onRemoveShare={async (shareId) => {
|
||||||
return (
|
await api.removeSpaceMember(activeSpace.id, shareId);
|
||||||
<div
|
const [sp, freshShares] = await Promise.all([
|
||||||
key={m.userId}
|
api.getSpaces(),
|
||||||
className="p-2.5 bg-canvas border border-border rounded-btn flex items-center justify-between gap-3 text-xs"
|
api.getSpaceShares ? api.getSpaceShares(activeSpace.id).catch(() => []) : Promise.resolve([]),
|
||||||
>
|
]);
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
setSpaces(sp);
|
||||||
<div className="w-7 h-7 rounded-full bg-surface-2 flex items-center justify-center text-xs font-bold text-accent shrink-0">
|
const updated = sp.find((s) => s.id === activeSpace.id);
|
||||||
{(m.displayName || m.email || '?')[0].toUpperCase()}
|
if (updated) {
|
||||||
</div>
|
setActiveSpace({ ...updated, shares: freshShares });
|
||||||
<div className="truncate">
|
}
|
||||||
<div className="font-medium text-main truncate">
|
onRefreshGlobal();
|
||||||
{m.displayName || m.email} {isMe && <span className="text-muted">(Du)</span>}
|
}}
|
||||||
</div>
|
/>
|
||||||
<div className="text-[11px] text-muted truncate">{m.email}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
<span
|
|
||||||
className={`text-[10px] px-2 py-0.5 rounded font-semibold ${
|
|
||||||
isOwner
|
|
||||||
? 'bg-accent/15 text-accent'
|
|
||||||
: 'bg-surface-2 text-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isOwner ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Leser'}
|
|
||||||
</span>
|
|
||||||
{canRemove && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRemoveMember(m.userId)}
|
|
||||||
disabled={invitingLoading}
|
|
||||||
className="btn-icon text-muted hover:text-status-danger p-1"
|
|
||||||
title="Freigabe entfernen"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn } from '../../lib/types';
|
import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
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';
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
@@ -1233,222 +1234,62 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* SHARE LIST MODAL */}
|
{/* UNIFIED SHARE LIST MODAL */}
|
||||||
{showShareListModal && sharingTaskList && (
|
{showShareListModal && sharingTaskList && (
|
||||||
<div className="fixed inset-0 modal-overlay z-50 flex items-center justify-center p-4">
|
<UnifiedShareModal
|
||||||
<div className="w-full max-w-md panel-surface shadow-panel p-4 space-y-4">
|
isOpen={showShareListModal}
|
||||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
onClose={() => {
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-base">{sharingTaskList.icon || '📋'}</span>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-main">
|
|
||||||
Liste teilen: {sharingTaskList.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-[11px] text-muted">
|
|
||||||
{sharingTaskList.shares && sharingTaskList.shares.length > 0
|
|
||||||
? `${sharingTaskList.shares.length} Freigabe(n) aktiv`
|
|
||||||
: 'Aktuell privat (nur für dich)'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setShowShareListModal(false);
|
setShowShareListModal(false);
|
||||||
setSharingTaskList(null);
|
setSharingTaskList(null);
|
||||||
}}
|
}}
|
||||||
className="text-muted hover:text-main"
|
title={`Aufgabenliste teilen: ${sharingTaskList.title}`}
|
||||||
>
|
resourceName={sharingTaskList.title}
|
||||||
<X className="w-4 h-4" />
|
resourceIcon={sharingTaskList.icon || '📋'}
|
||||||
</button>
|
isOwner={sharingTaskList.isOwner !== false}
|
||||||
</div>
|
currentUserId={currentUser?.id}
|
||||||
|
shares={(sharingTaskList.shares || []).map((sh) => ({
|
||||||
<div className="space-y-4">
|
id: sh.id,
|
||||||
{/* Active Shares */}
|
targetType: sh.targetType,
|
||||||
<div>
|
targetId: sh.targetId,
|
||||||
<label className="block text-xs font-semibold text-main mb-1.5">
|
targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'),
|
||||||
Aktive Freigaben:
|
targetEmail: sh.email,
|
||||||
</label>
|
permission: 'editor' as const,
|
||||||
{sharingTaskList.shares && sharingTaskList.shares.length > 0 ? (
|
role: 'editor',
|
||||||
<div className="space-y-1.5 max-h-40 overflow-y-auto pr-1">
|
createdAt: sh.createdAt,
|
||||||
{sharingTaskList.shares.map((sh) => (
|
}))}
|
||||||
<div
|
teams={safeTeams}
|
||||||
key={sh.id}
|
collaborators={safeCollaborators}
|
||||||
className="p-2 bg-canvas border border-border rounded-card flex items-center justify-between text-xs"
|
allowRoleSelection={false}
|
||||||
>
|
onAddShare={async (targetType, targetId) => {
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
await api.addTaskListShare(sharingTaskList.id, {
|
||||||
<div className="w-6 h-6 rounded-pill bg-accent/20 text-accent flex items-center justify-center text-xs flex-shrink-0">
|
targetType,
|
||||||
{sh.targetType === 'team' ? (
|
targetId,
|
||||||
<Users className="w-3.5 h-3.5" />
|
});
|
||||||
) : (
|
const [freshLists, freshShares] = await Promise.all([
|
||||||
<UserIcon className="w-3.5 h-3.5" />
|
api.getTaskLists(),
|
||||||
)}
|
api.getTaskListShares(sharingTaskList.id).catch(() => []),
|
||||||
</div>
|
]);
|
||||||
<div className="min-w-0">
|
setTaskLists(freshLists);
|
||||||
<p className="font-semibold text-main truncate">
|
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
||||||
{sh.targetName || (sh.targetType === 'team' ? 'Team' : sh.email || 'Benutzer')}
|
if (updated) {
|
||||||
</p>
|
setSharingTaskList({ ...updated, shares: freshShares });
|
||||||
{sh.email && sh.email !== sh.targetName && (
|
}
|
||||||
<p className="text-[10px] text-muted truncate">{sh.email}</p>
|
await loadData();
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
<span className="badge-neutral text-[10px]">
|
|
||||||
{sh.targetType === 'team' ? 'Team' : 'Benutzer'}
|
|
||||||
</span>
|
|
||||||
{sharingTaskList.isOwner !== false && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRemoveShare(sh.id)}
|
|
||||||
disabled={shareLoading}
|
|
||||||
className="text-muted hover:text-status-err p-1 transition-colors"
|
|
||||||
title="Freigabe entfernen"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="p-2.5 bg-canvas border border-border rounded-card text-xs text-muted flex items-center gap-2">
|
|
||||||
<Lock className="w-3.5 h-3.5 text-muted flex-shrink-0" />
|
|
||||||
<span>Diese Liste ist privat. Füge unten ein Team oder einen Benutzer hinzu.</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add New Share */}
|
|
||||||
{sharingTaskList.isOwner !== false && (
|
|
||||||
<div className="space-y-2 pt-2 border-t border-border">
|
|
||||||
<label className="block text-xs font-semibold text-main">
|
|
||||||
Neue Freigabe hinzufügen:
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setAddShareType('team');
|
|
||||||
setSelectedShareUser(null);
|
|
||||||
}}
|
}}
|
||||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
|
onRemoveShare={async (shareId) => {
|
||||||
addShareType === 'team'
|
await api.removeTaskListShare(sharingTaskList.id, shareId);
|
||||||
? 'bg-panel border-accent text-main'
|
const [freshLists, freshShares] = await Promise.all([
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
api.getTaskLists(),
|
||||||
}`}
|
api.getTaskListShares(sharingTaskList.id).catch(() => []),
|
||||||
>
|
]);
|
||||||
<Users className="w-3 h-3 text-accent" />
|
setTaskLists(freshLists);
|
||||||
<span>Team / Gruppe</span>
|
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
||||||
</button>
|
if (updated) {
|
||||||
<button
|
setSharingTaskList({ ...updated, shares: freshShares });
|
||||||
type="button"
|
}
|
||||||
onClick={() => {
|
await loadData();
|
||||||
setAddShareType('user');
|
|
||||||
setSelectedShareTargetId('');
|
|
||||||
}}
|
}}
|
||||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
|
|
||||||
addShareType === 'user'
|
|
||||||
? 'bg-panel border-accent text-main'
|
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<UserIcon className="w-3 h-3 text-accent" />
|
|
||||||
<span>Einzelner Benutzer</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{addShareType === 'team' ? (
|
|
||||||
<select
|
|
||||||
value={selectedShareTargetId}
|
|
||||||
onChange={(e) => setSelectedShareTargetId(e.target.value)}
|
|
||||||
className="flex-1 h-8 px-2.5 input-field text-xs bg-panel"
|
|
||||||
>
|
|
||||||
<option value="">-- Team / Gruppe auswählen --</option>
|
|
||||||
{safeTeams
|
|
||||||
.filter((t) => !(sharingTaskList.shares || []).some((s) => s.targetType === 'team' && s.targetId === t.id))
|
|
||||||
.map((t) => (
|
|
||||||
<option key={t.id} value={t.id}>
|
|
||||||
👥 {t.name} ({t.members?.length || 1} Mitglieder)
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1">
|
|
||||||
<UserAutocomplete
|
|
||||||
users={safeCollaborators}
|
|
||||||
selectedUser={selectedShareUser}
|
|
||||||
onSelectUser={setSelectedShareUser}
|
|
||||||
excludeUserIds={[
|
|
||||||
currentUser?.id || '',
|
|
||||||
...(sharingTaskList.shares || [])
|
|
||||||
.filter((s) => s.targetType === 'user')
|
|
||||||
.map((s) => s.targetId),
|
|
||||||
].filter(Boolean)}
|
|
||||||
disabled={shareLoading}
|
|
||||||
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAddShare}
|
|
||||||
disabled={shareLoading || (addShareType === 'team' ? !selectedShareTargetId : !selectedShareUser)}
|
|
||||||
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-50 shrink-0"
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" />
|
|
||||||
<span>Freigeben</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Persons with access summary */}
|
|
||||||
{sharingTaskList.members && sharingTaskList.members.length > 0 && (
|
|
||||||
<div className="space-y-1.5 pt-2 border-t border-border">
|
|
||||||
<p className="text-xs font-semibold text-main">
|
|
||||||
Personen mit Zugriff ({sharingTaskList.members.length}):
|
|
||||||
</p>
|
|
||||||
<div className="max-h-36 overflow-y-auto space-y-1 pr-1">
|
|
||||||
{sharingTaskList.members.map((m) => (
|
|
||||||
<div
|
|
||||||
key={m.userId}
|
|
||||||
className="p-1.5 bg-canvas border border-border rounded-card flex items-center justify-between text-xs"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<div className="w-5 h-5 rounded-pill bg-accent/20 text-accent font-bold text-[9px] flex items-center justify-center flex-shrink-0">
|
|
||||||
{getInitials(m.displayName || m.email)}
|
|
||||||
</div>
|
|
||||||
<span className="font-medium text-main truncate">
|
|
||||||
{m.displayName || m.email}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="badge-neutral text-[10px] flex-shrink-0">
|
|
||||||
{m.role === 'owner' ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Mitglied'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-2 border-t border-border">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setShowShareListModal(false);
|
|
||||||
setSharingTaskList(null);
|
|
||||||
}}
|
|
||||||
className="btn-action text-xs"
|
|
||||||
>
|
|
||||||
Fertig
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* MANAGE COLUMNS MODAL */}
|
{/* MANAGE COLUMNS MODAL */}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails,
|
|||||||
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';
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
|
|
||||||
interface WorkspaceViewProps {
|
interface WorkspaceViewProps {
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
@@ -4328,291 +4329,53 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ===================================================================== */}
|
{/* UNIFIED DOCUMENT SHARING MODAL */}
|
||||||
{/* DOCUMENT SHARING MODAL */}
|
|
||||||
{/* ===================================================================== */}
|
|
||||||
{showShareModal && selectedDoc && (
|
{showShareModal && selectedDoc && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs">
|
<UnifiedShareModal
|
||||||
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4">
|
isOpen={showShareModal}
|
||||||
{/* Modal Header */}
|
onClose={() => setShowShareModal(false)}
|
||||||
<div className="flex items-center justify-between pb-2 border-b border-border">
|
title={`Notiz teilen: ${selectedDoc.title || 'Unbenannte Notiz'}`}
|
||||||
<div className="flex items-center gap-2">
|
resourceName={selectedDoc.title || 'Unbenannte Notiz'}
|
||||||
<Users className="w-5 h-5 text-accent" />
|
resourceIcon={selectedDoc.icon || '📄'}
|
||||||
<h3 className="text-base font-bold text-main">Notiz teilen & Berechtigungen</h3>
|
isOwner={selectedDoc.isOwner !== false}
|
||||||
</div>
|
currentUserId={currentUser?.id}
|
||||||
<button
|
shares={(docShares || []).map((sh) => ({
|
||||||
type="button"
|
id: sh.id,
|
||||||
onClick={() => setShowShareModal(false)}
|
targetType: sh.targetType || (sh.teamId ? 'team' : 'user'),
|
||||||
className="btn-icon"
|
targetId: sh.targetId || sh.teamId || sh.userId || '',
|
||||||
title="Schließen"
|
targetName: sh.targetName || sh.displayName || (sh.targetType === 'team' || sh.teamId ? 'Gruppe' : sh.userEmail || 'Benutzer'),
|
||||||
>
|
targetEmail: sh.userEmail,
|
||||||
<X className="w-4 h-4" />
|
permission: sh.permission === 'read' ? ('viewer' as const) : ('editor' as const),
|
||||||
</button>
|
role: sh.permission === 'read' ? 'viewer' : 'editor',
|
||||||
</div>
|
createdAt: sh.createdAt,
|
||||||
|
}))}
|
||||||
<div className="text-xs text-muted">
|
teams={shareTeams}
|
||||||
Dokument: <span className="font-semibold text-main">{selectedDoc.title || 'Unbenannte Notiz'}</span>
|
collaborators={collaborators}
|
||||||
</div>
|
allowRoleSelection={true}
|
||||||
|
onAddShare={async (targetType, targetId, permission) => {
|
||||||
{shareError && (
|
const perm = permission === 'viewer' ? 'read' : 'write';
|
||||||
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
|
if (targetType === 'team') {
|
||||||
{shareError}
|
await api.createDocShare(selectedDoc.id, {
|
||||||
</div>
|
teamId: targetId,
|
||||||
)}
|
permission: perm,
|
||||||
|
});
|
||||||
{/* If current user is owner: can add shares and manage permissions */}
|
} else {
|
||||||
{selectedDoc.isOwner !== false ? (
|
await api.createDocShare(selectedDoc.id, {
|
||||||
<div className="space-y-4">
|
userId: targetId,
|
||||||
<form onSubmit={handleAddShare} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
|
permission: perm,
|
||||||
<div className="flex items-center justify-between gap-2">
|
});
|
||||||
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setAddShareType('team');
|
|
||||||
setSelectedShareUser(null);
|
|
||||||
}}
|
|
||||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
|
|
||||||
addShareType === 'team'
|
|
||||||
? 'bg-panel border-accent text-main'
|
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<Users className="w-3 h-3 text-accent" />
|
|
||||||
<span>Team / Gruppe</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setAddShareType('user');
|
|
||||||
setSelectedShareTeamId('');
|
|
||||||
}}
|
|
||||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1 border transition-colors ${
|
|
||||||
addShareType === 'user'
|
|
||||||
? 'bg-panel border-accent text-main'
|
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<UserIcon className="w-3 h-3 text-accent" />
|
|
||||||
<span>Einzelner Benutzer</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{addShareType === 'team' ? (
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
|
||||||
<select
|
|
||||||
value={selectedShareTeamId}
|
|
||||||
onChange={(e) => setSelectedShareTeamId(e.target.value)}
|
|
||||||
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs flex-1 cursor-pointer"
|
|
||||||
>
|
|
||||||
<option value="" className="bg-panel text-muted">-- Team / Gruppe auswählen --</option>
|
|
||||||
{shareTeams.map((t) => (
|
|
||||||
<option key={t.id} value={t.id} className="bg-panel text-main">
|
|
||||||
👥 {t.name} ({t.members?.length || 1} Mitglieder)
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<select
|
|
||||||
value={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>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<UserAutocomplete
|
|
||||||
users={collaborators}
|
|
||||||
selectedUser={selectedShareUser}
|
|
||||||
onSelectUser={setSelectedShareUser}
|
|
||||||
excludeUserIds={[
|
|
||||||
currentUser?.id || '',
|
|
||||||
...docShares.map((s) => s.targetId || s.userId).filter(Boolean),
|
|
||||||
]}
|
|
||||||
disabled={sharingLoading}
|
|
||||||
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
|
||||||
/>
|
|
||||||
</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 className="flex justify-end pt-1">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={
|
|
||||||
sharingLoading ||
|
|
||||||
(addShareType === 'team' ? !selectedShareTeamId : !selectedShareUser)
|
|
||||||
}
|
}
|
||||||
className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5"
|
const shares = await api.getDocShares(selectedDoc.id);
|
||||||
>
|
setDocShares(shares);
|
||||||
<Plus className="w-3.5 h-3.5" />
|
await loadDocs(selectedDoc.id);
|
||||||
<span>
|
}}
|
||||||
{sharingLoading
|
onRemoveShare={async (shareId) => {
|
||||||
? 'Wird geteilt…'
|
await api.deleteDocShare(selectedDoc.id, shareId);
|
||||||
: addShareType === 'team'
|
const shares = await api.getDocShares(selectedDoc.id);
|
||||||
? 'Team freigeben'
|
setDocShares(shares);
|
||||||
: 'Benutzer freigeben'}
|
await loadDocs(selectedDoc.id);
|
||||||
</span>
|
}}
|
||||||
</button>
|
/>
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* List of current shares */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-xs font-semibold text-main">Aktuelle Freigaben ({docShares.length})</div>
|
|
||||||
{docShares.length === 0 ? (
|
|
||||||
<div className="p-3 bg-panel-elevated border border-border rounded-card text-xs text-muted text-center">
|
|
||||||
Diese Notiz ist bisher noch mit niemandem geteilt.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1.5 max-h-56 overflow-y-auto">
|
|
||||||
{docShares.map((share) => {
|
|
||||||
const isTeam = share.targetType === 'team' || Boolean((share as any).teamId) || String(share.targetId || '').startsWith('team_');
|
|
||||||
const targetKey = share.targetId || share.userId || share.id;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={share.id || targetKey}
|
|
||||||
className="p-2.5 bg-panel-elevated border border-border rounded-card flex items-center justify-between gap-3 text-xs"
|
|
||||||
>
|
|
||||||
<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 className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
{!isTeam && (
|
|
||||||
<select
|
|
||||||
value={share.permission}
|
|
||||||
onChange={(e) => handleUpdateSharePerm(share.userId, e.target.value as 'read' | 'write')}
|
|
||||||
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>
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
{isTeam && (
|
|
||||||
<span className="badge-neutral text-[10px]">
|
|
||||||
{share.permission === 'write' ? 'Schreiben' : 'Nur Lesen'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRemoveShare(targetKey)}
|
|
||||||
className="btn-icon btn-icon-danger h-7 w-7"
|
|
||||||
title="Freigabe entziehen"
|
|
||||||
disabled={sharingLoading}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
/* If current user is a collaborator */
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* Document & Owner Card */}
|
|
||||||
<div className="p-3 bg-panel-elevated border border-border rounded-card space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-muted">Eigentümer der Notiz:</span>
|
|
||||||
<span className="font-semibold text-main flex items-center gap-1.5">
|
|
||||||
<span className="w-5 h-5 rounded-full bg-accent/20 text-accent text-[10px] font-bold flex items-center justify-center">
|
|
||||||
{(selectedDoc.ownerName || 'U').slice(0, 1).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<span>{selectedDoc.ownerName || 'Unbekannt'}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Permission Card */}
|
|
||||||
{selectedDoc.permission === 'write' ? (
|
|
||||||
<div className="p-3 bg-emerald-500/10 border border-emerald-500/30 rounded-card flex items-start gap-3">
|
|
||||||
<div className="w-8 h-8 rounded-btn bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
||||||
<Edit3 className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div className="text-xs space-y-0.5">
|
|
||||||
<div className="font-semibold text-emerald-600 dark:text-emerald-400">Schreibzugriff aktiv</div>
|
|
||||||
<div className="text-muted">
|
|
||||||
Du hast Bearbeitungsrechte für diese Notiz und kannst Inhalte in Echtzeit anpassen.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="p-3 bg-accent/10 border border-accent/30 rounded-card flex items-start gap-3">
|
|
||||||
<div className="w-8 h-8 rounded-btn bg-accent/20 text-accent flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div className="text-xs space-y-0.5">
|
|
||||||
<div className="font-semibold text-accent">Nur Lesezugriff</div>
|
|
||||||
<div className="text-muted">
|
|
||||||
Du kannst diese Notiz ansehen und durchsuchen, aber keine Änderungen an den Inhalten vornehmen.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Action: Leave Share */}
|
|
||||||
<div className="pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleLeaveShare}
|
|
||||||
className="w-full btn-action h-8 px-3 text-xs text-status-err hover:bg-status-err-bg border border-status-err-border rounded-btn flex items-center justify-center gap-1.5 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
<span>Notiz aus meinem Workspace entfernen</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-end pt-2 border-t border-border">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowShareModal(false)}
|
|
||||||
className="btn-action h-8 px-4 text-xs font-medium"
|
|
||||||
>
|
|
||||||
Schließen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* LINK INSERTION MODAL (/link, /notiz, /web) */}
|
{/* LINK INSERTION MODAL (/link, /notiz, /web) */}
|
||||||
|
|||||||
@@ -243,6 +243,18 @@ export interface SpaceMember {
|
|||||||
joinedAt: string;
|
joinedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UnifiedShare {
|
||||||
|
id: string;
|
||||||
|
targetType: 'team' | 'user';
|
||||||
|
targetId: string;
|
||||||
|
targetName?: string;
|
||||||
|
targetEmail?: string;
|
||||||
|
permission?: 'editor' | 'viewer';
|
||||||
|
role?: string;
|
||||||
|
createdBy?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SpaceShare {
|
export interface SpaceShare {
|
||||||
id: string;
|
id: string;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
|
|||||||
+12
-20
@@ -2374,21 +2374,6 @@ func (s *Store) CreateSpaceTeamShare(currentUserID, spaceID, teamID, role string
|
|||||||
return nil, err
|
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{
|
return &domain.SpaceShare{
|
||||||
ID: shareID,
|
ID: shareID,
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
@@ -2596,12 +2581,13 @@ func (s *Store) GetListsForSpace(spaceID string) ([]domain.SharedList, error) {
|
|||||||
|
|
||||||
func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
|
func (s *Store) GetAllListsForUser(userID string) ([]domain.SharedList, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT l.id, l.space_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at
|
`SELECT DISTINCT l.id, l.space_id, l.title, l.icon, l.list_type, l.updated_at, l.created_at
|
||||||
FROM lists l
|
FROM lists l
|
||||||
JOIN space_members sm ON sm.space_id = l.space_id
|
LEFT JOIN space_members sm ON sm.space_id = l.space_id AND sm.user_id = ?
|
||||||
WHERE sm.user_id = ?
|
LEFT JOIN space_team_shares sts ON sts.space_id = l.space_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
|
||||||
ORDER BY l.updated_at DESC`,
|
ORDER BY l.updated_at DESC`,
|
||||||
userID,
|
userID, userID, userID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -2840,7 +2826,13 @@ func (s *Store) GetDashboardStats(userID string) (*domain.DashboardStats, error)
|
|||||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) AND is_completed = 1`, userID).Scan(&st.TasksDoneCount)
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND (status = 'kept' OR (status = 'converted' AND converted_type = 'task')) AND is_completed = 1`, userID).Scan(&st.TasksDoneCount)
|
||||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ? AND is_archived = 0`, userID).Scan(&st.DocsCount)
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE user_id = ? AND is_archived = 0`, userID).Scan(&st.DocsCount)
|
||||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID).Scan(&st.DiscardedCount)
|
_ = s.db.QueryRow(`SELECT COUNT(*) FROM thoughts WHERE user_id = ? AND status = 'discarded'`, userID).Scan(&st.DiscardedCount)
|
||||||
_ = s.db.QueryRow(`SELECT COUNT(*) FROM lists l JOIN space_members sm ON sm.space_id = l.space_id WHERE sm.user_id = ?`, userID).Scan(&st.ListsCount)
|
_ = s.db.QueryRow(`
|
||||||
|
SELECT COUNT(DISTINCT l.id)
|
||||||
|
FROM lists l
|
||||||
|
LEFT JOIN space_members sm ON sm.space_id = l.space_id AND sm.user_id = ?
|
||||||
|
LEFT JOIN space_team_shares sts ON sts.space_id = l.space_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`,
|
||||||
|
userID, userID, userID).Scan(&st.ListsCount)
|
||||||
|
|
||||||
// Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared)
|
// Count open (uncompleted) To-Dos across accessible workspace documents (owned or shared)
|
||||||
docRows, err := s.db.Query(`
|
docRows, err := s.db.Query(`
|
||||||
|
|||||||
Reference in new issue
Block a user