MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
2566 lines
109 KiB
TypeScript
2566 lines
109 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 { DocSyncStatus } from '../../lib/useDocumentSync';
|
||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||
import { useMobileActions } from '../../context/MobileActionContext';
|
||
import { DocumentEditor, DocSaveStatus, DocLiveState } from './DocumentEditor';
|
||
|
||
interface WorkspaceViewProps {
|
||
currentUser?: User | null;
|
||
initialDocId?: string | null;
|
||
onStatsChange: () => 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;
|
||
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,
|
||
handleShareFolderClick,
|
||
handleDeleteFolder,
|
||
onSelectDoc,
|
||
}) => {
|
||
const isExpanded = Boolean(expandedFolders[node.fullPath]);
|
||
const isDragOver = dragOverFolder === node.fullPath;
|
||
const hasContent = node.subfolders.length > 0 || node.docs.length > 0;
|
||
|
||
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={() =>
|
||
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" />
|
||
)
|
||
) : (
|
||
<span className="w-3.5 shrink-0" />
|
||
)}
|
||
{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" />
|
||
)}
|
||
<span className="text-[10px] text-faint ml-auto pr-1">
|
||
{node.docs.length}
|
||
</span>
|
||
</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 rounded-btn hover:bg-canvas text-muted hover:text-main cursor-pointer"
|
||
title="Notiz in diesem Ordner erstellen"
|
||
>
|
||
<Plus 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 rounded-btn hover:bg-canvas 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 rounded-btn hover:bg-status-err/20 text-faint hover:text-status-err 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}
|
||
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 border-accent text-main font-semibold shadow-xs'
|
||
: '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,
|
||
onStatsChange,
|
||
}) => {
|
||
const [docs, setDocs] = useState<Document[]>([]);
|
||
const [folders, setFolders] = useState<string[]>([]);
|
||
const [sharedFolders, setSharedFolders] = useState<SharedFolder[]>([]);
|
||
const [tagsSummary, setTagsSummary] = useState<TagSummary[]>([]);
|
||
|
||
const [selectedDocId, setSelectedDocId] = useState<string | null>(initialDocId || null);
|
||
const [targetBlockId, setTargetBlockId] = useState<string | null>(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>>({});
|
||
const [showNewFolderModal, setShowNewFolderModal] = useState<boolean>(false);
|
||
const [newFolderName, setNewFolderName] = useState<string>('');
|
||
const [newFolderIsShared, setNewFolderIsShared] = useState<boolean>(false);
|
||
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 [activeDocSyncStatus, setActiveDocSyncStatus] = useState<DocSyncStatus>('connected');
|
||
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
|
||
const [docSaveError, setDocSaveError] = useState<string | null>(null);
|
||
const [docLiveState, setDocLiveState] = useState<DocLiveState>('idle');
|
||
const [docLiveInitiator, setDocLiveInitiator] = useState<string | null>(null);
|
||
const [showWsBlockedModal, setShowWsBlockedModal] = useState<boolean>(false);
|
||
const liveToggleRef = useRef<(() => void) | 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<'folder_note' | 'note' | 'folder'>(() => {
|
||
return (localStorage.getItem('mindsift_todo_grouping') as any) || 'folder_note';
|
||
});
|
||
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);
|
||
setTagsSummary(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 clean = newFolderName.trim().replace(/^\/+|\/+$/g, '');
|
||
if (!clean) return;
|
||
try {
|
||
if (newFolderIsShared) {
|
||
const sf = await api.createSharedFolder({ name: clean, mountPath: clean });
|
||
setSharedFolders((prev) => [...prev, sf]);
|
||
}
|
||
const res = await api.createFolder(clean);
|
||
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('');
|
||
setNewFolderIsShared(false);
|
||
setShowNewFolderModal(false);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
};
|
||
|
||
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>
|
||
|
||
{/* Interactive Live Collaboration Button (replaces passive radio icon) */}
|
||
{docLiveState === 'locked_by_other' ? (
|
||
<div
|
||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-status-warn/15 border border-status-warn/40 text-status-warn select-none"
|
||
title={`Live-Zusammenarbeit aktiv durch ${docLiveInitiator || 'anderer Nutzer'}`}
|
||
>
|
||
<Lock className="w-3.5 h-3.5 shrink-0" />
|
||
<span className="truncate max-w-[120px]">Live: {docLiveInitiator || 'Gesperrt'}</span>
|
||
</div>
|
||
) : docLiveState === 'blocked' ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowWsBlockedModal(true)}
|
||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-status-err/15 border border-status-err/40 text-status-err hover:bg-status-err/25 transition-colors cursor-pointer"
|
||
title="WebSockets im Firmennetzwerk blockiert – Hier klicken für Infos"
|
||
>
|
||
<AlertTriangle className="w-3.5 h-3.5 text-status-err animate-pulse shrink-0" />
|
||
<span>Live blockiert</span>
|
||
</button>
|
||
) : docLiveState === 'connecting' ? (
|
||
<button
|
||
type="button"
|
||
disabled
|
||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-amber-500/15 border border-amber-500/40 text-amber-500 opacity-80 cursor-wait"
|
||
>
|
||
<Loader2 className="w-3.5 h-3.5 animate-spin text-amber-500 shrink-0" />
|
||
<span>Verbinde…</span>
|
||
</button>
|
||
) : docLiveState === 'active' ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => liveToggleRef.current?.()}
|
||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-semibold bg-emerald-500/15 border border-emerald-500/40 text-emerald-500 hover:bg-emerald-500/25 transition-colors cursor-pointer"
|
||
title="Live-Zusammenarbeit beenden (Tastendruck / WebSocket)"
|
||
>
|
||
<Radio className="w-3.5 h-3.5 text-emerald-500 animate-pulse shrink-0" />
|
||
<span>Live aktiv</span>
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => liveToggleRef.current?.()}
|
||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-canvas border border-border text-muted hover:text-main hover:border-accent transition-colors cursor-pointer"
|
||
title="Live-Zusammenarbeit aktivieren (WebSockets)"
|
||
>
|
||
<Radio className="w-3.5 h-3.5 text-muted hover:text-accent shrink-0" />
|
||
<span>Live</span>
|
||
</button>
|
||
)}
|
||
</>
|
||
) : (
|
||
/* Standalone Realtime Status Indicator for Todos / Hashtags */
|
||
(() => {
|
||
const isConnected = realtimeConnected;
|
||
const isSSE = realtimeTransport === 'sse';
|
||
return (
|
||
<div
|
||
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
|
||
isConnected
|
||
? isSSE
|
||
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
|
||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
||
: 'bg-canvas border-border text-muted'
|
||
}`}
|
||
title={
|
||
isConnected
|
||
? isSSE
|
||
? 'Synchronisiert (Server-Sent Events / HTTPS)'
|
||
: 'Synchronisiert (WebSocket online)'
|
||
: 'Offline'
|
||
}
|
||
>
|
||
<Radio
|
||
className={`w-3.5 h-3.5 ${isConnected ? 'animate-pulse' : ''}`}
|
||
/>
|
||
</div>
|
||
);
|
||
})()
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main Workspace Split Pane */}
|
||
<div className="flex h-[calc(100vh-7.5rem)] bg-canvas rounded-card border border-border overflow-hidden shadow-panel">
|
||
{/* 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-canvas 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={() => setShowNewFolderModal(true)}
|
||
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">
|
||
{/* Drop target for moving docs or folders to root */}
|
||
{(draggingDocId || draggingFolderPath) && (
|
||
<div
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
setDragOverFolder('__root__');
|
||
}}
|
||
onDragLeave={() => setDragOverFolder(null)}
|
||
onDrop={(e) => handleDropOnFolder('', e)}
|
||
className={`p-2 border-2 border-dashed rounded-btn text-center text-xs transition-colors ${
|
||
dragOverFolder === '__root__'
|
||
? 'border-accent bg-accent/20 text-main font-semibold'
|
||
: 'border-border text-muted bg-canvas'
|
||
}`}
|
||
>
|
||
📁 In Hauptebene ablegen
|
||
</div>
|
||
)}
|
||
|
||
{/* 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 border-accent text-main font-semibold shadow-xs'
|
||
: '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}
|
||
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 border-accent text-main font-semibold shadow-xs'
|
||
: '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>
|
||
|
||
{/* 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)}
|
||
onSyncStatusChange={setActiveDocSyncStatus}
|
||
onSaveStatusChange={(status, err) => {
|
||
setDocSaveStatus(status);
|
||
setDocSaveError(err || null);
|
||
}}
|
||
onLiveStateChange={(state, initiator) => {
|
||
setDocLiveState(state);
|
||
setDocLiveInitiator(initiator || null);
|
||
}}
|
||
registerLiveToggle={(handler) => {
|
||
liveToggleRef.current = handler;
|
||
}}
|
||
/>
|
||
) : 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-canvas hover:border-accent hover:shadow-panel 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}
|
||
</h3>
|
||
</div>
|
||
<span className="badge-neutral font-mono text-[10px] shrink-0">
|
||
{t.count} {t.count === 1 ? 'Treffer' : '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">
|
||
In {meta.docs.length} {meta.docs.length === 1 ? 'Notiz' : 'Notizen'}:
|
||
</span>
|
||
<div className="flex flex-wrap gap-1">
|
||
{meta.docs.slice(0, 3).map((d) => (
|
||
<span
|
||
key={d.id}
|
||
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-muted truncate max-w-[140px]"
|
||
title={d.title}
|
||
>
|
||
<span>{d.icon || '📝'}</span>
|
||
<span className="truncate">{d.title}</span>
|
||
</span>
|
||
))}
|
||
{meta.docs.length > 3 && (
|
||
<span className="text-[10px] text-faint self-center">
|
||
+{meta.docs.length - 3}
|
||
</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>
|
||
) : (
|
||
<span className="text-faint text-[10px]">Blöcke & Childs anzeigen</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-lg font-bold text-main">
|
||
Globaler To-Do Rollup ({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 */}
|
||
<div className="flex items-center rounded-btn bg-panel p-0.5 border border-border shadow-xs">
|
||
<span className="text-[10px] uppercase font-bold text-faint px-1.5 hidden sm:inline">
|
||
Ansicht:
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setTodoGrouping('folder_note');
|
||
localStorage.setItem('mindsift_todo_grouping', 'folder_note');
|
||
}}
|
||
className={`px-2 py-1 text-[11px] font-semibold rounded-btn transition-colors cursor-pointer flex items-center gap-1 ${
|
||
todoGrouping === 'folder_note'
|
||
? 'bg-panel-elevated text-main shadow-xs'
|
||
: 'text-muted hover:text-main'
|
||
}`}
|
||
title="Erst nach Ordner, dann nach Notiz gruppieren"
|
||
>
|
||
<Folder className="w-3 h-3 text-accent" />
|
||
<span>+</span>
|
||
<FileText className="w-3 h-3 text-accent" />
|
||
<span>Ordner & Notiz</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setTodoGrouping('note');
|
||
localStorage.setItem('mindsift_todo_grouping', 'note');
|
||
}}
|
||
className={`px-2 py-1 text-[11px] font-semibold rounded-btn transition-colors cursor-pointer flex items-center gap-1 ${
|
||
todoGrouping === 'note'
|
||
? 'bg-panel-elevated text-main shadow-xs'
|
||
: 'text-muted hover:text-main'
|
||
}`}
|
||
title="Direkt nach Notiz gruppieren"
|
||
>
|
||
<FileText className="w-3 h-3 text-accent" />
|
||
<span>Notiz</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setTodoGrouping('folder');
|
||
localStorage.setItem('mindsift_todo_grouping', 'folder');
|
||
}}
|
||
className={`px-2 py-1 text-[11px] font-semibold rounded-btn transition-colors cursor-pointer flex items-center gap-1 ${
|
||
todoGrouping === 'folder'
|
||
? 'bg-panel-elevated text-main shadow-xs'
|
||
: 'text-muted hover:text-main'
|
||
}`}
|
||
title="Nur nach Ordner gruppieren"
|
||
>
|
||
<Folder className="w-3 h-3 text-accent" />
|
||
<span>Ordner</span>
|
||
</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">
|
||
<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 === 'note' ? (
|
||
/* Grouped by Note directly */
|
||
<div className="space-y-3">
|
||
{Array.from(rollupTodosByNote.entries()).map(([docId, { doc, items }]) => {
|
||
const isCollapsed = collapsedTodoNotes.has(docId);
|
||
const openCount = items.filter((i) => !i.checked).length;
|
||
|
||
return (
|
||
<div
|
||
key={docId}
|
||
className="border border-border rounded-card bg-canvas overflow-hidden"
|
||
>
|
||
<div className="p-2 bg-canvas border-b border-border flex items-center justify-between">
|
||
<div
|
||
onClick={() => {
|
||
setCollapsedTodoNotes((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(docId)) next.delete(docId);
|
||
else next.add(docId);
|
||
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" />
|
||
)}
|
||
<span className="text-sm">{doc.icon || '📝'}</span>
|
||
<span className="font-bold text-xs text-main">{doc.title}</span>
|
||
{doc.folderPath && (
|
||
<span className="badge-neutral text-[10px]">📁 {doc.folderPath}</span>
|
||
)}
|
||
<span className="badge-neutral text-[10px] font-mono">
|
||
{openCount} 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-canvas cursor-pointer flex items-center gap-1"
|
||
title="Zur Notiz springen"
|
||
>
|
||
<span>Notiz öffnen</span>
|
||
<ExternalLink className="w-2.5 h-2.5" />
|
||
</button>
|
||
</div>
|
||
|
||
{!isCollapsed && (
|
||
<div className="p-2 space-y-1">
|
||
{items.map((item) => (
|
||
<div
|
||
key={`${item.doc.id}-${item.blockId}`}
|
||
className="flex items-center justify-between gap-2 p-1.5 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>
|
||
) : todoGrouping === 'folder_note' ? (
|
||
/* 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-canvas overflow-hidden"
|
||
>
|
||
{/* Folder Header */}
|
||
<div className="p-2 bg-canvas 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-canvas 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/30 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-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>
|
||
|
||
{!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-canvas 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>
|
||
) : (
|
||
/* Grouped by Folder (Flat tasks inside folder) */
|
||
<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;
|
||
|
||
return (
|
||
<div
|
||
key={folderName}
|
||
className="border border-border rounded-card bg-canvas overflow-hidden"
|
||
>
|
||
{/* Folder Header */}
|
||
<div className="p-2 bg-canvas 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-canvas cursor-pointer"
|
||
title="Diesen Ordner isolieren"
|
||
>
|
||
Nur diesen Ordner
|
||
</button>
|
||
</div>
|
||
|
||
{/* Folder Tasks */}
|
||
{!isCollapsed && (
|
||
<div className="p-2 space-y-1">
|
||
{items.map((item) => (
|
||
<div
|
||
key={`${item.doc.id}-${item.blockId}`}
|
||
className="flex items-center justify-between gap-2 p-1.5 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-[11px] text-accent hover:underline flex items-center gap-1 shrink-0 cursor-pointer"
|
||
title="Zu dieser Zeile springen"
|
||
>
|
||
<span>{item.doc.icon || '📝'}</span>
|
||
<span className="truncate max-w-[120px]">{item.doc.title}</span>
|
||
<ExternalLink className="w-2.5 h-2.5 opacity-60" />
|
||
</button>
|
||
</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 Modal */}
|
||
{showNewFolderModal && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-overlay backdrop-blur-sm p-4">
|
||
<form
|
||
onSubmit={handleCreateFolderSubmit}
|
||
className="w-full max-w-md panel-surface shadow-panel border border-border rounded-card p-4 space-y-3"
|
||
>
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-bold text-main">
|
||
Neuen Ordner anlegen
|
||
</h3>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowNewFolderModal(false)}
|
||
className="p-1 rounded-btn text-faint hover:text-main"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="text-[11px] font-medium text-muted">
|
||
Ordnerpfad (z.B. <code>Projekte/2026</code>)
|
||
</label>
|
||
<input
|
||
type="text"
|
||
autoFocus
|
||
value={newFolderName}
|
||
onChange={(e) => setNewFolderName(e.target.value)}
|
||
placeholder="Projekte"
|
||
className="input-field w-full px-2.5 py-1.5 text-xs"
|
||
/>
|
||
</div>
|
||
|
||
<label className="flex items-center gap-2 text-xs text-muted cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={newFolderIsShared}
|
||
onChange={(e) => setNewFolderIsShared(e.target.checked)}
|
||
className="checkbox-square"
|
||
/>
|
||
<span>Als geteilten Team-Ordner freigeben</span>
|
||
</label>
|
||
|
||
<div className="flex justify-end gap-2 pt-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowNewFolderModal(false)}
|
||
className="btn-action px-3 py-1 text-xs"
|
||
>
|
||
Abbrechen
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="btn-primary px-3 py-1 text-xs font-semibold"
|
||
>
|
||
Erstellen
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)}
|
||
|
||
{/* Unified Sharing Modal */}
|
||
{(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}
|
||
onAddShare={async (targetType, targetId, permission) => {
|
||
const perm = permission === 'editor' ? 'write' : 'read';
|
||
if (shareModalDoc) {
|
||
await api.createDocShare(
|
||
shareModalDoc.id,
|
||
targetType === 'team'
|
||
? { teamId: targetId, permission: perm }
|
||
: { userId: targetId, permission: perm }
|
||
);
|
||
const updatedShares = await api.getDocShares(shareModalDoc.id);
|
||
setActiveDocShares(updatedShares);
|
||
} else if (shareModalFolder) {
|
||
await api.createFolderShare(shareModalFolder.id, {
|
||
targetType,
|
||
targetId,
|
||
permission: perm,
|
||
});
|
||
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);
|
||
}
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* WebSocket Blocked Modal */}
|
||
{showWsBlockedModal && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150">
|
||
<div className="w-full max-w-md bg-canvas border border-border rounded-card shadow-modal p-6 space-y-4">
|
||
<div className="flex items-start gap-3">
|
||
<div className="w-10 h-10 rounded-full bg-status-err/15 flex items-center justify-center shrink-0">
|
||
<AlertTriangle className="w-5 h-5 text-status-err" />
|
||
</div>
|
||
<div className="flex-1">
|
||
<h3 className="text-sm font-bold text-main">
|
||
WebSockets in diesem Netzwerk blockiert
|
||
</h3>
|
||
<p className="text-xs text-muted mt-1 leading-relaxed">
|
||
Deine Firewall oder dein Firmen-Proxy blockiert WebSocket-Verbindungen (<code className="text-accent bg-panel px-1 py-0.5 rounded">ws://</code> / <code className="text-accent bg-panel px-1 py-0.5 rounded">wss://</code>).
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-surface-subtle border border-border rounded-btn p-3 text-xs space-y-2 text-main">
|
||
<div className="flex items-center gap-2 text-status-ok font-semibold">
|
||
<Check className="w-4 h-4 shrink-0" />
|
||
<span>Kein Datenverlust: Auto-Save per HTTPS ist aktiv!</span>
|
||
</div>
|
||
<p className="text-muted leading-relaxed">
|
||
Jede getippte Zeile und alle Änderungen werden zuverlässig über Standard-HTTPS-Anfragen im Hintergrund gespeichert.
|
||
</p>
|
||
<p className="text-muted text-[11px] leading-relaxed">
|
||
Lediglich die gleichzeitige Tastendruck-Echtzeitanzeige mit anderen Nutzern im selben Moment ist ohne WebSockets eingeschränkt.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex justify-end pt-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowWsBlockedModal(false)}
|
||
className="btn-action px-4 py-1.5 text-xs font-semibold bg-accent text-on-accent hover:opacity-90 transition-opacity cursor-pointer"
|
||
>
|
||
Verstanden
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|