diff --git a/.env.example b/.env.example index bf4a4d8..3a9ac3e 100644 --- a/.env.example +++ b/.env.example @@ -5,7 +5,10 @@ DATABASE_URL=sqlite:///data/mindsift.db JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars BASE_URL=http://localhost:8080 ALLOW_REGISTRATION=false +# Logging level: debug, info, warn, error (Default: info) LOG_LEVEL=info +# LOG_LEVEL=debug +# DEBUG=true # Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI) SMTP_HOST= diff --git a/Doku/api/websocket.md b/Doku/api/websocket.md index ba172ba..fb211e6 100644 --- a/Doku/api/websocket.md +++ b/Doku/api/websocket.md @@ -66,55 +66,81 @@ Alle WebSocket-Nachrichten werden als JSON mit einheitlicher Hülle übertragen: --- -## ⚡ 4. CRDT-Echtzeitprotokoll (Yjs Collaborative Editing) +## ⚡ 4. Granulare Workspace-Echtzeitsynchronisation (`/api/v1/ws/docs/{id}`) -Seit Version 2.1 unterstützt MindSift **echte, konfliktfreie Mehrbenutzer-Kollaboration** für Notizen via Yjs CRDT. Clients tauschen binäre Delta-Updates und Live-Awareness-Informationen über dedizierte WebSocket-Nachrichten aus: +MindSift nutzt für die Outliner-Synchronisation eine schlanke, hochgradig reaktive In-Memory-Raumarchitektur (`DocRoom` in [`internal/ws/docroom.go`](../../internal/ws/docroom.go)). Clients verbinden sich mit ihrer eindeutigen `clientId`: -### CRDT-Nachrichtentypen -| Event | Richtung | Zweck / Payload | +``` +wss:///api/v1/ws/docs/?token=&clientId=&userName=&userColor= +``` + +### 4.1 JSON-Echtzeit-Eventprotokoll +Die Kommunikation erfolgt über leichtgewichtige JSON-Nachrichten ohne binären CRDT-Overhead: + +| Event-Typ | Richtung | Zweck / Payload | |---|---|---| -| `CRDT_JOIN` | Client ➔ Server | Tritt dem Dokumentenraum (`doc:{docId}`) bei. | -| `CRDT_INIT` | Server ➔ Client | Sendet den aktuellen Basiszustand (`stateBlob`) und offene Delta-Updates (`updates[]`) an den Client. | -| `CRDT_UPDATE` | Bidirektional | Überträgt inkrementelle binäre Yjs-Updates (Base64-kodiert). Server speichert in `document_crdt_updates` und verteilt an alle anderen Raumnutzer. | -| `CRDT_COMPACT` | Client ➔ Server | Komprimiert mehrere Updates zu einem konsolidierten Snapshot in `document_crdt_state`. | -| `CRDT_AWARENESS` | Bidirektional | Überträgt Live-Präsenz (Fokus auf `blockId`, Benutzername, Farbe). | -| `CRDT_AWARENESS_LEAVE` | Bidirektional | Signalisiert, dass ein Nutzer den Fokus verloren hat oder das Dokument verlassen hat. | -| `CRDT_LEAVE` | Client ➔ Server | Verlässt den Dokumentenraum explizit. | +| `doc_state` | Server ➔ Client | Wird direkt beim Verbinden gesendet: Liefert den aktuellen In-Memory-Zustand (`title`, `blocks[]`, `activeCursors[]`). | +| `block_updated` | Bidirektional | Feingranulare Inhalts- oder Statusänderung eines Blocks (`blockId`, `content`, `level`, `type`, `checked`, `collapsed`). | +| `block_created` | Bidirektional | Erzeugt einen neuen Block (`block`, `afterBlockId`, `newIndex`). | +| `block_deleted` | Bidirektional | Löscht einen Block anhand seiner `blockId`. | +| `block_moved` | Bidirektional | Verschiebt einen Block / Unterbaum (`blockId`, `newIndex`, `level`). | +| `title_updated` | Bidirektional | Aktualisiert den Dokumententitel (`title`). | +| `cursor_moved` | Bidirektional | **Ephemere Live-Präsenz & Cursor:** Sendet Benutzer-Avatar, Cursorposition und Textselektion (`clientId`, `userId`, `userName`, `userColor`, `blockId`, `selectionStart`, `selectionEnd`). Wird nicht in SQLite gespeichert! | +| `client_left` | Server ➔ Client | Wird automatisch ausgelöst, wenn ein Peer die Verbindung trennt, damit Remote-Cursor sofort verschwinden. | ```mermaid sequenceDiagram autonumber - actor UserA as Benutzer A - participant WS as WebSocket Hub & SQLite - actor UserB as Benutzer B + actor UserA as Admin (Fenster A, cli_1) + participant Room as DocRoom (Go RAM) + actor UserB as User (Fenster B, cli_2) + participant DB as SQLite (blocks, block_tags, documents) - UserA->>WS: CRDT_JOIN (docId: "doc_123") - WS-->>UserA: CRDT_INIT (stateBlob, updates) - UserB->>WS: CRDT_JOIN (docId: "doc_123") - WS-->>UserB: CRDT_INIT (stateBlob, updates) + UserA->>Room: Connect (clientId="cli_1", userName="Admin") + Room-->>UserA: doc_state (title, blocks, activeCursors) + UserB->>Room: Connect (clientId="cli_2", userName="User") + Room-->>UserB: doc_state (title, blocks, activeCursors) - UserA->>WS: CRDT_UPDATE (docId, updateBlob) - WS->>WS: Append in document_crdt_updates - WS->>UserB: CRDT_UPDATE (docId, updateBlob) - Note over UserB: Yjs wendet Delta atomar an (0 Konflikte) + UserA->>Room: block_updated (clientId="cli_1", blockId="b1", content="Neu") + Note over Room: Mutiert RAM-Slice & resetet 1.5s Timer + Room-->>UserB: block_updated (clientId="cli_1", blockId="b1", content="Neu") + Note over UserA: Echo Filter: cli_1 erhält kein Selbstecho! - UserA->>WS: CRDT_AWARENESS (docId, focusedBlockId) - WS->>UserB: CRDT_AWARENESS (docId, user, focusedBlockId) - Note over UserB: Avatar-Badge erscheint am Block + UserB->>Room: cursor_moved (clientId="cli_2", blockId="b1", selStart=3) + Room-->>UserA: cursor_moved (clientId="cli_2", blockId="b1") + Note over UserA: Farbiger Cursor-Avatar erscheint an b1 + + Note over Room: 1.5s Inaktivität ohne neue Tastenanschläge + Room->>DB: Debounce-Flush (blocks, Regex #tags -> block_tags, markdown_cache) ``` ---- +### 4.2 Strikter Echo-Schutz (`clientId`) +- Jeder Client generiert beim Start einen flüchtigen Schlüssel (`clientIdRef`, z. B. `cli_a8f9_l0z3`). +- Der Go-Hub filtert beim Broadcasten im Raum alle Clients heraus, deren Verbindung oder `clientId` mit dem Absender übereinstimmt: + ```go + if evt.ClientID != "" && client.clientID == evt.ClientID { + continue + } + ``` +- **Vorteil:** Verhindert Cursor-Resets, Fokus-Verluste und Race Conditions durch Selbstechos während des lokalen Tippens. -## 🔒 5. Abwärtskompatibles Document-Lease-Locking - -Vor Version 2.1 nutzte MindSift ein serverseitiges Lease-Locking mit 30-Sekunden-Heartbeats. Durch die CRDT-Engine wurde die blockierende Bearbeitungssperre (`isLockedByOther`) abgelöst: -- **Aktuelles Verhalten:** Mehrere Benutzer mit Bearbeitungsrechten können zeitgleich dasselbe Dokument bearbeiten. -- **Presence & Locks:** Die Events `LOCK_DOC` und `HEARTBEAT_DOC` bleiben abwärtskompatibel erhalten und dienen nun der reinen Präsenzanzeige (z.B. gelber Statusindikator „wird gerade von X angesehen"). -- **Schreibschutz:** Ein echter Schreibschutz (`isReadOnly`) greift ausschließlich noch dann, wenn der Nutzer nur über Leserechte (`permission === 'read'`) für die Notiz oder den Ordner verfügt. +### 4.3 1.5-Sekunden Debounce-Flush in SQLite +- Das Tippen im Editor erfolgt latenzfrei direkt im Arbeitsspeicher des `DocRoom`. +- Sobald 1,5 Sekunden lang keine neuen Tastenanschläge oder Mutationen eintreffen (oder wenn der letzte Client den Raum verlässt), stößt der Raum einen synchronisierten Flush an: + 1. Persistiert alle Blöcke in `blocks`. + 2. Extrahiert alle Hashtags (`#tag`) per Regex und aktualisiert `block_tags`. + 3. Kompiliert die Blockliste (anhand der `level`-Tiefe) in Standard-Markdown und speichert sie in `documents.markdown_cache`. --- -## 🔄 5. Fallback-Strategie & Proxy-Resilienz (Dual-Transport Architektur) +## 🔒 5. Berechtigungen & Bearbeitungsrechte + +- **Kollaboration ohne blockierende Locks:** Alle Nutzer mit Schreibrechten (`permission === 'owner'` oder `'write'`) können zeitgleich im selben Dokument tippen. Pessimistische Locks oder Lease-Heartbeats wurden vollständig entfernt. +- **Schreibschutz:** Verfügt ein Nutzer nur über Leserechte (`permission === 'read'`), ist das Eingabefeld gesperrt (`readOnly`). Er kann das Dokument jedoch in Echtzeit mitlesen und sieht die Cursors der Bearbeiter. + +--- + +## 🔄 6. Fallback-Strategie & Proxy-Resilienz (Dual-Transport Architektur) In restriktiven Firmennetzwerken, hinter Unternehmens-Proxies (z.B. Zscaler, BlueCoat) oder fehlerhaft konfigurierten Reverse-Proxies wird der `Upgrade: websocket`-Header häufig verworfen (`NS_ERROR_CONNECTION_REFUSED`, Code 1006 / 1002 / 1008). @@ -153,12 +179,11 @@ MindSift löst dieses Problem durch eine **vollwertige Dual-Transport-Architektu - Header `X-Accel-Buffering: no` und `Cache-Control: no-cache` verhindern Pufferung im Proxy; periodische Ping-Kommentare (`: ping\n\n`) alle 15 Sekunden halten Timeouts fern. - Der Client bleibt dadurch **vollständig echtzeitfähig** (`Live (SSE)`). -### 2. REST-Endpunkte für Upstream-Aktionen im SSE-Modus +### 2. REST-Endpunkte für Upstream-Aktionen im SSE-Fallback-Modus Da SSE unidirektional (Server ➔ Client) ist, stellt MindSift für Schreibvorgänge im SSE-Modus REST-Äquivalente bereit: -- `PUT /api/v1/documents/:id/crdt`: Unterstützt sowohl volles `state` (Compaction) als auch inkrementelles `update` (Delta). Der Hub persistiert das Update und broadcastet es sofort an alle anderen WS- und SSE-Clients im Dokumentenraum. -- `POST /api/v1/documents/:id/awareness`: Überträgt Cursor- und Blockfokus-Präsenzen. -- `DELETE /api/v1/documents/:id/awareness`: Informiert Peers beim Verlassen des Dokuments. -- `POST /api/v1/events/rooms`: Erlaubt SSE-Clients, Dokument- oder Listenräumen dynamisch beizutreten (`join`) oder sie zu verlassen (`leave`). +- `PATCH /api/v1/documents/:id/blocks`: Akzeptiert Block-Mutationen (`upserted`, `deletedIds`, `blockOrder`) und synchronisiert den In-Memory-Raum sowie SQLite. +- `PUT /api/v1/documents/:id`: Aktualisiert Dokumenttitel und Metadaten. +- `POST /api/v1/events/rooms`: Erlaubt SSE-Clients, Räumen dynamisch beizutreten (`join`) oder sie zu verlassen (`leave`). ### 3. Verbindungs-Indikator - **Grün pulsierend:** Live verbunden über WebSockets (`Live (WebSocket)`). diff --git a/Doku/database.md b/Doku/database.md index 3b73140..c2478c1 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -27,7 +27,9 @@ erDiagram lists ||--o{ list_items : "enthält" lists ||--o{ list_shares : "wird geteilt mit" - documents ||--o{ document_blocks : "besteht aus" + documents ||--o{ blocks : "besteht aus" + documents ||--o{ block_tags : "indiziert" + blocks ||--o{ block_tags : "verknüpft" documents ||--o{ doc_shares : "geteilt mit Benutzern" documents ||--o{ doc_team_shares : "geteilt mit Teams" documents ||--o{ doc_user_folders : "eigene Platzierung" @@ -207,45 +209,54 @@ CREATE TABLE IF NOT EXISTS task_list_shares ( ### 2.4 Workspace: Dokumente, Blöcke & Geteilte Ordner -#### `documents` & `document_blocks` (Database-First Outliner) -Dokumente werden als Kopfdatensatz in `documents` gespeichert, während der Inhalt als feingranulare Blöcke in `document_blocks` verwaltet wird. MindSift speichert alle Notizen zu 100% in SQLite (keine physischen `.md`-Dateien auf Festplatte). +#### `documents`, `blocks` & `block_tags` (Database-First Outliner Schema) +Dokumente werden als Kopfdatensatz in `documents` gespeichert, während der Inhalt als feingranulare Blöcke in `blocks` verwaltet wird. Hashtags (#tags) werden per Regex analysiert und in `block_tags` abgelegt. MindSift speichert alle Notizen zu 100% in SQLite (keine physischen `.md`-Dateien auf Festplatte). ```sql CREATE TABLE IF NOT EXISTS documents ( id TEXT PRIMARY KEY, -- Präfix "doc_..." - user_id TEXT NOT NULL, -- Eigentümer - title TEXT NOT NULL, -- Dokumenttitel + user_id TEXT NOT NULL DEFAULT '', -- Eigentümer (usr_...) + space_id TEXT DEFAULT '', + title TEXT NOT NULL DEFAULT '', -- Dokumenttitel + folder_id TEXT NOT NULL DEFAULT '', -- Ordner-Zuordnung + markdown_cache TEXT NOT NULL DEFAULT '', -- Kompiliertes Standard-Markdown (automatischer Cache) icon TEXT NOT NULL DEFAULT '📄', -- Emoji-Icon - content TEXT NOT NULL DEFAULT '', -- Plaintext / Text-Repräsentation (Cache) - blocks_json TEXT NOT NULL DEFAULT '[]', -- Legacy JSON-Cache + content TEXT NOT NULL DEFAULT '', -- Plaintext / Text-Repräsentation + blocks_json TEXT NOT NULL DEFAULT '[]', -- Legacy/Zusatz-Cache tags TEXT NOT NULL DEFAULT '[]', -- JSON Array von Tags folder_path TEXT NOT NULL DEFAULT '', -- Relativer Ordnerpfad des Eigentümers - shared_folder_id TEXT NOT NULL DEFAULT '', -- ID aus workspace_shared_folders (wenn in geteiltem Ordner) - sub_folder_path TEXT NOT NULL DEFAULT '', -- Relativer Unterordnerpfad innerhalb des geteilten Ordners is_pinned INTEGER NOT NULL DEFAULT 0, -- Globaler Pin is_archived INTEGER NOT NULL DEFAULT 0, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC); -CREATE INDEX IF NOT EXISTS idx_documents_shared_folder ON documents(shared_folder_id); -CREATE TABLE IF NOT EXISTS document_blocks ( - id TEXT PRIMARY KEY, -- Präfix "blk_..." - doc_id TEXT NOT NULL, - sort_order REAL NOT NULL DEFAULT 0, -- Fließkommazahl für konfliktfreies Einfügen - type TEXT NOT NULL DEFAULT 'bullet', -- 'bullet', 'todo', 'h1', 'h2', 'h3', 'code', 'quote', 'callout', 'divider' +CREATE TABLE IF NOT EXISTS blocks ( + id TEXT PRIMARY KEY, -- Block-ID (z.B. "blk_..." oder "docId-b0") + document_id TEXT NOT NULL, -- Zugehöriges Dokument (doc_...) content TEXT NOT NULL DEFAULT '', -- Textinhalt des Blocks - level INTEGER NOT NULL DEFAULT 0, -- Hierarchieebene (0 = Root, 1 = Kind...) + level INT NOT NULL DEFAULT 0, -- Hierarchieebene (0 = Root, 1 = Kind...) + position REAL NOT NULL DEFAULT 0, -- Fließkommazahl für konfliktfreies Einfügen/Sortieren + type TEXT NOT NULL DEFAULT 'bullet', -- 'bullet', 'todo', 'h1', 'h2', 'h3', 'code', 'quote', 'callout', 'divider' checked INTEGER NOT NULL DEFAULT 0, -- Für Checklisten (0 = offen, 1 = abgehakt) - collapsed INTEGER NOT NULL DEFAULT 0, -- Für eingeklappte Unterbäume + collapsed INTEGER NOT NULL DEFAULT 0, -- Für eingeklappte Unterbäume (1 = zugeklappt) lang TEXT NOT NULL DEFAULT '', -- Programmiersprache für Codeblöcke - updated_at TEXT NOT NULL, - updated_by TEXT NOT NULL DEFAULT '', - FOREIGN KEY(doc_id) REFERENCES documents(id) ON DELETE CASCADE + FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE ); -CREATE INDEX IF NOT EXISTS idx_document_blocks_doc_order ON document_blocks(doc_id, sort_order); +CREATE INDEX IF NOT EXISTS idx_blocks_doc_pos ON blocks(document_id, position ASC); + +CREATE TABLE IF NOT EXISTS block_tags ( + tag TEXT NOT NULL, -- Hashtag (z.B. "meeting", ohne führendes '#') + document_id TEXT NOT NULL, -- Zugehöriges Dokument + block_id TEXT NOT NULL, -- Zugehöriger Block + PRIMARY KEY(tag, block_id), + FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE, + FOREIGN KEY(block_id) REFERENCES blocks(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_block_tags_tag ON block_tags(tag); +CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id); ``` #### Entkoppelte Ordner-Freigabe (`workspace_shared_folders` & `workspace_folder_mounts`) diff --git a/Doku/flaws.md b/Doku/flaws.md index 13b33a8..f3eaf7b 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -8,31 +8,30 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle | ID | Bereich | Problem / Schwachstelle | Schweregrad | Risiko im Produktivbetrieb | |---|---|---|---|---| -| **FLAW-01** | Workspace Sync | Kein echtes CRDT/OT – Lock-Heartbeat-Modell mit Last-Write-Wins | **Gelöst (v2.1)** | Behoben durch Yjs CRDT-Engine & blockbasierte kollaborative Bearbeitung | +| **FLAW-01** | Workspace Sync | Blockierende Locks / Synchronisations-Konflikte | **Gelöst (v2.2)** | Behoben durch In-Memory DocRooms, Echo-Schutz per `clientId` und 1.5s Debounce-Flush | | **FLAW-02** | Storage | Dual-Storage Drift (SQLite vs. Dateisystem) | **Obsolet / Gelöst** | Kein Risiko: MindSift nutzt Pure-DB (keine `.md`-Dateien auf Platte) | -| **FLAW-03** | Netzwerk / WS | Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback | **Mittel** | Verzögerte Synchronisierung (1–3s Polling) in Firmennetzwerken | +| **FLAW-03** | Netzwerk / WS | Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback | **Gelöst (v2.2)** | Behoben durch Dual-Transport SSE-Fallback & globale Singleton-Verbindung | | **FLAW-04** | Datenbank / Suche | Volltextsuche via `LIKE '%query%'` statt SQLite FTS5 | **Mittel** | Performance-Einbrüche bei wachsenden Notizbeständen (>10.000 Blöcke) | | **FLAW-05** | Frontend DOM | Fehlende Virtualisierung im Outliner-Baum | **Mittel** | Frame-Drops und Lags bei Dokumenten mit >500 Blöcken auf Mobilgeräten | | **FLAW-06** | SQLite Concurrency | Single-Writer-Bottleneck bei hohem parallelem Schreibaufkommen | **Mittel** | `SQLITE_BUSY` Fehler bei vielen gleichzeitigen Team-Nutzern | | **FLAW-07** | Benachrichtigungen | Fehlende Dead-Letter-Queue für Webhook- & E-Mail-Alarme | **Niedrig** | Verlorene Benachrichtigungen bei temporärem Ausfall von Drittsystemen | | **FLAW-08** | Sicherheit / Auth | Statische JWT-Tokens ohne rotierende Refresh-Tokens & Blacklisting | **Niedrig** | Keine sofortige Sitzungsinvalidierung bei Rechteentzug | -| **FLAW-09** | Mobile UX | Viewport-Verschiebung durch Virtual-Keyboard auf iOS/Android | **Niedrig** | Verdeckte Eingabefelder und springende Bottom-Navigationsleisten | -| **FLAW-10** | Outliner UX | Kein hierarchischer Undo/Redo-Stack für Strukturmutationen | **Mittel** | Versehentliches Löschen/Einrücken von Blöcken nicht per `Ctrl+Z` rückgängig machbar | --- ## 🔍 Detaillierte Analyse & Maßnahmen -### FLAW-01: [GELÖST / IMPLEMENTIERT] Yjs Outliner CRDT & Multi-User Kollaboration -- **Status:** **Gelöst via Yjs CRDT Engine (Lösung i)** -- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`modules/workspace.md`](./modules/workspace.md), `internal/ws/hub.go`, `internal/storage/sqlite.go`, `frontend/src/lib/crdt.ts`, `frontend/src/features/workspace/WorkspaceView.tsx` -- **Ursache (Historisch):** MindSift nutzte ein kooperatives Dokumenten-Lease-Locking mit Last-Write-Wins. Bei parallelem Zugriff oder Netztrennung konnten Änderungen überschrieben werden. -- **Implementierte Lösung (Yjs CRDT):** - 1. **Block-Level Yjs CRDT:** Implementierung eines vollwertigen CRDT-Modells mit `Y.Doc`. Die Blockreihenfolge wird über ein `Y.Array` (Block-IDs) konfliktfrei mit dem YATA-Algorithmus aufgelöst. Inhalte und Metadaten (Text, Level, Checked, Collapsed) sind in `Y.Map` gekapselt. - 2. **Wegfall von Ganzdokument-Sperren:** Keine blockierenden Sperren mehr (`isLockedByOther` entfernt). Mehrere Nutzer können zeitgleich am selben Dokument schreiben. - 3. **Dual-Persistence:** CRDT-Zustände und Delta-Updates werden binär/Base64 in SQLite (`document_crdt_state`, `document_crdt_updates`) persistiert. Zur Aufrechterhaltung relationaler Indizes (Global To-Dos, Tag-Indizierung, Suche) werden Blöcke parallel in `document_blocks` materialisiert. - 4. **Live-Awareness & Presence:** Block-Fokus und Cursor-Zustand werden via `CRDT_AWARENESS` übertragen. Nutzer sehen ein Live-Badge im Header (`Live (N)`) sowie farbige Kollaborator-Pillen an aktiven Blöcken. - 5. **REST- & Proxy-Sicherheit:** Ergänzend zu WebSockets stehen Endpunkte `GET /api/v1/documents/:id/crdt` und `PUT /api/v1/documents/:id/crdt` bereit. +### FLAW-01: [GELÖST / IMPLEMENTIERT] Granulare Outliner-Synchronisation & Multi-User Kollaboration +- **Status:** **Gelöst via In-Memory DocRooms, Echo-Schutz & Debounced Persistence** +- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`modules/workspace.md`](./modules/workspace.md), `internal/ws/docroom.go`, `internal/storage/sqlite.go`, `frontend/src/lib/useDocumentSync.ts`, `frontend/src/features/workspace/OutlinerBlock.tsx` +- **Ursache (Historisch):** MindSift nutzte früher pessimistisches Dokumentensperren (Lease-Locks mit Heartbeats). Bei parallelem Bearbeiten blockierten sich Nutzer gegenseitig; unausgereifte Sync-Versuche führten zu Selbstechos und Cursor-Jumps. +- **Implementierte Lösung:** + 1. **In-Memory DocRooms:** Granularer WebSocket-Raum pro Dokument (`/api/v1/ws/docs/:id`) mit Mutex-geschütztem Block-State im Go-Backend. + 2. **Strikter Echo-Schutz per `clientId`:** Jede Verbindung besitzt eine eindeutige `clientId`. Der Hub filtert den Sender heraus und leitet Events nur an Peers weiter. + 3. **Live Presence & Ephemere Cursors:** Cursorposition und Textselektionen (`cursor_moved`) werden flüchtig an Peers gestreamt, ohne SQLite zu belasten. + 4. **Fokus- & Eingabeschutz (`OutlinerBlock.tsx`):** Remote-Updates werden nur blockiert, wenn `document.hasFocus()` vorliegt. Bei `window.onblur` und `input.onblur` wird sofort (0ms) geflusht; Callbacks werden über `useRef` geführt (keine Stale Closures). + 5. **1.5s Inaktivitäts-Debounce-Flush:** Nach 1,5s Inaktivität sichert der Raum persistent in SQLite (`blocks`, Regex `#tags` in `block_tags` und kompiliertes Markdown in `documents.markdown_cache`). + 6. **Vollständige Eliminierung von Locks:** Alle blockierenden Sperren wurden restlos entfernt. Mehrere Nutzer können zeitgleich schreiben. --- @@ -84,7 +83,7 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle --- ### FLAW-05: Fehlende Virtualisierung im Outliner-Baum -- **Betroffene Komponenten:** [`modules/workspace.md`](./modules/workspace.md), `frontend/src/lib/components/Outliner.svelte` +- **Betroffene Komponenten:** [`modules/workspace.md`](./modules/workspace.md), `frontend/src/features/workspace/WorkspaceView.tsx` - **Ursache:** Jeder Block im Notizbaum wird als eigenes DOM-Element gerendert. Enthält ein Dokument 500 bis 2000 Blöcke (z. B. lange Projektjournale oder Besprechungsprotokolle), befinden sich tausende verschachtelte `div`- und `contenteditable`-Elemente gleichzeitig im DOM. - **Folgen:** Auf älteren Smartphones oder bei schnellem Tippen kommt es zu spürbaren Input-Latenzen und Ruckeln beim Scrollen. - **Empfohlene Lösung:** @@ -123,26 +122,6 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle --- -### FLAW-09: Mobile Viewport-Verschiebung durch Virtual-Keyboard -- **Betroffene Komponenten:** `frontend/src/routes/+layout.svelte`, `frontend/src/lib/components/BottomNav.svelte` -- **Ursache:** Öffnet sich auf iOS Safari oder Android Chrome die Bildschirmtastatur, ändert der Browser die Höhe des sichtbaren Viewports dynamisch. Feste CSS-Attribute wie `height: 100vh` oder `fixed bottom-0` führen dazu, dass Eingabefelder unter die Tastatur rutschen oder die Navigationsleiste den Bildschirm verdeckt. -- **Folgen:** In der mobilen Triage- und Aufgabenansicht muss der Nutzer manuell scrollen, um die Speichern-Buttons zu erreichen. -- **Empfohlene Lösung:** - 1. Nutzung der modernen `window.visualViewport`-API in Svelte. - 2. Dynamisches Setzen einer CSS-Variable `--vvh` (`visualViewport.height`) zur präzisen Anpassung von Modals und Bottom-Sheets. - ---- - -### FLAW-10: Kein hierarchischer Undo/Redo-Stack im Outliner -- **Betroffene Komponenten:** [`modules/workspace.md`](./modules/workspace.md), `frontend/src/lib/components/Outliner.svelte` -- **Ursache:** Standardmäßiges Browser-`Ctrl+Z` funktioniert nur innerhalb eines einzelnen aktiven Textfelds (`contenteditable`). Strukturelle Aktionen – wie das Verschieben von Blöcken via Drag & Drop, Einrücken (`Tab`), Ausrücken (`Shift+Tab`) oder das Löschen ganzer Unterbäume – sind im Standard-Browser-Undo-Stack nicht enthalten. -- **Folgen:** Verschiebt ein Nutzer versehentlich einen Block samt 10 Unterpunkten, kann dies nicht mit `Ctrl+Z` rückgängig gemacht werden. -- **Empfohlene Lösung:** - 1. Implementierung eines expliziten In-Memory Command-Stacks im Frontend (Command-Pattern: `execute()` / `undo()`). - 2. Aufzeichnung aller Block-Mutationen (Insert, Indent, Move, Delete) mit Revert-Payloads. - ---- - ## 🧭 Verwandte Dokumente - 🏗️ [Gesamtarchitektur](architecture.md) - 💾 [Datenbank-Design & Tabellenschemata](database.md) diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index be55f6a..a0d93f7 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -1,12 +1,12 @@ # 📝 Kernmodul 3: Workspace Outliner-Engine, Wiki-Links & Pure-DB Storage -Der MindSift Workspace ([`WorkspaceView.tsx`](../../frontend/src/features/workspace/WorkspaceView.tsx)) kombiniert ein **hierarchisches Bullet-Point Notizsystem (inspiriert von Logseq)** mit einem **persönlichen Wiki** und einem **globalen To-Do Rollup Dashboard**. +Der MindSift Workspace ([`WorkspaceView.tsx`](../../frontend/src/features/workspace/WorkspaceView.tsx)) kombiniert ein **hierarchisches Bullet-Point Notizsystem (inspiriert von Logseq)** mit einem **persönlichen Wiki**, **Live-Kollaboration** und einem **globalen To-Do Rollup Dashboard**. --- ## 🌳 1. Die Logseq-Outliner-Engine -Im Gegensatz zu klassischen Fließtext-Editoren basiert der MindSift Workspace auf **Blöcken** (`document_blocks` in SQLite). Jeder Block ist ein gedanklicher Knotenpunkt mit einer flexiblen Einrückungsebene (`level: 0, 1, 2, ...`). +Im Gegensatz zu klassischen Fließtext-Editoren basiert der MindSift Workspace auf einer **flachen, relationalen Liste von Blöcken** (`blocks`-Tabelle in SQLite). Jeder Block ist ein gedanklicher Knotenpunkt mit einer flexiblen Einrückungsebene (`level: 0, 1, 2, ...`) und einer fortlaufenden Fließkommaposition (`position`): ``` • Projekt MindSift v2.0 (level 0) @@ -22,12 +22,20 @@ Im Gegensatz zu klassischen Fließtext-Editoren basiert der MindSift Workspace a - `Tab`: Rückt den aktuellen Block ein (wird Kindknoten des vorherigen Blocks). - `Shift + Tab`: Rückt den Block um eine Ebene aus (verringert die Tiefe). - `Shift + Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks (z.B. für mehrzeilige Notizen oder Code). -- `Backspace` (auf leerem Block): Rückt den Block aus oder löscht ihn, wenn er auf Level 0 ist. -- `/` (Slash-Commands): Öffnet das Umwandlungsmenü (`/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/quote`, `/divider`). +- `Backspace` (auf leerem Block): Löscht den Block sofort. Das Ausrücken erfolgt strikt und kontrolliert über `Shift + Tab`. +- `/` (Slash-Commands): Öffnet das Umwandlungsmenü (`/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/quote`, `/divider`). +- **Markdown-Tastatur-Kürzel:** Tippen von `[] ` oder `[ ] ` wandelt den Block sofort in ein Todo um; `# `, `## `, `### ` erzeugt Überschriften. + +### Eingabestabilität & Zombie-Fokus-Schutz (`OutlinerBlock.tsx`) +1. **Echter Fokus-Check:** Remote-Updates (`block_updated`) werden nur dann abgewehrt, wenn der Block tatsächlich aktiv im Betriebssystem editiert wird: + `isFocusedRef.current === true && document.hasFocus()`. +2. **Sofort-Flush bei Fensterwechsel:** Sobald das Browser-Fenster den Fokus verliert (`window.addEventListener('blur', ...)`), wird ein offener 150ms-Debounce sofort gecancelt und der Text augenblicklich per WebSocket übertragen. +3. **Stale Closures eliminiert:** Alle Callbacks werden in `useRef` gehalten, sodass asynchrone Debounce-Timer garantiert die aktuellste Komponenten-Referenz verwenden. ### Zoom-In Hoisting (Fokus-Modus) -Klickt der Benutzer auf den Bullet-Dot (`•`) eines Blocks, blendet der Editor alle übergeordneten und benachbarten Blöcke aus und zoomt **ausschließlich in diesen Ast** hinein. -- Eine dezente Breadcrumb-Leiste oben zeigt den Pfad an (z.B. `Workspace / Projekte / MindSift / Architektur`). +Klickt der Benutzer auf den Bullet-Dot (`•`) eines Blocks, blendet der Editor alle übergeordneten und benachbarten Blöcke aus und zoomt **ausschließlich in diesen Ast** hinein: +- Der gewählte Punkt wird zur prominenten Überschrift im Fokus-Modus. +- Eine klickbare Breadcrumb-Leiste oben zeigt den Pfad an (z.B. `Workspace / Projekte / MindSift / Architektur`). - Ein Klick auf ein beliebiges Breadcrumb-Element zoomt sofort auf die jeweilige Ebene zurück. ### Drag & Drop von Blöcken @@ -35,51 +43,55 @@ Der Bullet-Dot dient gleichzeitig als Drag-Anfasser. Wird ein Block verschoben, --- -## 🔗 2. Wiki-Links (`[[Link]]`) & Hashtags +## 🔗 2. Wiki-Links (`[[Link]]`), Web-Links (`/link`) & Hashtags 1. **Interne Wiki-Links:** - - Text in doppelten eckigen Klammern (z.B. `[[Reiseplanung 2026]]`) wird automatisch als klickbarer Link gerendert. - - Ein Klick darauf durchsucht die Notizen des Benutzers und öffnet das Ziel-Dokument direkt im Editor. -2. **Hashtags (`#tag`):** - - Hashtags im Fließtext werden automatisch indiziert (`doc_block_tags`-Tabelle). + - Text in doppelten eckigen Klammern (z.B. `[[Reiseplanung 2026]]`) wird automatisch als klickbare Notiz-Pille gerendert. + - Klick darauf öffnet die verknüpfte Notiz direkt im Editor. +2. **Slash-Befehl `/link` & Link-Modal:** + - `/link` öffnet ein Modal mit zwei Modi: + - **Notiz verknüpfen:** Durchsucht bestehende Notizen und fügt `[[Titel]]` ein. + - **Web-Link:** Eingabe von Titel und URL, fügt Standard-Markdown `[Titel](url)` ein. +3. **Hashtags (`#tag`):** + - Hashtags im Fließtext werden automatisch indiziert (`block_tags`-Tabelle). - In der linken Leiste listet der Tag-Explorer alle verwendeten Schlagworte auf. Klick auf ein Tag filtert alle Dokumente und Blöcke, die dieses Tag enthalten. --- ---- +## ⚡ 3. Echtzeit-Kollaboration & Automatische Markdown-Kompilierung -## ⚡ 3. Yjs CRDT Real-Time Engine & Dual-Persistence - -Ab MindSift v2.1 setzt der Workspace auf ein **konfliktfreies Replicated Data Type (CRDT) Modell** auf Basis von [Yjs](https://yjs.dev/): +MindSift setzt auf eine latenzfreie In-Memory Kollaborations-Architektur ([`internal/ws/docroom.go`](../../internal/ws/docroom.go)): ### Funktionsweise -1. **CRDT-Struktur im Client:** - - Ein `Y.Doc` pro geöffnetem Dokument. - - `Y.Array` (`blocks`): Führt die Block-IDs in korrekter Reihenfolge. Der YATA-Algorithmus garantiert, dass simultanes Einfügen, Verschieben oder Löschen von Blöcken durch mehrere Nutzer deterministisch und konfliktfrei konvergiert. - - `Y.Map` (`block:`): Speichert Inhalt (`content`), Einrückung (`level`), Erledigungsstatus (`checked`) und Faltungsstatus (`collapsed`). -2. **Kollaborative Bearbeitung ohne Sperren:** - - Nutzer können simultan dieselbe Notiz bearbeiten. - - Fokus- und Cursor-Positionen werden via `CRDT_AWARENESS` übertragen und als farbige Benutzer-Pillen direkt am jeweiligen Block visualisiert. +1. **In-Memory DocRoom pro geöffnetem Dokument:** + - Hält den synchronen Zustand von `title` und `blocks[]` im RAM. + - Granulare Mutations-Events (`block_updated`, `block_created`, `block_deleted`, `block_moved`, `title_updated`). + - Ephemeres Presence-Tracking (`cursor_moved`, `client_left`) zeigt farbige Benutzer-Avatare im Header und Cursors am aktiven Block an. +2. **Echo-Schutz:** + - Jeder Client identifiziert sich mit einer eindeutigen `clientId`. + - Der Server sendet Mutations-Events ausschließlich an andere Clients im selben Raum (`clientId != senderClientId`), um Cursor-Springen und Selbstechos zu verhindern. -### Dual-Persistence in SQLite +### Automatische Markdown-Kompilierung & SQLite Debounce-Flush ``` -Client (Y.Doc Mutation) +Eingabe im OutlinerBlock │ - ├──▶ WebSocket (CRDT_UPDATE Base64 Delta) + ├──▶ WebSocket (block_updated Event) │ │ │ ▼ - │ SQLite: document_crdt_updates / document_crdt_state - │ (Mathematisch exakte CRDT-Konvergenz) + │ Go DocRoom: In-Memory Mutation & Broadcast an Peers │ - └──▶ Periodischer Snapshot / Flush + └──▶ 1.5s Inaktivitäts-Debounce (oder Raum-Exit) │ ▼ - SQLite: document_blocks - (Relationale Indizes für Global To-Dos, Tag-Explorer & FTS) + SQLite Transaktion: + 1. Persistiert Blöcke in `blocks` + 2. Regex-Extraktion von #tags in `block_tags` + 3. Kompiliert Block-Hierarchie zu Standard-Markdown + und speichert Cache in `documents.markdown_cache` ``` -1. **Mathematische CRDT-Garantie:** Binäre Yjs-Updates werden in `document_crdt_updates` und periodisch als Snapshot in `document_crdt_state` gesichert. Auch nach einem Server-Neustart oder Offline-Phasen konvergieren alle Clients verlustfrei. -2. **Relationale Query-Fähigkeit:** Um schnelle SQL-Abfragen für das globale To-Do Rollup Dashboard und Hashtags zu ermöglichen, werden die Blöcke parallel in `document_blocks` materialisiert. Es existieren **keine Markdown-Dateien auf der Festplatte**. +1. **Kompiliertes Markdown:** Jeder Block wird gemäß seiner `level`-Tiefe mit 2 Leerzeichen pro Einrückungsebene als sauberes Markdown formatiert (`documents.markdown_cache`). +2. **Pure Database Architecture:** Alle Daten liegen transaktional und sicher in SQLite. Es werden keine unkontrollierten Dateien auf der Festplatte abgelegt; Notizen können jederzeit per Klick als `.md`-Datei exportiert werden. --- diff --git a/Doku/reverse-proxy.md b/Doku/reverse-proxy.md index 1cb3e52..7a3615e 100644 --- a/Doku/reverse-proxy.md +++ b/Doku/reverse-proxy.md @@ -71,8 +71,8 @@ server { proxy_send_timeout 3600s; } - # 2. Server-Sent Events Fallback (/api/v1/events) - location /api/v1/events { + # 2. Server-Sent Events Fallback (/api/v1/events & /api/v1/sync/) + location ~ ^/api/v1/(events|sync/) { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; diff --git a/README.md b/README.md index 9ce2f6d..8ef9705 100644 --- a/README.md +++ b/README.md @@ -74,13 +74,14 @@ Der MindSift-Workspace vereint die Übersicht eines klassischen Dokumenten-Explo * Verschiebe Absätze und ganze Gedankenbäume mühelos mit der Maus. * **Unterpunkte per Drop erstellen:** Ziehst du einen Bullet-Point direkt auf einen anderen Aufzählungspunkt, wird er automatisch als Unter-Bullet eingerückt. Eingeklappte Eltern-Punkte klappen beim Hineinlegen automatisch auf. * Untergeordnete Unterpunkte werden beim Ziehen automatisch als zusammenhängender Ast mitbewegt. -* **Echtzeit-Schreib-Lock (Kollisionsschutz):** - * Sobald du einen Bullet-Point per Drag & Drop greifst oder anfängst zu tippen, wird sofort ein Schreib-Lock für das Dokument aktiviert. - * Andere Nutzer sehen live, wer das Dokument gerade bearbeitet, und Kollisionen bei gleichzeitigen Änderungen werden zuverlässig verhindert. -* **Slash-Befehle (`/`):** Tippe einfach `/` für `/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/callout` oder `/divider`. +* **Live-Kollaboration & Presence-Tracking:** + * Gleichzeitiges, latenzfreies Bearbeiten von Dokumenten im Team ohne blockierende Sperren. + * Farbige Avatare im Header und Live-Cursors am aktiven Block visualisieren genau, wer gerade wo tippt. + * Strikter Echo-Schutz verhindert Cursor-Springen und Flackern beim Tippen. +* **Slash-Befehle (`/`):** Tippe einfach `/` für `/todo`, `/link` (Notizen & Web-Links verknüpfen), `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/quote` oder `/divider`. * **Vernetzung & Hashtags:** Verlinke Notizen untereinander mit Wiki-Links (`[[Notiztitel]]`) und tagge Gedanken mit `#Hashtags`. * **Explorer mit Ordnern & Favoriten:** Organisiere Dokumente hierarchisch in Ordnern, verschiebe Notizen per Drag & Drop und hefte deine wichtigsten Dokumente als Favoriten ganz oben an. -* **100 % Standard-Markdown:** Jedes Dokument wird lokal unter `./data/workspace/...` als saubere `.md`-Datei mit 2-Leerzeichen-Einrückung abgelegt – jederzeit lesbar und kompatibel mit Obsidian, Logseq oder VS Code. +* **100 % Pure-DB & Markdown-Export:** Alle Notizen und Blöcke werden transaktional und sicher in SQLite gespeichert. Beim Speichern wird automatisch ein sauberer Markdown-Cache generiert, und Dokumente können jederzeit mit einem Klick als `.md`-Datei exportiert werden. --- @@ -226,7 +227,7 @@ MindSift erkennt den Erststart automatisch und führt dich durch den Einrichtung ## 🔒 Deine Daten gehören dir * **100 % Self-Hosted:** Keine fremden Cloud-Dienste, keine Telemetrie, kein Tracking. -* **Kein Vendor-Lock-in:** Alle Notizen liegen als lesbare `.md`-Dateien im Ordner `./data/workspace`. Backups können jederzeit exportiert werden. +* **Kein Vendor-Lock-in:** Alle Notizen liegen als relationale Daten in SQLite und werden automatisch als Standard-Markdown kompiliert. Dokumente lassen sich jederzeit mit einem Klick als `.md`-Datei exportieren oder als SQLite-Dump sichern. --- diff --git a/cmd/server/main.go b/cmd/server/main.go index b52aeb4..9f034c9 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -4,7 +4,7 @@ import ( "context" "embed" "io/fs" - "log" + "log/slog" "net/http" "os" "os/signal" @@ -23,11 +23,16 @@ var embeddedDist embed.FS func main() { cfg := config.Load() - log.Printf("🧠 Starting MindSift Server on port %s (DB: %s)", cfg.Port, cfg.DatabasePath) + level := cfg.SlogLevel() + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) + slog.SetDefault(logger) + + slog.Info("MindSift Server starting", "port", cfg.Port, "log_level", level.String()) store, err := storage.NewStore(cfg.DatabasePath) if err != nil { - log.Fatalf("Failed to initialize storage: %v", err) + slog.Error("Failed to initialize storage", "error", err) + os.Exit(1) } defer store.Close() @@ -35,11 +40,11 @@ func main() { store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS) if cfg.DemoMode { - log.Printf("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...") + slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...") if err := store.EnsureDemoAccounts(); err != nil { - log.Printf("⚠️ Fehler beim Anlegen der Demo-Accounts: %v", err) + slog.Warn("⚠️ Fehler beim Anlegen der Demo-Accounts", "error", err) } else { - log.Printf("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)") + slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)") } } @@ -47,7 +52,8 @@ func main() { staticFS, err := fs.Sub(embeddedDist, "dist") if err != nil { - log.Fatalf("Failed to mount embedded dist filesystem: %v", err) + slog.Error("Failed to mount embedded dist filesystem", "error", err) + os.Exit(1) } srv := api.NewServer(cfg, store, wsHub, staticFS) @@ -61,9 +67,10 @@ func main() { } go func() { - log.Printf("✨ MindSift ready at http://0.0.0.0:%s", cfg.Port) + slog.Info("✨ MindSift ready", "url", "http://0.0.0.0:"+cfg.Port) if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("HTTP server error: %v", err) + slog.Error("HTTP server error", "error", err) + os.Exit(1) } }() @@ -71,7 +78,7 @@ func main() { signal.Notify(stop, os.Interrupt, syscall.SIGTERM) <-stop - log.Println("Shutting down MindSift server gracefully...") + slog.Info("Shutting down MindSift server gracefully...") ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = httpServer.Shutdown(ctx) diff --git a/compose.yaml b/compose.yaml index fc22f8d..c57c511 100644 --- a/compose.yaml +++ b/compose.yaml @@ -14,6 +14,9 @@ services: - BASE_URL=${BASE_URL:-http://localhost:8080} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false} - LOG_LEVEL=${LOG_LEVEL:-info} + # Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging + # - LOG_LEVEL=debug + # - DEBUG=true # Demo mode: if true, automatically seeds demo accounts admin/admin and user/user - MINDA_DEMO=${MINDA_DEMO:-false} # Optional SMTP / Mail Configuration (can also be configured in Admin UI) diff --git a/docker-compose.yml b/docker-compose.yml index fc22f8d..c57c511 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,9 @@ services: - BASE_URL=${BASE_URL:-http://localhost:8080} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false} - LOG_LEVEL=${LOG_LEVEL:-info} + # Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging + # - LOG_LEVEL=debug + # - DEBUG=true # Demo mode: if true, automatically seeds demo accounts admin/admin and user/user - MINDA_DEMO=${MINDA_DEMO:-false} # Optional SMTP / Mail Configuration (can also be configured in Admin UI) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 11ddca9..3e9936a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "prismjs": "^1.30.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "y-indexeddb": "^9.0.12", "yjs": "^13.6.33" }, "devDependencies": { @@ -2782,6 +2783,26 @@ } } }, + "node_modules/y-indexeddb": { + "version": "9.0.12", + "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", + "integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.74" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index a4ff258..ee43213 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,8 +16,7 @@ "lucide-react": "^0.468.0", "prismjs": "^1.30.0", "react": "^18.3.1", - "react-dom": "^18.3.1", - "yjs": "^13.6.33" + "react-dom": "^18.3.1" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a0bfd16..0cc26a1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Layers, CheckSquare, @@ -414,7 +414,7 @@ export const App: React.FC = () => { setActiveThemeState(themeId); }; - const refreshAll = async () => { + const refreshAll = useCallback(async () => { try { const [meRes, deckRes, listsRes] = await Promise.all([ api.getMe(), @@ -428,7 +428,7 @@ export const App: React.FC = () => { } catch { // If token expired or invalid, api.ts dispatches mindsift:unauthorized } - }; + }, []); const checkBootAndAuth = async () => { setBootLoading(true); diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 8fc62f0..49f9484 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -105,7 +105,6 @@ export const SharedListsView: React.FC = ({ onRefreshGloba const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null); const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState(null); - const wsRef = useRef(null); const realtimeConnRef = useRef(null); const loadLists = async () => { @@ -795,52 +794,8 @@ export const SharedListsView: React.FC = ({ onRefreshGloba return (
- {/* Mobile Compact Header Bar (List switching & actions live in MindSift Logo Menu) */} -
-
- - {activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'} - - {openItems.length > 0 && ( - - {openItems.length} offen - - )} -
-
- {activeList && ( - - )} -
- -
-
-
- {/* Top Header: List Tabs & Actions (Desktop) */} -
+
{/* List Tabs */}
{lists.map((lst) => { @@ -953,6 +908,50 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
+ {/* Mobile Compact Header Bar (List switching & actions live in MindSift Logo Menu) */} +
+
+ + {activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'} + + {openItems.length > 0 && ( + + {openItems.length} offen + + )} +
+
+ {activeList && ( + + )} +
+ +
+
+
+ {showNewListModal && (
= ({ return (
- {/* Mobile Compact Context Bar (replaces cluttered button rows; options live in MindSift Logo Menu) */} -
-
- - {currentSelectedList ? `${currentSelectedList.icon} ${currentSelectedList.title}` : '📋 Alle Aufgaben'} - - - {viewMode === 'kanban' - ? 'Kanban' - : filter === 'open' - ? `Offen (${openTasks.length})` - : filter === 'done' - ? `Erledigt (${doneTasks.length})` - : `Papierkorb (${safeDiscarded.length})`} - -
- {currentSelectedList && ( - - )} -
- {/* Top Header: Task Lists Tabs & View Mode Switcher (Desktop) */} -
+
{/* Task Lists selector tabs */}
+ {/* Mobile Compact Context Bar (replaces cluttered button rows; options live in MindSift Logo Menu) */} +
+
+ + {currentSelectedList ? `${currentSelectedList.icon} ${currentSelectedList.title}` : '📋 Alle Aufgaben'} + + + {viewMode === 'kanban' + ? 'Kanban' + : filter === 'open' + ? `Offen (${openTasks.length})` + : filter === 'done' + ? `Erledigt (${doneTasks.length})` + : `Papierkorb (${safeDiscarded.length})`} + +
+ {currentSelectedList && ( + + )} +
+ {/* Quick Add Task Input (Desktop only - on mobile, tapping the bottom '+' FAB opens mobile create sheet) */} void; + onDeleteDoc: (docId: string) => void; + onOpenShareModal: (doc: Document) => void; + onSelectTag: (tag: string) => void; + onWikiLinkClick?: (pageTitle: string) => void; + onBackMobile?: () => void; + onSyncStatusChange?: (status: DocSyncStatus) => void; +} + +const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒']; + +function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] { + if (!blocks || blocks.length === 0) { + return [ + { + id: `${docId}-b0`, + documentId: docId, + type: 'text', + content: '', + level: 0, + position: 1000, + }, + ]; + } + return blocks.map((b, idx) => ({ + ...b, + id: b.id || `${docId}-b${idx}`, + documentId: docId, + type: b.type || 'text', + level: Math.max(0, b.level || 0), + position: b.position || (idx + 1) * 1000, + })); +} + +export interface BreadcrumbNode { + id: string | null; + title: string; +} + +export function computeBreadcrumbs( + docTitle: string, + blocks: DocumentBlock[], + zoomedBlockId: string | null +): BreadcrumbNode[] { + if (!zoomedBlockId) return []; + + const zoomedIdx = blocks.findIndex((b) => b.id === zoomedBlockId); + if (zoomedIdx === -1) return []; + + const trail: BreadcrumbNode[] = []; + const zoomedBlock = blocks[zoomedIdx]; + + let currentLevel = zoomedBlock.level; + for (let i = zoomedIdx - 1; i >= 0; i--) { + if (blocks[i].level < currentLevel) { + trail.unshift({ + id: blocks[i].id, + title: blocks[i].content?.trim() || 'Punkt', + }); + currentLevel = blocks[i].level; + if (currentLevel === 0) break; + } + } + + trail.unshift({ + id: null, + title: docTitle || 'Dokument', + }); + + trail.push({ + id: zoomedBlock.id, + title: zoomedBlock.content?.trim() || 'Punkt', + }); + + return trail; +} + +export function getZoomedSubtree( + blocks: DocumentBlock[], + zoomedBlockId: string | null +): { + zoomedBlock: DocumentBlock | null; + childBlocks: DocumentBlock[]; + levelOffset: number; +} { + if (!zoomedBlockId) { + return { zoomedBlock: null, childBlocks: blocks, levelOffset: 0 }; + } + + const idx = blocks.findIndex((b) => b.id === zoomedBlockId); + if (idx === -1) { + return { zoomedBlock: null, childBlocks: blocks, levelOffset: 0 }; + } + + const zoomedBlock = blocks[idx]; + const zLevel = zoomedBlock.level; + const childBlocks: DocumentBlock[] = []; + + for (let i = idx + 1; i < blocks.length; i++) { + if (blocks[i].level > zLevel) { + childBlocks.push(blocks[i]); + } else { + break; + } + } + + return { + zoomedBlock, + childBlocks, + levelOffset: zLevel + 1, + }; +} + +export const DocumentEditor: React.FC = ({ + docId, + currentUser, + initialDoc, + availableFolders, + autoFocusTitle, + initialFocusedBlockId, + onDocMetadataChange, + onDeleteDoc, + onOpenShareModal, + onSelectTag, + onWikiLinkClick, + onBackMobile, + onSyncStatusChange, +}) => { + const [doc, setDoc] = useState(initialDoc || null); + const [title, setTitle] = useState(initialDoc?.title || ''); + const [blocks, setBlocks] = useState(() => + ensureNonEmptyBlocks(docId, initialDoc?.blocks) + ); + const [loading, setLoading] = useState(!initialDoc); + const [error, setError] = useState(null); + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + const [zoomedBlockId, setZoomedBlockId] = useState(null); + + const titleInputRef = useRef(null); + const titleDebounceRef = useRef(null); + const docRef = useRef(doc); + docRef.current = doc; + + useEffect(() => { + if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) { + const focusTitle = () => { + if (titleInputRef.current) { + titleInputRef.current.focus(); + titleInputRef.current.select(); + } + }; + focusTitle(); + const t1 = setTimeout(focusTitle, 50); + const t2 = setTimeout(focusTitle, 150); + return () => { + clearTimeout(t1); + clearTimeout(t2); + }; + } + }, [loading, autoFocusTitle, doc?.id]); + + useEffect(() => { + if (zoomedBlockId && !blocks.some((b) => b.id === zoomedBlockId)) { + setZoomedBlockId(null); + } + }, [blocks, zoomedBlockId]); + + const { zoomedBlock, childBlocks, levelOffset } = useMemo(() => { + return getZoomedSubtree(blocks, zoomedBlockId); + }, [blocks, zoomedBlockId]); + + const breadcrumbs = useMemo(() => { + return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId); + }, [title, doc?.title, blocks, zoomedBlockId]); + + // Crucial fix: Collaborator with write permission can edit! + const canEdit = useMemo(() => { + if (!doc) return true; + if (doc.isOwner) return true; + return doc.permission === 'owner' || doc.permission === 'write'; + }, [doc]); + + // Handle incoming WebSocket events for THIS document room + const handleRemoteEvent = useCallback( + (evt: DocSyncEvent) => { + if (evt.docId && evt.docId !== docId) return; + + switch (evt.type) { + case 'doc_state': { + if (evt.title !== undefined) { + const isTitleActivelyEditing = + titleInputRef.current !== null && + document.activeElement === titleInputRef.current && + typeof document !== 'undefined' && + document.hasFocus(); + if (!isTitleActivelyEditing) { + setTitle(evt.title); + } + } + if (Array.isArray(evt.blocks) && evt.blocks.length > 0) { + setBlocks(ensureNonEmptyBlocks(docId, evt.blocks)); + } + break; + } + + case 'title_updated': { + if (evt.title !== undefined) { + const isTitleActivelyEditing = + titleInputRef.current !== null && + document.activeElement === titleInputRef.current && + typeof document !== 'undefined' && + document.hasFocus(); + if (!isTitleActivelyEditing) { + setTitle(evt.title); + } + if (docRef.current) { + const updated = { ...docRef.current, title: evt.title }; + setDoc(updated); + onDocMetadataChange(updated); + } + } + break; + } + + case 'block_created': { + if (!evt.block) return; + const incoming = evt.block; + setBlocks((prev) => { + if (prev.some((b) => b.id === incoming.id)) { + return prev.map((b) => (b.id === incoming.id ? incoming : b)); + } + const next = [...prev]; + if (evt.afterBlockId) { + const idx = next.findIndex((b) => b.id === evt.afterBlockId); + if (idx >= 0) { + next.splice(idx + 1, 0, incoming); + return ensureNonEmptyBlocks(docId, next); + } + } + next.push(incoming); + return ensureNonEmptyBlocks(docId, next); + }); + break; + } + + case 'block_updated': { + const targetId = evt.blockId || evt.block?.id; + if (!targetId) return; + setBlocks((prev) => + prev.map((b) => { + if (b.id !== targetId) return b; + if (evt.block) { + return { ...b, ...evt.block }; + } + return { + ...b, + content: evt.content !== undefined ? evt.content : b.content, + level: evt.level !== undefined ? evt.level : b.level, + checked: evt.checked !== undefined ? evt.checked : b.checked, + collapsed: evt.collapsed !== undefined ? evt.collapsed : b.collapsed, + }; + }) + ); + break; + } + + case 'block_deleted': { + if (!evt.blockId) return; + setBlocks((prev) => + ensureNonEmptyBlocks( + docId, + prev.filter((b) => b.id !== evt.blockId) + ) + ); + break; + } + + case 'block_moved': { + if (!evt.blockId || evt.newIndex === undefined) return; + const targetIndex = evt.newIndex; + setBlocks((prev) => { + const fromIdx = prev.findIndex((b) => b.id === evt.blockId); + if (fromIdx === -1) return prev; + const copy = [...prev]; + const [moving] = copy.splice(fromIdx, 1); + const updatedMoving: DocumentBlock = { + ...moving, + level: evt.level !== undefined ? evt.level : moving.level, + }; + const boundedIdx = Math.max(0, Math.min(targetIndex, copy.length)); + copy.splice(boundedIdx, 0, updatedMoving); + return ensureNonEmptyBlocks(docId, copy); + }); + break; + } + + case 'blocks_replaced': { + if (Array.isArray(evt.blocks)) { + setBlocks(ensureNonEmptyBlocks(docId, evt.blocks)); + } + break; + } + } + }, + [docId, onDocMetadataChange] + ); + + const { + status: syncStatus, + remoteCursors, + sendCursorMoved, + sendBlockCreated, + sendBlockUpdated, + sendBlockDeleted, + sendBlockMoved, + sendTitleUpdated, + } = useDocumentSync(docId, handleRemoteEvent, canEdit, currentUser); + + useEffect(() => { + onSyncStatusChange?.(syncStatus); + }, [syncStatus, onSyncStatusChange]); + + // Fetch isolated document state on mount + useEffect(() => { + let isCancelled = false; + if (!initialDoc) { + setLoading(true); + } + setError(null); + + api + .getDocument(docId) + .then((fetched) => { + if (isCancelled) return; + setDoc(fetched); + setTitle(fetched.title || 'Unbenannte Notiz'); + setBlocks(ensureNonEmptyBlocks(docId, fetched.blocks)); + setLoading(false); + onDocMetadataChange(fetched); + }) + .catch((err: any) => { + if (isCancelled) return; + setError(err?.message || 'Dokument konnte nicht geladen werden'); + setLoading(false); + }); + + return () => { + isCancelled = true; + if (titleDebounceRef.current !== null) { + window.clearTimeout(titleDebounceRef.current); + titleDebounceRef.current = null; + } + }; + }, [docId]); + + // Extract all #tags present across the current blocks + const extractedTags = useMemo(() => { + const tagSet = new Set(); + for (const b of blocks) { + const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g); + if (matches) { + for (const m of matches) { + tagSet.add(m.slice(1).toLowerCase()); + } + } + } + return Array.from(tagSet); + }, [blocks]); + + // Keep parent sidebar metadata in sync when blocks/tags change + useEffect(() => { + if (!docRef.current) return; + const updatedDoc: Document = { + ...docRef.current, + title, + blocks, + tags: extractedTags, + updatedAt: new Date().toISOString(), + }; + docRef.current = updatedDoc; + onDocMetadataChange(updatedDoc); + }, [blocks, extractedTags, title]); + + // Active remote users in this room (excluding self) + const activeUsers = useMemo(() => { + const list: RemoteCursor[] = []; + const seen = new Set(); + for (const c of Object.values(remoteCursors)) { + const key = c.userId || c.clientId; + if (!seen.has(key)) { + seen.add(key); + list.push(c); + } + } + return list; + }, [remoteCursors]); + + const handleTitleChange = (e: React.ChangeEvent) => { + if (!canEdit) return; + const nextTitle = e.target.value; + setTitle(nextTitle); + + if (titleDebounceRef.current !== null) { + window.clearTimeout(titleDebounceRef.current); + } + titleDebounceRef.current = window.setTimeout(() => { + titleDebounceRef.current = null; + sendTitleUpdated(nextTitle); + }, 250); + }; + + const handleTitleBlur = () => { + if (titleDebounceRef.current !== null) { + window.clearTimeout(titleDebounceRef.current); + titleDebounceRef.current = null; + sendTitleUpdated(title); + } + }; + + // Flush title debouncing on window blur so background window never holds stale pending title + useEffect(() => { + const handleWindowBlur = () => { + if (titleDebounceRef.current !== null) { + window.clearTimeout(titleDebounceRef.current); + titleDebounceRef.current = null; + sendTitleUpdated(title); + } + }; + window.addEventListener('blur', handleWindowBlur); + return () => window.removeEventListener('blur', handleWindowBlur); + }, [title, sendTitleUpdated]); + + const handleUpdateBlock = useCallback( + (updatedBlock: DocumentBlock) => { + setBlocks((prev) => + prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b)) + ); + sendBlockUpdated(updatedBlock); + }, + [sendBlockUpdated] + ); + + const handleCreateBlock = useCallback( + (newBlock: DocumentBlock, afterBlockId?: string) => { + setBlocks((prev) => { + const next = [...prev]; + if (afterBlockId) { + const idx = next.findIndex((b) => b.id === afterBlockId); + if (idx >= 0) { + next.splice(idx + 1, 0, newBlock); + const normalized = ensureNonEmptyBlocks(docId, next); + sendBlockCreated(newBlock, afterBlockId, normalized); + return normalized; + } + } + next.push(newBlock); + const normalized = ensureNonEmptyBlocks(docId, next); + sendBlockCreated(newBlock, afterBlockId, normalized); + return normalized; + }); + }, + [docId, sendBlockCreated] + ); + + const handleDeleteBlock = useCallback( + (blockId: string) => { + setBlocks((prev) => { + const filtered = prev.filter((b) => b.id !== blockId); + const normalized = ensureNonEmptyBlocks(docId, filtered); + sendBlockDeleted(blockId, normalized); + return normalized; + }); + }, + [docId, sendBlockDeleted] + ); + + const handleMoveBlock = useCallback( + (blockId: string, newIndex: number, level?: number) => { + setBlocks((prev) => { + const fromIdx = prev.findIndex((b) => b.id === blockId); + if (fromIdx === -1) return prev; + const copy = [...prev]; + const [moving] = copy.splice(fromIdx, 1); + const updatedMoving: DocumentBlock = { + ...moving, + level: level !== undefined ? level : moving.level, + }; + const boundedIdx = Math.max(0, Math.min(newIndex, copy.length)); + copy.splice(boundedIdx, 0, updatedMoving); + const normalized = ensureNonEmptyBlocks(docId, copy); + sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized); + return normalized; + }); + }, + [docId, sendBlockMoved] + ); + + const handleTogglePin = async () => { + if (!doc) return; + try { + const updated = await api.updateDocument(doc.id, { isPinned: !doc.isPinned }); + setDoc(updated); + onDocMetadataChange(updated); + } catch { + // ignore + } + }; + + const handleSelectIcon = async (icon: string) => { + setShowEmojiPicker(false); + if (!doc || !canEdit) return; + try { + const updated = await api.updateDocument(doc.id, { icon }); + setDoc(updated); + onDocMetadataChange(updated); + } catch { + // ignore + } + }; + + const handleExportMarkdown = () => { + const lines: string[] = [`# ${title || 'Unbenannte Notiz'}`, '']; + for (const b of blocks) { + const indent = ' '.repeat(Math.max(0, b.level || 0)); + if (b.type === 'todo') { + lines.push(`${indent}- [${b.checked ? 'x' : ' '}] ${b.content}`); + } else if (b.type === 'h1') { + lines.push(`${indent}- # ${b.content}`); + } else if (b.type === 'h2') { + lines.push(`${indent}- ## ${b.content}`); + } else if (b.type === 'h3') { + lines.push(`${indent}- ### ${b.content}`); + } else if (b.type === 'code') { + lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``); + } else if (b.type === 'divider') { + lines.push(`${indent}- ---`); + } else { + lines.push(`${indent}- ${b.content}`); + } + } + const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${(title || 'notiz').toLowerCase().replace(/[^a-z0-9äöüß_-]+/gi, '-')}.md`; + a.click(); + URL.revokeObjectURL(url); + }; + + if (loading) { + return ( +
+ + Notiz wird geladen… +
+ ); + } + + if (error) { + return ( +
+

{error}

+ {onBackMobile && ( + + )} +
+ ); + } + + return ( +
+ {/* Top Document Action Bar */} +
+
+ {onBackMobile && ( + + )} + + {/* Breadcrumbs Navigation (Fokus-Modus) or Doc Indicator */} + {zoomedBlock && breadcrumbs.length > 0 ? ( + + ) : ( +
+ + + {title || doc?.title || 'Dokument'} + +
+ )} + + {/* Read-only indicator */} + {!canEdit && ( + + + Nur Lesen + + )} +
+ + {/* Right Status & Action Buttons */} +
+ {/* Active Presence Avatars (Immer rund: rounded-full mit Überlappung) */} + {activeUsers.length > 0 && ( +
+ {activeUsers.map((u) => { + const initials = (u.userName || 'A') + .split(' ') + .map((n) => n[0]) + .slice(0, 2) + .join('') + .toUpperCase(); + return ( +
+
+ {initials} +
+ {/* Tooltip */} +
+ {u.userName} +
+
+ ); + })} +
+ )} + + {/* Sharing Section (1:1 styling from SharedListsView) */} + {doc && (() => { + const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin'); + const sharesCount = doc.sharedWith?.length || 0; + const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId); + const ownerDisplayName = doc.ownerName || 'Eigentümer'; + + return ( +
+ {isShared && ( + isOwner ? ( + + ) : ( + + ) + )} + + +
+ ); + })()} + + + + + + {canEdit && (doc?.isOwner || doc?.permission === 'owner') && ( + + )} +
+
+ + {/* Main Content Area */} +
+ {zoomedBlock ? ( + /* ========================================================================= + FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW) + ========================================================================= */ +
+ {/* Prominent Zoomed Ast-Heading */} +
+
+
+ + + Fokus-Modus + + + Isolierter Ast • Zoom aktiv + +
+ { + handleUpdateBlock({ ...zoomedBlock, content: e.target.value }); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + const firstChild: DocumentBlock = { + id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, + documentId: docId, + type: 'text', + content: '', + level: levelOffset, + position: (zoomedBlock.position || 1000) + 10, + }; + handleCreateBlock(firstChild, zoomedBlock.id); + } + }} + placeholder="Ast-Titel…" + readOnly={!canEdit} + className="w-full text-2xl sm:text-3xl font-extrabold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight" + /> +
+ + +
+ + {/* Subtree Children or Empty State */} + {childBlocks.length === 0 ? ( +
+

Dieser Ast hat noch keine Unterpunkte.

+ {canEdit && ( + + )} +
+ ) : ( + + )} +
+ ) : ( + /* ========================================================================= + STANDARD GESAMTDOKUMENT-ANSICHT + ========================================================================= */ + <> + {/* Document Icon + Title Header */} +
+
+ + {showEmojiPicker && canEdit && ( +
+ {EMOJI_PALETTE.map((emoji) => ( + + ))} +
+ )} +
+ + +
+ + {/* Logseq Outliner Tree */} + + + )} +
+
+ ); +}; diff --git a/frontend/src/features/workspace/LinkModal.tsx b/frontend/src/features/workspace/LinkModal.tsx new file mode 100644 index 0000000..2f31dcf --- /dev/null +++ b/frontend/src/features/workspace/LinkModal.tsx @@ -0,0 +1,308 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react'; +import { + Link as LinkIcon, + FileText, + Globe, + X, + Search, + ExternalLink, + Loader2, + Plus, +} from 'lucide-react'; +import { api } from '../../lib/api'; +import { Document } from '../../lib/types'; + +export interface LinkModalProps { + isOpen: boolean; + onClose: () => void; + onInsertLink: (markdown: string) => void; + availableDocs?: { id: string; title: string; icon?: string; folderPath?: string }[]; +} + +export const LinkModal: React.FC = ({ + isOpen, + onClose, + onInsertLink, + availableDocs, +}) => { + const [tab, setTab] = useState<'note' | 'web'>('note'); + const [search, setSearch] = useState(''); + const [docs, setDocs] = useState([]); + const [loading, setLoading] = useState(false); + + const [webTitle, setWebTitle] = useState(''); + const [webUrl, setWebUrl] = useState(''); + + const searchInputRef = useRef(null); + const webTitleInputRef = useRef(null); + + // Load documents when modal opens + useEffect(() => { + if (!isOpen) return; + if (availableDocs && availableDocs.length > 0) { + setDocs(availableDocs as Document[]); + return; + } + let isCancelled = false; + setLoading(true); + api + .getDocuments() + .then((fetched) => { + if (!isCancelled) { + setDocs(fetched); + setLoading(false); + } + }) + .catch(() => { + if (!isCancelled) { + setLoading(false); + } + }); + + return () => { + isCancelled = true; + }; + }, [isOpen, availableDocs]); + + // Focus input on mount or tab change + useEffect(() => { + if (!isOpen) return; + const timer = setTimeout(() => { + if (tab === 'note') { + searchInputRef.current?.focus(); + } else { + webTitleInputRef.current?.focus(); + } + }, 50); + return () => clearTimeout(timer); + }, [isOpen, tab]); + + // Close on Escape + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onClose]); + + const filteredDocs = useMemo(() => { + if (!search.trim()) return docs; + const q = search.toLowerCase(); + return docs.filter( + (d) => + d.title.toLowerCase().includes(q) || + (d.folderPath && d.folderPath.toLowerCase().includes(q)) + ); + }, [docs, search]); + + const handleSelectNote = (doc: { title: string }) => { + const noteTitle = doc.title.trim() || 'Notiz'; + onInsertLink(`[[${noteTitle}]]`); + }; + + const handleWebLinkSubmit = (e: React.FormEvent) => { + e.preventDefault(); + let url = webUrl.trim(); + if (!url) return; + if (!/^https?:\/\//i.test(url) && !url.startsWith('#') && !url.startsWith('/')) { + url = `https://${url}`; + } + const label = webTitle.trim() || url; + onInsertLink(`[${label}](${url})`); + }; + + if (!isOpen) return null; + + return ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Header - identical to UnifiedShareModal */} +
+
+
+ +
+
+

Link einfügen

+

+ Notiz intern verknüpfen oder externen Web-Link einbetten +

+
+
+ +
+ + {/* Control Box - matching UnifiedShareModal form header */} +
+
+ Link-Typ: +
+ + +
+
+ + {tab === 'note' ? ( +
+ + setSearch(e.target.value)} + placeholder="Notiz suchen (Titel oder Ordner)…" + className="input-field bg-panel h-8 pl-8 pr-2.5 text-xs w-full text-main placeholder:text-muted" + /> +
+ ) : ( + +
+ + setWebTitle(e.target.value)} + placeholder="z. B. MindSift Dokumentation" + className="input-field bg-panel h-8 px-2.5 text-xs w-full text-main placeholder:text-muted" + /> +
+ +
+ + setWebUrl(e.target.value)} + placeholder="https://example.com" + className="input-field bg-panel h-8 px-2.5 text-xs w-full text-main placeholder:text-muted" + /> +
+ +
+ + +
+ + )} +
+ + {/* Note List Section (Matching Active Shares list in UnifiedShareModal) */} + {tab === 'note' && ( +
+
+ Verfügbare Notizen + + {filteredDocs.length} {filteredDocs.length === 1 ? 'Notiz' : 'Notizen'} + +
+ +
+ {loading ? ( +
+ + Notizen werden geladen… +
+ ) : filteredDocs.length === 0 ? ( +
+ + + {search + ? `Keine Notiz für "${search}" gefunden.` + : 'Keine Notizen im Workspace vorhanden.'} + +
+ ) : ( + filteredDocs.map((d) => ( +
handleSelectNote(d)} + className="flex items-center justify-between p-2.5 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors group cursor-pointer" + > +
+ {d.icon || '📝'} +
+
+ {d.title || 'Unbenannte Notiz'} +
+ {d.folderPath && ( +
+ 📁 {d.folderPath} +
+ )} +
+
+ +
+ )) + )} +
+
+ )} +
+
+ ); +}; diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx new file mode 100644 index 0000000..0d3ef76 --- /dev/null +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -0,0 +1,829 @@ +import React, { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } from 'react'; +import { + ChevronRight, + ChevronDown, + Check, + X, + GripVertical, + Code, + Heading1, + Heading2, + Heading3, + ListTodo, + Minus, + MessageSquare, + Hash, + ExternalLink, + Maximize2, + Link as LinkIcon, + FileText, +} from 'lucide-react'; +import { DocumentBlock, BlockType } from '../../lib/types'; +import { RemoteCursor } from '../../lib/useDocumentSync'; +import { LinkModal } from './LinkModal'; + +export interface SlashCommand { + type: BlockType | 'link'; + label: string; + desc: string; + icon: React.ReactNode; +} + +const SLASH_COMMANDS: SlashCommand[] = [ + { type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: }, + { type: 'link', label: '/link', desc: 'Notiz verknüpfen oder Web-Link einfügen', icon: }, + { type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: }, + { type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: }, + { type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: }, + { type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: }, + { type: 'callout', label: '/callout', desc: 'Hinweisbox', icon: }, + { type: 'divider', label: '/divider', desc: 'Trennlinie', icon: }, +]; + +export interface OutlinerBlockProps { + block: DocumentBlock; + index: number; + maxAllowedLevel: number; + hasChildren: boolean; + isFocused: boolean; + canEdit: boolean; + activeRemoteCursors?: RemoteCursor[]; + sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void; + levelOffset?: number; + onZoomBlock?: (blockId: string) => void; + onFocusBlock: (blockId: string) => void; + onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; + onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void; + onDeleteBlock: (blockId: string) => void; + onIndentBlock: (blockId: string, delta: 1 | -1) => void; + onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void; + onTagClick?: (tag: string) => void; + onWikiLinkClick?: (pageTitle: string) => void; + onDragStartBlock?: (index: number) => void; + onDragOverBlock?: (e: React.DragEvent, index: number) => void; + onDropBlock?: (index: number) => void; + isDragTarget?: boolean; + isTargeted?: boolean; + registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void; +} + +export const OutlinerBlock: React.FC = ({ + block, + index, + hasChildren, + isFocused, + canEdit, + activeRemoteCursors = [], + sendCursorMoved, + levelOffset = 0, + onZoomBlock, + onFocusBlock, + onUpdateBlock, + onCreateBlockBelow, + onDeleteBlock, + onIndentBlock, + onNavigateFocus, + onTagClick, + onWikiLinkClick, + onDragStartBlock, + onDragOverBlock, + onDropBlock, + isDragTarget, + isTargeted, + registerTextareaRef, +}) => { + const [localContent, setLocalContent] = useState(block.content || ''); + const [slashSearch, setSlashSearch] = useState(null); + const [slashIndex, setSlashIndex] = useState(0); + const [showLinkModal, setShowLinkModal] = useState(false); + + const textareaRef = useRef(null); + const debounceTimerRef = useRef(null); + const latestBlockRef = useRef(block); + latestBlockRef.current = block; + const localContentRef = useRef(localContent); + localContentRef.current = localContent; + + // Callback refs to avoid stale closures in setTimeout, debounce flush, and event listeners + const onUpdateBlockRef = useRef(onUpdateBlock); + onUpdateBlockRef.current = onUpdateBlock; + const onFocusBlockRef = useRef(onFocusBlock); + onFocusBlockRef.current = onFocusBlock; + const onCreateBlockBelowRef = useRef(onCreateBlockBelow); + onCreateBlockBelowRef.current = onCreateBlockBelow; + const onDeleteBlockRef = useRef(onDeleteBlock); + onDeleteBlockRef.current = onDeleteBlock; + const onIndentBlockRef = useRef(onIndentBlock); + onIndentBlockRef.current = onIndentBlock; + const onNavigateFocusRef = useRef(onNavigateFocus); + onNavigateFocusRef.current = onNavigateFocus; + const sendCursorMovedRef = useRef(sendCursorMoved); + sendCursorMovedRef.current = sendCursorMoved; + + // Track if this input currently has actual focus in an active browser window + const isFocusedRef = useRef(false); + + const flushDebounce = useCallback( + (contentToFlush: string, overrides?: Partial) => { + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + const nextBlock: DocumentBlock = { + ...latestBlockRef.current, + content: contentToFlush, + ...overrides, + }; + onUpdateBlockRef.current(nextBlock, true); + }, + [] + ); + + // Sync incoming remote changes ONLY when this textarea is NOT actively being edited + // Condition: isFocusedRef.current === true && document.hasFocus() + useEffect(() => { + const isActivelyEditing = + isFocusedRef.current === true && + typeof document !== 'undefined' && + document.hasFocus() && + textareaRef.current !== null && + document.activeElement === textareaRef.current; + + if (!isActivelyEditing && block.content !== localContentRef.current) { + setLocalContent(block.content || ''); + localContentRef.current = block.content || ''; + } + }, [block.content]); + + // Handle window blur / focus to instantly flush debounced input and clear zombie focus + useEffect(() => { + const handleWindowBlur = () => { + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + flushDebounce(localContentRef.current); + } else if (localContentRef.current !== latestBlockRef.current.content) { + flushDebounce(localContentRef.current); + } + isFocusedRef.current = false; + sendCursorMovedRef.current?.(null); + }; + + const handleWindowFocus = () => { + const isThisActive = + textareaRef.current !== null && + document.activeElement === textareaRef.current && + document.hasFocus(); + isFocusedRef.current = isThisActive; + }; + + window.addEventListener('blur', handleWindowBlur); + window.addEventListener('focus', handleWindowFocus); + return () => { + window.removeEventListener('blur', handleWindowBlur); + window.removeEventListener('focus', handleWindowFocus); + }; + }, [flushDebounce]); + + useEffect(() => { + return () => { + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + }; + }, []); + + const adjustHeight = useCallback(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = 'auto'; + el.style.height = `${Math.max(22, el.scrollHeight)}px`; + }, []); + + useLayoutEffect(() => { + adjustHeight(); + }, [localContent, block.type, block.level, adjustHeight]); + + useEffect(() => { + window.addEventListener('resize', adjustHeight); + return () => window.removeEventListener('resize', adjustHeight); + }, [adjustHeight]); + + const filteredSlashCommands = useMemo(() => { + if (slashSearch === null) return []; + const q = slashSearch.toLowerCase(); + return SLASH_COMMANDS.filter( + (c) => c.label.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q) + ); + }, [slashSearch]); + + const applySlashCommand = (cmd: SlashCommand) => { + setSlashSearch(null); + const clean = localContent.replace(/\/([a-z0-9]*)$/i, '').trim(); + if (cmd.type === 'link') { + setLocalContent(clean); + setShowLinkModal(true); + return; + } + setLocalContent(clean); + flushDebounce(clean, { + type: cmd.type, + checked: cmd.type === 'todo' ? Boolean(block.checked) : false, + }); + }; + + const handleInsertLink = (linkMarkdown: string) => { + setShowLinkModal(false); + const cursor = textareaRef.current?.selectionStart ?? localContent.length; + const before = localContent.slice(0, cursor); + const after = localContent.slice(cursor); + const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim(); + setLocalContent(newContent); + flushDebounce(newContent); + }; + + const handleContentChange = (e: React.ChangeEvent) => { + if (!canEdit) return; + let val = e.target.value; + let typeOverride: BlockType | undefined; + let checkedOverride: boolean | undefined; + + // Quick markdown shortcuts + if (val.startsWith('[] ') || val.startsWith('[ ] ')) { + val = val.replace(/^\[\s?\]\s/, ''); + typeOverride = 'todo'; + checkedOverride = false; + } else if (val.startsWith('[x] ') || val.startsWith('[X] ')) { + val = val.replace(/^\[[xX]\]\s/, ''); + typeOverride = 'todo'; + checkedOverride = true; + } else if (val.startsWith('# ') && block.type !== 'h1') { + val = val.slice(2); + typeOverride = 'h1'; + } else if (val.startsWith('## ') && block.type !== 'h2') { + val = val.slice(3); + typeOverride = 'h2'; + } else if (val.startsWith('### ') && block.type !== 'h3') { + val = val.slice(4); + typeOverride = 'h3'; + } + + // Check for slash command invocation + const slashMatch = val.match(/\/([a-z0-9]*)$/i); + if (slashMatch) { + setSlashSearch(slashMatch[1]); + setSlashIndex(0); + } else { + setSlashSearch(null); + } + + setLocalContent(val); + sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd); + + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + } + + if (typeOverride) { + flushDebounce(val, { + type: typeOverride, + checked: checkedOverride ?? latestBlockRef.current.checked, + }); + return; + } + + debounceTimerRef.current = window.setTimeout(() => { + debounceTimerRef.current = null; + onUpdateBlockRef.current({ + ...latestBlockRef.current, + content: val, + }); + }, 150); + }; + + const handleFocus = (e: React.FocusEvent) => { + isFocusedRef.current = true; + onFocusBlockRef.current(block.id); + sendCursorMovedRef.current?.(block.id, e.target.selectionStart, e.target.selectionEnd); + }; + + const handleBlur = () => { + isFocusedRef.current = false; + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + flushDebounce(localContentRef.current); + } else if (localContentRef.current !== latestBlockRef.current.content) { + flushDebounce(localContentRef.current); + } + sendCursorMovedRef.current?.(null); + }; + + const handleClick = (e: React.MouseEvent) => { + sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd); + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd); + }; + + const handleSelect = (e: React.SyntheticEvent) => { + const target = e.currentTarget; + sendCursorMovedRef.current?.(block.id, target.selectionStart, target.selectionEnd); + }; + + const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!canEdit) return; + + // Slash command navigation + if (slashSearch !== null && filteredSlashCommands.length > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSlashIndex((prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length); + return; + } + if (e.key === 'Enter' || e.key === 'Tab') { + e.preventDefault(); + const selected = filteredSlashCommands[slashIndex]; + if (selected) { + applySlashCommand(selected); + } + return; + } + if (e.key === 'Escape') { + e.preventDefault(); + setSlashSearch(null); + return; + } + } + + // 1. Tab / Shift+Tab -> Strict Indent / Outdent + if (e.key === 'Tab') { + e.preventDefault(); + e.stopPropagation(); + flushDebounce(localContent); + onIndentBlockRef.current(block.id, e.shiftKey ? -1 : 1); + return; + } + + // 2. Enter (without Shift) -> Create new block on same level + if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') { + e.preventDefault(); + e.stopPropagation(); + setSlashSearch(null); + flushDebounce(localContent); + onCreateBlockBelowRef.current(block.id, localContent); + return; + } + + // 3. Backspace on empty block -> IMMEDIATELY DELETE THE BLOCK (regardless of level)! + // Outdenting is strictly done via Shift+Tab. + if (e.key === 'Backspace' && localContent === '') { + e.preventDefault(); + e.stopPropagation(); + setSlashSearch(null); + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + onDeleteBlockRef.current(block.id); + return; + } + + // 4. Arrow navigation between blocks + if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) { + const el = textareaRef.current; + if (el && el.selectionStart === 0 && el.selectionEnd === 0) { + e.preventDefault(); + flushDebounce(localContent); + onNavigateFocusRef.current(block.id, 'up'); + } + return; + } + + if (e.key === 'ArrowDown' && !e.shiftKey && !e.altKey) { + const el = textareaRef.current; + if (el && el.selectionStart === localContent.length && el.selectionEnd === localContent.length) { + e.preventDefault(); + flushDebounce(localContent); + onNavigateFocusRef.current(block.id, 'down'); + } + return; + } + }; + + const level = Math.max(0, block.level || 0); + const visualLevel = Math.max(0, level - (levelOffset || 0)); + + // Parse rich tokens (WikiLinks [[Page]], Markdown Links [Title](url), raw URLs, and #Tags) + const tokens = useMemo(() => { + const parts = (localContent || '').split( + /(\[\[.*?\]\]|\[.*?\]\(https?:\/\/.*?\)|\bhttps?:\/\/[^\s]+|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g + ); + return parts.filter(Boolean); + }, [localContent]); + + const hasSpecialTokens = tokens.some( + (t) => + t.startsWith('#') || + (t.startsWith('[[') && t.endsWith(']]')) || + /^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) || + /^https?:\/\//.test(t) + ); + + return ( +
onDragOverBlock?.(e, index)} + onDrop={() => onDropBlock?.(index)} + > + {/* Remote Cursors Floating Name Badges on Block */} + {activeRemoteCursors.length > 0 && ( +
+ {activeRemoteCursors.map((c) => ( + + {c.userName} + + ))} +
+ )} + + {/* Indentation Guide Line */} + {visualLevel > 0 && + Array.from({ length: visualLevel }).map((_, lvlIdx) => ( +
+ ))} + + {/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */} +
+ {canEdit && ( + + )} + + {hasChildren ? ( + + ) : ( + + )} + + {block.type === 'todo' ? ( + + ) : ( + + )} +
+ + {/* Main Block Content */} +
+ {block.type === 'divider' ? ( +
+
+
+ ) : block.type === 'code' ? ( +
+
+ Code + +
+