MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m32s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2856 lines
119 KiB
TypeScript
2856 lines
119 KiB
TypeScript
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||
import {
|
||
FilePlus,
|
||
FolderPlus,
|
||
Folder,
|
||
FolderOpen,
|
||
ChevronRight,
|
||
ChevronDown,
|
||
Search,
|
||
Pin,
|
||
Trash2,
|
||
Check,
|
||
Hash,
|
||
FileText,
|
||
Users,
|
||
Plus,
|
||
X,
|
||
ExternalLink,
|
||
Sparkles,
|
||
ArrowLeft,
|
||
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-11 sm:h-7 rounded-btn text-sm sm: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 min-h-11 sm:min-h-0 rounded-btn text-left text-sm sm: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]);
|
||
|
||
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]);
|
||
|
||
// Register mobile FAB actions and the menu at the top; on phones the menu also switches between
|
||
// notes, to-dos and hashtags (like the lists in Tasks and Shopping lists), the tab row is hidden there
|
||
useEffect(() => {
|
||
setPrimaryAction(() => handleCreateDocument(''));
|
||
setGroups([
|
||
{
|
||
id: 'workspace-views',
|
||
title: 'Ansicht',
|
||
items: [
|
||
{
|
||
id: 'view-notes',
|
||
label: 'Notizen',
|
||
icon: <FileText className="w-4 h-4" />,
|
||
active: subView === 'editor' || subView === 'journal' || subView === 'trash',
|
||
onClick: () => {
|
||
setSubView('editor');
|
||
setMobileShowDetail(false);
|
||
},
|
||
},
|
||
{
|
||
id: 'view-todos',
|
||
label: 'To-Dos',
|
||
icon: <Check className="w-4 h-4" />,
|
||
badge: totalOpenTodos || undefined,
|
||
active: subView === 'todos',
|
||
onClick: () => {
|
||
setSubView('todos');
|
||
setMobileShowDetail(true);
|
||
},
|
||
},
|
||
{
|
||
id: 'view-tags',
|
||
label: 'Hashtags',
|
||
icon: <Hash className="w-4 h-4" />,
|
||
badge: tagsSummary.length || undefined,
|
||
active: subView === 'tags' || subView === 'tag_detail',
|
||
onClick: () => {
|
||
setSubView('tags');
|
||
setMobileShowDetail(true);
|
||
},
|
||
},
|
||
],
|
||
},
|
||
{
|
||
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, subView, totalOpenTodos, tagsSummary.length]);
|
||
|
||
// 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 */}
|
||
{/* Phones: the views are switched in the menu at the top, so the row is hidden there */}
|
||
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
||
{/* Workspace selector tabs & left sidebar toggle */}
|
||
<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={`chip-btn 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={`chip-btn 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={`chip-btn 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={`chip-btn 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 max-lg:animate-in max-lg:slide-in-from-left-1/4 max-lg:fade-in max-lg: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. Only from lg on:
|
||
below, the journal is a view of its own and the button stays a plain entry like the folders
|
||
(otherwise it stayed half highlighted after "back"). */}
|
||
{journalEnabled && (
|
||
<div>
|
||
<div
|
||
className={`rounded-card border-2 transition-[background-color,border-color,padding] duration-300 ease-out ${
|
||
subView === 'journal' ? 'border-transparent p-0 lg:bg-panel-elevated lg: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 h-11 sm:h-8 px-2 text-sm sm:text-xs text-main hover:bg-panel-elevated ${
|
||
subView === 'journal' ? 'lg:h-7 lg:px-0.5 lg:text-sm lg:font-bold lg:hover:bg-transparent' : ''
|
||
}`}
|
||
>
|
||
<CalendarDays
|
||
className={`text-accent transition-all duration-300 w-3.5 h-3.5 ${subView === 'journal' ? 'lg:w-4 lg:h-4' : ''}`}
|
||
/>
|
||
<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-100 lg: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 min-h-11 sm:min-h-0 rounded-btn text-left text-sm sm: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 min-h-11 sm:min-h-0 rounded-btn text-left text-sm sm: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 max-lg:animate-in max-lg:slide-in-from-right-1/3 max-lg:fade-in max-lg:duration-200`}
|
||
>
|
||
{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-[repeat(auto-fill,minmax(min(100%,18rem),1fr))] gap-3.5">
|
||
{/* As many columns as fit with at least 18rem each, so tag names and note titles have room */}
|
||
{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' ? (
|
||
/* Each nesting level lays a light tint of the text colour on top: darker per level in
|
||
light themes, lighter per level in dark ones */
|
||
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-editor">
|
||
<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-main/[0.04] 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-main/[0.03]">
|
||
<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-main/[0.03] 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-main/[0.06] 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-main/[0.06] 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-main/[0.03] overflow-hidden"
|
||
>
|
||
{/* Folder Header */}
|
||
<div className="p-2 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-main/[0.06] 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-main/[0.04] 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-main/[0.06] 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>
|
||
);
|
||
};
|