Files
MindSift/frontend/src/features/workspace/WorkspaceView.tsx
T
sandrodach 94acb2638e
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 19s
fix(workspace): fix outliner cursor focus, enhanced task linking, nav order and Hauptebene drop zone
2026-10-27 20:26:45 +01:00

2310 lines
94 KiB
TypeScript

import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
FilePlus,
FolderPlus,
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
Search,
Pin,
Trash2,
Check,
Hash,
FileText,
Users,
Plus,
X,
ExternalLink,
Sparkles,
ArrowLeft,
CheckSquare,
PanelLeftClose,
PanelLeftOpen,
ChevronLeft,
Radio,
Clock,
AlertTriangle,
Loader2,
Lock,
} from 'lucide-react';
import {
Document,
DocumentBlock,
DocShare,
SharedFolder,
TagDetails,
TagSummary,
Team,
UnifiedShare,
User,
} from '../../lib/types';
import {
api,
onWebSocketMessage,
triggerHaptic,
onRealtimeStatus,
isRealtimeConnected,
getRealtimeTransport,
RealtimeTransport,
} from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
interface WorkspaceViewProps {
currentUser?: User | null;
initialDocId?: string | null;
initialBlockId?: string | null;
onStatsChange: () => void;
onOpenTask?: (taskId: string) => void;
}
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags';
interface FolderTreeNode {
name: string;
fullPath: string;
subfolders: FolderTreeNode[];
docs: Document[];
sharedFolderObj?: SharedFolder;
}
interface FolderTreeItemProps {
node: FolderTreeNode;
depth: number;
expandedFolders: Record<string, boolean>;
setExpandedFolders: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
selectedDocId: string | null;
subView: WorkspaceSubView;
dragOverFolder: string | null;
setDragOverFolder: (f: string | null) => void;
handleDropOnFolder: (folderPath: string, e: React.DragEvent) => void;
handleFolderDragStart: (e: React.DragEvent, path: string) => void;
handleDocDragStart: (e: React.DragEvent, id: string) => void;
handleCreateDocument: (folderPath?: string) => void;
handleOpenNewFolder: (parentPath?: string, e?: React.MouseEvent) => void;
handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void;
handleDeleteFolder: (folderPath: string) => void;
onSelectDoc: (id: string) => void;
}
const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
node,
depth,
expandedFolders,
setExpandedFolders,
selectedDocId,
subView,
dragOverFolder,
setDragOverFolder,
handleDropOnFolder,
handleFolderDragStart,
handleDocDragStart,
handleCreateDocument,
handleOpenNewFolder,
handleShareFolderClick,
handleDeleteFolder,
onSelectDoc,
}) => {
const hasContent = node.subfolders.length > 0 || node.docs.length > 0;
const isExpanded = Boolean(hasContent && expandedFolders[node.fullPath]);
const isDragOver = dragOverFolder === node.fullPath;
return (
<div className="space-y-0.5">
<div
draggable
onDragStart={(e) => handleFolderDragStart(e, node.fullPath)}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverFolder(node.fullPath);
}}
onDragLeave={(e) => {
e.preventDefault();
if (dragOverFolder === node.fullPath) setDragOverFolder(null);
}}
onDrop={(e) => handleDropOnFolder(node.fullPath, e)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
className={`group flex items-center justify-between pr-1.5 h-7 rounded-btn text-xs cursor-pointer border-l-2 transition-colors ${
isDragOver
? 'bg-accent/20 border-accent text-main font-semibold'
: 'border-transparent hover:bg-panel-elevated text-muted'
}`}
>
<button
type="button"
onClick={() =>
hasContent &&
setExpandedFolders((prev) => ({
...prev,
[node.fullPath]: !prev[node.fullPath],
}))
}
className="flex items-center gap-1.5 flex-1 min-w-0 text-left h-full cursor-pointer"
>
{hasContent ? (
isExpanded ? (
<ChevronDown className="w-3.5 h-3.5 text-faint shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-faint shrink-0" />
)
) : null}
{isExpanded ? (
<FolderOpen className="w-3.5 h-3.5 text-accent shrink-0" />
) : (
<Folder className="w-3.5 h-3.5 text-accent shrink-0" />
)}
<span className="truncate">{node.name}</span>
{node.sharedFolderObj && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
)}
</button>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity shrink-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleCreateDocument(node.fullPath);
}}
className="p-1 h-6 w-6 flex items-center justify-center text-muted hover:text-main transition-colors cursor-pointer"
title="Notiz in diesem Ordner erstellen"
>
<Plus className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleOpenNewFolder(node.fullPath, e);
}}
className="p-1 h-6 w-6 flex items-center justify-center text-muted hover:text-main transition-colors cursor-pointer"
title="Neuen Unterordner erstellen"
>
<FolderPlus className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleShareFolderClick(node.fullPath, node.sharedFolderObj);
}}
className={`p-1 h-6 w-6 flex items-center justify-center transition-colors cursor-pointer ${
node.sharedFolderObj ? 'text-accent' : 'text-faint hover:text-accent'
}`}
title="Ordner teilen und Berechtigungen"
>
<Users className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(node.fullPath);
}}
className="p-1 h-6 w-6 flex items-center justify-center text-faint hover:text-status-err transition-colors cursor-pointer"
title="Ordner löschen"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
{isExpanded && (
<div className="space-y-0.5">
{/* Subfolders in this folder */}
{node.subfolders.map((childNode) => (
<FolderTreeItem
key={childNode.fullPath}
node={childNode}
depth={depth + 1}
expandedFolders={expandedFolders}
setExpandedFolders={setExpandedFolders}
selectedDocId={selectedDocId}
subView={subView}
dragOverFolder={dragOverFolder}
setDragOverFolder={setDragOverFolder}
handleDropOnFolder={handleDropOnFolder}
handleFolderDragStart={handleFolderDragStart}
handleDocDragStart={handleDocDragStart}
handleCreateDocument={handleCreateDocument}
handleOpenNewFolder={handleOpenNewFolder}
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={onSelectDoc}
/>
))}
{/* Notes directly in this folder */}
{node.docs.map((d) => (
<div
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
onClick={() => onSelectDoc(d.id)}
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
className={`w-full flex items-center gap-2 pr-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
</div>
))}
{!hasContent && (
<div
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
className="py-1 text-[10px] text-faint italic"
>
Leer
</div>
)}
</div>
)}
</div>
);
};
export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
currentUser,
initialDocId,
initialBlockId,
onStatsChange,
onOpenTask,
}) => {
const [docs, setDocs] = useState<Document[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [sharedFolders, setSharedFolders] = useState<SharedFolder[]>([]);
const [serverTagsSummary, setServerTagsSummary] = useState<TagSummary[]>([]);
// Dynamically compute hashtag counts directly from in-memory documents
const tagsSummary = useMemo(() => {
if (docs.length === 0) return serverTagsSummary;
const tagMap = new Map<string, number>();
for (const d of docs) {
for (const b of d.blocks || []) {
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
if (matches) {
for (const m of matches) {
const clean = m.slice(1).toLowerCase();
tagMap.set(clean, (tagMap.get(clean) || 0) + 1);
}
}
}
}
const list: TagSummary[] = [];
tagMap.forEach((count, tag) => {
list.push({ tag, count });
});
return list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
}, [docs, serverTagsSummary]);
const [selectedDocId, setSelectedDocId] = useState<string | null>(initialDocId || null);
const [targetBlockId, setTargetBlockId] = useState<string | null>(initialBlockId || null);
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
const [subView, setSubView] = useState<WorkspaceSubView>('editor');
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
const [tagNoteDraft, setTagNoteDraft] = useState<string>('');
const [tagSearchQuery, setTagSearchQuery] = useState<string>('');
const [tagSortBy, setTagSortBy] = useState<'count' | 'name'>('count');
const [searchQuery, setSearchQuery] = useState<string>('');
const [expandedFolders, setExpandedFolders] = useState<Record<string, boolean>>(() => {
try {
const saved = localStorage.getItem('mindsift:workspace_expanded_folders');
return saved ? JSON.parse(saved) : {};
} catch {
return {};
}
});
useEffect(() => {
try {
localStorage.setItem('mindsift:workspace_expanded_folders', JSON.stringify(expandedFolders));
} catch {}
}, [expandedFolders]);
const [showNewFolderModal, setShowNewFolderModal] = useState<boolean>(false);
const [newFolderCoords, setNewFolderCoords] = useState<{ x: number; y: number } | null>(null);
const [newFolderParentPath, setNewFolderParentPath] = useState<string>('');
const [newFolderName, setNewFolderName] = useState<string>('');
const [newFolderIsShared, setNewFolderIsShared] = useState<boolean>(false);
const handleOpenNewFolder = (parentPath = '', e?: React.MouseEvent) => {
setNewFolderParentPath(parentPath);
setNewFolderName('');
if (e) {
setNewFolderCoords({
x: Math.min(e.clientX, window.innerWidth - 270),
y: Math.min(e.clientY, window.innerHeight - 130),
});
} else {
setNewFolderCoords({ x: 100, y: 150 });
}
setShowNewFolderModal(true);
};
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(Boolean(initialDocId));
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState<boolean>(() => {
try {
return localStorage.getItem('mindsift:workspace_sidebar_collapsed') === 'true';
} catch {
return false;
}
});
const toggleSidebar = useCallback(() => {
setIsSidebarCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem('mindsift:workspace_sidebar_collapsed', String(next));
} catch {}
return next;
});
}, []);
// Realtime connection status for top header indicator (identical to SharedListsView)
const [realtimeConnected, setRealtimeConnected] = useState<boolean>(() => isRealtimeConnected());
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>(() => getRealtimeTransport());
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
const [docSaveError, setDocSaveError] = useState<string | null>(null);
useEffect(() => {
const unsub = onRealtimeStatus((connected, transport) => {
setRealtimeConnected(connected);
setRealtimeTransport(transport);
});
return unsub;
}, []);
// Drag and Drop state
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
// Rollup drilldown & View Mode
const [todoFolderDrilldown, setTodoFolderDrilldown] = useState<string | null>(null);
const [todoGrouping, setTodoGrouping] = useState<'grouped' | 'list'>(() => {
const saved = localStorage.getItem('mindsift_todo_grouping');
return saved === 'list' ? 'list' : 'grouped';
});
const [collapsedTodoFolders, setCollapsedTodoFolders] = useState<Set<string>>(new Set());
const [collapsedTodoNotes, setCollapsedTodoNotes] = useState<Set<string>>(new Set());
// Sharing Modal State
const [shareModalDoc, setShareModalDoc] = useState<Document | null>(null);
const [shareModalFolder, setShareModalFolder] = useState<SharedFolder | null>(null);
const [activeDocShares, setActiveDocShares] = useState<DocShare[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [collaborators, setCollaborators] = useState<User[]>([]);
const isCreatingRef = useRef<boolean>(false);
const { setGroups, setPrimaryAction } = useMobileActions();
const loadWorkspaceData = useCallback(async () => {
try {
const [docsRes, foldersRes, sharedFoldersRes, tagsRes] = await Promise.all([
api.getDocuments(),
api.getFolders(),
api.getSharedFolders().catch(() => []),
api.getWorkspaceTags().catch(() => []),
]);
setDocs(docsRes);
setFolders(foldersRes);
setSharedFolders(sharedFoldersRes);
setServerTagsSummary(tagsRes);
setSelectedDocId((prevId) => {
if (prevId && docsRes.some((d) => d.id === prevId)) {
return prevId;
}
return docsRes.length > 0 ? docsRes[0].id : null;
});
} catch {
// ignore
}
}, []);
useEffect(() => {
loadWorkspaceData();
}, [loadWorkspaceData]);
useEffect(() => {
if (initialDocId) {
setSelectedDocId(initialDocId);
setSubView('editor');
setMobileShowDetail(true);
}
}, [initialDocId]);
// Global WebSocket listener with strict isolation & deduplication
useEffect(() => {
const unsub = onWebSocketMessage((msg) => {
if (msg.event === 'DOC_CREATED' && msg.payload) {
const created = msg.payload as Document;
// Strict Privacy Check: Only add if created by or shared with current user
if (currentUser) {
const isOwner = created.userId === currentUser.id;
const isShared =
created.sharedWith?.some(
(s) => s.userId === currentUser.id || s.targetId === currentUser.id
) || false;
const isFolderShared = Boolean(created.sharedFolderId || created.isSharedFolder);
if (!isOwner && !isShared && !isFolderShared) {
return;
}
}
setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev]));
} else if (msg.event === 'DOC_UPDATED' && msg.payload) {
const updated = msg.payload as Document;
setDocs((prev) => prev.map((d) => (d.id === updated.id ? { ...d, ...updated } : d)));
} else if (msg.event === 'DOC_DELETED' && msg.payload?.id) {
const delId = msg.payload.id as string;
setDocs((prev) => prev.filter((d) => d.id !== delId));
setSelectedDocId((prev) => (prev === delId ? null : prev));
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
api.getFolders().then(setFolders).catch(() => {});
api.getSharedFolders().then(setSharedFolders).catch(() => {});
api.getDocuments().then(setDocs).catch(() => {});
}
});
return unsub;
}, [currentUser]);
const handleCreateDocument = useCallback(
async (folderPath = '') => {
if (isCreatingRef.current) return;
isCreatingRef.current = true;
triggerHaptic(10);
try {
const created = await api.createDocument({
title: 'Neue Notiz',
icon: '📝',
folderPath,
blocks: [
{
id: `blk_${Date.now().toString(36)}_0`,
type: 'text',
content: '',
level: 0,
position: 1000,
},
],
});
setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev]));
setNewlyCreatedDocId(created.id);
setTargetBlockId(null);
setSelectedDocId(created.id);
setSubView('editor');
setMobileShowDetail(true);
if (folderPath) {
setExpandedFolders((prev) => ({ ...prev, [folderPath]: true }));
}
onStatsChange();
} catch {
// ignore
} finally {
setTimeout(() => {
isCreatingRef.current = false;
}, 300);
}
},
[onStatsChange]
);
const handleJumpToDocBlock = useCallback((docId: string, blockId?: string) => {
setSelectedDocId(docId);
setTargetBlockId(blockId || null);
setNewlyCreatedDocId(null);
setSubView('editor');
setMobileShowDetail(true);
}, []);
// Register mobile FAB actions
useEffect(() => {
setPrimaryAction(() => handleCreateDocument(''));
setGroups([
{
id: 'workspace-actions',
title: 'Notizen & Ordner',
items: [
{
id: 'new-note',
label: 'Neue Notiz',
icon: <FilePlus className="w-4 h-4" />,
variant: 'accent',
onClick: () => handleCreateDocument(''),
},
{
id: 'new-folder',
label: 'Neuer Ordner',
icon: <FolderPlus className="w-4 h-4" />,
onClick: () => setShowNewFolderModal(true),
},
],
},
]);
return () => {
setGroups([]);
setPrimaryAction(undefined);
};
}, [handleCreateDocument, setGroups, setPrimaryAction]);
const handleDeleteDocument = useCallback(
async (docId: string) => {
try {
await api.deleteDocument(docId);
setDocs((prev) => {
const remaining = prev.filter((d) => d.id !== docId);
if (selectedDocId === docId) {
setSelectedDocId(remaining.length > 0 ? remaining[0].id : null);
}
return remaining;
});
setMobileShowDetail(false);
onStatsChange();
} catch {
// ignore
}
},
[onStatsChange, selectedDocId]
);
const handleDocMetadataChange = useCallback((updated: Document) => {
setDocs((prev) => prev.map((d) => (d.id === updated.id ? { ...d, ...updated } : d)));
}, []);
const handleCreateFolderSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const cleanName = newFolderName.trim().replace(/^\/+|\/+$/g, '');
if (!cleanName) return;
const cleanPath = newFolderParentPath ? `${newFolderParentPath}/${cleanName}` : cleanName;
try {
const res = await api.createFolder(cleanPath);
setFolders((prev) => (prev.includes(res.path) ? prev : [...prev, res.path].sort()));
const parts = res.path.split('/');
let acc = '';
const expansions: Record<string, boolean> = {};
for (const part of parts) {
acc = acc ? `${acc}/${part}` : part;
expansions[acc] = true;
}
setExpandedFolders((prev) => ({ ...prev, ...expansions }));
setNewFolderName('');
setNewFolderParentPath('');
setShowNewFolderModal(false);
} catch (err: any) {
alert(err.message || 'Fehler beim Erstellen des Ordners');
}
};
const handleDeleteFolder = async (folderPath: string) => {
try {
const res = await api.deleteFolder(folderPath);
setFolders((prev) =>
prev.filter((f) => f !== folderPath && !f.startsWith(`${folderPath}/`))
);
if (res.deletedDocIds && res.deletedDocIds.length > 0) {
setDocs((prev) => prev.filter((d) => !res.deletedDocIds.includes(d.id)));
if (selectedDocId && res.deletedDocIds.includes(selectedDocId)) {
setSelectedDocId(null);
}
}
onStatsChange();
} catch {
// ignore
}
};
// HTML5 Drag & Drop handlers for moving documents into folders
const handleDocDragStart = (e: React.DragEvent, docId: string) => {
e.stopPropagation();
e.dataTransfer.setData('application/mindsift-doc-id', docId);
e.dataTransfer.setData('text/plain', docId);
e.dataTransfer.effectAllowed = 'move';
setDraggingDocId(docId);
};
const handleFolderDragStart = (e: React.DragEvent, path: string) => {
e.stopPropagation();
e.dataTransfer.setData('application/mindsift-folder-path', path);
e.dataTransfer.setData('text/plain', path);
e.dataTransfer.effectAllowed = 'move';
setDraggingFolderPath(path);
};
const handleDropOnFolder = async (targetFolder: string, e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOverFolder(null);
const docId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
const folderPath = e.dataTransfer.getData('application/mindsift-folder-path') || draggingFolderPath;
setDraggingDocId(null);
setDraggingFolderPath(null);
if (docId) {
triggerHaptic(15);
try {
const updatedDoc = await api.moveDocument(docId, targetFolder);
setDocs((prev) =>
prev.map((d) => (d.id === docId ? { ...d, folderPath: updatedDoc.folderPath } : d))
);
if (targetFolder) {
const parts = targetFolder.split('/');
let acc = '';
const expansions: Record<string, boolean> = {};
for (const part of parts) {
acc = acc ? `${acc}/${part}` : part;
expansions[acc] = true;
}
setExpandedFolders((prev) => ({ ...prev, ...expansions }));
}
} catch {
// ignore
}
} else if (folderPath && folderPath !== targetFolder && !targetFolder.startsWith(`${folderPath}/`)) {
triggerHaptic(15);
try {
await api.moveFolder(folderPath, targetFolder);
if (targetFolder) {
const parts = targetFolder.split('/');
let acc = '';
const expansions: Record<string, boolean> = {};
for (const part of parts) {
acc = acc ? `${acc}/${part}` : part;
expansions[acc] = true;
}
setExpandedFolders((prev) => ({ ...prev, ...expansions }));
}
loadWorkspaceData();
} catch {
// ignore
}
}
};
const handleSelectTag = useCallback(async (tag: string) => {
const cleanTag = tag.replace(/^#/, '').toLowerCase();
setSelectedTag(cleanTag);
setSubView('tag_detail');
setMobileShowDetail(true);
try {
const details = await api.getTagDetails(cleanTag);
setTagDetails(details);
setTagNoteDraft(details.noteContent || '');
} catch {
setTagDetails(null);
}
}, []);
const handleSaveTagNote = async () => {
if (!selectedTag) return;
try {
await api.updateTagPageNote(selectedTag, tagNoteDraft);
} catch {
// ignore
}
};
const handleWikiLinkClick = useCallback(
(pageTitle: string) => {
const clean = pageTitle.trim().toLowerCase();
const match = docs.find((d) => d.title.trim().toLowerCase() === clean);
if (match) {
setSelectedDocId(match.id);
setSubView('editor');
setMobileShowDetail(true);
} else {
handleCreateDocument('');
}
},
[docs, handleCreateDocument]
);
const openDocShareModal = useCallback(async (doc: Document) => {
setShareModalDoc(doc);
setShareModalFolder(null);
try {
const [sharesRes, teamsRes, collabRes] = await Promise.all([
api.getDocShares(doc.id).catch(() => []),
api.getUserTeams().catch(() => []),
api.getUsersDirectory().catch(() => []),
]);
setActiveDocShares(sharesRes);
setTeams(teamsRes);
setCollaborators(collabRes);
} catch {
// ignore
}
}, []);
const openFolderShareModal = useCallback(async (sf: SharedFolder) => {
setShareModalFolder(sf);
setShareModalDoc(null);
try {
const [teamsRes, collabRes, freshSf] = await Promise.all([
api.getUserTeams().catch(() => []),
api.getUsersDirectory().catch(() => []),
api.getSharedFolder(sf.id).catch(() => sf),
]);
setShareModalFolder(freshSf);
setTeams(teamsRes);
setCollaborators(collabRes);
} catch {
// ignore
}
}, []);
const handleShareFolderClick = useCallback(
async (folderPath: string, existingSf?: SharedFolder) => {
if (existingSf) {
openFolderShareModal(existingSf);
return;
}
try {
const clean = folderPath.trim().replace(/^\/+|\/+$/g, '');
const sf = await api.createSharedFolder({ name: clean, mountPath: clean });
setSharedFolders((prev) => [...prev.filter((f) => f.id !== sf.id), sf]);
openFolderShareModal(sf);
} catch {
// ignore
}
},
[openFolderShareModal]
);
const handleToggleRollupTodo = async (doc: Document, blockId: string, currentChecked: boolean) => {
triggerHaptic(10);
const updatedBlocks = (doc.blocks || []).map((b) =>
b.id === blockId ? { ...b, checked: !currentChecked } : b
);
setDocs((prev) =>
prev.map((d) => (d.id === doc.id ? { ...d, blocks: updatedBlocks } : d))
);
try {
await api.patchDocumentBlocks(doc.id, {
upserted: [{ id: blockId, checked: !currentChecked, type: 'todo', content: '' }],
});
} catch {
loadWorkspaceData();
}
};
const allFolderPaths = useMemo(() => {
const set = new Set<string>(folders);
for (const d of docs) {
if (d.folderPath) set.add(d.folderPath);
}
for (const sf of sharedFolders) {
if (sf.mountPath) set.add(sf.mountPath);
}
return Array.from(set).sort();
}, [docs, folders, sharedFolders]);
const filteredDocs = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return docs;
return docs.filter(
(d) =>
d.title.toLowerCase().includes(q) ||
(d.content && d.content.toLowerCase().includes(q)) ||
(d.tags && d.tags.some((t) => t.toLowerCase().includes(q)))
);
}, [docs, searchQuery]);
const folderTree = useMemo(() => {
const rootNodes: FolderTreeNode[] = [];
const nodeMap = new Map<string, FolderTreeNode>();
const allPathsSet = new Set<string>();
for (const p of allFolderPaths) {
const parts = p.split('/');
let current = '';
for (const part of parts) {
current = current ? `${current}/${part}` : part;
allPathsSet.add(current);
}
}
const sortedPaths = Array.from(allPathsSet).sort((a, b) => {
const depthA = a.split('/').length;
const depthB = b.split('/').length;
if (depthA !== depthB) return depthA - depthB;
return a.localeCompare(b);
});
for (const p of sortedPaths) {
const parts = p.split('/');
const leafName = parts[parts.length - 1];
const sharedFolderObj = sharedFolders.find(
(sf) => sf.mountPath === p || sf.name === p
);
const folderDocs = filteredDocs.filter((d) => d.folderPath === p);
const node: FolderTreeNode = {
name: leafName,
fullPath: p,
subfolders: [],
docs: folderDocs,
sharedFolderObj,
};
nodeMap.set(p, node);
if (parts.length === 1) {
rootNodes.push(node);
} else {
const parentPath = parts.slice(0, -1).join('/');
const parentNode = nodeMap.get(parentPath);
if (parentNode) {
parentNode.subfolders.push(node);
} else {
rootNodes.push(node);
}
}
}
return rootNodes;
}, [allFolderPaths, filteredDocs, sharedFolders]);
const pinnedDocs = useMemo(
() => filteredDocs.filter((d) => d.isPinned),
[filteredDocs]
);
const rootDocs = useMemo(
() => filteredDocs.filter((d) => !d.folderPath),
[filteredDocs]
);
// Global To-Dos rollup collections
const allRollupTodos = useMemo(() => {
const list: { doc: Document; blockId: string; content: string; checked: boolean }[] = [];
for (const d of docs) {
const fName = d.folderPath || 'Hauptebene';
if (todoFolderDrilldown && fName !== todoFolderDrilldown) continue;
for (const b of d.blocks || []) {
if (b.type === 'todo' && b.content.trim() !== '') {
list.push({ doc: d, blockId: b.id, content: b.content, checked: Boolean(b.checked) });
}
}
}
return list;
}, [docs, todoFolderDrilldown]);
const rollupTodosByFolder = useMemo(() => {
const map = new Map<string, { doc: Document; blockId: string; content: string; checked: boolean }[]>();
for (const item of allRollupTodos) {
const fName = item.doc.folderPath || 'Hauptebene';
const arr = map.get(fName) || [];
arr.push(item);
map.set(fName, arr);
}
return map;
}, [allRollupTodos]);
const rollupTodosByNote = useMemo(() => {
const map = new Map<
string,
{ doc: Document; items: { doc: Document; blockId: string; content: string; checked: boolean }[] }
>();
for (const item of allRollupTodos) {
const existing = map.get(item.doc.id);
if (existing) {
existing.items.push(item);
} else {
map.set(item.doc.id, { doc: item.doc, items: [item] });
}
}
return map;
}, [allRollupTodos]);
const totalOpenTodos = useMemo(() => {
let count = 0;
for (const d of docs) {
for (const b of d.blocks || []) {
if (b.type === 'todo' && !b.checked && b.content.trim() !== '') {
count++;
}
}
}
return count;
}, [docs]);
// Real-time matched blocks and all nested children for the selected hashtag
const activeTagGroups = useMemo(() => {
if (!selectedTag) return [];
const cleanTag = selectedTag.toLowerCase();
const map = new Map<
string,
{
docId: string;
title: string;
icon: string;
folderPath: string;
matchedBlocks: { rootBlock: DocumentBlock; children: DocumentBlock[] }[];
}
>();
for (const d of docs) {
const blks = d.blocks || [];
if (blks.length === 0) continue;
const matched: { rootBlock: DocumentBlock; children: DocumentBlock[] }[] = [];
const processedChildIds = new Set<string>();
for (let i = 0; i < blks.length; i++) {
const b = blks[i];
if (processedChildIds.has(b.id)) continue;
const content = b.content || '';
const regex = new RegExp(`(?:^|[\\s(])#(${cleanTag})(?=[\\s.,!?:;)#\\]}]|$)`, 'i');
if (regex.test(content)) {
const children: DocumentBlock[] = [];
const rootLevel = b.level || 0;
for (let j = i + 1; j < blks.length; j++) {
const nextB = blks[j];
if ((nextB.level || 0) > rootLevel) {
children.push(nextB);
processedChildIds.add(nextB.id);
} else {
break;
}
}
matched.push({ rootBlock: b, children });
}
}
if (matched.length > 0) {
map.set(d.id, {
docId: d.id,
title: d.title || 'Unbenannte Notiz',
icon: d.icon || '📝',
folderPath: d.folderPath || '',
matchedBlocks: matched,
});
}
}
if (tagDetails?.documents) {
for (const dg of tagDetails.documents) {
if (!map.has(dg.docId)) {
map.set(dg.docId, {
docId: dg.docId,
title: dg.title || 'Unbenannte Notiz',
icon: dg.icon || '📝',
folderPath: dg.folderPath || '',
matchedBlocks: dg.matchedBlocks || [],
});
}
}
}
return Array.from(map.values());
}, [docs, selectedTag, tagDetails]);
const activeTagTotalMatches = useMemo(() => {
let count = 0;
for (const g of activeTagGroups) {
count += g.matchedBlocks.length;
}
return count;
}, [activeTagGroups]);
const selectedDocObj = useMemo(
() => docs.find((d) => d.id === selectedDocId) || null,
[docs, selectedDocId]
);
const unifiedShares: UnifiedShare[] = useMemo(() => {
if (shareModalDoc) {
return activeDocShares.map((s) => ({
id: s.userId || s.teamId || s.id,
targetType: s.targetType || (s.teamId ? 'team' : 'user'),
targetId: s.targetId || s.teamId || s.userId || '',
targetName: s.targetName || s.displayName || s.userEmail || 'Mitglied',
targetEmail: s.userEmail,
permission: s.permission === 'write' ? 'editor' : 'viewer',
createdAt: s.createdAt,
}));
}
if (shareModalFolder) {
return (shareModalFolder.shares || []).map((s) => ({
id: s.id,
targetType: s.targetType,
targetId: s.targetId,
targetName: s.targetName || s.email || 'Mitglied',
targetEmail: s.email,
permission: s.permission === 'write' ? 'editor' : 'viewer',
createdAt: s.createdAt,
}));
}
return [];
}, [activeDocShares, shareModalDoc, shareModalFolder]);
// Aggregated metadata map for every tag (documents where it occurs, open todos count)
const tagsMetadataMap = useMemo(() => {
const map = new Map<
string,
{
tag: string;
count: number;
docs: { id: string; title: string; icon?: string; folderPath?: string }[];
openTodosCount: number;
}
>();
for (const t of tagsSummary) {
const cleanTag = t.tag.toLowerCase();
const matchingDocs: { id: string; title: string; icon?: string; folderPath?: string }[] = [];
let openTodos = 0;
for (const d of docs) {
let hasInDoc = false;
const blocks = d.blocks || [];
for (const b of blocks) {
const content = b.content || '';
const regex = new RegExp(`(?:^|[\\s(])#(${cleanTag})(?=[\\s.,!?:;)#\\]}]|$)`, 'i');
if (regex.test(content)) {
hasInDoc = true;
if (b.type === 'todo' && !b.checked) openTodos++;
}
}
if (hasInDoc) {
matchingDocs.push({
id: d.id,
title: d.title || 'Unbenannte Notiz',
icon: d.icon,
folderPath: d.folderPath,
});
}
}
map.set(cleanTag, {
tag: t.tag,
count: t.count,
docs: matchingDocs,
openTodosCount: openTodos,
});
}
return map;
}, [tagsSummary, docs]);
// Filtered and sorted tags for the Kachel Grid view
const filteredTags = useMemo(() => {
const q = tagSearchQuery.trim().toLowerCase().replace(/^#/, '');
let list = [...tagsSummary];
if (q) {
list = list.filter((t) => t.tag.toLowerCase().includes(q));
}
if (tagSortBy === 'count') {
list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
} else {
list.sort((a, b) => a.tag.localeCompare(b.tag));
}
return list;
}, [tagsSummary, tagSearchQuery, tagSortBy]);
return (
<div className="w-full space-y-3">
{/* Top Header: Workspace Tabs & Actions */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
{/* Workspace selector tabs & left sidebar toggle */}
<div className="flex flex-wrap items-center gap-1">
<button
type="button"
disabled={subView !== 'editor'}
onClick={subView === 'editor' ? toggleSidebar : undefined}
className={`hidden sm:inline-flex h-8 w-8 items-center justify-center rounded-btn border transition-colors ${
subView !== 'editor'
? 'opacity-35 bg-canvas border-border text-faint cursor-not-allowed pointer-events-none'
: isSidebarCollapsed
? 'bg-panel border-accent text-accent cursor-pointer'
: 'bg-canvas border-border text-muted hover:text-main cursor-pointer'
}`}
title={
subView !== 'editor'
? 'Ordnerleiste nur im Notizen-Modus verfügbar'
: isSidebarCollapsed
? 'Ordnerleiste einblenden'
: 'Ordnerleiste einklappen'
}
aria-label={
subView !== 'editor'
? 'Ordnerleiste nur im Notizen-Modus verfügbar'
: isSidebarCollapsed
? 'Ordnerleiste einblenden'
: 'Ordnerleiste einklappen'
}
>
{isSidebarCollapsed ? (
<PanelLeftOpen className="w-4 h-4" />
) : (
<PanelLeftClose className="w-4 h-4" />
)}
</button>
<button
type="button"
onClick={() => setSubView('editor')}
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
subView === 'editor'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<FileText className="w-3.5 h-3.5 text-accent" />
<span>Notizen</span>
{docs.length > 0 && <span className="ml-1 badge-neutral text-[10px]">{docs.length}</span>}
</button>
<button
type="button"
onClick={() => {
setSubView('todos');
setMobileShowDetail(true);
}}
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
subView === 'todos'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Check className="w-3.5 h-3.5 text-status-ok" />
<span>To-Dos</span>
{totalOpenTodos > 0 && <span className="ml-1 badge-neutral text-[10px]">{totalOpenTodos}</span>}
</button>
<button
type="button"
onClick={() => {
setSubView('tags');
setMobileShowDetail(true);
}}
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
subView === 'tags' || subView === 'tag_detail'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Hash className="w-3.5 h-3.5 text-accent" />
<span>Hashtags</span>
{tagsSummary.length > 0 && <span className="ml-1 badge-neutral text-[10px]">{tagsSummary.length}</span>}
</button>
</div>
{/* Right Header Section: Save Status & Live Collaboration Button */}
<div className="flex items-center gap-2">
{subView === 'editor' && selectedDocId ? (
/* Save Status Pill */
<div
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${
docSaveStatus === 'saved'
? 'bg-canvas border-border text-muted'
: docSaveStatus === 'saving'
? 'bg-panel border-accent/40 text-accent animate-pulse'
: docSaveStatus === 'unsaved'
? 'bg-status-warn/10 border-status-warn/30 text-status-warn'
: 'bg-status-err/10 border-status-err/30 text-status-err'
}`}
title={
docSaveStatus === 'saved'
? 'Alle Änderungen per HTTPS gesichert'
: docSaveStatus === 'saving'
? 'Änderungen werden per HTTPS gespeichert…'
: docSaveStatus === 'unsaved'
? 'Ungespeicherte Änderungen (Auto-Save aktiv)'
: docSaveError || 'Fehler beim Speichern'
}
>
{docSaveStatus === 'saved' && (
<>
<Check className="w-3.5 h-3.5 text-status-ok" />
<span>Gespeichert</span>
</>
)}
{docSaveStatus === 'saving' && (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
<span>Speichert…</span>
</>
)}
{docSaveStatus === 'unsaved' && (
<>
<Clock className="w-3.5 h-3.5 text-status-warn" />
<span>Ungespeichert</span>
</>
)}
{docSaveStatus === 'error' && (
<>
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
<span>Fehler</span>
</>
)}
</div>
) : null}
</div>
</div>
{/* Main Workspace Split Pane */}
<div className="flex h-[calc(100vh-7.5rem)] bg-canvas rounded-card border border-border overflow-hidden">
{/* LEFT MASTER SIDEBAR */}
<aside
className={`${
subView !== 'editor' || isSidebarCollapsed
? 'hidden'
: mobileShowDetail
? 'hidden lg:flex'
: 'flex'
} w-full lg:w-72 xl:w-80 shrink-0 flex-col bg-panel border-r border-border transition-all duration-200`}
>
{/* Sidebar Header & Actions */}
<div className="p-3 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-xs font-bold text-main uppercase tracking-wider">
Notizen & Outliner
</h2>
<div className="flex items-center gap-1">
<button
type="button"
onClick={(e) => handleOpenNewFolder('', e)}
className="btn-action p-1 text-muted hover:text-main"
title="Neuer Ordner"
>
<FolderPlus className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleCreateDocument('')}
className="h-7 px-2.5 bg-canvas hover:bg-panel border border-border border-dashed rounded-btn text-xs text-muted hover:text-main inline-flex items-center gap-1 transition-colors cursor-pointer"
title="Neue Notiz anlegen"
>
<Plus className="w-3.5 h-3.5" />
<span>Notiz</span>
</button>
</div>
</div>
{/* Search Input */}
<div className="relative">
<Search className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Notizen oder #Tags suchen…"
className="input-field w-full pl-8 pr-6 py-1 text-xs"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-faint hover:text-main"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Scrollable Navigation Tree */}
<div className="flex-1 overflow-y-auto p-2 space-y-3">
{/* Pinned Notes */}
{pinnedDocs.length > 0 && !searchQuery && (
<div>
<div className="px-2 mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-faint">
<Pin className="w-3 h-3 text-status-warn" />
<span>Angeheftet</span>
</div>
<div className="space-y-0.5">
{pinnedDocs.map((d) => (
<div
key={`pin-${d.id}`}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span>{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
</div>
))}
</div>
</div>
)}
{/* Folders Tree with Recursive Hierarchical Nesting */}
{folderTree.length > 0 && (
<div>
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
Ordner
</div>
<div className="space-y-0.5">
{folderTree.map((node) => (
<FolderTreeItem
key={node.fullPath}
node={node}
depth={0}
expandedFolders={expandedFolders}
setExpandedFolders={setExpandedFolders}
selectedDocId={selectedDocId}
subView={subView}
dragOverFolder={dragOverFolder}
setDragOverFolder={setDragOverFolder}
handleDropOnFolder={handleDropOnFolder}
handleFolderDragStart={handleFolderDragStart}
handleDocDragStart={handleDocDragStart}
handleCreateDocument={handleCreateDocument}
handleOpenNewFolder={handleOpenNewFolder}
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={(id) => {
setSelectedDocId(id);
setTargetBlockId(null);
setNewlyCreatedDocId(null);
setSubView('editor');
setMobileShowDetail(true);
}}
/>
))}
</div>
</div>
)}
{/* Root Documents */}
<div>
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
Notizen (Hauptebene)
</div>
{rootDocs.length === 0 ? (
<div className="px-2 py-2 text-xs text-faint italic">
Keine Notizen auf der Hauptebene.
</div>
) : (
<div className="space-y-0.5">
{rootDocs.map((d) => (
<div
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-border/80'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span>{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
)}
</div>
))}
</div>
)}
</div>
{/* Bottom drop target for moving to root */}
{(draggingDocId || draggingFolderPath) && (
<div
onDragOver={(e) => {
e.preventDefault();
setDragOverFolder('__root_bottom__');
}}
onDragLeave={() => setDragOverFolder(null)}
onDrop={(e) => handleDropOnFolder('', e)}
className={`p-3 rounded-card border-2 border-dashed transition-all flex items-center justify-center text-xs font-medium text-center ${
dragOverFolder === '__root_bottom__'
? 'border-accent bg-accent/20 text-accent ring-2 ring-accent/30'
: 'border-border/60 bg-panel-elevated/40 text-muted hover:border-accent/40'
}`}
>
<span>Auf Hauptebene verschieben</span>
</div>
)}
{/* Tags Explorer */}
{tagsSummary.length > 0 && (
<div>
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
Hashtags (#)
</div>
<div className="flex flex-wrap gap-1 px-1">
{tagsSummary.map((t) => (
<button
key={t.tag}
type="button"
onClick={() => handleSelectTag(t.tag)}
className={`inline-flex items-center gap-0.5 px-2 py-0.5 rounded-btn text-[10px] font-medium transition-colors ${
subView === 'tag_detail' && selectedTag === t.tag
? 'bg-accent text-on-accent'
: 'bg-panel-elevated text-muted hover:text-accent'
}`}
>
<span>#{t.tag}</span>
<span className="opacity-60 font-mono text-[9px]">{t.count}</span>
</button>
))}
</div>
</div>
)}
</div>
</aside>
{/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */}
<main
className={`${
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex'
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-canvas`}
>
{subView === 'editor' && selectedDocId ? (
<DocumentEditor
key={selectedDocId}
docId={selectedDocId}
currentUser={currentUser}
initialDoc={selectedDocObj}
availableFolders={allFolderPaths}
autoFocusTitle={newlyCreatedDocId === selectedDocId}
initialFocusedBlockId={targetBlockId}
onDocMetadataChange={handleDocMetadataChange}
onDeleteDoc={handleDeleteDocument}
onOpenShareModal={openDocShareModal}
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}
onBackMobile={() => setMobileShowDetail(false)}
onOpenTask={onOpenTask}
onSaveStatusChange={(status, err) => {
setDocSaveStatus(status);
setDocSaveError(err || null);
}}
/>
) : subView === 'tags' ? (
/* =========================================================================
HASHTAG OVERVIEW & TILE DIRECTORY (KACHEL-ANSICHT MIT SUCHE)
========================================================================= */
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
<div className="w-full space-y-5">
{/* Header with Title and Search Controls */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Hash className="w-5 h-5 text-accent" />
<h1 className="text-xl font-bold text-main">Hashtags Übersicht</h1>
<span className="badge-neutral text-xs font-mono">{tagsSummary.length} Tags</span>
</div>
<p className="text-xs text-muted mt-0.5">
Entdecke alle Themen, Schlagworte und verknüpften Blöcke aus deinen Notizen
</p>
</div>
{/* Search & Sort Toolbar */}
<div className="flex items-center gap-2">
<div className="relative min-w-[200px] sm:min-w-[240px]">
<Search className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={tagSearchQuery}
onChange={(e) => setTagSearchQuery(e.target.value)}
placeholder="Hashtags durchsuchen…"
className="input-field w-full pl-8 pr-7 py-1.5 text-xs"
/>
{tagSearchQuery && (
<button
type="button"
onClick={() => setTagSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-faint hover:text-main cursor-pointer"
>
<X className="w-3 h-3" />
</button>
)}
</div>
{/* Sort Mode Switcher */}
<div className="flex items-center bg-canvas border border-border rounded-btn p-0.5 text-[11px]">
<button
type="button"
onClick={() => setTagSortBy('count')}
className={`px-2 py-1 rounded-btn font-semibold transition-colors cursor-pointer ${
tagSortBy === 'count'
? 'bg-panel-elevated text-main shadow-xs'
: 'text-muted hover:text-main'
}`}
title="Nach Häufigkeit sortieren"
>
Häufigkeit
</button>
<button
type="button"
onClick={() => setTagSortBy('name')}
className={`px-2 py-1 rounded-btn font-semibold transition-colors cursor-pointer ${
tagSortBy === 'name'
? 'bg-panel-elevated text-main shadow-xs'
: 'text-muted hover:text-main'
}`}
title="Alphabetisch sortieren"
>
A-Z
</button>
</div>
</div>
</div>
{/* Grid of Hashtag Tiles (Kacheln) */}
{filteredTags.length === 0 ? (
<div className="p-12 text-center text-muted text-xs space-y-2 border border-border rounded-card bg-canvas">
<Hash className="w-8 h-8 text-faint mx-auto" />
<p className="font-semibold text-main text-sm">
{tagSearchQuery
? `Keine Hashtags für "${tagSearchQuery}" gefunden`
: 'Noch keine Hashtags vorhanden'}
</p>
<p className="text-faint max-w-sm mx-auto">
{tagSearchQuery
? 'Versuche einen anderen Suchbegriff oder setze die Suche zurück.'
: 'Schreibe #tag in einen beliebigen Notiz-Bullet, um Themen automatisch als Kachel zu gruppieren.'}
</p>
{tagSearchQuery && (
<button
type="button"
onClick={() => setTagSearchQuery('')}
className="btn-action inline-flex items-center gap-1 px-3 py-1 text-xs text-accent mt-2 cursor-pointer"
>
Suche zurücksetzen
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3.5">
{filteredTags.map((t) => {
const meta = tagsMetadataMap.get(t.tag.toLowerCase());
return (
<div
key={t.tag}
onClick={() => handleSelectTag(t.tag)}
className="p-4 rounded-card border border-border bg-panel hover:border-accent hover:bg-panel-elevated transition-all cursor-pointer group flex flex-col justify-between space-y-3"
>
<div className="space-y-2.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="w-7 h-7 rounded-btn bg-accent/10 border border-accent/20 text-accent font-bold flex items-center justify-center shrink-0 text-sm group-hover:bg-accent group-hover:text-on-accent transition-colors">
#
</span>
<h3 className="font-bold text-sm text-main group-hover:text-accent transition-colors truncate">
{t.tag.replace(/^#/, '')}
</h3>
</div>
<span className="badge-neutral font-mono text-[10px] shrink-0">
{t.count} Treffer
</span>
</div>
{meta && meta.docs.length > 0 && (
<div className="space-y-1">
<span className="text-[10px] font-semibold text-faint uppercase tracking-wider block">
Aktuellste Notiz:
</span>
<div
className="inline-flex items-center gap-1.5 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-main truncate max-w-full"
title={meta.docs[0].title}
>
<span>{meta.docs[0].icon || '📝'}</span>
<span className="truncate">{meta.docs[0].title || 'Unbenannte Notiz'}</span>
</div>
</div>
)}
</div>
<div className="flex items-center justify-between pt-2.5 border-t border-border/50 text-[11px]">
<div>
{meta && meta.openTodosCount > 0 && (
<span className="text-status-warn text-[10px] flex items-center gap-1 font-medium">
<Check className="w-2.5 h-2.5" />
{meta.openTodosCount} offene To-Dos
</span>
)}
</div>
<span className="text-accent text-xs font-semibold group-hover:translate-x-0.5 transition-transform flex items-center gap-0.5">
<span>Details</span>
<span>→</span>
</span>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
) : subView === 'tag_detail' && selectedTag ? (
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
<div className="w-full space-y-4">
{/* Back to all tags */}
<div className="flex items-center justify-between border-b border-border pb-3">
<button
type="button"
onClick={() => setSubView('tags')}
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main cursor-pointer"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Alle Hashtag-Kacheln</span>
</button>
</div>
{/* Tag Context Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="w-7 h-7 rounded-btn bg-accent/10 border border-accent/20 text-accent font-bold flex items-center justify-center shrink-0 text-sm">
#
</span>
<h1 className="text-xl font-bold text-main">
#{selectedTag}
</h1>
</div>
<button
type="button"
onClick={() => setSubView('tags')}
className="text-xs text-accent hover:underline flex items-center gap-1 cursor-pointer"
>
<ArrowLeft className="w-3 h-3" />
<span>Zur Kachel-Übersicht</span>
</button>
</div>
{/* Tag Context Note */}
<div className="space-y-1">
<label className="text-[10px] font-bold uppercase tracking-wider text-faint">
Tag-Notiz
</label>
<textarea
rows={3}
value={tagNoteDraft}
onChange={(e) => setTagNoteDraft(e.target.value)}
onBlur={handleSaveTagNote}
placeholder={`Notizen zu #${selectedTag}…`}
className="input-field w-full p-2.5 text-xs text-main"
/>
</div>
{/* Matched Document Blocks */}
<div className="space-y-3">
<h3 className="text-[10px] font-bold uppercase tracking-wider text-faint">
Verknüpfte Outliner-Blöcke & Unterpunkte ({activeTagTotalMatches})
</h3>
{activeTagGroups.length === 0 ? (
<div className="p-8 text-center text-muted text-xs space-y-1 border border-border rounded-card bg-canvas">
<p className="font-semibold text-main">Keine Blöcke mit #{selectedTag} gefunden.</p>
</div>
) : (
activeTagGroups.map((docGroup) => {
const docObj = docs.find((d) => d.id === docGroup.docId);
return (
<div
key={docGroup.docId}
className="p-3 rounded-card border border-border bg-canvas space-y-2"
>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => handleJumpToDocBlock(docGroup.docId)}
className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer"
>
<span>{docGroup.icon || '📝'}</span>
<span>{docGroup.title}</span>
{docGroup.folderPath && (
<span className="badge-neutral text-[10px] font-normal">
📁 {docGroup.folderPath}
</span>
)}
</button>
<button
type="button"
onClick={() => handleJumpToDocBlock(docGroup.docId)}
className="text-[10px] text-accent hover:underline px-1.5 py-0.5 rounded-btn hover:bg-canvas cursor-pointer flex items-center gap-1"
title="Notiz öffnen"
>
<span>Notiz öffnen</span>
<ExternalLink className="w-2.5 h-2.5" />
</button>
</div>
<div className="space-y-2 pl-3 border-l-2 border-border/70">
{docGroup.matchedBlocks.map((mb) => (
<div key={mb.rootBlock.id} className="space-y-1">
{/* Root Bullet Point containing the hashtag */}
<div
onClick={() => handleJumpToDocBlock(docGroup.docId, mb.rootBlock.id)}
className="flex items-start justify-between gap-1.5 text-xs text-main font-medium group cursor-pointer hover:bg-panel-elevated p-1.5 rounded-btn transition-colors"
title="Klick: Zu dieser Zeile springen und markieren"
>
<div className="flex items-start gap-2 flex-1 min-w-0">
{mb.rootBlock.type === 'todo' ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (docObj) {
handleToggleRollupTodo(
docObj,
mb.rootBlock.id,
Boolean(mb.rootBlock.checked)
);
}
}}
className={`w-3.5 h-3.5 mt-0.5 checkbox-square flex items-center justify-center shrink-0 border cursor-pointer ${
mb.rootBlock.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
) : (
<span className="text-accent font-bold select-none text-sm leading-none mt-0.5">
•
</span>
)}
<span
className={`flex-1 break-words ${
mb.rootBlock.checked ? 'line-through text-faint' : ''
}`}
>
{mb.rootBlock.content}
</span>
</div>
<ExternalLink className="w-3 h-3 text-faint group-hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5" />
</div>
{/* All Children indented */}
{mb.children && mb.children.length > 0 && (
<div className="space-y-0.5">
{mb.children.map((ch) => {
const relLevel = Math.max(
1,
(ch.level || 0) - (mb.rootBlock.level || 0)
);
return (
<div
key={ch.id}
onClick={() => handleJumpToDocBlock(docGroup.docId, ch.id)}
style={{ paddingLeft: `${relLevel * 16}px` }}
className="flex items-start justify-between gap-1.5 text-xs text-muted hover:text-main group cursor-pointer hover:bg-panel-elevated p-1 rounded-btn transition-colors"
title="Klick: Zu dieser Zeile springen und markieren"
>
<div className="flex items-start gap-2 flex-1 min-w-0">
{ch.type === 'todo' ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (docObj) {
handleToggleRollupTodo(
docObj,
ch.id,
Boolean(ch.checked)
);
}
}}
className={`w-3.5 h-3.5 mt-0.5 checkbox-square flex items-center justify-center shrink-0 border cursor-pointer ${
ch.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
) : (
<span className="text-faint group-hover:text-accent font-bold select-none text-xs leading-none mt-1">
◦
</span>
)}
<span
className={`flex-1 break-words ${
ch.checked ? 'line-through text-faint' : ''
}`}
>
{ch.content}
</span>
</div>
<ExternalLink className="w-3 h-3 text-faint group-hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5" />
</div>
);
})}
</div>
)}
</div>
))}
</div>
</div>
);
})
)}
</div>
</div>
</div>
) : subView === 'todos' ? (
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
<div className="w-full space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between border-b border-border pb-3 gap-2">
<div>
<h2 className="text-base sm:text-lg font-bold text-main">
To-Dos aus Notizen ({totalOpenTodos} offen)
</h2>
<p className="text-xs text-muted">
Alle Aufgaben aus deinen Notizen im gesamten Workspace
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* View Grouping Selector (matching hashtag switcher style) */}
<div className="flex items-center bg-canvas border border-border rounded-btn p-0.5 text-[11px]">
<button
type="button"
onClick={() => {
setTodoGrouping('grouped');
localStorage.setItem('mindsift_todo_grouping', 'grouped');
}}
className={`px-2.5 py-1 rounded-btn font-semibold transition-colors cursor-pointer ${
todoGrouping === 'grouped'
? 'bg-panel-elevated text-main shadow-xs'
: 'text-muted hover:text-main'
}`}
title="Sortiert nach Ordner und Notiz"
>
Gruppiert
</button>
<button
type="button"
onClick={() => {
setTodoGrouping('list');
localStorage.setItem('mindsift_todo_grouping', 'list');
}}
className={`px-2.5 py-1 rounded-btn font-semibold transition-colors cursor-pointer ${
todoGrouping === 'list'
? 'bg-panel-elevated text-main shadow-xs'
: 'text-muted hover:text-main'
}`}
title="Alle To-Dos als einfache Liste"
>
Liste
</button>
</div>
</div>
</div>
{/* Folder Drilldown Tag */}
{todoFolderDrilldown && (
<div className="flex items-center justify-between p-2 rounded-btn bg-accent/10 border border-accent text-xs">
<span>
Gefiltert nach Ordner: <strong>{todoFolderDrilldown}</strong>
</span>
<button
type="button"
onClick={() => setTodoFolderDrilldown(null)}
className="text-accent hover:underline font-semibold cursor-pointer"
>
Filter aufheben
</button>
</div>
)}
{/* Grouped Folders & To-Dos */}
{allRollupTodos.length === 0 ? (
<div className="p-8 text-center text-muted text-xs space-y-1 border border-border rounded-card bg-panel">
<Sparkles className="w-6 h-6 text-accent mx-auto mb-1" />
<p className="font-semibold text-main">Keine To-Dos in Notizen gefunden!</p>
<p className="text-faint">
Tippe in einem beliebigen Dokument <code>/todo</code> oder <code>[] </code> ein.
</p>
</div>
) : todoGrouping === 'list' ? (
/* Flat list of all todos across notes */
<div className="border border-border rounded-card bg-panel p-2 space-y-1">
{allRollupTodos.map((item) => (
<div
key={`${item.doc.id}-${item.blockId}`}
className="flex items-center justify-between gap-2 p-2 rounded-btn hover:bg-panel-elevated text-xs transition-colors group cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleToggleRollupTodo(item.doc, item.blockId, item.checked);
}}
className={`w-3.5 h-3.5 checkbox-square flex items-center justify-center transition-colors border cursor-pointer ${
item.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
<span
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
className={`truncate flex-1 ${
item.checked
? 'line-through text-faint'
: 'text-main font-medium group-hover:text-accent'
}`}
>
{item.content}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-muted hover:text-main"
title={item.doc.title}
>
<span>{item.doc.icon || '📝'}</span>
<span className="truncate max-w-[120px]">{item.doc.title}</span>
{item.doc.folderPath && (
<span className="text-faint text-[10px]">({item.doc.folderPath})</span>
)}
</span>
<button
type="button"
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
className="text-faint hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity p-1"
title="Zu dieser Zeile springen"
>
<ExternalLink className="w-3 h-3" />
</button>
</div>
</div>
))}
</div>
) : (
/* Grouped by Folder -> grouped by Note */
<div className="space-y-3">
{Array.from(rollupTodosByFolder.entries()).map(([folderName, items]) => {
const isCollapsed = collapsedTodoFolders.has(folderName);
const openCount = items.filter((i) => !i.checked).length;
// Subgroup by doc
const docGroupsMap = new Map<string, { doc: Document; items: typeof items }>();
for (const it of items) {
const ex = docGroupsMap.get(it.doc.id);
if (ex) ex.items.push(it);
else docGroupsMap.set(it.doc.id, { doc: it.doc, items: [it] });
}
return (
<div
key={folderName}
className="border border-border rounded-card bg-panel overflow-hidden"
>
{/* Folder Header */}
<div className="p-2 bg-panel border-b border-border flex items-center justify-between">
<div
onClick={() => {
setCollapsedTodoFolders((prev) => {
const next = new Set(prev);
if (next.has(folderName)) next.delete(folderName);
else next.add(folderName);
return next;
});
}}
className="flex items-center gap-2 cursor-pointer select-none"
>
{isCollapsed ? (
<ChevronRight className="w-3.5 h-3.5 text-faint" />
) : (
<ChevronDown className="w-3.5 h-3.5 text-accent" />
)}
<Folder className="w-3.5 h-3.5 text-accent" />
<span className="font-bold text-xs text-main">{folderName}</span>
<span className="badge-neutral text-[10px] font-mono">
{openCount} offen
</span>
</div>
<button
type="button"
onClick={() => setTodoFolderDrilldown(folderName)}
className="text-[10px] text-muted hover:text-main px-1.5 py-0.5 rounded-btn hover:bg-panel-elevated cursor-pointer"
title="Diesen Ordner isolieren"
>
Nur diesen Ordner
</button>
</div>
{/* Folder Notes Subgroups */}
{!isCollapsed && (
<div className="p-2 space-y-3">
{Array.from(docGroupsMap.values()).map(({ doc, items: docItems }) => {
const docOpenCount = docItems.filter((i) => !i.checked).length;
const isDocCollapsed = collapsedTodoNotes.has(doc.id);
return (
<div
key={doc.id}
className="border border-border/60 rounded-btn p-2 bg-panel-elevated/40 space-y-1.5"
>
<div className="flex items-center justify-between pb-1 border-b border-border/40">
<div
onClick={() => {
setCollapsedTodoNotes((prev) => {
const next = new Set(prev);
if (next.has(doc.id)) next.delete(doc.id);
else next.add(doc.id);
return next;
});
}}
className="flex items-center gap-1.5 text-xs font-semibold text-main hover:text-accent cursor-pointer select-none"
>
{isDocCollapsed ? (
<ChevronRight className="w-3 h-3 text-faint" />
) : (
<ChevronDown className="w-3 h-3 text-accent" />
)}
<span>{doc.icon || '📝'}</span>
<span>{doc.title}</span>
<span className="badge-neutral text-[9px] font-mono">
{docOpenCount} offen
</span>
</div>
<button
type="button"
onClick={() => handleJumpToDocBlock(doc.id)}
className="text-[10px] text-accent hover:underline px-1.5 py-0.5 rounded-btn hover:bg-panel cursor-pointer flex items-center gap-1"
title="Notiz öffnen"
>
<span>Notiz öffnen</span>
<ExternalLink className="w-2.5 h-2.5" />
</button>
</div>
{!isDocCollapsed && (
<div className="space-y-0.5">
{docItems.map((item) => (
<div
key={`${item.doc.id}-${item.blockId}`}
className="flex items-center justify-between gap-2 p-1 rounded-btn hover:bg-panel-elevated text-xs transition-colors group cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleToggleRollupTodo(
item.doc,
item.blockId,
item.checked
);
}}
className={`w-3.5 h-3.5 checkbox-square flex items-center justify-center transition-colors border cursor-pointer ${
item.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
<span
onClick={() =>
handleJumpToDocBlock(item.doc.id, item.blockId)
}
className={`truncate flex-1 ${
item.checked
? 'line-through text-faint'
: 'text-main font-medium group-hover:text-accent'
}`}
>
{item.content}
</span>
</div>
<button
type="button"
onClick={() =>
handleJumpToDocBlock(item.doc.id, item.blockId)
}
className="text-faint hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity p-1"
title="Zu dieser Zeile springen"
>
<ExternalLink className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-canvas">
<FileText className="w-10 h-10 text-faint mb-2" />
<h3 className="text-sm font-semibold text-main mb-1">
Keine Notiz ausgewählt
</h3>
<p className="text-xs text-muted max-w-sm mb-3">
{isSidebarCollapsed
? 'Die Ordnerleiste ist eingeklappt. Blende sie ein oder erstelle eine neue Notiz.'
: 'Wähle links in der Seitenleiste eine bestehende Notiz aus oder erstelle eine neue.'}
</p>
<div className="flex items-center gap-2">
{isSidebarCollapsed && (
<button
type="button"
onClick={toggleSidebar}
className="btn-action inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold cursor-pointer"
>
<PanelLeftOpen className="w-3.5 h-3.5" />
<span>Ordner anzeigen</span>
</button>
)}
<button
type="button"
onClick={() => handleCreateDocument('')}
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold"
>
<Plus className="w-3.5 h-3.5" />
<span>Notiz erstellen</span>
</button>
</div>
</div>
)}
</main>
{/* New Folder Popover (small & positioned near click, asks only for name & OK) */}
{showNewFolderModal && (
<div
className="fixed inset-0 z-50 bg-black/20"
onClick={() => setShowNewFolderModal(false)}
>
<form
onSubmit={handleCreateFolderSubmit}
onClick={(e) => e.stopPropagation()}
style={
newFolderCoords
? { top: `${newFolderCoords.y}px`, left: `${newFolderCoords.x}px` }
: undefined
}
className="fixed w-64 bg-panel border border-border rounded-card p-3 shadow-lg space-y-2.5 z-50 text-xs"
>
<div className="font-semibold text-main text-xs flex items-center justify-between">
<span className="truncate">{newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'}</span>
<button
type="button"
onClick={() => setShowNewFolderModal(false)}
className="text-faint hover:text-main cursor-pointer"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<input
type="text"
autoFocus
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Ordnername"
className="input-field w-full px-2 py-1 text-xs"
/>
<div className="flex justify-end gap-1.5 pt-1">
<button
type="button"
onClick={() => setShowNewFolderModal(false)}
className="btn-action px-2.5 py-1 text-xs"
>
Abbrechen
</button>
<button
type="submit"
className="btn-primary px-3 py-1 text-xs font-semibold"
>
OK
</button>
</div>
</form>
</div>
)}
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
{(shareModalDoc || shareModalFolder) && (
<UnifiedShareModal
isOpen={true}
onClose={() => {
setShareModalDoc(null);
setShareModalFolder(null);
}}
resourceName={shareModalDoc ? shareModalDoc.title : shareModalFolder?.name || ''}
resourceIcon={shareModalDoc ? shareModalDoc.icon : '📁'}
isOwner={
shareModalDoc
? (shareModalDoc.isOwner || shareModalDoc.permission === 'owner')
: Boolean(shareModalFolder?.isOwner)
}
currentUserId={currentUser?.id}
shares={unifiedShares}
teams={teams}
collaborators={collaborators}
allowRoleSelection={false}
fixedPermission="viewer"
onAddShare={async (targetType, targetId) => {
if (shareModalDoc) {
await api.createDocShare(
shareModalDoc.id,
targetType === 'team'
? { teamId: targetId, permission: 'read' }
: { userId: targetId, permission: 'read' }
);
const updatedShares = await api.getDocShares(shareModalDoc.id);
setActiveDocShares(updatedShares);
} else if (shareModalFolder) {
await api.createFolderShare(shareModalFolder.id, {
targetType,
targetId,
permission: 'read',
});
const updatedFolder = await api.getSharedFolder(shareModalFolder.id);
setShareModalFolder(updatedFolder);
}
}}
onRemoveShare={async (shareId) => {
if (shareModalDoc) {
await api.deleteDocShare(shareModalDoc.id, shareId);
const updatedShares = await api.getDocShares(shareModalDoc.id);
setActiveDocShares(updatedShares);
} else if (shareModalFolder) {
await api.deleteFolderShare(shareModalFolder.id, shareId);
const updatedFolder = await api.getSharedFolder(shareModalFolder.id);
setShareModalFolder(updatedFolder);
}
}}
/>
)}
</div>
</div>
);
};