From fe1d574787bb503b118429e39fcef1e8b1cbe4db Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 00:15:43 +0200 Subject: [PATCH] feat(workspace): add logseq outliner engine, global todo rollup, task list sharing avatars and admin toggle --- BRAIN.md | 29 + .../features/settings/AdminSettingsView.tsx | 46 +- frontend/src/features/triage/TasksView.tsx | 217 +- .../src/features/workspace/WorkspaceView.tsx | 1888 +++++++++++------ frontend/src/lib/types.ts | 5 + go.mod | 16 + go.sum | 59 + internal/domain/models.go | 33 +- internal/storage/sqlite.go | 43 +- 9 files changed, 1604 insertions(+), 732 deletions(-) create mode 100644 go.sum diff --git a/BRAIN.md b/BRAIN.md index fba0b47..41ced99 100644 --- a/BRAIN.md +++ b/BRAIN.md @@ -49,6 +49,29 @@ - Word-/Notion-artige Formatierungsleiste oben (Fett, Kursiv, Durchgestrichen, Code, Links, Überschriften H1-H3, Checklisten, Aufzählungen, Callouts, Markdown-Tabellen, Fortschrittsbalken). - Interne Wiki-Links (`[[Dokumenttitel]]`) mit Klick-Navigation und Auto-Erkennung. - Physische Speicherung auf Festplatte unter `/data/workspace//...` als Standard-`.md`-Dateien zur einfachen Daten-Extraktion, synchronisiert mit SQLite. +- [x] **Workspace Logseq Outliner-Engine, Zoom-Hoisting & Drag & Drop (`WorkspaceView.tsx`, `models.go`, `sqlite.go`):** + - Bullet-Point-basiertes hierarchisches Notizensystem inspiriert von Logseq. + - Jeder Block ist ein Bullet-Point (`•`) mit flexibler Einrückungstiefe (`Level: 0, 1, 2...`). + - Schnelle Tastaturnavigation: + - `Enter`: Neuer Bullet-Point auf gleicher Ebene. + - `Tab`: Block dem vorherigen unterordnen (einrücken). + - `Shift+Tab`: Block ausrücken. + - `Shift+Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks. + - `Backspace`: Auf leerem Block rückt aus oder entfernt den Bullet-Point. + - **Zoom-in Hoisting:** Klick auf den Bullet-Dot zoomt tief in diesen Ast hinein (Fokusmodus) mit Breadcrumbs-Navigation und „Zoom aufheben"-Button. + - **Drag & Drop:** Bullet-Dots dienen als Anfasser zum Verschieben und Umsortieren von Blöcken und ganzen Unterbäumen. + - **Slash-Commands (`/`):** Schnelle Umwandlung in `/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/table`, `/callout`, `/progress`, `/divider`. + - Physische Speicherung auf Festplatte unter `/data/workspace//...` als Standard-`.md`-Dateien mit portabler 2-Leerzeichen-Einrückung (` - [ ] ` bzw. ` - `), vollständig kompatibel mit externen Outlinern und Markdown-Viewern. +- [x] **Globaler Workspace To-Do Rollup Dashboard (`WorkspaceView.tsx`):** + - Aggregierte Übersicht aller To-Dos (`- [ ] ` / `- [x] `) aus sämtlichen Notiz-Dateien über alle Ordner hinweg. + - Übersichtliche Gruppierung nach Ordner ➔ Dokument ➔ Aufgaben mit einklappbaren Akkordeons und Zähler für offene Aufgaben. + - **Ordner-Drilldown:** Klick auf einen Ordner isoliert nur diesen Zweig für fokussiertes Abarbeiten, inklusive Zurücksetzen-Filter. + - **Live 2-Wege-Sync:** Abhaken oder Aktivieren eines To-Dos im Rollup synchronisiert direkt in Echtzeit zurück in die SQLite-Datenbank und die physische `.md`-Datei auf Festplatte. +- [x] **Aufgabenlisten-Teilen & Avatar-Stack (`TasksView.tsx`, `models.go`, `sqlite.go`):** + - Aufgabenlisten können direkt mit Teams/Spaces geteilt werden. + - Avatar-Stack (farbige Initialen-Bubbles mit Hover-Tooltips) im Header der Liste und in den Tabs visualisiert, wem eine Liste gehört und mit welchen Teammitgliedern sie geteilt ist. +- [x] **Admin-Schalter für öffentliche Selbstregistrierung (`AdminSettingsView.tsx`):** + - Unintuitiver Button in den Admin-Einstellungen unter Server & Isolation durch einen flüssig animierten Schiebeschalter (`rounded-pill`) mit eindeutigen Status-Beschriftungen ersetzt. - [x] **Cross-Browser & Performance Fixes (Chrome & Firefox):** - SQLite Connection-Pool Deadlock durch striktes Schließen von `sql.Rows` vor Unterabfragen behoben. - `Cache-Control: no-store` auf allen API-Routen und `index.html`. @@ -268,6 +291,12 @@ Verbindungs-URL: `ws(s):///api/v1/ws/spaces/:spaceId?token=` | `T` | Triage-Deck | Datums-Auswahl für Termin-Aufgabe öffnen | | `D` | Triage-Deck | Aktiven Quick Dump sofort in Workspace-Notiz umwandeln | | `Leertaste` | Triage-Deck | Karte überspringen („Später" – ans Ende des Stapels schieben) | +| `Enter` | Workspace Outliner | Neuen Bullet-Point auf gleicher Ebene erstellen | +| `Tab` | Workspace Outliner | Bullet-Point einrücken (dem vorherigen unterordnen) | +| `Shift + Tab` | Workspace Outliner | Bullet-Point ausrücken (Ebene verringern) | +| `Shift + Enter` | Workspace Outliner | Weicher Zeilenumbruch innerhalb des aktuellen Blocks | +| `Backspace` | Workspace Outliner | Bei leerem Block: Ebene ausrücken bzw. Bullet löschen | +| `/` | Workspace Outliner | Slash-Command-Menü öffnen (/todo, /h1, /code, etc.) | --- diff --git a/frontend/src/features/settings/AdminSettingsView.tsx b/frontend/src/features/settings/AdminSettingsView.tsx index 5bacd8e..a344b11 100644 --- a/frontend/src/features/settings/AdminSettingsView.tsx +++ b/frontend/src/features/settings/AdminSettingsView.tsx @@ -1126,30 +1126,40 @@ export const AdminSettingsView: React.FC = ({ - {/* Public Registration Toggle */} + {/* Public Registration Toggle Switch */}
-
+

Öffentliche Selbstregistrierung erlauben

- Wenn deaktiviert, können sich Fremde auf dem Login-Bildschirm nicht registrieren. - Neue Benutzer benötigen zwingend einen Einladungscode oder müssen von einem Admin - angelegt werden. + Wenn deaktiviert, können sich fremde Personen auf dem Login-Bildschirm nicht selbst registrieren. + Neue Konten erfordern dann zwingend einen Einladungscode oder müssen von einem Admin angelegt werden.

- +
+ + +
diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 85dd4b5..d48ea80 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -14,10 +14,20 @@ import { X, Layers, Share2, + Users, } from 'lucide-react'; import { TaskList, Thought, Space } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; +const getInitials = (name?: string): string => { + if (!name) return '?'; + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return name.slice(0, 2).toUpperCase(); +}; + interface TasksViewProps { onRefreshStats: () => void; onOpenDocument: (docId: string) => void; @@ -48,6 +58,11 @@ export const TasksView: React.FC = ({ const [newListIcon, setNewListIcon] = useState('📋'); const [newListSpaceId, setNewListSpaceId] = useState(''); + // Share TaskList modal state + const [showShareListModal, setShowShareListModal] = useState(false); + const [sharingTaskList, setSharingTaskList] = useState(null); + const [shareTargetSpaceId, setShareTargetSpaceId] = useState(''); + // Active notes inline edit ID const [activeNotesTaskId, setActiveNotesTaskId] = useState(null); const [notesDraft, setNotesDraft] = useState(''); @@ -195,6 +210,30 @@ export const TasksView: React.FC = ({ } }; + const handleOpenShareModal = (tl: TaskList) => { + setSharingTaskList(tl); + setShareTargetSpaceId(tl.spaceId || ''); + setShowShareListModal(true); + }; + + const handleSaveShareList = async () => { + if (!sharingTaskList) return; + triggerHaptic(15); + try { + const updated = await api.updateTaskList(sharingTaskList.id, { + spaceId: shareTargetSpaceId, + }); + setTaskLists((prev) => + prev.map((l) => (l.id === updated.id ? { ...l, ...updated } : l)) + ); + setShowShareListModal(false); + setSharingTaskList(null); + await loadData(); + } catch (err: any) { + alert(err.message || 'Fehler beim Speichern der Freigabe'); + } + }; + const safeTasks = Array.isArray(tasks) ? tasks : []; const safeTaskLists = Array.isArray(taskLists) ? taskLists : []; const safeSpaces = Array.isArray(spaces) ? spaces : []; @@ -245,11 +284,19 @@ export const TasksView: React.FC = ({ {tl.tasksCount !== undefined && tl.tasksCount > 0 && ( {tl.tasksCount} )} - {tl.spaceId && ( + {tl.members && tl.members.length > 0 ? ( +
+ {tl.members.slice(0, 2).map((m) => ( + + {getInitials(m.displayName || m.email)[0]} + + ))} +
+ ) : tl.spaceId ? ( - )} + ) : null} {selectedListId === tl.id && ( - {/* View Switcher: List vs Kanban */} -
+ {/* Right Header Section: Sharing, Avatar Stack & View Switcher */} +
+ {currentSelectedList && ( +
+ + + {/* Avatar Stack in the corner */} + {currentSelectedList.members && currentSelectedList.members.length > 0 ? ( +
handleOpenShareModal(currentSelectedList)} + className="flex items-center -space-x-1.5 cursor-pointer hover:opacity-90 transition-opacity pl-0.5" + title={`Geteilt mit ${currentSelectedList.members.length} Mitgliedern (${currentSelectedList.spaceName || 'Team'}). Klick zum Verwalten.`} + > + {currentSelectedList.members.slice(0, 3).map((m) => ( +
+ {getInitials(m.displayName || m.email)} +
+ ))} + {currentSelectedList.members.length > 3 && ( +
+ +{currentSelectedList.members.length - 3} +
+ )} +
+ ) : ( +
handleOpenShareModal(currentSelectedList)} + className="flex items-center cursor-pointer pl-0.5" + title="Privat (nur du). Klick zum Teilen." + > +
+ 🔒 +
+
+ )} +
+ )} + + {/* View Switcher: List vs Kanban */}
)} + {/* SHARE LIST MODAL */} + {showShareListModal && sharingTaskList && ( +
+
+
+

+ + Liste teilen: {sharingTaskList.title} +

+ +
+ +
+
+ + +
+ + {/* Members preview */} + {(() => { + const selectedSpace = safeSpaces.find((s) => s.id === shareTargetSpaceId); + const members = selectedSpace?.members || []; + if (members.length > 0) { + return ( +
+

+ Personen mit Zugriff ({members.length}): +

+
+ {members.map((m) => ( +
+
+
+ {getInitials(m.displayName || m.email)} +
+
+

{m.displayName || m.email}

+

{m.email}

+
+
+ + {m.role === 'owner' ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Mitglied'} + +
+ ))} +
+
+ ); + } else if (shareTargetSpaceId) { + return ( +

Keine Mitglieder in diesem Bereich.

+ ); + } else { + return ( +
+

Diese Aufgabenliste ist privat.

+

+ Wähle oben einen Familien- oder Team-Space aus, um die Liste und alle enthaltenen Aufgaben automatisch mit den Mitgliedern zu teilen. +

+
+ ); + } + })()} +
+ +
+ + +
+
+
+ )} + {/* VIEW MODES: LIST OR KANBAN */} {viewMode === 'kanban' ? ( /* KANBAN BOARD (3 COLUMNS) */ diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index e1b5725..a3633aa 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -11,7 +11,9 @@ import { Trash2, Download, CheckSquare, + Heading1, Heading2, + Heading3, List, Code, Sparkles, @@ -28,6 +30,14 @@ import { Percent, Minus, ExternalLink, + ArrowLeft, + GripVertical, + Layers, + Filter, + CheckCircle2, + Circle, + FolderTree, + Plus, } from 'lucide-react'; import { BlockType, Document, DocumentBlock } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; @@ -47,10 +57,33 @@ interface FolderTreeItem { docs: Document[]; } +interface SlashCommandItem { + type: BlockType; + label: string; + desc: string; + icon: string; +} + +const SLASH_COMMANDS: SlashCommandItem[] = [ + { type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: '☑️' }, + { type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: 'H1' }, + { type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: 'H2' }, + { type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: 'H3' }, + { type: 'bullet', label: '/bullet', desc: 'Standard Listenpunkt', icon: '•' }, + { type: 'callout', label: '/callout', desc: 'Hervorgehobene Hinweisbox', icon: '💡' }, + { type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: '💻' }, + { type: 'table', label: '/table', desc: 'Markdown-Tabelle', icon: '📊' }, + { type: 'progress', label: '/progress', desc: 'Fortschrittsbalken', icon: '📈' }, + { type: 'divider', label: '/divider', desc: 'Horizontale Trennlinie', icon: '—' }, +]; + export const WorkspaceView: React.FC = ({ initialDocId, onStatsChange, }) => { + // Top Workspace Tab Mode: 'editor' vs 'todos' (Global To-Do Rollup Dashboard) + const [workspaceMode, setWorkspaceMode] = useState<'editor' | 'todos'>('editor'); + const [docs, setDocs] = useState([]); const [selectedDoc, setSelectedDoc] = useState(null); const [searchQuery, setSearchQuery] = useState(''); @@ -75,6 +108,24 @@ export const WorkspaceView: React.FC = ({ // Active focused block ref const [lastFocusedBlockId, setLastFocusedBlockId] = useState(null); + // --- LOGSEQ OUTLINER SPECIFIC STATE --- + // Zoom-in / Focus mode (Hoisting to a bullet) + const [zoomedBlockId, setZoomedBlockId] = useState(null); + // Floating Slash Command menu + const [slashBlockId, setSlashBlockId] = useState(null); + const [slashSearch, setSlashSearch] = useState(''); + const [slashSelectedIndex, setSlashSelectedIndex] = useState(0); + + // Drag and Drop reordering state + const [draggingBlockId, setDraggingBlockId] = useState(null); + + // --- GLOBAL TO-DO ROLLUP STATE --- + const [todoFilter, setTodoFilter] = useState<'all' | 'open' | 'done'>('open'); + const [todoFolderDrilldown, setTodoFolderDrilldown] = useState(null); + const [collapsedTodoFolders, setCollapsedTodoFolders] = useState>(new Set()); + + const blockInputRefs = useRef>({}); + const loadDocs = async (selectId?: string | null) => { const list = await api.getDocuments(); setDocs(list); @@ -149,31 +200,28 @@ export const WorkspaceView: React.FC = ({ const handleCreateDoc = async (folderPath?: string | null) => { triggerHaptic(15); - const tags = ['projekt']; + const tags = ['notiz']; const path = folderPath || ''; if (path) { tags.push(`folder:${path}`); } const created = await api.createDocument({ - title: 'Neues Projekt / Notiz', + title: 'Neuer Gedanke / Notiz', icon: '💡', folderPath: path, blocks: [ { id: `blk_${Date.now()}_1`, - type: 'callout', - content: 'Kernidee oder Ziel dieses Projekts kurz zusammenfassen…', + type: 'bullet', + content: 'Erster Gedanke…', + level: 0, }, { id: `blk_${Date.now()}_2`, - type: 'h2', - content: 'Nächste Schritte', - }, - { - id: `blk_${Date.now()}_3`, type: 'todo', - content: 'Ersten Meilenstein definieren', + content: 'Nächste Aufgabe', checked: false, + level: 1, }, ], tags, @@ -182,6 +230,7 @@ export const WorkspaceView: React.FC = ({ await loadDocs(created.id); onStatsChange(); + setWorkspaceMode('editor'); }; const handleDeleteDoc = async (id: string) => { @@ -252,25 +301,84 @@ export const WorkspaceView: React.FC = ({ }); }; - const addBlock = (type: BlockType, afterIndex?: number, defaultContent = '') => { + // ============================================================================ + // LOGSEQ OUTLINER CORE LOGIC + // ============================================================================ + + // Helper: check if a block has children (i.e. next block has greater level) + const hasChildren = (blocks: DocumentBlock[], index: number): boolean => { + if (index + 1 >= blocks.length) return false; + const currentLevel = blocks[index].level || 0; + return (blocks[index + 1].level || 0) > currentLevel; + }; + + // Insert a new bullet immediately after index, inheriting level + const insertNewBulletAfter = (afterIndex: number, level: number, type: BlockType = 'bullet') => { if (!selectedDoc) return; const newBlock: DocumentBlock = { id: `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, type, - content: defaultContent, + content: '', + level, checked: false, - lang: type === 'code' ? 'bash' : undefined, }; const nextBlocks = [...selectedDoc.blocks]; - if (afterIndex !== undefined && afterIndex >= 0) { - nextBlocks.splice(afterIndex + 1, 0, newBlock); - } else { - nextBlocks.push(newBlock); - } - + nextBlocks.splice(afterIndex + 1, 0, newBlock); persistDocument({ ...selectedDoc, blocks: nextBlocks }); setLastFocusedBlockId(newBlock.id); + + // Focus into the new element next tick + setTimeout(() => { + const el = blockInputRefs.current[newBlock.id]; + if (el) { + el.focus(); + } + }, 20); + }; + + // Indent block (Tab): increase level by 1 + const indentBlock = (index: number) => { + if (!selectedDoc || index === 0) return; + const block = selectedDoc.blocks[index]; + const prevBlock = selectedDoc.blocks[index - 1]; + const currentLevel = block.level || 0; + const maxLevel = (prevBlock.level || 0) + 1; + if (currentLevel < maxLevel) { + updateBlock(block.id, { level: currentLevel + 1 }); + } + }; + + // Outdent block (Shift+Tab): decrease level by 1 + const outdentBlock = (blockId: string) => { + if (!selectedDoc) return; + const block = selectedDoc.blocks.find((b) => b.id === blockId); + if (!block) return; + const currentLevel = block.level || 0; + if (currentLevel > 0) { + updateBlock(blockId, { level: currentLevel - 1 }); + } + }; + + // Delete block and focus the preceding one + const deleteBlockAndFocusPrev = (index: number) => { + if (!selectedDoc || selectedDoc.blocks.length <= 1) return; + const prevBlock = selectedDoc.blocks[index - 1] || selectedDoc.blocks[index + 1]; + const targetId = selectedDoc.blocks[index].id; + removeBlock(targetId); + if (prevBlock) { + setLastFocusedBlockId(prevBlock.id); + setTimeout(() => { + const el = blockInputRefs.current[prevBlock.id]; + if (el) { + el.focus(); + // Put cursor at end of text + if ('setSelectionRange' in el) { + el.setSelectionRange(el.value.length, el.value.length); + } + } + }, 20); + } }; const updateBlock = (blockId: string, patch: Partial) => { @@ -287,79 +395,233 @@ export const WorkspaceView: React.FC = ({ persistDocument({ ...selectedDoc, blocks: nextBlocks }); }; - const moveBlock = (index: number, direction: -1 | 1) => { + const toggleCollapse = (blockId: string) => { if (!selectedDoc) return; - const targetIdx = index + direction; - if (targetIdx < 0 || targetIdx >= selectedDoc.blocks.length) return; - const nextBlocks = [...selectedDoc.blocks]; - const [moved] = nextBlocks.splice(index, 1); - nextBlocks.splice(targetIdx, 0, moved); - persistDocument({ ...selectedDoc, blocks: nextBlocks }); + const block = selectedDoc.blocks.find((b) => b.id === blockId); + if (block) { + updateBlock(blockId, { collapsed: !block.collapsed }); + } }; - // --- TOP TOOLBAR FORMATTING HELPERS --- - const applyInlineFormat = (prefix: string, suffix = prefix) => { - if (!selectedDoc) return; - const targetBlockId = lastFocusedBlockId || selectedDoc.blocks[selectedDoc.blocks.length - 1]?.id; - if (!targetBlockId) return; + // Keyboard Navigation: Enter, Tab, Shift+Tab, Backspace, / Slash Commands + const handleKeyDown = ( + e: React.KeyboardEvent, + block: DocumentBlock, + index: number + ) => { + if (slashBlockId === block.id) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSlashSelectedIndex((prev) => (prev + 1) % filteredSlashCommands.length); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSlashSelectedIndex((prev) => + prev === 0 ? filteredSlashCommands.length - 1 : prev - 1 + ); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + const selected = filteredSlashCommands[slashSelectedIndex]; + if (selected) { + applySlashCommand(block.id, selected.type); + } + return; + } + if (e.key === 'Escape') { + setSlashBlockId(null); + return; + } + } - const block = selectedDoc.blocks.find((b) => b.id === targetBlockId); + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + setSlashBlockId(null); + insertNewBulletAfter(index, block.level || 0); + } else if (e.key === 'Tab') { + e.preventDefault(); + if (e.shiftKey) { + outdentBlock(block.id); + } else { + indentBlock(index); + } + } else if (e.key === 'Backspace' && (!block.content || block.content === '')) { + if ((block.level || 0) > 0) { + e.preventDefault(); + outdentBlock(block.id); + } else if (selectedDoc?.blocks && selectedDoc.blocks.length > 1) { + e.preventDefault(); + deleteBlockAndFocusPrev(index); + } + } + }; + + const handleContentChange = (block: DocumentBlock, newContent: string) => { + updateBlock(block.id, { content: newContent }); + + // Check if user just typed '/' + if (newContent.endsWith('/') || newContent.includes('/')) { + const match = newContent.match(/\/([a-z0-9]*)$/i); + if (match) { + setSlashBlockId(block.id); + setSlashSearch(match[1]); + setSlashSelectedIndex(0); + return; + } + } + if (slashBlockId === block.id) { + setSlashBlockId(null); + } + }; + + const filteredSlashCommands = useMemo(() => { + if (!slashSearch) return SLASH_COMMANDS; + const q = slashSearch.toLowerCase(); + return SLASH_COMMANDS.filter( + (c) => c.label.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q) + ); + }, [slashSearch]); + + const applySlashCommand = (blockId: string, type: BlockType) => { + if (!selectedDoc) return; + const block = selectedDoc.blocks.find((b) => b.id === blockId); if (!block) return; - - const newContent = `${block.content} ${prefix}Text${suffix}`; - updateBlock(targetBlockId, { content: newContent }); + const cleanContent = block.content.replace(/\/([a-z0-9]*)$/i, '').trim(); + updateBlock(blockId, { type, content: cleanContent, checked: type === 'todo' ? false : undefined }); + setSlashBlockId(null); + setSlashSearch(''); }; - const insertWikiLink = (targetDocTitle: string) => { - if (!selectedDoc) return; - const targetBlockId = lastFocusedBlockId || selectedDoc.blocks[selectedDoc.blocks.length - 1]?.id; - if (!targetBlockId) return; - - const block = selectedDoc.blocks.find((b) => b.id === targetBlockId); - if (!block) return; - - const newContent = `${block.content} [[${targetDocTitle}]]`; - updateBlock(targetBlockId, { content: newContent }); - setShowWikiLinkMenu(false); + // Drag & Drop Handlers on the Bullet Dot + const handleDragStart = (e: React.DragEvent, blockId: string) => { + e.dataTransfer.setData('text/plain', blockId); + setDraggingBlockId(blockId); }; + const handleDropOnBlock = (e: React.DragEvent, targetBlockId: string) => { + e.preventDefault(); + const sourceId = e.dataTransfer.getData('text/plain') || draggingBlockId; + if (!selectedDoc || !sourceId || sourceId === targetBlockId) { + setDraggingBlockId(null); + return; + } + + const blocks = [...selectedDoc.blocks]; + const sourceIdx = blocks.findIndex((b) => b.id === sourceId); + const targetIdx = blocks.findIndex((b) => b.id === targetBlockId); + if (sourceIdx === -1 || targetIdx === -1) { + setDraggingBlockId(null); + return; + } + + const [moved] = blocks.splice(sourceIdx, 1); + // Align moved block's level to target + moved.level = blocks[targetIdx]?.level || 0; + blocks.splice(targetIdx, 0, moved); + + persistDocument({ ...selectedDoc, blocks }); + setDraggingBlockId(null); + }; + + // Zoom-in / Hoisting Calculation + const { visibleBlocks, breadcrumbBlocks, zoomedBlock } = useMemo(() => { + if (!selectedDoc) return { visibleBlocks: [], breadcrumbBlocks: [], zoomedBlock: null }; + const all = selectedDoc.blocks; + + if (!zoomedBlockId) { + // Normal view: filter out children of collapsed blocks + const list: { block: DocumentBlock; originalIndex: number }[] = []; + let hideUntilLevel = -1; + + for (let i = 0; i < all.length; i++) { + const b = all[i]; + const lvl = b.level || 0; + + if (hideUntilLevel !== -1) { + if (lvl > hideUntilLevel) { + continue; // Skip collapsed child + } else { + hideUntilLevel = -1; // End of collapsed subtree + } + } + + list.push({ block: b, originalIndex: i }); + + if (b.collapsed) { + hideUntilLevel = lvl; + } + } + + return { visibleBlocks: list, breadcrumbBlocks: [], zoomedBlock: null }; + } + + // Zoomed view: find zoomed block and its descendants + const zoomIdx = all.findIndex((b) => b.id === zoomedBlockId); + if (zoomIdx === -1) { + return { visibleBlocks: [], breadcrumbBlocks: [], zoomedBlock: null }; + } + + const target = all[zoomIdx]; + const targetLevel = target.level || 0; + + // Build breadcrumbs: find parents with strictly decreasing levels + const crumbs: DocumentBlock[] = []; + let curLevel = targetLevel; + for (let i = zoomIdx - 1; i >= 0; i--) { + const b = all[i]; + const lvl = b.level || 0; + if (lvl < curLevel) { + crumbs.unshift(b); + curLevel = lvl; + } + } + + // Descendants of zoomed block + const descendants: { block: DocumentBlock; originalIndex: number }[] = []; + for (let i = zoomIdx + 1; i < all.length; i++) { + const b = all[i]; + if ((b.level || 0) > targetLevel) { + descendants.push({ block: b, originalIndex: i }); + } else { + break; + } + } + + return { visibleBlocks: descendants, breadcrumbBlocks: crumbs, zoomedBlock: target }; + }, [selectedDoc, zoomedBlockId]); + + // Export full document as portable Logseq-indented Markdown const handleExportMarkdown = () => { if (!selectedDoc) return; const lines: string[] = [`# ${selectedDoc.icon} ${selectedDoc.title}`, '']; for (const b of selectedDoc.blocks) { + const indent = ' '.repeat(b.level || 0); switch (b.type) { case 'h1': - lines.push(`# ${b.content}`, ''); + lines.push(`${indent}- # ${b.content}`); break; case 'h2': - lines.push(`## ${b.content}`, ''); + lines.push(`${indent}- ## ${b.content}`); break; case 'h3': - lines.push(`### ${b.content}`, ''); + lines.push(`${indent}- ### ${b.content}`); break; case 'todo': - lines.push(`- [${b.checked ? 'x' : ' '}] ${b.content}`); - break; - case 'bullet': - lines.push(`- ${b.content}`); + lines.push(`${indent}- [${b.checked ? 'x' : ' '}] ${b.content}`); break; case 'callout': - lines.push(`> 💡 ${b.content}`, ''); + lines.push(`${indent}- > 💡 ${b.content}`); break; case 'code': - lines.push('```' + (b.lang || ''), b.content, '```', ''); + lines.push(`${indent}- \`\`\`${b.lang || ''}\n${indent} ${b.content}\n${indent} \`\`\``); break; case 'table': - lines.push(b.content, ''); - break; - case 'progress': - lines.push(``, ''); - break; - case 'divider': - lines.push('---', ''); - break; + case 'bullet': + case 'paragraph': default: - lines.push(b.content, ''); + lines.push(`${indent}- ${b.content}`); } } @@ -384,7 +646,10 @@ export const WorkspaceView: React.FC = ({ key={i} onClick={(e) => { e.stopPropagation(); - if (target) setSelectedDoc(target); + if (target) { + setSelectedDoc(target); + setZoomedBlockId(null); + } }} className={`inline-flex items-center gap-1 px-1.5 py-0.2 mx-0.5 border text-xs font-medium cursor-pointer rounded-btn transition-colors ${ target @@ -403,6 +668,87 @@ export const WorkspaceView: React.FC = ({ }); }; + // --- GLOBAL TO-DO ROLLUP AGGREGATOR --- + const allWorkspaceTodos = useMemo(() => { + const items: { + docId: string; + docTitle: string; + docIcon: string; + folder: string; + block: DocumentBlock; + }[] = []; + + docs.forEach((doc) => { + const folder = getDocFolder(doc) || 'Root (Unsortiert)'; + (doc.blocks || []).forEach((b) => { + if (b.type === 'todo' || (b.content && b.content.startsWith('- [ ]'))) { + items.push({ + docId: doc.id, + docTitle: doc.title, + docIcon: doc.icon || '📄', + folder, + block: b, + }); + } + }); + }); + + return items; + }, [docs]); + + const filteredTodos = useMemo(() => { + return allWorkspaceTodos.filter((item) => { + if (todoFilter === 'open' && item.block.checked) return false; + if (todoFilter === 'done' && !item.block.checked) return false; + if (todoFolderDrilldown && item.folder !== todoFolderDrilldown) return false; + return true; + }); + }, [allWorkspaceTodos, todoFilter, todoFolderDrilldown]); + + // Group filtered todos by folder -> docTitle + const todosByFolder = useMemo(() => { + const map = new Map(); + filteredTodos.forEach((item) => { + const existing = map.get(item.folder) || []; + existing.push(item); + map.set(item.folder, existing); + }); + return map; + }, [filteredTodos]); + + // Direct toggle of a To-Do item in the rollup dashboard + const handleToggleRollupTodo = async (docId: string, blockId: string) => { + triggerHaptic(15); + const targetDoc = docs.find((d) => d.id === docId); + if (!targetDoc) return; + + const updatedBlocks = targetDoc.blocks.map((b) => + b.id === blockId ? { ...b, checked: !b.checked } : b + ); + const updatedDoc = { ...targetDoc, blocks: updatedBlocks }; + + // Update in local state + setDocs((prev) => prev.map((d) => (d.id === docId ? updatedDoc : d))); + if (selectedDoc?.id === docId) { + setSelectedDoc(updatedDoc); + } + + // Persist immediately to backend & disk + await api.updateDocument(docId, { + blocks: updatedBlocks, + }); + }; + + // Jump directly to document from rollup + const handleJumpToDoc = (docId: string) => { + const found = docs.find((d) => d.id === docId); + if (found) { + setSelectedDoc(found); + setZoomedBlockId(null); + setWorkspaceMode('editor'); + } + }; + // --- FOLDER TREE CALCULATION --- const filteredDocs = useMemo(() => { if (!searchQuery.trim()) return docs; @@ -456,11 +802,15 @@ export const WorkspaceView: React.FC = ({ }, [allFolderPaths, filteredDocs]); const renderDocItem = (doc: Document, depth: number) => { - const isSelected = selectedDoc?.id === doc.id; + const isSelected = selectedDoc?.id === doc.id && workspaceMode === 'editor'; return (
setSelectedDoc(doc)} + onClick={() => { + setSelectedDoc(doc); + setZoomedBlockId(null); + setWorkspaceMode('editor'); + }} style={{ paddingLeft: `${depth * 14 + 16}px` }} className={`group flex items-center justify-between py-1.5 pr-2 border-l-2 transition-colors cursor-pointer text-xs ${ isSelected @@ -590,660 +940,832 @@ export const WorkspaceView: React.FC = ({ const isEmpty = docs.length === 0 && allFolderPaths.length === 0; return ( -
- {/* Left Document & Folder Explorer Tree */} -
-
-
- - setSearchQuery(e.target.value)} - placeholder="Dateien durchsuchen…" - className="w-full h-7 pl-7 pr-2 input-field text-xs" - /> -
+
+ {/* Top Navigation Mode Switcher: Notizen & Outliner vs. Globales To-Do Dashboard */} +
+
+ -
- - - -
+
- {isCreatingFolder && newFolderParent === null && ( -
- - setNewFolderName(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') handleConfirmCreateFolder(); - if (e.key === 'Escape') setIsCreatingFolder(false); - }} - placeholder="Neuer Ordnername…" - className="flex-1 h-6 px-1.5 input-field text-xs" - /> + {workspaceMode === 'editor' && selectedDoc && ( +
+ + + {saveState === 'saving' ? 'Speichert…' : 'Gespeichert'} + - -
- )} - - {isEmpty ? ( -
- -
Keine Dateien vorhanden
-

- Erstelle eine neue Datei oder lege Ordner an, um deine Dokumente übersichtlich zu strukturieren. -

-
- - -
-
- ) : ( -
- {rootTree.map(renderFolder)} - {rootDocs.map((doc) => renderDocItem(doc, 0))} - {filteredDocs.length === 0 && searchQuery && ( -
- Keine Dateien gefunden für "{searchQuery}" -
- )}
)}
- {/* Right Word / Notion Formatted Editor */} -
- {selectedDoc ? ( -
- {/* Document Header & Metadata */} -
-
- - - {saveState === 'saving' ? 'Speichert…' : 'Gespeichert'} - - -
- - -
- - • {selectedDoc.blocks.length} Blöcke -
- -
- - - - - -
-
- - {/* Document Icon & Title */} -
- - - {showIconPicker && ( -
- {ICONS.map((ic) => ( - - ))} -
- )} - - persistDocument({ ...selectedDoc, title: e.target.value })} - placeholder="Dokument-Titel…" - className="w-full bg-transparent text-xl sm:text-2xl font-bold text-main focus:outline-none placeholder:text-faint" - /> -
- - {/* NOTION / WORD RICH FORMATTING TOOLBAR */} -
- {/* Text styling buttons */} - - - - - - - - - {/* Wiki-Link Popover Button */} + {/* ===================================================================== */} + {/* VIEW MODE 1: LOGSEQ OUTLINER & EXPLORER */} + {/* ===================================================================== */} + {workspaceMode === 'editor' && ( +
+ {/* Left Document & Folder Explorer Tree */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Dateien durchsuchen…" + className="w-full h-7 pl-7 pr-2 input-field text-xs" + /> +
+ +
- {showWikiLinkMenu && ( -
-

- Dokument zum Verlinken wählen: -

-
- {docs - .filter((d) => d.id !== selectedDoc.id) - .map((d) => ( - - ))} - {docs.length <= 1 && ( -
- Keine weiteren Dokumente vorhanden -
- )} -
+ + +
+
+ + {isCreatingFolder && newFolderParent === null && ( +
+ + setNewFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleConfirmCreateFolder(); + if (e.key === 'Escape') setIsCreatingFolder(false); + }} + placeholder="Neuer Ordnername…" + className="flex-1 h-6 px-1.5 input-field text-xs" + /> + + +
+ )} + + {isEmpty ? ( +
+ +
Keine Dateien vorhanden
+

+ Erstelle eine neue Datei oder lege Ordner an, um deine Dokumente übersichtlich zu strukturieren. +

+
+ + +
+
+ ) : ( +
+ {rootTree.map(renderFolder)} + {rootDocs.map((doc) => renderDocItem(doc, 0))} + {filteredDocs.length === 0 && searchQuery && ( +
+ Keine Dateien gefunden für "{searchQuery}"
)}
+ )} +
- + {/* Right Logseq Outliner Editor */} +
+ {selectedDoc ? ( +
+ {/* Document Header & Metadata */} +
+
+
+ + +
- {/* Custom Elements: Table & Progress */} - - - - - -
- - {/* BLOCKS STACK */} -
- {selectedDoc.blocks.map((block, idx) => ( -
setLastFocusedBlockId(block.id)} - className="group flex items-start gap-2" - > -
- {/* H1 */} - {block.type === 'h1' && ( - updateBlock(block.id, { content: e.target.value })} - placeholder="Überschrift 1…" - className="w-full bg-transparent text-xl font-bold text-main focus:outline-none py-1 placeholder:text-faint" - /> - )} - - {/* H2 */} - {block.type === 'h2' && ( - updateBlock(block.id, { content: e.target.value })} - placeholder="Abschnitts-Überschrift…" - className="w-full bg-transparent text-base font-semibold text-accent focus:outline-none pt-2 pb-1 placeholder:text-faint" - /> - )} - - {/* H3 */} - {block.type === 'h3' && ( - updateBlock(block.id, { content: e.target.value })} - placeholder="Unterabschnitt…" - className="w-full bg-transparent text-sm font-semibold text-main focus:outline-none pt-1 placeholder:text-faint" - /> - )} - - {/* PARAGRAPH with Wiki-Link support */} - {block.type === 'paragraph' && ( -
-