Files
MindSift/frontend/src/features/workspace/WorkspaceView.tsx
T
sandrodachandClaude Opus 5.5 61c7dea6a1
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m15s
style(workspace): borderless selection in the notes sidebar and journal calendar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:28:06 +01:00

2813 lines
117 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
FilePlus,
FolderPlus,
Folder,
FolderOpen,
ChevronRight,
ChevronDown,
Search,
Pin,
Trash2,
Check,
Hash,
FileText,
Users,
Plus,
X,
ExternalLink,
Sparkles,
ArrowLeft,
PanelLeftClose,
PanelLeftOpen,
Clock,
AlertTriangle,
Loader2,
CalendarDays,
Minimize2,
} from 'lucide-react';
import {
Document,
DocumentBlock,
MergeResult,
DocShare,
SharedFolder,
TagDetails,
TagSummary,
Team,
TrashedDocument,
UnifiedShare,
User,
JournalDay,
} from '../../lib/types';
import {
api,
onWebSocketMessage,
triggerHaptic,
} from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { FormSheet } from '../../components/FormSheet';
import { sendDocOps } from '../../lib/docSync/oneoff';
import { useMobileActions } from '../../context/MobileActionContext';
import { DocumentEditor, DocSaveStatus, DocHeaderStatus } from './DocumentEditor';
import { PresenceAvatars } from './PresenceAvatars';
import { TrashView } from './TrashView';
import { JournalView } from './JournalView';
import { JournalCalendar } from './JournalCalendar';
import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession } from '../../lib/journal';
import { MergeDialog } from './MergeDialog';
import { CountBadge } from '../../components/CountBadge';
import { extractHashtags } from '../../lib/hashtags';
interface WorkspaceViewProps {
currentUser?: User | null;
initialDocId?: string | null;
initialBlockId?: string | null;
onStatsChange: () => void;
onOpenTask?: (taskId: string) => void;
// Opens the global search (command palette); the notes sidebar has no search of its own
onOpenSearch?: () => void;
}
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash' | 'journal';
interface FolderTreeNode {
name: string;
fullPath: string;
subfolders: FolderTreeNode[];
docs: Document[];
sharedFolderObj?: SharedFolder;
}
interface FolderTreeItemProps {
node: FolderTreeNode;
depth: number;
expandedFolders: Record<string, boolean>;
setExpandedFolders: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
selectedDocId: string | null;
subView: WorkspaceSubView;
dragOverFolder: string | null;
setDragOverFolder: (f: string | null) => void;
handleDropOnFolder: (folderPath: string, e: React.DragEvent) => void;
handleFolderDragStart: (e: React.DragEvent, path: string) => void;
handleDocDragStart: (e: React.DragEvent, id: string) => void;
handleCreateDocument: (folderPath?: string) => void;
handleOpenNewFolder: (parentPath?: string, e?: React.MouseEvent) => void;
handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void;
handleDeleteFolder: (folderPath: string) => void;
onSelectDoc: (id: string) => void;
// Dropping a note onto another one offers to merge them
docDropProps: (targetId: string) => React.HTMLAttributes<HTMLDivElement>;
mergeHoverId: string | null;
}
const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
node,
depth,
expandedFolders,
setExpandedFolders,
selectedDocId,
subView,
dragOverFolder,
setDragOverFolder,
handleDropOnFolder,
handleFolderDragStart,
handleDocDragStart,
handleCreateDocument,
handleOpenNewFolder,
handleShareFolderClick,
handleDeleteFolder,
onSelectDoc,
docDropProps,
mergeHoverId,
}) => {
const hasContent = node.subfolders.length > 0 || node.docs.length > 0;
const isExpanded = Boolean(hasContent && expandedFolders[node.fullPath]);
const isDragOver = dragOverFolder === node.fullPath;
return (
<div className="space-y-0.5">
<div
draggable
onDragStart={(e) => handleFolderDragStart(e, node.fullPath)}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragOverFolder(node.fullPath);
}}
onDragLeave={(e) => {
e.preventDefault();
if (dragOverFolder === node.fullPath) setDragOverFolder(null);
}}
onDrop={(e) => handleDropOnFolder(node.fullPath, e)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
className={`group flex items-center justify-between pr-1.5 h-7 rounded-btn text-xs cursor-pointer border-l-2 transition-colors ${
isDragOver
? 'bg-accent/20 border-accent text-main font-semibold'
: 'border-transparent hover:bg-panel-elevated text-muted'
}`}
>
<button
type="button"
onClick={() =>
hasContent &&
setExpandedFolders((prev) => ({
...prev,
[node.fullPath]: !prev[node.fullPath],
}))
}
className="flex items-center gap-1.5 flex-1 min-w-0 text-left h-full cursor-pointer"
>
{hasContent ? (
isExpanded ? (
<ChevronDown className="w-3.5 h-3.5 text-faint shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 text-faint shrink-0" />
)
) : null}
{isExpanded ? (
<FolderOpen className="w-3.5 h-3.5 text-accent shrink-0" />
) : (
<Folder className="w-3.5 h-3.5 text-accent shrink-0" />
)}
<span className="truncate">{node.name}</span>
{node.sharedFolderObj && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
)}
</button>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity shrink-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleCreateDocument(node.fullPath);
}}
className="p-1 h-6 w-6 flex items-center justify-center text-muted hover:text-main transition-colors cursor-pointer"
title="Notiz in diesem Ordner erstellen"
>
<Plus className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleOpenNewFolder(node.fullPath, e);
}}
className="p-1 h-6 w-6 flex items-center justify-center text-muted hover:text-main transition-colors cursor-pointer"
title="Neuen Unterordner erstellen"
>
<FolderPlus className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleShareFolderClick(node.fullPath, node.sharedFolderObj);
}}
className={`p-1 h-6 w-6 flex items-center justify-center transition-colors cursor-pointer ${
node.sharedFolderObj ? 'text-accent' : 'text-faint hover:text-accent'
}`}
title="Ordner teilen und Berechtigungen"
>
<Users className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeleteFolder(node.fullPath);
}}
className="p-1 h-6 w-6 flex items-center justify-center text-faint hover:text-status-err transition-colors cursor-pointer"
title="Ordner löschen"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
{isExpanded && (
<div className="space-y-0.5">
{/* Subfolders in this folder */}
{node.subfolders.map((childNode) => (
<FolderTreeItem
key={childNode.fullPath}
node={childNode}
depth={depth + 1}
expandedFolders={expandedFolders}
setExpandedFolders={setExpandedFolders}
selectedDocId={selectedDocId}
subView={subView}
dragOverFolder={dragOverFolder}
setDragOverFolder={setDragOverFolder}
handleDropOnFolder={handleDropOnFolder}
handleFolderDragStart={handleFolderDragStart}
handleDocDragStart={handleDocDragStart}
handleCreateDocument={handleCreateDocument}
handleOpenNewFolder={handleOpenNewFolder}
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={onSelectDoc}
docDropProps={docDropProps}
mergeHoverId={mergeHoverId}
/>
))}
{/* Notes directly in this folder */}
{node.docs.map((d) => (
<div
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => onSelectDoc(d.id)}
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
className={`w-full flex items-center gap-2 pr-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-transparent'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
</div>
))}
{!hasContent && (
<div
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
className="py-1 text-[10px] text-faint italic"
>
Leer
</div>
)}
</div>
)}
</div>
);
};
export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
currentUser,
initialDocId,
initialBlockId,
onStatsChange,
onOpenTask,
onOpenSearch,
}) => {
const [docs, setDocs] = useState<Document[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const [sharedFolders, setSharedFolders] = useState<SharedFolder[]>([]);
const [serverTagsSummary, setServerTagsSummary] = useState<TagSummary[]>([]);
// Dynamically compute hashtag counts directly from in-memory documents
const tagsSummary = useMemo(() => {
if (docs.length === 0) return serverTagsSummary;
const tagMap = new Map<string, number>();
for (const d of docs) {
for (const b of d.blocks || []) {
for (const clean of extractHashtags(b.content)) {
tagMap.set(clean, (tagMap.get(clean) || 0) + 1);
}
}
}
const list: TagSummary[] = [];
tagMap.forEach((count, tag) => {
list.push({ tag, count });
});
return list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
}, [docs, serverTagsSummary]);
const [selectedDocId, setSelectedDocId] = useState<string | null>(
initialDocId || readWorkspaceSession().docId || null
);
const [targetBlockId, setTargetBlockId] = useState<string | null>(initialBlockId || null);
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
// Journal open and the last regular note survive switching to other tabs (the view remounts)
const [subView, setSubView] = useState<WorkspaceSubView>(() =>
!initialDocId && isJournalEnabled() && readWorkspaceSession().journal ? 'journal' : 'editor'
);
const [trash, setTrash] = useState<TrashedDocument[]>([]);
// Bumped after a restore so the open editor reloads the note without the trash banner
const [editorNonce, setEditorNonce] = useState(0);
const reloadTrash = useCallback(() => {
api.getTrash().then(setTrash).catch(() => {});
}, []);
useEffect(() => {
reloadTrash();
}, [reloadTrash]);
// Journal: one note per day, listed separately from the folder tree (can be switched off per device)
const [journalEnabled] = useState(isJournalEnabled);
const [journalDays, setJournalDays] = useState<JournalDay[]>([]);
const [journalExtra, setJournalExtra] = useState<string[]>([]);
const [journalActive, setJournalActive] = useState(todayISO);
const [journalJump, setJournalJump] = useState<{ date: string; nonce: number } | null>(null);
const reloadJournal = useCallback(() => {
if (!journalEnabled) return;
api.getJournal().then(setJournalDays).catch(() => {});
}, [journalEnabled]);
useEffect(() => {
reloadJournal();
}, [reloadJournal]);
// After typing, refresh which days have text (calendar dots) – at most every few seconds
const journalReloadTimer = useRef<number | null>(null);
const reloadJournalSoon = useCallback(() => {
if (journalReloadTimer.current !== null) return;
journalReloadTimer.current = window.setTimeout(() => {
journalReloadTimer.current = null;
reloadJournal();
}, 3000);
}, [reloadJournal]);
useEffect(() => () => {
if (journalReloadTimer.current !== null) window.clearTimeout(journalReloadTimer.current);
}, []);
const journalDocIds = useMemo(() => new Set(journalDays.map((d) => d.docId)), [journalDays]);
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
const [tagNoteDraft, setTagNoteDraft] = useState<string>('');
const [tagSearchQuery, setTagSearchQuery] = useState<string>('');
const [tagSortBy, setTagSortBy] = useState<'count' | 'name'>('count');
const [expandedFolders, setExpandedFolders] = useState<Record<string, boolean>>(() => {
try {
const saved = localStorage.getItem('mindsift:workspace_expanded_folders');
return saved ? JSON.parse(saved) : {};
} catch {
return {};
}
});
useEffect(() => {
try {
localStorage.setItem('mindsift:workspace_expanded_folders', JSON.stringify(expandedFolders));
} catch {}
}, [expandedFolders]);
const [showNewFolderModal, setShowNewFolderModal] = useState<boolean>(false);
const [newFolderCoords, setNewFolderCoords] = useState<{ x: number; y: number } | null>(null);
const [newFolderParentPath, setNewFolderParentPath] = useState<string>('');
const [newFolderName, setNewFolderName] = useState<string>('');
const handleOpenNewFolder = (parentPath = '', e?: React.MouseEvent) => {
setNewFolderParentPath(parentPath);
setNewFolderName('');
if (e) {
setNewFolderCoords({
x: Math.min(e.clientX, window.innerWidth - 270),
y: Math.min(e.clientY, window.innerHeight - 200),
});
} else {
setNewFolderCoords({ x: 100, y: 150 });
}
setShowNewFolderModal(true);
};
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(
Boolean(initialDocId) || subView === 'journal'
);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState<boolean>(() => {
try {
return localStorage.getItem('mindsift:workspace_sidebar_collapsed') === 'true';
} catch {
return false;
}
});
const toggleSidebar = useCallback(() => {
setIsSidebarCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem('mindsift:workspace_sidebar_collapsed', String(next));
} catch {}
return next;
});
}, []);
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
const [docSaveError, setDocSaveError] = useState<string | null>(null);
const [docHeaderStatus, setDocHeaderStatus] = useState<DocHeaderStatus | null>(null);
const [docActionsTarget, setDocActionsTarget] = useState<HTMLDivElement | null>(null);
// Distraction-free mode: only the note, full window, reading width
const [focusMode, setFocusMode] = useState(false);
// Focus mode: the folder sidebar slides in from the left (button, title click or mouse at the edge)
const [zenSidebar, setZenSidebar] = useState<'closed' | 'click' | 'hover'>('closed');
const [zenActionsTarget, setZenActionsTarget] = useState<HTMLDivElement | null>(null);
// Drag and Drop state
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
// Merging notes: dialog (picker/confirmation) and a toast with undo afterwards
const [mergeDialog, setMergeDialog] = useState<{ sourceId: string; targetId?: string } | null>(null);
const [mergeHoverId, setMergeHoverId] = useState<string | null>(null);
const [toast, setToast] = useState<{ text: string; actions: { label: string; run: () => void }[] } | null>(null);
const toastTimerRef = useRef<number | null>(null);
const showToast = useCallback((text: string, actions: { label: string; run: () => void }[] = []) => {
setToast({ text, actions });
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
toastTimerRef.current = window.setTimeout(() => setToast(null), actions.length ? 10000 : 4000);
}, []);
useEffect(() => () => {
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
}, []);
const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
// A drag that ends without a drop (released elsewhere, Escape) fires only dragend: clear every drag state
useEffect(() => {
const clear = () => {
setDraggingDocId(null);
setDraggingFolderPath(null);
setDragOverFolder(null);
setMergeHoverId(null);
};
document.addEventListener('dragend', clear);
return () => document.removeEventListener('dragend', clear);
}, []);
// Rollup drilldown & View Mode
const [todoFolderDrilldown, setTodoFolderDrilldown] = useState<string | null>(null);
const [todoGrouping, setTodoGrouping] = useState<'grouped' | 'list'>(() => {
const saved = localStorage.getItem('mindsift_todo_grouping');
return saved === 'list' ? 'list' : 'grouped';
});
const [collapsedTodoFolders, setCollapsedTodoFolders] = useState<Set<string>>(new Set());
const [collapsedTodoNotes, setCollapsedTodoNotes] = useState<Set<string>>(new Set());
// Sharing Modal State
const [shareModalDoc, setShareModalDoc] = useState<Document | null>(null);
const [shareModalFolder, setShareModalFolder] = useState<SharedFolder | null>(null);
const [activeDocShares, setActiveDocShares] = useState<DocShare[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [collaborators, setCollaborators] = useState<User[]>([]);
const isCreatingRef = useRef<boolean>(false);
const { setGroups, setPrimaryAction } = useMobileActions();
const loadWorkspaceData = useCallback(async () => {
try {
const [docsRes, foldersRes, sharedFoldersRes, tagsRes, journalRes] = await Promise.all([
api.getDocuments(),
api.getFolders(),
api.getSharedFolders().catch(() => []),
api.getWorkspaceTags().catch(() => []),
isJournalEnabled() ? api.getJournal().catch(() => [] as JournalDay[]) : Promise.resolve([] as JournalDay[]),
]);
const journalIds = new Set(journalRes.map((d) => d.docId));
setDocs(docsRes);
setFolders(foldersRes);
setSharedFolders(sharedFoldersRes);
setServerTagsSummary(tagsRes);
setSelectedDocId((prevId) => {
if (prevId && docsRes.some((d) => d.id === prevId)) {
return prevId;
}
// Not a journal day: those open in the journal
const first = docsRes.find((d) => !(d.isOwner && journalIds.has(d.id)));
return first ? first.id : null;
});
} catch {
// ignore
}
}, []);
useEffect(() => {
loadWorkspaceData();
}, [loadWorkspaceData]);
useEffect(() => {
if (initialDocId) {
setSelectedDocId(initialDocId);
setSubView('editor');
setMobileShowDetail(true);
}
}, [initialDocId]);
// Global WebSocket listener with strict isolation & deduplication
useEffect(() => {
const unsub = onWebSocketMessage((msg) => {
if (msg.event === 'DOC_CREATED' && msg.payload) {
const created = msg.payload as Document;
// Strict Privacy Check: Only add if created by or shared with current user
if (currentUser) {
const isOwner = created.userId === currentUser.id;
const isShared =
created.sharedWith?.some(
(s) => s.userId === currentUser.id || s.targetId === currentUser.id
) || false;
const isFolderShared = Boolean(created.sharedFolderId || created.isSharedFolder);
if (!isOwner && !isShared && !isFolderShared) {
return;
}
}
setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev]));
} else if (msg.event === 'DOC_UPDATED' && msg.payload) {
const updated = msg.payload as Document;
setDocs((prev) => prev.map((d) => (d.id === updated.id ? { ...d, ...updated } : d)));
} else if (msg.event === 'DOC_DELETED' && msg.payload?.id) {
const delId = msg.payload.id as string;
setDocs((prev) => prev.filter((d) => d.id !== delId));
setSelectedDocId((prev) => (prev === delId ? null : prev));
reloadTrash();
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
api.getFolders().then(setFolders).catch(() => {});
api.getSharedFolders().then(setSharedFolders).catch(() => {});
api.getDocuments().then(setDocs).catch(() => {});
reloadTrash();
}
});
return unsub;
}, [currentUser, reloadTrash]);
const handleCreateDocument = useCallback(
async (folderPath = '') => {
if (isCreatingRef.current) return;
isCreatingRef.current = true;
triggerHaptic(10);
try {
const created = await api.createDocument({
title: 'Neue Notiz',
icon: '📝',
folderPath,
blocks: [
{
id: `blk_${Date.now().toString(36)}_0`,
type: 'text',
content: '',
level: 0,
position: 1000,
},
],
});
setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev]));
setNewlyCreatedDocId(created.id);
setTargetBlockId(null);
setSelectedDocId(created.id);
setSubView('editor');
setMobileShowDetail(true);
if (folderPath) {
setExpandedFolders((prev) => ({ ...prev, [folderPath]: true }));
}
onStatsChange();
} catch {
// ignore
} finally {
setTimeout(() => {
isCreatingRef.current = false;
}, 300);
}
},
[onStatsChange]
);
const handleJumpToDocBlock = useCallback((docId: string, blockId?: string) => {
setSelectedDocId(docId);
setTargetBlockId(blockId || null);
setNewlyCreatedDocId(null);
setSubView('editor');
setMobileShowDetail(true);
}, []);
const openJournalDay = useCallback(async (date: string): Promise<Document | null> => {
try {
const doc = await api.openJournalDay(date);
setDocs((prev) => (prev.some((d) => d.id === doc.id) ? prev : [doc, ...prev]));
setJournalDays((prev) =>
[
...prev.filter((d) => d.date !== date),
{ date, docId: doc.id, empty: !(doc.content || '').trim() },
].sort((a, b) => b.date.localeCompare(a.date))
);
return doc;
} catch {
return null;
}
}, []);
const jumpToJournalDay = useCallback((date: string) => {
setJournalExtra((prev) => (prev.includes(date) ? prev : [...prev, date]));
setJournalActive(date);
setJournalJump({ date, nonce: Date.now() });
}, []);
const openJournal = useCallback(() => {
reloadJournal();
setSubView('journal');
setMobileShowDetail(true);
}, [reloadJournal]);
// A second click closes the journal and shows the regular note again
const toggleJournal = useCallback(() => {
if (subView === 'journal') {
setSubView('editor');
setSelectedDocId((prev) => prev ?? treeDocsRef.current[0]?.id ?? null);
} else {
openJournal();
}
}, [subView, openJournal]);
const openTrash = useCallback(() => {
reloadTrash();
setSubView('trash');
setMobileShowDetail(true);
}, [reloadTrash]);
// Register mobile FAB actions
useEffect(() => {
setPrimaryAction(() => handleCreateDocument(''));
setGroups([
{
id: 'workspace-actions',
title: 'Notizen & Ordner',
items: [
{
id: 'new-note',
label: 'Neue Notiz',
icon: <FilePlus className="w-4 h-4" />,
variant: 'accent',
onClick: () => handleCreateDocument(''),
},
{
id: 'new-folder',
label: 'Neuer Ordner',
icon: <FolderPlus className="w-4 h-4" />,
onClick: () => setShowNewFolderModal(true),
},
{
id: 'trash',
label: 'Papierkorb',
icon: <Trash2 className="w-4 h-4" />,
badge: trash.length,
onClick: openTrash,
},
],
},
]);
return () => {
setGroups([]);
setPrimaryAction(undefined);
};
}, [handleCreateDocument, setGroups, setPrimaryAction, trash.length, openTrash]);
const handleDeleteDocument = useCallback(
async (docId: string) => {
try {
await api.deleteDocument(docId);
setDocs((prev) => {
const remaining = prev.filter((d) => d.id !== docId);
if (selectedDocId === docId) {
setSelectedDocId(remaining.length > 0 ? remaining[0].id : null);
}
return remaining;
});
setMobileShowDetail(false);
onStatsChange();
reloadTrash();
} catch {
// ignore
}
},
[onStatsChange, selectedDocId, reloadTrash]
);
const handleOpenTrashedDoc = (docId: string) => {
setSelectedDocId(docId);
setTargetBlockId(null);
setNewlyCreatedDocId(null);
setSubView('editor');
setMobileShowDetail(true);
};
const handleRestoreDocument = useCallback(
async (docId: string) => {
await api.restoreDocument(docId);
const [freshDocs, freshFolders] = await Promise.all([api.getDocuments(), api.getFolders()]);
setDocs(freshDocs);
setFolders(freshFolders);
setEditorNonce((n) => n + 1);
reloadTrash();
onStatsChange();
},
[reloadTrash, onStatsChange]
);
const handlePurgeDocument = async (docId: string) => {
await api.purgeDocument(docId);
if (selectedDocId === docId) setSelectedDocId(null);
reloadTrash();
};
const handleEmptyTrash = async () => {
await api.emptyTrash();
if (selectedDocId && trash.some((t) => t.id === selectedDocId)) setSelectedDocId(null);
reloadTrash();
};
const handleDocMetadataChange = useCallback((updated: Document) => {
setDocs((prev) => prev.map((d) => (d.id === updated.id ? { ...d, ...updated } : d)));
}, []);
const handleCreateFolderSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const cleanName = newFolderName.trim().replace(/^\/+|\/+$/g, '');
if (!cleanName) return;
const cleanPath = newFolderParentPath ? `${newFolderParentPath}/${cleanName}` : cleanName;
try {
const res = await api.createFolder(cleanPath);
setFolders((prev) => (prev.includes(res.path) ? prev : [...prev, res.path].sort()));
const parts = res.path.split('/');
let acc = '';
const expansions: Record<string, boolean> = {};
for (const part of parts) {
acc = acc ? `${acc}/${part}` : part;
expansions[acc] = true;
}
setExpandedFolders((prev) => ({ ...prev, ...expansions }));
setNewFolderName('');
setNewFolderParentPath('');
setShowNewFolderModal(false);
} catch (err: any) {
alert(err.message || 'Fehler beim Erstellen des Ordners');
}
};
const handleDeleteFolder = async (folderPath: string) => {
try {
const res = await api.deleteFolder(folderPath);
setFolders((prev) =>
prev.filter((f) => f !== folderPath && !f.startsWith(`${folderPath}/`))
);
if (res.deletedDocIds && res.deletedDocIds.length > 0) {
setDocs((prev) => prev.filter((d) => !res.deletedDocIds.includes(d.id)));
if (selectedDocId && res.deletedDocIds.includes(selectedDocId)) {
setSelectedDocId(null);
}
}
onStatsChange();
reloadTrash();
} catch {
// ignore
}
};
// HTML5 Drag & Drop handlers for moving documents into folders
const handleDocDragStart = (e: React.DragEvent, docId: string) => {
e.stopPropagation();
e.dataTransfer.setData('application/mindsift-doc-id', docId);
e.dataTransfer.setData('text/plain', docId);
e.dataTransfer.effectAllowed = 'move';
setDraggingDocId(docId);
};
const requestMerge = (sourceId: string, targetId?: string) => {
const source = docs.find((d) => d.id === sourceId);
if (!source?.isOwner) {
showToast('Nur eigene Notizen lassen sich in eine andere einfügen');
return;
}
setMergeDialog({ sourceId, targetId });
};
// Dropping a dragged note onto another note (instead of a folder) offers to merge them
const docDropProps = (targetId: string): React.HTMLAttributes<HTMLDivElement> => ({
onDragOver: (e) => {
if (!draggingDocId || draggingDocId === targetId) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
if (mergeHoverId !== targetId) setMergeHoverId(targetId);
},
onDragLeave: () => setMergeHoverId((cur) => (cur === targetId ? null : cur)),
onDrop: (e) => {
const sourceId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
if (!sourceId || sourceId === targetId) return;
e.preventDefault();
e.stopPropagation();
setDraggingDocId(null);
setMergeHoverId(null);
setDragOverFolder(null);
requestMerge(sourceId, targetId);
},
});
const handleMerged = async (result: MergeResult) => {
setMergeDialog(null);
triggerHaptic(20);
setDocs((prev) => prev.filter((d) => d.id !== result.sourceId));
setSelectedDocId(result.targetId);
setTargetBlockId(null);
setSubView('editor');
setMobileShowDetail(true);
setEditorNonce((n) => n + 1);
reloadTrash();
showToast(`„${result.sourceTitle || 'Notiz'}“ wurde angehängt`, [
{
label: 'Rückgängig',
run: async () => {
try {
await api.undoMerge(result);
await loadWorkspaceData();
setSelectedDocId(result.sourceId);
setEditorNonce((n) => n + 1);
reloadTrash();
showToast('Zusammenführen rückgängig gemacht');
} catch (err: any) {
showToast(err?.message || 'Rückgängig fehlgeschlagen');
}
},
},
]);
};
const handleFolderDragStart = (e: React.DragEvent, path: string) => {
e.stopPropagation();
e.dataTransfer.setData('application/mindsift-folder-path', path);
e.dataTransfer.setData('text/plain', path);
e.dataTransfer.effectAllowed = 'move';
setDraggingFolderPath(path);
};
const handleDropOnFolder = async (targetFolder: string, e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragOverFolder(null);
const docId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
const folderPath = e.dataTransfer.getData('application/mindsift-folder-path') || draggingFolderPath;
setDraggingDocId(null);
setDraggingFolderPath(null);
if (docId) {
triggerHaptic(15);
try {
const updatedDoc = await api.moveDocument(docId, targetFolder);
setDocs((prev) =>
prev.map((d) => (d.id === docId ? { ...d, folderPath: updatedDoc.folderPath } : d))
);
if (targetFolder) {
const parts = targetFolder.split('/');
let acc = '';
const expansions: Record<string, boolean> = {};
for (const part of parts) {
acc = acc ? `${acc}/${part}` : part;
expansions[acc] = true;
}
setExpandedFolders((prev) => ({ ...prev, ...expansions }));
}
} catch {
// ignore
}
} else if (folderPath && folderPath !== targetFolder && !targetFolder.startsWith(`${folderPath}/`)) {
triggerHaptic(15);
try {
await api.moveFolder(folderPath, targetFolder);
if (targetFolder) {
const parts = targetFolder.split('/');
let acc = '';
const expansions: Record<string, boolean> = {};
for (const part of parts) {
acc = acc ? `${acc}/${part}` : part;
expansions[acc] = true;
}
setExpandedFolders((prev) => ({ ...prev, ...expansions }));
}
loadWorkspaceData();
} catch {
// ignore
}
}
};
const handleSelectTag = useCallback(async (tag: string) => {
const cleanTag = tag.replace(/^#/, '').toLowerCase();
setSelectedTag(cleanTag);
setSubView('tag_detail');
setMobileShowDetail(true);
try {
const details = await api.getTagDetails(cleanTag);
setTagDetails(details);
setTagNoteDraft(details.noteContent || '');
} catch {
setTagDetails(null);
}
}, []);
const handleSaveTagNote = async () => {
if (!selectedTag) return;
try {
await api.updateTagPageNote(selectedTag, tagNoteDraft);
} catch {
// ignore
}
};
const handleWikiLinkClick = useCallback(
(pageTitle: string, blockId?: string) => {
const clean = pageTitle.trim().toLowerCase();
const match = docs.find((d) => d.title.trim().toLowerCase() === clean);
if (match && blockId) {
handleJumpToDocBlock(match.id, blockId);
} else if (match) {
setSelectedDocId(match.id);
setSubView('editor');
setMobileShowDetail(true);
} else {
handleCreateDocument('');
}
},
[docs, handleCreateDocument, handleJumpToDocBlock]
);
const openDocShareModal = useCallback(async (doc: Document) => {
setShareModalDoc(doc);
setShareModalFolder(null);
try {
const [sharesRes, teamsRes, collabRes] = await Promise.all([
api.getDocShares(doc.id).catch(() => []),
api.getUserTeams().catch(() => []),
api.getUsersDirectory().catch(() => []),
]);
setActiveDocShares(sharesRes);
setTeams(teamsRes);
setCollaborators(collabRes);
} catch {
// ignore
}
}, []);
const openFolderShareModal = useCallback(async (sf: SharedFolder) => {
setShareModalFolder(sf);
setShareModalDoc(null);
try {
const [teamsRes, collabRes, freshSf] = await Promise.all([
api.getUserTeams().catch(() => []),
api.getUsersDirectory().catch(() => []),
api.getSharedFolder(sf.id).catch(() => sf),
]);
setShareModalFolder(freshSf);
setTeams(teamsRes);
setCollaborators(collabRes);
} catch {
// ignore
}
}, []);
const handleShareFolderClick = useCallback(
async (folderPath: string, existingSf?: SharedFolder) => {
if (existingSf) {
openFolderShareModal(existingSf);
return;
}
try {
const clean = folderPath.trim().replace(/^\/+|\/+$/g, '');
const sf = await api.createSharedFolder({ name: clean, mountPath: clean });
setSharedFolders((prev) => [...prev.filter((f) => f.id !== sf.id), sf]);
openFolderShareModal(sf);
} catch {
// ignore
}
},
[openFolderShareModal]
);
const handleToggleRollupTodo = async (doc: Document, blockId: string, currentChecked: boolean) => {
triggerHaptic(10);
const updatedBlocks = (doc.blocks || []).map((b) =>
b.id === blockId ? { ...b, checked: !currentChecked } : b
);
setDocs((prev) =>
prev.map((d) => (d.id === doc.id ? { ...d, blocks: updatedBlocks } : d))
);
try {
const [result] = await sendDocOps(doc.id, [{ kind: 'set_props', blockId, patch: { checked: !currentChecked } }]);
if (result?.status !== 'applied') loadWorkspaceData();
} catch {
loadWorkspaceData();
}
};
// Own journal notes appear under "Journal", not in the folder tree
const treeDocs = useMemo(
() => (journalDocIds.size ? docs.filter((d) => !(d.isOwner && journalDocIds.has(d.id))) : docs),
[docs, journalDocIds]
);
const treeDocsRef = useRef(treeDocs);
treeDocsRef.current = treeDocs;
useEffect(() => {
writeWorkspaceSession({ journal: subView === 'journal', docId: selectedDocId });
}, [subView, selectedDocId]);
const canFocus = subView === 'editor' && Boolean(selectedDocId);
useEffect(() => {
if (!canFocus) setFocusMode(false);
}, [canFocus]);
// Leaving focus mode or picking another note closes the sliding sidebar
useEffect(() => {
setZenSidebar('closed');
}, [focusMode, selectedDocId]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'f' && canFocus) {
e.preventDefault();
setFocusMode((v) => !v);
} else if (e.key === 'Escape' && focusMode && !e.defaultPrevented) {
if (zenSidebar !== 'closed') setZenSidebar('closed');
else setFocusMode(false);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [canFocus, focusMode, zenSidebar]);
const allFolderPaths = useMemo(() => {
const set = new Set<string>(folders);
for (const d of docs) {
if (d.folderPath) set.add(d.folderPath);
}
for (const sf of sharedFolders) {
if (sf.mountPath) set.add(sf.mountPath);
}
return Array.from(set).sort();
}, [docs, folders, sharedFolders]);
const folderTree = useMemo(() => {
const rootNodes: FolderTreeNode[] = [];
const nodeMap = new Map<string, FolderTreeNode>();
const allPathsSet = new Set<string>();
for (const p of allFolderPaths) {
const parts = p.split('/');
let current = '';
for (const part of parts) {
current = current ? `${current}/${part}` : part;
allPathsSet.add(current);
}
}
const sortedPaths = Array.from(allPathsSet).sort((a, b) => {
const depthA = a.split('/').length;
const depthB = b.split('/').length;
if (depthA !== depthB) return depthA - depthB;
return a.localeCompare(b);
});
for (const p of sortedPaths) {
const parts = p.split('/');
const leafName = parts[parts.length - 1];
const sharedFolderObj = sharedFolders.find(
(sf) => sf.mountPath === p || sf.name === p
);
const folderDocs = treeDocs.filter((d) => d.folderPath === p);
const node: FolderTreeNode = {
name: leafName,
fullPath: p,
subfolders: [],
docs: folderDocs,
sharedFolderObj,
};
nodeMap.set(p, node);
if (parts.length === 1) {
rootNodes.push(node);
} else {
const parentPath = parts.slice(0, -1).join('/');
const parentNode = nodeMap.get(parentPath);
if (parentNode) {
parentNode.subfolders.push(node);
} else {
rootNodes.push(node);
}
}
}
return rootNodes;
}, [allFolderPaths, treeDocs, sharedFolders]);
const pinnedDocs = useMemo(
() => treeDocs.filter((d) => d.isPinned),
[treeDocs]
);
const rootDocs = useMemo(
() => treeDocs.filter((d) => !d.folderPath && !d.isPinned),
[treeDocs]
);
// Global To-Dos rollup collections
const allRollupTodos = useMemo(() => {
const list: { doc: Document; blockId: string; content: string; checked: boolean }[] = [];
for (const d of docs) {
const fName = d.folderPath || 'Hauptebene';
if (todoFolderDrilldown && fName !== todoFolderDrilldown) continue;
for (const b of d.blocks || []) {
if (b.type === 'todo' && b.content.trim() !== '') {
list.push({ doc: d, blockId: b.id, content: b.content, checked: Boolean(b.checked) });
}
}
}
return list;
}, [docs, todoFolderDrilldown]);
const rollupTodosByFolder = useMemo(() => {
const map = new Map<string, { doc: Document; blockId: string; content: string; checked: boolean }[]>();
for (const item of allRollupTodos) {
const fName = item.doc.folderPath || 'Hauptebene';
const arr = map.get(fName) || [];
arr.push(item);
map.set(fName, arr);
}
return map;
}, [allRollupTodos]);
const totalOpenTodos = useMemo(() => {
let count = 0;
for (const d of docs) {
for (const b of d.blocks || []) {
if (b.type === 'todo' && !b.checked && b.content.trim() !== '') {
count++;
}
}
}
return count;
}, [docs]);
// Real-time matched blocks and all nested children for the selected hashtag
const activeTagGroups = useMemo(() => {
if (!selectedTag) return [];
const cleanTag = selectedTag.toLowerCase();
const map = new Map<
string,
{
docId: string;
title: string;
icon: string;
folderPath: string;
matchedBlocks: { rootBlock: DocumentBlock; children: DocumentBlock[] }[];
}
>();
for (const d of docs) {
const blks = d.blocks || [];
if (blks.length === 0) continue;
const matched: { rootBlock: DocumentBlock; children: DocumentBlock[] }[] = [];
const processedChildIds = new Set<string>();
for (let i = 0; i < blks.length; i++) {
const b = blks[i];
if (processedChildIds.has(b.id)) continue;
const content = b.content || '';
const regex = new RegExp(`(?:^|[\\s(])#(${cleanTag})(?=[\\s.,!?:;)#\\]}]|$)`, 'i');
if (regex.test(content)) {
const children: DocumentBlock[] = [];
const rootLevel = b.level || 0;
for (let j = i + 1; j < blks.length; j++) {
const nextB = blks[j];
if ((nextB.level || 0) > rootLevel) {
children.push(nextB);
processedChildIds.add(nextB.id);
} else {
break;
}
}
matched.push({ rootBlock: b, children });
}
}
if (matched.length > 0) {
map.set(d.id, {
docId: d.id,
title: d.title || 'Unbenannte Notiz',
icon: d.icon || '📝',
folderPath: d.folderPath || '',
matchedBlocks: matched,
});
}
}
if (tagDetails?.documents) {
for (const dg of tagDetails.documents) {
if (!map.has(dg.docId)) {
map.set(dg.docId, {
docId: dg.docId,
title: dg.title || 'Unbenannte Notiz',
icon: dg.icon || '📝',
folderPath: dg.folderPath || '',
matchedBlocks: dg.matchedBlocks || [],
});
}
}
}
return Array.from(map.values());
}, [docs, selectedTag, tagDetails]);
const activeTagTotalMatches = useMemo(() => {
let count = 0;
for (const g of activeTagGroups) {
count += g.matchedBlocks.length;
}
return count;
}, [activeTagGroups]);
const selectedDocObj = useMemo(
() => docs.find((d) => d.id === selectedDocId) || null,
[docs, selectedDocId]
);
const unifiedShares: UnifiedShare[] = useMemo(() => {
if (shareModalDoc) {
return activeDocShares.map((s) => ({
id: s.userId || s.teamId || s.id,
targetType: s.targetType || (s.teamId ? 'team' : 'user'),
targetId: s.targetId || s.teamId || s.userId || '',
targetName: s.targetName || s.displayName || s.userEmail || 'Mitglied',
targetEmail: s.userEmail,
permission: s.permission === 'write' ? 'editor' : 'viewer',
createdAt: s.createdAt,
}));
}
if (shareModalFolder) {
return (shareModalFolder.shares || []).map((s) => ({
id: s.id,
targetType: s.targetType,
targetId: s.targetId,
targetName: s.targetName || s.email || 'Mitglied',
targetEmail: s.email,
permission: s.permission === 'write' ? 'editor' : 'viewer',
createdAt: s.createdAt,
}));
}
return [];
}, [activeDocShares, shareModalDoc, shareModalFolder]);
// Aggregated metadata map for every tag (documents where it occurs, open todos count)
const tagsMetadataMap = useMemo(() => {
const map = new Map<
string,
{
tag: string;
count: number;
docs: { id: string; title: string; icon?: string; folderPath?: string }[];
openTodosCount: number;
}
>();
for (const t of tagsSummary) {
const cleanTag = t.tag.toLowerCase();
const matchingDocs: { id: string; title: string; icon?: string; folderPath?: string }[] = [];
let openTodos = 0;
for (const d of docs) {
let hasInDoc = false;
const blocks = d.blocks || [];
for (const b of blocks) {
const content = b.content || '';
const regex = new RegExp(`(?:^|[\\s(])#(${cleanTag})(?=[\\s.,!?:;)#\\]}]|$)`, 'i');
if (regex.test(content)) {
hasInDoc = true;
if (b.type === 'todo' && !b.checked) openTodos++;
}
}
if (hasInDoc) {
matchingDocs.push({
id: d.id,
title: d.title || 'Unbenannte Notiz',
icon: d.icon,
folderPath: d.folderPath,
});
}
}
map.set(cleanTag, {
tag: t.tag,
count: t.count,
docs: matchingDocs,
openTodosCount: openTodos,
});
}
return map;
}, [tagsSummary, docs]);
// Filtered and sorted tags for the Kachel Grid view
const filteredTags = useMemo(() => {
const q = tagSearchQuery.trim().toLowerCase().replace(/^#/, '');
let list = [...tagsSummary];
if (q) {
list = list.filter((t) => t.tag.toLowerCase().includes(q));
}
if (tagSortBy === 'count') {
list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
} else {
list.sort((a, b) => a.tag.localeCompare(b.tag));
}
return list;
}, [tagsSummary, tagSearchQuery, tagSortBy]);
return (
<div className="w-full h-full min-h-0 flex flex-col gap-3">
{/* Top Header: Workspace Tabs & Actions */}
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
{/* Workspace selector tabs & left sidebar toggle; on phones the three tabs share the full width */}
<div className="grid grid-cols-3 w-full gap-1 sm:flex sm:flex-wrap sm:items-center sm:w-auto">
<button
type="button"
disabled={subView !== 'editor'}
onClick={subView === 'editor' ? toggleSidebar : undefined}
className={`hidden sm:inline-flex h-8 w-8 items-center justify-center rounded-btn border transition-colors ${
subView !== 'editor'
? 'opacity-35 bg-canvas border-border text-faint cursor-not-allowed pointer-events-none'
: isSidebarCollapsed
? 'bg-canvas border-border text-accent hover:bg-panel cursor-pointer'
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel cursor-pointer'
}`}
title={
subView !== 'editor'
? 'Ordnerleiste nur im Notizen-Modus verfügbar'
: isSidebarCollapsed
? 'Ordnerleiste einblenden'
: 'Ordnerleiste einklappen'
}
aria-label={
subView !== 'editor'
? 'Ordnerleiste nur im Notizen-Modus verfügbar'
: isSidebarCollapsed
? 'Ordnerleiste einblenden'
: 'Ordnerleiste einklappen'
}
>
{isSidebarCollapsed ? (
<PanelLeftOpen className="w-4 h-4" />
) : (
<PanelLeftClose className="w-4 h-4" />
)}
</button>
<button
type="button"
onClick={() => setSubView('editor')}
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
subView === 'editor'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<FileText className="w-3.5 h-3.5 text-accent" />
<span>Notizen</span>
</button>
<button
type="button"
onClick={() => {
setSubView('todos');
setMobileShowDetail(true);
}}
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
subView === 'todos'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Check className="w-3.5 h-3.5 text-status-ok" />
<span className={`relative inline-flex items-center ${totalOpenTodos > 0 ? 'pr-3' : ''}`}>
To-Dos
<CountBadge count={totalOpenTodos} position="corner" className="-top-0.5 -right-2" />
</span>
</button>
<button
type="button"
onClick={() => {
setSubView('tags');
setMobileShowDetail(true);
}}
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
subView === 'tags' || subView === 'tag_detail'
? 'bg-panel border-accent text-main'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<Hash className="w-3.5 h-3.5 text-accent" />
<span className={`relative inline-flex items-center ${tagsSummary.length > 0 ? 'pr-3' : ''}`}>
Hashtags
<CountBadge count={tagsSummary.length} position="corner" className="-top-0.5 -right-2" />
</span>
</button>
</div>
{/* Right Header Section: Live/Share Status, Avatars & Save Status (on phones it sits compactly in the note's own header) */}
<div className="hidden sm:flex items-center gap-2">
{subView === 'editor' && selectedDocId ? (
<>
{/* Fixed order: people first (they come and go without moving the buttons), status, actions */}
{docHeaderStatus && (
<PresenceAvatars
people={docHeaderStatus.otherPeople}
avatars={docHeaderStatus.avatarsById}
/>
)}
{/* Live connection pill */}
{docHeaderStatus?.showLive && (
<span
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel text-muted"
title={
docHeaderStatus.channel === 'ws'
? 'Live-Verbindung über WebSocket'
: docHeaderStatus.channel === 'sse'
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
: docHeaderStatus.channel === 'longpoll'
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
: 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt'
}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
docHeaderStatus.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok'
}`}
/>
{docHeaderStatus.channel === 'longpoll'
? 'Live (Long-Polling)'
: docHeaderStatus.channel === 'interval'
? 'Abgleich alle 3 s'
: 'Live'}
</span>
)}
{/* Share pill */}
{selectedDocObj &&
!selectedDocObj.isArchived &&
(() => {
const isOwner =
selectedDocObj.isOwner !== false &&
(selectedDocObj.userId === currentUser?.id ||
!selectedDocObj.userId ||
currentUser?.role === 'admin');
const sharesCount = selectedDocObj.sharedWith?.length || 0;
const isShared =
sharesCount > 0 || !isOwner || Boolean(selectedDocObj.sharedFolderId);
const ownerDisplayName = selectedDocObj.ownerName || 'Eigentümer';
if (!isShared) return null;
return isOwner ? (
<button
type="button"
onClick={() => openDocShareModal(selectedDocObj)}
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel font-semibold text-accent hover:bg-panel-elevated transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
</button>
) : (
<button
type="button"
onClick={() => openDocShareModal(selectedDocObj)}
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel font-medium text-muted 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 {ownerDisplayName}</span>
</button>
);
})()}
{/* Save status: icon only, the text is the tooltip */}
<div
role="img"
aria-label={
docSaveStatus === 'saved'
? 'Gespeichert'
: docSaveStatus === 'saving'
? 'Speichert…'
: docSaveStatus === 'unsaved'
? 'Ungespeichert'
: 'Fehler beim Speichern'
}
className={`inline-flex items-center justify-center h-7 w-7 rounded-btn transition-colors ${
docSaveStatus === 'saved'
? 'bg-panel'
: docSaveStatus === 'saving'
? 'bg-panel animate-pulse'
: docSaveStatus === 'unsaved'
? 'bg-status-warn/10'
: 'bg-status-err/10'
}`}
title={
docSaveStatus === 'saved'
? 'Alle Änderungen per HTTPS gesichert'
: docSaveStatus === 'saving'
? 'Änderungen werden per HTTPS gespeichert…'
: docSaveStatus === 'unsaved'
? 'Ungespeicherte Änderungen (Auto-Save aktiv)'
: docSaveError || 'Fehler beim Speichern'
}
>
{docSaveStatus === 'saved' && (
<>
<Check className="w-3.5 h-3.5 text-status-ok" />
</>
)}
{docSaveStatus === 'saving' && (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
</>
)}
{docSaveStatus === 'unsaved' && (
<>
<Clock className="w-3.5 h-3.5 text-status-warn" />
</>
)}
{docSaveStatus === 'error' && (
<>
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
</>
)}
</div>
</>
) : null}
{/* The open note renders undo/redo, share and its menu here on large screens */}
{subView === 'editor' && selectedDocId && (
<span className="hidden lg:block w-px h-5 bg-border mx-0.5" aria-hidden />
)}
<div ref={setDocActionsTarget} className="hidden lg:flex items-center" />
</div>
</div>
{/* Main Workspace Split Pane */}
<div
className={
focusMode
? 'fixed inset-0 z-[45] flex bg-editor focus-mode-enter'
: 'flex flex-1 min-h-0 bg-editor rounded-card border border-border overflow-hidden'
}
>
{focusMode && (
<>
{/* Header slides in when the mouse touches the top edge (large screens) */}
<div className="hidden lg:block fixed top-0 inset-x-0 h-3 z-[46] group">
<div className="absolute top-0 inset-x-0 flex items-center gap-2 px-6 py-2 bg-panel/95 backdrop-blur-sm border-b border-border shadow-panel -translate-y-full group-hover:translate-y-0 group-focus-within:translate-y-0 transition-transform duration-200">
<button
type="button"
onClick={() => setZenSidebar((v) => (v === 'closed' ? 'click' : 'closed'))}
className="btn-icon h-7 w-7 shrink-0"
title="Ordner und Notizen einblenden"
aria-label="Ordner und Notizen einblenden"
aria-expanded={zenSidebar !== 'closed'}
>
<FolderOpen className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setZenSidebar((v) => (v === 'closed' ? 'click' : 'closed'))}
className="flex-1 min-w-0 truncate text-left text-xs font-medium text-main hover:text-accent cursor-pointer"
title="Andere Notiz wählen"
>
{selectedDocObj?.icon} {selectedDocObj?.title}
</button>
<div ref={setZenActionsTarget} className="flex items-center" />
<button
type="button"
onClick={() => setFocusMode(false)}
className="btn-action h-7 px-2.5 text-xs inline-flex items-center gap-1.5"
title="Fokus-Modus beenden (Esc)"
>
<Minimize2 className="w-3.5 h-3.5" />
Beenden
</button>
</div>
</div>
{/* Wide screens: touching the left edge opens the sidebar (the note sits far from it) */}
<div
className="hidden xl:block fixed left-0 inset-y-0 w-2 z-[46]"
onMouseEnter={() => setZenSidebar((v) => (v === 'closed' ? 'hover' : v))}
aria-hidden
/>
{zenSidebar === 'click' && (
<div className="fixed inset-0 z-[46] bg-overlay/30" onClick={() => setZenSidebar('closed')} aria-hidden />
)}
<button
type="button"
onClick={() => setFocusMode(false)}
className="lg:hidden fixed bottom-4 right-4 z-[46] h-12 w-12 rounded-full bg-panel border border-border shadow-panel flex items-center justify-center text-main"
title="Fokus-Modus beenden"
aria-label="Fokus-Modus beenden"
>
<Minimize2 className="w-4 h-4" />
</button>
</>
)}
{/* LEFT MASTER SIDEBAR */}
<aside
onMouseLeave={() => setZenSidebar((v) => (v === 'hover' ? 'closed' : v))}
className={
focusMode
? `fixed left-0 inset-y-0 z-[47] w-80 max-w-[85vw] flex flex-col bg-sidebar border-r border-border shadow-panel transition-transform duration-300 ease-out ${
zenSidebar !== 'closed' ? 'translate-x-0' : '-translate-x-full'
}`
: `${
(subView !== 'editor' && subView !== 'trash' && subView !== 'journal') || isSidebarCollapsed
? 'hidden'
: mobileShowDetail
? 'hidden lg:flex'
: 'flex'
} w-full lg:w-72 xl:w-80 shrink-0 flex-col bg-sidebar border-r border-border transition-all duration-200`
}
>
{/* Sidebar header: search and the create buttons in one row */}
<div className="p-3 border-b border-border flex items-center gap-1.5">
{/* Opens the global full-text search, which also finds the content of every bullet */}
{onOpenSearch && (
<button
type="button"
onClick={onOpenSearch}
className="input-field flex-1 min-w-0 h-11 sm:h-8 px-2.5 flex items-center gap-2 text-xs text-faint hover:text-main cursor-pointer"
title="Globale Suche (Strg+K / ⌘K oder /)"
>
<Search className="w-3.5 h-3.5 shrink-0" />
<span className="flex-1 text-left truncate">Notizen durchsuchen…</span>
<span className="hidden xl:inline font-mono text-[10px]">⌘K</span>
</button>
)}
{/* On phones these are in the menu at the top (and the + button), so not repeated here */}
<div className="hidden sm:flex shrink-0 items-center gap-1">
<button
type="button"
onClick={(e) => handleOpenNewFolder('', e)}
className="btn-icon h-8 w-8"
title="Neuer Ordner"
aria-label="Neuer Ordner"
>
<FolderPlus className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleCreateDocument('')}
className="btn-icon h-8 w-8"
title="Neue Notiz anlegen"
aria-label="Neue Notiz anlegen"
>
<FilePlus className="w-4 h-4" />
</button>
</div>
</div>
{/* Scrollable Navigation Tree */}
<div className="flex-1 overflow-y-auto p-2 space-y-3">
{/* Journal on top; open, it becomes a panel with the calendar (click the heading to close).
Panel and calendar animate open (grid rows 0fr → 1fr) instead of jumping. */}
{journalEnabled && (
<div>
<div
className={`rounded-card border-2 transition-[background-color,border-color,padding] duration-300 ease-out ${
subView === 'journal' ? 'border-transparent bg-panel-elevated p-2.5' : 'border-transparent p-0'
}`}
>
<button
type="button"
onClick={toggleJournal}
aria-expanded={subView === 'journal'}
title={subView === 'journal' ? 'Journal schließen' : 'Journal öffnen'}
className={`w-full flex items-center gap-2 text-left cursor-pointer rounded-btn transition-all duration-300 ${
subView === 'journal'
? 'h-11 sm:h-7 px-0.5 text-sm font-bold text-main'
: 'h-11 sm:h-8 px-2 text-xs text-main hover:bg-panel-elevated'
}`}
>
<CalendarDays
className={`text-accent transition-all duration-300 ${subView === 'journal' ? 'w-4 h-4' : 'w-3.5 h-3.5'}`}
/>
<span>Journal</span>
</button>
<div
className={`hidden lg:grid transition-[grid-template-rows,opacity] duration-300 ease-out ${
subView === 'journal' ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
}`}
aria-hidden={subView !== 'journal'}
>
<div className="overflow-hidden min-h-0">
<div className="pt-2">
<JournalCalendar
activeDate={journalActive}
entryDates={new Set(journalDays.filter((d) => !d.empty).map((d) => d.date))}
onSelect={jumpToJournalDay}
/>
</div>
</div>
</div>
</div>
<div
className={`h-px bg-border mt-1.5 transition-opacity duration-300 ${
subView === 'journal' ? 'opacity-0' : 'opacity-100'
}`}
/>
</div>
)}
{pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted">Noch keine Notizen</div>
) : (
<div>
{/* Pinned Notes */}
{pinnedDocs.length > 0 && (
<div
className={`space-y-0.5 ${
folderTree.length > 0 || rootDocs.length > 0
? 'border-b border-border pb-1.5 mb-1.5'
: ''
}`}
>
{pinnedDocs.map((d) => (
<div
key={`pin-${d.id}`}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-transparent'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
)}
<Pin className="w-3 h-3 text-muted shrink-0" />
</div>
))}
</div>
)}
{/* Folders and Root Documents */}
{(folderTree.length > 0 || rootDocs.length > 0) && (
<div className="space-y-0.5">
{folderTree.map((node) => (
<FolderTreeItem
key={node.fullPath}
node={node}
depth={0}
expandedFolders={expandedFolders}
setExpandedFolders={setExpandedFolders}
selectedDocId={selectedDocId}
subView={subView}
dragOverFolder={dragOverFolder}
setDragOverFolder={setDragOverFolder}
handleDropOnFolder={handleDropOnFolder}
handleFolderDragStart={handleFolderDragStart}
handleDocDragStart={handleDocDragStart}
handleCreateDocument={handleCreateDocument}
handleOpenNewFolder={handleOpenNewFolder}
handleShareFolderClick={handleShareFolderClick}
handleDeleteFolder={handleDeleteFolder}
onSelectDoc={(id) => {
setSelectedDocId(id);
setTargetBlockId(null);
setNewlyCreatedDocId(null);
setSubView('editor');
setMobileShowDetail(true);
}}
docDropProps={docDropProps}
mergeHoverId={mergeHoverId}
/>
))}
{rootDocs.map((d) => (
<div
key={d.id}
draggable
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-transparent'
: 'border-transparent text-main hover:bg-panel-elevated'
}`}
>
<span className="shrink-0">{d.icon || '📝'}</span>
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
{d.sharedWith && d.sharedWith.length > 0 && (
<Users className="w-3 h-3 text-status-ok shrink-0" />
)}
</div>
))}
</div>
)}
</div>
)}
{/* Bottom drop target for moving to root */}
{(draggingDocId || draggingFolderPath) && (
<div
onDragOver={(e) => {
e.preventDefault();
setDragOverFolder('__root_bottom__');
}}
onDragLeave={() => setDragOverFolder(null)}
onDrop={(e) => handleDropOnFolder('', e)}
className={`p-3 rounded-card border-2 border-dashed transition-all flex items-center justify-center text-xs font-medium text-center ${
dragOverFolder === '__root_bottom__'
? 'border-accent bg-accent/20 text-accent ring-2 ring-accent/30'
: 'border-border/60 bg-panel-elevated/40 text-muted hover:border-accent/40'
}`}
>
<span>Auf Hauptebene verschieben</span>
</div>
)}
{/* Tags Explorer */}
{tagsSummary.length > 0 && (
<div>
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
Hashtags (#)
</div>
<div className="flex flex-wrap gap-1 px-1">
{tagsSummary.map((t) => (
<button
key={t.tag}
type="button"
onClick={() => handleSelectTag(t.tag)}
className={`inline-flex items-center gap-0.5 px-2 py-0.5 rounded-btn text-[10px] font-medium transition-colors ${
subView === 'tag_detail' && selectedTag === t.tag
? 'bg-accent text-on-accent'
: 'bg-panel-elevated text-muted hover:text-accent'
}`}
>
<span>#{t.tag}</span>
<span className="opacity-60 font-mono text-[9px]">{t.count}</span>
</button>
))}
</div>
</div>
)}
</div>
{/* Trash at the bottom of the tree */}
<div className="p-2 border-t border-border shrink-0">
<button
type="button"
onClick={openTrash}
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
subView === 'trash'
? 'bg-panel-elevated text-main font-semibold border-transparent'
: 'border-transparent text-muted hover:text-main hover:bg-panel-elevated'
}`}
>
<Trash2 className="w-3.5 h-3.5" />
<span className="flex-1 text-left">Papierkorb</span>
<CountBadge count={trash.length} />
</button>
</div>
</aside>
{/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */}
<main
className={`${
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed || focusMode ? 'flex' : 'hidden lg:flex'
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-editor`}
>
{subView === 'journal' && journalEnabled ? (
<JournalView
days={journalDays}
extraDates={journalExtra}
activeDate={journalActive}
onActiveDateChange={setJournalActive}
jumpRequest={journalJump}
onOpenDay={openJournalDay}
onJump={jumpToJournalDay}
getDoc={(id) => docs.find((d) => d.id === id)}
currentUser={currentUser}
availableFolders={allFolderPaths}
onOpenShareModal={openDocShareModal}
onDocMetadataChange={handleDocMetadataChange}
onDeleteDoc={handleDeleteDocument}
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock}
onOpenTask={onOpenTask}
onBackMobile={() => setMobileShowDetail(false)}
onDaySaved={reloadJournalSoon}
/>
) : subView === 'trash' ? (
<TrashView
items={trash}
onOpen={handleOpenTrashedDoc}
onRestore={handleRestoreDocument}
onPurge={handlePurgeDocument}
onEmpty={handleEmptyTrash}
onBackMobile={() => {
setSubView('editor');
setMobileShowDetail(false);
}}
/>
) : subView === 'editor' && selectedDocId ? (
<DocumentEditor
key={`${selectedDocId}:${editorNonce}`}
docId={selectedDocId}
currentUser={currentUser}
initialDoc={selectedDocObj}
availableFolders={allFolderPaths}
autoFocusTitle={newlyCreatedDocId === selectedDocId}
initialFocusedBlockId={targetBlockId}
onDocMetadataChange={handleDocMetadataChange}
onDeleteDoc={handleDeleteDocument}
onRestoreDoc={handleRestoreDocument}
onOpenShareModal={openDocShareModal}
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock}
onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })}
onDocCreated={(created) => {
setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev]));
if (created.folderPath) {
const parts = created.folderPath.split('/');
const open: Record<string, boolean> = {};
parts.forEach((_, i) => {
open[parts.slice(0, i + 1).join('/')] = true;
});
setExpandedFolders((prev) => ({ ...prev, ...open }));
}
onStatsChange();
}}
onDocRemoved={(id) => {
setDocs((prev) => prev.filter((d) => d.id !== id));
reloadTrash();
onStatsChange();
}}
onBackMobile={() => setMobileShowDetail(false)}
onOpenTask={onOpenTask}
onSaveStatusChange={(status, err) => {
setDocSaveStatus(status);
setDocSaveError(err || null);
}}
onHeaderStatusChange={setDocHeaderStatus}
actionsTarget={focusMode ? zenActionsTarget : docActionsTarget}
focusMode={focusMode}
onToggleFocusMode={() => setFocusMode((v) => !v)}
/>
) : subView === 'tags' ? (
/* =========================================================================
HASHTAG OVERVIEW & TILE DIRECTORY (KACHEL-ANSICHT MIT SUCHE)
========================================================================= */
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
<div className="w-full space-y-5">
{/* Header with Title and Search Controls */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<Hash className="w-5 h-5 text-accent" />
<h1 className="text-xl font-bold text-main">Hashtags Übersicht</h1>
<span className="badge-neutral text-xs font-mono">{tagsSummary.length} Tags</span>
</div>
<p className="text-xs text-muted mt-0.5">
Entdecke alle Themen, Schlagworte und verknüpften Blöcke aus deinen Notizen
</p>
</div>
{/* Search & Sort Toolbar */}
<div className="flex items-center gap-2">
<div className="relative min-w-[200px] sm:min-w-[240px]">
<Search className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={tagSearchQuery}
onChange={(e) => setTagSearchQuery(e.target.value)}
placeholder="Hashtags durchsuchen…"
className="input-field w-full pl-8 pr-7 py-1.5 text-xs"
/>
{tagSearchQuery && (
<button
type="button"
onClick={() => setTagSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-faint hover:text-main cursor-pointer"
>
<X className="w-3 h-3" />
</button>
)}
</div>
{/* Sort Mode Switcher */}
<div className="flex items-center bg-canvas border border-border rounded-btn p-0.5 text-[11px]">
<button
type="button"
onClick={() => setTagSortBy('count')}
className={`px-2 py-1 rounded-btn font-semibold transition-colors cursor-pointer ${
tagSortBy === 'count'
? 'bg-panel-elevated text-main shadow-xs'
: 'text-muted hover:text-main'
}`}
title="Nach Häufigkeit sortieren"
>
Häufigkeit
</button>
<button
type="button"
onClick={() => setTagSortBy('name')}
className={`px-2 py-1 rounded-btn font-semibold transition-colors cursor-pointer ${
tagSortBy === 'name'
? 'bg-panel-elevated text-main shadow-xs'
: 'text-muted hover:text-main'
}`}
title="Alphabetisch sortieren"
>
A-Z
</button>
</div>
</div>
</div>
{/* Grid of Hashtag Tiles (Kacheln) */}
{filteredTags.length === 0 ? (
<div className="p-12 text-center text-muted text-xs space-y-2 border border-border rounded-card bg-canvas">
<Hash className="w-8 h-8 text-faint mx-auto" />
<p className="font-semibold text-main text-sm">
{tagSearchQuery
? `Keine Hashtags für "${tagSearchQuery}" gefunden`
: 'Noch keine Hashtags vorhanden'}
</p>
<p className="text-faint max-w-sm mx-auto">
{tagSearchQuery
? 'Versuche einen anderen Suchbegriff oder setze die Suche zurück.'
: 'Schreibe #tag in einen beliebigen Notiz-Bullet, um Themen automatisch als Kachel zu gruppieren.'}
</p>
{tagSearchQuery && (
<button
type="button"
onClick={() => setTagSearchQuery('')}
className="btn-action inline-flex items-center gap-1 px-3 py-1 text-xs text-accent mt-2 cursor-pointer"
>
Suche zurücksetzen
</button>
)}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3.5">
{filteredTags.map((t) => {
const meta = tagsMetadataMap.get(t.tag.toLowerCase());
return (
<div
key={t.tag}
onClick={() => handleSelectTag(t.tag)}
className="p-4 rounded-card border border-border bg-panel hover:border-accent hover:bg-panel-elevated transition-all cursor-pointer group flex flex-col justify-between space-y-3"
>
<div className="space-y-2.5">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="w-7 h-7 rounded-btn bg-accent/10 border border-accent/20 text-accent font-bold flex items-center justify-center shrink-0 text-sm group-hover:bg-accent group-hover:text-on-accent transition-colors">
#
</span>
<h3 className="font-bold text-sm text-main group-hover:text-accent transition-colors truncate">
{t.tag.replace(/^#/, '')}
</h3>
</div>
<span className="badge-neutral font-mono text-[10px] shrink-0">
{t.count} Treffer
</span>
</div>
{meta && meta.docs.length > 0 && (
<div className="space-y-1">
<span className="text-[10px] font-semibold text-faint uppercase tracking-wider block">
Aktuellste Notiz:
</span>
<div
className="inline-flex items-center gap-1.5 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-main truncate max-w-full"
title={meta.docs[0].title}
>
<span>{meta.docs[0].icon || '📝'}</span>
<span className="truncate">{meta.docs[0].title || 'Unbenannte Notiz'}</span>
</div>
</div>
)}
</div>
<div className="flex items-center justify-between pt-2.5 border-t border-border/50 text-[11px]">
<div>
{meta && meta.openTodosCount > 0 && (
<span className="text-status-warn text-[10px] flex items-center gap-1 font-medium">
<Check className="w-2.5 h-2.5" />
{meta.openTodosCount} offene To-Dos
</span>
)}
</div>
<span className="text-accent text-xs font-semibold group-hover:translate-x-0.5 transition-transform flex items-center gap-0.5">
<span>Details</span>
<span>→</span>
</span>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
) : subView === 'tag_detail' && selectedTag ? (
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
<div className="w-full space-y-4">
{/* Back to all tags */}
<div className="flex items-center justify-between border-b border-border pb-3">
<button
type="button"
onClick={() => setSubView('tags')}
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main cursor-pointer"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Alle Hashtag-Kacheln</span>
</button>
</div>
{/* Tag Context Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="w-7 h-7 rounded-btn bg-accent/10 border border-accent/20 text-accent font-bold flex items-center justify-center shrink-0 text-sm">
#
</span>
<h1 className="text-xl font-bold text-main">
#{selectedTag}
</h1>
</div>
<button
type="button"
onClick={() => setSubView('tags')}
className="text-xs text-accent hover:underline flex items-center gap-1 cursor-pointer"
>
<ArrowLeft className="w-3 h-3" />
<span>Zur Kachel-Übersicht</span>
</button>
</div>
{/* Tag Context Note */}
<div className="space-y-1">
<label className="text-[10px] font-bold uppercase tracking-wider text-faint">
Tag-Notiz
</label>
<textarea
rows={3}
value={tagNoteDraft}
onChange={(e) => setTagNoteDraft(e.target.value)}
onBlur={handleSaveTagNote}
placeholder={`Notizen zu #${selectedTag}…`}
className="input-field w-full p-2.5 text-xs text-main"
/>
</div>
{/* Matched Document Blocks */}
<div className="space-y-3">
<h3 className="text-[10px] font-bold uppercase tracking-wider text-faint">
Verknüpfte Outliner-Blöcke & Unterpunkte ({activeTagTotalMatches})
</h3>
{activeTagGroups.length === 0 ? (
<div className="p-8 text-center text-muted text-xs space-y-1 border border-border rounded-card bg-canvas">
<p className="font-semibold text-main">Keine Blöcke mit #{selectedTag} gefunden.</p>
</div>
) : (
activeTagGroups.map((docGroup) => {
const docObj = docs.find((d) => d.id === docGroup.docId);
return (
<div
key={docGroup.docId}
className="p-3 rounded-card border border-border bg-canvas space-y-2"
>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => handleJumpToDocBlock(docGroup.docId)}
className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer"
>
<span>{docGroup.icon || '📝'}</span>
<span>{docGroup.title}</span>
{docGroup.folderPath && (
<span className="badge-neutral text-[10px] font-normal">
📁 {docGroup.folderPath}
</span>
)}
</button>
<button
type="button"
onClick={() => handleJumpToDocBlock(docGroup.docId)}
className="text-[10px] text-accent hover:underline px-1.5 py-0.5 rounded-btn hover:bg-canvas cursor-pointer flex items-center gap-1"
title="Notiz öffnen"
>
<span>Notiz öffnen</span>
<ExternalLink className="w-2.5 h-2.5" />
</button>
</div>
<div className="space-y-2 pl-3 border-l-2 border-border/70">
{docGroup.matchedBlocks.map((mb) => (
<div key={mb.rootBlock.id} className="space-y-1">
{/* Root Bullet Point containing the hashtag */}
<div
onClick={() => handleJumpToDocBlock(docGroup.docId, mb.rootBlock.id)}
className="flex items-start justify-between gap-1.5 text-xs text-main font-medium group cursor-pointer hover:bg-panel-elevated p-1.5 rounded-btn transition-colors"
title="Klick: Zu dieser Zeile springen und markieren"
>
<div className="flex items-start gap-2 flex-1 min-w-0">
{mb.rootBlock.type === 'todo' ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (docObj) {
handleToggleRollupTodo(
docObj,
mb.rootBlock.id,
Boolean(mb.rootBlock.checked)
);
}
}}
className={`w-3.5 h-3.5 mt-0.5 checkbox-square flex items-center justify-center shrink-0 border cursor-pointer ${
mb.rootBlock.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
) : (
<span className="text-accent font-bold select-none text-sm leading-none mt-0.5">
•
</span>
)}
<span
className={`flex-1 break-words ${
mb.rootBlock.checked ? 'line-through text-faint' : ''
}`}
>
{mb.rootBlock.content}
</span>
</div>
<ExternalLink className="w-3 h-3 text-faint group-hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5" />
</div>
{/* All Children indented */}
{mb.children && mb.children.length > 0 && (
<div className="space-y-0.5">
{mb.children.map((ch) => {
const relLevel = Math.max(
1,
(ch.level || 0) - (mb.rootBlock.level || 0)
);
return (
<div
key={ch.id}
onClick={() => handleJumpToDocBlock(docGroup.docId, ch.id)}
style={{ paddingLeft: `${relLevel * 16}px` }}
className="flex items-start justify-between gap-1.5 text-xs text-muted hover:text-main group cursor-pointer hover:bg-panel-elevated p-1 rounded-btn transition-colors"
title="Klick: Zu dieser Zeile springen und markieren"
>
<div className="flex items-start gap-2 flex-1 min-w-0">
{ch.type === 'todo' ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (docObj) {
handleToggleRollupTodo(
docObj,
ch.id,
Boolean(ch.checked)
);
}
}}
className={`w-3.5 h-3.5 mt-0.5 checkbox-square flex items-center justify-center shrink-0 border cursor-pointer ${
ch.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
) : (
<span className="text-faint group-hover:text-accent font-bold select-none text-xs leading-none mt-1">
◦
</span>
)}
<span
className={`flex-1 break-words ${
ch.checked ? 'line-through text-faint' : ''
}`}
>
{ch.content}
</span>
</div>
<ExternalLink className="w-3 h-3 text-faint group-hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5" />
</div>
);
})}
</div>
)}
</div>
))}
</div>
</div>
);
})
)}
</div>
</div>
</div>
) : subView === 'todos' ? (
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
<div className="w-full space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between border-b border-border pb-3 gap-2">
<div>
<h2 className="text-base sm:text-lg font-bold text-main">
To-Dos aus Notizen ({totalOpenTodos} offen)
</h2>
<p className="text-xs text-muted">
Alle Aufgaben aus deinen Notizen im gesamten Workspace
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{/* View Grouping Selector (matching hashtag switcher style) */}
<div className="flex items-center bg-canvas border border-border rounded-btn p-0.5 text-[11px]">
<button
type="button"
onClick={() => {
setTodoGrouping('grouped');
localStorage.setItem('mindsift_todo_grouping', 'grouped');
}}
className={`px-2.5 py-1 rounded-btn font-semibold transition-colors cursor-pointer ${
todoGrouping === 'grouped'
? 'bg-panel-elevated text-main shadow-xs'
: 'text-muted hover:text-main'
}`}
title="Sortiert nach Ordner und Notiz"
>
Gruppiert
</button>
<button
type="button"
onClick={() => {
setTodoGrouping('list');
localStorage.setItem('mindsift_todo_grouping', 'list');
}}
className={`px-2.5 py-1 rounded-btn font-semibold transition-colors cursor-pointer ${
todoGrouping === 'list'
? 'bg-panel-elevated text-main shadow-xs'
: 'text-muted hover:text-main'
}`}
title="Alle To-Dos als einfache Liste"
>
Liste
</button>
</div>
</div>
</div>
{/* Folder Drilldown Tag */}
{todoFolderDrilldown && (
<div className="flex items-center justify-between p-2 rounded-btn bg-accent/10 border border-accent text-xs">
<span>
Gefiltert nach Ordner: <strong>{todoFolderDrilldown}</strong>
</span>
<button
type="button"
onClick={() => setTodoFolderDrilldown(null)}
className="text-accent hover:underline font-semibold cursor-pointer"
>
Filter aufheben
</button>
</div>
)}
{/* Grouped Folders & To-Dos */}
{allRollupTodos.length === 0 ? (
<div className="p-8 text-center text-muted text-xs space-y-1 border border-border rounded-card bg-panel">
<Sparkles className="w-6 h-6 text-accent mx-auto mb-1" />
<p className="font-semibold text-main">Keine To-Dos in Notizen gefunden!</p>
<p className="text-faint">
Tippe in einem beliebigen Dokument <code>/todo</code> oder <code>[] </code> ein.
</p>
</div>
) : todoGrouping === 'list' ? (
/* Flat list of all todos across notes */
<div className="border border-border rounded-card bg-panel p-2 space-y-1">
{allRollupTodos.map((item) => (
<div
key={`${item.doc.id}-${item.blockId}`}
className="flex items-center justify-between gap-2 p-2 rounded-btn hover:bg-panel-elevated text-xs transition-colors group cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleToggleRollupTodo(item.doc, item.blockId, item.checked);
}}
className={`w-3.5 h-3.5 checkbox-square flex items-center justify-center transition-colors border cursor-pointer ${
item.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
<span
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
className={`truncate flex-1 ${
item.checked
? 'line-through text-faint'
: 'text-main font-medium group-hover:text-accent'
}`}
>
{item.content}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<span
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-muted hover:text-main"
title={item.doc.title}
>
<span>{item.doc.icon || '📝'}</span>
<span className="truncate max-w-[120px]">{item.doc.title}</span>
{item.doc.folderPath && (
<span className="text-faint text-[10px]">({item.doc.folderPath})</span>
)}
</span>
<button
type="button"
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
className="text-faint hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity p-1"
title="Zu dieser Zeile springen"
>
<ExternalLink className="w-3 h-3" />
</button>
</div>
</div>
))}
</div>
) : (
/* Grouped by Folder -> grouped by Note */
<div className="space-y-3">
{Array.from(rollupTodosByFolder.entries()).map(([folderName, items]) => {
const isCollapsed = collapsedTodoFolders.has(folderName);
const openCount = items.filter((i) => !i.checked).length;
// Subgroup by doc
const docGroupsMap = new Map<string, { doc: Document; items: typeof items }>();
for (const it of items) {
const ex = docGroupsMap.get(it.doc.id);
if (ex) ex.items.push(it);
else docGroupsMap.set(it.doc.id, { doc: it.doc, items: [it] });
}
return (
<div
key={folderName}
className="border border-border rounded-card bg-panel overflow-hidden"
>
{/* Folder Header */}
<div className="p-2 bg-panel border-b border-border flex items-center justify-between">
<div
onClick={() => {
setCollapsedTodoFolders((prev) => {
const next = new Set(prev);
if (next.has(folderName)) next.delete(folderName);
else next.add(folderName);
return next;
});
}}
className="flex items-center gap-2 cursor-pointer select-none"
>
{isCollapsed ? (
<ChevronRight className="w-3.5 h-3.5 text-faint" />
) : (
<ChevronDown className="w-3.5 h-3.5 text-accent" />
)}
<Folder className="w-3.5 h-3.5 text-accent" />
<span className="font-bold text-xs text-main">{folderName}</span>
<span className="badge-neutral text-[10px] font-mono">
{openCount} offen
</span>
</div>
<button
type="button"
onClick={() => setTodoFolderDrilldown(folderName)}
className="text-[10px] text-muted hover:text-main px-1.5 py-0.5 rounded-btn hover:bg-panel-elevated cursor-pointer"
title="Diesen Ordner isolieren"
>
Nur diesen Ordner
</button>
</div>
{/* Folder Notes Subgroups */}
{!isCollapsed && (
<div className="p-2 space-y-3">
{Array.from(docGroupsMap.values()).map(({ doc, items: docItems }) => {
const docOpenCount = docItems.filter((i) => !i.checked).length;
const isDocCollapsed = collapsedTodoNotes.has(doc.id);
return (
<div
key={doc.id}
className="border border-border/60 rounded-btn p-2 bg-panel-elevated/40 space-y-1.5"
>
<div className="flex items-center justify-between pb-1 border-b border-border/40">
<div
onClick={() => {
setCollapsedTodoNotes((prev) => {
const next = new Set(prev);
if (next.has(doc.id)) next.delete(doc.id);
else next.add(doc.id);
return next;
});
}}
className="flex items-center gap-1.5 text-xs font-semibold text-main hover:text-accent cursor-pointer select-none"
>
{isDocCollapsed ? (
<ChevronRight className="w-3 h-3 text-faint" />
) : (
<ChevronDown className="w-3 h-3 text-accent" />
)}
<span>{doc.icon || '📝'}</span>
<span>{doc.title}</span>
<span className="badge-neutral text-[9px] font-mono">
{docOpenCount} offen
</span>
</div>
<button
type="button"
onClick={() => handleJumpToDocBlock(doc.id)}
className="text-[10px] text-accent hover:underline px-1.5 py-0.5 rounded-btn hover:bg-panel cursor-pointer flex items-center gap-1"
title="Notiz öffnen"
>
<span>Notiz öffnen</span>
<ExternalLink className="w-2.5 h-2.5" />
</button>
</div>
{!isDocCollapsed && (
<div className="space-y-0.5">
{docItems.map((item) => (
<div
key={`${item.doc.id}-${item.blockId}`}
className="flex items-center justify-between gap-2 p-1 rounded-btn hover:bg-panel-elevated text-xs transition-colors group cursor-pointer"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleToggleRollupTodo(
item.doc,
item.blockId,
item.checked
);
}}
className={`w-3.5 h-3.5 checkbox-square flex items-center justify-center transition-colors border cursor-pointer ${
item.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
<span
onClick={() =>
handleJumpToDocBlock(item.doc.id, item.blockId)
}
className={`truncate flex-1 ${
item.checked
? 'line-through text-faint'
: 'text-main font-medium group-hover:text-accent'
}`}
>
{item.content}
</span>
</div>
<button
type="button"
onClick={() =>
handleJumpToDocBlock(item.doc.id, item.blockId)
}
className="text-faint hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity p-1"
title="Zu dieser Zeile springen"
>
<ExternalLink className="w-3 h-3" />
</button>
</div>
))}
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-editor">
<FileText className="w-10 h-10 text-faint mb-2" />
<h3 className="text-sm font-semibold text-main mb-1">
Keine Notiz ausgewählt
</h3>
<p className="text-xs text-muted max-w-sm mb-3">
{isSidebarCollapsed
? 'Die Ordnerleiste ist eingeklappt. Blende sie ein oder erstelle eine neue Notiz.'
: 'Wähle links in der Seitenleiste eine bestehende Notiz aus oder erstelle eine neue.'}
</p>
<div className="flex items-center gap-2">
{isSidebarCollapsed && (
<button
type="button"
onClick={toggleSidebar}
className="btn-action inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold cursor-pointer"
>
<PanelLeftOpen className="w-3.5 h-3.5" />
<span>Ordner anzeigen</span>
</button>
)}
<button
type="button"
onClick={() => handleCreateDocument('')}
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold"
>
<Plus className="w-3.5 h-3.5" />
<span>Notiz erstellen</span>
</button>
</div>
</div>
)}
</main>
{/* New Folder Popover (small & positioned near click, asks only for name & OK) */}
{showNewFolderModal && (
<FormSheet
title={newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'}
icon={<FolderPlus className="w-4 h-4 text-accent" />}
onClose={() => setShowNewFolderModal(false)}
onSubmit={handleCreateFolderSubmit}
anchor={newFolderCoords}
>
<input
type="text"
autoFocus
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder="Ordnername"
className="input-field w-full h-11 sm:h-8 px-2.5 text-base sm:text-xs"
/>
</FormSheet>
)}
{mergeDialog && (
<MergeDialog
docs={docs}
sourceId={mergeDialog.sourceId}
targetId={mergeDialog.targetId}
onClose={() => setMergeDialog(null)}
onMerged={(r) => void handleMerged(r)}
/>
)}
{toast && (
<div
role="status"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-6 z-50 max-w-[calc(100vw-1.5rem)] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel flex items-center gap-3"
>
<span>{toast.text}</span>
{toast.actions.map((a) => (
<button
key={a.label}
type="button"
onClick={() => {
setToast(null);
a.run();
}}
className="font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0 shrink-0"
>
{a.label}
</button>
))}
</div>
)}
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
{(shareModalDoc || shareModalFolder) && (
<UnifiedShareModal
isOpen={true}
onClose={() => {
setShareModalDoc(null);
setShareModalFolder(null);
}}
resourceName={shareModalDoc ? shareModalDoc.title : shareModalFolder?.name || ''}
resourceIcon={shareModalDoc ? shareModalDoc.icon : '📁'}
isOwner={
shareModalDoc
? (shareModalDoc.isOwner || shareModalDoc.permission === 'owner')
: Boolean(shareModalFolder?.isOwner)
}
currentUserId={currentUser?.id}
shares={unifiedShares}
teams={teams}
collaborators={collaborators}
onAddShare={async (targetType, targetId, role) => {
const permission = role === 'editor' ? 'write' : 'read';
if (shareModalDoc) {
await api.createDocShare(
shareModalDoc.id,
targetType === 'team' ? { teamId: targetId, permission } : { userId: targetId, permission }
);
const updatedShares = await api.getDocShares(shareModalDoc.id);
setActiveDocShares(updatedShares);
} else if (shareModalFolder) {
await api.createFolderShare(shareModalFolder.id, {
targetType,
targetId,
permission,
});
const updatedFolder = await api.getSharedFolder(shareModalFolder.id);
setShareModalFolder(updatedFolder);
}
}}
onRemoveShare={async (shareId) => {
if (shareModalDoc) {
await api.deleteDocShare(shareModalDoc.id, shareId);
const updatedShares = await api.getDocShares(shareModalDoc.id);
setActiveDocShares(updatedShares);
} else if (shareModalFolder) {
await api.deleteFolderShare(shareModalFolder.id, shareId);
const updatedFolder = await api.getSharedFolder(shareModalFolder.id);
setShareModalFolder(updatedFolder);
}
}}
/>
)}
</div>
</div>
);
};