MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 19s
2310 lines
94 KiB
TypeScript
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>
|
|
);
|
|
};
|