Files
MindSift/frontend/src/features/workspace/WorkspaceView.tsx
T
sandrodach 31847ffaa3
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s
fix(workspace): auto-create bullet below divider and allow click to create bullet at bottom
2026-10-25 12:20:27 +01:00

3871 lines
160 KiB
TypeScript

import React, { useEffect, useState, useMemo, useRef } from 'react';
import {
FilePlus,
FolderPlus,
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
Search,
Pin,
Trash2,
Download,
CheckSquare,
Heading1,
Heading2,
Heading3,
List,
Code,
Sparkles,
AlignLeft,
Check,
X,
ArrowUp,
ArrowDown,
Bold,
Italic,
Strikethrough,
Link,
FileText,
Table as TableIcon,
Percent,
Minus,
ExternalLink,
ArrowLeft,
GripVertical,
Layers,
Filter,
CheckCircle2,
Circle,
FolderTree,
Plus,
HelpCircle,
Users,
Lock,
Unlock,
Eye,
Edit3,
Share,
Share2,
Hash,
} from 'lucide-react';
import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails, TagSummary, User } from '../../lib/types';
import { api, getToken, triggerHaptic } from '../../lib/api';
interface WorkspaceViewProps {
currentUser?: User | null;
initialDocId?: string | null;
onStatsChange: () => void;
}
const ICONS = ['📄', '📝', '💡', '🚀', '🏡', '⚡', '🎯', '🍕', '🛠️', '🧠', '🔥', '🏔️', '📦', '📑', '⚙️', '📊'];
interface FolderTreeItem {
path: string;
name: string;
depth: number;
subfolders: FolderTreeItem[];
docs: Document[];
}
interface SlashCommandItem {
type?: BlockType;
action?: 'insert_link' | 'insert_note' | 'insert_weblink';
label: string;
desc: string;
icon: string;
}
const SLASH_COMMANDS: SlashCommandItem[] = [
{ action: 'insert_link', label: '/link', desc: 'Webseite oder Notiz verlinken', icon: '🔗' },
{ action: 'insert_note', label: '/notiz', desc: 'Notiz verlinken [[Notiz]]', icon: '📄' },
{ action: 'insert_weblink', label: '/web', desc: 'Externe Webseite verlinken [Name](url)', icon: '🌐' },
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: '☑️' },
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: 'H1' },
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: 'H2' },
{ type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: 'H3' },
{ type: 'bullet', label: '/bullet', desc: 'Standard Listenpunkt', icon: '•' },
{ type: 'callout', label: '/callout', desc: 'Hervorgehobene Hinweisbox', icon: '💡' },
{ type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: '💻' },
{ type: 'table', label: '/table', desc: 'Markdown-Tabelle', icon: '📊' },
{ type: 'progress', label: '/progress', desc: 'Fortschrittsbalken', icon: '📈' },
{ type: 'divider', label: '/divider', desc: 'Horizontale Trennlinie', icon: '—' },
];
export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
currentUser,
initialDocId,
onStatsChange,
}) => {
// Top Workspace Tab Mode: 'editor' vs 'todos' vs 'tag'
const [workspaceMode, setWorkspaceMode] = useState<'editor' | 'todos' | 'tag'>('editor');
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
const [workspaceTags, setWorkspaceTags] = useState<TagSummary[]>([]);
const [isLoadingTag, setIsLoadingTag] = useState(false);
const [docs, setDocs] = useState<Document[]>([]);
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [saveState, setSaveState] = useState<'saved' | 'saving'>('saved');
const [showIconPicker, setShowIconPicker] = useState(false);
const [showWikiLinkMenu, setShowWikiLinkMenu] = useState(false);
// Folder state (synchronized with server)
const [customFolders, setCustomFolders] = useState<string[]>([]);
useEffect(() => {
try {
localStorage.removeItem('mindsift_workspace_folders');
} catch {}
}, []);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const [newFolderParent, setNewFolderParent] = useState<string | null>(null);
const [newFolderName, setNewFolderName] = useState('');
// Sidebar Document & Folder 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);
// Active focused block ref
const [lastFocusedBlockId, setLastFocusedBlockId] = useState<string | null>(null);
// --- LOGSEQ OUTLINER SPECIFIC STATE ---
// Zoom-in / Focus mode (Hoisting to a bullet)
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
// Floating Slash Command menu
const [slashBlockId, setSlashBlockId] = useState<string | null>(null);
const [slashSearch, setSlashSearch] = useState('');
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
const slashMenuRef = useRef<HTMLDivElement | null>(null);
// Link Insertion Modal state (/link, /notiz, /web)
const [linkModalBlockId, setLinkModalBlockId] = useState<string | null>(null);
const [linkModalTab, setLinkModalTab] = useState<'note' | 'web'>('note');
const [linkSearchQuery, setLinkSearchQuery] = useState('');
const [linkWebTitle, setLinkWebTitle] = useState('');
const [linkWebUrl, setLinkWebUrl] = useState('');
// Drag and Drop reordering state for blocks
const [draggingBlockId, setDraggingBlockId] = useState<string | null>(null);
// --- GLOBAL TO-DO ROLLUP STATE ---
const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open');
const [todoFolderDrilldown, setTodoFolderDrilldown] = useState<string | null>(null);
const [collapsedTodoFolders, setCollapsedTodoFolders] = useState<Set<string>>(new Set());
// --- DOCUMENT SHARING STATE ---
const [showShareModal, setShowShareModal] = useState(false);
const [docShares, setDocShares] = useState<DocShare[]>([]);
const [collaborators, setCollaborators] = useState<User[]>([]);
const [shareTargetEmailOrId, setShareTargetEmailOrId] = useState('');
const [sharePermission, setSharePermission] = useState<'read' | 'write'>('read');
const [sharingLoading, setSharingLoading] = useState(false);
const [shareError, setShareError] = useState<string | null>(null);
// --- DOCUMENT LOCKING STATE ---
const [activeLock, setActiveLock] = useState<DocLockInfo | null>(null);
const [holdsLock, setHoldsLock] = useState(false);
const holdsLockRef = useRef(false);
const heartbeatTimerRef = useRef<any>(null);
const idleUnlockTimerRef = useRef<any>(null);
const currentDocIdRef = useRef<string | null>(null);
const isEditorFocusedRef = useRef(false);
const blockInputRefs = useRef<Record<string, HTMLTextAreaElement | HTMLInputElement | null>>({});
const isLockedByOther = !!(activeLock && activeLock.userId !== currentUser?.id);
const isReadOnly = selectedDoc?.permission === 'read' || isLockedByOther;
const stopHeartbeat = () => {
if (heartbeatTimerRef.current) {
clearInterval(heartbeatTimerRef.current);
heartbeatTimerRef.current = null;
}
};
const startHeartbeat = (docId: string) => {
stopHeartbeat();
heartbeatTimerRef.current = setInterval(async () => {
if (!holdsLockRef.current) {
stopHeartbeat();
return;
}
const res = await api.heartbeatDocLock(docId);
if (!res.extended) {
holdsLockRef.current = false;
setHoldsLock(false);
stopHeartbeat();
}
}, 4000);
};
const releaseMyLock = async (docId?: string) => {
const targetDocId = docId || currentDocIdRef.current;
if (!targetDocId) return;
const wasHolding = holdsLockRef.current;
stopHeartbeat();
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
idleUnlockTimerRef.current = null;
}
holdsLockRef.current = false;
setHoldsLock(false);
if (wasHolding || docId) {
try {
await api.unlockDocument(targetDocId);
} catch {}
}
};
const ensureLock = async (): Promise<boolean> => {
if (!selectedDoc || selectedDoc.permission === 'read') return false;
if (holdsLockRef.current) {
return true;
}
try {
const res = await api.lockDocument(selectedDoc.id);
if (res.acquired) {
holdsLockRef.current = true;
setHoldsLock(true);
setActiveLock(null);
startHeartbeat(selectedDoc.id);
return true;
} else if (res.lock) {
setActiveLock(res.lock);
holdsLockRef.current = false;
setHoldsLock(false);
return false;
}
} catch (e) {
console.error('Failed to acquire lock', e);
}
return false;
};
const handleBlockFocus = () => {
isEditorFocusedRef.current = true;
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
idleUnlockTimerRef.current = null;
}
if (!isReadOnly) {
ensureLock();
}
};
const handleBlockBlur = () => {
isEditorFocusedRef.current = false;
if (idleUnlockTimerRef.current) {
clearTimeout(idleUnlockTimerRef.current);
}
// Short grace period: if user clicked into another block in the doc, focus will cancel this.
// If user clicked outside or stopped editing, save and release the lock immediately!
idleUnlockTimerRef.current = setTimeout(async () => {
if (!isEditorFocusedRef.current && holdsLockRef.current) {
await flushPendingSave();
await releaseMyLock();
}
}, 1500);
};
// Debounced auto-save refs (500ms delay to avoid writing every keystroke to SSD)
const debounceSaveTimerRef = useRef<any>(null);
const pendingSaveDocRef = useRef<Document | null>(null);
const executeSaveToBackend = async (docToSave: Document) => {
if (isReadOnly) return;
try {
const saved = await api.updateDocument(docToSave.id, {
title: docToSave.title,
icon: docToSave.icon,
folderPath: docToSave.folderPath,
blocks: docToSave.blocks,
tags: docToSave.tags,
isPinned: docToSave.isPinned,
});
setSelectedDoc((prev) =>
prev && prev.id === saved.id
? {
...prev,
...saved,
permission: prev.permission,
isOwner: prev.isOwner,
ownerName: prev.ownerName,
sharedWith: prev.sharedWith,
}
: prev
);
onStatsChange();
api.getWorkspaceTags().then(setWorkspaceTags).catch(() => {});
if (selectedTag) {
api.getTagDetails(selectedTag).then(setTagDetails).catch(() => {});
}
} catch (e: any) {
if (e.message && e.message.includes('423')) {
api.getDocLock(docToSave.id).then((res) => {
if (res.lock) setActiveLock(res.lock);
});
}
} finally {
if (pendingSaveDocRef.current === null || pendingSaveDocRef.current === docToSave) {
pendingSaveDocRef.current = null;
setSaveState('saved');
}
if (!isEditorFocusedRef.current && holdsLockRef.current) {
releaseMyLock();
}
}
};
const flushPendingSave = async () => {
if (debounceSaveTimerRef.current) {
clearTimeout(debounceSaveTimerRef.current);
debounceSaveTimerRef.current = null;
}
if (pendingSaveDocRef.current) {
const doc = pendingSaveDocRef.current;
pendingSaveDocRef.current = null;
await executeSaveToBackend(doc);
}
};
const persistDocument = async (updatedDoc: Document, immediate = false) => {
if (isReadOnly) return;
await ensureLock();
// 1. Update local UI state immediately for responsive, instant typing
setSelectedDoc(updatedDoc);
setDocs((prev) => prev.map((d) => (d.id === updatedDoc.id ? updatedDoc : d)));
setSaveState('saving');
pendingSaveDocRef.current = updatedDoc;
if (debounceSaveTimerRef.current) {
clearTimeout(debounceSaveTimerRef.current);
debounceSaveTimerRef.current = null;
}
if (immediate) {
await flushPendingSave();
} else {
// 500 ms debounced delay after last keystroke so disk/SSD is only written after typing pauses
debounceSaveTimerRef.current = setTimeout(async () => {
debounceSaveTimerRef.current = null;
if (pendingSaveDocRef.current) {
const doc = pendingSaveDocRef.current;
pendingSaveDocRef.current = null;
await executeSaveToBackend(doc);
}
}, 500);
}
};
const loadDocs = async (selectId?: string | null) => {
try {
const [list, serverFolders, tags] = await Promise.all([
api.getDocuments(),
api.getFolders(),
api.getWorkspaceTags().catch(() => []),
]);
setDocs(list);
setCustomFolders(serverFolders);
setWorkspaceTags(tags);
if (selectId) {
const found = list.find((d) => d.id === selectId);
if (found) {
setSelectedDoc(found);
return;
}
}
if (!selectedDoc && list.length > 0) {
setSelectedDoc(list[0]);
} else if (selectedDoc) {
const updated = list.find((d) => d.id === selectedDoc.id);
if (updated) setSelectedDoc(updated);
}
} catch (err) {
console.error('Error loading documents or folders:', err);
}
};
const openTagPage = async (tag: string) => {
const clean = tag.replace(/^#/, '').toLowerCase();
setSelectedTag(clean);
setWorkspaceMode('tag');
setIsLoadingTag(true);
try {
const details = await api.getTagDetails(clean);
setTagDetails(details);
} catch (err) {
console.error('Failed to load tag details', err);
} finally {
setIsLoadingTag(false);
}
};
useEffect(() => {
loadDocs(initialDocId);
}, [initialDocId]);
const selectedDocIdRef = useRef<string | null>(null);
selectedDocIdRef.current = selectedDoc?.id || null;
const currentUserIdRef = useRef<string | null>(null);
currentUserIdRef.current = currentUser?.id || null;
// Handle document switch and lock status checks
useEffect(() => {
const prevId = currentDocIdRef.current;
if (prevId && prevId !== selectedDoc?.id) {
flushPendingSave();
releaseMyLock(prevId);
}
currentDocIdRef.current = selectedDoc?.id || null;
if (selectedDoc) {
api
.getDocLock(selectedDoc.id)
.then((res) => {
if (res.locked && res.lock && res.lock.userId !== currentUser?.id) {
setActiveLock(res.lock);
} else {
setActiveLock(null);
}
})
.catch(() => {});
} else {
setActiveLock(null);
}
return () => {
flushPendingSave();
if (currentDocIdRef.current) {
releaseMyLock(currentDocIdRef.current);
}
};
}, [selectedDoc?.id]);
// Window blur / visibility change listener: release lock when user leaves tab/window
useEffect(() => {
const handleWindowBlur = () => {
if (!isEditorFocusedRef.current && holdsLockRef.current) {
flushPendingSave();
releaseMyLock();
}
};
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
isEditorFocusedRef.current = false;
flushPendingSave();
releaseMyLock();
}
};
window.addEventListener('blur', handleWindowBlur);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('blur', handleWindowBlur);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, []);
// Flush pending save and release lock before tab closes or unmounts
useEffect(() => {
const handleBeforeUnload = () => {
flushPendingSave();
if (currentDocIdRef.current && holdsLockRef.current) {
try {
const token = getToken();
fetch(`/api/v1/documents/${currentDocIdRef.current}/lock`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {},
keepalive: true,
}).catch(() => {});
} catch {}
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
window.removeEventListener('beforeunload', handleBeforeUnload);
flushPendingSave();
if (currentDocIdRef.current && holdsLockRef.current) {
releaseMyLock(currentDocIdRef.current);
}
};
}, []);
// Real-time WebSocket listener for Document & Lock events
useEffect(() => {
const handleWs = (e: any) => {
const msg = e.detail;
if (!msg || !msg.event) return;
if (msg.event === 'DOC_LOCKED') {
const payload = msg.payload as DocLockInfo;
if (payload && payload.docId === selectedDocIdRef.current) {
if (payload.userId !== currentUserIdRef.current) {
setActiveLock(payload);
}
}
} else if (msg.event === 'DOC_UNLOCKED') {
const payload = msg.payload as { docId?: string; doc_id?: string; id?: string };
const unlockedDocId = payload?.docId || payload?.doc_id || payload?.id;
if (unlockedDocId && unlockedDocId === selectedDocIdRef.current) {
setActiveLock(null);
// Fetch fresh document from backend so updates made by the other user are immediately visible
api.getDocument(unlockedDocId).then((freshDoc) => {
if (freshDoc && freshDoc.id === selectedDocIdRef.current) {
setSelectedDoc((prev) => (prev && prev.id === freshDoc.id ? { ...prev, ...freshDoc, isPinned: prev.isPinned } : prev));
setDocs((prev) => prev.map((d) => (d.id === freshDoc.id ? { ...d, ...freshDoc, isPinned: d.isPinned } : d)));
}
}).catch(() => {});
}
} else if (msg.event === 'DOC_CREATED') {
const created = msg.payload as Document;
if (created && created.id) {
setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev]));
onStatsChange();
}
} else if (msg.event === 'DOC_UPDATED') {
const updated = msg.payload as Document;
if (updated && updated.id) {
setDocs((prev) =>
prev.map((d) => (d.id === updated.id ? { ...d, ...updated, isPinned: d.isPinned } : d))
);
if (selectedDocIdRef.current === updated.id) {
if (!holdsLockRef.current) {
setSelectedDoc((prev) => (prev ? { ...prev, ...updated, isPinned: prev.isPinned } : updated));
}
}
onStatsChange();
}
} else if (msg.event === 'DOC_DELETED') {
const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id;
if (deletedId) {
setDocs((prev) => prev.filter((d) => d.id !== deletedId));
if (selectedDocIdRef.current === deletedId) {
setSelectedDoc(null);
}
onStatsChange();
}
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
api.getFolders().then(setCustomFolders).catch(() => {});
}
};
window.addEventListener('mindsift:ws', handleWs);
return () => window.removeEventListener('mindsift:ws', handleWs);
}, [onStatsChange]);
const openShareModal = async () => {
if (!selectedDoc) return;
setShowShareModal(true);
setShareError(null);
setSharingLoading(true);
try {
const [sharesList, usersList] = await Promise.all([
api.getDocShares(selectedDoc.id).catch(() => []),
api.getCollaborators().catch(() => []),
]);
setDocShares(sharesList);
setCollaborators(usersList);
} finally {
setSharingLoading(false);
}
};
const handleAddShare = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedDoc) return;
const target = shareTargetEmailOrId.trim();
if (!target) return;
setSharingLoading(true);
setShareError(null);
try {
const isId = collaborators.some((u) => u.id === target);
const newShare = await api.createDocShare(selectedDoc.id, {
userId: isId ? target : undefined,
email: !isId ? target : undefined,
permission: sharePermission,
});
setDocShares((prev) => [...prev.filter((s) => s.userId !== newShare.userId), newShare]);
setShareTargetEmailOrId('');
setSelectedDoc((prev) =>
prev
? {
...prev,
sharedWith: [
...(prev.sharedWith || []).filter((s) => s.userId !== newShare.userId),
newShare,
],
}
: prev
);
} catch (err: any) {
setShareError(err.message || 'Freigabe konnte nicht erstellt werden');
} finally {
setSharingLoading(false);
}
};
const handleUpdateSharePerm = async (targetUserId: string, newPerm: 'read' | 'write') => {
if (!selectedDoc) return;
setSharingLoading(true);
try {
const updated = await api.createDocShare(selectedDoc.id, {
userId: targetUserId,
permission: newPerm,
});
setDocShares((prev) => prev.map((s) => (s.userId === targetUserId ? updated : s)));
setSelectedDoc((prev) =>
prev
? {
...prev,
sharedWith: (prev.sharedWith || []).map((s) =>
s.userId === targetUserId ? updated : s
),
}
: prev
);
} catch (err: any) {
setShareError(err.message || 'Berechtigung konnte nicht geändert werden');
} finally {
setSharingLoading(false);
}
};
const handleRemoveShare = async (targetUserId: string) => {
if (!selectedDoc) return;
if (!window.confirm('Freigabe für diesen Benutzer wirklich aufheben?')) return;
setSharingLoading(true);
try {
await api.deleteDocShare(selectedDoc.id, targetUserId);
setDocShares((prev) => prev.filter((s) => s.userId !== targetUserId));
setSelectedDoc((prev) =>
prev
? {
...prev,
sharedWith: (prev.sharedWith || []).filter((s) => s.userId !== targetUserId),
}
: prev
);
} catch (err: any) {
setShareError(err.message || 'Freigabe konnte nicht entfernt werden');
} finally {
setSharingLoading(false);
}
};
const handleLeaveShare = async () => {
if (!selectedDoc || !currentUser) return;
const totalBlocks = selectedDoc.blocks?.length || 0;
const subpointsCount = (selectedDoc.blocks || []).filter((b) => (b.level || 0) > 0).length;
const title = selectedDoc.title || 'Geteilte Notiz';
let confirmMsg = 'Möchtest du diese freigegebene Notiz wirklich aus deinem Workspace entfernen?';
if (totalBlocks > 0 && subpointsCount > 0) {
confirmMsg = `Möchtest du die geteilte Notiz "${title}" mit allen ${totalBlocks} Punkten (inklusive ${subpointsCount} Unterpunkten) wirklich aus deinem Workspace entfernen?`;
} else if (totalBlocks > 0) {
confirmMsg = `Möchtest du die geteilte Notiz "${title}" wirklich aus deinem Workspace entfernen?`;
}
if (!window.confirm(confirmMsg)) return;
try {
await api.deleteDocument(selectedDoc.id);
setShowShareModal(false);
const remaining = docs.filter((d) => d.id !== selectedDoc.id);
setDocs(remaining);
setSelectedDoc(remaining[0] || null);
onStatsChange();
} catch (err: any) {
alert(err.message || 'Konnte Freigabe nicht verlassen');
}
};
const getDocFolder = (doc: Document): string => {
if (doc.folderPath) return doc.folderPath;
const tag = doc.tags?.find((t) => t.startsWith('folder:'));
return tag ? tag.replace('folder:', '') : '';
};
// Compute all unique folder paths
const allFolderPaths = useMemo(() => {
const set = new Set<string>(customFolders);
docs.forEach((doc) => {
const f = getDocFolder(doc);
if (f) {
const parts = f.split('/');
let cur = '';
for (const p of parts) {
cur = cur ? `${cur}/${p}` : p;
set.add(cur);
}
}
});
return Array.from(set).sort((a, b) => a.localeCompare(b));
}, [customFolders, docs]);
// Keep folders expanded by default
useEffect(() => {
setExpandedFolders((prev) => {
const next = new Set(prev);
allFolderPaths.forEach((p) => next.add(p));
return next;
});
}, [allFolderPaths.join(',')]);
const handleCreateDoc = async (folderPath?: string | null) => {
triggerHaptic(15);
const tags = ['notiz'];
const path = folderPath || '';
if (path) {
tags.push(`folder:${path}`);
}
const created = await api.createDocument({
title: 'Neue Notiz',
icon: '📄',
folderPath: path,
blocks: [
{
id: `blk_${Date.now()}_1`,
type: 'bullet',
content: '',
level: 0,
},
],
tags,
isPinned: false,
});
await loadDocs(created.id);
onStatsChange();
setWorkspaceMode('editor');
};
const handleDeleteDoc = async (id: string) => {
const docToDelete = docs.find((d) => d.id === id) || (selectedDoc?.id === id ? selectedDoc : null);
const title = docToDelete?.title || selectedDoc?.title || 'Notiz';
const totalBlocks = docToDelete?.blocks?.length || 0;
const subpointsCount = (docToDelete?.blocks || []).filter((b) => (b.level || 0) > 0).length;
let confirmMsg = `Möchtest du die Notiz "${title}" wirklich löschen?`;
if (totalBlocks > 0) {
if (subpointsCount > 0) {
confirmMsg = `Möchtest du die Notiz "${title}" mit allen ${totalBlocks} Aufzählungspunkten (inklusive ${subpointsCount} Unterpunkten) wirklich unwiderruflich löschen?`;
} else {
confirmMsg = `Möchtest du die Notiz "${title}" mit allen ${totalBlocks} Aufzählungspunkten wirklich löschen?`;
}
}
if (!window.confirm(confirmMsg)) return;
triggerHaptic(20);
await api.deleteDocument(id);
const remaining = docs.filter((d) => d.id !== id);
setDocs(remaining);
setSelectedDoc(remaining[0] || null);
onStatsChange();
};
const handleChangeDocFolder = async (folderPath: string) => {
if (!selectedDoc) return;
const cleanPath = folderPath.trim();
const updatedTags = (selectedDoc.tags || []).filter((t) => !t.startsWith('folder:'));
if (cleanPath) {
updatedTags.push(`folder:${cleanPath}`);
}
const updatedDoc = {
...selectedDoc,
folderPath: cleanPath,
tags: updatedTags,
};
await persistDocument(updatedDoc, true);
};
// Global drag cleanup so drag states never get stuck if dragend is lost during re-render
useEffect(() => {
const handleGlobalDragEnd = () => {
setDraggingDocId(null);
setDraggingFolderPath(null);
setDragOverFolder(null);
setDraggingBlockId(null);
};
window.addEventListener('dragend', handleGlobalDragEnd);
window.addEventListener('drop', handleGlobalDragEnd);
return () => {
window.removeEventListener('dragend', handleGlobalDragEnd);
window.removeEventListener('drop', handleGlobalDragEnd);
};
}, []);
const handleMoveFolder = async (sourcePath: string, targetParentPath: string) => {
const cleanSource = sourcePath.trim();
const cleanTarget = targetParentPath.trim();
// Cannot move to self or into own subfolder
if (!cleanSource || cleanSource === cleanTarget || cleanTarget.startsWith(cleanSource + '/')) {
return;
}
// Name of the folder being moved
const folderName = cleanSource.split('/').pop() || cleanSource;
const newFolderPath = cleanTarget ? `${cleanTarget}/${folderName}` : folderName;
if (newFolderPath === cleanSource) return;
triggerHaptic(15);
// 1. Update all documents that are in cleanSource or any subfolder of cleanSource
const docsToUpdate = docs.filter((d) => {
const f = getDocFolder(d);
return f === cleanSource || f.startsWith(cleanSource + '/');
});
const updatedDocsList = docs.map((d) => {
const f = getDocFolder(d);
if (f === cleanSource) {
const nextFolder = newFolderPath;
const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:'));
if (nextFolder) updatedTags.push(`folder:${nextFolder}`);
return { ...d, folderPath: nextFolder, tags: updatedTags };
} else if (f.startsWith(cleanSource + '/')) {
const suffix = f.slice(cleanSource.length);
const nextFolder = `${newFolderPath}${suffix}`;
const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:'));
if (nextFolder) updatedTags.push(`folder:${nextFolder}`);
return { ...d, folderPath: nextFolder, tags: updatedTags };
}
return d;
});
setDocs(updatedDocsList);
if (selectedDoc) {
const found = updatedDocsList.find((d) => d.id === selectedDoc.id);
if (found) setSelectedDoc(found);
}
// Persist all affected docs to backend
for (const d of docsToUpdate) {
const f = getDocFolder(d);
let nextFolder = newFolderPath;
if (f.startsWith(cleanSource + '/')) {
const suffix = f.slice(cleanSource.length);
nextFolder = `${newFolderPath}${suffix}`;
}
const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:'));
if (nextFolder) updatedTags.push(`folder:${nextFolder}`);
api
.updateDocument(d.id, {
folderPath: nextFolder,
tags: updatedTags,
})
.catch((err) => console.error('Failed to update doc folder', err));
}
// 2. Sync folder structure with server
api
.createFolder(newFolderPath)
.then(() => api.deleteFolder(cleanSource))
.then(() => api.getFolders())
.then(setCustomFolders)
.catch((err) => console.error('Error syncing moved folders with server', err));
// Expand the new folder path and its parent
setExpandedFolders((prev) => {
const next = new Set(prev);
next.add(newFolderPath);
if (cleanTarget) next.add(cleanTarget);
return next;
});
};
const handleDropDocToFolder = async (folderPath: string, e: React.DragEvent) => {
const docId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
setDraggingDocId(null);
setDragOverFolder(null);
if (!docId) return;
const doc = docs.find((d) => d.id === docId);
if (!doc) return;
const cleanPath = folderPath.trim();
if (getDocFolder(doc) === cleanPath) return;
triggerHaptic(15);
const updatedTags = (doc.tags || []).filter((t) => !t.startsWith('folder:'));
if (cleanPath) {
updatedTags.push(`folder:${cleanPath}`);
}
const updatedDoc: Document = {
...doc,
folderPath: cleanPath,
tags: updatedTags,
};
await persistDocument(updatedDoc, true);
if (cleanPath) {
setExpandedFolders((prev) => new Set([...prev, cleanPath]));
}
};
const handleStartCreateFolder = (parentPath?: string | null) => {
setNewFolderParent(parentPath || null);
setNewFolderName('');
setIsCreatingFolder(true);
if (parentPath) {
setExpandedFolders((prev) => new Set([...prev, parentPath]));
}
};
const handleConfirmCreateFolder = async () => {
const trimmed = newFolderName.trim();
if (!trimmed) {
setIsCreatingFolder(false);
return;
}
const fullPath = newFolderParent ? `${newFolderParent}/${trimmed}` : trimmed;
try {
await api.createFolder(fullPath);
const serverFolders = await api.getFolders();
setCustomFolders(serverFolders);
} catch (err) {
console.error('Failed to create folder on server', err);
if (!customFolders.includes(fullPath)) {
setCustomFolders((prev) => [...prev, fullPath].sort((a, b) => a.localeCompare(b)));
}
}
setExpandedFolders((prev) => new Set([...prev, fullPath]));
setIsCreatingFolder(false);
setNewFolderName('');
setNewFolderParent(null);
};
const handleDeleteFolder = async (folderPath: string, folderName: string) => {
// Find all documents in this folder and subfolders
const docsInFolder = docs.filter((d) => {
const f = getDocFolder(d);
return f === folderPath || f.startsWith(`${folderPath}/`);
});
const confirmMsg =
docsInFolder.length > 0
? `Möchtest du den Ordner "${folderName}" wirklich löschen? Alle ${docsInFolder.length} darin enthaltenen Notizen werden unwiderruflich gelöscht!`
: `Möchtest du den leeren Ordner "${folderName}" wirklich löschen?`;
if (!window.confirm(confirmMsg)) return;
try {
const res = await api.deleteFolder(folderPath);
const deletedIds = new Set(res.deletedDocIds || docsInFolder.map((d) => d.id));
const nextDocs = docs.filter((d) => !deletedIds.has(d.id));
setDocs(nextDocs);
if (selectedDoc && deletedIds.has(selectedDoc.id)) {
setSelectedDoc(nextDocs.length > 0 ? nextDocs[0] : null);
}
const serverFolders = await api.getFolders();
setCustomFolders(serverFolders);
onStatsChange();
} catch (err) {
console.error('Failed to delete folder', err);
alert('Fehler beim Löschen des Ordners');
}
};
const toggleFolder = (folderPath: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(folderPath)) {
next.delete(folderPath);
} else {
next.add(folderPath);
}
return next;
});
};
// ============================================================================
// LOGSEQ OUTLINER CORE LOGIC
// ============================================================================
// Helper: check if a block has children (i.e. next block has greater level)
const hasChildren = (blocks: DocumentBlock[], index: number): boolean => {
if (index + 1 >= blocks.length) return false;
const currentLevel = blocks[index].level || 0;
return (blocks[index + 1].level || 0) > currentLevel;
};
// Insert a new bullet immediately after index, inheriting level
const insertNewBulletAfter = (
afterIndex: number,
level: number,
type: BlockType = 'bullet',
initialContent: string = ''
) => {
if (isReadOnly || !selectedDoc) return;
const newBlock: DocumentBlock = {
id: `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
type,
content: initialContent,
level,
checked: false,
};
const nextBlocks = [...selectedDoc.blocks];
nextBlocks.splice(afterIndex + 1, 0, newBlock);
persistDocument({ ...selectedDoc, blocks: nextBlocks });
setLastFocusedBlockId(newBlock.id);
// Focus into the new element next tick
setTimeout(() => {
const el = blockInputRefs.current[newBlock.id];
if (el) {
el.focus();
if ('setSelectionRange' in el && typeof (el as any).value === 'string') {
(el as any).setSelectionRange(0, 0);
}
}
}, 20);
};
// Indent block (Tab): increase level by 1
const indentBlock = (index: number) => {
if (isReadOnly || !selectedDoc || index === 0) return;
const block = selectedDoc.blocks[index];
const prevBlock = selectedDoc.blocks[index - 1];
const currentLevel = block.level || 0;
const maxLevel = (prevBlock.level || 0) + 1;
if (currentLevel < maxLevel) {
updateBlock(block.id, { level: currentLevel + 1 });
}
};
// Outdent block (Shift+Tab): decrease level by 1
const outdentBlock = (blockId: string) => {
if (isReadOnly || !selectedDoc) return;
const block = selectedDoc.blocks.find((b) => b.id === blockId);
if (!block) return;
const currentLevel = block.level || 0;
if (currentLevel > 0) {
updateBlock(blockId, { level: currentLevel - 1 });
}
};
// Delete block and all its descendant sub-bullets with confirmation
const removeBlock = (blockId: string): boolean => {
if (isReadOnly || !selectedDoc) return false;
const blocks = selectedDoc.blocks;
const index = blocks.findIndex((b) => b.id === blockId);
if (index === -1) return false;
const targetBlock = blocks[index];
const targetLevel = targetBlock.level || 0;
// Find all descendant sub-bullets directly or indirectly nested under this block
const descendantIds: string[] = [];
for (let i = index + 1; i < blocks.length; i++) {
const cur = blocks[i];
const curLevel = cur.level || 0;
if (curLevel <= targetLevel) break;
descendantIds.push(cur.id);
}
if (descendantIds.length > 0) {
const count = descendantIds.length;
const snippet = targetBlock.content?.trim() ? ` "${targetBlock.content.trim().slice(0, 30)}..."` : '';
const confirmText =
count === 1
? `Dieser Aufzählungspunkt${snippet} enthält 1 Unterpunkt. Möchtest du ihn mitsamt dem Unterpunkt wirklich löschen?`
: `Dieser Aufzählungspunkt${snippet} enthält ${count} Unterpunkte. Möchtest du ihn mitsamt allen ${count} Unterpunkten wirklich löschen?`;
if (!window.confirm(confirmText)) {
return false;
}
}
triggerHaptic(15);
const idsToDelete = new Set([blockId, ...descendantIds]);
let nextBlocks = blocks.filter((b) => !idsToDelete.has(b.id));
if (nextBlocks.length === 0) {
nextBlocks = [
{
id: `blk_${Date.now()}_1`,
type: 'bullet',
content: '',
level: 0,
},
];
}
// Determine fallback block to focus
const prevBlock = index > 0 ? blocks[index - 1] : nextBlocks[0];
persistDocument({ ...selectedDoc, blocks: nextBlocks });
if (prevBlock) {
setLastFocusedBlockId(prevBlock.id);
setTimeout(() => {
const el = blockInputRefs.current[prevBlock.id];
if (el) {
el.focus();
if ('setSelectionRange' in el && typeof el.value === 'string') {
el.setSelectionRange(el.value.length, el.value.length);
}
}
}, 30);
}
return true;
};
// Delete block and focus the preceding one
const deleteBlockAndFocusPrev = (index: number) => {
if (isReadOnly || !selectedDoc) return;
const targetBlock = selectedDoc.blocks[index];
if (!targetBlock) return;
removeBlock(targetBlock.id);
};
const updateBlock = (blockId: string, patch: Partial<DocumentBlock>) => {
if (isReadOnly || !selectedDoc) return;
const nextBlocks = selectedDoc.blocks.map((b) =>
b.id === blockId ? { ...b, ...patch } : b
);
persistDocument({ ...selectedDoc, blocks: nextBlocks });
};
const toggleCollapse = (blockId: string) => {
if (!selectedDoc) return;
const block = selectedDoc.blocks.find((b) => b.id === blockId);
if (block) {
updateBlock(blockId, { collapsed: !block.collapsed });
}
};
// Keyboard Navigation: Enter, Tab, Shift+Tab, Backspace, / Slash Commands
const handleKeyDown = (
e: React.KeyboardEvent,
block: DocumentBlock,
index: number
) => {
if (isReadOnly) return;
ensureLock();
if (slashBlockId === block.id) {
if (filteredSlashCommands.length === 0) {
if (e.key === 'Escape') {
setSlashBlockId(null);
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setSlashSelectedIndex((prev) => (prev + 1) % filteredSlashCommands.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSlashSelectedIndex((prev) =>
prev === 0 ? filteredSlashCommands.length - 1 : prev - 1
);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const selected = filteredSlashCommands[slashSelectedIndex];
if (selected) {
applySlashCommand(block.id, selected);
}
return;
}
if (e.key === 'Escape') {
setSlashBlockId(null);
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
setSlashBlockId(null);
const target = e.target as HTMLTextAreaElement | HTMLInputElement;
let afterContent = '';
if (target && typeof target.selectionStart === 'number') {
const cursor = target.selectionStart;
const fullContent = block.content || '';
const before = fullContent.slice(0, cursor);
const after = fullContent.slice(cursor);
if (after.length > 0) {
updateBlock(block.id, { content: before });
afterContent = after;
}
}
insertNewBulletAfter(index, block.level || 0, block.type === 'todo' ? 'todo' : 'bullet', afterContent);
return;
} else if (e.key === 'Tab') {
e.preventDefault();
if (e.shiftKey) {
outdentBlock(block.id);
} else {
indentBlock(index);
}
} else if (e.key === 'Backspace' && (!block.content || block.content === '')) {
if ((block.level || 0) > 0) {
e.preventDefault();
outdentBlock(block.id);
} else if (selectedDoc?.blocks && selectedDoc.blocks.length > 1) {
e.preventDefault();
setSlashBlockId(null);
deleteBlockAndFocusPrev(index);
}
}
};
const handleContentChange = (block: DocumentBlock, newContent: string) => {
if (isReadOnly) return;
ensureLock();
// Markdown shortcut: typing '---' creates a divider and inserts a new bullet below
if (newContent.trim() === '---') {
const blkIdx = selectedDoc ? selectedDoc.blocks.findIndex((b) => b.id === block.id) : -1;
if (selectedDoc && blkIdx !== -1) {
const curLevel = block.level || 0;
const newBlockId = `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const newBullet: DocumentBlock = {
id: newBlockId,
type: 'bullet',
content: '',
level: curLevel,
checked: false,
};
const nextBlocks = [...selectedDoc.blocks];
nextBlocks[blkIdx] = {
...block,
type: 'divider',
content: '',
};
nextBlocks.splice(blkIdx + 1, 0, newBullet);
persistDocument({ ...selectedDoc, blocks: nextBlocks });
setSlashBlockId(null);
focusBlock(newBlockId);
return;
}
}
updateBlock(block.id, { content: newContent });
// Check if user just typed '/'
if (newContent.endsWith('/') || newContent.includes('/')) {
const match = newContent.match(/\/([a-z0-9]*)$/i);
if (match) {
setSlashBlockId(block.id);
setSlashSearch(match[1]);
setSlashSelectedIndex(0);
return;
}
}
if (slashBlockId === block.id) {
setSlashBlockId(null);
}
};
const filteredSlashCommands = useMemo(() => {
if (!slashSearch) return SLASH_COMMANDS;
const q = slashSearch.toLowerCase();
return SLASH_COMMANDS.filter(
(c) => c.label.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)
);
}, [slashSearch]);
useEffect(() => {
if (slashBlockId && slashMenuRef.current) {
const container = slashMenuRef.current;
const activeBtn = container.querySelector<HTMLElement>(
`[data-slash-index="${slashSelectedIndex}"]`
);
if (activeBtn) {
const itemTop = activeBtn.offsetTop;
const itemBottom = itemTop + activeBtn.offsetHeight;
const containerTop = container.scrollTop;
const containerBottom = containerTop + container.clientHeight;
if (itemTop < containerTop) {
container.scrollTop = itemTop;
} else if (itemBottom > containerBottom) {
container.scrollTop = itemBottom - container.clientHeight;
}
}
}
}, [slashSelectedIndex, slashBlockId]);
const focusBlock = (blockId: string) => {
setLastFocusedBlockId(blockId);
const tryFocus = () => {
const el = blockInputRefs.current[blockId];
if (el) {
el.focus();
if ('setSelectionRange' in el && typeof (el as any).value === 'string') {
const len = (el as any).value.length;
(el as any).setSelectionRange(len, len);
}
return true;
}
return false;
};
setTimeout(() => {
if (!tryFocus()) {
setTimeout(tryFocus, 30);
}
}, 20);
};
const handleCanvasBottomClick = () => {
if (isReadOnly || !selectedDoc) return;
if (selectedDoc.blocks.length === 0) {
insertNewBulletAfter(-1, 0);
return;
}
const lastIndex = selectedDoc.blocks.length - 1;
const lastBlock = selectedDoc.blocks[lastIndex];
if (lastBlock && lastBlock.type !== 'divider' && (!lastBlock.content || lastBlock.content.trim() === '')) {
focusBlock(lastBlock.id);
} else {
insertNewBulletAfter(lastIndex, 0);
}
};
const applySlashCommand = (blockId: string, cmd: SlashCommandItem) => {
if (isReadOnly || !selectedDoc) return;
const block = selectedDoc.blocks.find((b) => b.id === blockId);
if (!block) return;
const cleanContent = block.content.replace(/\/([a-z0-9]*)$/i, '').trim();
setSlashBlockId(null);
setSlashSearch('');
if (cmd.action) {
updateBlock(blockId, { content: cleanContent });
setLinkModalBlockId(blockId);
setLinkModalTab(cmd.action === 'insert_weblink' ? 'web' : 'note');
setLinkSearchQuery('');
setLinkWebTitle('');
setLinkWebUrl('');
return;
}
if (cmd.type) {
if (cmd.type === 'divider') {
const blkIdx = selectedDoc.blocks.findIndex((b) => b.id === blockId);
const curLevel = block.level || 0;
const newBlockId = `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const newBullet: DocumentBlock = {
id: newBlockId,
type: 'bullet',
content: '',
level: curLevel,
checked: false,
};
const nextBlocks = [...selectedDoc.blocks];
nextBlocks[blkIdx] = {
...block,
type: 'divider',
content: '',
};
nextBlocks.splice(blkIdx + 1, 0, newBullet);
persistDocument({ ...selectedDoc, blocks: nextBlocks });
focusBlock(newBlockId);
return;
}
updateBlock(blockId, { type: cmd.type, content: cleanContent, checked: cmd.type === 'todo' ? false : undefined });
focusBlock(blockId);
}
};
const handleInsertNoteLink = (docTitle: string) => {
if (!linkModalBlockId || !selectedDoc) return;
const block = selectedDoc.blocks.find((b) => b.id === linkModalBlockId);
if (block) {
const space = block.content && !block.content.endsWith(' ') ? ' ' : '';
const newContent = `${block.content}${space}[[${docTitle}]] `;
updateBlock(linkModalBlockId, { content: newContent });
}
const targetBlockId = linkModalBlockId;
setLinkModalBlockId(null);
focusBlock(targetBlockId);
};
const handleInsertWebLink = () => {
if (!linkModalBlockId || !selectedDoc) return;
const block = selectedDoc.blocks.find((b) => b.id === linkModalBlockId);
let title = linkWebTitle.trim();
let url = linkWebUrl.trim();
if (!url) return;
if (!/^https?:\/\//i.test(url)) {
url = `https://${url}`;
}
if (!title) {
try {
title = new URL(url).hostname;
} catch {
title = url;
}
}
if (block) {
const space = block.content && !block.content.endsWith(' ') ? ' ' : '';
const newContent = `${block.content}${space}[${title}](${url}) `;
updateBlock(linkModalBlockId, { content: newContent });
}
const targetBlockId = linkModalBlockId;
setLinkModalBlockId(null);
setLinkWebTitle('');
setLinkWebUrl('');
focusBlock(targetBlockId);
};
const getDirectChildTodos = (blocks: DocumentBlock[], parentIndex: number): DocumentBlock[] => {
const parent = blocks[parentIndex];
if (!parent) return [];
const parentLevel = parent.level || 0;
const childTodos: DocumentBlock[] = [];
for (let i = parentIndex + 1; i < blocks.length; i++) {
const cur = blocks[i];
const curLevel = cur.level || 0;
if (curLevel <= parentLevel) break;
if (cur.type === 'todo') {
childTodos.push(cur);
}
}
return childTodos;
};
// Drag & Drop Handlers on the Bullet Dot
const handleDragStart = (e: React.DragEvent, blockId: string) => {
e.dataTransfer.setData('text/plain', blockId);
setDraggingBlockId(blockId);
};
const handleDropOnBlock = (e: React.DragEvent, targetBlockId: string) => {
e.preventDefault();
const sourceId = e.dataTransfer.getData('text/plain') || draggingBlockId;
if (!selectedDoc || !sourceId || sourceId === targetBlockId) {
setDraggingBlockId(null);
return;
}
const blocks = [...selectedDoc.blocks];
const sourceIdx = blocks.findIndex((b) => b.id === sourceId);
const targetIdx = blocks.findIndex((b) => b.id === targetBlockId);
if (sourceIdx === -1 || targetIdx === -1) {
setDraggingBlockId(null);
return;
}
const [moved] = blocks.splice(sourceIdx, 1);
// Align moved block's level to target
moved.level = blocks[targetIdx]?.level || 0;
blocks.splice(targetIdx, 0, moved);
persistDocument({ ...selectedDoc, blocks }, true);
setDraggingBlockId(null);
};
// Zoom-in / Hoisting Calculation
const { visibleBlocks, breadcrumbBlocks, zoomedBlock } = useMemo(() => {
if (!selectedDoc) return { visibleBlocks: [], breadcrumbBlocks: [], zoomedBlock: null };
const all = selectedDoc.blocks;
if (!zoomedBlockId) {
// Normal view: filter out children of collapsed blocks
const list: { block: DocumentBlock; originalIndex: number }[] = [];
let hideUntilLevel = -1;
for (let i = 0; i < all.length; i++) {
const b = all[i];
const lvl = b.level || 0;
if (hideUntilLevel !== -1) {
if (lvl > hideUntilLevel) {
continue; // Skip collapsed child
} else {
hideUntilLevel = -1; // End of collapsed subtree
}
}
list.push({ block: b, originalIndex: i });
if (b.collapsed) {
hideUntilLevel = lvl;
}
}
return { visibleBlocks: list, breadcrumbBlocks: [], zoomedBlock: null };
}
// Zoomed view: find zoomed block and its descendants
const zoomIdx = all.findIndex((b) => b.id === zoomedBlockId);
if (zoomIdx === -1) {
return { visibleBlocks: [], breadcrumbBlocks: [], zoomedBlock: null };
}
const target = all[zoomIdx];
const targetLevel = target.level || 0;
// Build breadcrumbs: find parents with strictly decreasing levels
const crumbs: DocumentBlock[] = [];
let curLevel = targetLevel;
for (let i = zoomIdx - 1; i >= 0; i--) {
const b = all[i];
const lvl = b.level || 0;
if (lvl < curLevel) {
crumbs.unshift(b);
curLevel = lvl;
}
}
// Descendants of zoomed block
const descendants: { block: DocumentBlock; originalIndex: number }[] = [];
for (let i = zoomIdx + 1; i < all.length; i++) {
const b = all[i];
if ((b.level || 0) > targetLevel) {
descendants.push({ block: b, originalIndex: i });
} else {
break;
}
}
return { visibleBlocks: descendants, breadcrumbBlocks: crumbs, zoomedBlock: target };
}, [selectedDoc, zoomedBlockId]);
// Export full document as portable Logseq-indented Markdown
const handleExportMarkdown = () => {
if (!selectedDoc) return;
const lines: string[] = [`# ${selectedDoc.icon} ${selectedDoc.title}`, ''];
for (const b of selectedDoc.blocks) {
const indent = ' '.repeat(b.level || 0);
switch (b.type) {
case 'h1':
lines.push(`${indent}- # ${b.content}`);
break;
case 'h2':
lines.push(`${indent}- ## ${b.content}`);
break;
case 'h3':
lines.push(`${indent}- ### ${b.content}`);
break;
case 'todo':
lines.push(`${indent}- [${b.checked ? 'x' : ' '}] ${b.content}`);
break;
case 'callout':
lines.push(`${indent}- > 💡 ${b.content}`);
break;
case 'code':
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${indent} ${b.content}\n${indent} \`\`\``);
break;
case 'table':
case 'bullet':
case 'paragraph':
default:
lines.push(`${indent}- ${b.content}`);
}
}
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${selectedDoc.title.toLowerCase().replace(/[^a-z0-9äöüß]+/g, '-')}.md`;
a.click();
URL.revokeObjectURL(url);
};
// Helper to render text with clickable Wiki-Links, Web-Links and Hashtags
const renderFormattedText = (text: string) => {
const parts = text.split(/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/[^\s)]+\)|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g);
return parts.map((part, i) => {
if (part.startsWith('[[') && part.endsWith(']]')) {
const linkTitle = part.slice(2, -2).trim();
const target = docs.find((d) => d.title.toLowerCase() === linkTitle.toLowerCase());
return (
<span
key={i}
onClick={(e) => {
e.stopPropagation();
if (target) {
setSelectedDoc(target);
setZoomedBlockId(null);
setWorkspaceMode('editor');
}
}}
className={`inline-flex items-center gap-1 px-1.5 py-0.2 mx-0.5 border text-xs font-medium cursor-pointer rounded-btn transition-colors ${
target
? 'bg-accent/20 border-accent/40 text-accent hover:bg-accent/30'
: 'badge-neutral hover:text-main'
}`}
title={target ? `Öffne: ${target.title}` : `Dokument "${linkTitle}" existiert noch nicht`}
>
<span>📄</span>
<span>{linkTitle}</span>
<ExternalLink className="w-2.5 h-2.5 opacity-60" />
</span>
);
}
const webMatch = part.match(/^\[(.*?)\]\((https?:\/\/[^\s)]+)\)$/);
if (webMatch) {
const title = webMatch[1] || webMatch[2];
const url = webMatch[2];
return (
<a
key={i}
href={url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 px-1.5 py-0.2 mx-0.5 bg-sky-500/10 border border-sky-500/30 text-sky-500 dark:text-sky-400 hover:bg-sky-500/20 text-xs font-medium rounded-btn transition-colors"
title={`Öffne: ${url}`}
>
<span>🌐</span>
<span>{title}</span>
<ExternalLink className="w-2.5 h-2.5 opacity-60" />
</a>
);
}
if (part.startsWith('#') && part.length > 1) {
const tagWord = part.slice(1);
return (
<span
key={i}
onClick={(e) => {
e.stopPropagation();
openTagPage(tagWord);
}}
className="inline-flex items-center gap-0.5 px-1.5 py-0.2 mx-0.5 bg-blue-500/10 border border-blue-500/30 text-blue-500 dark:text-blue-400 hover:bg-blue-500/20 text-xs font-medium cursor-pointer rounded-btn transition-colors"
title={`Öffne Hashtag-Seite: #${tagWord}`}
>
<Hash className="w-2.5 h-2.5 opacity-80" />
<span>{tagWord}</span>
</span>
);
}
return <span key={i}>{part}</span>;
});
};
// --- GLOBAL TO-DO ROLLUP AGGREGATOR ---
const allWorkspaceTodos = useMemo(() => {
const items: {
docId: string;
docTitle: string;
docIcon: string;
folder: string;
block: DocumentBlock;
}[] = [];
docs.forEach((doc) => {
const folder = getDocFolder(doc) || 'Root (Unsortiert)';
(doc.blocks || []).forEach((b) => {
if (b.type === 'todo' || (b.content && b.content.startsWith('- [ ]'))) {
items.push({
docId: doc.id,
docTitle: doc.title,
docIcon: doc.icon || '📄',
folder,
block: b,
});
}
});
});
return items;
}, [docs]);
const filteredTodos = useMemo(() => {
return allWorkspaceTodos.filter((item) => {
if (todoFilter === 'open' && item.block.checked) return false;
if (todoFilter === 'done' && !item.block.checked) return false;
if (todoFolderDrilldown && item.folder !== todoFolderDrilldown) return false;
return true;
});
}, [allWorkspaceTodos, todoFilter, todoFolderDrilldown]);
// Group filtered todos by folder -> docTitle
const todosByFolder = useMemo(() => {
const map = new Map<string, typeof filteredTodos>();
filteredTodos.forEach((item) => {
const existing = map.get(item.folder) || [];
existing.push(item);
map.set(item.folder, existing);
});
return map;
}, [filteredTodos]);
// Direct toggle of a To-Do item in the rollup dashboard
const handleToggleRollupTodo = async (docId: string, blockId: string) => {
triggerHaptic(15);
const targetDoc = docs.find((d) => d.id === docId);
if (!targetDoc) return;
const updatedBlocks = targetDoc.blocks.map((b) =>
b.id === blockId ? { ...b, checked: !b.checked } : b
);
const updatedDoc = { ...targetDoc, blocks: updatedBlocks };
// Update in local state
setDocs((prev) => prev.map((d) => (d.id === docId ? updatedDoc : d)));
if (selectedDoc?.id === docId) {
setSelectedDoc(updatedDoc);
}
// Also update tagDetails if currently viewing tag page
setTagDetails((prev) => {
if (!prev) return prev;
return {
...prev,
documents: prev.documents.map((dg) => {
if (dg.docId !== docId) return dg;
return {
...dg,
matchedBlocks: dg.matchedBlocks.map((mb) => ({
rootBlock:
mb.rootBlock.id === blockId
? { ...mb.rootBlock, checked: !mb.rootBlock.checked }
: mb.rootBlock,
children: mb.children.map((c) =>
c.id === blockId ? { ...c, checked: !c.checked } : c
),
})),
};
}),
};
});
// Persist immediately to backend & disk
await api.updateDocument(docId, {
blocks: updatedBlocks,
});
};
// Jump directly to document from rollup
const handleJumpToDoc = (docId: string) => {
const found = docs.find((d) => d.id === docId);
if (found) {
setSelectedDoc(found);
setZoomedBlockId(null);
setWorkspaceMode('editor');
}
};
// --- FOLDER TREE CALCULATION ---
const filteredDocs = useMemo(() => {
if (!searchQuery.trim()) return docs;
const q = searchQuery.toLowerCase();
return docs.filter(
(d) => d.title.toLowerCase().includes(q) || d.content.toLowerCase().includes(q)
);
}, [docs, searchQuery]);
const { rootTree, rootDocs } = useMemo(() => {
const folderMap = new Map<string, FolderTreeItem>();
allFolderPaths.forEach((path) => {
const parts = path.split('/');
const name = parts[parts.length - 1];
folderMap.set(path, {
path,
name,
depth: parts.length - 1,
subfolders: [],
docs: [],
});
});
filteredDocs.forEach((doc) => {
const folder = getDocFolder(doc);
if (folder && folderMap.has(folder)) {
folderMap.get(folder)!.docs.push(doc);
}
});
const rootFolders: FolderTreeItem[] = [];
allFolderPaths.forEach((path) => {
const item = folderMap.get(path)!;
const lastSlash = path.lastIndexOf('/');
if (lastSlash === -1) {
rootFolders.push(item);
} else {
const parentPath = path.substring(0, lastSlash);
const parent = folderMap.get(parentPath);
if (parent) {
parent.subfolders.push(item);
} else {
rootFolders.push(item);
}
}
});
const rootOnlyDocs = filteredDocs.filter((d) => !getDocFolder(d));
return { rootTree: rootFolders, rootDocs: rootOnlyDocs };
}, [allFolderPaths, filteredDocs]);
const pinnedDocs = useMemo(() => {
return docs.filter((d) => d.isPinned);
}, [docs]);
const renderDocItem = (doc: Document, depth: number) => {
const isSelected = selectedDoc?.id === doc.id && workspaceMode === 'editor';
const isDragging = draggingDocId === doc.id;
return (
<div
key={doc.id}
draggable
onDragStart={(e) => {
e.stopPropagation();
e.dataTransfer.setData('application/mindsift-doc-id', doc.id);
e.dataTransfer.setData('text/plain', doc.id);
setDraggingDocId(doc.id);
}}
onDragEnd={() => setDraggingDocId(null)}
onClick={() => {
setSelectedDoc(doc);
setZoomedBlockId(null);
setWorkspaceMode('editor');
}}
style={{ paddingLeft: `${depth * 14 + 16}px` }}
className={`group flex items-center justify-between py-1.5 pr-2 border-l-2 transition-all cursor-pointer text-xs ${
isDragging ? 'opacity-40 scale-[0.98]' : ''
} ${
isSelected
? 'bg-panel-elevated border-accent text-main font-medium'
: 'border-transparent text-muted hover:text-main hover:bg-canvas/50'
}`}
title={`${doc.title || 'Notiz'} (Ziehen zum Verschieben in Ordner)`}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-sm flex-shrink-0">{doc.icon || '📄'}</span>
<span className="truncate">{doc.title || 'Unbenannt'}</span>
{doc.isPinned && <Pin className="w-2.5 h-2.5 text-accent flex-shrink-0" />}
{doc.isOwner === false && (
<span
title={`Geteilt von ${doc.ownerName || 'anderem Nutzer'} (${doc.permission === 'read' ? 'Nur Lesen' : 'Schreibrecht'})`}
className="text-accent flex-shrink-0"
>
<Users className="w-3 h-3" />
</span>
)}
</div>
</div>
);
};
const renderFolder = (node: FolderTreeItem): React.ReactNode => {
const isExpanded = expandedFolders.has(node.path);
const isCreatingSubfolder = isCreatingFolder && newFolderParent === node.path;
const isDragOver = dragOverFolder === node.path;
const isDraggingThisFolder = draggingFolderPath === node.path;
const totalItems = node.docs.length + node.subfolders.length;
return (
<div
key={node.path}
className="select-none"
onDragOver={(e) => {
if (
draggingDocId ||
(draggingFolderPath &&
draggingFolderPath !== node.path &&
!node.path.startsWith(draggingFolderPath + '/'))
) {
e.preventDefault();
e.stopPropagation();
setDragOverFolder(node.path);
}
}}
onDragLeave={(e) => {
e.preventDefault();
if (dragOverFolder === node.path) {
setDragOverFolder(null);
}
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const droppedFolder =
e.dataTransfer.getData('application/mindsift-folder-path') || draggingFolderPath;
setDragOverFolder(null);
setDraggingDocId(null);
setDraggingFolderPath(null);
if (droppedFolder) {
handleMoveFolder(droppedFolder, node.path);
} else {
handleDropDocToFolder(node.path, e);
}
}}
>
<div
draggable
onDragStart={(e) => {
e.stopPropagation();
e.dataTransfer.setData('application/mindsift-folder-path', node.path);
e.dataTransfer.setData('text/plain', node.path);
setDraggingFolderPath(node.path);
}}
onDragEnd={() => setDraggingFolderPath(null)}
onClick={() => toggleFolder(node.path)}
style={{ paddingLeft: `${node.depth * 14 + 8}px` }}
className={`group flex items-center justify-between py-1.5 pr-2 cursor-pointer transition-colors text-xs ${
isDraggingThisFolder ? 'opacity-40 scale-[0.98]' : ''
} ${
isDragOver
? 'bg-accent/20 border-l-2 border-accent text-main font-medium'
: 'hover:bg-panel-elevated text-muted'
}`}
title={`${node.path} (Klick: Öffnen/Schließen · Ziehen: In anderen Ordner verschieben)`}
>
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-faint hover:text-muted">
{isExpanded ? (
<ChevronDown className="w-3 h-3 flex-shrink-0" />
) : (
<ChevronRight className="w-3 h-3 flex-shrink-0" />
)}
</span>
{isExpanded ? (
<FolderOpen className="w-3.5 h-3.5 text-accent flex-shrink-0" />
) : (
<Folder className="w-3.5 h-3.5 text-muted flex-shrink-0" />
)}
<span className="font-medium text-xs text-main truncate">{node.name}</span>
<span className="text-[10px] text-faint font-mono">({totalItems})</span>
</div>
<div className="opacity-0 group-hover:opacity-100 flex items-center gap-1 transition-opacity">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleCreateDoc(node.path);
}}
className="p-1 hover:bg-panel-elevated text-muted hover:text-main rounded-btn"
title="Neue Datei in diesem Ordner"
>
<FilePlus className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleStartCreateFolder(node.path);
}}
className="p-1 hover:bg-panel-elevated text-muted hover:text-main rounded-btn"
title="Neuer Unterordner"
>
<FolderPlus className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(node.path, node.name);
}}
className="p-1 hover:bg-red-500/10 text-muted hover:text-red-400 rounded-btn transition-colors"
title="Ordner löschen"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
{isCreatingSubfolder && (
<div
style={{ paddingLeft: `${(node.depth + 1) * 14 + 8}px` }}
className="py-1 pr-2 flex items-center gap-1 bg-canvas"
>
<Folder className="w-3 h-3 text-muted flex-shrink-0" />
<input
type="text"
autoFocus
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirmCreateFolder();
if (e.key === 'Escape') setIsCreatingFolder(false);
}}
placeholder="Unterordner…"
className="flex-1 h-6 px-1.5 input-field text-xs"
/>
<button
type="button"
onClick={handleConfirmCreateFolder}
className="p-1 text-accent hover:text-main rounded-btn"
title="Bestätigen"
>
<Check className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setIsCreatingFolder(false)}
className="p-1 text-muted hover:text-main rounded-btn"
title="Abbrechen"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
{isExpanded && (
<div>
{node.subfolders.map(renderFolder)}
{node.docs.map((doc) => renderDocItem(doc, node.depth + 1))}
{node.subfolders.length === 0 && node.docs.length === 0 && !isCreatingSubfolder && (
<div
style={{ paddingLeft: `${(node.depth + 1) * 14 + 14}px` }}
className="py-1 text-[11px] text-faint italic"
>
(leer)
</div>
)}
</div>
)}
</div>
);
};
const isEmpty = docs.length === 0 && allFolderPaths.length === 0;
return (
<div className="w-full space-y-2">
{/* Top Navigation Mode Switcher: Notizen & Outliner vs. Globales To-Do Dashboard */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setWorkspaceMode('editor')}
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
workspaceMode === 'editor'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Layers className="w-3.5 h-3.5 text-accent" />
<span>Notizen</span>
</button>
<button
type="button"
onClick={() => setWorkspaceMode('todos')}
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
workspaceMode === 'todos'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<CheckSquare className="w-3.5 h-3.5 text-accent" />
<span>Globale To-Dos</span>
<span className="badge-neutral font-mono text-[10px]">
{allWorkspaceTodos.filter((t) => !t.block.checked).length}
</span>
</button>
{selectedTag && (
<button
type="button"
onClick={() => setWorkspaceMode('tag')}
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
workspaceMode === 'tag'
? '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>#{selectedTag}</span>
{tagDetails && (
<span className="badge-neutral font-mono text-[10px]">
{tagDetails.totalMatches}
</span>
)}
</button>
)}
</div>
<div className="flex items-center gap-2 text-xs text-muted">
{workspaceMode === 'editor' && selectedDoc && (
<>
<span className="badge-accent">
<Check className="w-3 h-3" />
{saveState === 'saving' ? 'Speichert…' : 'Gespeichert'}
</span>
<button
type="button"
onClick={handleExportMarkdown}
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
title="Als Logseq/Markdown-Datei (.md) herunterladen"
>
<Download className="w-3.5 h-3.5 text-accent" />
<span>Export (.md)</span>
</button>
</>
)}
</div>
</div>
{/* ===================================================================== */}
{/* VIEW MODE 1: LOGSEQ OUTLINER & EXPLORER */}
{/* ===================================================================== */}
{workspaceMode === 'editor' && (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-2">
{/* Left Document & Folder Explorer Tree */}
<div className="lg:col-span-4 panel-surface flex flex-col self-start">
<div className="p-2 panel-header space-y-2">
<div className="relative">
<Search className="w-3.5 h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Dateien durchsuchen…"
className="w-full h-7 pl-7 pr-2 input-field text-xs"
/>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => handleCreateDoc(null)}
className="flex-1 btn-action"
title="Neue Datei erstellen"
>
<FilePlus className="w-3.5 h-3.5 text-muted" />
<span>Neue Datei</span>
</button>
<button
type="button"
onClick={() => handleStartCreateFolder(null)}
className="flex-1 btn-action"
title="Neuen Ordner erstellen"
>
<FolderPlus className="w-3.5 h-3.5 text-muted" />
<span>Neuer Ordner</span>
</button>
</div>
</div>
{isCreatingFolder && newFolderParent === null && (
<div className="p-2 panel-header bg-canvas flex items-center gap-1.5">
<Folder className="w-3.5 h-3.5 text-muted flex-shrink-0" />
<input
type="text"
autoFocus
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleConfirmCreateFolder();
if (e.key === 'Escape') setIsCreatingFolder(false);
}}
placeholder="Neuer Ordnername…"
className="flex-1 h-6 px-1.5 input-field text-xs"
/>
<button
type="button"
onClick={handleConfirmCreateFolder}
className="p-1 text-accent hover:text-main rounded-btn"
title="Bestätigen"
>
<Check className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setIsCreatingFolder(false)}
className="p-1 text-muted hover:text-main rounded-btn"
title="Abbrechen"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
{isEmpty ? (
<div className="p-6 my-3 mx-2 border border-dashed border-border bg-canvas/50 text-center rounded-card">
<Folder className="w-7 h-7 text-faint mx-auto mb-2" />
<h5 className="text-xs font-semibold text-muted mb-1">Keine Dateien vorhanden</h5>
<p className="text-[11px] text-faint mb-3 max-w-xs mx-auto leading-relaxed">
Erstelle eine neue Datei oder lege Ordner an, um deine Dokumente übersichtlich zu strukturieren.
</p>
<div className="flex items-center justify-center gap-2">
<button
type="button"
onClick={() => handleCreateDoc(null)}
className="btn-action"
>
<FilePlus className="w-3.5 h-3.5 text-muted" />
<span>Neue Datei</span>
</button>
<button
type="button"
onClick={() => handleStartCreateFolder(null)}
className="btn-action"
>
<FolderPlus className="w-3.5 h-3.5 text-muted" />
<span>Neuer Ordner</span>
</button>
</div>
</div>
) : (
<div className="py-1 max-h-[calc(100vh-240px)] overflow-y-auto">
{/* Drag target for Root when dragging a note or a folder */}
{(draggingDocId || draggingFolderPath) && (
<div
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverFolder('__root__');
}}
onDragLeave={(e) => {
e.preventDefault();
if (dragOverFolder === '__root__') setDragOverFolder(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const droppedFolder =
e.dataTransfer.getData('application/mindsift-folder-path') ||
draggingFolderPath;
setDragOverFolder(null);
setDraggingDocId(null);
setDraggingFolderPath(null);
if (droppedFolder) {
handleMoveFolder(droppedFolder, '');
} else {
handleDropDocToFolder('', e);
}
}}
className={`mx-2 my-1.5 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'
}`}
>
{draggingFolderPath
? '📁 Ordner in Hauptverzeichnis verschieben'
: '📁 In Hauptverzeichnis verschieben (ohne Ordner)'}
</div>
)}
{/* Angepinnte Notizen (Favorites) */}
{pinnedDocs.length > 0 && !searchQuery && (
<div className="mb-2 pb-1.5 border-b border-border">
<div className="px-2.5 py-1 text-[10px] font-bold text-accent uppercase tracking-wider flex items-center gap-1.5">
<Pin className="w-3 h-3 text-accent" />
<span>Angepinnte Notizen ({pinnedDocs.length})</span>
</div>
<div className="space-y-0.5 px-1">
{pinnedDocs.map((doc) => {
const isSelected = selectedDoc?.id === doc.id && workspaceMode === 'editor';
return (
<div
key={`pinned-${doc.id}`}
draggable
onDragStart={(e) => {
e.stopPropagation();
e.dataTransfer.setData('application/mindsift-doc-id', doc.id);
e.dataTransfer.setData('text/plain', doc.id);
setDraggingDocId(doc.id);
}}
onDragEnd={() => setDraggingDocId(null)}
onClick={() => {
setSelectedDoc(doc);
setZoomedBlockId(null);
setWorkspaceMode('editor');
}}
className={`group flex items-center justify-between px-2 py-1.5 rounded-btn transition-colors cursor-pointer text-xs ${
isSelected
? 'bg-panel-elevated border-l-2 border-accent text-main font-medium'
: 'text-muted hover:text-main hover:bg-canvas/50'
}`}
title="Angepinnte Notiz öffnen"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<span className="text-sm flex-shrink-0">{doc.icon || '📄'}</span>
<span className="truncate">{doc.title || 'Unbenannt'}</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
persistDocument({ ...doc, isPinned: false }, true);
}}
className="opacity-0 group-hover:opacity-100 p-0.5 text-muted hover:text-accent rounded transition-opacity"
title="Von Angepinnt entfernen"
>
<Pin className="w-3 h-3 text-accent" />
</button>
</div>
);
})}
</div>
</div>
)}
{rootTree.map(renderFolder)}
{rootDocs.map((doc) => renderDocItem(doc, 0))}
{/* Workspace Hashtags Section */}
{workspaceTags.length > 0 && !searchQuery && (
<div className="mt-3 pt-2.5 border-t border-border">
<div className="px-2.5 py-1 text-[10px] font-bold text-muted uppercase tracking-wider flex items-center justify-between">
<div className="flex items-center gap-1.5 text-accent">
<Hash className="w-3 h-3" />
<span>Themen & Hashtags ({workspaceTags.length})</span>
</div>
</div>
<div className="flex flex-wrap gap-1 px-1.5 py-1">
{workspaceTags.map((wt) => (
<button
key={wt.tag}
type="button"
onClick={() => openTagPage(wt.tag)}
className={`px-2 py-0.5 rounded-pill text-[11px] font-medium border flex items-center gap-1 transition-colors ${
selectedTag === wt.tag
? 'bg-accent/20 border-accent text-accent font-semibold'
: 'bg-canvas border-border text-muted hover:border-accent/40 hover:text-main'
}`}
title={`#${wt.tag} (${wt.count} Abschnitte)`}
>
<span>#{wt.tag}</span>
<span className="text-[9px] opacity-60">({wt.count})</span>
</button>
))}
</div>
</div>
)}
{filteredDocs.length === 0 && searchQuery && (
<div className="p-4 text-center text-xs text-faint">
Keine Dateien gefunden für &quot;{searchQuery}&quot;
</div>
)}
</div>
)}
</div>
{/* Right Logseq Outliner Editor */}
<div className="lg:col-span-8">
{selectedDoc ? (
<div
className="p-4 panel-surface space-y-3 min-h-[500px]"
onClick={(e) => {
if (e.target === e.currentTarget) {
handleCanvasBottomClick();
}
}}
>
{/* Document Header & Metadata */}
<div className="flex flex-wrap items-center justify-between gap-3 pb-3 panel-header">
<div className="flex flex-wrap items-center gap-2 text-xs text-muted">
<div className="flex items-center gap-1.5">
<Folder className="w-3.5 h-3.5 text-faint" />
<span>{selectedDoc.folderPath || 'Hauptverzeichnis'}</span>
</div>
<span>• {selectedDoc.blocks.length} Bullets</span>
{selectedDoc.isOwner === false && (
<button
type="button"
onClick={openShareModal}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {selectedDoc.ownerName || 'Eigentümer'}</span>
</button>
)}
{selectedDoc.permission === 'read' && (
<button
type="button"
onClick={openShareModal}
className="px-2 py-0.5 rounded text-[10px] font-semibold border border-amber-500/30 bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center gap-1 hover:bg-amber-500/25 transition-colors cursor-pointer"
title="Berechtigungsdetails öffnen"
>
<Lock className="w-2.5 h-2.5" />
<span>Nur Lesezugriff</span>
</button>
)}
{holdsLock && (
<span className="px-2 py-0.5 rounded text-[10px] font-semibold border border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
<span>Schreibzugriff aktiv</span>
</span>
)}
{isLockedByOther && (
<span className="px-2 py-0.5 rounded text-[10px] font-semibold border border-amber-500/30 bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center gap-1 animate-pulse">
<Lock className="w-2.5 h-2.5" />
<span>Wird bearbeitet ({activeLock?.userName || 'anderer Nutzer'})</span>
</span>
)}
{selectedDoc.sharedWith && selectedDoc.sharedWith.length > 0 && selectedDoc.isOwner !== false && (
<button
type="button"
onClick={openShareModal}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt ({selectedDoc.sharedWith.length})</span>
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={openShareModal}
className="btn-icon"
title="Notiz teilen und Berechtigungen"
>
<Share className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() =>
persistDocument({ ...selectedDoc, isPinned: !selectedDoc.isPinned }, true)
}
className={
selectedDoc.isPinned
? 'btn-icon text-accent border-accent/40 bg-accent/10'
: 'btn-icon'
}
title={selectedDoc.isPinned ? 'Von Favoriten / Angepinnt entfernen' : 'Oben in Favoriten anpinnen'}
>
<Pin className="w-3.5 h-3.5" />
</button>
{selectedDoc.isOwner !== false ? (
<button
type="button"
onClick={() => handleDeleteDoc(selectedDoc.id)}
className="btn-icon btn-icon-danger"
title="Dokument löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
) : (
<button
type="button"
onClick={handleLeaveShare}
className="btn-icon text-muted hover:text-status-danger"
title="Geteilte Notiz verlassen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* LOCK WARNING BANNER */}
{isLockedByOther && (
<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300 rounded-card flex items-center gap-2.5 text-xs font-medium animate-pulse">
<Lock className="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0" />
<div>
<div className="font-semibold">Schreibgeschützt</div>
<div>
Wird aktuell bearbeitet von <strong>{activeLock?.userName || 'einem anderen Benutzer'}</strong>. Eingaben sind vorübergehend gesperrt.
</div>
</div>
</div>
)}
{!isLockedByOther && selectedDoc.permission === 'read' && (
<div className="p-2.5 bg-blue-500/10 border border-blue-500/30 text-blue-700 dark:text-blue-300 rounded-card flex items-center gap-2 text-xs">
<Eye className="w-4 h-4 text-blue-500 flex-shrink-0" />
<span>Du hast <strong>Nur-Lesezugriff</strong> auf diese Notiz. Änderungen können nicht vorgenommen werden.</span>
</div>
)}
{/* ZOOM BREADCRUMB BAR (Hoisting Focus Mode) */}
{zoomedBlock && (
<div className="p-2.5 bg-canvas border border-border rounded-card flex items-center gap-2 text-xs">
<div className="flex flex-wrap items-center gap-1.5 min-w-0">
<button
type="button"
onClick={() => setZoomedBlockId(null)}
className="text-muted hover:text-main flex items-center gap-1 font-medium transition-colors"
>
<span>{selectedDoc.icon || '📄'}</span>
<span className="truncate">{selectedDoc.title}</span>
</button>
{breadcrumbBlocks.map((b) => (
<React.Fragment key={b.id}>
<ChevronRight className="w-3 h-3 text-faint flex-shrink-0" />
<button
type="button"
onClick={() => setZoomedBlockId(b.id)}
className="text-muted hover:text-main truncate max-w-[140px] font-medium transition-colors"
>
{b.content || 'Bullet'}
</button>
</React.Fragment>
))}
<ChevronRight className="w-3 h-3 text-faint flex-shrink-0" />
<span className="text-muted font-medium truncate max-w-[180px]">
{zoomedBlock.content || 'Aktiver Ast'}
</span>
</div>
</div>
)}
{/* Document Icon & Title (when not zoomed) */}
{!zoomedBlock && (
<div className="flex items-center gap-3 relative pb-2 border-b border-border">
<button
type="button"
disabled={isReadOnly}
onClick={() => !isReadOnly && setShowIconPicker((prev) => !prev)}
className={`text-2xl p-0.5 flex items-center justify-center flex-shrink-0 transition-transform ${
isReadOnly ? 'opacity-60 cursor-not-allowed' : 'hover:scale-110 cursor-pointer'
}`}
title={isReadOnly ? 'Schreibgeschützt' : 'Icon ändern'}
>
{selectedDoc.icon || '📄'}
</button>
{showIconPicker && !isReadOnly && (
<div className="absolute top-10 left-0 z-20 p-2.5 panel-surface flex flex-wrap gap-1 w-64 shadow-panel border border-border rounded-card">
{ICONS.map((ic) => (
<button
key={ic}
type="button"
onClick={() => {
persistDocument({ ...selectedDoc, icon: ic }, true);
setShowIconPicker(false);
}}
className="w-8 h-8 hover:bg-panel-elevated text-base flex items-center justify-center rounded-btn transition-colors"
>
{ic}
</button>
))}
</div>
)}
<input
type="text"
readOnly={isReadOnly}
value={selectedDoc.title}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => {
if (!isReadOnly) {
ensureLock();
persistDocument({ ...selectedDoc, title: e.target.value });
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (selectedDoc.blocks.length > 0) {
const firstId = selectedDoc.blocks[0].id;
blockInputRefs.current[firstId]?.focus();
} else if (!isReadOnly) {
insertNewBulletAfter(-1, 0);
}
}
}}
placeholder="Dokument-Titel…"
className={`w-full bg-transparent text-xl sm:text-2xl font-bold text-main focus:outline-none placeholder:text-faint ${
isReadOnly ? 'cursor-default' : ''
}`}
/>
</div>
)}
{/* If zoomed, show zoomed block as editable prominent header */}
{zoomedBlock && (
<div className="pb-2 border-b border-border flex items-center gap-2.5">
<span className="w-2.5 h-2.5 rounded-pill bg-accent flex-shrink-0" />
<input
type="text"
value={zoomedBlock.content}
onChange={(e) => updateBlock(zoomedBlock.id, { content: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const zoomIdx = selectedDoc.blocks.findIndex((b) => b.id === zoomedBlock.id);
if (zoomIdx !== -1) {
insertNewBulletAfter(zoomIdx, (zoomedBlock.level || 0) + 1);
}
}
}}
className="w-full bg-transparent text-xl font-bold text-main focus:outline-none placeholder:text-faint"
/>
</div>
)}
{/* =========================================================== */}
{/* LOGSEQ HIERARCHICAL BULLET LIST CANVAS */}
{/* =========================================================== */}
<div
className="space-y-1 pt-2 min-h-[400px] pb-20 cursor-text"
onClick={(e) => {
if (e.target === e.currentTarget) {
handleCanvasBottomClick();
}
}}
>
{visibleBlocks.map(({ block, originalIndex }) => {
const blockLevel = Math.max(0, (block.level || 0) - (zoomedBlock ? (zoomedBlock.level || 0) + 1 : 0));
const isChildrenPresent = hasChildren(selectedDoc.blocks, originalIndex);
return (
<div
key={block.id}
style={{ paddingLeft: `${blockLevel * 22}px` }}
className="group flex items-start gap-1 py-0.5 relative transition-all"
>
{/* Drag Handle on hover */}
<div
className="opacity-0 group-hover:opacity-100 flex items-center pt-1 text-faint hover:text-muted cursor-grab flex-shrink-0"
title="Ziehen zum Verschieben"
>
<GripVertical className="w-3 h-3" />
</div>
{/* Collapse Chevron if block has children */}
{isChildrenPresent ? (
<button
type="button"
onClick={() => toggleCollapse(block.id)}
className="w-4 h-4 mt-0.5 flex items-center justify-center text-muted hover:text-main flex-shrink-0"
title={block.collapsed ? 'Unterpunkte aufklappen' : 'Unterpunkte einklappen'}
>
{block.collapsed ? (
<ChevronRight className="w-3 h-3 text-accent" />
) : (
<ChevronDown className="w-3 h-3" />
)}
</button>
) : (
<div className="w-4 flex-shrink-0" />
)}
{/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder!) */}
<div
draggable={!isReadOnly}
onDragStart={(e) => !isReadOnly && handleDragStart(e, block.id)}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => !isReadOnly && handleDropOnBlock(e, block.id)}
onClick={() => setZoomedBlockId(block.id)}
className={`w-4 h-4 mt-1 rounded-pill flex items-center justify-center transition-colors flex-shrink-0 ${
isReadOnly
? 'cursor-pointer text-muted'
: 'hover:bg-accent/20 cursor-pointer text-muted hover:text-accent'
}`}
title="Klick: In diesen Ast zoomen · Ziehen: Verschieben"
>
<span
className={`w-1.5 h-1.5 rounded-pill transition-colors ${
isChildrenPresent
? 'bg-accent ring-2 ring-accent/30'
: 'bg-muted group-hover:bg-accent'
}`}
/>
</div>
{/* Block Content Container */}
<div className="flex-1 min-w-0 pt-0.5 relative">
{/* TYPE: TODO */}
{block.type === 'todo' && (
<div className="space-y-1">
<div className="flex items-center gap-2 group/todo">
<input
type="checkbox"
disabled={isReadOnly}
checked={!!block.checked}
onChange={(e) => {
if (!isReadOnly && selectedDoc) {
const nextBlocks = selectedDoc.blocks.map((b) =>
b.id === block.id ? { ...b, checked: e.target.checked } : b
);
persistDocument({ ...selectedDoc, blocks: nextBlocks }, true);
onStatsChange();
}
}}
className={`w-4 h-4 checkbox-square flex-shrink-0 ${
isReadOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'
}`}
/>
<input
ref={(el) => (blockInputRefs.current[block.id] = el)}
type="text"
readOnly={isReadOnly}
value={block.content}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
className={`w-full bg-transparent text-sm focus:outline-none ${
block.checked ? 'line-through text-faint' : 'text-main'
} ${isReadOnly ? 'cursor-default' : ''}`}
/>
{!isReadOnly && (
<button
type="button"
onClick={() => insertNewBulletAfter(originalIndex, (block.level || 0) + 1, 'todo')}
className="opacity-0 group-hover/todo:opacity-100 p-0.5 text-muted hover:text-accent rounded transition-opacity cursor-pointer flex-shrink-0"
title="Unteraufgabe erstellen"
>
<Plus className="w-3.5 h-3.5" />
</button>
)}
</div>
{(block.content.includes('[[') || block.content.includes('#') || (block.content.includes('[') && block.content.includes(']('))) && (
<div className="text-xs text-muted flex flex-wrap items-center gap-1 pt-0.5 pl-6">
{renderFormattedText(block.content)}
</div>
)}
{/* Progress bar for child todos if any exist */}
{(() => {
const childTodos = getDirectChildTodos(selectedDoc?.blocks || [], originalIndex);
if (childTodos.length === 0) return null;
const doneChild = childTodos.filter((c) => c.checked).length;
const totalChild = childTodos.length;
const pct = Math.round((doneChild / totalChild) * 100);
return (
<div
className="space-y-0.5 ml-6 pt-0.5 select-none"
title={`${doneChild} von ${totalChild} Unteraufgaben erledigt (${pct}%)`}
>
<div className="flex items-center justify-between text-[10px] text-muted">
<span>
{doneChild}/{totalChild} erledigt
</span>
<span className={pct === 100 ? 'text-status-ok font-bold' : ''}>
{pct}%
</span>
</div>
<div className="w-full h-1 bg-border rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
pct === 100 ? 'bg-status-ok' : 'bg-accent'
}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})()}
</div>
)}
{/* TYPE: H1 */}
{block.type === 'h1' && (
<input
ref={(el) => (blockInputRefs.current[block.id] = el)}
type="text"
readOnly={isReadOnly}
value={block.content}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
className={`w-full bg-transparent text-lg font-bold text-main focus:outline-none ${
isReadOnly ? 'cursor-default' : ''
}`}
/>
)}
{/* TYPE: H2 */}
{block.type === 'h2' && (
<input
ref={(el) => (blockInputRefs.current[block.id] = el)}
type="text"
readOnly={isReadOnly}
value={block.content}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
className={`w-full bg-transparent text-base font-semibold text-accent focus:outline-none ${
isReadOnly ? 'cursor-default' : ''
}`}
/>
)}
{/* TYPE: H3 */}
{block.type === 'h3' && (
<input
ref={(el) => (blockInputRefs.current[block.id] = el)}
type="text"
readOnly={isReadOnly}
value={block.content}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
className={`w-full bg-transparent text-sm font-semibold text-main focus:outline-none ${
isReadOnly ? 'cursor-default' : ''
}`}
/>
)}
{/* TYPE: CALLOUT */}
{block.type === 'callout' && (
<div className="p-2 bg-canvas border border-accent/40 rounded-card flex items-start gap-2">
<Sparkles className="w-3.5 h-3.5 text-accent flex-shrink-0 mt-0.5" />
<textarea
ref={(el) => (blockInputRefs.current[block.id] = el)}
readOnly={isReadOnly}
rows={Math.max(1, Math.ceil((block.content || '').length / 70))}
value={block.content}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
className={`w-full bg-transparent text-xs text-main focus:outline-none resize-none ${
isReadOnly ? 'cursor-default' : ''
}`}
/>
</div>
)}
{/* TYPE: CODE */}
{block.type === 'code' && (
<div className="p-2 code-block">
<textarea
ref={(el) => (blockInputRefs.current[block.id] = el)}
readOnly={isReadOnly}
rows={Math.max(2, (block.content || '').split('\n').length)}
value={block.content}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
className={`w-full bg-transparent text-xs font-mono text-main focus:outline-none resize-none ${
isReadOnly ? 'cursor-default' : ''
}`}
/>
</div>
)}
{/* TYPE: TABLE */}
{block.type === 'table' && (
<div className="p-2 bg-canvas border border-border rounded-card space-y-1">
<div className="flex items-center gap-1 text-[11px] text-muted font-semibold">
<TableIcon className="w-3 h-3 text-accent" /> Markdown Tabelle
</div>
<textarea
ref={(el) => (blockInputRefs.current[block.id] = el)}
readOnly={isReadOnly}
rows={Math.max(3, (block.content || '').split('\n').length)}
value={block.content}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
className={`w-full bg-transparent font-mono text-xs text-main focus:outline-none resize-none ${
isReadOnly ? 'cursor-default' : ''
}`}
/>
</div>
)}
{/* TYPE: PROGRESS */}
{block.type === 'progress' && (
<div className="p-2 bg-canvas border border-border rounded-card space-y-1.5">
<div className="flex items-center justify-between text-xs">
<span className="text-main font-medium flex items-center gap-1">
<Percent className="w-3 h-3 text-accent" /> Fortschritt
</span>
<span className="font-mono text-accent font-semibold">
{parseInt(block.content) || 0}%
</span>
</div>
<div className="w-full bg-panel h-2 overflow-hidden border border-border rounded-pill">
<div
className="bg-accent h-full transition-all rounded-pill"
style={{
width: `${Math.min(100, Math.max(0, parseInt(block.content) || 0))}%`,
}}
/>
</div>
<input
type="range"
min="0"
max="100"
disabled={isReadOnly}
value={parseInt(block.content) || 0}
onChange={(e) => !isReadOnly && updateBlock(block.id, { content: e.target.value })}
className={`w-full accent-accent ${isReadOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer'}`}
/>
</div>
)}
{/* TYPE: DIVIDER */}
{block.type === 'divider' && (
<div
tabIndex={isReadOnly ? undefined : 0}
onKeyDown={(e) => {
if (!isReadOnly && e.key === 'Enter') {
e.preventDefault();
insertNewBulletAfter(originalIndex, block.level || 0);
} else if (!isReadOnly && (e.key === 'Backspace' || e.key === 'Delete')) {
e.preventDefault();
deleteBlockAndFocusPrev(originalIndex);
}
}}
onClick={() => {
if (!isReadOnly) {
insertNewBulletAfter(originalIndex, block.level || 0);
}
}}
className="py-2.5 my-1 group/divider relative cursor-pointer focus:outline-none"
title={isReadOnly ? undefined : "Klicken oder Enter drücken, um darunter einen neuen Aufzählungspunkt einzufügen"}
>
<div className="h-0.5 w-full bg-border group-hover/divider:bg-accent/70 group-focus/divider:bg-accent transition-colors rounded-full" />
{!isReadOnly && (
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover/divider:opacity-100 transition-opacity pointer-events-none">
<span className="bg-panel border border-border text-[10px] text-muted px-2 py-0.5 rounded-full shadow-xs flex items-center gap-1 font-medium">
<Plus className="w-3 h-3 text-accent" />
<span>Punkt einfügen</span>
</span>
</div>
)}
</div>
)}
{/* TYPE: DEFAULT BULLET / PARAGRAPH */}
{(block.type === 'bullet' || block.type === 'paragraph') && (
<div className="space-y-0.5">
<textarea
ref={(el) => (blockInputRefs.current[block.id] = el)}
readOnly={isReadOnly}
rows={Math.max(1, Math.ceil((block.content || '').length / 80))}
value={block.content}
onFocus={handleBlockFocus}
onBlur={handleBlockBlur}
onChange={(e) => handleContentChange(block, e.target.value)}
onKeyDown={(e) => handleKeyDown(e, block, originalIndex)}
className={`w-full bg-transparent text-sm text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint ${
isReadOnly ? 'cursor-default' : ''
}`}
/>
{(block.content.includes('[[') || block.content.includes('#') || (block.content.includes('[') && block.content.includes(']('))) && (
<div className="text-xs text-muted flex flex-wrap items-center gap-1 pt-0.5">
{renderFormattedText(block.content)}
</div>
)}
</div>
)}
{/* FLOATING SLASH COMMAND MENU */}
{!isReadOnly && slashBlockId === block.id && (
<div className="absolute top-7 left-0 z-40 w-64 bg-panel border border-border shadow-panel rounded-card p-1 space-y-0.5">
<div className="px-2 py-1 text-[10px] text-faint font-semibold uppercase tracking-wider border-b border-border">
Block-Typ wählen:
</div>
<div ref={slashMenuRef} className="relative max-h-48 overflow-y-auto space-y-0.5">
{filteredSlashCommands.map((cmd, idx) => (
<button
key={cmd.label || idx}
type="button"
data-slash-index={idx}
onMouseDown={(e) => e.preventDefault()}
onClick={() => applySlashCommand(block.id, cmd)}
className={`w-full text-left px-2 py-1.5 rounded-btn text-xs flex items-center justify-between transition-colors ${
slashSelectedIndex === idx
? 'bg-accent text-on-accent'
: 'text-main hover:bg-panel-elevated'
}`}
>
<span className="font-semibold">{cmd.label}</span>
<span className="text-[10px] opacity-80">{cmd.desc}</span>
</button>
))}
{filteredSlashCommands.length === 0 && (
<div className="p-2 text-center text-xs text-faint">
Kein Befehl gefunden
</div>
)}
</div>
</div>
)}
</div>
{/* Quick Delete Block on Hover */}
{!isReadOnly && (
<button
type="button"
onClick={() => removeBlock(block.id)}
className="opacity-0 group-hover:opacity-100 p-0.5 text-faint hover:text-status-err rounded transition-opacity"
title="Bullet löschen"
>
<X className="w-3 h-3" />
</button>
)}
</div>
);
})}
{/* Explicit bottom clickable area */}
{!isReadOnly && (
<div
className="pt-3 pb-8 px-2 text-xs text-faint hover:text-muted cursor-pointer transition-colors flex items-center gap-1.5 opacity-60 hover:opacity-100 select-none"
onClick={handleCanvasBottomClick}
>
<Plus className="w-3.5 h-3.5 text-accent" />
<span>Neuen Punkt hinzufügen...</span>
</div>
)}
</div>
</div>
) : (
<div className="p-8 panel-surface text-center text-muted text-xs">
Wähle links eine Datei im Datei-Explorer aus oder erstelle eine neue Datei.
</div>
)}
</div>
</div>
)}
{/* ===================================================================== */}
{/* VIEW MODE 2: GLOBAL WORKSPACE TO-DO ROLLUP DASHBOARD */}
{/* ===================================================================== */}
{workspaceMode === 'todos' && (
<div className="p-4 panel-surface space-y-4">
{/* Header & Filter Controls */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-3">
<div>
<h2 className="text-sm font-bold text-main flex items-center gap-2">
<CheckSquare className="w-4 h-4 text-accent" />
<span>Globale To-Dos</span>
</h2>
</div>
<div className="flex items-center gap-2">
<div className="flex border border-border rounded-btn bg-canvas overflow-hidden text-xs">
<button
type="button"
onClick={() => setTodoFilter('open')}
className={`px-3 py-1.5 transition-colors border-r border-border ${
todoFilter === 'open' ? 'bg-panel text-main font-medium' : 'text-muted hover:text-main'
}`}
>
Nur Offene ({allWorkspaceTodos.filter((t) => !t.block.checked).length})
</button>
<button
type="button"
onClick={() => setTodoFilter('all')}
className={`px-3 py-1.5 transition-colors border-r border-border ${
todoFilter === 'all' ? 'bg-panel text-main font-medium' : 'text-muted hover:text-main'
}`}
>
Alle ({allWorkspaceTodos.length})
</button>
<button
type="button"
onClick={() => setTodoFilter('done')}
className={`px-3 py-1.5 transition-colors ${
todoFilter === 'done' ? 'bg-panel text-main font-medium' : 'text-muted hover:text-main'
}`}
>
Erledigt ({allWorkspaceTodos.filter((t) => t.block.checked).length})
</button>
</div>
{todoFolderDrilldown && (
<button
type="button"
onClick={() => setTodoFolderDrilldown(null)}
className="btn-action text-xs"
>
← Alle Ordner
</button>
)}
</div>
</div>
{/* Drilldown Active Notification Banner */}
{todoFolderDrilldown && (
<div className="p-2.5 bg-canvas border border-accent/40 rounded-card flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5 text-main">
<Folder className="w-3.5 h-3.5 text-accent" />
<span>Gefiltert auf Ordner: <strong>{todoFolderDrilldown}</strong></span>
</span>
<button
type="button"
onClick={() => setTodoFolderDrilldown(null)}
className="text-accent hover:underline"
>
Filter entfernen
</button>
</div>
)}
{/* Folders & Files Hierarchy Accordion */}
{todosByFolder.size === 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, um Aufgaben anzulegen.
</p>
</div>
) : (
<div className="space-y-3">
{Array.from(todosByFolder.entries()).map(([folderName, items]) => {
const isCollapsed = collapsedTodoFolders.has(folderName);
const openCount = items.filter((i) => !i.block.checked).length;
// Group items within this folder by document
const docsInFolder = new Map<string, typeof items>();
items.forEach((item) => {
const arr = docsInFolder.get(item.docId) || [];
arr.push(item);
docsInFolder.set(item.docId, arr);
});
return (
<div key={folderName} className="border border-border rounded-card bg-canvas overflow-hidden">
{/* Folder Header Accordion */}
<div className="p-2.5 bg-panel-elevated 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-4 h-4 text-muted" />
) : (
<ChevronDown className="w-4 h-4 text-accent" />
)}
<Folder className="w-4 h-4 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-[11px] text-muted hover:text-main px-2 py-0.5 rounded hover:bg-canvas/50"
title="Diesen Ordner isolieren"
>
Nur diesen Ordner
</button>
</div>
{/* Folder Documents & Tasks */}
{!isCollapsed && (
<div className="p-3 space-y-3">
{Array.from(docsInFolder.entries()).map(([docId, docItems]) => {
const docInfo = docItems[0];
return (
<div
key={docId}
className="p-2.5 bg-panel border border-border rounded-card space-y-2"
>
{/* Document header in rollup */}
<div className="flex items-center justify-between border-b border-border pb-1.5">
<button
type="button"
onClick={() => handleJumpToDoc(docId)}
className="text-xs font-semibold text-accent hover:underline flex items-center gap-1.5"
title="Zur Notiz im Editor springen"
>
<span>{docInfo.docIcon}</span>
<span>{docInfo.docTitle}</span>
<ExternalLink className="w-3 h-3 opacity-60" />
</button>
<span className="text-[10px] text-faint font-mono">
{docItems.filter((i) => !i.block.checked).length} / {docItems.length}
</span>
</div>
{/* To-Do Items inside Document */}
<div className="space-y-1.5 pl-1">
{docItems.map(({ block }) => (
<div
key={block.id}
onClick={() => handleToggleRollupTodo(docId, block.id)}
className="flex items-center gap-2 cursor-pointer group py-0.5 select-none"
>
<input
type="checkbox"
checked={!!block.checked}
onChange={() => handleToggleRollupTodo(docId, block.id)}
className="w-4 h-4 checkbox-square cursor-pointer flex-shrink-0"
/>
<span
className={`text-xs ${
block.checked
? 'line-through text-faint'
: 'text-main group-hover:text-accent'
} transition-colors`}
>
{block.content || '(Leere Aufgabe)'}
</span>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
{/* ===================================================================== */}
{/* VIEW MODE 3: HASHTAG / THEMEN-SEITE (LOGSEQ STYLE) */}
{/* ===================================================================== */}
{workspaceMode === 'tag' && (
<div className="space-y-4">
{/* Tag Page Header */}
<div className="p-4 panel-surface space-y-2 border border-border rounded-card">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setWorkspaceMode('editor')}
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
title="Zurück zum Editor"
>
<ArrowLeft className="w-3.5 h-3.5 text-accent" />
<span>Notizen</span>
</button>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-500 dark:text-blue-400 font-bold">
<Hash className="w-4 h-4" />
</div>
<div>
<h2 className="text-base font-bold text-main leading-tight flex items-center gap-1.5">
<span>#{selectedTag}</span>
</h2>
<p className="text-[11px] text-muted">
{isLoadingTag
? 'Wird geladen…'
: `${tagDetails?.totalMatches || 0} ${
tagDetails?.totalMatches === 1 ? 'Abschnitt' : 'Abschnitte'
} in ${tagDetails?.documents.length || 0} ${
tagDetails?.documents.length === 1 ? 'Notiz' : 'Notizen'
}`}
</p>
</div>
</div>
</div>
{/* Other tags pills for fast hopping */}
<div className="flex items-center gap-1 flex-wrap">
{workspaceTags
.filter((t) => t.tag !== selectedTag)
.slice(0, 6)
.map((t) => (
<button
key={t.tag}
type="button"
onClick={() => openTagPage(t.tag)}
className="px-2 py-0.5 rounded-pill text-[11px] badge-neutral hover:border-accent/40 hover:text-main transition-colors"
>
#{t.tag} ({t.count})
</button>
))}
</div>
</div>
</div>
{/* Matched Documents List */}
{isLoadingTag ? (
<div className="p-12 text-center text-muted text-xs panel-surface">
<Sparkles className="w-6 h-6 animate-spin mx-auto mb-2 text-accent" />
Hashtag-Verknüpfungen werden geladen…
</div>
) : !tagDetails || tagDetails.documents.length === 0 ? (
<div className="p-12 text-center text-muted text-xs panel-surface space-y-2">
<Hash className="w-8 h-8 mx-auto text-faint" />
<p className="font-medium text-main">Keine Bullet Points mit #{selectedTag} gefunden</p>
<p className="text-xs text-faint">
Füge einfach in irgendeiner Notiz <code className="text-accent">#{selectedTag}</code> in einen Bullet Point ein.
</p>
</div>
) : (
<div className="space-y-4">
{tagDetails.documents.map((docGroup) => (
<div
key={docGroup.docId}
className="panel-surface p-4 space-y-3 border border-border rounded-card hover:border-border/80 transition-colors"
>
{/* Document Header */}
<div className="flex flex-wrap items-center justify-between gap-2 pb-2 border-b border-border">
<div className="flex items-center gap-2 min-w-0">
<span className="text-base flex-shrink-0">{docGroup.icon || '📄'}</span>
<span className="font-semibold text-xs text-main truncate">{docGroup.title || 'Unbenannt'}</span>
{docGroup.folderPath && (
<span className="badge-neutral text-[10px] flex items-center gap-1 font-mono">
<Folder className="w-2.5 h-2.5" />
<span>{docGroup.folderPath}</span>
</span>
)}
<span className="text-[10px] text-faint">
• {docGroup.matchedBlocks.length}{' '}
{docGroup.matchedBlocks.length === 1 ? 'Treffer' : 'Treffer'}
</span>
</div>
<button
type="button"
onClick={() => {
const found = docs.find((d) => d.id === docGroup.docId);
if (found) {
setSelectedDoc(found);
setZoomedBlockId(null);
setWorkspaceMode('editor');
}
}}
className="px-2.5 py-1 text-xs font-medium text-accent hover:text-main bg-accent/10 hover:bg-accent/20 rounded-btn flex items-center gap-1 transition-colors"
>
<span>Notiz öffnen</span>
<ExternalLink className="w-3 h-3" />
</button>
</div>
{/* Matched Bullet Points and their children */}
<div className="space-y-3 pl-1">
{docGroup.matchedBlocks.map((mb, idx) => (
<div
key={mb.rootBlock.id || idx}
className="space-y-1.5 bg-canvas/40 p-3 rounded-card border border-border/50"
>
{/* Root Bullet Point */}
<div className="flex items-start gap-2 group">
{mb.rootBlock.type === 'todo' ? (
<input
type="checkbox"
checked={!!mb.rootBlock.checked}
onChange={() => handleToggleRollupTodo(docGroup.docId, mb.rootBlock.id)}
className="w-4 h-4 mt-0.5 checkbox-square cursor-pointer flex-shrink-0"
/>
) : (
<div className="w-3.5 h-3.5 mt-1 rounded-full bg-accent/20 border border-accent flex items-center justify-center flex-shrink-0">
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
</div>
)}
<div
onClick={() => {
const found = docs.find((d) => d.id === docGroup.docId);
if (found) {
setSelectedDoc(found);
setZoomedBlockId(mb.rootBlock.id);
setWorkspaceMode('editor');
}
}}
className={`flex-1 text-xs leading-relaxed cursor-pointer hover:text-accent transition-colors ${
mb.rootBlock.checked ? 'line-through text-faint' : 'text-main'
}`}
title="Klick: Zu diesem Bullet Point in der Notiz zoomen"
>
{renderFormattedText(mb.rootBlock.content || '(Leerer Bullet Point)')}
</div>
</div>
{/* Children / Sub-bullets */}
{mb.children.length > 0 && (
<div className="space-y-1 mt-1 pl-4 border-l-2 border-border/60 ml-2">
{mb.children.map((child) => {
const indentLevel = Math.max(
0,
(child.level || 0) - (mb.rootBlock.level || 0) - 1
);
return (
<div
key={child.id}
style={{ paddingLeft: `${indentLevel * 14}px` }}
className="flex items-start gap-2 py-0.5 text-xs text-muted"
>
{child.type === 'todo' ? (
<input
type="checkbox"
checked={!!child.checked}
onChange={() =>
handleToggleRollupTodo(docGroup.docId, child.id)
}
className="w-3.5 h-3.5 mt-0.5 checkbox-square cursor-pointer flex-shrink-0"
/>
) : (
<span className="w-1.5 h-1.5 mt-1.5 rounded-full bg-muted/60 flex-shrink-0" />
)}
<div
onClick={() => {
const found = docs.find((d) => d.id === docGroup.docId);
if (found) {
setSelectedDoc(found);
setZoomedBlockId(child.id);
setWorkspaceMode('editor');
}
}}
className={`flex-1 text-xs leading-relaxed cursor-pointer hover:text-accent transition-colors ${
child.checked ? 'line-through text-faint' : 'text-muted'
}`}
title="Klick: In Notiz fokussieren"
>
{renderFormattedText(child.content || '')}
</div>
</div>
);
})}
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* ===================================================================== */}
{/* DOCUMENT SHARING MODAL */}
{/* ===================================================================== */}
{showShareModal && selectedDoc && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs">
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4">
{/* Modal Header */}
<div className="flex items-center justify-between pb-2 border-b border-border">
<div className="flex items-center gap-2">
<Users className="w-5 h-5 text-accent" />
<h3 className="text-base font-bold text-main">Notiz teilen & Berechtigungen</h3>
</div>
<button
type="button"
onClick={() => setShowShareModal(false)}
className="btn-icon"
title="Schließen"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="text-xs text-muted">
Dokument: <span className="font-semibold text-main">{selectedDoc.title || 'Unbenannte Notiz'}</span>
</div>
{shareError && (
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
{shareError}
</div>
)}
{/* If current user is owner: can add shares and manage permissions */}
{selectedDoc.isOwner !== false ? (
<div className="space-y-4">
<form onSubmit={handleAddShare} className="space-y-2.5 p-3 bg-panel-elevated border border-border rounded-card">
<div className="text-xs font-semibold text-main">Neuen Nutzer einladen / berechtigen</div>
<div className="flex flex-col sm:flex-row gap-2">
<select
value={shareTargetEmailOrId}
onChange={(e) => setShareTargetEmailOrId(e.target.value)}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs flex-1 cursor-pointer"
>
<option value="" className="bg-panel text-muted">Nutzer auswählen oder Email eingeben…</option>
{collaborators
.filter((u) => u.id !== currentUser?.id && !docShares.some((s) => s.userId === u.id))
.map((u) => (
<option key={u.id} value={u.id} className="bg-panel text-main">
{u.displayName || u.email} ({u.email})
</option>
))}
</select>
<select
value={sharePermission}
onChange={(e) => setSharePermission(e.target.value as 'read' | 'write')}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full sm:w-40 cursor-pointer"
>
<option value="read" className="bg-panel text-main">Nur Lesen (read)</option>
<option value="write" className="bg-panel text-main">Schreiben (write)</option>
</select>
</div>
{/* Direct Email fallback if user not in list */}
{(!shareTargetEmailOrId || !collaborators.some((u) => u.id === shareTargetEmailOrId)) && (
<input
type="email"
placeholder="Oder E-Mail-Adresse direkt eingeben…"
value={shareTargetEmailOrId}
onChange={(e) => setShareTargetEmailOrId(e.target.value)}
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full"
/>
)}
<div className="flex justify-end pt-1">
<button
type="submit"
disabled={sharingLoading || !shareTargetEmailOrId.trim()}
className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5"
>
<Plus className="w-3.5 h-3.5" />
<span>Freigeben</span>
</button>
</div>
</form>
{/* List of current shares */}
<div className="space-y-2">
<div className="text-xs font-semibold text-main">Aktuelle Freigaben ({docShares.length})</div>
{docShares.length === 0 ? (
<div className="p-3 bg-panel-elevated border border-border rounded-card text-xs text-muted text-center">
Diese Notiz ist bisher noch mit niemandem geteilt.
</div>
) : (
<div className="space-y-1.5 max-h-56 overflow-y-auto">
{docShares.map((share) => (
<div
key={share.id || share.userId}
className="p-2.5 bg-panel-elevated border border-border rounded-card flex items-center justify-between gap-3 text-xs"
>
<div className="min-w-0">
<div className="font-semibold text-main truncate">
{share.displayName || share.userEmail || share.userId}
</div>
{share.userEmail && share.displayName && (
<div className="text-[11px] text-muted truncate">{share.userEmail}</div>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<select
value={share.permission}
onChange={(e) => handleUpdateSharePerm(share.userId, e.target.value as 'read' | 'write')}
className="input-field h-7 px-2 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-[11px] cursor-pointer"
disabled={sharingLoading}
>
<option value="read" className="bg-panel text-main">Nur Lesen</option>
<option value="write" className="bg-panel text-main">Schreiben</option>
</select>
<button
type="button"
onClick={() => handleRemoveShare(share.userId)}
className="btn-icon btn-icon-danger h-7 w-7"
title="Freigabe entziehen"
disabled={sharingLoading}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
) : (
/* If current user is a collaborator */
<div className="space-y-3">
{/* Document & Owner Card */}
<div className="p-3 bg-panel-elevated border border-border rounded-card space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted">Eigentümer der Notiz:</span>
<span className="font-semibold text-main flex items-center gap-1.5">
<span className="w-5 h-5 rounded-full bg-accent/20 text-accent text-[10px] font-bold flex items-center justify-center">
{(selectedDoc.ownerName || 'U').slice(0, 1).toUpperCase()}
</span>
<span>{selectedDoc.ownerName || 'Unbekannt'}</span>
</span>
</div>
</div>
{/* Permission Card */}
{selectedDoc.permission === 'write' ? (
<div className="p-3 bg-emerald-500/10 border border-emerald-500/30 rounded-card flex items-start gap-3">
<div className="w-8 h-8 rounded-btn bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 flex items-center justify-center flex-shrink-0 mt-0.5">
<Edit3 className="w-4 h-4" />
</div>
<div className="text-xs space-y-0.5">
<div className="font-semibold text-emerald-600 dark:text-emerald-400">Schreibzugriff aktiv</div>
<div className="text-muted">
Du hast Bearbeitungsrechte für diese Notiz und kannst Inhalte in Echtzeit anpassen.
</div>
</div>
</div>
) : (
<div className="p-3 bg-accent/10 border border-accent/30 rounded-card flex items-start gap-3">
<div className="w-8 h-8 rounded-btn bg-accent/20 text-accent flex items-center justify-center flex-shrink-0 mt-0.5">
<Eye className="w-4 h-4" />
</div>
<div className="text-xs space-y-0.5">
<div className="font-semibold text-accent">Nur Lesezugriff</div>
<div className="text-muted">
Du kannst diese Notiz ansehen und durchsuchen, aber keine Änderungen an den Inhalten vornehmen.
</div>
</div>
</div>
)}
{/* Action: Leave Share */}
<div className="pt-2">
<button
type="button"
onClick={handleLeaveShare}
className="w-full btn-action h-8 px-3 text-xs text-status-err hover:bg-status-err-bg border border-status-err-border rounded-btn flex items-center justify-center gap-1.5 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Notiz aus meinem Workspace entfernen</span>
</button>
</div>
</div>
)}
<div className="flex justify-end pt-2 border-t border-border">
<button
type="button"
onClick={() => setShowShareModal(false)}
className="btn-action h-8 px-4 text-xs font-medium"
>
Schließen
</button>
</div>
</div>
</div>
)}
{/* LINK INSERTION MODAL (/link, /notiz, /web) */}
{linkModalBlockId && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
onClick={() => {
const blockId = linkModalBlockId;
setLinkModalBlockId(null);
if (blockId) focusBlock(blockId);
}}
>
<div
className="bg-panel border border-border rounded-xl shadow-panel w-full max-w-md overflow-hidden animate-in fade-in zoom-in-95 duration-150"
onClick={(e) => e.stopPropagation()}
>
{/* Modal Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-btn bg-accent/15 text-accent">
<ExternalLink className="w-4 h-4" />
</div>
<h3 className="text-sm font-semibold text-main">Link einfügen</h3>
</div>
<button
type="button"
onClick={() => {
const blockId = linkModalBlockId;
setLinkModalBlockId(null);
if (blockId) focusBlock(blockId);
}}
className="p-1 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Tab Switcher: Notiz vs Webseite */}
<div className="flex border-b border-border bg-panel-elevated/40 p-1 gap-1">
<button
type="button"
onClick={() => setLinkModalTab('note')}
className={`flex-1 py-1.5 px-3 rounded-btn text-xs font-medium flex items-center justify-center gap-1.5 transition-colors cursor-pointer ${
linkModalTab === 'note'
? 'bg-panel text-main shadow-xs font-semibold'
: 'text-muted hover:text-main'
}`}
>
<FileText className="w-3.5 h-3.5" />
<span>Notiz verlinken</span>
</button>
<button
type="button"
onClick={() => setLinkModalTab('web')}
className={`flex-1 py-1.5 px-3 rounded-btn text-xs font-medium flex items-center justify-center gap-1.5 transition-colors cursor-pointer ${
linkModalTab === 'web'
? 'bg-panel text-main shadow-xs font-semibold'
: 'text-muted hover:text-main'
}`}
>
<ExternalLink className="w-3.5 h-3.5" />
<span>Webseite verlinken</span>
</button>
</div>
{/* Tab Content */}
<div className="p-4">
{linkModalTab === 'note' ? (
<div className="space-y-3">
<div className="relative">
<Search className="w-4 h-4 absolute left-3 top-2.5 text-muted pointer-events-none" />
<input
type="text"
autoFocus
placeholder="Notiz nach Titel oder Ordner filtern..."
value={linkSearchQuery}
onChange={(e) => setLinkSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
const blockId = linkModalBlockId;
setLinkModalBlockId(null);
if (blockId) focusBlock(blockId);
} else if (e.key === 'Enter') {
const filtered = docs.filter(
(d) =>
d.title.toLowerCase().includes(linkSearchQuery.toLowerCase()) ||
(d.folderPath && d.folderPath.toLowerCase().includes(linkSearchQuery.toLowerCase()))
);
if (filtered.length > 0) {
handleInsertNoteLink(filtered[0].title);
}
}
}}
className="input-field w-full pl-9 h-9 text-xs"
/>
</div>
<div className="max-h-60 overflow-y-auto space-y-1 divide-y divide-border/30">
{docs
.filter(
(d) =>
!linkSearchQuery.trim() ||
d.title.toLowerCase().includes(linkSearchQuery.toLowerCase()) ||
(d.folderPath && d.folderPath.toLowerCase().includes(linkSearchQuery.toLowerCase()))
)
.slice(0, 20)
.map((d) => (
<button
key={d.id}
type="button"
onClick={() => handleInsertNoteLink(d.title)}
className="w-full text-left px-2.5 py-2 rounded-btn text-xs hover:bg-panel-elevated flex items-center justify-between gap-2 transition-colors cursor-pointer group"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-base flex-shrink-0">{d.icon || '📄'}</span>
<div className="min-w-0">
<div className="font-medium text-main truncate group-hover:text-accent">
{d.title}
</div>
{d.folderPath && (
<div className="text-[10px] text-faint truncate">
{d.folderPath}
</div>
)}
</div>
</div>
<span className="text-[10px] text-faint group-hover:text-accent flex-shrink-0 font-mono">
[[{d.title}]]
</span>
</button>
))}
{docs.filter(
(d) =>
!linkSearchQuery.trim() ||
d.title.toLowerCase().includes(linkSearchQuery.toLowerCase())
).length === 0 && (
<div className="py-6 text-center text-xs text-faint">
Keine passende Notiz gefunden
</div>
)}
</div>
</div>
) : (
<form
onSubmit={(e) => {
e.preventDefault();
handleInsertWebLink();
}}
className="space-y-3"
>
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted">Titel / Name der Verlinkung</label>
<input
type="text"
autoFocus
placeholder="z.B. Wikipedia oder Referenzseite"
value={linkWebTitle}
onChange={(e) => setLinkWebTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
const blockId = linkModalBlockId;
setLinkModalBlockId(null);
if (blockId) focusBlock(blockId);
}
}}
className="input-field w-full h-9 text-xs"
/>
</div>
<div className="space-y-1">
<label className="text-[11px] font-medium text-muted">Web-Adresse (URL) *</label>
<input
type="text"
required
placeholder="https://example.com"
value={linkWebUrl}
onChange={(e) => setLinkWebUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
const blockId = linkModalBlockId;
setLinkModalBlockId(null);
if (blockId) focusBlock(blockId);
}
}}
className="input-field w-full h-9 text-xs"
/>
</div>
<div className="pt-2 flex justify-end gap-2 border-t border-border">
<button
type="button"
onClick={() => {
const blockId = linkModalBlockId;
setLinkModalBlockId(null);
if (blockId) focusBlock(blockId);
}}
className="btn-action h-8 px-3 text-xs cursor-pointer"
>
Abbrechen
</button>
<button
type="submit"
disabled={!linkWebUrl.trim()}
className="btn-primary h-8 px-4 text-xs font-medium cursor-pointer disabled:opacity-50"
>
Link einfügen
</button>
</div>
</form>
)}
</div>
</div>
</div>
)}
</div>
);
};