Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49a49094de | ||
|
|
5c6f162c96 | ||
|
|
33e1223e69 |
No files matched your search
@@ -5,10 +5,7 @@ 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=
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
# 🧠 BRAIN.md — MindSift Internal Dev Cheat Sheet & Architecture Log
|
||||
> **Zweck:** Zentrales, technisch präzises Nachschlagewerk für Entwicklung, Design-System, Datenbank-Architektur, State-Machines, Backend-APIs und aktuellen Umsetzungsstand. Wird bei jeder Änderung aktuell gehalten.
|
||||
|
||||
---
|
||||
|
||||
## 📋 0. Aktueller Projektplan & Umsetzungs-Status
|
||||
|
||||
### Erledigte Meilensteine
|
||||
- [x] **CI/CD & Multi-Stage Docker Build (`Dockerfile`, `.gitea/workflows/build-and-publish.yaml`):**
|
||||
- Alle Frontend-Checks (`tsc --noEmit`, `vite build`) und Backend-Tests (`go test -v ./...`) laufen direkt im Multi-Stage Container-Build ohne doppelte Vorab-Steps.
|
||||
- Push auf `dev` baut und überschreibt `git.dsit.me/sandrodach/mindsift:dev`.
|
||||
- [x] **Single-File CSS Theming Engine & Design-System (`frontend/src/themes/`, `theme.ts`, `tailwind.config.js`):**
|
||||
- Jedes Theme ist als **genau eine einzige, eigenständige CSS-Datei** unter `frontend/src/themes/` definiert.
|
||||
- Alle Farben, Kontraste, Oberflächen, Rahmen, Statusfarben und Rundungen sind in 6 klaren Gruppen organisiert.
|
||||
- 4 vorkonfigurierte Themes (Dark, Light, High Contrast, Soft) plus saubere `theme-template.css`-Vorlage für eigene Themes.
|
||||
- Vollständige Trennung von Struktur und Styling: React-Komponenten enthalten keine hardcodierten Farbwerte mehr, sondern ausschließlich semantische Klassen und CSS-Variablen (`text-main`, `text-muted`, `bg-canvas`, `panel-surface`, `checkbox-square`, etc.).
|
||||
- Schnellauswahl im Profil-Dropdown im Header sowie visueller Theme-Manager in den Benutzereinstellungen.
|
||||
- [x] **Monochromes, eckiges Desktop-Design & Zentrale CSS-Steuerung:**
|
||||
- Zentrale CSS-Variablen (`--bg-canvas`, `--bg-panel`, `--accent`, `--border-color`, `--radius-card`, `--radius-btn`, `--radius-input`) in den Theme-Dateien.
|
||||
- Eckige Tabs, eckige Panels/Karten (`0px` Radius im Dark/Monochrome-Theme), nur die Schnell-Eingabe-Blase (`InstantCaptureBar`) oben im Header nutzt `--radius-pill`.
|
||||
- Untere doppelte Navigationsleiste entfernt; Schnell-Notiz-Blase sitzt ganz oben im Header über den Tabs.
|
||||
- Workspace links als eckiger, monochromer Datei-/Ordner-Baum (File Explorer Tree mit Unterordnern).
|
||||
- [x] **Quick Dump Notes + Triage Swipen + Direkt-Zuordnung (`InstantCaptureBar.tsx`, `TriageDeck.tsx`, `server.go`):**
|
||||
- **Quick Dump (Standard):** Text oben eingeben + `Enter` wirft den Gedanken sofort als Quick Dump (`status: 'inbox'`) ins Triage-Deck.
|
||||
- **Direkt-Zuordnung bei der Eingabe:** Über das Auswahl-Menü (`Quick Dump ▾`) in der Eingabeleiste kann ein Eintrag alternativ direkt als **Aufgabe** (`target: 'task'`), direkt auf eine **Geteilte Liste** (`target: 'list_item'`) oder direkt als **Workspace-Notiz** (`target: 'document'`) gespeichert werden.
|
||||
- **Triage-Entscheidung & Warteschlange:** Im Triage-Deck per Swipe/Klick **Wegwerfen** (`←` / `H`), **Später** (`Leertaste` – ans Ende schieben), **Auf Liste**, **Als Notiz** (`D`), **Mit Termin** (`T`) oder **Als Aufgabe** (`→` / `L`). Inklusive Inline-Textbearbeitung und Warteschlangen-Liste unter der aktiven Karte.
|
||||
- [x] **Mehrere Aufgabenlisten & Kanban-Board (`TasksView.tsx`, `models.go`, `sqlite.go`):**
|
||||
- Beliebig viele Aufgabenlisten (z.B. Privat, Arbeit, Homelab) mit eigenem Farb- und Icon-Code.
|
||||
- Teilen von Aufgabenlisten mit Teams/Spaces.
|
||||
- Umschalter zwischen kompakter Listenansicht und 3-Spalten-Kanban-Board (`Zu erledigen`, `In Arbeit`, `Erledigt`) mit 1-Klick-Statuswechsel, Fälligkeitsdaten und Notizfeldern.
|
||||
- [x] **Ersteinrichtung (First-Boot Setup Wizard), Echter Login & Einladungscodes (`SetupAndAuthView.tsx`, `server.go`, `sqlite.go`):**
|
||||
- Kein hartcodierter Auto-Seed-Benutzer (`admin@mindsift.local` / `"Sandro"`) und kein automatischer `quickStart`-Login mehr beim Seitenaufruf.
|
||||
- Beim ersten Start (`0` Benutzer in der Datenbank) erkennt `GET /api/v1/auth/status` automatisch `needsSetup: true` und zeigt den **Ersteinrichtungs-Assistenten** (`POST /api/v1/auth/setup`) zum Anlegen des ersten Server-Admin-Kontos und der Instanz-Grundeinstellungen.
|
||||
- Öffentliche Selbstregistrierung ist standardmäßig deaktiviert (`allowRegistration: false`). Neue Benutzer können nur über einen **Einladungscode** (`MS-XXXXXXXX`, Tabelle `user_invites`) oder direkt durch einen Admin (`admin`) angelegt werden.
|
||||
- [x] **Profile-Dropdown & Vollbild-Einstellungsseiten (`App.tsx`, `UserSettingsView.tsx`, `GroupsView.tsx`, `AdminSettingsView.tsx`):**
|
||||
- Klick auf den Benutzer oben rechts öffnet ein eckiges, monochromes Dropdown-Menü:
|
||||
- **Kontoeinstellungen** (`UserSettingsView.tsx` — Vollbild-Ansicht für Profil & Passwort, Darstellung & Themes, Multi-Channel-Alarme, Private Webhooks & Daten-Export).
|
||||
- **Gruppen** (`GroupsView.tsx` — für alle Benutzer; Self-Service Erstellung und Verwaltung von Gruppen, Mitgliedern und Rollen).
|
||||
- **Administration** (`AdminSettingsView.tsx` — nur sichtbar für `admin`; Vollbild-Ansicht für Benutzer, Einladungscodes, Server-Isolation und SMTP-Mailserver).
|
||||
- **Abmelden** (bereinigt JWT-Token und führt zurück zum Login-Bildschirm).
|
||||
- Klares Rollensystem: Global `admin` (Server-Admin) und `member` (Standard-Benutzer). Innerhalb von Gruppen: `admin` (Gruppen-Admin) und `member` (Gruppen-Mitglied).
|
||||
- Layout-Fix im Benutzer-Erstellungsformular (`grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3` mit `min-w-0`), sodass das Rollen-Dropdown niemals über den Container hinausragt.
|
||||
- [x] **Multi-Channel-Alarme: E-Mail (SMTP), Discord & Eigener Webhook (`UserSettingsView.tsx`, `AdminSettingsView.tsx`, `server.go`):**
|
||||
- Tab heißt klar **Alarme** mit frei kombinierbaren Zielen (`alarmChannels`: `'email'`, `'discord'`, `'webhook'`).
|
||||
- SMTP-Konfiguration (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS`) sowohl über `docker-compose.yml` Environment-Variablen als auch direkt in der Admin-Oberfläche (`system_settings` in SQLite) einstellbar, inklusive Live-Testversand.
|
||||
- [x] **Crash-Fix für „Aufgaben" & „Organisationen & Teams" (`sqlite.go`, `api.ts`, `TasksView.tsx`):**
|
||||
- Alle Go-Storage-Methoden initialisieren Slices strikt mit `make([]T, 0)` statt `var s []T`, damit `json.Marshal` niemals `null` statt `[]` liefert.
|
||||
- Zusätzliche `ensureArray()`-Normalisierung in `frontend/src/lib/api.ts` sowie `ViewErrorBoundary` in `App.tsx`.
|
||||
- [x] **Workspace Markdown-Wiki mit Notion-/Word-Toolbar & Disk-Sync (`WorkspaceView.tsx`, `sqlite.go`):**
|
||||
- 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/<user_id>/...` 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 (zurückspringen per Klick auf die grauen Breadcrumbs).
|
||||
- **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/<user_id>/...` als Standard-`.md`-Dateien mit portabler 2-Leerzeichen-Einrückung (` - [ ] ` bzw. ` - `), vollständig kompatibel mit externen Outlinern und Markdown-Viewern.
|
||||
- [x] **Zentrale Hilfeseite & Dokumentation (`HelpView.tsx`, `App.tsx`):**
|
||||
- Vollständige, übersichtliche Hilfe- und Tastatur-Dokumentation für Outliner, Triage, Aufgaben, geteilte Listen und Tastatur-Shortcuts.
|
||||
- Direkt erreichbar über das Benutzer-Profil-Menü („Hilfe & Anleitung") sowie den „Hilfe"-Button im Workspace-Header.
|
||||
- [x] **Workspace UX-Refinements & Datei-Explorer Drag & Drop (`WorkspaceView.tsx`, `sqlite.go`):**
|
||||
- **Dateien in Ordner ziehen:** Notizen können im Datei-Explorer per Drag & Drop direkt auf beliebige Ordner oder in das Hauptverzeichnis gezogen werden. Das alte Ordner-Dropdown im Menü wurde entfernt.
|
||||
- **Angepinnte Notizen (Favoriten):** Dokumente mit `isPinned: true` werden direkt ganz oben im Datei-Explorer in einer eigenen Schnellzugriffs-Sektion dargestellt.
|
||||
- **Bereinigter Editor:** Störende Tasten-Legende unter den Notizen entfernt; klobiger Rahmen um das Notiz-Icon entfernt; Standard-Icon ist der Zettel (`📄`).
|
||||
- **Breadcrumbs & Zoom:** Überflüssiger „Zoom aufheben"-Button entfernt; alle Breadcrumbs-Elemente inklusive Dokumententitel erscheinen einheitlich dezent in Grau ohne störende Link-Unterstreichung.
|
||||
- **Globale To-Dos:** Tab- und Bereichs-Titel auf prägnantes „Globale To-Dos" gekürzt, unnötiger Erklärungstext entfernt.
|
||||
- [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`.
|
||||
- Optimistisches Leeren des Capture-Inputs und 12-Sekunden-Fetch-Timeout.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 1. Single-File CSS Theming Engine & Design-System
|
||||
|
||||
### Philosophie: 1 Datei = 1 komplettes Theme
|
||||
Anstatt Styling in Dutzenden Tailwind-Klassen über hunderte Komponenten zu verstreuen, steuert **eine einzige CSS-Datei** das komplette Erscheinungsbild der Anwendung.
|
||||
|
||||
Jedes Theme liegt unter `frontend/src/themes/` und gruppiert **6 standardisierte Bereiche**:
|
||||
|
||||
```css
|
||||
:root[data-theme="dein-theme"] {
|
||||
/* ==========================================================================
|
||||
1. AKZENTFARBE (Fokus, Primäraktionen, Highlights)
|
||||
========================================================================== */
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #3b82f6;
|
||||
--accent-dim: rgba(37, 99, 235, 0.15);
|
||||
--accent-contrast: #ffffff;
|
||||
|
||||
/* ==========================================================================
|
||||
2. OBERFLÄCHEN & HINTERGRÜNDE
|
||||
========================================================================== */
|
||||
--bg-canvas: #0e1013;
|
||||
--bg-panel: #16181d;
|
||||
--bg-panel-elevated: #1f2229;
|
||||
--bg-input: #121418;
|
||||
--bg-code: #0b0c0f;
|
||||
--bg-overlay: rgba(0, 0, 0, 0.75);
|
||||
|
||||
/* ==========================================================================
|
||||
3. TEXT & KONTRASTE
|
||||
========================================================================== */
|
||||
--text-main: #f4f4f5;
|
||||
--text-muted: #a1a1aa;
|
||||
--text-faint: #71717a;
|
||||
--text-on-accent: #ffffff;
|
||||
|
||||
/* ==========================================================================
|
||||
4. RAHMEN, LINIEN & SCHATTEN
|
||||
========================================================================== */
|
||||
--border-color: rgba(255, 255, 255, 0.08);
|
||||
--border-strong: rgba(255, 255, 255, 0.18);
|
||||
--shadow-panel: 0 4px 20px -2px rgba(0, 0, 0, 0.45);
|
||||
|
||||
/* ==========================================================================
|
||||
5. STATUS- & SIGNALFARBEN
|
||||
========================================================================== */
|
||||
--status-ok: #22c55e;
|
||||
--status-err: #ef4444;
|
||||
--status-warn: #eab308;
|
||||
--status-info: #3b82f6;
|
||||
|
||||
/* ==========================================================================
|
||||
6. RUNDUNGEN / GEOMETRIE
|
||||
========================================================================== */
|
||||
--radius-card: 0px;
|
||||
--radius-btn: 2px;
|
||||
--radius-input: 2px;
|
||||
--radius-checkbox: 2px;
|
||||
--radius-pill: 9999px;
|
||||
}
|
||||
```
|
||||
|
||||
### Verfügbare Themes
|
||||
|
||||
| Theme-ID | Datei | Charakter | Standard-Geometrie |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `dark` | [`theme-dark.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/themes/theme-dark.css) | Standard Monochrom / Dark Slate | Streng eckig (`0px` Cards, `2px` Buttons) |
|
||||
| `light` | [`theme-light.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalist Light | Modern sauber (`4px` Cards, `4px` Buttons) |
|
||||
| `high-contrast` | [`theme-high-contrast.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit maximaler Lesbarkeit (AAA Kontrast) | Streng eckig (`0px`, `2px` Rahmen) |
|
||||
| `soft` | [`theme-soft.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/themes/theme-soft.css) | Soft Rounded Slate mit warmem Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) |
|
||||
| *Template* | [`theme-template.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/themes/theme-template.css) | Dokumentierte Vorlage zur Erstellung eigener Themes | Voll anpassbar |
|
||||
|
||||
### Semantische Komponenten-Klassen (`frontend/src/index.css`)
|
||||
- `.panel-surface` / `.panel-elevated`: Automatisch über `--radius-card`, `--bg-panel` und `--border-color` gesteuert.
|
||||
- `.btn-primary` / `.btn-action`: Aktionen und Buttons mit `--radius-btn`, `--accent` und `--text-on-accent`.
|
||||
- `.input-field`: Formularfelder mit `--radius-input`, `--bg-input`, `--border-color` und `--text-main`.
|
||||
- `.checkbox-square`: Kontrollkästchen mit `--radius-checkbox` und `--accent`.
|
||||
- `.badge-accent`, `.badge-neutral`, `.badge-ok`, `.badge-err`: Pillen und Zähler.
|
||||
- `.alert-ok`, `.alert-err`: Hinweiskästen mit Signalfarben.
|
||||
- `.capture-pill`: Die markante Schnell-Eingabeleiste im Header mit `--radius-pill`.
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ 2. Datenbank-Architektur (SQLite Schema & Tabellen)
|
||||
|
||||
MindSift speichert alle Daten lokal und hochperformant in SQLite (`/data/mindsift.db`).
|
||||
|
||||
### Tabellen-Übersicht
|
||||
|
||||
1. **`users`**
|
||||
- `id TEXT PRIMARY KEY`, `email TEXT UNIQUE`, `password_hash TEXT`, `display_name TEXT`
|
||||
- `role TEXT DEFAULT 'member'` (`admin`, `member`)
|
||||
- `is_active INTEGER DEFAULT 1`, `created_at DATETIME`, `updated_at DATETIME`
|
||||
2. **`system_settings`** (Instanzweite globale Konfiguration, 1 Row mit `id = 'singleton'`)
|
||||
- `sharing_mode TEXT DEFAULT 'teams_only'` (`strict_private`, `teams_only`, `open`)
|
||||
- `allow_registration INTEGER DEFAULT 0` (Öffentliche Registrierung erlaubt oder gesperrt)
|
||||
- `instance_name TEXT DEFAULT 'MindSift'`
|
||||
- `smtp_host TEXT`, `smtp_port INTEGER`, `smtp_user TEXT`, `smtp_pass TEXT`, `smtp_from TEXT`, `smtp_tls INTEGER`
|
||||
3. **`user_invites`** (Einladungscodes für neue Registrierungen)
|
||||
- `id TEXT PRIMARY KEY`, `code TEXT UNIQUE` (`MS-XXXXXXXX`), `email TEXT` (optional gebunden)
|
||||
- `role TEXT DEFAULT 'member'` (`admin`, `member`)
|
||||
- `created_by TEXT`, `used_by TEXT`, `expires_at DATETIME`, `created_at DATETIME`
|
||||
4. **`organizations` & `organization_teams` & `team_members`**
|
||||
- Strukturierung von Benutzern in Abteilungen, Familien oder Projekt-Teams.
|
||||
- Bildet die Basis für den Sichtbarkeits-Modus `teams_only`.
|
||||
5. **`spaces` & `space_members`**
|
||||
- Geteilte Kollaborations-Räume mit Einladungscode (`invite_code`), Echtzeit-WebSocket Sync und Rollen (`owner`, `editor`, `viewer`).
|
||||
6. **`shared_lists` & `list_items`**
|
||||
- Geteilte Einkaufs-, Pack- und Inventarlisten mit Kategorien, Mengen und Echtzeit-Abhaken.
|
||||
7. **`tasks` & `task_lists`**
|
||||
- Persönliche und geteilte Aufgabenlisten mit Kanban-Spalten (`todo`, `in_progress`, `done`), Fälligkeit, Priorität und Alarm-Optionen.
|
||||
8. **`quick_dumps` (Triage-Deck)**
|
||||
- Schnelle Einfälle (`status = 'inbox'`, `'discarded'`, `'converted'`), Fälligkeitsdatum und Text-Notiz.
|
||||
9. **`documents` (Workspace Markdown-Wiki)**
|
||||
- Notizen und Dokumentationen mit Folder-Pfad (`folder_path`), Tags, Pinned-Status, JSON-Blocks und physischer `.md`-Spiegelung auf Disk.
|
||||
10. **`user_alarms` & `user_webhooks`**
|
||||
- Multi-Channel Alarme (Mail, Discord, Webhook) und ausgehende Event-Webhooks.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 3. Triage & Quick-Dump State Machine
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> InstantCapture: Gedanke eingetippt
|
||||
InstantCapture --> QuickDump_Inbox: Enter (Standard)
|
||||
InstantCapture --> TaskCreated: Ziel = 'task'
|
||||
InstantCapture --> ListItemCreated: Ziel = 'list_item'
|
||||
InstantCapture --> DocCreated: Ziel = 'document'
|
||||
|
||||
QuickDump_Inbox --> TriageDeck: Stapel sichten
|
||||
TriageDeck --> Discarded: Swipe links / H (Wegwerfen)
|
||||
TriageDeck --> TaskCreated: Swipe rechts / L (Als Aufgabe)
|
||||
TriageDeck --> TaskCreated: T (Termin-Aufgabe)
|
||||
TriageDeck --> DocCreated: D (In Notiz umwandeln)
|
||||
TriageDeck --> ListItemCreated: Auf Liste schieben
|
||||
TriageDeck --> QuickDump_Inbox: Leertaste (Später - ans Ende)
|
||||
|
||||
Discarded --> [*]
|
||||
TaskCreated --> [*]
|
||||
DocCreated --> [*]
|
||||
ListItemCreated --> [*]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 👥 4. Server-Administration & Isolation Modes
|
||||
|
||||
Der Server-Administrator (`role = 'admin'`) legt unter `AdminSettingsView.tsx` global fest, wie Benutzer miteinander interagieren dürfen:
|
||||
|
||||
1. **`strict_private` (Strikt Privat):**
|
||||
- Jeder Benutzer arbeitet komplett autark. Keine teamübergreifende Benutzersuche, keine geteilten Spaces.
|
||||
2. **`teams_only` (Teams isoliert — Empfohlener Standard):**
|
||||
- Benutzer sehen im Adressbuch und bei geteilten Spaces ausschließlich Personen, mit denen sie mindestens in einem gemeinsamen Team sind.
|
||||
3. **`open` (Offene Instanz):**
|
||||
- Alle Benutzer können sich gegenseitig sehen, zu Spaces einladen und gemeinsam an Listen arbeiten.
|
||||
|
||||
---
|
||||
|
||||
## 📝 5. Workspace: Word/Notion-Toolbar, Wiki-Links & Disk-Sync
|
||||
|
||||
1. **Formatierungs-Werkzeuge:**
|
||||
- Markdown-Syntax wird in Echtzeit formatiert und visuell aufbereitet.
|
||||
- Buttons für H1, H2, H3, Fett (`**`), Kursiv (`*`), Durchgestrichen (`~~`), Code-Blöcke (```` ``` ````), Aufzählungen (`- `), Todos (`- [ ] `), Callouts (`> `), Markdown-Tabellen (`| Spalte 1 | Spalte 2 |`) und Fortschrittsbalken (`[progress:65%]`).
|
||||
2. **Wiki-Links (`[[Dokumenttitel]]`):**
|
||||
- Interne Querverweise werden als interaktive Links gerendert.
|
||||
- Ein Klick auf `[[Dokumenttitel]]` durchsucht die Workspace-Dokumente und öffnet das verlinkte Dokument direkt im Editor.
|
||||
3. **Physische Speicherung auf Festplatte:**
|
||||
- Jede Notiz wird zusätzlich zur SQLite-Tabelle als reine `.md`-Datei unter `/data/workspace/<user_id>/<folder_path>/<slug>.md` gespeichert.
|
||||
- Ermöglicht jederzeitige manuelle Datensicherung, Git-Versionierung oder Backup-Extraktion ohne Datenbank-Abhängigkeit.
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 6. Kritische Backend-Regeln (SQLite & HTTP)
|
||||
|
||||
1. **NIEMALS verschachtelte Queries bei offenem `sql.Rows`-Cursor ausführen:**
|
||||
- In [`internal/storage/sqlite.go`](file:///home/sandro/Dokumente/git/MindSift/internal/storage/sqlite.go) muss jede Schleife `for rows.Next()` zuerst alle Zeilen in ein Slice einlesen und sofort `_ = rows.Close()` aufrufen, **bevor** Folgeabfragen (`GetListItems`, `GetSpaceMembers`, `QueryRow` etc.) ausgeführt werden.
|
||||
2. **SQLite DSN-Pragmas:**
|
||||
- Jede Verbindung im `sql.DB`-Pool erhält über den DSN automatisch:
|
||||
`?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)`
|
||||
- `MaxOpenConns(8)` und `MaxIdleConns(4)` konfiguriert.
|
||||
3. **Thread-Safe Default User (`EnsureDefaultUser`):**
|
||||
- Durch `s.initMu.Lock()` abgesichert, damit parallele Browser-Requests beim ersten Laden niemals in einen `UNIQUE constraint`-Konflikt laufen.
|
||||
4. **Kein Browser-Caching auf API & `index.html`:**
|
||||
- `writeJSON` und der SPA-Fallback setzen `Cache-Control: no-store, no-cache, must-revalidate`, damit Chrome und Firefox nach jedem Container-Update sofort den aktuellen Stand zeigen.
|
||||
|
||||
---
|
||||
|
||||
## 📡 7. WebSocket Realtime Protocol (Geteilte Listen)
|
||||
|
||||
Verbindungs-URL: `ws(s)://<host>/api/v1/ws/spaces/:spaceId?token=<jwt>`
|
||||
|
||||
### Events
|
||||
- `ITEM_CREATED` (auch ausgelöst, wenn ein Gedanke per Direkt-Zuordnung oder Triage auf eine Liste geschoben wird)
|
||||
- `ITEM_TOGGLED`
|
||||
- `ITEM_DELETED`
|
||||
- `LIST_CREATED` / `LIST_DELETED` / `LIST_CLEARED`
|
||||
- `USER_PRESENCE`
|
||||
|
||||
---
|
||||
|
||||
## ⌨️ 8. Tastatur-Shortcuts (Desktop)
|
||||
|
||||
| Taste | Kontext | Aktion |
|
||||
| :--- | :--- | :--- |
|
||||
| `C` oder `/` | Überall | Fokus sofort oben in die Quick-Dump-Eingabeleiste |
|
||||
| `Enter` | Capture-Leiste | Sofort speichern (Quick Dump oder gewähltes Direkt-Ziel) |
|
||||
| `Esc` | Capture-Leiste | Eingabe leeren & Dropdown schließen |
|
||||
| `H` oder `←` | Triage-Deck | Aktiven Quick Dump wegwerfen (`discard`) |
|
||||
| `L` oder `→` | Triage-Deck | Aktiven Quick Dump sofort zur Aufgabe machen (`task`) |
|
||||
| `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.) |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 9. Gitea CI/CD & Docker Workflow
|
||||
|
||||
- **Arbeits-Branch:** `dev` (`origin/dev`)
|
||||
- **Ziel-Image:** `git.dsit.me/sandrodach/mindsift:dev`
|
||||
- **Build-Prinzip:** Ein einziger Multi-Stage-Build ([`Dockerfile`](file:///home/sandro/Dokumente/git/MindSift/Dockerfile)), der Frontend-Typecheck, Vite-Build und Go-Unit-Tests (`go test -v ./...` inkl. [`internal/api/server_test.go`](file:///home/sandro/Dokumente/git/MindSift/internal/api/server_test.go)) direkt beim Image-Bau ausführt und bei Erfolg sofort pusht.
|
||||
@@ -1,40 +0,0 @@
|
||||
# MindSift Technische Dokumentation
|
||||
|
||||
Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code im Branch `dev` geprüft (Stand Oktober 2026). Abweichungen zwischen Doku und Code melde bitte als Fehler in [flaws.md](flaws.md).
|
||||
|
||||
## Inhalt
|
||||
|
||||
| Dokument | Inhalt |
|
||||
|---|---|
|
||||
| [architecture.md](architecture.md) | Aufbau, Technologie-Stack, Start, Konfiguration, Build |
|
||||
| [database.md](database.md) | SQLite-Schema, Migrationen, Datenmodell |
|
||||
| [api/rest.md](api/rest.md) | Alle REST-Endpunkte |
|
||||
| [api/websocket.md](api/websocket.md) | Echtzeit über WebSocket und SSE, Ereignisse |
|
||||
| [modules/triage.md](modules/triage.md) | Schnellerfassung und Triage |
|
||||
| [modules/tasks.md](modules/tasks.md) | Aufgaben, Kanban, Alarme |
|
||||
| [modules/workspace.md](modules/workspace.md) | Outliner-Notizen, Ordner, Tags |
|
||||
| [modules/lists.md](modules/lists.md) | Geteilte Listen |
|
||||
| [sharing-and-permissions.md](sharing-and-permissions.md) | Rollen, Gruppen, Freigaben |
|
||||
| [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare |
|
||||
| [theming.md](theming.md) | Themes und Design-Variablen |
|
||||
| [flaws.md](flaws.md) | Bekannte Schwachstellen und technische Schulden |
|
||||
|
||||
## Überblick in 60 Sekunden
|
||||
|
||||
- **Ein Go-Binary** liefert REST-API, WebSocket/SSE und das eingebettete React-Frontend (`go:embed`). Es gibt genau einen Container und eine SQLite-Datei (`/data/mindsift.db`).
|
||||
- **Datenhaltung:** vollständig in SQLite. Es gibt keine `.md`-Dateien auf der Platte; Markdown wird bei Bedarf aus den Blöcken erzeugt (`documents.markdown_cache`, Export als `.md`).
|
||||
- **Echtzeit:** ein Hub mit Räumen und Benutzer-Kanälen. Verteilt werden Ereignisse nach REST-Änderungen. Es gibt keine CRDTs, Sperren oder Cursor-Presence.
|
||||
- **Auth:** JWT (HS256, 30 Tage) mit `token_version` und Sperrliste, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
|
||||
- **Freigaben:** Aufgabenlisten und geteilte Listen mit Rolle Bearbeiter oder Leser, Notizen und Ordner nur lesend.
|
||||
|
||||
## Entwickeln
|
||||
|
||||
```bash
|
||||
# Backend (Go 1.23)
|
||||
go vet ./... && go test ./...
|
||||
|
||||
# Frontend (Node 22)
|
||||
cd frontend && npm ci && npx tsc --noEmit && npm run build
|
||||
```
|
||||
|
||||
Das Frontend wird für das Binary nach `cmd/server/dist` gebaut (siehe `Dockerfile`). Die CI läuft unter `.gitea/workflows/build-and-publish.yaml`.
|
||||
@@ -1,129 +0,0 @@
|
||||
# REST-API
|
||||
|
||||
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>` oder `Bearer mindsift_sec_…` bzw. Header `X-API-Key`. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
|
||||
|
||||
## Öffentlich
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET | `/health` | Lebenszeichen |
|
||||
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode` |
|
||||
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
|
||||
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
|
||||
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
||||
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
||||
| POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen |
|
||||
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md) |
|
||||
| GET | `/events` | SSE-Fallback |
|
||||
| POST | `/events/rooms` | SSE-Räume beitreten/verlassen ([FLAW-02](../flaws.md)) |
|
||||
|
||||
## Konto und Allgemeines (angemeldet)
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET | `/auth/me` | Benutzer und Kennzahlen |
|
||||
| POST | `/auth/logout` | Token sperren |
|
||||
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
|
||||
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` |
|
||||
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
|
||||
| GET | `/users/directory`, `/collaborators` | Benutzerverzeichnis für Freigaben ([FLAW-10](../flaws.md)) |
|
||||
| GET | `/stats` | Zähler fürs Dashboard |
|
||||
| GET | `/search?q=` | Suche in eigenen Gedanken und Notizen |
|
||||
| GET | `/export` | Datenexport als JSON |
|
||||
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
|
||||
|
||||
## Gedanken, Triage, Aufgaben
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) |
|
||||
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
|
||||
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`); Löschen |
|
||||
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
||||
| GET | `/triage/deck` | Posteingang (max. 100) |
|
||||
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
|
||||
| GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) |
|
||||
| POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) |
|
||||
| POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen |
|
||||
|
||||
## Aufgabenlisten
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET/POST | `/task-lists` | Listen (`permission` je Liste: `owner`/`editor`/`viewer`); anlegen (`title`, `icon`, `color`, `teamId`) |
|
||||
| PUT/DELETE | `/task-lists/{id}` | Ändern (`title`, `icon`, `color`, `teamId`, `columns`) – nicht für Leser; Löschen nur Eigentümer |
|
||||
| GET | `/task-lists/{id}/shares` | Freigaben |
|
||||
| POST | `/task-lists/{id}/shares` | Nur Eigentümer. Body: `targetType` (`team`/`user`), `targetId`, `permission` (`editor`/`viewer`, Standard `editor`). Erneutes Senden für dasselbe Ziel ändert die Stufe |
|
||||
| DELETE | `/task-lists/{id}/shares/{shareId}` | Nur Eigentümer |
|
||||
|
||||
Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Keine Schreibrechte für diese Aufgabenliste (nur Lesen)“).
|
||||
|
||||
## Workspace
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET/POST/DELETE | `/folders` | Ordnerliste, anlegen (`path`), löschen |
|
||||
| POST | `/folders/move` | `sourcePath`, `targetParentPath` |
|
||||
| GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) |
|
||||
| GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen |
|
||||
| PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) |
|
||||
| GET/POST/DELETE | `/workspace/folders/shared/{id}/shares[/{shareId}]` | Freigaben (nur Lesen wirksam) |
|
||||
| GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
|
||||
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
|
||||
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
|
||||
| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`); Löschen |
|
||||
| PATCH | `/documents/{id}/blocks` | Teil-Update der Blöcke (`upserted`, `deletedIds`, Reihenfolge); löst `DOC_BLOCKS_PATCHED` aus |
|
||||
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
|
||||
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
|
||||
| GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`; wirksam nur Lesen) |
|
||||
|
||||
Nur der Eigentümer schreibt in eine Notiz; Empfänger erhalten `403`/„nur Lesezugriff“ ([FLAW-17](../flaws.md)).
|
||||
|
||||
## Geteilte Listen
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) |
|
||||
| GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen |
|
||||
| GET/POST/DELETE | `/lists/{id}/shares[/{shareId}]` | Freigaben (`targetType`, `targetId`, `role`: `editor`/`viewer`) |
|
||||
| POST | `/lists/{id}/items` | Eintrag (`content`, `category`, `quantity`) |
|
||||
| PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) |
|
||||
| PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) |
|
||||
| DELETE | `/lists/{id}/items/{itemId}` | Löschen |
|
||||
| POST/PUT | `/lists/{id}/items/reorder` | Reihenfolge/Kategorie (`items[{id, sortOrder, category, content}]`) |
|
||||
| POST | `/lists/{id}/clear-completed` | Erledigte entfernen |
|
||||
|
||||
## Gruppen
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET/POST | `/teams` | Eigene Gruppen; anlegen (`name`, `description`) – Ersteller wird Gruppen-Admin |
|
||||
| PUT/DELETE | `/teams/{id}` | Nur Gruppen-Admin |
|
||||
| POST | `/teams/{id}/members` | Mitglied hinzufügen (`userId` oder `email`, `role`) |
|
||||
| PUT/DELETE | `/teams/{id}/members/{userId}` | Rolle ändern (`admin`/`member`); entfernen |
|
||||
| GET/POST/DELETE | `/organizations`, `/organizations/{id}[/teams]` | Veraltet, ungeschützt ([FLAW-24](../flaws.md)) |
|
||||
|
||||
## Administration (`role = admin`)
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET/POST | `/admin/users` | Benutzer auflisten, anlegen (`email`, `password`, `displayName`, `role`) |
|
||||
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
|
||||
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
|
||||
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `teamId`, `validDays`) |
|
||||
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode`, SMTP-Felder |
|
||||
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
|
||||
|
||||
## Beispiele
|
||||
|
||||
```bash
|
||||
# Gedanken per API-Key erfassen
|
||||
curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
|
||||
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
||||
-d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}'
|
||||
|
||||
# Aufgabenliste nur lesend freigeben
|
||||
curl -X POST https://mindsift.example.com/api/v1/task-lists/tkl_…/shares \
|
||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"targetType":"user","targetId":"usr_…","permission":"viewer"}'
|
||||
```
|
||||
@@ -1,55 +0,0 @@
|
||||
# Echtzeit: WebSocket und SSE
|
||||
|
||||
Der Server verteilt Änderungen als JSON-Ereignisse an verbundene Clients. Die Ereignisse entstehen **nach** einer REST-Änderung; es gibt keinen Schreibkanal über die Verbindung. Implementierung: [hub.go](../../internal/ws/hub.go), Client: [api.ts](../../frontend/src/lib/api.ts).
|
||||
|
||||
> Keine CRDTs, keine Sperren, keine Cursor-Presence. Frühere Fassungen dieser Doku beschrieben DocRooms, `clientId`-Echo-Schutz und Yjs; das existiert nicht (mehr).
|
||||
|
||||
## Transporte
|
||||
|
||||
| Transport | Endpunkt | Hinweise |
|
||||
|---|---|---|
|
||||
| WebSocket | `GET /api/v1/ws` (auch `/ws/lists/{id}`, `/ws/spaces/{id}`) | Ping alle 30 s, Lesefrist 60 s, Nachrichten bis 4 MB |
|
||||
| SSE | `GET /api/v1/events` | Kommentar-Ping alle 15 s, Header `X-Accel-Buffering: no` |
|
||||
|
||||
Der Client versucht zuerst WebSocket. Gelingt der Aufbau nicht innerhalb von 2,5 s oder bricht die Verbindung unsauber ab, wechselt er auf SSE und bleibt dabei (Firmen-Proxys). Dort liefert der Server `SSE_CONNECTED` mit einer `sseId`; Räume werden über `POST /api/v1/events/rooms` (`sseId`, `room`, `action`: `join`/`leave`) abonniert.
|
||||
|
||||
**Authentifizierung:** Token als `?token=<JWT|API-Key>` oder `Authorization`-Header. Ohne gültiges Token wird die Verbindung als `anonymous` akzeptiert ([FLAW-01](../flaws.md), [FLAW-02](../flaws.md)). Das Token wird nur beim Verbinden geprüft.
|
||||
|
||||
## Nachrichtenformat
|
||||
|
||||
```json
|
||||
{ "event": "TASK_UPDATED", "listId": "", "payload": { … }, "timestamp": "2026-10-27T12:00:00Z" }
|
||||
```
|
||||
|
||||
Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":"<id>"}` und `{"event":"LEAVE_ROOM","room":"<id>"}`. Räume heißen `doc:<docId>` für Notizen oder `<listId>` für geteilte Listen.
|
||||
|
||||
## Zustellung
|
||||
|
||||
| Kanal | Wer erhält es |
|
||||
|---|---|
|
||||
| Benutzer-Kanal (`BroadcastToUser`) | alle Verbindungen eines Benutzers |
|
||||
| Raum (`BroadcastRoom`, `Broadcast`) | Verbindungen im Raum |
|
||||
| Alle (`BroadcastToAll`) | **jede** Verbindung, auch unangemeldete |
|
||||
|
||||
## Ereignisse
|
||||
|
||||
| Ereignis | Auslöser | Kanal |
|
||||
|---|---|---|
|
||||
| `TASK_CREATED` | `POST /inbox` | Benutzer |
|
||||
| `TASK_UPDATED` | `PATCH /thoughts/{id}` (Triage: Benutzer) | alle |
|
||||
| `TASK_DELETED` | `DELETE /thoughts/{id}` | alle |
|
||||
| `TASK_REORDERED` | Aufgaben umsortieren | alle |
|
||||
| `TASK_LIST_UPDATED` / `TASK_LIST_DELETED` | Liste ändern, (un)teilen, löschen | alle |
|
||||
| `DOC_CREATED`, `DOC_UPDATED`, `DOC_DELETED` | Notiz anlegen/ändern/löschen, Freigaben | berechtigte Benutzer |
|
||||
| `DOC_BLOCKS_PATCHED` | `PATCH /documents/{id}/blocks` (`docId`, `updatedBy`, `upserted`, `deletedIds`, `blockOrder`) | Raum `doc:<id>` und berechtigte Benutzer |
|
||||
| `WORKSPACE_FOLDERS_UPDATED` | Ordner/Mounts ändern | Benutzer |
|
||||
| `LIST_CREATED`, `LIST_UPDATED`, `LIST_DELETED`, `LIST_SHARED`, `LIST_UNSHARED`, `LIST_CLEARED` | Listenverwaltung | Benutzer bzw. Raum der Liste |
|
||||
| `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste |
|
||||
| `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum |
|
||||
| `SSE_CONNECTED` | Nur SSE, direkt nach Verbindung | Verbindung |
|
||||
|
||||
## Verhalten im Frontend
|
||||
|
||||
- Die globale Verbindung (ein Singleton) leitet jede Nachricht als Browser-Ereignis `mindsift:ws` weiter; Ansichten (`TasksView`, `WorkspaceView`, `SharedListsView`) hören darauf und laden bzw. patchen ihren Zustand.
|
||||
- Nach einem Abbruch verbindet sich der Client mit Verzögerung neu.
|
||||
- Weil Notizen nur vom Eigentümer geschrieben werden, dient `DOC_BLOCKS_PATCHED` vor allem dazu, Lesenden und weiteren Geräten des Eigentümers Änderungen zu zeigen. Es gibt keine Konfliktauflösung (letzter Schreiber gewinnt).
|
||||
@@ -1,87 +0,0 @@
|
||||
# Architektur
|
||||
|
||||
## Überblick
|
||||
|
||||
```
|
||||
Browser (React SPA, PWA-Manifest)
|
||||
│ REST /api/v1/* Authorization: Bearer <JWT | mindsift_sec_…>
|
||||
│ WebSocket /api/v1/ws Fallback: SSE /api/v1/events
|
||||
▼
|
||||
Go-Server (chi) ── internal/api Handler, Middleware, Alarm-Ticker
|
||||
│ internal/ws Hub (Räume, Benutzer-Kanäle, SSE)
|
||||
│ internal/auth JWT, bcrypt, API-Key-Hash
|
||||
▼ internal/config
|
||||
SQLite (modernc.org/sqlite, WAL) ── internal/storage
|
||||
```
|
||||
|
||||
Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/main.go)). Unbekannte Pfade liefern `index.html` (SPA-Routing); `index.html`, `sw.js` und `manifest.json` werden mit `no-store` ausgeliefert.
|
||||
|
||||
## Stack
|
||||
|
||||
| Schicht | Technik |
|
||||
|---|---|
|
||||
| Backend | Go 1.23, `go-chi/chi` v5, `go-chi/cors`, `gorilla/websocket`, `golang-jwt/jwt` v5, `x/crypto/bcrypt` |
|
||||
| Datenbank | SQLite über `modernc.org/sqlite` (reines Go, kein CGO), WAL, `foreign_keys=ON` |
|
||||
| Frontend | React 18, TypeScript, Vite 5, Tailwind 3, framer-motion (Triage-Karten), lucide-react, prismjs (Code-Blöcke) |
|
||||
| Auslieferung | Mehrstufiges Dockerfile (Node 22 → Go 1.23 → Alpine 3.21), Image `git.dsit.me/sandrodach/mindsift` |
|
||||
|
||||
## Verzeichnisse
|
||||
|
||||
| Pfad | Inhalt |
|
||||
|---|---|
|
||||
| `cmd/server/` | `main.go`, eingebettetes `dist/` |
|
||||
| `internal/api/` | `server.go` (Routen, Handler, Alarm-Ticker, SMTP/Discord/Webhook-Versand) |
|
||||
| `internal/storage/` | `sqlite.go` (Schema, Migrationen, gesamte Datenlogik) |
|
||||
| `internal/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion |
|
||||
| `internal/ws/` | `hub.go` |
|
||||
| `internal/auth/` | Tokens und Passwörter |
|
||||
| `internal/config/` | Umgebungsvariablen |
|
||||
| `frontend/src/features/` | `triage`, `workspace`, `lists`, `teams`, `settings`, `capture`, `auth`, `help` |
|
||||
| `frontend/src/lib/` | `api.ts` (REST-Client, Transport-Umschaltung WS/SSE), `types.ts`, `markdown.tsx`, `theme.ts` |
|
||||
| `frontend/src/themes/` | CSS pro Theme |
|
||||
|
||||
## Start und Laufzeit
|
||||
|
||||
1. `config.Load()` liest die Umgebung (siehe unten).
|
||||
2. `storage.NewStore()` öffnet SQLite (`busy_timeout`, WAL, max. 8 Verbindungen) und führt `migrate()` aus.
|
||||
3. SMTP-Werte aus der Umgebung werden in `system_settings` übernommen, sofern dort noch nichts steht.
|
||||
4. Mit Demo-Flag legt `EnsureDemoAccounts` die Konten `admin/admin` und `user/user` an.
|
||||
5. `api.NewServer` startet den **Alarm-Ticker** (jede Minute) und baut den Router. Server-Timeouts: Lesen 15 s, Schreiben 30 s, Leerlauf 60 s. Beim Beenden wird sauber heruntergefahren.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
| Variable | Standard | Bedeutung |
|
||||
|---|---|---|
|
||||
| `PORT` | `8080` | HTTP-Port |
|
||||
| `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank |
|
||||
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
|
||||
| `JWT_SECRET` | festes Fallback (unsicher!) | Signaturschlüssel der Tokens; unbedingt setzen, siehe [FLAW-04](flaws.md) |
|
||||
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz |
|
||||
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
|
||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
|
||||
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
|
||||
|
||||
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
|
||||
|
||||
## Authentifizierung
|
||||
|
||||
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
|
||||
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
||||
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
|
||||
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und authentifiziert als Besitzer ([FLAW-07](flaws.md)).
|
||||
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
|
||||
|
||||
## Hintergrundjob: Alarme
|
||||
|
||||
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist `due_date <= heute` (Serverzeit), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md).
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
Stage 1 node:22-alpine npm ci && npm run build → frontend/dist
|
||||
Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (dist wird eingebettet)
|
||||
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
|
||||
```
|
||||
|
||||
Die Tests laufen beim Image-Build (`go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests existieren nicht ([FLAW-19](flaws.md)).
|
||||
@@ -1,83 +0,0 @@
|
||||
# Datenbank
|
||||
|
||||
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`).
|
||||
|
||||
Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist veraltet ([FLAW-13](flaws.md)).
|
||||
|
||||
## Tabellen
|
||||
|
||||
### Benutzer und Zugang
|
||||
| Tabelle | Zweck | Wichtige Spalten |
|
||||
|---|---|---|
|
||||
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url` |
|
||||
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `team_id`, `expires_at`, `used_by` |
|
||||
| `revoked_tokens` | Sperrliste ausgeloggter JWTs | `token_hash`, `expires_at` |
|
||||
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
|
||||
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
|
||||
|
||||
### Gedanken und Aufgaben
|
||||
| Tabelle | Zweck | Wichtige Spalten |
|
||||
|---|---|---|
|
||||
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (nur Datum), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
|
||||
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `team_id` (Altfeld), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
|
||||
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
|
||||
|
||||
Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND converted_type='task'`. Aufgaben, die aus einem Notiz-Block entstehen, tragen `converted_type='document_block'` und `converted_ref_id='<docId>:<blockId>'`.
|
||||
|
||||
### Workspace
|
||||
| Tabelle | Zweck |
|
||||
|---|---|
|
||||
| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived`, `markdown_cache`, `shared_folder_id`, `sub_folder_path`, Altspalten `content`, `blocks_json` |
|
||||
| `blocks` | Outliner-Blöcke: `document_id`, `type`, `content`, `level`, `position` (REAL), `checked`, `collapsed`, `lang` |
|
||||
| `block_tags` | `#Hashtags` je Block (`tag`, `document_id`, `block_id`) |
|
||||
| `tag_pages` | Freitext-Notiz je Benutzer und Tag |
|
||||
| `doc_pins` | Favoriten je Benutzer |
|
||||
| `doc_shares`, `doc_team_shares` | Notiz-Freigaben an Benutzer bzw. Gruppen (`permission` effektiv immer `read`) |
|
||||
| `workspace_folders` | Explizit angelegte Ordnerpfade |
|
||||
| `workspace_shared_folders` | Geteilte Ordner (Entität mit Eigentümer) |
|
||||
| `workspace_folder_shares` | Freigaben geteilter Ordner |
|
||||
| `workspace_folder_mounts` | Persönlicher Einhängepfad je Empfänger und geteiltem Ordner |
|
||||
| `doc_user_folders` | Persönliche Ablage einer geteilten Einzelnotiz je Empfänger |
|
||||
|
||||
Blocktypen: `h1`, `h2`, `h3`, `paragraph`, `text`, `todo`, `bullet`, `code`, `callout`, `divider`, `table`, `progress`.
|
||||
|
||||
### Listen
|
||||
| Tabelle | Zweck |
|
||||
|---|---|
|
||||
| `lists` | Geteilte Listen: `title`, `icon`, `list_type` (`shopping`/`checklist`/`vacation`) |
|
||||
| `list_shares` | Freigaben: `target_type`, `target_id`, `role` (`editor`/`viewer`) |
|
||||
| `list_items` | Einträge: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order` |
|
||||
|
||||
### Gruppen
|
||||
| Tabelle | Zweck |
|
||||
|---|---|
|
||||
| `teams` | `name`, `description`, `org_id` (Standard `default`) |
|
||||
| `team_members` | `team_id`, `user_id`, `role` (`admin`/`member`) |
|
||||
| `organizations` | Rudimentär; wird vom UI nicht mehr benötigt ([FLAW-24](flaws.md)) |
|
||||
|
||||
## Beziehungen
|
||||
|
||||
```
|
||||
users 1─n thoughts thoughts n─1 task_lists (task_list_id, lose)
|
||||
users 1─n task_lists task_lists 1─n task_list_shares ─(team|user)→ teams/users
|
||||
users 1─n documents 1─n blocks 1─n block_tags
|
||||
documents n─n users via doc_shares / doc_team_shares (nur Lesen)
|
||||
workspace_shared_folders 1─n workspace_folder_shares / workspace_folder_mounts
|
||||
users 1─n lists 1─n list_items; lists 1─n list_shares
|
||||
teams 1─n team_members n─1 users
|
||||
```
|
||||
|
||||
`thoughts.task_list_id` ist keine Fremdschlüsselbeziehung; beim Löschen einer Liste setzt der Code die Verweise auf `''`.
|
||||
|
||||
## Migrationen und Aufräumarbeiten beim Start
|
||||
|
||||
- Spalten/Tabellen/Indizes werden nachgezogen (`ALTER TABLE … ADD COLUMN`, Fehler werden ignoriert).
|
||||
- Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`.
|
||||
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
||||
- Alle Dokument-, Team- und Ordnerfreigaben werden auf `read` gesetzt (bei **jedem** Start, siehe [FLAW-17](flaws.md)).
|
||||
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=teams_only`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
||||
- Die Tabelle `teams` wird einmalig ohne Fremdschlüssel auf `organizations` neu aufgebaut.
|
||||
|
||||
## Sicherung
|
||||
|
||||
Die gesamte Instanz besteht aus der Datei `mindsift.db` (plus `-wal`/`-shm` im laufenden Betrieb). Konsistent sichern per `sqlite3 mindsift.db ".backup 'backup.db'"` oder durch Stoppen des Containers und Kopieren des `data/`-Verzeichnisses. Benutzerexport: `GET /api/v1/export` (JSON mit Gedanken, Notizen, Listen, Aufgabenlisten).
|
||||
@@ -1,173 +0,0 @@
|
||||
# Bekannte Schwachstellen, Risiken & technische Schulden (Flaws)
|
||||
|
||||
Stand: Code-Review des Branches `dev` (Oktober 2026). Jeder Eintrag wurde gegen den Quellcode geprüft; die Fundstellen sind angegeben. Einträge ohne Live-Test sind als *nicht live verifiziert* markiert.
|
||||
|
||||
Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (Fehlfunktion oder Schutz greift nicht), **Niedrig** (Wartbarkeit, Komfort, Hygiene).
|
||||
|
||||
---
|
||||
|
||||
## Übersicht
|
||||
|
||||
| ID | Bereich | Kurzbeschreibung | Schwere |
|
||||
|---|---|---|---|
|
||||
| FLAW-01 | Realtime | `BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen, auch unauthentifizierte | **Hoch** |
|
||||
| FLAW-02 | Realtime | Räume (WS `JOIN_ROOM`, SSE `/events/rooms`, `?listId=`) ohne Berechtigungsprüfung | **Hoch** |
|
||||
| FLAW-03 | Aufgaben | `ReorderTasks` ändert beliebige Aufgaben per ID, ohne Zugriffsprüfung (IDOR) | **Hoch** |
|
||||
| FLAW-04 | Auth | Fest eincodiertes Fallback-`JWT_SECRET` | **Hoch** |
|
||||
| FLAW-05 | Benachrichtigungen | Discord-/Webhook-URLs ungeprüft (SSRF) | Mittel |
|
||||
| FLAW-06 | Aufgaben | Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben | Mittel |
|
||||
| FLAW-07 | Auth | API-Key-`scopes` werden nie ausgewertet; ein Key ist ein Vollzugriff | Mittel |
|
||||
| FLAW-08 | Auth | Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active` | Mittel |
|
||||
| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel |
|
||||
| FLAW-10 | Freigaben | `sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten | Mittel |
|
||||
| FLAW-11 | Workspace | Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht | Mittel |
|
||||
| FLAW-12 | Benachrichtigungen | `notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder | Mittel |
|
||||
| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel |
|
||||
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
|
||||
| FLAW-15 | Realtime | `WriteTimeout` von 30 s trifft vermutlich auch SSE-Streams *(nicht live verifiziert)* | Mittel |
|
||||
| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
|
||||
| FLAW-17 | Workspace | Dokument- und Ordnerfreigaben sind per Migration erzwungen schreibgeschützt; tote Schreibpfade | Niedrig |
|
||||
| FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig |
|
||||
| FLAW-19 | Qualität | Keine Frontend-Tests, `lint` und `test:run` sind Platzhalter | Niedrig |
|
||||
| FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig |
|
||||
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
|
||||
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
|
||||
| FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig |
|
||||
| FLAW-24 | Gruppen | Organisations-Endpunkte ohne Berechtigungsprüfung | Mittel |
|
||||
|
||||
---
|
||||
|
||||
## Details
|
||||
|
||||
### FLAW-01: Broadcast an alle Verbindungen (Datenabfluss)
|
||||
- **Fundstellen:** [`internal/ws/hub.go`](../internal/ws/hub.go) `BroadcastToAll`; Aufrufer in [`internal/api/server.go`](../internal/api/server.go): `handleUpdateThought` (`TASK_UPDATED`), `handleDeleteThought` (`TASK_DELETED`), `handleReorderTasks` (`TASK_REORDERED`), `handleUpdateTaskList`, `handleCreateTaskListShare`, `handleDeleteTaskListShare` (`TASK_LIST_UPDATED`), `handleDeleteTaskList`.
|
||||
- **Problem:** `BroadcastToAll` schreibt an `allClients` und `sseClients`. Dazu zählen auch Verbindungen mit `userID = "anonymous"`, denn `handleWebSocket` und `handleSSE` liegen außerhalb der Auth-Gruppe und lassen jede Verbindung zu. Der vollständige Aufgabeninhalt (Titel, Notizen, Unteraufgaben) und die Listen samt Mitgliedern gehen so an jeden, der `/api/v1/events` öffnet.
|
||||
- **Maßnahme:** Ereignisse an die berechtigten Nutzer adressieren (wie bei `DOC_*` über `GetAuthorizedDocUsers`), unauthentifizierte Verbindungen ablehnen.
|
||||
|
||||
### FLAW-02: Räume ohne Berechtigungsprüfung
|
||||
- **Fundstellen:** `Hub.readPump` (`JOIN_ROOM`), `handleSSERooms` (Route `/api/v1/events/rooms` liegt **außerhalb** der Auth-Gruppe), `Hub.Register` (Raum aus `?listId=`/`?spaceId=`).
|
||||
- **Problem:** Wer eine Raum-ID kennt (`doc:<id>`, Listen-ID), tritt bei und erhält Inhalte wie `DOC_BLOCKS_PATCHED` oder `ITEM_*`. Raum-IDs tauchen in Ereignissen auf und sind nicht geheim. Für den SSE-Beitritt reicht die `sseId`, die der Server selbst im Stream mitsendet.
|
||||
- **Maßnahme:** Beitritt nur nach Zugriffsprüfung (`GetDocumentByID`, `GetListByID`); `/events/rooms` hinter `authMiddleware`.
|
||||
|
||||
### FLAW-03: `ReorderTasks` ohne Zugriffsprüfung (IDOR)
|
||||
- **Fundstelle:** `Store.ReorderTasks` (`UPDATE thoughts SET sort_order … WHERE id = ?`) und `handleReorderTasks`.
|
||||
- **Problem:** Die SQL-Anweisung ist weder auf `user_id` noch auf die Liste eingeschränkt. Jeder angemeldete Nutzer kann `sortOrder`, `kanbanStatus` und damit `isCompleted` fremder Aufgaben ändern, sofern er die ID kennt. Die Leser-Prüfung für Aufgabenlisten überspringt Aufgaben, auf die der Nutzer gar keinen Zugriff hat (`GetThoughtByID` liefert dann einen Fehler), und schützt davor nicht.
|
||||
- **Maßnahme:** Jede ID gegen `accessibleTaskListIDsQuery` bzw. `user_id` prüfen und bei Verstoß ablehnen.
|
||||
|
||||
### FLAW-04: Fallback-JWT-Secret
|
||||
- **Fundstelle:** [`internal/config/config.go`](../internal/config/config.go): `getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")`; `docker-compose.yml` und `.env.example` liefern ebenfalls einen Platzhalter.
|
||||
- **Problem:** Wer `JWT_SECRET` nicht setzt (oder den Platzhalter behält), läuft mit einem öffentlich bekannten Schlüssel. Damit lassen sich gültige Tokens für jede Benutzer-ID fälschen. `install.sh` erzeugt zwar ein Secret, andere Installationswege nicht.
|
||||
- **Maßnahme:** Start verweigern oder Secret beim ersten Start erzeugen und in `/data` ablegen.
|
||||
|
||||
### FLAW-05: SSRF über Alarm-URLs
|
||||
- **Fundstellen:** `handleUpdateUserSettings` (keine Validierung), `sendDiscordNotification`, `sendCustomWebhookNotification`.
|
||||
- **Problem:** Jeder Nutzer kann beliebige URLs hinterlegen. Der Server sendet POST-Anfragen dorthin, auch an interne Adressen (`http://169.254.169.254`, Docker-Netz, `localhost`). Über `handleTestUserAlarm` lässt sich das sofort auslösen.
|
||||
- **Maßnahme:** Nur `https`, Discord-Host prüfen, private/Loopback-Adressen beim Verbinden sperren.
|
||||
|
||||
### FLAW-06: Editor kann Aufgabenliste weiterteilen
|
||||
- **Fundstelle:** `Store.UpdateTaskList` (`teamID`-Zweig ruft `AddTaskListShare`); `handleUpdateTaskList` prüft nur, dass der Nutzer **kein** Leser ist.
|
||||
- **Problem:** `POST /task-lists/{id}/shares` ist Eigentümern vorbehalten. Über `PUT /task-lists/{id}` mit `teamId` kann aber jeder Editor die Liste einer weiteren Gruppe zuweisen (mit Schreibrecht) und die Zuweisung der bisherigen Gruppe aufheben.
|
||||
- **Maßnahme:** `teamId`-Änderungen nur für den Eigentümer; das Legacy-Feld `task_lists.team_id` zugunsten von `task_list_shares` abschaffen.
|
||||
|
||||
### FLAW-07: API-Key-Scopes ohne Wirkung
|
||||
- **Fundstellen:** Tabelle `api_keys.scopes` (Standard `ingest,read`), `authMiddleware`.
|
||||
- **Problem:** Ein Key mit Präfix `mindsift_sec_` authentifiziert als der Benutzer, mit allen Rechten: alle `/api/v1`-Routen, bei Admins auch `/admin/*`, außerdem das Anlegen weiterer Keys. Für Webhooks (nur Aufgaben anlegen) ist das überdimensioniert.
|
||||
- **Maßnahme:** Scopes auswerten; Keys standardmäßig nur auf `/inbox` und `/webhooks/ingest` beschränken.
|
||||
|
||||
### FLAW-08: Webhook-Ingest umgeht Sitzungsprüfung
|
||||
- **Fundstelle:** `handleWebhookIngest`, JWT-Zweig.
|
||||
- **Problem:** Das Token wird nur kryptografisch validiert. `IsTokenRevoked`, `token_version` und `is_active` werden nicht geprüft; ein ausgeloggtes oder deaktiviertes Konto kann weiter Aufgaben anlegen (bis Token-Ablauf nach 30 Tagen).
|
||||
- **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`).
|
||||
|
||||
### FLAW-09: Kein Schutz gegen Brute-Force
|
||||
- **Fundstellen:** `handleLogin`, `handleRegister`, `handleFirstBootSetup`.
|
||||
- **Problem:** Weder Rate-Limit noch Sperre; `middleware.RealIP` vertraut `X-Forwarded-For` ungeprüft. Mindestpasswortlänge ist 4 Zeichen. Der Login-Pfad ist so über das Internet raten- und durchprobierbar.
|
||||
- **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort.
|
||||
|
||||
### FLAW-10: `sharing_mode` wird nicht durchgesetzt
|
||||
- **Fundstellen:** `Store.GetDiscoverableUsers`, `Store.GetCollaborators`; Share-Handler in `server.go`.
|
||||
- **Problem:**
|
||||
1. Der Modus beeinflusst nur `GetDiscoverableUsers`. In `teams_only` liefert die Funktion trotzdem **alle** Benutzer (`if … mode == "open" || mode == "teams_only" { return GetAllUsers() }`).
|
||||
2. `GET /collaborators` ignoriert den Modus komplett und liefert alle aktiven Konten mit E-Mail-Adresse.
|
||||
3. Keine der Share-Funktionen prüft den Modus. `strict_private` und `teams_only` verhindern Freigaben nicht, wenn man die Ziel-ID kennt.
|
||||
- **Maßnahme:** Eine zentrale Funktion `canShareWith(owner, target)` und in allen Share-Handlern verwenden; Verzeichnisse darauf einschränken.
|
||||
|
||||
### FLAW-11: Block-Upsert prüft das Dokument nicht
|
||||
- **Fundstelle:** `Store.PatchDocumentBlocks`, `INSERT INTO blocks … ON CONFLICT(id) DO UPDATE`.
|
||||
- **Problem:** `blocks.id` ist global eindeutig. Existiert die ID bereits in einem **anderen** Dokument, überschreibt der Konflikt-Zweig dessen Inhalt, ohne `document_id` zu prüfen. Voraussetzung: Schreibrecht im eigenen Dokument und Kenntnis einer fremden Block-ID.
|
||||
- **Maßnahme:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen.
|
||||
|
||||
### FLAW-12: Alarm-Logik
|
||||
- **Fundstellen:** `startAlarmTicker`, `GetDueTasksForAlarm`, `UpdateThoughtDetails`, `UserSettingsView`.
|
||||
- **Probleme:**
|
||||
- `notify_before_minutes` (Einstellungen: „15 Minuten vorher“ usw.) wird nirgends gelesen. Fälligkeiten haben nur ein Datum (`YYYY-MM-DD`), eine Vorlaufzeit ist damit nicht abbildbar.
|
||||
- Verglichen wird mit `time.Now()` in der Zeitzone des Containers (ohne `TZ` also UTC).
|
||||
- `alarm_sent` wird nie zurückgesetzt. Wird die Fälligkeit nach einem Alarm verschoben, gibt es keinen neuen.
|
||||
- Schlagen alle Kanäle fehl, wird jede Minute erneut gesendet (kein Backoff, keine Dead-Letter-Queue).
|
||||
- **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
|
||||
|
||||
### FLAW-13: Migrationen
|
||||
- **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql).
|
||||
- **Probleme:**
|
||||
- Es gibt keine Versionstabelle. Alle `ALTER TABLE`-Anweisungen laufen bei jedem Start erneut; Fehler werden pauschal ignoriert (`_, _ = s.db.Exec(q)`), echte Fehler fallen nicht auf.
|
||||
- Mehrere `UPDATE`/`DROP TABLE`-Bereinigungen laufen bei jedem Start.
|
||||
- `migrations/001_init.sql` wird nur in das Image kopiert, aber nirgends gelesen, und ist gegenüber dem tatsächlichen Schema veraltet.
|
||||
- **Maßnahme:** `schema_migrations`-Tabelle mit nummerierten, einmalig ausgeführten Schritten; die SQL-Datei entweder einbetten oder entfernen.
|
||||
|
||||
### FLAW-14: Verschluckte Fehler
|
||||
- **Fundstellen:** u. a. `PatchDocumentBlocks` (Upserts/Deletes), `AddTaskListShare`, `RemoveTaskListShare`, viele `_, _ = s.db.Exec`.
|
||||
- **Problem:** Scheitert ein Schreibzugriff (z. B. `SQLITE_BUSY`), antwortet die API trotzdem mit Erfolg und broadcastet den Stand, der nie gespeichert wurde. Fehler lassen sich nur schwer nachvollziehen.
|
||||
- **Maßnahme:** Mehrstufige Änderungen in Transaktionen bündeln und Fehler zurückgeben.
|
||||
|
||||
### FLAW-15: WriteTimeout und SSE *(nicht live verifiziert)*
|
||||
- **Fundstelle:** [`cmd/server/main.go`](../cmd/server/main.go) `WriteTimeout: 30 * time.Second`.
|
||||
- **Problem:** Go beendet nach diesem Zeitraum das Schreiben der Antwort. Für den SSE-Fallback (`ServeSSE`) bedeutet das voraussichtlich einen Verbindungsabbruch alle 30 s. WebSockets sind durch Hijacking nicht betroffen. Der Client verbindet sich neu, daher fällt es kaum auf.
|
||||
- **Maßnahme:** Im SSE-Handler `http.ResponseController.SetWriteDeadline(time.Time{})` setzen oder `WriteTimeout` für diese Route aufheben.
|
||||
|
||||
### FLAW-16: Ersteinrichtung und Demo-Modus
|
||||
- **Fundstellen:** `handleFirstBootSetup`, `Store.EnsureDemoAccounts`, `config.Load` (`MINDA_DEMO`, `MINDSIFT_DEMO`, `DEMO_MODE`).
|
||||
- **Probleme:** Solange kein Konto existiert, kann jeder `POST /auth/setup` aufrufen und wird Administrator. Mit gesetztem Demo-Flag entstehen Konten `admin/admin` und `user/user`; versehentlich auf einer öffentlichen Instanz aktiv wäre das eine Komplettübernahme.
|
||||
- **Maßnahme:** Setup per einmaligem Token aus dem Server-Log absichern; Demo-Modus nur zusammen mit expliziter Warnung/Bestätigung.
|
||||
|
||||
### FLAW-17: Schreibgeschützte Dokumentfreigaben
|
||||
- **Fundstellen:** Migration in `Store.migrate` (`UPDATE doc_shares SET permission = 'read' …`, ebenso `doc_team_shares`, `workspace_folder_shares`), `PatchDocumentBlocks`/`UpdateDocument` (nur Eigentümer).
|
||||
- **Hinweis:** Das ist offenbar gewollt: Nur der Eigentümer schreibt in ein Dokument, Empfänger lesen. Es gibt daher **kein** kollaboratives Bearbeiten. Das Schema (`CHECK(permission IN ('read','write'))`), die Zweige für `write` im Code und frühere Dokumentation suggerieren etwas anderes. Es gibt weder Sperren noch Konfliktauflösung, noch Cursor-Presence.
|
||||
- **Maßnahme:** Entweder Schreibfreigaben mit Konfliktbehandlung ergänzen oder den toten Code und das Schema bereinigen.
|
||||
|
||||
### FLAW-18: Suche
|
||||
- **Fundstelle:** `Store.SearchAll`.
|
||||
- **Probleme:** Durchsucht nur eigene Gedanken (30) und eigene Dokumente (20); geteilte Dokumente, Aufgaben aus geteilten Listen, Einkaufslisten und Blöcke bleiben außen vor. `LIKE '%…%'` ohne Escaping von `%`/`_`, keine Indizes, keine Relevanz.
|
||||
- **Maßnahme:** FTS5-Index über Blöcke/Gedanken; Berechtigungen mitberücksichtigen.
|
||||
|
||||
### FLAW-19: Keine automatisierten Frontend-Prüfungen
|
||||
- **Fundstelle:** [`frontend/package.json`](../frontend/package.json): `"lint": "echo 'Linting passed'"`, `"test:run": "echo 'Frontend checks passed'"`.
|
||||
- **Problem:** Die Skripte melden immer Erfolg. Die CI (`.gitea/workflows/build-and-publish.yaml`) prüft daher nur Go-Tests und den Vite-Build. Regressionen im UI (z. B. Berechtigungsanzeigen) fallen nicht auf.
|
||||
- **Maßnahme:** `tsc --noEmit` und ESLint als echte Skripte, Komponententests für kritische Pfade.
|
||||
|
||||
### FLAW-20: Dateigröße und Duplikate
|
||||
- `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500).
|
||||
- Die Zugriffsabfrage für Aufgabenlisten steht dreimal fast identisch im Code (`accessibleTaskListIDsQuery`, `GetTaskLists`, `GetTaskListByID`). Dokumentzugriff ist ähnlich verteilt. Änderungen an Berechtigungen müssen an mehreren Stellen erfolgen und werden leicht vergessen.
|
||||
- **Maßnahme:** In Teilmodule zerlegen (Auth, Dokumente, Aufgaben, Listen, Teams), Zugriffsprüfung zentralisieren.
|
||||
|
||||
### FLAW-21: Lizenzdatei fehlt
|
||||
- README und Badge nennen die MIT-Lizenz und verlinken auf `LICENSE`; die Datei existiert nicht im Repository.
|
||||
|
||||
### FLAW-22: Netzwerk-Details
|
||||
- `authenticateUserFromReq` akzeptiert das Token als `?token=` (nötig für `WebSocket`/`EventSource`, die keine Header setzen). Tokens landen dadurch in Proxy- und Zugriffslogs.
|
||||
- `AllowedOrigins: ["*"]` (Auth läuft über `Authorization`-Header, nicht über Cookies, das Risiko ist daher begrenzt) und `CheckOrigin` liefert immer `true`.
|
||||
- Ein einmal verbundener WebSocket/SSE-Stream wird bei Logout oder Token-Widerruf nicht getrennt.
|
||||
- **Maßnahme:** Kurzlebiges, einmaliges Stream-Ticket statt Langzeit-JWT in der URL; Origin gegen `BASE_URL` prüfen.
|
||||
|
||||
### FLAW-23: SQLite-Schreibengpass
|
||||
- Eine SQLite-Datei (WAL, `busy_timeout`, bis zu 8 offene Verbindungen) erlaubt nur einen gleichzeitigen Schreiber. Für Familien und kleine Teams unkritisch; bei vielen aktiven Editoren sind `SQLITE_BUSY`-Fehler möglich, die wegen FLAW-14 stumm bleiben.
|
||||
|
||||
### FLAW-24: Organisations-Endpunkte ohne Berechtigungsprüfung
|
||||
- **Fundstellen:** `handleDeleteOrganization`, `handleGetOrgTeams`, `handleCreateOrgTeam` (und `Store.DeleteOrganization`) in `server.go` bzw. `sqlite.go`.
|
||||
- **Problem:** Diese Handler prüfen weder Eigentümer noch Mitgliedschaft. Jeder angemeldete Nutzer kann mit bekannter ID eine fremde Organisation löschen, deren Gruppen auflisten oder dort Gruppen anlegen. Gruppenbezogene Endpunkte (`/teams/{id}…`) prüfen dagegen korrekt auf Gruppen-Admin. Das Datenmodell kennt Organisationen nur noch rudimentär; im UI werden Gruppen direkt über `POST /teams` angelegt (`orgId` Standard `default`).
|
||||
- **Maßnahme:** Organisationen entweder konsequent absichern (Eigentümer/Server-Admin) oder entfernen.
|
||||
|
||||
---
|
||||
|
||||
## Aus der früheren Fassung entfernt
|
||||
|
||||
Die frühere `flaws.md` führte als „gelöst“, was im Code nicht existiert: In-Memory-DocRooms, `clientId`-Echo-Schutz, Cursor-Presence, 1,5-s-Debounce-Flush, `internal/ws/docroom.go`, `frontend/src/lib/useDocumentSync.ts` und Yjs/CRDT. Der Code enthält nur noch `internal/ws/hub.go` (Räume, Benutzer-Kanäle, SSE-Fallback). Die CRDT-Tabellen werden beim Start gelöscht (`DROP TABLE document_crdt_*`). Dokumente werden per REST gespeichert (`PATCH /documents/{id}/blocks`) und als `DOC_BLOCKS_PATCHED` verteilt. Die frühere Aussage zur Token-Versionierung (`token_version`, `revoked_tokens`) trifft dagegen zu und ist in [sharing-and-permissions.md](sharing-and-permissions.md) und [architecture.md](architecture.md) beschrieben.
|
||||
@@ -1,19 +0,0 @@
|
||||
# Modul: Geteilte Listen
|
||||
|
||||
Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsView.tsx](../../frontend/src/features/lists/SharedListsView.tsx); Backend: `*List*`-Handler in [server.go](../../internal/api/server.go), `Store.CreateList` und Verwandte.
|
||||
|
||||
## Datenmodell
|
||||
|
||||
- `lists`: Eigentümer, `title`, `icon`, `list_type` (`shopping`, `checklist`, `vacation`).
|
||||
- `list_items`: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order`.
|
||||
- `list_shares`: Ziel (Benutzer oder Gruppe) und Rolle `editor` oder `viewer`.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- **Kategorien per Hashtag:** `Hafermilch #Kühlregal` landet in der Kategorie „Kühlregal“. Kategorien werden alphabetisch als Abschnitte angezeigt; die Eingabe schlägt vorhandene Hashtags vor.
|
||||
- **Menge:** Feld `quantity` (z. B. `2x`, `500g`).
|
||||
- **Drag & Drop** ordnet Einträge um; in eine andere Kategorie gezogen, wird der Hashtag im Text angepasst (`items/reorder` mit `category` und `content`).
|
||||
- **Abhaken** speichert, wer und wann; Erledigte stehen in einem eigenen Bereich und lassen sich per „Erledigte entfernen“ löschen (`clear-completed`).
|
||||
- **Freigabe** über den Dialog `UnifiedShareModal` (Bearbeiten oder Nur Lesen). Ein Leser sieht Änderungen live, ändert aber nichts.
|
||||
- **Live-Sync:** Ereignisse `ITEM_*` und `LIST_*` im Raum der Liste ([websocket.md](../api/websocket.md)). Abhaken erscheint bei allen Beteiligten sofort.
|
||||
- Aus der Triage lassen sich Gedanken direkt als Eintrag in eine Liste übernehmen.
|
||||
@@ -1,38 +0,0 @@
|
||||
# Modul: Aufgaben, Kanban & Alarme
|
||||
|
||||
Code: [TasksView.tsx](../../frontend/src/features/triage/TasksView.tsx), [TaskDetailModal.tsx](../../frontend/src/features/triage/TaskDetailModal.tsx), [ManageColumnsModal.tsx](../../frontend/src/features/triage/ManageColumnsModal.tsx); Backend: `handleUpdateThought`, `handleReorderTasks`, `*TaskList*`-Handler, `startAlarmTicker`.
|
||||
|
||||
## Datenmodell
|
||||
|
||||
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (nur Datum), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId` und `sortOrder`. Aufgaben ohne Liste sind persönlich.
|
||||
|
||||
## Ansichten
|
||||
|
||||
- **Liste** mit Filtern (offen, erledigt, Papierkorb), Gruppierung (z. B. nach Status oder Fälligkeit), Sortierung und Hashtag-Filter.
|
||||
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`.
|
||||
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
|
||||
- Aufgaben lassen sich in eine Notiz umwandeln („Als Notiz vertiefen“). Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block.
|
||||
|
||||
## Aufgabenlisten und Freigabe
|
||||
|
||||
- Jede Liste hat Eigentümer, Titel, Symbol, Farbe und eigene Kanban-Spalten.
|
||||
- Freigabe an Gruppen oder Benutzer über den Dialog `UnifiedShareModal` (Stufe pro Freigabe):
|
||||
- **Bearbeiter:** darf Aufgaben anlegen, ändern, abhaken, verschieben, löschen sowie Liste umbenennen und Spalten ändern.
|
||||
- **Leser:** sieht alles, kann nichts ändern. Der Server antwortet mit `403`; die Oberfläche blendet Eingaben aus und zeigt „Nur Lesen“.
|
||||
- Gruppenmitglieder mit mehreren Wegen erhalten die höchste Stufe. Nur der Eigentümer legt Freigaben an oder entfernt sie und kann die Liste löschen.
|
||||
- Umsetzung: `Store.GetTaskListPermission` liefert `owner`/`editor`/`viewer`/`""`; `ensureTaskListWritable` schützt Schreibpfade.
|
||||
- Bekannte Lücken: [FLAW-03](../flaws.md) (Umsortieren per ID), [FLAW-06](../flaws.md) (Editor kann weiterteilen).
|
||||
|
||||
## Alarme
|
||||
|
||||
Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `due_date <= heute` und `alarm_sent = 0` sendet er an die Kanäle des Aufgaben-**Eigentümers**:
|
||||
|
||||
| Kanal | Ziel | Konfiguration |
|
||||
|---|---|---|
|
||||
| E-Mail | `alarmEmail` (sonst Konto-Adresse) | SMTP aus Admin-Einstellungen oder Umgebung (Port 465 mit TLS wird direkt unterstützt, sonst STARTTLS über `smtp.SendMail`) |
|
||||
| Discord | Webhook-URL | Benutzereinstellungen |
|
||||
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
|
||||
|
||||
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war.
|
||||
|
||||
Einschränkungen ([FLAW-12](../flaws.md)): Fälligkeiten haben keine Uhrzeit, `notifyBeforeMinutes` wird ignoriert, die Serverzeit gilt, und ein verschobener Termin löst nicht erneut aus. URLs werden nicht geprüft ([FLAW-05](../flaws.md)).
|
||||
@@ -1,36 +0,0 @@
|
||||
# Modul: Schnellerfassung & Triage
|
||||
|
||||
Ziel: Gedanken in Sekunden festhalten und später in Ruhe sortieren. Code: [InstantCaptureBar.tsx](../../frontend/src/features/capture/InstantCaptureBar.tsx), [TriageDeck.tsx](../../frontend/src/features/triage/TriageDeck.tsx), Backend `handleCaptureInbox`, `handleTriageAction` und `Store.UpdateThoughtTriage`.
|
||||
|
||||
## Erfassung
|
||||
|
||||
- Die Eingabeleiste sitzt im Header; `C` oder `/` fokussiert sie (außer beim Tippen in einem Feld).
|
||||
- Ziel (`target`): **Posteingang** (`inbox`, Standard), **Direkt-Aufgabe** (`task`), **Listeneintrag** (`list_item`, mit Zielliste) oder **Direkt-Notiz** (`document`).
|
||||
- Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt die Web Speech API des Browsers; Quelle `voice` fügt den Tag `#diktat` an.
|
||||
- Quelle `webhook` (per API-Key/Webhook) fügt den Tag `#webhook` an. Hashtags im Text werden als Tags extrahiert (`domain.ExtractTags`).
|
||||
- Per API: `POST /inbox` und `POST /webhooks/ingest` (siehe [rest.md](../api/rest.md)).
|
||||
|
||||
## Triage-Deck
|
||||
|
||||
Der Posteingang (`status = inbox`) wird Karte für Karte abgearbeitet.
|
||||
|
||||
| Aktion | Desktop | Mobil | Ergebnis |
|
||||
|---|---|---|---|
|
||||
| Verwerfen | `H` oder `←` | Wischen links | `status = discarded` (Papierkorb) |
|
||||
| Zur Aufgabe | `L` oder `→` | Wischen rechts | `kept`/`converted` als Aufgabe |
|
||||
| Aufgabe mit Termin | `T` | Aktionsmenü | wie oben, mit Fälligkeit |
|
||||
| In Notiz wandeln | `D` | Aktionsmenü | neues Dokument, Gedanke wird `converted` (`document`) |
|
||||
| Später | Leertaste | Aktionsmenü | Karte ans Stapelende |
|
||||
| Menü schließen | `Esc` | – | – |
|
||||
|
||||
Die Umwandlung in eine Notiz übernimmt Titel, Erledigt-Status, Notizen, Unteraufgaben sowie Fälligkeit und Priorität als eingerückte Blöcke. Eine Umwandlung in einen Listeneintrag legt einen Eintrag in der gewählten Liste an und benachrichtigt deren Teilnehmer.
|
||||
|
||||
## Zustände eines Gedankens
|
||||
|
||||
```
|
||||
inbox ──keep──▶ kept ──convert──▶ converted (task | document | list_item)
|
||||
│ │
|
||||
└─discard─▶ discarded ──restore──▶ inbox (Papierkorb leeren: DELETE /thoughts/discarded/purge)
|
||||
```
|
||||
|
||||
Ein Gedanke mit `status = kept` zählt bereits als Aufgabe und erscheint in der Aufgabenansicht.
|
||||
@@ -1,39 +0,0 @@
|
||||
# Modul: Workspace (Outliner-Notizen)
|
||||
|
||||
Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [CodeBlockItem.tsx](../../frontend/src/features/workspace/CodeBlockItem.tsx), [LinkModal.tsx](../../frontend/src/features/workspace/LinkModal.tsx); Backend: Dokument-, Ordner- und Tag-Handler, `Store.PatchDocumentBlocks`.
|
||||
|
||||
## Datenhaltung
|
||||
|
||||
- Eine Notiz ist ein Datensatz in `documents` plus geordnete Zeilen in `blocks` (`position` als Kommazahl, `level` für die Einrückung). Alles liegt in SQLite; es gibt keine Dateien auf der Platte.
|
||||
- Bei jeder Änderung wird `documents.markdown_cache` neu erzeugt; `block_tags` wird aus `#Hashtags` der Blöcke neu aufgebaut.
|
||||
- Der Export als `.md` erfolgt im Browser (Button im Editor, Blob-Download).
|
||||
- Blocktypen: Überschriften `h1`–`h3`, `paragraph`/`text`, `bullet`, `todo`, `code`, `callout`, `divider`, `table`, `progress`.
|
||||
|
||||
## Editor
|
||||
|
||||
- **Outliner:** `Enter` neuer Block, `Tab`/`Shift+Tab` ein-/ausrücken, `Shift+Enter` weicher Umbruch, Blöcke ein-/ausklappen.
|
||||
- **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück.
|
||||
- **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
|
||||
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`).
|
||||
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
|
||||
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
|
||||
- **Speichern:** Der Editor speichert 800 ms nach der letzten Eingabe das gesamte Dokument (`PUT /documents/{id}`). Der schlanke Block-Endpunkt `PATCH /documents/{id}/blocks` wird derzeit vom To-Do-Rollup beim Abhaken genutzt.
|
||||
|
||||
## Explorer, Tags, To-Dos
|
||||
|
||||
- Ordnerbaum mit Favoriten (`doc_pins`), Verschieben per Drag & Drop (`/documents/{id}/move`, `/folders/move`).
|
||||
- **Tags:** Alle `#Hashtags` werden aggregiert (`/workspace/tags`). Eine Tag-Seite zeigt alle Fundstellen gruppiert nach Notiz und kann eine eigene Freitext-Notiz tragen (`tag_pages`).
|
||||
- **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz.
|
||||
- **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron.
|
||||
|
||||
## Freigabe
|
||||
|
||||
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md), [FLAW-17](../flaws.md)).
|
||||
- Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt.
|
||||
- Änderungen erreichen offene Fenster über `DOC_BLOCKS_PATCHED`/`DOC_UPDATED` ([websocket.md](../api/websocket.md)).
|
||||
|
||||
## Grenzen
|
||||
|
||||
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie.
|
||||
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
|
||||
- Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen ([FLAW-18](../flaws.md)).
|
||||
@@ -1,229 +0,0 @@
|
||||
# 🛡️ Reverse-Proxy & WebServer-Konfigurationshandbuch
|
||||
|
||||
Dieses Dokument beschreibt die empfohlene Konfiguration von Reverse-Proxies und Ingress-Controllern für MindSift. MindSift nutzt eine **hybride Realtime-Architektur** bestehend aus bidirektionalen **WebSockets** und einem automatischen **Server-Sent Events (SSE) Fallback**, um selbst in restriktivsten Firmennetzwerken, hinter strikten Firewalls oder bei Deep-Packet-Inspection unterbrechungsfreie Live-Synchronisation zu gewährleisten.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Wichtige Anforderungen auf einen Blick
|
||||
|
||||
| Protokoll | Standard-Port | Benötigte Header & Einstellungen | Zweck |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`<br>`Connection "Upgrade"`<br>`proxy_read_timeout 3600s` | Bidirektionale Live-Updates (Aufgaben, Listen, Notizen) |
|
||||
| **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`<br>`proxy_buffering off`<br>`proxy_cache off` | Fallback bei blockiertem WebSocket-Upgrade |
|
||||
| **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Import/Export (JSON) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Nginx
|
||||
|
||||
Eine typische `nginx.conf` bzw. Site-Konfiguration unter `/etc/nginx/sites-available/mindsift`:
|
||||
|
||||
```nginx
|
||||
# Map für sauberes Connection-Handling bei WebSocket-Upgrades
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name mindsift.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name mindsift.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/mindsift.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/mindsift.example.com/privkey.pem;
|
||||
|
||||
# Maximale Payload-Größe für große Notizen und Exporte
|
||||
client_max_body_size 64M;
|
||||
|
||||
# MindSift Frontend & REST API
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 1. WebSocket Endpunkte (/api/v1/ws)
|
||||
location /api/v1/ws {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Upgrade-Header zwingend erforderlich
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Verhindert vorzeitigen Verbindungsabbruch durch Proxy-Inaktivitäts-Timeout
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# 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;
|
||||
|
||||
# Pufferung zwingend deaktivieren für sofortiges Event-Streaming
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_set_header X-Accel-Buffering no;
|
||||
|
||||
proxy_set_header Connection '';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Traefik (Docker Compose / Ingress)
|
||||
|
||||
Bei Verwendung von Traefik Version 2/3 müssen keine expliziten WebSocket-Upgrades deklariert werden, da Traefik diese transparent durchleitet. Für Server-Sent Events und Timeouts empfehlen sich Middleware-Definitionen:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mindsift:
|
||||
image: ghcr.io/sandrodach/mindsift:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORT=8080
|
||||
- DB_PATH=/app/data/mindsift.db
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.mindsift.rule=Host(`mindsift.example.com`)"
|
||||
- "traefik.http.routers.mindsift.entrypoints=websecure"
|
||||
- "traefik.http.routers.mindsift.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mindsift.loadbalancer.server.port=8080"
|
||||
|
||||
# Verhindert Pufferung und hält persistente SSE-Streams am Leben
|
||||
- "traefik.http.middlewares.mindsift-buffering.buffering.maxRequestBodyBytes=67108864"
|
||||
- "traefik.http.routers.mindsift.middlewares=mindsift-buffering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Caddy Server (Caddyfile)
|
||||
|
||||
Caddy unterstützt WebSockets und HTTP/2 Server-Sent Events von Haus aus vollständig ("out-of-the-box"):
|
||||
|
||||
```caddyfile
|
||||
mindsift.example.com {
|
||||
encode gzip zstd
|
||||
|
||||
# Event-Stream (SSE) ohne Pufferung weiterleiten
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
# Streaming für SSE optimieren
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Cloudflare Proxy (Orange Cloud)
|
||||
|
||||
Wird MindSift hinter dem Cloudflare CDN betrieben:
|
||||
1. **WebSockets aktivieren:**
|
||||
- Navigiere in das Cloudflare Dashboard: **Network** > **WebSockets** > Schalter auf **ON** stellen.
|
||||
2. **Cloudflare Rocket Loader / Auto Minify:**
|
||||
- Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe den Start der Single-Page-App stören können.
|
||||
3. **Cloudflare WAF / Bot Fight Mode:**
|
||||
- Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden.
|
||||
4. **SSE-Unterstützung:**
|
||||
- Cloudflare unterstützt Server-Sent Events nativ über HTTP/2 und HTTP/3. Dank der vom MindSift-Backend gesetzten Header `X-Accel-Buffering: no` und `Cache-Control: no-cache` streamt Cloudflare Echtzeit-Events verzögerungsfrei an die Clients.
|
||||
|
||||
---
|
||||
|
||||
## 5. Apache 2.4 (`mod_proxy_wstunnel`)
|
||||
|
||||
Unter Debian/Ubuntu:
|
||||
```bash
|
||||
sudo a2enmod proxy proxy_http proxy_wstunnel headers
|
||||
sudo systemctl restart apache2
|
||||
```
|
||||
|
||||
VirtualHost-Konfiguration (`/etc/apache2/sites-available/mindsift.conf`):
|
||||
|
||||
```apache
|
||||
<VirtualHost *:443>
|
||||
ServerName mindsift.example.com
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/mindsift.example.com/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/mindsift.example.com/privkey.pem
|
||||
|
||||
# 1. WebSocket Tunneling
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTP:Upgrade} =websocket [NC]
|
||||
RewriteRule /(.*) ws://127.0.0.1:8080/$1 [P,L]
|
||||
RewriteCond %{HTTP:Upgrade} !=websocket [NC]
|
||||
RewriteRule /(.*) http://127.0.0.1:8080/$1 [P,L]
|
||||
|
||||
# 2. SSE Response Flushing
|
||||
<Location "/api/v1/events">
|
||||
ProxyPass "http://127.0.0.1:8080/api/v1/events" flushpackets=on
|
||||
ProxyPassReverse "http://127.0.0.1:8080/api/v1/events"
|
||||
</Location>
|
||||
|
||||
ProxyPass / http://127.0.0.1:8080/
|
||||
ProxyPassReverse / http://127.0.0.1:8080/
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Diagnose & Verifikation
|
||||
|
||||
Mit den folgenden Befehlen lässt sich die Erreichbarkeit und Streaming-Funktion prüfen:
|
||||
|
||||
### 1. Server-Sent Events Test (Standard-HTTPS)
|
||||
```bash
|
||||
curl -N -H "Accept: text/event-stream" -H "Authorization: Bearer <DEIN_TOKEN>" https://mindsift.example.com/api/v1/events
|
||||
```
|
||||
*Erwartete Ausgabe:*
|
||||
```text
|
||||
: connected (transport: sse, id: sse_...)
|
||||
data: {"event":"SSE_CONNECTED","payload":{"sseId":"sse_...","transport":"sse"},"timestamp":"..."}
|
||||
: ping
|
||||
```
|
||||
|
||||
### 2. WebSocket Handshake Test
|
||||
```bash
|
||||
curl -i -N \
|
||||
-H "Connection: Upgrade" \
|
||||
-H "Upgrade: websocket" \
|
||||
-H "Host: mindsift.example.com" \
|
||||
-H "Origin: https://mindsift.example.com" \
|
||||
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
|
||||
-H "Sec-WebSocket-Version: 13" \
|
||||
https://mindsift.example.com/api/v1/ws?token=<DEIN_TOKEN>
|
||||
```
|
||||
*Erwartete Antwort:* `101 Switching Protocols`
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Verwandte Dokumente
|
||||
- 🔌 [WebSocket & Live-Protokoll](api/websocket.md)
|
||||
- 🏗️ [Gesamtarchitektur](architecture.md)
|
||||
- 🚨 [Bekannte Schwachstellen & Flaws](flaws.md)
|
||||
@@ -1,55 +0,0 @@
|
||||
# Teilen & Berechtigungen
|
||||
|
||||
## Rollen
|
||||
|
||||
| Ebene | Werte | Bedeutung |
|
||||
|---|---|---|
|
||||
| Server | `admin`, `member` | Admins verwalten Benutzer, Einladungen und Einstellungen (`/admin/*`). |
|
||||
| Gruppe | `admin`, `member` | Gruppen-Admins ändern/löschen die Gruppe und verwalten Mitglieder. Wer eine Gruppe anlegt, wird Admin. |
|
||||
| Ressource | siehe unten | Eigentümer, Bearbeiter, Leser. |
|
||||
|
||||
## Berechtigungen je Ressource
|
||||
|
||||
| Ressource | Eigentümer | Bearbeiter | Leser | Freigabe an |
|
||||
|---|---|---|---|---|
|
||||
| **Aufgabenliste** (`task_list_shares.permission`) | alles, löschen, (un)teilen | Aufgaben anlegen/ändern/abhaken/verschieben/löschen, Liste umbenennen, Spalten ändern | nur ansehen | Benutzer, Gruppen |
|
||||
| **Geteilte Liste** (`list_shares.role`) | alles, löschen, (un)teilen | Einträge anlegen/ändern/abhaken/löschen | nur ansehen | Benutzer, Gruppen |
|
||||
| **Notiz** (`doc_shares`, `doc_team_shares`) | schreiben | – | nur lesen | Benutzer, Gruppen |
|
||||
| **Geteilter Ordner** (`workspace_folder_shares`) | schreiben | – | nur lesen | Benutzer, Gruppen |
|
||||
|
||||
- **Notizen und Ordner sind absichtlich schreibgeschützt für Empfänger.** Das erzwingt eine Migration beim Start (`permission = 'read'`); Schreibpfade im Code sind tot ([FLAW-17](flaws.md)).
|
||||
- Bei Aufgabenlisten und Listen gilt: Hat jemand mehrere Zugriffswege (direkt und über Gruppen), zählt die **höchste** Stufe.
|
||||
- Für Gruppen gilt die Freigabe für alle aktuellen und künftigen Mitglieder.
|
||||
- Aufgabenlisten besitzen zusätzlich das Altfeld `team_id`: Mitglieder dieser Gruppe haben ohne Freigabezeile Bearbeiterrechte. Eine Gruppenfreigabe über den Dialog setzt `team_id`, falls es noch leer ist.
|
||||
|
||||
## Aufgabenlisten teilen (Details)
|
||||
|
||||
1. Eigentümer öffnet „Teilen“ an der Liste, wählt Gruppe oder Benutzer und die Stufe **Bearbeiten** oder **Nur Lesen**.
|
||||
2. `POST /task-lists/{id}/shares` speichert `permission`; erneutes Hinzufügen desselben Ziels ändert die Stufe.
|
||||
3. `GET /task-lists` liefert je Liste `permission` (`owner`/`editor`/`viewer`). Das Frontend blendet für `viewer` alle Eingabemöglichkeiten aus und zeigt „Nur Lesen“.
|
||||
4. Der Server prüft unabhängig davon jeden Schreibzugriff (`403`, `storage.ErrTaskListReadOnly`).
|
||||
|
||||
Bestehende Freigaben aus der Zeit vor dieser Stufe wurden mit `editor` migriert, behalten also ihr Verhalten.
|
||||
|
||||
## Isolations-Modi (`system_settings.sharing_mode`)
|
||||
|
||||
Werte: `strict_private`, `teams_only` (Standard), `open`. Gedacht ist:
|
||||
|
||||
| Modus | Zweck |
|
||||
|---|---|
|
||||
| `strict_private` | Keine Sichtbarkeit zwischen Benutzern |
|
||||
| `teams_only` | Freigaben nur zwischen Mitgliedern gemeinsamer Gruppen |
|
||||
| `open` | Jeder kann für jeden freigeben |
|
||||
|
||||
**Tatsächlich** wird der Modus nur in `GetDiscoverableUsers` ausgewertet, und dort liefert `teams_only` bereits alle Benutzer. `GET /collaborators` ignoriert den Modus, und keine Freigabe-Funktion prüft ihn. Die Einstellung hat derzeit also kaum Wirkung ([FLAW-10](flaws.md)).
|
||||
|
||||
## Entkoppelte Ablage geteilter Notizen und Ordner
|
||||
|
||||
- **Geteilte Ordner:** Der Ordner existiert einmal (`workspace_shared_folders`); jeder Empfänger hat seinen eigenen Einhängepfad (`workspace_folder_mounts.mount_path`). Verschiebt ein Empfänger den Ordner, ändert sich nur sein Pfad. Der Unterpfad innerhalb des Ordners (`documents.sub_folder_path`) gilt für alle.
|
||||
- **Einzelne Notizen:** Jeder Empfänger kann eine geteilte Notiz in einen eigenen Ordner legen (`doc_user_folders`); die Notiz des Eigentümers bleibt unberührt.
|
||||
|
||||
## Sitzungen und Zugriff
|
||||
|
||||
- Tokens werden bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ ungültig (`token_version`); ausgeloggte Tokens landen in `revoked_tokens`.
|
||||
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
|
||||
- Bekannte Lücken in der Zugriffskontrolle: [FLAW-01](flaws.md), [FLAW-02](flaws.md), [FLAW-03](flaws.md), [FLAW-06](flaws.md), [FLAW-07](flaws.md), [FLAW-08](flaws.md), [FLAW-24](flaws.md).
|
||||
@@ -1,109 +0,0 @@
|
||||
# 🎨 Single-File CSS Theming Engine & Design-System
|
||||
|
||||
MindSift implementiert ein radikal einfaches, modulares Theming-System. Anstatt Farb- und Geometriewerte in Dutzenden Tailwind-Klassen über hunderte Komponenten zu verstreuen, wird das komplette Erscheinungsbild der Plattform über **eine einzige CSS-Datei pro Theme** gesteuert.
|
||||
|
||||
---
|
||||
|
||||
## 💡 1. Philosophie: 1 Datei = 1 komplettes Theme
|
||||
|
||||
Jedes Theme liegt im Verzeichnis [`frontend/src/themes/`](../frontend/src/themes/) und definiert alle visuellen Eigenschaften über standardisierte CSS-Variablen.
|
||||
|
||||
### Trennung von Struktur und Design
|
||||
- **React-Komponenten** definieren ausschließlich semantische Klassen (wie `.panel-surface`, `.input-field`, `.btn-primary`, `text-main`, `bg-canvas`).
|
||||
- **Die aktive Theme-CSS-Datei** bestimmt, welche konkreten Farben, Kontraste, Rahmenbreiten und Ecken-Rundungen diesen Klassen zugewiesen werden.
|
||||
|
||||
---
|
||||
|
||||
## 🧱 2. Die 6 standardisierten Variablen-Blöcke
|
||||
|
||||
Jede Theme-Datei gliedert sich in sechs exakt definierte Bereiche:
|
||||
|
||||
```css
|
||||
:root[data-theme="dein-theme"] {
|
||||
/* ==========================================================================
|
||||
1. AKZENTFARBE (Fokus, Primäraktionen, Highlights)
|
||||
========================================================================== */
|
||||
--accent: #2563eb; /* Primäre Schaltflächen & aktive Tabs */
|
||||
--accent-hover: #3b82f6; /* Hover-Zustand */
|
||||
--accent-dim: rgba(37, 99, 235, 0.15);/* Transparenter Hintergrund für Badges */
|
||||
--accent-contrast: #ffffff; /* Textfarbe auf Akzentflächen */
|
||||
|
||||
/* ==========================================================================
|
||||
2. OBERFLÄCHEN & HINTERGRÜNDE
|
||||
========================================================================== */
|
||||
--bg-canvas: #0e1013; /* Grundhintergrund der gesamten Seite */
|
||||
--bg-panel: #16181d; /* Panels, Karten, Modals & Seitenleisten */
|
||||
--bg-panel-elevated: #1f2229; /* Hervorgehobene Elemente & Hover-Panels */
|
||||
--bg-input: #121418; /* Hintergrund von Textfeldern */
|
||||
--bg-code: #0b0c0f; /* Codeblöcke & Tastatur-Badges */
|
||||
--bg-overlay: rgba(0, 0, 0, 0.75); /* Modal-Hintergrund Backdrop */
|
||||
|
||||
/* ==========================================================================
|
||||
3. TEXT & KONTRASTE
|
||||
========================================================================== */
|
||||
--text-main: #f4f4f5; /* Haupttext, Überschriften, Labels */
|
||||
--text-muted: #a1a1aa; /* Sekundärtext, Beschreibungen */
|
||||
--text-faint: #71717a; /* Dezentester Text, Tastaturhinweise */
|
||||
--text-on-accent: #ffffff; /* Lesbarer Text auf --accent */
|
||||
|
||||
/* ==========================================================================
|
||||
4. RAHMEN, LINIEN & SCHATTEN
|
||||
========================================================================== */
|
||||
--border-color: rgba(255, 255, 255, 0.08); /* Standard-Trennlinien */
|
||||
--border-strong: rgba(255, 255, 255, 0.18);/* Fokussierte Rahmen, Tabs */
|
||||
--shadow-panel: 0 4px 20px -2px rgba(0, 0, 0, 0.45); /* Box-Shadows */
|
||||
|
||||
/* ==========================================================================
|
||||
5. STATUS- & SIGNALFARBEN
|
||||
========================================================================== */
|
||||
--status-ok: #22c55e; /* Erledigt, Online, Aktiv (Grün) */
|
||||
--status-err: #ef4444; /* Fehler, Löschen, Dringend (Rot) */
|
||||
--status-warn: #eab308; /* Wichtig, Warnung, Gesperrt (Gelb) */
|
||||
--status-info: #3b82f6; /* Hinweise, Information (Blau) */
|
||||
|
||||
/* ==========================================================================
|
||||
6. RUNDUNGEN / GEOMETRIE
|
||||
========================================================================== */
|
||||
--radius-card: 0px; /* Eckenrundung von Panels & Karten */
|
||||
--radius-btn: 2px; /* Eckenrundung von Schaltflächen */
|
||||
--radius-input: 2px; /* Eckenrundung von Eingabefeldern */
|
||||
--radius-checkbox: 2px; /* Checkboxen */
|
||||
--radius-pill: 9999px; /* Pillen & Status-Badges */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎭 3. Verfügbare Standard-Themes
|
||||
|
||||
| Theme-ID | Datei | Stil & Charakter | Standard-Geometrie |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `dark` | [`theme-dark.css`](../frontend/src/themes/theme-dark.css) | Standard Dark Slate / Monochrom | Streng eckig (`0px` Cards, `2px` Buttons) |
|
||||
| `light` | [`theme-light.css`](../frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalistisch | Modern dezent (`4px` Cards, `4px` Buttons) |
|
||||
| `high-contrast` | [`theme-high-contrast.css`](../frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit AAA-Kontrast | Streng eckig (`0px`, kräftige Rahmen) |
|
||||
| `soft` | [`theme-soft.css`](../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) |
|
||||
| *Template* | [`theme-template.css`](../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar |
|
||||
|
||||
---
|
||||
|
||||
## 🔌 4. Tailwind-Bridge & Semantische Klassen
|
||||
|
||||
In [`frontend/src/index.css`](../frontend/src/index.css) und `tailwind.config.js` sind die semantischen Utility-Klassen definiert, die sich automatisch an das gewählte Theme anpassen:
|
||||
|
||||
- `.panel-surface`: Wendet `--bg-panel`, `--border-color` und `--radius-card` an.
|
||||
- `.input-field`: Wendet `--bg-input`, `--border-color`, `--radius-input` und `--text-main` an.
|
||||
- `.btn-primary`: Akzent-Schaltfläche mit `--accent`, `--accent-hover`, `--text-on-accent` und `--radius-btn`.
|
||||
- `.btn-action`: Sekundär-Aktion mit dezentem Rahmen und Hover-Zustand.
|
||||
- `.checkbox-square`: Kontrollkästchen mit `--radius-checkbox`.
|
||||
- `.capture-pill`: Spezial-Pille für die Desktop-Schnelleingabeleiste mit `--radius-pill`.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 5. Ein eigenes Theme erstellen
|
||||
|
||||
Um ein neues Theme (z.B. „Nordic Blue" oder „Cyberpunk") hinzuzufügen:
|
||||
1. Kopiere `frontend/src/themes/theme-template.css` nach `frontend/src/themes/theme-meintheme.css`.
|
||||
2. Ändere den Selektor `:root[data-theme="template"]` in `:root[data-theme="meintheme"]`.
|
||||
3. Passe Farben und Rundungen nach Belieben an.
|
||||
4. Importiere die CSS-Datei in [`frontend/src/lib/theme.ts`](../frontend/src/lib/theme.ts) und trage das Theme im `AVAILABLE_THEMES`-Array ein.
|
||||
5. Das neue Theme steht sofort in den Kontoeinstellungen und im Profil-Dropdown zur Verfügung!
|
||||
@@ -5,194 +5,172 @@
|
||||
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#-deine-daten-gehören-dir)
|
||||
[](#)
|
||||
[](LICENSE)
|
||||
|
||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||
**MindSift** ist deine zentrale, kompromisslos schnelle Schaltstelle für spontane Gedanken, strukturierte Aufgaben, hierarchische Outliner-Notizen und gemeinsame Echtzeit-Listen — ablenkungsfrei gestaltet, lokal als Standard-Markdown gespeichert und zu 100 % unter deiner eigenen Kontrolle.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/Triage_desktop.png" alt="MindSift Triage Desktop" width="85%" />
|
||||
</p>
|
||||
[Philosophie](#-die-philosophie) • [Funktionen](#-was-mindsift-auszeichnet) • [Schnellstart](#-schnellstart) • [Für wen?](#-für-wen-ist-mindsift-gemacht)
|
||||
|
||||
[Philosophie](#-die-philosophie) • [Funktionen](#-funktionen) • [Schnellstart](#-schnellstart) • [Betrieb](#-betrieb--konfiguration) • [Entwicklung](#-entwicklung) • [Dokumentation](#-dokumentation)
|
||||
---
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
## 💡 Die Philosophie: *Erst raus aus dem Kopf – später sortieren*
|
||||
|
||||
## 💡 Die Philosophie
|
||||
Klassische Produktivitäts- und Notiz-Apps scheitern im Alltag oft an derselben Hürde: **Sie verlangen Struktur im falschen Moment.**
|
||||
Wenn dir mitten im Gespräch, unterwegs oder kurz vor Feierabend ein wichtiger Gedanke kommt, willst du keinen Ordner auswählen, keine Datenbank-Eigenschaften pflegen und kein Formular ausfüllen. Du willst den Gedanken in drei Sekunden loswerden – und sicher sein, dass er nicht verloren geht.
|
||||
|
||||
Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn dir ein Gedanke kommt, willst du keinen Ordner suchen und keine Felder ausfüllen. MindSift trennt deshalb **Erfassen** und **Organisieren**:
|
||||
**MindSift trennt das Erfassen radikal vom Organisieren:**
|
||||
|
||||
1. **Abladen:** In die Eingabeleiste tippen, `Enter`, fertig. Wer will, weist den Gedanken gleich einer Aufgabe, Liste oder Notiz zu.
|
||||
2. **Sichten (Triage):** Später gehst du den Stapel Karte für Karte durch: verwerfen, zur Aufgabe machen, in eine Notiz oder Liste verwandeln.
|
||||
3. **Vertiefen:** Aus losen Ideen werden Aufgaben mit Unteraufgaben, vernetzte Outliner-Notizen oder geteilte Listen für Familie und Team.
|
||||
1. **Sofort abladen (Quick Dump):** Oben in der Eingabeleiste tippen, `Enter` drücken, fertig. Dein Kopf ist wieder frei. Alternativ kannst du einen Eintrag schon beim Schreiben direkt einer Aufgabenliste, Einkaufsliste oder Notiz zuweisen.
|
||||
2. **Spielerisch sichten (Triage):** Wenn du später Ruhe hast, sichtest du deinen Gedanken-Stapel Karte für Karte per Tastatur oder Swipe. Nach links verwerfen, auf „Später“ schieben oder mit einem Tastendruck in eine Aufgabe, ein Dokument oder einen Listeneintrag verwandeln.
|
||||
3. **Strukturiert vertiefen:** Aus spontanen Notizen werden hierarchische Outliner-Dokumente, vernetzte Notizbäume, Kanban-Boards oder geteilte Listen für Familie und Team.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Funktionen
|
||||
## ✨ Was MindSift auszeichnet
|
||||
|
||||
### ⚡ Schnellerfassung & Triage
|
||||
### ⚡ Quick-Dump & Triage-Deck (Das 3-Sekunden-Prinzip)
|
||||
* **Blitz-Erfassung:** Die Schnelleingabeleiste sitzt prominent ganz oben im Header – jederzeit erreichbar per Klick oder Tastendruck (`C` oder `/`).
|
||||
* **Entscheiden mit 1 Taste:** Im Triage-Deck steht immer genau ein Gedanke im Fokus:
|
||||
* `H` oder `←`: Wegwerfen / Erledigt
|
||||
* `L` oder `→`: Sofort zur Aufgabe machen
|
||||
* `T`: Termin-Aufgabe mit Fälligkeitsdatum
|
||||
* `D`: In Workspace-Notiz umwandeln
|
||||
* `Leertaste`: Später (ans Ende des Stapels schieben)
|
||||
|
||||
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe (Browser-Funktion).
|
||||
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
|
||||
* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste.
|
||||
* Papierkorb mit Wiederherstellen.
|
||||
### 📝 Workspace: Der hierarchische Logseq-Outliner
|
||||
* **Jeder Gedanke ein Bullet-Point:** Schreibe Notizen als flexible, hierarchische Gliederung:
|
||||
* `Enter`: Neuer Bullet-Point auf derselben Ebene
|
||||
* `Tab` / `Shift + Tab`: Unterordnen (einrücken) und ausrücken
|
||||
* `Shift + Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks
|
||||
* **Zoom-in Hoisting:** Klicke auf den Bullet-Punkt (`•`), um tief in einen Ast hineinzuzoomen (Fokus-Modus). Über die dezenten Breadcrumbs oben springst du jederzeit wieder zurück.
|
||||
* **Slash-Befehle (`/`):** Tippe einfach `/` für `/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/table`, `/callout` oder `/progress`.
|
||||
* **Datei-Explorer mit Drag & Drop:** Verschiebe Dokumente mit der Maus direkt per Drag & Drop in Ordner oder zurück ins Hauptverzeichnis.
|
||||
* **Angepinnte Favoriten:** Hefte wichtige Notizen mit einem Klick an – sie erscheinen prominent ganz oben im Explorer.
|
||||
* **100 % Standard-Markdown auf der Festplatte:** Alle Notizen werden unter `/data/workspace/<user_id>/...` als echte `.md`-Dateien gespeichert (mit 2-Leerzeichen-Einrückung). Vollständig kompatibel mit Obsidian, Logseq oder einfachen Text-Editoren – kein Vendor-Lock-in!
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/Mobile_Triage.png" alt="MindSift Mobile Triage" width="280px" />
|
||||
</p>
|
||||
### 📋 Globale To-Dos: Das automatische Aufgaben-Rollup
|
||||
* **Alles auf einen Blick:** MindSift durchsucht all deine Notizen und sammelt sämtliche Checkboxen (`- [ ]`) in einem zentralen Rollup-Dashboard.
|
||||
* **Ordner-Drilldown:** Isoliere einzelne Projekt-Ordner mit einem Klick.
|
||||
* **Echtzeit-2-Wege-Sync:** Hakst du eine Aufgabe im Rollup ab, wird sie im selben Augenblick direkt in der physischen `.md`-Datei auf deiner Festplatte als `- [x]` aktualisiert!
|
||||
|
||||
### ✅ Aufgaben & Kanban
|
||||
### ✅ Aufgabenlisten & Kanban-Board
|
||||
* **Mehrere Listen:** Trenne Berufliches, Privates, Homelab oder Team-Projekte sauber in eigenen Listen.
|
||||
* **Liste oder Kanban:** Wechsle mit einem Klick zwischen kompakter Listenansicht und dem **3-Spalten-Kanban-Board** (*Zu erledigen*, *In Arbeit*, *Erledigt*).
|
||||
* **Teilen & Avatar-Stack:** Teile Aufgabenlisten mit Spaces und Teams. Der integrierte Avatar-Stack zeigt mit kleinen Initialen-Bubbles und Tooltips sofort, wer Zugriff hat.
|
||||
|
||||
* Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar.
|
||||
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
|
||||
* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board.
|
||||
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
|
||||
* **Alarme** bei Fälligkeit per E-Mail, Discord oder eigenem Webhook.
|
||||
### 🛒 Geteilte Echtzeit-Listen (Einkauf & Checklisten)
|
||||
* **Gemeinsam abhaken:** Ob Wocheneinkauf oder Urlaubs-Packliste – teile Listen in Echtzeit über WebSocket.
|
||||
* **Live-Synchronisation:** Wird ein Artikel im Laden abgehakt, verschwindet er sofort bei allen anderen Teilnehmern.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
|
||||
</p>
|
||||
### 🏢 Organisationen, Teams & Administration
|
||||
* **Geführte Ersteinrichtung:** Beim allerersten Start führt dich MindSift automatisch durch die Einrichtung deines Administrator-Kontos.
|
||||
* **3-Stufen-Rollensystem:** Server-Admin, Team-Admin und reguläres Mitglied.
|
||||
* **Einladungscodes & Registrierungs-Schalter:** Schalte die öffentliche Registrierung bequem per Schiebeschalter ab und lade neue Mitglieder gezielt per Einladungscode ein.
|
||||
* **Server-Isolation:** Bestimme zentral, ob Nutzer strikt privat für sich arbeiten, sich nur innerhalb ihrer Teams sehen oder serverweit kooperieren.
|
||||
|
||||
### 📝 Workspace: Outliner-Notizen
|
||||
### 🔔 Multi-Channel-Alarme
|
||||
* **Pünktlich erinnert:** Automatische Benachrichtigung vor Fälligkeit wahlweise per **E-Mail (SMTP)**, **Discord-Webhook** oder **eigenem Webhook-Endpunkt**.
|
||||
* **Eigene API-Schlüssel:** Sende System-Alarme, Server-Logs oder Skripte per einfachem `curl`-Befehl direkt in deine Inbox.
|
||||
|
||||
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste.
|
||||
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren.
|
||||
* Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`.
|
||||
* Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
|
||||
* Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`.
|
||||
* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt.
|
||||
* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen **zum Lesen** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace Outliner" width="90%" />
|
||||
</p>
|
||||
|
||||
### 🛒 Geteilte Listen
|
||||
|
||||
* Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien.
|
||||
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/Listen_desktop.png" alt="MindSift Geteilte Listen" width="90%" />
|
||||
</p>
|
||||
|
||||
### 🏢 Benutzer, Gruppen, Administration
|
||||
|
||||
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
|
||||
* Gruppen mit Gruppen-Admins für Freigaben.
|
||||
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
|
||||
* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA.
|
||||
|
||||
### 🔌 Integrationen
|
||||
|
||||
* **Webhook / API-Key:** `POST /api/v1/webhooks/ingest` legt Aufgaben aus Smarthome, Skripten oder Automationen an.
|
||||
* Persönliche API-Keys, Datenexport als JSON.
|
||||
### 🎨 Single-File CSS Theming & Hilfe
|
||||
* **1 Datei = 1 Theme:** Vollständiges Design-System gesteuert über genau eine CSS-Datei (`dark`, `light`, `high-contrast`, `soft`).
|
||||
* **Integrierte Hilfeseite:** Ein Klick auf das Profil-Menü oder den Hilfe-Button öffnet eine strukturierte Dokumentation inklusive Tastatur-Cheat-Sheet.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Schnellstart
|
||||
|
||||
MindSift läuft als einzelner Container mit Webserver, API, Datenbank und Frontend.
|
||||
MindSift läuft als leichtgewichtiger, einzelner Container und bringt Webserver, API, Datenbank und Frontend direkt mit.
|
||||
|
||||
### Option 1: Installationsskript
|
||||
### Option 1: Der 1-Zeiler (Empfohlen)
|
||||
|
||||
Führe diesen Befehl im Terminal deines Servers aus. Er erstellt das Verzeichnis, lädt die Konfiguration, erzeugt ein sicheres JWT-Geheimnis und startet den Container:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/install.sh | bash
|
||||
```
|
||||
|
||||
Das Skript legt ein Verzeichnis an, lädt `docker-compose.yml` und `.env.example`, erzeugt ein `JWT_SECRET` und startet den Container.
|
||||
---
|
||||
|
||||
### Option 2: Docker Compose
|
||||
### Option 2: Schneller Start mit zwei `curl`-Befehlen
|
||||
|
||||
Falls du die Dateien direkt ohne Skript herunterladen möchtest:
|
||||
|
||||
```bash
|
||||
mkdir -p mindsift && cd mindsift
|
||||
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/docker-compose.yml -o docker-compose.yml
|
||||
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/.env.example -o .env
|
||||
# In .env: JWT_SECRET durch einen langen Zufallswert ersetzen!
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Minimal:
|
||||
---
|
||||
|
||||
### Option 3: Manuelle `docker-compose.yml`
|
||||
|
||||
Erstelle eine Datei namens `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mindsift:
|
||||
image: git.dsit.me/sandrodach/mindsift:dev
|
||||
container_name: mindsift
|
||||
restart: unless-stopped
|
||||
ports: ["8080:8080"]
|
||||
volumes: ["./data:/data"]
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
environment:
|
||||
- JWT_SECRET=hier-ein-langer-zufaelliger-wert
|
||||
- BASE_URL=https://mindsift.example.com
|
||||
- PORT=8080
|
||||
- DATA_DIR=/data
|
||||
- JWT_SECRET=hier-ein-langes-sicheres-zufalls-geheimnis-eintragen
|
||||
- ALLOW_REGISTRATION=false
|
||||
# Optional: Mailserver für E-Mail-Alarme (auch im Admin-UI einstellbar)
|
||||
- SMTP_HOST=
|
||||
- SMTP_PORT=587
|
||||
- SMTP_USER=
|
||||
- SMTP_PASS=
|
||||
- SMTP_FROM=
|
||||
- SMTP_TLS=true
|
||||
```
|
||||
|
||||
### Ersteinrichtung
|
||||
|
||||
Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch das Anlegen des Administrator-Kontos.
|
||||
|
||||
> ⚠️ **Setze `JWT_SECRET`.** Ohne Angabe nutzt der Server einen öffentlich bekannten Standardwert, mit dem sich Anmelde-Tokens fälschen lassen. Rufe die Instanz außerdem direkt nach dem Start auf, damit kein Fremder das erste Konto anlegt.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Betrieb & Konfiguration
|
||||
|
||||
| Variable | Standard | Zweck |
|
||||
|---|---|---|
|
||||
| `PORT` | `8080` | HTTP-Port |
|
||||
| `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
|
||||
| `JWT_SECRET` | unsicherer Standard | **Pflicht in Produktion**, Signaturschlüssel |
|
||||
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL |
|
||||
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
|
||||
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin`, `user/user` an. **Nie auf öffentlichen Servern!** |
|
||||
|
||||
* **Reverse-Proxy:** WebSocket-Upgrade und ungepufferte SSE-Streams müssen durchgereicht werden. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
|
||||
* **Datensicherung:** Alle Daten liegen in `data/mindsift.db` (+ `-wal`/`-shm`). Sichere sie mit `sqlite3 mindsift.db ".backup 'backup.db'"` oder bei gestopptem Container. Zusätzlich gibt es einen JSON-Export pro Benutzer.
|
||||
* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 Entwicklung
|
||||
|
||||
Voraussetzungen: Go 1.23, Node 22.
|
||||
Starte den Container:
|
||||
|
||||
```bash
|
||||
# Backend: Vet und Tests
|
||||
go vet ./... && go test ./...
|
||||
|
||||
# Frontend
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run dev # Vite-Entwicklungsserver
|
||||
npx tsc --noEmit # Typprüfung
|
||||
npm run build # Produktions-Build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Vet und Tests beim Image-Build aus.
|
||||
|
||||
**Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite) · React 18, TypeScript, Vite, Tailwind.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Dokumentation
|
||||
### 🌐 Ersteinrichtung aufrufen
|
||||
|
||||
Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank, REST- und Echtzeit-API, Module, Freigaben und Berechtigungen, Themes. Bekannte Schwachstellen und offene Punkte stehen in [Doku/flaws.md](Doku/flaws.md).
|
||||
Öffne deinen Browser unter **`http://localhost:8080`** (bzw. die IP deines Servers).
|
||||
MindSift erkennt den Erststart automatisch und führt dich durch den Einrichtungs-Assistenten zum Erstellen des Administrator-Kontos.
|
||||
|
||||
---
|
||||
|
||||
## 👥 Für wen ist MindSift gemacht?
|
||||
|
||||
* **Für Macher & Wissensarbeiter**, die tagsüber dutzende Gedanken, Links und Aufgaben festhalten müssen, ohne aus dem Arbeitsfluss gerissen zu werden.
|
||||
* **Für Outliner-Fans**, die die Flexibilität von Logseq schätzen, aber ihre Notizen in einer klaren Ordnerstruktur auf dem eigenen Server haben wollen.
|
||||
* **Für Familien & WGs**, die gemeinsame Einkaufs- und Packlisten ohne Werbe-Apps und fremde Clouds synchronisieren möchten.
|
||||
* **Für Homelab- & Self-Hosting-Begeisterte**, die volle Datenhoheit, saubere Markdown-Dateien und automatisierte Webhooks auf eigener Hardware schätzen.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Deine Daten gehören dir
|
||||
|
||||
* Selbst gehostet, keine Telemetrie, keine Fremddienste. Ausgehende Verbindungen gibt es nur zu den Alarm-Zielen, die du selbst einrichtest (SMTP, Discord, Webhook).
|
||||
* Alle Inhalte liegen in einer einzigen SQLite-Datei; Notizen lassen sich als Markdown exportieren, alle Daten eines Benutzers als JSON.
|
||||
* **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 als JSON exportiert werden.
|
||||
|
||||
---
|
||||
|
||||
## 📄 Lizenz
|
||||
|
||||
Im Repository liegt derzeit keine `LICENSE`-Datei; die Lizenz ist damit nicht festgelegt.
|
||||
Veröffentlicht unter der **MIT-Lizenz** – siehe [LICENSE](LICENSE).
|
||||
|
||||
<div align="center">
|
||||
<sub>Gebaut für Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
|
||||
<sub>Gebaut für maximale Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
|
||||
</div>
|
||||
@@ -4,8 +4,7 @@ import (
|
||||
"context"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -24,16 +23,11 @@ var embeddedDist embed.FS
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
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())
|
||||
log.Printf("🧠 Starting MindSift Server on port %s (DB: %s)", cfg.Port, cfg.DatabasePath)
|
||||
|
||||
store, err := storage.NewStore(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize storage", "error", err)
|
||||
os.Exit(1)
|
||||
log.Fatalf("Failed to initialize storage: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
@@ -41,11 +35,11 @@ func main() {
|
||||
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS)
|
||||
|
||||
if cfg.DemoMode {
|
||||
slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
|
||||
log.Printf("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
|
||||
if err := store.EnsureDemoAccounts(); err != nil {
|
||||
slog.Warn("⚠️ Fehler beim Anlegen der Demo-Accounts", "error", err)
|
||||
log.Printf("⚠️ Fehler beim Anlegen der Demo-Accounts: %v", err)
|
||||
} else {
|
||||
slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
|
||||
log.Printf("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,31 +47,23 @@ func main() {
|
||||
|
||||
staticFS, err := fs.Sub(embeddedDist, "dist")
|
||||
if err != nil {
|
||||
slog.Error("Failed to mount embedded dist filesystem", "error", err)
|
||||
os.Exit(1)
|
||||
log.Fatalf("Failed to mount embedded dist filesystem: %v", err)
|
||||
}
|
||||
|
||||
srv := api.NewServer(cfg, store, wsHub, staticFS)
|
||||
|
||||
serverCtx, serverCancel := context.WithCancel(context.Background())
|
||||
defer serverCancel()
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: srv.Router(),
|
||||
BaseContext: func(l net.Listener) context.Context {
|
||||
return serverCtx
|
||||
},
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: srv.Router(),
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
slog.Info("✨ MindSift ready", "url", "http://0.0.0.0:"+cfg.Port)
|
||||
log.Printf("✨ MindSift ready at http://0.0.0.0:%s", cfg.Port)
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("HTTP server error", "error", err)
|
||||
os.Exit(1)
|
||||
log.Fatalf("HTTP server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -85,13 +71,8 @@ func main() {
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
slog.Info("Shutting down MindSift server gracefully...")
|
||||
serverCancel()
|
||||
wsHub.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
log.Println("Shutting down MindSift server gracefully...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := httpServer.Shutdown(ctx); err != nil {
|
||||
slog.Warn("HTTP server shutdown finished with error", "error", err)
|
||||
}
|
||||
_ = httpServer.Shutdown(ctx)
|
||||
}
|
||||
@@ -14,9 +14,6 @@ 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)
|
||||
|
||||
@@ -14,9 +14,6 @@ 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)
|
||||
|
||||
@@ -13,9 +13,7 @@
|
||||
"lucide-react": "^0.468.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"yjs": "^13.6.33"
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
@@ -1842,16 +1840,6 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isomorphic.js": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
@@ -1894,27 +1882,6 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/lib0": {
|
||||
"version": "0.2.119",
|
||||
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz",
|
||||
"integrity": "sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isomorphic.js": "^0.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
|
||||
"0gentesthtml": "bin/gentesthtml.js",
|
||||
"0serve": "bin/0serve.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.4.tgz",
|
||||
@@ -2783,49 +2750,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yjs": {
|
||||
"version": "13.6.33",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.33.tgz",
|
||||
"integrity": "sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.99"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" rx="128" fill="#8ab4f8"/>
|
||||
<g transform="translate(80, 80) scale(14.666)" fill="none" stroke="#0b0f19" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
|
||||
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
|
||||
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
|
||||
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/>
|
||||
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/>
|
||||
<path d="M3.477 10.896a4 4 0 0 1 .585-.396"/>
|
||||
<path d="M19.938 10.5a4 4 0 0 1 .585.396"/>
|
||||
<path d="M6 18a4 4 0 0 1-1.967-.516"/>
|
||||
<path d="M19.967 17.484A4 4 0 0 1 18 18"/>
|
||||
</g>
|
||||
<rect width="512" height="512" rx="128" fill="#0b0f19"/>
|
||||
<circle cx="256" cy="256" r="164" fill="none" stroke="#8ab4f8" stroke-width="36"/>
|
||||
<path d="M184 266 L236 318 L338 194" fill="none" stroke="#c58af9" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 851 B After Width: | Height: | Size: 374 B |
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Layers,
|
||||
CheckSquare,
|
||||
@@ -18,13 +18,8 @@ import {
|
||||
Smartphone,
|
||||
Share,
|
||||
Users,
|
||||
Calendar,
|
||||
AlertTriangle,
|
||||
Flame,
|
||||
Mic,
|
||||
MicOff,
|
||||
} from 'lucide-react';
|
||||
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User } from './lib/types';
|
||||
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
|
||||
import {
|
||||
api,
|
||||
clearToken,
|
||||
@@ -49,9 +44,6 @@ import { UserSettingsView } from './features/settings/UserSettingsView';
|
||||
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
||||
import { HelpView } from './features/help/HelpView';
|
||||
import { GroupsView } from './features/teams/GroupsView';
|
||||
import { useMobileActions } from './context/MobileActionContext';
|
||||
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
||||
import { UserAvatar } from './components/UserAvatar';
|
||||
|
||||
type ActiveTab =
|
||||
| 'triage'
|
||||
@@ -127,8 +119,6 @@ export const App: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
|
||||
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
|
||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [captureFocusCount, setCaptureFocusCount] = useState(0);
|
||||
const [refreshRevision, setRefreshRevision] = useState(0);
|
||||
|
||||
@@ -139,189 +129,6 @@ export const App: React.FC = () => {
|
||||
// Mobile Navigation & Quick-Action State
|
||||
const [showMobileCapture, setShowMobileCapture] = useState(false);
|
||||
const [showMobileProfile, setShowMobileProfile] = useState(false);
|
||||
const [mobileTriageContent, setMobileTriageContent] = useState('');
|
||||
const [mobileTriagePriority, setMobileTriagePriority] = useState<PriorityLevel>('normal');
|
||||
const [mobileTriageDueDate, setMobileTriageDueDate] = useState('');
|
||||
const [mobileVoiceActive, setMobileVoiceActive] = useState(false);
|
||||
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
|
||||
const mobileRecognitionRef = useRef<any>(null);
|
||||
const mobileBaseVoiceRef = useRef('');
|
||||
|
||||
const toggleMobileVoice = () => {
|
||||
const SpeechRecognition =
|
||||
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
if (!SpeechRecognition) {
|
||||
alert('Spracheingabe wird von diesem Browser leider nicht unterstützt.');
|
||||
return;
|
||||
}
|
||||
if (mobileVoiceActive && mobileRecognitionRef.current) {
|
||||
mobileRecognitionRef.current.stop();
|
||||
setMobileVoiceActive(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim();
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.lang = 'de-DE';
|
||||
recognition.continuous = false;
|
||||
recognition.interimResults = true;
|
||||
recognition.onstart = () => {
|
||||
setMobileVoiceActive(true);
|
||||
triggerHaptic(20);
|
||||
};
|
||||
recognition.onresult = (event: any) => {
|
||||
let transcript = '';
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
transcript += event.results[i][0].transcript;
|
||||
}
|
||||
const base = mobileBaseVoiceRef.current;
|
||||
setMobileTriageContent(base ? `${base} ${transcript}` : transcript);
|
||||
setIsVoiceCaptured(true);
|
||||
};
|
||||
recognition.onerror = () => setMobileVoiceActive(false);
|
||||
recognition.onend = () => setMobileVoiceActive(false);
|
||||
mobileRecognitionRef.current = recognition;
|
||||
recognition.start();
|
||||
} catch {
|
||||
setMobileVoiceActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMobileCreateTriage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = mobileTriageContent.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
const thought = await api.captureThought(
|
||||
trimmed,
|
||||
mobileTriagePriority,
|
||||
isVoiceCaptured ? 'voice' : 'manual',
|
||||
mobileTriageDueDate,
|
||||
{ target: 'inbox' }
|
||||
);
|
||||
handleCaptured(thought, 'inbox');
|
||||
setMobileTriageContent('');
|
||||
setMobileTriagePriority('normal');
|
||||
setMobileTriageDueDate('');
|
||||
setIsVoiceCaptured(false);
|
||||
setShowMobileCapture(false);
|
||||
triggerHaptic(20);
|
||||
} catch (err: any) {
|
||||
alert(`Fehler beim Speichern: ${err.message || 'Unbekannter Fehler'}`);
|
||||
}
|
||||
};
|
||||
|
||||
const openMobileTriageCapture = () => {
|
||||
setMobileTriageContent('');
|
||||
setMobileTriagePriority('normal');
|
||||
setMobileTriageDueDate('');
|
||||
setIsVoiceCaptured(false);
|
||||
setMobileVoiceActive(false);
|
||||
setShowMobileCapture(true);
|
||||
};
|
||||
|
||||
const { isMenuOpen, setIsMenuOpen, closeMenu, setGroups, primaryAction, setPrimaryAction } = useMobileActions();
|
||||
|
||||
useEffect(() => {
|
||||
closeMenu();
|
||||
if (activeTab === 'triage') {
|
||||
setPrimaryAction(() => openMobileTriageCapture);
|
||||
setGroups([
|
||||
{
|
||||
id: 'triage-actions',
|
||||
title: 'Schnellaktionen',
|
||||
items: [
|
||||
{
|
||||
id: 'new-thought',
|
||||
label: 'Neuen Gedanken erfassen',
|
||||
icon: <Plus className="w-4 h-4" />,
|
||||
variant: 'accent',
|
||||
onClick: () => openMobileTriageCapture(),
|
||||
},
|
||||
{
|
||||
id: 'refresh-deck',
|
||||
label: 'Inbox aktualisieren',
|
||||
icon: <RotateCcw className="w-4 h-4" />,
|
||||
badge: stats.inboxCount,
|
||||
onClick: () => refreshAll(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'triage-nav',
|
||||
title: 'Bereiche wechseln',
|
||||
items: [
|
||||
{
|
||||
id: 'goto-workspace',
|
||||
label: 'Notizen & Workspace',
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
badge: stats.docsCount,
|
||||
onClick: () => setActiveTab('workspace'),
|
||||
},
|
||||
{
|
||||
id: 'goto-tasks',
|
||||
label: 'Aufgaben & Kanban',
|
||||
icon: <CheckSquare className="w-4 h-4" />,
|
||||
badge: stats.tasksOpenCount,
|
||||
onClick: () => setActiveTab('tasks'),
|
||||
},
|
||||
{
|
||||
id: 'goto-lists',
|
||||
label: 'Einkaufslisten',
|
||||
icon: <ShoppingCart className="w-4 h-4" />,
|
||||
badge: stats.listsCount,
|
||||
onClick: () => setActiveTab('lists'),
|
||||
},
|
||||
{
|
||||
id: 'goto-groups',
|
||||
label: 'Teams & Gruppen',
|
||||
icon: <Users className="w-4 h-4" />,
|
||||
onClick: () => setActiveTab('groups'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
} else if (
|
||||
activeTab === 'groups' ||
|
||||
activeTab === 'user_settings' ||
|
||||
activeTab === 'admin_settings' ||
|
||||
activeTab === 'help'
|
||||
) {
|
||||
setGroups([
|
||||
{
|
||||
id: 'subview-nav',
|
||||
title: 'Navigation',
|
||||
items: [
|
||||
{
|
||||
id: 'back-triage',
|
||||
label: 'Zurück zur Triage (Startseite)',
|
||||
icon: <Layers className="w-4 h-4" />,
|
||||
variant: 'accent',
|
||||
onClick: () => setActiveTab('triage'),
|
||||
},
|
||||
{
|
||||
id: 'goto-workspace',
|
||||
label: 'Zum Workspace',
|
||||
icon: <FileText className="w-4 h-4" />,
|
||||
onClick: () => setActiveTab('workspace'),
|
||||
},
|
||||
{
|
||||
id: 'goto-tasks',
|
||||
label: 'Zu den Aufgaben',
|
||||
icon: <CheckSquare className="w-4 h-4" />,
|
||||
onClick: () => setActiveTab('tasks'),
|
||||
},
|
||||
{
|
||||
id: 'goto-lists',
|
||||
label: 'Zu den Listen',
|
||||
icon: <ShoppingCart className="w-4 h-4" />,
|
||||
onClick: () => setActiveTab('lists'),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
}, [activeTab, stats.inboxCount, stats.tasksOpenCount, stats.docsCount, stats.listsCount]);
|
||||
|
||||
// Mobile Web-App (PWA) Installation State
|
||||
const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => {
|
||||
@@ -416,7 +223,7 @@ export const App: React.FC = () => {
|
||||
setActiveThemeState(themeId);
|
||||
};
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
const refreshAll = async () => {
|
||||
try {
|
||||
const [meRes, deckRes, listsRes] = await Promise.all([
|
||||
api.getMe(),
|
||||
@@ -430,7 +237,7 @@ export const App: React.FC = () => {
|
||||
} catch {
|
||||
// If token expired or invalid, api.ts dispatches mindsift:unauthorized
|
||||
}
|
||||
}, []);
|
||||
};
|
||||
|
||||
const checkBootAndAuth = async () => {
|
||||
setBootLoading(true);
|
||||
@@ -551,21 +358,15 @@ export const App: React.FC = () => {
|
||||
refreshAll();
|
||||
};
|
||||
|
||||
const handleOpenDocument = (docId: string, blockId?: string) => {
|
||||
const handleOpenDocument = (docId: string) => {
|
||||
setSelectedDocId(docId);
|
||||
setSelectedBlockId(blockId || null);
|
||||
setActiveTab('workspace');
|
||||
};
|
||||
|
||||
const handleOpenTask = useCallback((taskId: string) => {
|
||||
setSelectedTaskId(taskId);
|
||||
setActiveTab('tasks');
|
||||
}, []);
|
||||
|
||||
const handleTriggerAddButton = () => {
|
||||
triggerHaptic(20);
|
||||
if (window.innerWidth < 640) {
|
||||
openMobileTriageCapture();
|
||||
setShowMobileCapture(true);
|
||||
} else {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
setCaptureFocusCount((c) => c + 1);
|
||||
@@ -575,11 +376,7 @@ export const App: React.FC = () => {
|
||||
const handleLogout = async () => {
|
||||
triggerHaptic(20);
|
||||
disconnectWebSocket();
|
||||
try {
|
||||
await api.logout();
|
||||
} catch {
|
||||
clearToken();
|
||||
}
|
||||
clearToken();
|
||||
setUser(null);
|
||||
setShowProfileMenu(false);
|
||||
setActiveTab('triage');
|
||||
@@ -612,53 +409,27 @@ export const App: React.FC = () => {
|
||||
const isServerAdmin = user.role === 'admin';
|
||||
const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied';
|
||||
|
||||
const activeTabLabelMap: Record<ActiveTab, string> = {
|
||||
triage: 'Triage',
|
||||
tasks: 'Aufgaben',
|
||||
workspace: 'Workspace',
|
||||
lists: 'Listen',
|
||||
groups: 'Teams & Gruppen',
|
||||
user_settings: 'Einstellungen',
|
||||
admin_settings: 'Administration',
|
||||
help: 'Hilfe',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30">
|
||||
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
|
||||
<header className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm">
|
||||
{/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */}
|
||||
{/* Mobile Header: Floating Bar with Larger Logo Left (Triage Home) & Profile Avatar Right */}
|
||||
<div className="sm:hidden flex items-center justify-between h-14 px-4 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(12);
|
||||
setIsMenuOpen((prev) => !prev);
|
||||
triggerHaptic(10);
|
||||
setActiveTab('triage');
|
||||
}}
|
||||
className={`min-h-[44px] px-2.5 -ml-2 rounded-xl flex items-center gap-2.5 cursor-pointer select-none active:scale-95 transition-all ${
|
||||
isMenuOpen ? 'bg-panel border border-accent/40 shadow-sm' : 'hover:bg-panel/60'
|
||||
}`}
|
||||
title="Menü & Ansichts-Optionen öffnen"
|
||||
className="h-11 px-2 -ml-2 rounded-xl flex items-center gap-2.5 cursor-pointer select-none active:scale-95 transition-transform"
|
||||
title="Zur Startseite (Triage)"
|
||||
>
|
||||
<div className="w-8 h-8 bg-accent text-on-accent rounded-xl shadow-sm flex items-center justify-center flex-shrink-0">
|
||||
<div className="w-8 h-8 bg-accent text-on-accent rounded-xl shadow-sm flex items-center justify-center">
|
||||
<Brain className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-base text-main tracking-tight leading-none">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">v0.1</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
|
||||
{activeTabLabelMap[activeTab]}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 text-muted transition-transform duration-150 ${
|
||||
isMenuOpen ? 'rotate-180 text-accent' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-bold text-base text-main tracking-tight">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
@@ -691,7 +462,9 @@ export const App: React.FC = () => {
|
||||
}`}
|
||||
title="Profil & Einstellungen"
|
||||
>
|
||||
<UserAvatar user={user} size="sm" className="shadow-sm" />
|
||||
<div className="w-8 h-8 rounded-full bg-accent text-on-accent text-xs font-bold shadow-sm flex items-center justify-center">
|
||||
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -709,12 +482,9 @@ export const App: React.FC = () => {
|
||||
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
||||
<Brain className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">v0.1</span>
|
||||
</div>
|
||||
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
||||
@@ -736,7 +506,9 @@ export const App: React.FC = () => {
|
||||
className="h-8 flex items-center gap-2 text-xs font-medium text-main hover:opacity-85 transition-opacity cursor-pointer group"
|
||||
title="Benutzermenü öffnen"
|
||||
>
|
||||
<UserAvatar user={user} size="sm" />
|
||||
<div className="w-7 h-7 rounded-full bg-accent text-on-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0">
|
||||
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
||||
</div>
|
||||
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
||||
{user.displayName}
|
||||
</span>
|
||||
@@ -746,15 +518,12 @@ export const App: React.FC = () => {
|
||||
{showProfileMenu && (
|
||||
<div className="absolute right-0 top-full mt-1.5 w-64 panel-surface shadow-panel z-50 divide-y divide-border text-xs">
|
||||
{/* User Info Header */}
|
||||
<div className="p-3 bg-canvas flex items-center gap-2.5">
|
||||
<UserAvatar user={user} size="md" className="border border-border/70" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-bold text-main truncate">{user.displayName}</p>
|
||||
<p className="text-[11px] text-muted truncate">{user.email}</p>
|
||||
<span className="inline-block mt-1 px-1.5 py-0.5 bg-panel border border-border rounded-btn text-[10px] text-accent font-semibold">
|
||||
{roleLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 bg-canvas">
|
||||
<p className="font-bold text-main truncate">{user.displayName}</p>
|
||||
<p className="text-[11px] text-muted truncate">{user.email}</p>
|
||||
<span className="inline-block mt-1.5 px-1.5 py-0.5 bg-panel border border-border rounded-btn text-[10px] text-accent font-semibold">
|
||||
{roleLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Theme Quick Switcher */}
|
||||
@@ -880,24 +649,6 @@ export const App: React.FC = () => {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setActiveTab('workspace');
|
||||
}}
|
||||
className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${(stats.docsTodosOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
|
||||
<span>Workspace</span>
|
||||
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
|
||||
{stats.docsTodosOpenCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
@@ -916,6 +667,24 @@ export const App: React.FC = () => {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setActiveTab('workspace');
|
||||
}}
|
||||
className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${(stats.docsTodosOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
|
||||
<span>Workspace</span>
|
||||
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
|
||||
{stats.docsTodosOpenCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
@@ -946,8 +715,6 @@ export const App: React.FC = () => {
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
|
||||
|
||||
{/* Main Container */}
|
||||
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
|
||||
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
||||
@@ -968,7 +735,6 @@ export const App: React.FC = () => {
|
||||
onRefreshStats={refreshAll}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
refreshTrigger={refreshRevision}
|
||||
initialSelectedTaskId={selectedTaskId}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -976,9 +742,7 @@ export const App: React.FC = () => {
|
||||
<WorkspaceView
|
||||
currentUser={user}
|
||||
initialDocId={selectedDocId}
|
||||
initialBlockId={selectedBlockId}
|
||||
onStatsChange={refreshAll}
|
||||
onOpenTask={handleOpenTask}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1045,49 +809,7 @@ export const App: React.FC = () => {
|
||||
<span className="text-[10px] leading-tight">Triage</span>
|
||||
</button>
|
||||
|
||||
{/* Col 2: Workspace */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setActiveTab('workspace');
|
||||
}}
|
||||
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
|
||||
activeTab === 'workspace' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
||||
}`}
|
||||
title="Workspace"
|
||||
>
|
||||
<div className="relative">
|
||||
<FileText className="w-5 h-5" />
|
||||
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
||||
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
|
||||
{stats.docsTodosOpenCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Workspace</span>
|
||||
</button>
|
||||
|
||||
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(15);
|
||||
if (primaryAction) {
|
||||
primaryAction();
|
||||
} else {
|
||||
openMobileTriageCapture();
|
||||
}
|
||||
}}
|
||||
className="w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer"
|
||||
title="Schnell erfassen (+)"
|
||||
>
|
||||
<Plus className="w-7 h-7 stroke-[2.5]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Col 4: Aufgaben */}
|
||||
{/* Col 2: Aufgaben */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -1110,6 +832,44 @@ export const App: React.FC = () => {
|
||||
<span className="text-[10px] leading-tight">Aufgaben</span>
|
||||
</button>
|
||||
|
||||
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
|
||||
<div className="flex items-center justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(15);
|
||||
setShowMobileCapture(true);
|
||||
}}
|
||||
className="w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer"
|
||||
title="Schnell erfassen (+)"
|
||||
>
|
||||
<Plus className="w-7 h-7 stroke-[2.5]" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Col 4: Workspace */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setActiveTab('workspace');
|
||||
}}
|
||||
className={`min-h-[48px] rounded-xl flex flex-col items-center justify-center gap-0.5 transition-colors relative cursor-pointer active:scale-95 ${
|
||||
activeTab === 'workspace' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
||||
}`}
|
||||
title="Workspace"
|
||||
>
|
||||
<div className="relative">
|
||||
<FileText className="w-5 h-5" />
|
||||
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
||||
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
|
||||
{stats.docsTodosOpenCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Workspace</span>
|
||||
</button>
|
||||
|
||||
{/* Col 5: Listen */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1127,149 +887,44 @@ export const App: React.FC = () => {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* MOBILE QUICK-ADD BOTTOM SHEET / MODAL (DESIGN-ALIGNED WITH TASKS VIEW) */}
|
||||
{/* MOBILE QUICK-ADD BOTTOM SHEET / MODAL */}
|
||||
{showMobileCapture && (
|
||||
<div
|
||||
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
||||
className="sm:hidden fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
||||
onClick={() => setShowMobileCapture(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-h-[85vh] overflow-y-auto bg-panel border-t border-border rounded-t-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] animate-in slide-in-from-bottom duration-200"
|
||||
className="w-full bg-panel border-t border-border rounded-t-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] animate-in slide-in-from-bottom duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-border mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
|
||||
<Layers className="w-4 h-4" />
|
||||
<div className="w-6 h-6 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
|
||||
<Plus className="w-4 h-4" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-main">Quick Dump erfassen</h3>
|
||||
<h3 className="text-sm font-semibold text-main">Schnell erfassen</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMobileCapture(false)}
|
||||
className="p-1.5 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
||||
className="p-1 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleMobileCreateTriage} className="space-y-3">
|
||||
{/* Text Input with Voice Dictation */}
|
||||
<div>
|
||||
<label className="text-[11px] font-semibold text-muted block mb-1">
|
||||
Gedanke / Quick Dump
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={mobileTriageContent}
|
||||
onChange={(e) => setMobileTriageContent(e.target.value)}
|
||||
placeholder="Was geht dir durch den Kopf?…"
|
||||
className="w-full input-field px-3 py-2 pr-10 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMobileVoice}
|
||||
className={`absolute right-1.5 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
|
||||
mobileVoiceActive
|
||||
? 'bg-accent text-on-accent animate-pulse'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
title="Spracheingabe"
|
||||
>
|
||||
{mobileVoiceActive ? <MicOff className="w-3.5 h-3.5" /> : <Mic className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority - 4 Buttons (Identical to TasksView) */}
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<label className="text-[11px] font-semibold text-muted block">Priorität</label>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
|
||||
const config: Record<
|
||||
PriorityLevel,
|
||||
{ label: string; icon?: React.ReactNode; activeClass: string }
|
||||
> = {
|
||||
low: {
|
||||
label: 'Niedrig',
|
||||
activeClass: 'bg-canvas border-strong text-main font-semibold shadow-xs',
|
||||
},
|
||||
normal: {
|
||||
label: 'Normal',
|
||||
activeClass: 'bg-accent/15 border-accent text-accent font-semibold shadow-xs',
|
||||
},
|
||||
high: {
|
||||
label: 'Wichtig',
|
||||
icon: <AlertTriangle className="w-3 h-3 text-status-warn" />,
|
||||
activeClass:
|
||||
'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs',
|
||||
},
|
||||
urgent: {
|
||||
label: 'Dringend',
|
||||
icon: <Flame className="w-3 h-3 text-status-err" />,
|
||||
activeClass:
|
||||
'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs',
|
||||
},
|
||||
};
|
||||
const active = mobileTriagePriority === p;
|
||||
const item = config[p];
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setMobileTriagePriority(p);
|
||||
}}
|
||||
className={`h-9 px-1 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
||||
active
|
||||
? item.activeClass
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span className="truncate">{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Due Date (Optional) */}
|
||||
<div className="space-y-1 pt-1">
|
||||
<label className="text-[11px] font-semibold text-muted flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Fällig am (optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={mobileTriageDueDate}
|
||||
onChange={(e) => setMobileTriageDueDate(e.target.value)}
|
||||
className="w-full input-field px-3 py-2 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom Buttons */}
|
||||
<div className="pt-2 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowMobileCapture(false)}
|
||||
className="btn-ghost h-10 px-4 text-xs cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!mobileTriageContent.trim()}
|
||||
className="btn-primary h-10 px-5 text-xs font-semibold disabled:opacity-40 cursor-pointer"
|
||||
>
|
||||
Gedanke erfassen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/* InstantCaptureBar */}
|
||||
<div className="pt-1">
|
||||
<InstantCaptureBar
|
||||
onCaptured={(thought, target) => {
|
||||
handleCaptured(thought, target);
|
||||
setShowMobileCapture(false);
|
||||
}}
|
||||
lists={lists}
|
||||
autoFocusTrigger={Date.now()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1277,7 +932,7 @@ export const App: React.FC = () => {
|
||||
{/* MOBILE USER PROFILE SHEET */}
|
||||
{showMobileProfile && (
|
||||
<div
|
||||
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
||||
className="sm:hidden fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
||||
onClick={() => setShowMobileProfile(false)}
|
||||
>
|
||||
<div
|
||||
@@ -1287,7 +942,9 @@ export const App: React.FC = () => {
|
||||
{/* User Info Header */}
|
||||
<div className="flex items-center justify-between pb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserAvatar user={user} size="lg" className="border border-border/80 shadow-sm" />
|
||||
<div className="w-10 h-10 rounded-full bg-accent text-on-accent text-sm font-bold flex items-center justify-center flex-shrink-0">
|
||||
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-sm text-main">{user.displayName}</p>
|
||||
<p className="text-xs text-muted">{user.email}</p>
|
||||
@@ -1434,7 +1091,7 @@ export const App: React.FC = () => {
|
||||
{/* IMMEDIATE MOBILE WEB-APP INSTALLATION OFFER SHEET */}
|
||||
{showPwaInstallModal && !isStandaloneApp && (
|
||||
<div
|
||||
className="sm:hidden fixed inset-0 !m-0 z-50 flex items-end justify-center bg-black/65 backdrop-blur-xs animate-in fade-in duration-150"
|
||||
className="sm:hidden fixed inset-0 z-50 flex items-end justify-center bg-black/65 backdrop-blur-xs animate-in fade-in duration-150"
|
||||
onClick={handleDismissPwaModal}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useMobileActions } from '../context/MobileActionContext';
|
||||
import { triggerHaptic } from '../lib/api';
|
||||
|
||||
interface MobileHeaderMenuProps {
|
||||
activeTabLabel: string;
|
||||
}
|
||||
|
||||
export const MobileHeaderMenu: React.FC<MobileHeaderMenuProps> = ({ activeTabLabel }) => {
|
||||
const { groups, isMenuOpen, closeMenu } = useMobileActions();
|
||||
|
||||
if (!isMenuOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="sm:hidden fixed inset-0 !m-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 !m-0 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
||||
onClick={() => {
|
||||
triggerHaptic(8);
|
||||
closeMenu();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Menu Sheet below Header */}
|
||||
<div className="fixed top-14 left-2.5 right-2.5 max-h-[80vh] overflow-y-auto bg-panel border border-border rounded-2xl shadow-2xl p-3 space-y-3.5 z-50 animate-in fade-in slide-in-from-top-2 duration-150">
|
||||
{/* Menu Header */}
|
||||
<div className="flex items-center justify-between px-2 pb-2 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-accent"></span>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted">
|
||||
{activeTabLabel} — Optionen & Aktionen
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(8);
|
||||
closeMenu();
|
||||
}}
|
||||
className="w-9 h-9 rounded-xl flex items-center justify-center text-muted hover:text-main hover:bg-canvas active:scale-95 transition-all"
|
||||
title="Menü schließen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-muted">
|
||||
Keine spezifischen Optionen für diese Ansicht verfügbar.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.id} className="space-y-1.5">
|
||||
{group.title && (
|
||||
<div className="px-2 text-[11px] font-bold uppercase tracking-wider text-faint">
|
||||
{group.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => {
|
||||
const isAccent = item.variant === 'accent';
|
||||
const isDanger = item.variant === 'danger';
|
||||
const isActive = item.active;
|
||||
|
||||
let btnClass =
|
||||
'bg-canvas/70 hover:bg-canvas text-main border border-border/60';
|
||||
if (isAccent) {
|
||||
btnClass =
|
||||
'bg-accent text-on-accent border border-accent font-semibold shadow-sm';
|
||||
} else if (isDanger) {
|
||||
btnClass =
|
||||
'bg-status-err/10 text-status-err border border-status-err/30 font-medium';
|
||||
} else if (isActive) {
|
||||
btnClass =
|
||||
'bg-accent/15 text-accent border border-accent/40 font-semibold';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(12);
|
||||
closeMenu();
|
||||
item.onClick();
|
||||
}}
|
||||
className={`w-full min-h-[46px] px-3.5 py-2.5 rounded-xl flex items-center justify-between gap-3 text-sm transition-all active:scale-[0.98] cursor-pointer ${btnClass}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1 text-left">
|
||||
{item.icon && (
|
||||
<span
|
||||
className={`flex-shrink-0 flex items-center justify-center w-5 h-5 ${
|
||||
isAccent
|
||||
? 'text-on-accent'
|
||||
: isDanger
|
||||
? 'text-status-err'
|
||||
: isActive
|
||||
? 'text-accent'
|
||||
: 'text-muted'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{item.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{item.badge !== undefined && item.badge !== '' && (
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${
|
||||
isAccent
|
||||
? 'bg-black/20 text-on-accent'
|
||||
: isActive
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'bg-panel text-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
{isActive && !isAccent && (
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
Plus,
|
||||
Trash2,
|
||||
Lock,
|
||||
Share2,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { User, Team, UnifiedShare } from '../lib/types';
|
||||
import { UserAutocomplete } from '../features/UserAutocomplete';
|
||||
import { UserAvatar } from './UserAvatar';
|
||||
|
||||
export interface UnifiedShareModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -24,7 +24,6 @@ export interface UnifiedShareModalProps {
|
||||
teams: Team[];
|
||||
collaborators: User[];
|
||||
allowRoleSelection?: boolean; // Default true (editor vs viewer)
|
||||
fixedPermission?: 'editor' | 'viewer'; // When allowRoleSelection is false, force fixedPermission (default 'viewer' for documents, 'editor' for tasks/lists)
|
||||
onAddShare: (targetType: 'team' | 'user', targetId: string, permission: 'editor' | 'viewer') => Promise<void>;
|
||||
onRemoveShare: (shareId: string) => Promise<void>;
|
||||
}
|
||||
@@ -41,17 +40,13 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
teams,
|
||||
collaborators,
|
||||
allowRoleSelection = true,
|
||||
fixedPermission,
|
||||
onAddShare,
|
||||
onRemoveShare,
|
||||
}) => {
|
||||
const effectiveFixedPerm = fixedPermission || 'viewer';
|
||||
const [shareType, setShareType] = useState<'team' | 'user'>('team');
|
||||
const [selectedTeamId, setSelectedTeamId] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>(
|
||||
allowRoleSelection ? 'editor' : effectiveFixedPerm
|
||||
);
|
||||
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>('editor');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -68,8 +63,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const permToUse = allowRoleSelection ? selectedPermission : effectiveFixedPerm;
|
||||
await onAddShare(shareType, targetId, permToUse);
|
||||
await onAddShare(shareType, targetId, selectedPermission);
|
||||
setSelectedTeamId('');
|
||||
setSelectedUser(null);
|
||||
} catch (err: any) {
|
||||
@@ -103,7 +97,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
@@ -120,7 +114,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
resourceIcon
|
||||
)
|
||||
) : (
|
||||
<Users className="w-4 h-4" />
|
||||
<Share2 className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
@@ -202,7 +196,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{allowRoleSelection ? (
|
||||
{allowRoleSelection && (
|
||||
<select
|
||||
value={selectedPermission}
|
||||
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
||||
@@ -212,20 +206,6 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted px-2 py-1 bg-panel rounded border border-border shrink-0 self-center">
|
||||
{effectiveFixedPerm === 'editor' ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-accent" />
|
||||
Bearbeiten
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-3 h-3 text-faint" />
|
||||
Nur Lesen
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -240,7 +220,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
||||
/>
|
||||
</div>
|
||||
{allowRoleSelection ? (
|
||||
{allowRoleSelection && (
|
||||
<select
|
||||
value={selectedPermission}
|
||||
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
||||
@@ -250,20 +230,6 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted px-2 py-1 bg-panel rounded border border-border shrink-0 self-center">
|
||||
{effectiveFixedPerm === 'editor' ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-accent" />
|
||||
Bearbeiten
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-3 h-3 text-faint" />
|
||||
Nur Lesen
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -319,18 +285,15 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
className="p-2.5 bg-canvas border border-border rounded-btn flex items-center justify-between gap-3 text-xs hover:border-border/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{isTeam ? (
|
||||
<div className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 bg-accent/15 text-accent border border-accent/25">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
) : (
|
||||
<UserAvatar
|
||||
avatarUrl={sh.avatarUrl || collaborators.find((c) => c.id === sh.targetId)?.avatarUrl}
|
||||
name={sh.targetName || sh.targetEmail}
|
||||
size="sm"
|
||||
className="border border-border/80"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`w-7 h-7 rounded-btn flex items-center justify-center shrink-0 ${
|
||||
isTeam
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'bg-surface-2 text-main border border-border'
|
||||
}`}
|
||||
>
|
||||
{isTeam ? <Users className="w-3.5 h-3.5" /> : <UserIcon className="w-3.5 h-3.5" />}
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<div className="font-medium text-main truncate flex items-center gap-1.5">
|
||||
<span className="truncate">{sh.targetName || (isTeam ? 'Gruppe' : sh.targetEmail || 'Benutzer')}</span>
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export interface UserAvatarProps {
|
||||
user?: {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
} | null;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
const SIZE_MAP = {
|
||||
xs: 'w-5 h-5 text-[10px]',
|
||||
sm: 'w-7 h-7 text-xs',
|
||||
md: 'w-9 h-9 text-sm',
|
||||
lg: 'w-12 h-12 text-base',
|
||||
xl: 'w-20 h-20 text-2xl',
|
||||
};
|
||||
|
||||
const BG_COLORS = [
|
||||
'bg-primary/20 text-primary border-primary/30',
|
||||
'bg-secondary/20 text-secondary border-secondary/30',
|
||||
'bg-accent/20 text-accent border-accent/30',
|
||||
'bg-info/20 text-info border-info/30',
|
||||
'bg-success/20 text-success border-success/30',
|
||||
'bg-warning/20 text-warning border-warning/30',
|
||||
];
|
||||
|
||||
function getInitials(name?: string, email?: string): string {
|
||||
const cleanName = (name || '').trim();
|
||||
if (cleanName) {
|
||||
const parts = cleanName.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return cleanName.slice(0, 2).toUpperCase();
|
||||
}
|
||||
const cleanEmail = (email || '').trim();
|
||||
if (cleanEmail) {
|
||||
return cleanEmail.slice(0, 2).toUpperCase();
|
||||
}
|
||||
return '?';
|
||||
}
|
||||
|
||||
function getColorIndex(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return Math.abs(hash) % BG_COLORS.length;
|
||||
}
|
||||
|
||||
export function isEmoji(str?: string): boolean {
|
||||
if (!str) return false;
|
||||
const trimmed = str.trim();
|
||||
// Simple heuristic: length <= 8 and not a URL or base64 image
|
||||
return !trimmed.startsWith('data:') && !trimmed.startsWith('http') && !trimmed.startsWith('/') && trimmed.length <= 8;
|
||||
}
|
||||
|
||||
export const UserAvatar: React.FC<UserAvatarProps> = ({
|
||||
user,
|
||||
name,
|
||||
avatarUrl,
|
||||
size = 'md',
|
||||
className = '',
|
||||
alt,
|
||||
}) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const effectiveAvatarUrl = avatarUrl !== undefined ? avatarUrl : user?.avatarUrl;
|
||||
const effectiveName = name !== undefined ? name : (user?.displayName || user?.email || '');
|
||||
const effectiveEmail = user?.email || '';
|
||||
|
||||
const sizeClass = SIZE_MAP[size] || SIZE_MAP.md;
|
||||
const initials = getInitials(effectiveName, effectiveEmail);
|
||||
const colorIndex = getColorIndex(effectiveName || effectiveEmail || 'user');
|
||||
const colorClass = BG_COLORS[colorIndex];
|
||||
|
||||
const hasImage = Boolean(
|
||||
effectiveAvatarUrl &&
|
||||
!imageError &&
|
||||
(effectiveAvatarUrl.startsWith('data:image/') ||
|
||||
effectiveAvatarUrl.startsWith('http://') ||
|
||||
effectiveAvatarUrl.startsWith('https://') ||
|
||||
effectiveAvatarUrl.startsWith('/'))
|
||||
);
|
||||
|
||||
const isEmojiAvatar = Boolean(effectiveAvatarUrl && isEmoji(effectiveAvatarUrl));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center justify-center rounded-full aspect-square shrink-0 select-none overflow-hidden font-semibold transition-all ${sizeClass} ${className}`}
|
||||
title={effectiveName || alt}
|
||||
>
|
||||
{hasImage ? (
|
||||
<img
|
||||
src={effectiveAvatarUrl}
|
||||
alt={alt || effectiveName || 'Avatar'}
|
||||
onError={() => setImageError(true)}
|
||||
className="w-full h-full object-cover rounded-full"
|
||||
/>
|
||||
) : isEmojiAvatar ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-base-300/80 rounded-full">
|
||||
<span className="leading-none">{effectiveAvatarUrl}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`w-full h-full flex items-center justify-center rounded-full border ${colorClass}`}>
|
||||
<span className="leading-none tracking-tight">{initials}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resizes and center-crops an uploaded image file to a compact base64 Data URL.
|
||||
*/
|
||||
export async function resizeImageToDataUrl(file: File, maxSize = 256): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('Fehler beim Lesen der Datei'));
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onerror = () => reject(new Error('Fehler beim Laden des Bildes'));
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = maxSize;
|
||||
canvas.height = maxSize;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
reject(new Error('Canvas Context konnte nicht erstellt werden'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Center crop calculation
|
||||
const minDim = Math.min(img.width, img.height);
|
||||
const sx = (img.width - minDim) / 2;
|
||||
const sy = (img.height - minDim) / 2;
|
||||
|
||||
ctx.drawImage(img, sx, sy, minDim, minDim, 0, 0, maxSize, maxSize);
|
||||
|
||||
// Try webp first, fallback to jpeg
|
||||
let dataUrl = canvas.toDataURL('image/webp', 0.85);
|
||||
if (!dataUrl.startsWith('data:image/webp')) {
|
||||
dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||
}
|
||||
resolve(dataUrl);
|
||||
};
|
||||
img.src = e.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
|
||||
export interface MobileActionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
badge?: string | number;
|
||||
variant?: 'default' | 'accent' | 'danger';
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface MobileMenuGroup {
|
||||
id: string;
|
||||
title?: string;
|
||||
items: MobileActionItem[];
|
||||
}
|
||||
|
||||
interface MobileActionContextValue {
|
||||
groups: MobileMenuGroup[];
|
||||
setGroups: (groups: MobileMenuGroup[]) => void;
|
||||
isMenuOpen: boolean;
|
||||
setIsMenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
closeMenu: () => void;
|
||||
primaryAction?: () => void;
|
||||
setPrimaryAction: (fn?: () => void) => void;
|
||||
}
|
||||
|
||||
const MobileActionContext = createContext<MobileActionContextValue>({
|
||||
groups: [],
|
||||
setGroups: () => {},
|
||||
isMenuOpen: false,
|
||||
setIsMenuOpen: () => {},
|
||||
closeMenu: () => {},
|
||||
primaryAction: undefined,
|
||||
setPrimaryAction: () => {},
|
||||
});
|
||||
|
||||
export const MobileActionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [groups, setGroupsState] = useState<MobileMenuGroup[]>([]);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [primaryAction, setPrimaryActionState] = useState<(() => void) | undefined>(undefined);
|
||||
|
||||
const setGroups = useCallback((newGroups: MobileMenuGroup[]) => {
|
||||
setGroupsState(newGroups);
|
||||
}, []);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setIsMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const setPrimaryAction = useCallback((fn?: () => void) => {
|
||||
setPrimaryActionState(() => fn);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MobileActionContext.Provider
|
||||
value={{
|
||||
groups,
|
||||
setGroups,
|
||||
isMenuOpen,
|
||||
setIsMenuOpen,
|
||||
closeMenu,
|
||||
primaryAction,
|
||||
setPrimaryAction,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MobileActionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useMobileActions = () => useContext(MobileActionContext);
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Search, X, Check } from 'lucide-react';
|
||||
import { User } from '../lib/types';
|
||||
import { UserAvatar } from '../components/UserAvatar';
|
||||
|
||||
interface UserAutocompleteProps {
|
||||
users: User[];
|
||||
@@ -102,7 +101,9 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
{selectedUser ? (
|
||||
<div className="h-8 px-2.5 bg-panel-elevated border border-accent/40 rounded-btn flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<UserAvatar user={selectedUser} size="xs" />
|
||||
<div className="w-5 h-5 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
|
||||
{(selectedUser.displayName || selectedUser.email || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<span className="font-semibold text-main truncate">
|
||||
{selectedUser.displayName || selectedUser.email}
|
||||
</span>
|
||||
@@ -169,7 +170,9 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<UserAvatar user={u} size="xs" />
|
||||
<div className="w-5 h-5 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
|
||||
{(u.displayName || u.email || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<span className="font-semibold text-main">
|
||||
{u.displayName || u.email}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
UserPlus,
|
||||
LogIn,
|
||||
Server,
|
||||
Users,
|
||||
Share2,
|
||||
Check,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
@@ -235,7 +235,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
|
||||
<div>
|
||||
<label className="block text-muted mb-1.5 flex items-center gap-1">
|
||||
<Users className="w-3 h-3 text-accent" />
|
||||
<Share2 className="w-3 h-3 text-accent" />
|
||||
<span>Sichtbarkeit & Sharing-Isolation zwischen Benutzern</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
FileText,
|
||||
Layers,
|
||||
ChevronDown,
|
||||
AlertTriangle,
|
||||
Flame,
|
||||
} from 'lucide-react';
|
||||
import { PriorityLevel, SharedList, Thought } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
@@ -23,27 +21,19 @@ interface InstantCaptureBarProps {
|
||||
onCaptured: (thought: Thought, target?: CaptureTarget) => void;
|
||||
lists?: SharedList[];
|
||||
autoFocusTrigger?: number;
|
||||
inlineOptions?: boolean;
|
||||
defaultShowOptions?: boolean;
|
||||
hideTargetPicker?: boolean;
|
||||
fixedTarget?: CaptureTarget;
|
||||
}
|
||||
|
||||
export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
onCaptured,
|
||||
lists = [],
|
||||
autoFocusTrigger = 0,
|
||||
inlineOptions = false,
|
||||
defaultShowOptions = false,
|
||||
hideTargetPicker = false,
|
||||
fixedTarget,
|
||||
}) => {
|
||||
const [content, setContent] = useState('');
|
||||
const [priority, setPriority] = useState<PriorityLevel>('normal');
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [target, setTarget] = useState<CaptureTarget>(fixedTarget || 'inbox');
|
||||
const [target, setTarget] = useState<CaptureTarget>('inbox');
|
||||
const [targetListId, setTargetListId] = useState<string>('');
|
||||
const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker);
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
|
||||
@@ -52,8 +42,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLFormElement>(null);
|
||||
const recognitionRef = useRef<any>(null);
|
||||
const baseVoiceTextRef = useRef('');
|
||||
const hasVoiceInputRef = useRef(false);
|
||||
|
||||
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
||||
const targetEl = el || inputRef.current;
|
||||
@@ -84,7 +72,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (inlineOptions) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setShowOptions(false);
|
||||
@@ -92,7 +79,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [inlineOptions]);
|
||||
}, []);
|
||||
|
||||
// Global keyboard shortcut: 'c' or '/' focuses the Instant Capture input immediately
|
||||
useEffect(() => {
|
||||
@@ -133,9 +120,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim();
|
||||
hasVoiceInputRef.current = true;
|
||||
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.lang = 'de-DE';
|
||||
recognition.continuous = false;
|
||||
@@ -151,8 +135,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
transcript += event.results[i][0].transcript;
|
||||
}
|
||||
const base = baseVoiceTextRef.current;
|
||||
setContent(base ? `${base} ${transcript}` : transcript);
|
||||
setContent((prev) => {
|
||||
const base = prev.replace(/\s*\[…\]$/, '').trim();
|
||||
return base ? `${base} ${transcript}` : transcript;
|
||||
});
|
||||
};
|
||||
|
||||
recognition.onerror = () => {
|
||||
@@ -178,21 +164,20 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || isSubmitting) return;
|
||||
|
||||
const effectiveTarget = fixedTarget || overrideTarget || target;
|
||||
const effectiveTarget = overrideTarget || target;
|
||||
const effectiveListId =
|
||||
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
|
||||
const capturedPriority = priority;
|
||||
const capturedDueDate = dueDate;
|
||||
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
|
||||
hasVoiceInputRef.current = false;
|
||||
const capturedSource = isListening ? 'voice' : 'manual';
|
||||
|
||||
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
|
||||
setContent('');
|
||||
setIsMultiline(false);
|
||||
setPriority('normal');
|
||||
setDueDate('');
|
||||
setTarget(fixedTarget || 'inbox');
|
||||
setShowOptions(defaultShowOptions || hideTargetPicker);
|
||||
setTarget('inbox');
|
||||
setShowOptions(false);
|
||||
setIsSubmitting(true);
|
||||
triggerHaptic(15);
|
||||
if (inputRef.current) {
|
||||
@@ -206,7 +191,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
? 'Auf Liste!'
|
||||
: effectiveTarget === 'document'
|
||||
? 'Als Notiz!'
|
||||
: 'In Triage!';
|
||||
: 'Im Dump!';
|
||||
setJustSavedLabel(savedMsg);
|
||||
setTimeout(() => setJustSavedLabel(null), 1600);
|
||||
inputRef.current?.focus();
|
||||
@@ -285,8 +270,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
placeholder={
|
||||
isListening
|
||||
? 'Ich höre zu…'
|
||||
: hideTargetPicker || fixedTarget === 'inbox'
|
||||
? 'Quick Dump erfassen… (Enter zum Speichern)'
|
||||
: target === 'task'
|
||||
? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
|
||||
: target === 'list_item'
|
||||
@@ -305,34 +288,32 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
isMultiline ? 'self-end mb-0.5' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Target & Options Toggle Pill (Hidden when target picker is disabled) */}
|
||||
{!hideTargetPicker && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOptions((prev) => !prev)}
|
||||
title="Ziel wählen (Quick Dump, direkt als Aufgabe, auf Liste oder als Notiz) & Optionen"
|
||||
className={`h-6 px-2.5 capture-pill rounded-full text-[11px] flex items-center gap-1 transition-colors cursor-pointer ${
|
||||
showOptions || target !== 'inbox' || priority !== 'normal' || dueDate
|
||||
? 'bg-accent-dim text-accent border border-accent-border font-medium'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{target === 'task' ? (
|
||||
<CheckSquare className="w-3 h-3" />
|
||||
) : target === 'list_item' ? (
|
||||
<ShoppingCart className="w-3 h-3" />
|
||||
) : target === 'document' ? (
|
||||
<FileText className="w-3 h-3" />
|
||||
) : (
|
||||
<Layers className="w-3 h-3" />
|
||||
)}
|
||||
<span className={`${inlineOptions ? 'inline' : 'hidden md:inline'} max-w-[110px] truncate`}>
|
||||
{getTargetLabel()}
|
||||
</span>
|
||||
{dueDate && <span className="hidden lg:inline text-[10px] opacity-80">• {dueDate.slice(5)}</span>}
|
||||
<ChevronDown className={`w-3 h-3 opacity-70 transition-transform ${showOptions ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
{/* Target & Options Toggle Pill */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOptions((prev) => !prev)}
|
||||
title="Ziel wählen (Quick Dump, direkt als Aufgabe, auf Liste oder als Notiz) & Optionen"
|
||||
className={`h-6 px-2.5 capture-pill rounded-full text-[11px] flex items-center gap-1 transition-colors ${
|
||||
showOptions || target !== 'inbox' || priority !== 'normal' || dueDate
|
||||
? 'bg-accent-dim text-accent border border-accent-border font-medium'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{target === 'task' ? (
|
||||
<CheckSquare className="w-3 h-3" />
|
||||
) : target === 'list_item' ? (
|
||||
<ShoppingCart className="w-3 h-3" />
|
||||
) : target === 'document' ? (
|
||||
<FileText className="w-3 h-3" />
|
||||
) : (
|
||||
<Layers className="w-3 h-3" />
|
||||
)}
|
||||
<span className="hidden md:inline max-w-[110px] truncate">
|
||||
{getTargetLabel()}
|
||||
</span>
|
||||
{dueDate && <span className="hidden lg:inline text-[10px] opacity-80">• {dueDate.slice(5)}</span>}
|
||||
<ChevronDown className="w-3 h-3 opacity-70" />
|
||||
</button>
|
||||
|
||||
{/* Voice Dictation Button */}
|
||||
<button
|
||||
@@ -361,15 +342,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
{justSavedLabel ? (
|
||||
<>
|
||||
<Check className="w-3 h-3" />
|
||||
<span className="inline">{justSavedLabel}</span>
|
||||
<span className="hidden sm:inline">{justSavedLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
<span className="inline">
|
||||
{hideTargetPicker || (fixedTarget || target) === 'inbox'
|
||||
? 'Quick Dump'
|
||||
: target === 'task'
|
||||
<span className="hidden sm:inline">
|
||||
{target === 'task'
|
||||
? 'Als Aufgabe'
|
||||
: target === 'list_item'
|
||||
? 'Auf Liste'
|
||||
@@ -383,187 +362,172 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable Target & Metadata Panel (Theme-controlled Dropdown or Inline) */}
|
||||
{/* Expandable Target & Metadata Panel (Theme-controlled Dropdown) */}
|
||||
{showOptions && (
|
||||
<div
|
||||
className={
|
||||
inlineOptions
|
||||
? 'mt-3 p-3 panel-surface border border-border rounded-xl space-y-3 text-xs animate-in fade-in duration-150'
|
||||
: 'absolute top-full left-0 right-0 mt-1.5 z-50 p-3 panel-surface shadow-panel space-y-3 text-xs'
|
||||
}
|
||||
>
|
||||
{/* Row 1: Where should this entry go? (Omitted when hideTargetPicker is true) */}
|
||||
{!hideTargetPicker && (
|
||||
<>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted">
|
||||
Wohin damit? {content.trim() ? '(Klick speichert sofort oder wählt Ziel)' : '(Ziel für Eingabe wählen)'}
|
||||
</span>
|
||||
<span className="text-[11px] text-faint">
|
||||
Standard: Quick Dump für späteres Swipen
|
||||
</span>
|
||||
</div>
|
||||
<div className="absolute top-full left-0 right-0 mt-1.5 z-50 p-3 panel-surface shadow-panel space-y-3 text-xs">
|
||||
{/* Row 1: Where should this entry go? */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted">
|
||||
Wohin damit? {content.trim() ? '(Klick speichert sofort oder wählt Ziel)' : '(Ziel für Eingabe wählen)'}
|
||||
</span>
|
||||
<span className="text-[11px] text-faint">
|
||||
Standard: Quick Dump für späteres Swipen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
|
||||
{/* Option 1: Quick Dump (Triage) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget('inbox');
|
||||
if (content.trim()) {
|
||||
executeCapture('inbox');
|
||||
}
|
||||
}}
|
||||
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||
target === 'inbox'
|
||||
? 'bg-accent-dim border-accent text-main font-medium'
|
||||
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||
}`}
|
||||
>
|
||||
<Layers className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span className="truncate">Quick Dump (Triage)</span>
|
||||
</button>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
|
||||
{/* Option 1: Quick Dump (Triage) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget('inbox');
|
||||
if (content.trim()) {
|
||||
executeCapture('inbox');
|
||||
}
|
||||
}}
|
||||
className={`h-8 px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors ${
|
||||
target === 'inbox'
|
||||
? 'bg-accent-dim border-accent text-main font-medium'
|
||||
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||
}`}
|
||||
>
|
||||
<Layers className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span className="truncate">Quick Dump (Triage)</span>
|
||||
</button>
|
||||
|
||||
{/* Option 2: Direct Task */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget('task');
|
||||
if (content.trim()) {
|
||||
executeCapture('task');
|
||||
}
|
||||
}}
|
||||
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||
target === 'task'
|
||||
? 'bg-accent-dim border-accent text-main font-medium'
|
||||
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||
}`}
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span className="truncate">Direkt als Aufgabe</span>
|
||||
</button>
|
||||
{/* Option 2: Direct Task */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget('task');
|
||||
if (content.trim()) {
|
||||
executeCapture('task');
|
||||
}
|
||||
}}
|
||||
className={`h-8 px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors ${
|
||||
target === 'task'
|
||||
? 'bg-accent-dim border-accent text-main font-medium'
|
||||
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||
}`}
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span className="truncate">Direkt als Aufgabe</span>
|
||||
</button>
|
||||
|
||||
{/* Option 3: Direct List Item */}
|
||||
{/* Option 3: Direct List Item */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget('list_item');
|
||||
if (!targetListId && lists.length > 0) {
|
||||
setTargetListId(lists[0].id);
|
||||
}
|
||||
}}
|
||||
className={`h-8 px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors ${
|
||||
target === 'list_item'
|
||||
? 'bg-accent-dim border-accent text-main font-medium'
|
||||
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span className="truncate">Direkt auf Liste…</span>
|
||||
</button>
|
||||
|
||||
{/* Option 4: Direct Workspace Note */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget('document');
|
||||
if (content.trim()) {
|
||||
executeCapture('document');
|
||||
}
|
||||
}}
|
||||
className={`h-8 px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors ${
|
||||
target === 'document'
|
||||
? 'bg-accent-dim border-accent text-main font-medium'
|
||||
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span className="truncate">Direkt als Notiz</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional Sub-Row: Shared List Picker */}
|
||||
{lists.length > 0 && (
|
||||
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-muted mr-1 flex items-center gap-1">
|
||||
<ShoppingCart className="w-3 h-3 text-accent" />
|
||||
Liste:
|
||||
</span>
|
||||
{lists.map((lst) => {
|
||||
const isSelected = target === 'list_item' && targetListId === lst.id;
|
||||
return (
|
||||
<button
|
||||
key={lst.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget('list_item');
|
||||
if (!targetListId && lists.length > 0) {
|
||||
setTargetListId(lists[0].id);
|
||||
}
|
||||
}}
|
||||
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||
target === 'list_item'
|
||||
? 'bg-accent-dim border-accent text-main font-medium'
|
||||
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span className="truncate">Direkt auf Liste…</span>
|
||||
</button>
|
||||
|
||||
{/* Option 4: Direct Workspace Note */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget('document');
|
||||
setTargetListId(lst.id);
|
||||
if (content.trim()) {
|
||||
executeCapture('document');
|
||||
executeCapture('list_item', lst.id);
|
||||
}
|
||||
}}
|
||||
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||
target === 'document'
|
||||
? 'bg-accent-dim border-accent text-main font-medium'
|
||||
className={`h-7 px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors ${
|
||||
isSelected
|
||||
? 'bg-accent text-on-accent border-accent font-medium'
|
||||
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span className="truncate">Direkt als Notiz</span>
|
||||
<span>{lst.icon}</span>
|
||||
<span>{lst.title}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional Sub-Row: Shared List Picker */}
|
||||
{lists.length > 0 && (
|
||||
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-muted mr-1 flex items-center gap-1">
|
||||
<ShoppingCart className="w-3 h-3 text-accent" />
|
||||
Liste:
|
||||
</span>
|
||||
{lists.map((lst) => {
|
||||
const isSelected = target === 'list_item' && targetListId === lst.id;
|
||||
return (
|
||||
<button
|
||||
key={lst.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget('list_item');
|
||||
setTargetListId(lst.id);
|
||||
if (content.trim()) {
|
||||
executeCapture('list_item', lst.id);
|
||||
}
|
||||
}}
|
||||
className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-accent text-on-accent border-accent font-medium'
|
||||
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||
}`}
|
||||
>
|
||||
<span>{lst.icon}</span>
|
||||
<span>{lst.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 2: Priority & Due Date */}
|
||||
<div className={`${!hideTargetPicker ? 'pt-2 border-t border-border' : ''} flex flex-wrap items-center justify-between gap-2.5`}>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-muted text-[11px] font-semibold mr-1">Priorität:</span>
|
||||
<div className="pt-2 border-t border-border flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted mr-1">Priorität:</span>
|
||||
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
|
||||
const config: Record<PriorityLevel, { label: string; icon?: React.ReactNode; activeClass: string }> = {
|
||||
low: { label: 'Niedrig', activeClass: 'bg-canvas border-strong text-main font-semibold shadow-xs' },
|
||||
normal: { label: 'Normal', activeClass: 'bg-accent/15 border-accent text-accent font-semibold shadow-xs' },
|
||||
high: { label: 'Wichtig', icon: <AlertTriangle className="w-3 h-3 text-status-warn" />, activeClass: 'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs' },
|
||||
urgent: { label: 'Dringend', icon: <Flame className="w-3 h-3 text-status-err" />, activeClass: 'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs' },
|
||||
const labels: Record<PriorityLevel, string> = {
|
||||
low: 'Niedrig',
|
||||
normal: 'Normal',
|
||||
high: 'Hoch',
|
||||
urgent: 'Dringend',
|
||||
};
|
||||
const active = priority === p;
|
||||
const item = config[p];
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setPriority(p);
|
||||
}}
|
||||
className={`min-h-[30px] px-2.5 py-1 border rounded-btn text-xs flex items-center gap-1 transition-colors cursor-pointer ${
|
||||
onClick={() => setPriority(p)}
|
||||
className={`px-2.5 py-0.5 border rounded-btn transition-colors text-xs ${
|
||||
active
|
||||
? item.activeClass
|
||||
? 'bg-accent text-on-accent border-accent font-medium'
|
||||
: 'bg-canvas text-muted hover:text-main border-border'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
{labels[p]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted text-[11px] font-semibold flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3 text-accent" />
|
||||
Fällig am:
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="input-field px-2.5 py-1 text-xs min-h-[30px]"
|
||||
className="input-field px-2 py-0.5 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -234,7 +234,7 @@ export const HelpView: React.FC<HelpViewProps> = () => {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
|
||||
<div className="p-3 bg-canvas border border-border rounded-card space-y-2">
|
||||
<h3 className="font-semibold text-main flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5 text-accent" />
|
||||
<Share2 className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Aufgabenlisten teilen & Avatar-Stack</span>
|
||||
</h3>
|
||||
<p className="text-muted leading-relaxed">
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Send,
|
||||
AlertCircle,
|
||||
UserPlus,
|
||||
Share2,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
SystemSettings,
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
UserRole,
|
||||
} from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
|
||||
interface AdminSettingsViewProps {
|
||||
currentUser: User;
|
||||
@@ -557,19 +557,16 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="p-3 space-y-2">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<UserAvatar user={u} size="sm" className="border border-border/80" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-main">{u.displayName}</span>
|
||||
{!u.isActive && (
|
||||
<span className="badge-err text-[10px]">
|
||||
Gesperrt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted text-[11px]">{u.email}</p>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-main">{u.displayName}</span>
|
||||
{!u.isActive && (
|
||||
<span className="badge-err text-[10px]">
|
||||
Gesperrt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted text-[11px]">{u.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -693,7 +690,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
{/* Sharing Isolation Cards */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-muted font-semibold flex items-center gap-1.5">
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<Share2 className="w-4 h-4 text-accent" />
|
||||
<span>Globale Sharing- & Sichtbarkeits-Isolation</span>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
User as UserIcon,
|
||||
Bell,
|
||||
@@ -15,13 +15,10 @@ import {
|
||||
Send,
|
||||
AlertCircle,
|
||||
Palette,
|
||||
Upload,
|
||||
Camera,
|
||||
} from 'lucide-react';
|
||||
import { APIKey, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme';
|
||||
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
||||
|
||||
interface UserSettingsViewProps {
|
||||
currentUser: User;
|
||||
@@ -45,13 +42,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
// --- Profile State ---
|
||||
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
|
||||
const [email, setEmail] = useState(currentUser.email || '');
|
||||
const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || '');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [revokingSessions, setRevokingSessions] = useState(false);
|
||||
const [isProcessingAvatar, setIsProcessingAvatar] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// --- Multi-Channel Alarms State ---
|
||||
const [alarmChannels, setAlarmChannels] = useState<string[]>(
|
||||
@@ -76,7 +69,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
useEffect(() => {
|
||||
setDisplayName(currentUser.displayName || '');
|
||||
setEmail(currentUser.email || '');
|
||||
setAvatarUrl(currentUser.avatarUrl || '');
|
||||
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
|
||||
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
|
||||
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
|
||||
@@ -103,34 +95,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// --- Handlers: Avatar ---
|
||||
const handleAvatarFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setIsProcessingAvatar(true);
|
||||
setProfileMsg(null);
|
||||
try {
|
||||
const dataUrl = await resizeImageToDataUrl(file, 256);
|
||||
setAvatarUrl(dataUrl);
|
||||
triggerHaptic(10);
|
||||
} catch (err: any) {
|
||||
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Laden des Bildes' });
|
||||
} finally {
|
||||
setIsProcessingAvatar(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectPresetAvatar = (preset: string) => {
|
||||
setAvatarUrl(preset);
|
||||
triggerHaptic(10);
|
||||
};
|
||||
|
||||
const handleRemoveAvatar = () => {
|
||||
setAvatarUrl('');
|
||||
triggerHaptic(10);
|
||||
};
|
||||
|
||||
// --- Handlers: Profile ---
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -140,7 +104,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
const patch: any = {
|
||||
displayName: displayName.trim(),
|
||||
email: email.trim(),
|
||||
avatarUrl: avatarUrl.trim(),
|
||||
};
|
||||
if (newPassword.trim()) {
|
||||
patch.password = newPassword.trim();
|
||||
@@ -156,21 +119,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeAllSessions = async () => {
|
||||
if (!window.confirm('Möchtest du wirklich alle aktiven Sitzungen auf allen Geräten beenden? Du wirst dabei auch auf diesem Gerät abgemeldet.')) {
|
||||
return;
|
||||
}
|
||||
setRevokingSessions(true);
|
||||
try {
|
||||
await api.revokeAllSessions();
|
||||
await api.logout();
|
||||
window.location.reload();
|
||||
} catch (err: any) {
|
||||
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Beenden der Sitzungen' });
|
||||
setRevokingSessions(false);
|
||||
}
|
||||
};
|
||||
|
||||
// --- Handlers: Alarms ---
|
||||
const handleSaveAlarms = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -280,11 +228,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
const activeKeyPlaceholder =
|
||||
newlyCreatedKey || (apiKeys[0]?.keyPrefix ? apiKeys[0].keyPrefix + '••••••••' : 'DEIN_API_KEY');
|
||||
|
||||
const curlTriageExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
|
||||
-H "X-API-Key: ${activeKeyPlaceholder}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm", "priority": "urgent"}'`;
|
||||
|
||||
const curlTaskExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
|
||||
-H "X-API-Key: ${activeKeyPlaceholder}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
@@ -415,88 +358,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSaveProfile} className="space-y-5">
|
||||
{/* Avatar Management */}
|
||||
<div className="p-3.5 bg-panel-elevated/40 border border-border rounded-box space-y-3">
|
||||
<label className="block text-muted font-medium">Profilbild</label>
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4">
|
||||
<div className="relative group shrink-0">
|
||||
<UserAvatar
|
||||
user={{ displayName, email, avatarUrl }}
|
||||
size="xl"
|
||||
className="border-2 border-border/80 shadow-md ring-2 ring-primary/20"
|
||||
/>
|
||||
{isProcessingAvatar && (
|
||||
<div className="absolute inset-0 bg-base-100/70 rounded-full flex items-center justify-center text-[10px] font-medium text-main">
|
||||
Lädt…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2.5 text-center sm:text-left">
|
||||
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarFileSelected}
|
||||
className="hidden"
|
||||
id="avatar-upload-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isProcessingAvatar}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
<span>Bild hochladen</span>
|
||||
</button>
|
||||
|
||||
{avatarUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveAvatar}
|
||||
className="btn-ghost h-8 px-2.5 text-xs text-status-err hover:bg-status-err/10 flex items-center gap-1"
|
||||
title="Profilbild entfernen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<span>Entfernen</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Emoji Presets */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] text-muted">Oder schnelles Emoji wählen:</span>
|
||||
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-1.5 pt-0.5">
|
||||
{['🦊', '🐱', '🐶', '🐼', '🦁', '🦉', '🚀', '☕', '⚡', '🎨', '🌟', '🎯', '💡'].map(
|
||||
(preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => handleSelectPresetAvatar(preset)}
|
||||
className={`w-7 h-7 rounded-full text-sm flex items-center justify-center transition-transform hover:scale-110 active:scale-95 ${
|
||||
avatarUrl === preset
|
||||
? 'bg-primary/20 border-2 border-primary scale-105'
|
||||
: 'bg-panel hover:bg-panel-hover border border-border'
|
||||
}`}
|
||||
title={`Emoji ${preset}`}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-faint">
|
||||
Wird überall kreisförmig dargestellt (z. B. Menüleiste oben, Freigabelisten, Aufgaben).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveProfile} className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">Anzeigename</label>
|
||||
@@ -546,32 +408,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Sessions & Security */}
|
||||
<div className="pt-5 border-t border-border space-y-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-main">Aktive Sitzungen & Sicherheit</h3>
|
||||
<p className="text-[11px] text-muted">
|
||||
Sitzungen werden durch kryptografische JWTs geschützt. Bei einer Passwortänderung werden alle alten Sitzungen automatisch ungültig.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-canvas">
|
||||
<div className="text-xs">
|
||||
<p className="font-semibold text-main">Alle Sitzungen sofort beenden</p>
|
||||
<p className="text-[11px] text-faint">
|
||||
Invalidiert alle ausgestellten Tokens auf allen Geräten und Browsern.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRevokeAllSessions}
|
||||
disabled={revokingSessions}
|
||||
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer"
|
||||
>
|
||||
{revokingSessions ? 'Beende…' : 'Alle Sitzungen beenden'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -967,43 +803,23 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Curl Examples */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-muted flex items-center gap-1.5">
|
||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Curl: In Triage-Deck einspeisen (erhält Webhook-Badge)</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopy(curlTriageExample, 'curl-triage')}
|
||||
className="btn-action h-7 px-2.5 text-[11px]"
|
||||
>
|
||||
{copiedLabel === 'curl-triage' ? 'Kopiert!' : 'Befehl kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
|
||||
{curlTriageExample}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-muted flex items-center gap-1.5">
|
||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Curl: Direkt als Aufgabe anlegen</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopy(curlTaskExample, 'curl-task')}
|
||||
className="btn-action h-7 px-2.5 text-[11px]"
|
||||
>
|
||||
{copiedLabel === 'curl-task' ? 'Kopiert!' : 'Befehl kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
|
||||
{curlTaskExample}
|
||||
</pre>
|
||||
{/* Curl Example */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-muted flex items-center gap-1.5">
|
||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Curl-Beispiel: Direkt als Aufgabe einspeisen</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopy(curlTaskExample, 'curl')}
|
||||
className="btn-action h-7 px-2.5 text-[11px]"
|
||||
>
|
||||
{copiedLabel === 'curl' ? 'Kopiert!' : 'Befehl kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
|
||||
{curlTaskExample}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Full Data Backup */}
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import { Team, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
|
||||
interface GroupsViewProps {
|
||||
currentUser: User;
|
||||
@@ -253,7 +252,7 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) =
|
||||
{/* Modal: Neue Gruppe erstellen */}
|
||||
{showCreateModal && (
|
||||
<div
|
||||
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs p-4 animate-in fade-in duration-150"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs p-4 animate-in fade-in duration-150"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
>
|
||||
<div
|
||||
@@ -488,7 +487,9 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) =
|
||||
className="p-2.5 bg-panel border border-border rounded-btn flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<UserAvatar user={{ displayName: m.displayName, email: m.email, avatarUrl: m.avatarUrl }} size="xs" />
|
||||
<div className="w-6 h-6 rounded-full bg-accent/20 text-accent text-[10px] font-bold flex items-center justify-center flex-shrink-0">
|
||||
{(m.displayName || 'U').slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-main font-medium truncate text-xs">
|
||||
{m.displayName} {isSelf && <span className="text-faint">(Du)</span>}
|
||||
|
||||
@@ -125,7 +125,7 @@ export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4">
|
||||
<div className="fixed inset-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4">
|
||||
<div
|
||||
className="w-full max-w-md panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[90vh] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -14,12 +14,9 @@ import {
|
||||
Flame,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
Webhook,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
import { MarkdownViewer } from '../../lib/markdown';
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
task: Thought | null;
|
||||
@@ -38,9 +35,7 @@ interface TaskDetailModalProps {
|
||||
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
||||
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||
onConvertToDoc: (task: Thought) => void;
|
||||
onOpenDocument?: (docId: string, blockId?: string) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
readOnly?: boolean; // Task belongs to a list shared with "viewer" permission
|
||||
}
|
||||
|
||||
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
@@ -60,9 +55,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
onAddSubtask,
|
||||
onDeleteSubtask,
|
||||
onConvertToDoc,
|
||||
onOpenDocument,
|
||||
onDeleteTask,
|
||||
readOnly = false,
|
||||
}) => {
|
||||
if (!isOpen || !task) return null;
|
||||
|
||||
@@ -170,7 +163,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
|
||||
<div className="fixed inset-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
|
||||
<div
|
||||
className="w-full max-w-2xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -179,7 +172,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
disabled={readOnly}
|
||||
onClick={() => {
|
||||
triggerHaptic(15);
|
||||
const nextState = !task.isCompleted;
|
||||
@@ -188,7 +180,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo');
|
||||
onUpdateStatus(task, nextKanban);
|
||||
}}
|
||||
className={`w-5 h-5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border disabled:cursor-default ${
|
||||
className={`w-5 h-5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${
|
||||
task.isCompleted
|
||||
? 'bg-status-ok border-status-ok text-on-accent'
|
||||
: 'border-strong hover:border-accent text-transparent'
|
||||
@@ -198,53 +190,23 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
<Check className="w-3.5 h-3.5 stroke-[3]" />
|
||||
</button>
|
||||
<span className="text-xs font-semibold text-main truncate">
|
||||
{readOnly ? 'Aufgabe ansehen' : task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
|
||||
{task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
|
||||
</span>
|
||||
{readOnly && (
|
||||
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
|
||||
<Lock className="w-2.5 h-2.5 text-faint" />
|
||||
<span>Nur Lesen</span>
|
||||
</span>
|
||||
)}
|
||||
{task.source?.toLowerCase() === 'webhook' && (
|
||||
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
|
||||
<Webhook className="w-2.5 h-2.5 text-accent" />
|
||||
<span>Webhook</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(task.convertedType === 'document_block' || task.docId) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
|
||||
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
|
||||
if (docId && onOpenDocument) onOpenDocument(docId, blockId);
|
||||
}}
|
||||
title="Zur verlinkten Notiz springen"
|
||||
className="btn-primary min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 font-semibold"
|
||||
>
|
||||
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||
<span>In Notiz öffnen</span>
|
||||
</button>
|
||||
) : !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onConvertToDoc(task);
|
||||
}}
|
||||
title="Als Dokument / Notiz vertiefen"
|
||||
className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5"
|
||||
>
|
||||
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
|
||||
<span className="hidden sm:inline">Als Notiz vertiefen</span>
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onConvertToDoc(task);
|
||||
}}
|
||||
title="Als Dokument / Notiz vertiefen"
|
||||
className="btn-action h-7 px-2 text-xs flex items-center gap-1"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-accent" />
|
||||
<span className="hidden sm:inline">Als Notiz vertiefen</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -254,25 +216,23 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
}
|
||||
}}
|
||||
title="Aufgabe löschen"
|
||||
className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs text-status-err border-status-err/30 hover:bg-status-err/10 flex items-center gap-1"
|
||||
className="text-muted hover:text-status-err p-1.5 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||
<span className="sm:hidden font-medium">Löschen</span>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted hover:text-main p-2 sm:p-1.5 min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 flex items-center justify-center transition-colors"
|
||||
className="text-muted hover:text-main p-1.5 transition-colors"
|
||||
title="Schließen"
|
||||
>
|
||||
<X className="w-5 h-5 sm:w-4 sm:h-4" />
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Body (all controls disabled for read-only shares) */}
|
||||
<fieldset disabled={readOnly} className="p-4 overflow-y-auto space-y-4 flex-1 min-w-0 border-0 m-0">
|
||||
{/* Scrollable Body */}
|
||||
<div className="p-4 overflow-y-auto space-y-4 flex-1">
|
||||
{/* Task Title */}
|
||||
<div>
|
||||
{isEditingTitle ? (
|
||||
@@ -315,11 +275,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => !readOnly && setIsEditingTitle(true)}
|
||||
className={`group p-2 rounded-card transition-colors border border-transparent ${
|
||||
readOnly ? '' : 'cursor-pointer hover:bg-panel hover:border-border'
|
||||
}`}
|
||||
title={readOnly ? undefined : 'Klicken zum Bearbeiten des Titels'}
|
||||
onClick={() => setIsEditingTitle(true)}
|
||||
className="group cursor-pointer p-2 rounded-card hover:bg-panel transition-colors border border-transparent hover:border-border"
|
||||
title="Klicken zum Bearbeiten des Titels"
|
||||
>
|
||||
<h2
|
||||
className={`text-base font-semibold break-words leading-snug ${
|
||||
@@ -328,11 +286,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
>
|
||||
{task.content}
|
||||
</h2>
|
||||
{!readOnly && (
|
||||
<span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Klicken zum Umbenennen
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Klicken zum Umbenennen
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -378,49 +334,29 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority Buttons (4 Buttons) */}
|
||||
{/* Priority Buttons */}
|
||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main">
|
||||
Priorität:
|
||||
</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
onUpdatePriority(task, 'low');
|
||||
}}
|
||||
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
||||
task.priority === 'low'
|
||||
? 'bg-canvas border-strong text-main font-semibold shadow-xs'
|
||||
onClick={() => onUpdatePriority(task, 'normal')}
|
||||
className={`h-7 px-3 text-xs rounded-btn font-medium transition-colors border ${
|
||||
!task.priority || task.priority === 'normal' || task.priority === 'low'
|
||||
? 'bg-panel border-accent text-main font-semibold'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<span>Niedrig</span>
|
||||
Normal / Keine
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
onUpdatePriority(task, 'normal');
|
||||
}}
|
||||
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
||||
!task.priority || task.priority === 'normal'
|
||||
? 'bg-accent/15 border-accent text-accent font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<span>Normal</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
onUpdatePriority(task, 'high');
|
||||
}}
|
||||
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
||||
onClick={() => onUpdatePriority(task, 'high')}
|
||||
className={`h-7 px-3 text-xs rounded-btn font-medium transition-colors border flex items-center gap-1 ${
|
||||
task.priority === 'high'
|
||||
? 'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs'
|
||||
? 'bg-status-warn/15 border-status-warn text-status-warn font-semibold'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
@@ -429,13 +365,10 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
onUpdatePriority(task, 'urgent');
|
||||
}}
|
||||
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
||||
onClick={() => onUpdatePriority(task, 'urgent')}
|
||||
className={`h-7 px-3 text-xs rounded-btn font-medium transition-colors border flex items-center gap-1 ${
|
||||
task.priority === 'urgent'
|
||||
? 'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs'
|
||||
? 'bg-status-err/15 border-status-err text-status-err font-semibold'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
@@ -531,18 +464,15 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main"
|
||||
>
|
||||
<span>#{tag}</span>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="text-muted hover:text-status-err"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="text-muted hover:text-status-err"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<form onSubmit={handleAddTagSubmit} className="inline-flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
@@ -552,7 +482,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
className="h-6 w-24 px-1.5 text-xs input-field"
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -607,7 +536,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
{st.title}
|
||||
</span>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteSubtask(task, st.id)}
|
||||
@@ -615,14 +543,12 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline add subtask form */}
|
||||
{!readOnly && (
|
||||
<form onSubmit={handleAddSubtaskSubmit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
@@ -640,7 +566,6 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
<span>Hinzufügen</span>
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes Section */}
|
||||
@@ -650,7 +575,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
<FileText className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Detailnotizen:</span>
|
||||
</label>
|
||||
{!readOnly && !isEditingNotes && task.notes && (
|
||||
{!isEditingNotes && task.notes && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -697,19 +622,14 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
) : task.notes ? (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (readOnly) return;
|
||||
setNotesDraft(task.notes || '');
|
||||
setIsEditingNotes(true);
|
||||
}}
|
||||
className={`p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed transition-colors ${
|
||||
readOnly ? '' : 'cursor-pointer hover:border-strong'
|
||||
}`}
|
||||
title={readOnly ? undefined : 'Klicken zum Bearbeiten'}
|
||||
className="p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed whitespace-pre-wrap cursor-pointer hover:border-strong transition-colors"
|
||||
title="Klicken zum Bearbeiten"
|
||||
>
|
||||
<MarkdownViewer content={task.notes} />
|
||||
{task.notes}
|
||||
</div>
|
||||
) : readOnly ? (
|
||||
<p className="text-xs text-faint">Keine Detailnotizen.</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
@@ -724,7 +644,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
||||
|
||||
@@ -139,21 +139,11 @@ const SwipeableQueueItem: React.FC<SwipeableQueueItemProps> = ({
|
||||
}}
|
||||
className="relative bg-panel px-4 py-3.5 sm:px-3.5 sm:py-2.5 min-h-[56px] sm:min-h-[44px] flex items-center justify-between gap-3 hover:bg-panel-hover transition-colors group cursor-pointer"
|
||||
>
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 flex items-center gap-3">
|
||||
<span className="w-2 h-2 rounded-full bg-accent flex-shrink-0" />
|
||||
<span className="text-sm sm:text-xs font-medium text-main line-clamp-2 sm:truncate">
|
||||
{item.content}
|
||||
</span>
|
||||
{item.source?.toLowerCase() === 'webhook' && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-canvas text-muted border border-border rounded-btn flex items-center gap-1 flex-shrink-0">
|
||||
<Webhook className="w-2.5 h-2.5 text-accent" /> Webhook
|
||||
</span>
|
||||
)}
|
||||
{item.source?.toLowerCase() === 'voice' && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-canvas text-accent border border-border rounded-btn flex items-center gap-1 flex-shrink-0">
|
||||
<Mic className="w-2.5 h-2.5" /> Diktat
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */}
|
||||
@@ -423,16 +413,15 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const sourceBadge = (source?: string) => {
|
||||
const s = (source || '').toLowerCase().trim();
|
||||
if (s === 'voice') {
|
||||
const sourceBadge = (source: string) => {
|
||||
if (source === 'voice') {
|
||||
return (
|
||||
<span className="px-2 py-0.5 text-[11px] bg-canvas text-accent border border-border rounded-btn flex items-center gap-1">
|
||||
<Mic className="w-3 h-3" /> Diktat
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (s === 'webhook') {
|
||||
if (source === 'webhook') {
|
||||
return (
|
||||
<span className="px-2 py-0.5 text-[11px] bg-canvas text-muted border border-border rounded-btn flex items-center gap-1">
|
||||
<Webhook className="w-3 h-3 text-accent" /> Webhook
|
||||
@@ -511,10 +500,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
key={nextCard.id + '-preview'}
|
||||
className="absolute inset-x-3 top-3 bottom-0 panel-surface p-4 opacity-40 scale-[0.97] pointer-events-none flex flex-col justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{priorityBadge(nextCard.priority)}
|
||||
{sourceBadge(nextCard.source)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">{priorityBadge(nextCard.priority)}</div>
|
||||
<p className="text-sm text-muted line-clamp-2">{nextCard.content}</p>
|
||||
<div className="text-[11px] text-faint">Nächster Quick Dump…</div>
|
||||
</div>
|
||||
@@ -825,7 +811,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
{/* LONG-PRESS / MORE OPTIONS ACTION SHEET (For Triage Card & Queue Items) */}
|
||||
{actionSheetCard && (
|
||||
<div
|
||||
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-xs p-0 sm:p-4 animate-in fade-in duration-150"
|
||||
className="fixed inset-0 z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-xs p-0 sm:p-4 animate-in fade-in duration-150"
|
||||
onClick={() => setActionSheetCard(null)}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
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<LinkModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onInsertLink,
|
||||
availableDocs,
|
||||
}) => {
|
||||
const [tab, setTab] = useState<'note' | 'web'>('note');
|
||||
const [search, setSearch] = useState('');
|
||||
const [docs, setDocs] = useState<Document[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [webTitle, setWebTitle] = useState('');
|
||||
const [webUrl, setWebUrl] = useState('');
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const webTitleInputRef = useRef<HTMLInputElement | null>(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 (
|
||||
<div
|
||||
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4 animate-in fade-in zoom-in-95 duration-150">
|
||||
{/* Header - identical to UnifiedShareModal */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-border">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-btn bg-accent/15 text-accent flex items-center justify-center shrink-0">
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-bold text-main truncate">Link einfügen</h3>
|
||||
<p className="text-[11px] text-muted truncate">
|
||||
Notiz intern verknüpfen oder externen Web-Link einbetten
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-muted hover:text-main rounded-btn hover:bg-panel transition-colors cursor-pointer"
|
||||
title="Schließen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Control Box - matching UnifiedShareModal form header */}
|
||||
<div className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-main">Link-Typ:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('note')}
|
||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
|
||||
tab === 'note'
|
||||
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
<span>Notiz</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('web')}
|
||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
|
||||
tab === 'web'
|
||||
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
<span>Web-Link</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'note' ? (
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleWebLinkSubmit} className="space-y-2.5">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-faint mb-1 block">
|
||||
Link-Text / Label
|
||||
</label>
|
||||
<input
|
||||
ref={webTitleInputRef}
|
||||
type="text"
|
||||
value={webTitle}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-faint mb-1 block">
|
||||
Web-Adresse (URL) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={webUrl}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-action text-xs h-8 px-3.5 cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!webUrl.trim()}
|
||||
className="btn-primary text-xs h-8 px-3.5 flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Link einfügen</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Note List Section (Matching Active Shares list in UnifiedShareModal) */}
|
||||
{tab === 'note' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5">
|
||||
<span>Verfügbare Notizen</span>
|
||||
<span className="font-mono text-[11px] font-normal lowercase">
|
||||
{filteredDocs.length} {filteredDocs.length === 1 ? 'Notiz' : 'Notizen'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-0.5">
|
||||
{loading ? (
|
||||
<div className="p-6 bg-canvas border border-border rounded-card text-xs text-muted flex items-center gap-2 justify-center text-center">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-accent" />
|
||||
<span>Notizen werden geladen…</span>
|
||||
</div>
|
||||
) : filteredDocs.length === 0 ? (
|
||||
<div className="p-4 bg-canvas border border-border rounded-card text-xs text-muted flex items-center gap-2.5 justify-center text-center">
|
||||
<Search className="w-4 h-4 text-faint shrink-0" />
|
||||
<span>
|
||||
{search
|
||||
? `Keine Notiz für "${search}" gefunden.`
|
||||
: 'Keine Notizen im Workspace vorhanden.'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
filteredDocs.map((d) => (
|
||||
<div
|
||||
key={d.id}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span className="text-base shrink-0">{d.icon || '📝'}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold text-main truncate group-hover:text-accent">
|
||||
{d.title || 'Unbenannte Notiz'}
|
||||
</div>
|
||||
{d.folderPath && (
|
||||
<div className="text-[10px] text-muted truncate">
|
||||
📁 {d.folderPath}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-action text-xs h-7 px-2.5 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 text-accent cursor-pointer shrink-0"
|
||||
>
|
||||
<span>Einfügen</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,957 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Check,
|
||||
CheckSquare,
|
||||
X,
|
||||
Trash2,
|
||||
MoreHorizontal,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
ListTodo,
|
||||
Minus,
|
||||
MessageSquare,
|
||||
Hash,
|
||||
ExternalLink,
|
||||
Maximize2,
|
||||
Link as LinkIcon,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { LinkModal } from './LinkModal';
|
||||
import { renderInlineMarkdown } from '../../lib/markdown';
|
||||
import { api } from '../../lib/api';
|
||||
|
||||
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: <ListTodo className="w-3.5 h-3.5" /> },
|
||||
{ type: 'link', label: '/link', desc: 'Notiz verknüpfen oder Web-Link einfügen', icon: <LinkIcon className="w-3.5 h-3.5" /> },
|
||||
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 className="w-3.5 h-3.5" /> },
|
||||
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: <Heading2 className="w-3.5 h-3.5" /> },
|
||||
{ type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: <Heading3 className="w-3.5 h-3.5" /> },
|
||||
{ type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: <Code className="w-3.5 h-3.5" /> },
|
||||
{ type: 'callout', label: '/callout', desc: 'Hinweisbox', icon: <MessageSquare className="w-3.5 h-3.5" /> },
|
||||
{ type: 'divider', label: '/divider', desc: 'Trennlinie', icon: <Minus className="w-3.5 h-3.5" /> },
|
||||
];
|
||||
|
||||
export interface OutlinerBlockProps {
|
||||
docId: string;
|
||||
block: DocumentBlock;
|
||||
index: number;
|
||||
isLast: boolean;
|
||||
maxAllowedLevel: number;
|
||||
hasChildren: boolean;
|
||||
isFocused: boolean;
|
||||
canEdit: boolean;
|
||||
levelOffset?: number;
|
||||
onZoomBlock?: (blockId: string) => void;
|
||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
|
||||
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
|
||||
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
||||
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
||||
onFocusBlock?: (blockId: string) => void;
|
||||
onOpenTask?: (taskId: string) => 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;
|
||||
onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
|
||||
}
|
||||
|
||||
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
docId,
|
||||
block,
|
||||
index,
|
||||
isLast,
|
||||
maxAllowedLevel,
|
||||
hasChildren,
|
||||
isFocused,
|
||||
canEdit,
|
||||
levelOffset = 0,
|
||||
onZoomBlock,
|
||||
onUpdateBlock,
|
||||
onCreateBlockBelow,
|
||||
onDeleteBlock,
|
||||
onIndentBlock,
|
||||
onNavigateFocus,
|
||||
onFocusBlock,
|
||||
onOpenTask,
|
||||
onTagClick,
|
||||
onWikiLinkClick,
|
||||
onDragStartBlock,
|
||||
onDragOverBlock,
|
||||
onDropBlock,
|
||||
isDragTarget,
|
||||
isTargeted,
|
||||
registerTextareaRef,
|
||||
onPasteMultiLines,
|
||||
}) => {
|
||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
||||
const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(false);
|
||||
const activeFocus = isFocused || isLocallyFocused;
|
||||
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
|
||||
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const debounceTimerRef = useRef<number | null>(null);
|
||||
const latestBlockRef = useRef<DocumentBlock>(block);
|
||||
latestBlockRef.current = block;
|
||||
const localContentRef = useRef<string>(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 onCreateBlockBelowRef = useRef(onCreateBlockBelow);
|
||||
onCreateBlockBelowRef.current = onCreateBlockBelow;
|
||||
const onPasteMultiLinesRef = useRef(onPasteMultiLines);
|
||||
onPasteMultiLinesRef.current = onPasteMultiLines;
|
||||
const onDeleteBlockRef = useRef(onDeleteBlock);
|
||||
onDeleteBlockRef.current = onDeleteBlock;
|
||||
const onIndentBlockRef = useRef(onIndentBlock);
|
||||
onIndentBlockRef.current = onIndentBlock;
|
||||
const onNavigateFocusRef = useRef(onNavigateFocus);
|
||||
onNavigateFocusRef.current = onNavigateFocus;
|
||||
const isPastingRef = useRef<boolean>(false);
|
||||
|
||||
// Track if this input currently has actual focus in an active browser window
|
||||
const isFocusedRef = useRef<boolean>(false);
|
||||
|
||||
const flushDebounce = useCallback(
|
||||
(contentToFlush: string, overrides?: Partial<DocumentBlock>) => {
|
||||
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;
|
||||
};
|
||||
|
||||
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, activeFocus, 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<HTMLTextAreaElement>) => {
|
||||
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);
|
||||
|
||||
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 = () => {
|
||||
isFocusedRef.current = true;
|
||||
setIsLocallyFocused(true);
|
||||
onFocusBlock?.(block.id);
|
||||
setTimeout(adjustHeight, 0);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
isFocusedRef.current = false;
|
||||
setIsLocallyFocused(false);
|
||||
if (isPastingRef.current) {
|
||||
isPastingRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
flushDebounce(localContentRef.current);
|
||||
} else if (localContentRef.current !== latestBlockRef.current.content) {
|
||||
flushDebounce(localContentRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!canEdit || block.type === 'code') return;
|
||||
const pastedText = e.clipboardData.getData('text');
|
||||
if (!pastedText || !pastedText.includes('\n')) return;
|
||||
|
||||
e.preventDefault();
|
||||
const rawLines = pastedText.split(/\r?\n/);
|
||||
if (rawLines.length <= 1) return;
|
||||
|
||||
const start = e.currentTarget.selectionStart ?? localContent.length;
|
||||
const end = e.currentTarget.selectionEnd ?? start;
|
||||
const textBefore = localContent.slice(0, start);
|
||||
const textAfter = localContent.slice(end);
|
||||
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
|
||||
isPastingRef.current = true;
|
||||
onPasteMultiLinesRef.current?.(block.id, textBefore, textAfter, rawLines);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
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) -> If block is completely empty:
|
||||
// - If level > minLevel, outdent (Shift+Tab behaviour)
|
||||
// - If already at minLevel and is the last block, do NOT create another empty block below
|
||||
// - Otherwise split paragraph at cursor and create new block below
|
||||
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSlashSearch(null);
|
||||
|
||||
const minLevel = levelOffset || 0;
|
||||
if (localContent === '') {
|
||||
if (Math.max(0, block.level || 0) > minLevel) {
|
||||
flushDebounce(localContent);
|
||||
onIndentBlockRef.current(block.id, -1);
|
||||
return;
|
||||
}
|
||||
if (isLast) {
|
||||
// Bereits auf oberster Ebene und der letzte Block: kein weiterer leerer Block
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const start = e.currentTarget.selectionStart ?? localContent.length;
|
||||
const end = e.currentTarget.selectionEnd ?? start;
|
||||
let beforeText = localContent.slice(0, start);
|
||||
let afterText = localContent.slice(end);
|
||||
|
||||
// Clean linebreaks at the split boundary if present
|
||||
if (beforeText.endsWith('\r\n')) {
|
||||
beforeText = beforeText.slice(0, -2);
|
||||
} else if (beforeText.endsWith('\n')) {
|
||||
beforeText = beforeText.slice(0, -1);
|
||||
}
|
||||
if (afterText.startsWith('\r\n')) {
|
||||
afterText = afterText.slice(2);
|
||||
} else if (afterText.startsWith('\n')) {
|
||||
afterText = afterText.slice(1);
|
||||
}
|
||||
|
||||
setLocalContent(beforeText);
|
||||
localContentRef.current = beforeText;
|
||||
flushDebounce(beforeText);
|
||||
onCreateBlockBelowRef.current(block.id, afterText);
|
||||
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));
|
||||
const isEmpty = (!localContent || localContent.trim() === '') && !hasChildren;
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className={`group relative flex items-start gap-1 py-1 px-1.5 rounded-card transition-all duration-300 ${
|
||||
isDragTarget
|
||||
? 'border-t-2 border-accent bg-accent/10'
|
||||
: isTargeted
|
||||
? 'bg-accent/15 ring-2 ring-accent'
|
||||
: activeFocus
|
||||
? 'bg-panel-elevated'
|
||||
: 'hover:bg-panel'
|
||||
}`}
|
||||
style={{
|
||||
paddingLeft: `${visualLevel * 22 + 4}px`,
|
||||
}}
|
||||
onDragOver={(e) => onDragOverBlock?.(e, index)}
|
||||
onDrop={() => onDropBlock?.(index)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input, select')) return;
|
||||
if (!activeFocus && canEdit && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
const len = textareaRef.current.value.length;
|
||||
textareaRef.current.setSelectionRange(len, len);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Indentation Guide Line */}
|
||||
{visualLevel > 0 &&
|
||||
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
|
||||
<div
|
||||
key={lvlIdx}
|
||||
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-border"
|
||||
style={{ left: `${lvlIdx * 22 + 12}px` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Bullet Controls: Collapse Toggle & Bullet (Drag-Handle & Zoom) / Checkbox */}
|
||||
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
flushDebounce(localContent, { collapsed: !block.collapsed })
|
||||
}
|
||||
className="p-0.5 text-muted hover:text-accent transition-colors"
|
||||
title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'}
|
||||
>
|
||||
{block.collapsed ? (
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-accent" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
|
||||
{/* Bullet handle for dragging and zoom focus */}
|
||||
<button
|
||||
type="button"
|
||||
draggable={canEdit}
|
||||
onDragStart={() => canEdit && onDragStartBlock?.(index)}
|
||||
onClick={() => onZoomBlock?.(block.id)}
|
||||
className={`w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover group/bullet transition-transform ${
|
||||
canEdit ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
|
||||
}`}
|
||||
title={
|
||||
canEdit
|
||||
? 'Ziehen zum Verschieben • Klick: Ast hineinzoomen (Fokus-Modus)'
|
||||
: 'Klick: In diesen Ast hineinzoomen (Fokus-Modus)'
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full transition-all duration-150 ${
|
||||
block.collapsed && hasChildren
|
||||
? 'bg-accent ring-2 ring-accent/30'
|
||||
: activeFocus
|
||||
? 'bg-accent scale-125'
|
||||
: 'bg-faint group-hover/bullet:bg-accent group-hover/bullet:scale-125'
|
||||
} ${
|
||||
isEmpty
|
||||
? 'opacity-0 group-hover:opacity-100 hover:opacity-100'
|
||||
: 'opacity-100'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* If To-Do block: checkbox alongside bullet */}
|
||||
{block.type === 'todo' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canEdit}
|
||||
onClick={() =>
|
||||
flushDebounce(localContent, { checked: !block.checked })
|
||||
}
|
||||
className={`w-4 h-4 checkbox-square flex items-center justify-center transition-colors border ${
|
||||
block.checked
|
||||
? 'bg-status-ok border-status-ok text-on-accent'
|
||||
: 'border-strong hover:border-accent text-transparent'
|
||||
}`}
|
||||
title={block.checked ? 'Als offen markieren' : 'Als erledigt markieren'}
|
||||
>
|
||||
<Check className="w-3 h-3 stroke-[3]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Block Content */}
|
||||
<div className="flex-1 min-w-0 pr-1 relative min-h-[1.5rem]">
|
||||
{block.type === 'divider' ? (
|
||||
<div className="py-2">
|
||||
<hr className="border-t border-border" />
|
||||
</div>
|
||||
) : block.type === 'code' ? (
|
||||
<div className="bg-code border border-border rounded-btn p-2 my-0.5">
|
||||
<div className="text-[10px] text-faint font-mono uppercase pb-1 flex justify-between">
|
||||
<span>Code</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => flushDebounce(localContent, { type: 'bullet' })}
|
||||
className="hover:text-accent"
|
||||
>
|
||||
Als Text
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRef.current = el;
|
||||
registerTextareaRef(block.id, el);
|
||||
}}
|
||||
rows={1}
|
||||
value={localContent}
|
||||
readOnly={!canEdit}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleContentChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
) : block.type === 'callout' ? (
|
||||
<div className="bg-panel-elevated border-l-2 border-accent p-2 rounded-r-btn my-0.5">
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRef.current = el;
|
||||
registerTextareaRef(block.id, el);
|
||||
}}
|
||||
rows={1}
|
||||
value={localContent}
|
||||
readOnly={!canEdit}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleContentChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-full min-h-[1.5rem]">
|
||||
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!activeFocus && canEdit && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
const len = textareaRef.current.value.length;
|
||||
textareaRef.current.setSelectionRange(len, len);
|
||||
}
|
||||
}}
|
||||
className={`w-full min-h-[1.5rem] leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
|
||||
activeFocus ? 'absolute inset-0 pointer-events-none' : 'relative cursor-text'
|
||||
} ${
|
||||
block.type === 'h1'
|
||||
? 'text-lg font-bold text-main tracking-tight'
|
||||
: block.type === 'h2'
|
||||
? 'text-base font-semibold text-main'
|
||||
: block.type === 'h3'
|
||||
? 'text-sm font-semibold text-main'
|
||||
: block.type === 'todo' && block.checked
|
||||
? 'text-sm text-faint line-through'
|
||||
: 'text-sm text-main'
|
||||
}`}
|
||||
>
|
||||
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
|
||||
<span className={activeFocus ? 'invisible' : ''}>
|
||||
{localContent ? renderInlineMarkdown(localContent) : '\u200b'}
|
||||
</span>
|
||||
|
||||
{/* Inline Badge and 3-Dots directly behind the text */}
|
||||
{block.type === 'todo' && (
|
||||
<span className="inline-flex items-center align-middle ml-1.5 gap-1 shrink-0 pointer-events-auto z-10 relative select-none">
|
||||
{block.taskId && (
|
||||
<span
|
||||
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.2 rounded font-medium shrink-0"
|
||||
title="Mit erweiterter Aufgabe synchronisiert"
|
||||
>
|
||||
<CheckSquare className="w-2.5 h-2.5" />
|
||||
<span>Erweitert</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
<span className="relative inline-flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowTodoMenu((prev) => !prev);
|
||||
}}
|
||||
className="p-0.5 text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
|
||||
title="Aufgaben-Optionen"
|
||||
>
|
||||
<MoreHorizontal className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{showTodoMenu && (
|
||||
<div
|
||||
className="absolute left-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{!block.taskId ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setShowTodoMenu(false);
|
||||
try {
|
||||
const targetDocId = docId || block.documentId || block.docId || '';
|
||||
const thought = await api.createEnhancedTask(targetDocId, block.id, {
|
||||
content: localContent || 'Aufgabe',
|
||||
checked: Boolean(block.checked),
|
||||
});
|
||||
const updatedBlock: DocumentBlock = {
|
||||
...latestBlockRef.current,
|
||||
content: localContent,
|
||||
taskId: thought.id,
|
||||
type: 'todo',
|
||||
};
|
||||
onUpdateBlockRef.current(updatedBlock, true);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Erstellen der erweiterten Aufgabe');
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer"
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||
<span>In erweiterte Aufgabe umwandeln</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setShowTodoMenu(false);
|
||||
try {
|
||||
if (block.taskId) {
|
||||
await api.unlinkEnhancedTask(block.taskId).catch(() => {});
|
||||
}
|
||||
const updatedBlock: DocumentBlock = {
|
||||
...latestBlockRef.current,
|
||||
content: localContent,
|
||||
taskId: '',
|
||||
type: 'todo',
|
||||
};
|
||||
onUpdateBlockRef.current(updatedBlock, true);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Aufheben der Verknüpfung');
|
||||
}
|
||||
}}
|
||||
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-status-warn flex items-center gap-1.5 text-main font-medium cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5 text-status-warn" />
|
||||
<span>Verknüpfung aufheben</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRef.current = el;
|
||||
registerTextareaRef(block.id, el);
|
||||
}}
|
||||
rows={1}
|
||||
value={localContent}
|
||||
readOnly={!canEdit}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleContentChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
className={`w-full min-h-[1.5rem] resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
|
||||
activeFocus ? 'relative z-0 text-main' : 'absolute inset-0 text-transparent selection:text-transparent pointer-events-none'
|
||||
} ${
|
||||
block.type === 'h1'
|
||||
? 'text-lg font-bold text-main tracking-tight'
|
||||
: block.type === 'h2'
|
||||
? 'text-base font-semibold text-main'
|
||||
: block.type === 'h3'
|
||||
? 'text-sm font-semibold text-main'
|
||||
: block.type === 'todo' && block.checked
|
||||
? 'text-sm text-faint line-through'
|
||||
: 'text-sm text-main'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rich Tokens Bar (Wiki-Links & #Tags) if present */}
|
||||
{hasSpecialTokens && !isFocused && (
|
||||
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
||||
{tokens.map((part, idx) => {
|
||||
if (part.startsWith('#') && part.length > 1) {
|
||||
const tag = part.slice(1);
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => onTagClick?.(tag)}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-accent bg-accent-dim hover:bg-accent/20 rounded-btn transition-colors"
|
||||
>
|
||||
<Hash className="w-2.5 h-2.5 opacity-80" />
|
||||
<span>{tag}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (part.startsWith('[[') && part.endsWith(']]')) {
|
||||
const page = part.slice(2, -2).trim();
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => onWikiLinkClick?.(page)}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-info bg-status-info/10 hover:bg-status-info/20 rounded-btn transition-colors"
|
||||
title={`Notiz öffnen: ${page}`}
|
||||
>
|
||||
<FileText className="w-2.5 h-2.5 opacity-80" />
|
||||
<span>{page}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
|
||||
if (mdMatch) {
|
||||
const label = mdMatch[1];
|
||||
const url = mdMatch[2];
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
|
||||
title={url}
|
||||
>
|
||||
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
|
||||
<span>{label || url}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (/^https?:\/\//.test(part)) {
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={part}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
|
||||
title={part}
|
||||
>
|
||||
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
|
||||
<span className="truncate max-w-[160px]">{part}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Slash Commands Menu */}
|
||||
{slashSearch !== null && canEdit && (
|
||||
<div className="absolute left-0 top-8 z-40 w-56 panel-surface shadow-panel border border-border rounded-card p-1">
|
||||
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-faint border-b border-border mb-1">
|
||||
Befehl auswählen
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto space-y-0.5">
|
||||
{filteredSlashCommands.map((cmd, i) => (
|
||||
<button
|
||||
key={cmd.type}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
applySlashCommand(cmd);
|
||||
}}
|
||||
className={`w-full text-left px-2 py-1 rounded-btn text-xs flex items-center justify-between transition-colors ${
|
||||
i === slashIndex
|
||||
? 'bg-accent text-on-accent'
|
||||
: 'text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{cmd.icon}
|
||||
<span className="font-semibold">{cmd.label}</span>
|
||||
</div>
|
||||
<span className="text-[10px] opacity-75">{cmd.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredSlashCommands.length === 0 && (
|
||||
<div className="p-2 text-center text-xs text-faint">
|
||||
Kein Befehl gefunden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zoom / Focus button on hover */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onZoomBlock?.(block.id)}
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
|
||||
title="In diesen Ast hineinzoomen (Fokus-Modus)"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Delete button on hover (Trash2 icon) */}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (hasChildren) {
|
||||
if (window.confirm('Bist du sicher? Alle Unterpunkte dieses Asts werden ebenfalls gelöscht.')) {
|
||||
onDeleteBlockRef.current(block.id, true);
|
||||
}
|
||||
} else {
|
||||
onDeleteBlockRef.current(block.id, false);
|
||||
}
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer"
|
||||
title="Block löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Link Insertion Modal */}
|
||||
{showLinkModal && (
|
||||
<LinkModal
|
||||
isOpen={showLinkModal}
|
||||
onClose={() => setShowLinkModal(false)}
|
||||
onInsertLink={handleInsertLink}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,731 +0,0 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { OutlinerBlock } from './OutlinerBlock';
|
||||
|
||||
export interface OutlinerTreeHandle {
|
||||
focusFirstBlock: () => void;
|
||||
focusBlock: (blockId: string, cursor?: 'start' | 'end') => void;
|
||||
}
|
||||
|
||||
export interface OutlinerTreeProps {
|
||||
docId: string;
|
||||
blocks: DocumentBlock[];
|
||||
canEdit: boolean;
|
||||
levelOffset?: number;
|
||||
onZoomBlock?: (blockId: string) => void;
|
||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
|
||||
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
|
||||
onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void;
|
||||
onDeleteBlock: (blockId: string) => void;
|
||||
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
onTagClick?: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
initialFocusedBlockId?: string | null;
|
||||
}
|
||||
|
||||
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
docId,
|
||||
blocks,
|
||||
canEdit,
|
||||
levelOffset = 0,
|
||||
onZoomBlock,
|
||||
onUpdateBlock,
|
||||
onUpdateBlocks,
|
||||
onCreateBlock,
|
||||
onInsertBlocks,
|
||||
onDeleteBlock,
|
||||
onMoveBlock,
|
||||
onOpenTask,
|
||||
onTagClick,
|
||||
onWikiLinkClick,
|
||||
initialFocusedBlockId,
|
||||
}, ref) => {
|
||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
|
||||
initialFocusedBlockId || null
|
||||
);
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
|
||||
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' } | null>(null);
|
||||
|
||||
const focusBlockInput = useCallback(
|
||||
(blockId: string, cursor: 'start' | 'end' | 'select' = 'end') => {
|
||||
setFocusedBlockId(blockId);
|
||||
const el = textareaMapRef.current.get(blockId);
|
||||
if (el) {
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
try {
|
||||
if (cursor === 'select') {
|
||||
el.select();
|
||||
} else {
|
||||
const pos = cursor === 'end' ? el.value.length : 0;
|
||||
el.setSelectionRange(pos, pos);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
} else {
|
||||
pendingFocusRef.current = { blockId, cursor };
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focusFirstBlock: () => {
|
||||
if (blocks.length > 0) {
|
||||
focusBlockInput(blocks[0].id, 'end');
|
||||
} else {
|
||||
const firstBlock: DocumentBlock = {
|
||||
id: `blk_${Date.now().toString(36)}_0`,
|
||||
documentId: docId,
|
||||
type: 'text',
|
||||
content: '',
|
||||
level: levelOffset || 0,
|
||||
position: 1000,
|
||||
};
|
||||
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'end' };
|
||||
onCreateBlock(firstBlock);
|
||||
}
|
||||
},
|
||||
focusBlock: (blockId: string, cursor: 'start' | 'end' = 'end') => {
|
||||
focusBlockInput(blockId, cursor);
|
||||
},
|
||||
}),
|
||||
[blocks, docId, levelOffset, onCreateBlock, focusBlockInput]
|
||||
);
|
||||
|
||||
const registerTextareaRef = useCallback((blockId: string, el: HTMLTextAreaElement | null) => {
|
||||
if (el) {
|
||||
textareaMapRef.current.set(blockId, el);
|
||||
if (pendingFocusRef.current && pendingFocusRef.current.blockId === blockId) {
|
||||
const target = pendingFocusRef.current;
|
||||
pendingFocusRef.current = null;
|
||||
requestAnimationFrame(() => {
|
||||
setFocusedBlockId(blockId);
|
||||
el.focus();
|
||||
try {
|
||||
if (target.cursor === 'select') {
|
||||
el.select();
|
||||
} else {
|
||||
const pos = target.cursor === 'end' ? el.value.length : 0;
|
||||
el.setSelectionRange(pos, pos);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
}
|
||||
} else {
|
||||
textareaMapRef.current.delete(blockId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFocusedBlockId) {
|
||||
setHighlightedBlockId(initialFocusedBlockId);
|
||||
focusBlockInput(initialFocusedBlockId, 'select');
|
||||
const timer = setTimeout(() => {
|
||||
focusBlockInput(initialFocusedBlockId, 'select');
|
||||
}, 70);
|
||||
const timer2 = setTimeout(() => {
|
||||
focusBlockInput(initialFocusedBlockId, 'select');
|
||||
}, 200);
|
||||
const fadeTimer = setTimeout(() => {
|
||||
setHighlightedBlockId(null);
|
||||
}, 2800);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(timer2);
|
||||
clearTimeout(fadeTimer);
|
||||
};
|
||||
}
|
||||
}, [initialFocusedBlockId, blocks.length, focusBlockInput]);
|
||||
|
||||
// Compute visibility based on collapsed ancestor blocks
|
||||
const visibleEntries = useMemo(() => {
|
||||
const result: {
|
||||
block: DocumentBlock;
|
||||
index: number;
|
||||
hasChildren: boolean;
|
||||
maxAllowedLevel: number;
|
||||
}[] = [];
|
||||
|
||||
let collapsedAboveLevel: number | null = null;
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const b = blocks[i];
|
||||
const lvl = Math.max(0, b.level || 0);
|
||||
|
||||
if (collapsedAboveLevel !== null) {
|
||||
if (lvl > collapsedAboveLevel) {
|
||||
continue;
|
||||
} else {
|
||||
collapsedAboveLevel = null;
|
||||
}
|
||||
}
|
||||
|
||||
const nextBlock = blocks[i + 1];
|
||||
const nextLvl = nextBlock ? Math.max(0, nextBlock.level || 0) : 0;
|
||||
const hasChildren = Boolean(nextBlock && nextLvl > lvl);
|
||||
|
||||
const prevBlock = i > 0 ? blocks[i - 1] : null;
|
||||
const maxAllowedLevel = prevBlock ? Math.max(0, prevBlock.level || 0) + 1 : (levelOffset || 0);
|
||||
|
||||
result.push({
|
||||
block: b,
|
||||
index: i,
|
||||
hasChildren,
|
||||
maxAllowedLevel,
|
||||
});
|
||||
|
||||
if (b.collapsed && hasChildren) {
|
||||
collapsedAboveLevel = lvl;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [blocks, levelOffset]);
|
||||
|
||||
const handleCreateBlockBelow = useCallback(
|
||||
(afterBlockId: string, newContent: string = '') => {
|
||||
if (!canEdit) return;
|
||||
const idx = blocks.findIndex((b) => b.id === afterBlockId);
|
||||
const currentBlock = idx >= 0 ? blocks[idx] : null;
|
||||
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
|
||||
const inheritedType: BlockType = 'text';
|
||||
|
||||
const newBlock: DocumentBlock = {
|
||||
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
documentId: docId,
|
||||
type: inheritedType,
|
||||
content: newContent,
|
||||
level,
|
||||
checked: false,
|
||||
collapsed: false,
|
||||
position: ((idx >= 0 ? idx + 2 : blocks.length + 1) * 1000),
|
||||
};
|
||||
|
||||
pendingFocusRef.current = { blockId: newBlock.id, cursor: 'start' };
|
||||
setFocusedBlockId(newBlock.id);
|
||||
onCreateBlock(newBlock, afterBlockId);
|
||||
},
|
||||
[blocks, canEdit, docId, onCreateBlock]
|
||||
);
|
||||
|
||||
const handlePasteMultiLines = useCallback(
|
||||
(blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => {
|
||||
if (!canEdit || rawLines.length === 0) return;
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx === -1) return;
|
||||
|
||||
const currentBlock = blocks[idx];
|
||||
const baseLevel = Math.max(0, currentBlock.level || 0);
|
||||
|
||||
// Detect indentation style (2-space vs 4-space tabs)
|
||||
let detectedSpaceStep = 2;
|
||||
const leadingSpacesList: number[] = [];
|
||||
for (const line of rawLines) {
|
||||
if (!line.trim()) continue;
|
||||
let sp = 0;
|
||||
while (sp < line.length && line[sp] === ' ') sp++;
|
||||
if (sp > 0) leadingSpacesList.push(sp);
|
||||
}
|
||||
if (leadingSpacesList.length > 0 && leadingSpacesList.every((s) => s % 4 === 0)) {
|
||||
detectedSpaceStep = 4;
|
||||
}
|
||||
|
||||
// Parse lines into structured blocks
|
||||
interface ParsedPasteItem {
|
||||
type: BlockType;
|
||||
checked?: boolean;
|
||||
content: string;
|
||||
indent: number;
|
||||
}
|
||||
|
||||
const parsed: ParsedPasteItem[] = [];
|
||||
let currentItem: ParsedPasteItem | null = null;
|
||||
let inCodeBlock = false;
|
||||
let afterHeading = false;
|
||||
|
||||
const pushCurrentItem = () => {
|
||||
if (currentItem) {
|
||||
currentItem.content = currentItem.content.trimEnd();
|
||||
parsed.push(currentItem);
|
||||
currentItem = null;
|
||||
}
|
||||
};
|
||||
|
||||
for (let i = 0; i < rawLines.length; i++) {
|
||||
const rawLine = rawLines[i];
|
||||
|
||||
// 1. Inside a fenced code block
|
||||
if (inCodeBlock) {
|
||||
if (currentItem) {
|
||||
currentItem.content += '\n' + rawLine;
|
||||
}
|
||||
if (rawLine.trim().startsWith('```')) {
|
||||
inCodeBlock = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Starts a code fence
|
||||
if (rawLine.trim().startsWith('```')) {
|
||||
inCodeBlock = true;
|
||||
if (afterHeading) {
|
||||
afterHeading = false;
|
||||
pushCurrentItem();
|
||||
currentItem = {
|
||||
type: 'text',
|
||||
content: rawLine,
|
||||
indent: 0,
|
||||
};
|
||||
} else if (currentItem) {
|
||||
currentItem.content += (currentItem.content.endsWith('\n') ? '' : '\n') + rawLine;
|
||||
} else {
|
||||
currentItem = {
|
||||
type: 'text',
|
||||
content: rawLine,
|
||||
indent: 0,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Measure leading indent
|
||||
let tabs = 0;
|
||||
let spaces = 0;
|
||||
let charIdx = 0;
|
||||
while (charIdx < rawLine.length) {
|
||||
if (rawLine[charIdx] === '\t') {
|
||||
tabs++;
|
||||
charIdx++;
|
||||
} else if (rawLine[charIdx] === ' ') {
|
||||
spaces++;
|
||||
charIdx++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const strippedLine = rawLine.slice(charIdx);
|
||||
const lineIndent = tabs + Math.floor(spaces / detectedSpaceStep);
|
||||
|
||||
// Blank lines
|
||||
if (strippedLine.trim() === '') {
|
||||
if (currentItem) {
|
||||
currentItem.content += '\n';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headings: #, ##, ###
|
||||
const h3Match = strippedLine.match(/^###\s+(.*)$/);
|
||||
const h2Match = strippedLine.match(/^##\s+(.*)$/);
|
||||
const h1Match = strippedLine.match(/^#\s+(.*)$/);
|
||||
if (h1Match || h2Match || h3Match) {
|
||||
pushCurrentItem();
|
||||
afterHeading = true;
|
||||
const type: BlockType = h1Match ? 'h1' : h2Match ? 'h2' : 'h3';
|
||||
const content = (h1Match || h2Match || h3Match)![1];
|
||||
parsed.push({ type, content, indent: lineIndent });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Dividers: ---, ***, ___
|
||||
if (/^(?:---|---|\*\*\*|___)\s*$/.test(strippedLine.trim())) {
|
||||
pushCurrentItem();
|
||||
afterHeading = false;
|
||||
parsed.push({ type: 'divider', content: '---', indent: lineIndent });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Todo items: - [ ], - [x], * [ ], * [x]
|
||||
const todoMatch = strippedLine.match(/^[-*]\s*\[([ xX])\]\s*(.*)$/);
|
||||
if (todoMatch) {
|
||||
pushCurrentItem();
|
||||
afterHeading = false;
|
||||
parsed.push({
|
||||
type: 'todo',
|
||||
checked: todoMatch[1].toLowerCase() === 'x',
|
||||
content: todoMatch[2],
|
||||
indent: lineIndent,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
|
||||
const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
|
||||
const numMatch = strippedLine.match(/^\d+[\.\)]\s+(.*)$/);
|
||||
if (bulletMatch || numMatch) {
|
||||
pushCurrentItem();
|
||||
afterHeading = false;
|
||||
parsed.push({
|
||||
type: 'text',
|
||||
content: (bulletMatch ? bulletMatch[1] : numMatch![1]),
|
||||
indent: lineIndent,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular text line
|
||||
if (afterHeading) {
|
||||
// This starts the single bullet point after the heading
|
||||
afterHeading = false;
|
||||
pushCurrentItem();
|
||||
currentItem = {
|
||||
type: 'text',
|
||||
content: strippedLine,
|
||||
indent: lineIndent,
|
||||
};
|
||||
} else if (currentItem) {
|
||||
if (currentItem.content.endsWith('\n')) {
|
||||
currentItem.content += strippedLine;
|
||||
} else {
|
||||
currentItem.content += '\n' + strippedLine;
|
||||
}
|
||||
} else {
|
||||
currentItem = {
|
||||
type: 'text',
|
||||
content: strippedLine,
|
||||
indent: lineIndent,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
pushCurrentItem();
|
||||
|
||||
if (parsed.length === 0) return;
|
||||
|
||||
const line0 = parsed[0];
|
||||
const baseIndent = line0.indent;
|
||||
const isSingle = parsed.length === 1;
|
||||
const line0Content = textBefore + line0.content + (isSingle ? textAfter : '');
|
||||
|
||||
const isCurrentEmpty = (currentBlock.content || '').trim() === '';
|
||||
const adoptedType = line0.type !== 'text' ? line0.type : (isCurrentEmpty ? 'text' : currentBlock.type);
|
||||
const adoptedChecked = line0.type === 'todo' ? (line0.checked ?? false) : currentBlock.checked;
|
||||
|
||||
const updatedCurrentBlock: DocumentBlock = {
|
||||
...currentBlock,
|
||||
content: line0Content,
|
||||
type: adoptedType,
|
||||
checked: adoptedChecked,
|
||||
};
|
||||
|
||||
const newBlocks: DocumentBlock[] = [];
|
||||
const nowBase = Date.now().toString(36);
|
||||
|
||||
for (let j = 1; j < parsed.length; j++) {
|
||||
const p = parsed[j];
|
||||
const isLastLine = j === parsed.length - 1;
|
||||
const lineContent = isLastLine ? (p.content + textAfter) : p.content;
|
||||
const lineLevel = Math.max(0, baseLevel + (p.indent - baseIndent));
|
||||
|
||||
newBlocks.push({
|
||||
id: `blk_${nowBase}_${j}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
documentId: docId,
|
||||
type: p.type,
|
||||
checked: p.checked || false,
|
||||
content: lineContent,
|
||||
level: lineLevel,
|
||||
collapsed: false,
|
||||
position: (currentBlock.position || 1000) + j * 10,
|
||||
});
|
||||
}
|
||||
|
||||
const lastTargetId = newBlocks.length > 0 ? newBlocks[newBlocks.length - 1].id : currentBlock.id;
|
||||
const existingEl = textareaMapRef.current.get(lastTargetId);
|
||||
if (existingEl) {
|
||||
pendingFocusRef.current = null;
|
||||
focusBlockInput(lastTargetId, 'end');
|
||||
} else {
|
||||
pendingFocusRef.current = { blockId: lastTargetId, cursor: 'end' };
|
||||
}
|
||||
setFocusedBlockId(lastTargetId);
|
||||
|
||||
if (onInsertBlocks) {
|
||||
onInsertBlocks(currentBlock.id, updatedCurrentBlock, newBlocks);
|
||||
} else {
|
||||
onUpdateBlock(updatedCurrentBlock, true);
|
||||
for (let k = 0; k < newBlocks.length; k++) {
|
||||
const afterId = k === 0 ? currentBlock.id : newBlocks[k - 1].id;
|
||||
onCreateBlock(newBlocks[k], afterId);
|
||||
}
|
||||
}
|
||||
},
|
||||
[blocks, canEdit, docId, focusBlockInput, onCreateBlock, onInsertBlocks, onUpdateBlock]
|
||||
);
|
||||
|
||||
const handleDeleteBlock = useCallback(
|
||||
(blockId: string, deleteSubtree = false) => {
|
||||
if (!canEdit) return;
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx === -1) return;
|
||||
|
||||
// If this is the only block in the document (and not in an isolated zoom subtree), clear instead of deleting
|
||||
if (blocks.length <= 1 && !levelOffset) {
|
||||
const only = blocks[0];
|
||||
onUpdateBlock(
|
||||
{
|
||||
...only,
|
||||
content: '',
|
||||
type: 'text',
|
||||
level: 0,
|
||||
checked: false,
|
||||
},
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetFocusBlock = idx > 0 ? blocks[idx - 1] : blocks[idx + 1];
|
||||
if (targetFocusBlock) {
|
||||
focusBlockInput(targetFocusBlock.id, 'end');
|
||||
}
|
||||
|
||||
if (deleteSubtree) {
|
||||
const rootLevel = blocks[idx].level || 0;
|
||||
const idsToDelete = [blockId];
|
||||
for (let j = idx + 1; j < blocks.length; j++) {
|
||||
if ((blocks[j].level || 0) > rootLevel) {
|
||||
idsToDelete.push(blocks[j].id);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const id of idsToDelete) {
|
||||
onDeleteBlock(id);
|
||||
}
|
||||
} else {
|
||||
onDeleteBlock(blockId);
|
||||
}
|
||||
},
|
||||
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock]
|
||||
);
|
||||
|
||||
const handleIndentBlock = useCallback(
|
||||
(blockId: string, delta: 1 | -1) => {
|
||||
if (!canEdit) return;
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx === -1) return;
|
||||
|
||||
const current = blocks[idx];
|
||||
const currentLevel = Math.max(0, current.level || 0);
|
||||
const prevLevel = idx > 0 ? Math.max(0, blocks[idx - 1].level || 0) : 0;
|
||||
const minLevel = levelOffset || 0;
|
||||
const maxLevel = idx === 0 ? minLevel : prevLevel + 1;
|
||||
|
||||
const nextLevel =
|
||||
delta === 1
|
||||
? Math.min(maxLevel, currentLevel + 1)
|
||||
: Math.max(minLevel, currentLevel - 1);
|
||||
|
||||
if (nextLevel === currentLevel) return;
|
||||
|
||||
const actualDelta = nextLevel - currentLevel;
|
||||
|
||||
// Find all contiguous children / descendants:
|
||||
// contiguous blocks with level > currentLevel
|
||||
let count = 1;
|
||||
for (let i = idx + 1; i < blocks.length; i++) {
|
||||
if (Math.max(0, blocks[i].level || 0) > currentLevel) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedBlocks: DocumentBlock[] = [];
|
||||
for (let i = idx; i < idx + count; i++) {
|
||||
const b = blocks[i];
|
||||
const newLvl = Math.max(minLevel, (b.level || 0) + actualDelta);
|
||||
updatedBlocks.push({
|
||||
...b,
|
||||
level: newLvl,
|
||||
});
|
||||
}
|
||||
|
||||
if (onUpdateBlocks) {
|
||||
onUpdateBlocks(updatedBlocks);
|
||||
} else {
|
||||
for (const b of updatedBlocks) {
|
||||
onUpdateBlock(b, true);
|
||||
}
|
||||
}
|
||||
},
|
||||
[blocks, canEdit, levelOffset, onUpdateBlock, onUpdateBlocks]
|
||||
);
|
||||
|
||||
const handleNavigateFocus = useCallback(
|
||||
(blockId: string, direction: 'up' | 'down') => {
|
||||
const visIdx = visibleEntries.findIndex((e) => e.block.id === blockId);
|
||||
if (visIdx === -1) return;
|
||||
const targetEntry =
|
||||
direction === 'up' ? visibleEntries[visIdx - 1] : visibleEntries[visIdx + 1];
|
||||
if (targetEntry) {
|
||||
focusBlockInput(
|
||||
targetEntry.block.id,
|
||||
direction === 'up' ? 'end' : 'start'
|
||||
);
|
||||
}
|
||||
},
|
||||
[focusBlockInput, visibleEntries]
|
||||
);
|
||||
|
||||
const handleDragStartBlock = useCallback((index: number) => {
|
||||
setDraggedIndex(index);
|
||||
}, []);
|
||||
|
||||
const handleDragOverBlock = useCallback(
|
||||
(e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex !== null) {
|
||||
const parentLevel = Math.max(0, blocks[draggedIndex]?.level || 0);
|
||||
let count = 1;
|
||||
for (let i = draggedIndex + 1; i < blocks.length; i++) {
|
||||
if (Math.max(0, blocks[i].level || 0) > parentLevel) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Do not allow dropping inside its own subtree (self or descendants)
|
||||
if (index >= draggedIndex && index < draggedIndex + count) {
|
||||
setDragOverIndex(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setDragOverIndex(index);
|
||||
},
|
||||
[blocks, draggedIndex]
|
||||
);
|
||||
|
||||
const handleDropBlock = useCallback(
|
||||
(targetIndex: number) => {
|
||||
if (draggedIndex === null || draggedIndex === targetIndex) {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
return;
|
||||
}
|
||||
const parentLevel = Math.max(0, blocks[draggedIndex]?.level || 0);
|
||||
let count = 1;
|
||||
for (let i = draggedIndex + 1; i < blocks.length; i++) {
|
||||
if (Math.max(0, blocks[i].level || 0) > parentLevel) {
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetIndex >= draggedIndex && targetIndex < draggedIndex + count) {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
return;
|
||||
}
|
||||
const movingBlock = blocks[draggedIndex];
|
||||
if (movingBlock) {
|
||||
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
|
||||
}
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
},
|
||||
[blocks, draggedIndex, onMoveBlock]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const clearDrag = () => {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
window.addEventListener('dragend', clearDrag);
|
||||
return () => window.removeEventListener('dragend', clearDrag);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
|
||||
const isLast = entryIdx === visibleEntries.length - 1;
|
||||
return (
|
||||
<OutlinerBlock
|
||||
key={block.id}
|
||||
docId={docId}
|
||||
block={block}
|
||||
index={index}
|
||||
isLast={isLast}
|
||||
maxAllowedLevel={maxAllowedLevel}
|
||||
hasChildren={hasChildren}
|
||||
isFocused={focusedBlockId === block.id}
|
||||
canEdit={canEdit}
|
||||
levelOffset={levelOffset}
|
||||
onZoomBlock={onZoomBlock}
|
||||
onUpdateBlock={onUpdateBlock}
|
||||
onCreateBlockBelow={handleCreateBlockBelow}
|
||||
onPasteMultiLines={handlePasteMultiLines}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onIndentBlock={handleIndentBlock}
|
||||
onNavigateFocus={handleNavigateFocus}
|
||||
onFocusBlock={setFocusedBlockId}
|
||||
onOpenTask={onOpenTask}
|
||||
onTagClick={onTagClick}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
onDragStartBlock={handleDragStartBlock}
|
||||
onDragOverBlock={handleDragOverBlock}
|
||||
onDropBlock={handleDropBlock}
|
||||
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
||||
isTargeted={highlightedBlockId === block.id}
|
||||
registerTextareaRef={registerTextareaRef}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{canEdit && (
|
||||
<div
|
||||
className={`min-h-[14rem] pt-2 pl-2 transition-colors cursor-text ${
|
||||
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
if (e.target !== e.currentTarget && (e.target as HTMLElement).closest('button, a, input, textarea, select')) {
|
||||
return;
|
||||
}
|
||||
if (blocks.length === 0) {
|
||||
const firstBlock: DocumentBlock = {
|
||||
id: `blk_${Date.now().toString(36)}_0`,
|
||||
documentId: docId,
|
||||
type: 'text',
|
||||
content: '',
|
||||
level: levelOffset || 0,
|
||||
position: 1000,
|
||||
};
|
||||
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };
|
||||
onCreateBlock(firstBlock);
|
||||
} else {
|
||||
const lastBlock = blocks[blocks.length - 1];
|
||||
if (lastBlock.content && lastBlock.content.trim() !== '') {
|
||||
handleCreateBlockBelow(lastBlock.id, '');
|
||||
} else {
|
||||
focusBlockInput(lastBlock.id, 'end');
|
||||
}
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex !== null) {
|
||||
setDragOverIndex(blocks.length);
|
||||
}
|
||||
}}
|
||||
onDrop={() => handleDropBlock(blocks.length)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -348,21 +348,10 @@ body {
|
||||
|
||||
/* Modal-Hintergrund-Overlay */
|
||||
.modal-overlay {
|
||||
margin: 0 !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
background-color: var(--bg-overlay);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Verhindert, dass Elterncontainer mit space-y-* oder space-x-* feste Overlays nach unten verschieben */
|
||||
.fixed.inset-0,
|
||||
[class*="fixed inset-0"] {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* File Tree Item */
|
||||
.tree-node-active {
|
||||
background-color: var(--bg-panel-elevated);
|
||||
|
||||
@@ -5,14 +5,12 @@ import {
|
||||
Document,
|
||||
DocumentBlock,
|
||||
DocShare,
|
||||
DocLockInfo,
|
||||
KanbanColumn,
|
||||
ListItem,
|
||||
ListShare,
|
||||
Organization,
|
||||
PriorityLevel,
|
||||
SharedList,
|
||||
SharedFolder,
|
||||
FolderShare,
|
||||
Space,
|
||||
SpaceMember,
|
||||
SpaceShare,
|
||||
@@ -36,19 +34,6 @@ export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getCurrentUserId(): string | null {
|
||||
const token = getToken();
|
||||
if (!token) return null;
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
return payload.userId || payload.sub || payload.id || null;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||
}
|
||||
@@ -129,18 +114,6 @@ export const api = {
|
||||
return request<AuthStatus>('/api/v1/auth/status');
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await request<{ success: boolean }>('/api/v1/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
clearToken();
|
||||
}
|
||||
},
|
||||
|
||||
async revokeAllSessions(): Promise<void> {
|
||||
await request<{ success: boolean }>('/api/v1/auth/revoke-all', { method: 'POST' });
|
||||
},
|
||||
|
||||
async completeSetup(payload: {
|
||||
email: string;
|
||||
password: string;
|
||||
@@ -189,7 +162,6 @@ export const api = {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
avatarUrl?: string;
|
||||
alarmChannels?: string[];
|
||||
alarmEmail?: string;
|
||||
discordWebhookUrl?: string;
|
||||
@@ -309,10 +281,6 @@ export const api = {
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async getThought(thoughtId: string): Promise<Thought> {
|
||||
return request<Thought>(`/api/v1/thoughts/${encodeURIComponent(thoughtId)}`);
|
||||
},
|
||||
|
||||
async triageAction(
|
||||
thoughtId: string,
|
||||
action: 'keep' | 'discard' | 'restore',
|
||||
@@ -341,14 +309,6 @@ export const api = {
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> {
|
||||
const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder';
|
||||
return request<{ success: boolean }>(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
},
|
||||
|
||||
async updateThought(
|
||||
thoughtId: string,
|
||||
patch: {
|
||||
@@ -430,7 +390,7 @@ export const api = {
|
||||
|
||||
async addTaskListShare(
|
||||
id: string,
|
||||
payload: { targetType: 'team' | 'user'; targetId: string; permission?: 'editor' | 'viewer' }
|
||||
payload: { targetType: 'team' | 'user'; targetId: string }
|
||||
): Promise<TaskListShare> {
|
||||
return request<TaskListShare>(`/api/v1/task-lists/${id}/shares`, {
|
||||
method: 'POST',
|
||||
@@ -462,60 +422,6 @@ export const api = {
|
||||
);
|
||||
},
|
||||
|
||||
async moveFolder(sourcePath: string, targetParentPath: string): Promise<{ newPath: string; movedDocIds: string[] }> {
|
||||
return request<{ newPath: string; movedDocIds: string[] }>('/api/v1/folders/move', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sourcePath, targetParentPath }),
|
||||
});
|
||||
},
|
||||
|
||||
// --- WORKSPACE SHARED FOLDERS ---
|
||||
async getSharedFolders(): Promise<SharedFolder[]> {
|
||||
const res = await request<SharedFolder[]>('/api/v1/workspace/folders/shared');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async createSharedFolder(payload: { name: string; mountPath?: string }): Promise<SharedFolder> {
|
||||
return request<SharedFolder>('/api/v1/workspace/folders/shared', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async getSharedFolder(id: string): Promise<SharedFolder> {
|
||||
return request<SharedFolder>(`/api/v1/workspace/folders/shared/${id}`);
|
||||
},
|
||||
|
||||
async deleteSharedFolder(id: string): Promise<void> {
|
||||
await request(`/api/v1/workspace/folders/shared/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async updateFolderMount(id: string, mountPath: string): Promise<void> {
|
||||
await request(`/api/v1/workspace/folders/shared/${id}/mount`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ mountPath }),
|
||||
});
|
||||
},
|
||||
|
||||
async getFolderShares(folderId: string): Promise<FolderShare[]> {
|
||||
const res = await request<FolderShare[]>(`/api/v1/workspace/folders/shared/${folderId}/shares`);
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async createFolderShare(
|
||||
folderId: string,
|
||||
payload: { targetType?: 'team' | 'user'; targetId?: string; userId?: string; email?: string; teamId?: string; permission: 'read' | 'write' }
|
||||
): Promise<FolderShare> {
|
||||
return request<FolderShare>(`/api/v1/workspace/folders/shared/${folderId}/shares`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteFolderShare(folderId: string, shareId: string): Promise<void> {
|
||||
await request(`/api/v1/workspace/folders/shared/${folderId}/shares/${shareId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- WORKSPACE TAGS ---
|
||||
async getWorkspaceTags(): Promise<TagSummary[]> {
|
||||
const res = await request<TagSummary[]>('/api/v1/workspace/tags');
|
||||
@@ -611,65 +517,10 @@ export const api = {
|
||||
};
|
||||
},
|
||||
|
||||
async patchDocumentBlocks(
|
||||
id: string,
|
||||
payload: {
|
||||
upserted?: DocumentBlock[];
|
||||
deletedIds?: string[];
|
||||
blockOrder?: string[];
|
||||
}
|
||||
): Promise<Document> {
|
||||
const d = await request<Document>(`/api/v1/documents/${id}/blocks`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return {
|
||||
...d,
|
||||
blocks: ensureArray(d.blocks),
|
||||
tags: ensureArray(d.tags),
|
||||
};
|
||||
},
|
||||
|
||||
async joinSSERoom(sseId: string, room: string, action: 'join' | 'leave' = 'join'): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>('/api/v1/events/rooms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sseId, room, action }),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteDocument(id: string): Promise<void> {
|
||||
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async moveDocument(id: string, folderPath: string): Promise<Document> {
|
||||
const d = await request<Document>(`/api/v1/documents/${id}/move`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ folderPath }),
|
||||
});
|
||||
return {
|
||||
...d,
|
||||
blocks: ensureArray(d.blocks),
|
||||
tags: ensureArray(d.tags),
|
||||
};
|
||||
},
|
||||
|
||||
async createEnhancedTask(
|
||||
docId: string,
|
||||
blockId: string,
|
||||
payload: { content: string; checked: boolean }
|
||||
): Promise<Thought> {
|
||||
return request<Thought>(`/api/v1/documents/${docId}/blocks/${blockId}/enhanced-task`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async unlinkEnhancedTask(taskId: string): Promise<{ unlinked: boolean }> {
|
||||
return request<{ unlinked: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/unlink`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
// --- DOCUMENT SHARING ---
|
||||
async getDocShares(docId: string): Promise<DocShare[]> {
|
||||
const res = await request<DocShare[]>(`/api/v1/documents/${docId}/shares`);
|
||||
@@ -695,6 +546,44 @@ export const api = {
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
// --- DOCUMENT LOCKING ---
|
||||
async getDocLock(docId: string): Promise<{ locked: boolean; lock?: DocLockInfo }> {
|
||||
return request<{ locked: boolean; lock?: DocLockInfo }>(`/api/v1/documents/${docId}/lock`);
|
||||
},
|
||||
|
||||
async lockDocument(
|
||||
docId: string
|
||||
): Promise<{ locked: boolean; acquired: boolean; lock?: DocLockInfo; message?: string }> {
|
||||
try {
|
||||
return await request<{ locked: boolean; acquired: boolean; lock?: DocLockInfo; message?: string }>(
|
||||
`/api/v1/documents/${docId}/lock`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
} catch (e: any) {
|
||||
return { locked: true, acquired: false, message: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
async heartbeatDocLock(docId: string): Promise<{ extended: boolean }> {
|
||||
try {
|
||||
return await request<{ extended: boolean }>(`/api/v1/documents/${docId}/lock/heartbeat`, {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch {
|
||||
return { extended: false };
|
||||
}
|
||||
},
|
||||
|
||||
async unlockDocument(docId: string): Promise<{ unlocked: boolean }> {
|
||||
try {
|
||||
return await request<{ unlocked: boolean }>(`/api/v1/documents/${docId}/lock`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch {
|
||||
return { unlocked: false };
|
||||
}
|
||||
},
|
||||
|
||||
// --- SPACES & REALTIME LISTS ---
|
||||
async getSpaces(): Promise<Space[]> {
|
||||
const res = await request<Space[]>('/api/v1/spaces');
|
||||
@@ -750,36 +639,17 @@ export const api = {
|
||||
return ensureArray(res).map((l) => ({
|
||||
...l,
|
||||
items: ensureArray(l.items),
|
||||
shares: ensureArray(l.shares),
|
||||
}));
|
||||
},
|
||||
|
||||
async getListById(listId: string): Promise<SharedList> {
|
||||
const l = await request<SharedList>(`/api/v1/lists/${listId}`);
|
||||
return {
|
||||
...l,
|
||||
items: ensureArray(l.items),
|
||||
shares: ensureArray(l.shares),
|
||||
};
|
||||
},
|
||||
|
||||
async createList(titleOrSpaceId: string, titleOrIcon?: string, iconOrType?: string, listType?: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
|
||||
let title = titleOrSpaceId;
|
||||
let icon = titleOrIcon || '🛒';
|
||||
let type = (iconOrType as any) || 'shopping';
|
||||
if (listType !== undefined) {
|
||||
title = titleOrIcon || '';
|
||||
icon = iconOrType || '🛒';
|
||||
type = listType;
|
||||
}
|
||||
const l = await request<SharedList>('/api/v1/lists', {
|
||||
async createList(spaceId: string, title: string, icon: string, listType: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
|
||||
const l = await request<SharedList>(`/api/v1/spaces/${spaceId}/lists`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, icon, listType: type }),
|
||||
body: JSON.stringify({ title, icon, listType }),
|
||||
});
|
||||
return {
|
||||
...l,
|
||||
items: ensureArray(l.items),
|
||||
shares: ensureArray(l.shares),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -787,22 +657,6 @@ export const api = {
|
||||
await request(`/api/v1/lists/${listId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async getListShares(listId: string): Promise<ListShare[]> {
|
||||
const res = await request<ListShare[]>(`/api/v1/lists/${listId}/shares`);
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async addListShare(listId: string, target: { targetType: 'team' | 'user'; targetId: string; role?: string }): Promise<ListShare> {
|
||||
return request<ListShare>(`/api/v1/lists/${listId}/shares`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(target),
|
||||
});
|
||||
},
|
||||
|
||||
async removeListShare(listId: string, shareId: string): Promise<void> {
|
||||
await request(`/api/v1/lists/${listId}/shares/${shareId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async createListItem(listId: string, content: string, category = '', quantity = ''): Promise<ListItem> {
|
||||
return request<ListItem>(`/api/v1/lists/${listId}/items`, {
|
||||
method: 'POST',
|
||||
@@ -817,17 +671,6 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async updateListItem(
|
||||
listId: string,
|
||||
itemId: string,
|
||||
payload: { content?: string; category?: string; quantity?: string }
|
||||
): Promise<ListItem> {
|
||||
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteListItem(listId: string, itemId: string): Promise<void> {
|
||||
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
||||
},
|
||||
@@ -838,16 +681,6 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async reorderListItems(
|
||||
listId: string,
|
||||
items: { id: string; sortOrder: number; category?: string; content?: string }[]
|
||||
): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>(`/api/v1/lists/${listId}/items/reorder`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
},
|
||||
|
||||
// --- ORGANIZATIONS & TEAMS ---
|
||||
async getOrganizations(): Promise<Organization[]> {
|
||||
const res = await request<Organization[]>('/api/v1/organizations');
|
||||
@@ -1067,233 +900,34 @@ export function getOfflineQueue(): Thought[] {
|
||||
}
|
||||
}
|
||||
|
||||
export type RealtimeTransport = 'ws' | 'sse' | 'none';
|
||||
|
||||
let activeTransport: RealtimeTransport = 'none';
|
||||
let activeGlobalWS: WebSocket | null = null;
|
||||
let activeGlobalSSE: EventSource | null = null;
|
||||
let activeSSEId: string | null = null;
|
||||
let wsReconnectTimer: any = null;
|
||||
let wsConnectionWatchdog: any = null;
|
||||
let wsListeners: ((msg: WSMessage) => void)[] = [];
|
||||
let realtimeStatusListeners: ((connected: boolean, transport: RealtimeTransport) => void)[] = [];
|
||||
let isRealtimeConnectedState = false;
|
||||
let manualDisconnect = false;
|
||||
let wsReconnectDelay = 2000;
|
||||
|
||||
export function buildWebSocketURL(listId?: string): string {
|
||||
export function buildWebSocketURL(spaceId?: string): string {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = getToken() || '';
|
||||
if (listId) {
|
||||
return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?token=${encodeURIComponent(token)}`;
|
||||
if (spaceId) {
|
||||
return `${protocol}//${window.location.host}/api/v1/ws/spaces/${encodeURIComponent(spaceId)}?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export function buildSSEURL(listId?: string, docId?: string, rooms?: string[]): string {
|
||||
const token = getToken() || '';
|
||||
const params = new URLSearchParams();
|
||||
if (token) params.set('token', token);
|
||||
if (listId) params.set('listId', listId);
|
||||
if (docId) params.set('docId', docId);
|
||||
if (rooms && rooms.length > 0) params.set('rooms', rooms.join(','));
|
||||
return `/api/v1/events?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function isWebSocketConnected(): boolean {
|
||||
return activeTransport === 'ws' && activeGlobalWS !== null && activeGlobalWS.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
export function isRealtimeConnected(): boolean {
|
||||
return isRealtimeConnectedState;
|
||||
}
|
||||
|
||||
export function getRealtimeTransport(): RealtimeTransport {
|
||||
return activeTransport;
|
||||
}
|
||||
|
||||
export function getSSEId(): string | null {
|
||||
return activeSSEId;
|
||||
}
|
||||
|
||||
export function onWebSocketStatus(callback: (connected: boolean) => void): () => void {
|
||||
const wrapper = (connected: boolean) => callback(connected);
|
||||
realtimeStatusListeners.push(wrapper);
|
||||
callback(isRealtimeConnected());
|
||||
return () => {
|
||||
realtimeStatusListeners = realtimeStatusListeners.filter((fn) => fn !== wrapper);
|
||||
};
|
||||
}
|
||||
|
||||
export function onRealtimeStatus(callback: (connected: boolean, transport: RealtimeTransport) => void): () => void {
|
||||
realtimeStatusListeners.push(callback);
|
||||
callback(isRealtimeConnected(), activeTransport);
|
||||
return () => {
|
||||
realtimeStatusListeners = realtimeStatusListeners.filter((fn) => fn !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
let isWsBlockedByProxy = typeof window !== 'undefined' && sessionStorage.getItem('mindsift_ws_blocked') === 'true';
|
||||
let sseReconnectTimer: any = null;
|
||||
let sseReconnectDelay = 2000;
|
||||
const globalSubscribedRooms = new Set<string>();
|
||||
|
||||
function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTransport) {
|
||||
isRealtimeConnectedState = connected;
|
||||
activeTransport = transport;
|
||||
realtimeStatusListeners.forEach((fn) => {
|
||||
try {
|
||||
fn(connected, transport);
|
||||
} catch (e) {
|
||||
console.error('Realtime status listener error', e);
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('mindsift:ws:status', { detail: { connected, transport } }));
|
||||
}
|
||||
|
||||
function switchToSSE(reason: string) {
|
||||
console.warn(`[MindSift Realtime] ${reason} -> Schalte sofort und dauerhaft auf Server-Sent Events (SSE) um.`);
|
||||
isWsBlockedByProxy = true;
|
||||
try {
|
||||
sessionStorage.setItem('mindsift_ws_blocked', 'true');
|
||||
} catch {}
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
clearTimeout(wsReconnectTimer);
|
||||
if (activeGlobalWS) {
|
||||
try {
|
||||
activeGlobalWS.onclose = null;
|
||||
activeGlobalWS.onerror = null;
|
||||
activeGlobalWS.close();
|
||||
} catch {}
|
||||
activeGlobalWS = null;
|
||||
}
|
||||
connectSSE();
|
||||
}
|
||||
|
||||
export function connectSSE(rooms?: string[]): void {
|
||||
if (rooms) {
|
||||
rooms.forEach((r) => globalSubscribedRooms.add(r));
|
||||
}
|
||||
clearTimeout(sseReconnectTimer);
|
||||
clearTimeout(wsReconnectTimer);
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
|
||||
if (activeGlobalSSE) {
|
||||
try { activeGlobalSSE.close(); } catch {}
|
||||
activeGlobalSSE = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = buildSSEURL(undefined, undefined, Array.from(globalSubscribedRooms));
|
||||
const es = new EventSource(url);
|
||||
activeGlobalSSE = es;
|
||||
|
||||
es.onopen = () => {
|
||||
sseReconnectDelay = 2000;
|
||||
notifyRealtimeStatusChange(true, 'sse');
|
||||
if (activeSSEId && globalSubscribedRooms.size > 0) {
|
||||
globalSubscribedRooms.forEach((room) => {
|
||||
api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as WSMessage;
|
||||
if (msg.event === 'SSE_CONNECTED') {
|
||||
activeSSEId = msg.payload?.sseId || null;
|
||||
if (activeSSEId && globalSubscribedRooms.size > 0) {
|
||||
globalSubscribedRooms.forEach((room) => {
|
||||
api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
wsListeners.forEach((fn) => {
|
||||
try {
|
||||
fn(msg);
|
||||
} catch (e) {
|
||||
console.error('SSE listener error', e);
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('mindsift:ws', { detail: msg }));
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE message', err);
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = (e) => {
|
||||
console.warn('[MindSift Realtime] SSE Verbindungsfehler:', e);
|
||||
if (activeTransport === 'sse') {
|
||||
notifyRealtimeStatusChange(false, 'none');
|
||||
}
|
||||
try { es.close(); } catch {}
|
||||
activeGlobalSSE = null;
|
||||
if (!manualDisconnect && getToken()) {
|
||||
clearTimeout(sseReconnectTimer);
|
||||
const delay = sseReconnectDelay;
|
||||
sseReconnectDelay = Math.min(30000, Math.round(sseReconnectDelay * 1.5));
|
||||
sseReconnectTimer = setTimeout(() => {
|
||||
if (!manualDisconnect && getToken()) {
|
||||
connectSSE();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize EventSource SSE', err);
|
||||
notifyRealtimeStatusChange(false, 'none');
|
||||
}
|
||||
}
|
||||
|
||||
export function connectWebSocket(): void {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
manualDisconnect = false;
|
||||
|
||||
// If proxy blocked WebSocket previously, do not attempt failing WebSocket; stay on SSE
|
||||
if (isWsBlockedByProxy) {
|
||||
connectSSE();
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
clearTimeout(wsReconnectTimer);
|
||||
clearTimeout(sseReconnectTimer);
|
||||
|
||||
try {
|
||||
const url = buildWebSocketURL();
|
||||
const ws = new WebSocket(url);
|
||||
activeGlobalWS = ws;
|
||||
|
||||
// 2.5s watchdog for corporate proxies silently stripping Upgrade header or hanging
|
||||
wsConnectionWatchdog = setTimeout(() => {
|
||||
if (ws && ws.readyState !== WebSocket.OPEN) {
|
||||
switchToSSE('WebSocket Timeout nach 2.5s (Proxy Upgrade Header blockiert)');
|
||||
}
|
||||
}, 2500);
|
||||
|
||||
ws.onopen = () => {
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
if (activeGlobalSSE) {
|
||||
try { activeGlobalSSE.close(); } catch {}
|
||||
activeGlobalSSE = null;
|
||||
}
|
||||
wsReconnectDelay = 2000;
|
||||
notifyRealtimeStatusChange(true, 'ws');
|
||||
if (globalSubscribedRooms.size > 0) {
|
||||
globalSubscribedRooms.forEach((room) => {
|
||||
try {
|
||||
ws.send(JSON.stringify({ event: 'JOIN_ROOM', room }));
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as WSMessage;
|
||||
@@ -1310,99 +944,40 @@ export function connectWebSocket(): void {
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (evt: CloseEvent) => {
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
ws.onclose = () => {
|
||||
activeGlobalWS = null;
|
||||
|
||||
// In corporate environments, blocked WebSockets close abruptly without clean handshake
|
||||
if (!evt.wasClean || evt.code === 1006 || evt.code === 1002 || evt.code === 1008 || evt.code === 1005) {
|
||||
switchToSSE(`WebSocket unsauber beendet (Code ${evt.code}, wasClean=${evt.wasClean})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTransport === 'ws') {
|
||||
notifyRealtimeStatusChange(false, 'none');
|
||||
}
|
||||
|
||||
if (!manualDisconnect && getToken() && activeTransport !== 'sse') {
|
||||
if (!manualDisconnect && getToken()) {
|
||||
clearTimeout(wsReconnectTimer);
|
||||
const delay = wsReconnectDelay;
|
||||
wsReconnectDelay = Math.min(30000, Math.round(wsReconnectDelay * 1.5));
|
||||
wsReconnectTimer = setTimeout(() => {
|
||||
if (!manualDisconnect && getToken()) {
|
||||
connectWebSocket();
|
||||
}
|
||||
}, delay);
|
||||
connectWebSocket();
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (e) => {
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
switchToSSE('WebSocket Fehler (Handshake/Verbindung verweigert)');
|
||||
ws.onerror = () => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {}
|
||||
};
|
||||
} catch (e) {
|
||||
switchToSSE('WebSocket Exception beim Initialisieren');
|
||||
console.error('Failed to connect WebSocket', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure reconnect when browser recovers from sleep or tab visibility
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', () => {
|
||||
if (getToken() && !isRealtimeConnected()) {
|
||||
if (isWsBlockedByProxy) {
|
||||
connectSSE();
|
||||
} else {
|
||||
connectWebSocket();
|
||||
}
|
||||
}
|
||||
});
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && getToken() && !isRealtimeConnected()) {
|
||||
if (isWsBlockedByProxy) {
|
||||
connectSSE();
|
||||
} else {
|
||||
connectWebSocket();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function disconnectWebSocket(): void {
|
||||
manualDisconnect = true;
|
||||
clearTimeout(wsReconnectTimer);
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
clearTimeout(sseReconnectTimer);
|
||||
if (activeGlobalWS) {
|
||||
try {
|
||||
activeGlobalWS.close();
|
||||
} catch {}
|
||||
activeGlobalWS = null;
|
||||
}
|
||||
if (activeGlobalSSE) {
|
||||
try {
|
||||
activeGlobalSSE.close();
|
||||
} catch {}
|
||||
activeGlobalSSE = null;
|
||||
}
|
||||
notifyRealtimeStatusChange(false, 'none');
|
||||
}
|
||||
|
||||
export function sendWebSocketMessage(msg: any): void {
|
||||
if (activeGlobalWS && activeGlobalWS.readyState === WebSocket.OPEN) {
|
||||
activeGlobalWS.send(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
return;
|
||||
}
|
||||
|
||||
// SSE Fallback: dispatch via REST endpoints
|
||||
try {
|
||||
const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg;
|
||||
if (parsed.event === 'JOIN_ROOM' && parsed.room) {
|
||||
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'join').catch(() => {});
|
||||
} else if (parsed.event === 'LEAVE_ROOM' && parsed.room) {
|
||||
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'leave').catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error dispatching fallback REST message for SSE:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1412,86 +987,3 @@ export function onWebSocketMessage(callback: (msg: WSMessage) => void): () => vo
|
||||
wsListeners = wsListeners.filter((fn) => fn !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
export function createRealtimeConnection(options: {
|
||||
listId?: string;
|
||||
docId?: string;
|
||||
rooms?: string[];
|
||||
onMessage?: (msg: WSMessage) => void;
|
||||
onStatusChange?: (status: { connected: boolean; transport: RealtimeTransport; reason?: string }) => void;
|
||||
}) {
|
||||
let isClosed = false;
|
||||
|
||||
const joinedRooms: string[] = [];
|
||||
if (options.docId) joinedRooms.push(`doc:${options.docId}`);
|
||||
if (options.listId) joinedRooms.push(options.listId);
|
||||
if (options.rooms) {
|
||||
options.rooms.forEach((r) => {
|
||||
if (!joinedRooms.includes(r)) joinedRooms.push(r);
|
||||
});
|
||||
}
|
||||
|
||||
joinedRooms.forEach((r) => globalSubscribedRooms.add(r));
|
||||
|
||||
// Subscribe to status changes
|
||||
const unsubStatus = onRealtimeStatus((connected, transport) => {
|
||||
if (isClosed) return;
|
||||
options.onStatusChange?.({
|
||||
connected,
|
||||
transport,
|
||||
reason: connected ? undefined : 'Verbindung getrennt',
|
||||
});
|
||||
});
|
||||
|
||||
// Subscribe to messages
|
||||
let unsubMsg: (() => void) | null = null;
|
||||
if (options.onMessage) {
|
||||
unsubMsg = onWebSocketMessage((msg) => {
|
||||
if (isClosed) return;
|
||||
options.onMessage?.(msg);
|
||||
});
|
||||
}
|
||||
|
||||
// Join rooms on the active connection
|
||||
const joinAll = () => {
|
||||
joinedRooms.forEach((r) => {
|
||||
sendWebSocketMessage({ event: 'JOIN_ROOM', room: r });
|
||||
if (activeSSEId) {
|
||||
api.joinSSERoom(activeSSEId, r, 'join').catch(() => {});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (isRealtimeConnected()) {
|
||||
joinAll();
|
||||
}
|
||||
|
||||
const unsubRejoin = onRealtimeStatus((connected) => {
|
||||
if (isClosed) return;
|
||||
if (connected) {
|
||||
joinAll();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
getTransport: () => activeTransport,
|
||||
isConnected: () => isRealtimeConnected(),
|
||||
send: (msg: any) => {
|
||||
if (isClosed) return;
|
||||
sendWebSocketMessage(msg);
|
||||
},
|
||||
close: () => {
|
||||
isClosed = true;
|
||||
unsubStatus();
|
||||
unsubRejoin();
|
||||
if (unsubMsg) unsubMsg();
|
||||
joinedRooms.forEach((r) => {
|
||||
globalSubscribedRooms.delete(r);
|
||||
sendWebSocketMessage({ event: 'LEAVE_ROOM', room: r });
|
||||
if (activeSSEId) {
|
||||
api.joinSSERoom(activeSSEId, r, 'leave').catch(() => {});
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,228 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Parses inline markdown:
|
||||
* - **bold** or __bold__
|
||||
* - *italic* or _italic_
|
||||
* - ~~strikethrough~~
|
||||
* - `code`
|
||||
* - [label](url)
|
||||
*/
|
||||
function renderInlineTokens(text: string): React.ReactNode[] {
|
||||
if (!text) return [];
|
||||
|
||||
const regex = /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|~~[^~]+~~|\*[^*]+\*|_[^_]+_|\[([^\]]+)\]\(([^)]+)\))/g;
|
||||
|
||||
const elements: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
elements.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
const token = match[0];
|
||||
const key = `${match.index}-${token}`;
|
||||
|
||||
if (token.startsWith('`') && token.endsWith('`')) {
|
||||
elements.push(
|
||||
<code key={key} className="px-1.5 py-0.5 rounded bg-panel-elevated text-accent font-mono text-[0.9em] border border-border/50">
|
||||
{token.slice(1, -1)}
|
||||
</code>
|
||||
);
|
||||
} else if (
|
||||
(token.startsWith('**') && token.endsWith('**')) ||
|
||||
(token.startsWith('__') && token.endsWith('__'))
|
||||
) {
|
||||
elements.push(
|
||||
<strong key={key} className="font-semibold text-main">
|
||||
{token.slice(2, -2)}
|
||||
</strong>
|
||||
);
|
||||
} else if (
|
||||
(token.startsWith('*') && token.endsWith('*')) ||
|
||||
(token.startsWith('_') && token.endsWith('_'))
|
||||
) {
|
||||
elements.push(
|
||||
<em key={key} className="italic text-main/90">
|
||||
{token.slice(1, -1)}
|
||||
</em>
|
||||
);
|
||||
} else if (token.startsWith('~~') && token.endsWith('~~')) {
|
||||
elements.push(
|
||||
<del key={key} className="line-through text-muted/80">
|
||||
{token.slice(2, -2)}
|
||||
</del>
|
||||
);
|
||||
} else if (token.startsWith('[') && match[2] && match[3]) {
|
||||
const label = match[2];
|
||||
const url = match[3];
|
||||
elements.push(
|
||||
<a
|
||||
key={key}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent underline hover:opacity-80 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
);
|
||||
} else {
|
||||
elements.push(token);
|
||||
}
|
||||
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
elements.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses inline markdown and fenced code blocks:
|
||||
* - ```lang\ncode\n```
|
||||
* - **bold** or __bold__
|
||||
* - *italic* or _italic_
|
||||
* - ~~strikethrough~~
|
||||
* - `code`
|
||||
* - [label](url)
|
||||
*/
|
||||
export function renderInlineMarkdown(text: string): React.ReactNode[] {
|
||||
if (!text) return [];
|
||||
|
||||
if (text.includes('```')) {
|
||||
const codeBlockRegex = /(```[a-zA-Z0-9_-]*\r?\n[\s\S]*?```|```[\s\S]*?```)/g;
|
||||
const parts = text.split(codeBlockRegex);
|
||||
const nodes: React.ReactNode[] = [];
|
||||
|
||||
parts.forEach((part, partIdx) => {
|
||||
if (part.startsWith('```') && part.endsWith('```')) {
|
||||
const lines = part.split(/\r?\n/);
|
||||
const firstLine = lines[0].slice(3).trim();
|
||||
const codeLines = lines.slice(1, lines.length - 1);
|
||||
const code = codeLines.join('\n');
|
||||
nodes.push(
|
||||
<div
|
||||
key={`fenced-${partIdx}`}
|
||||
className="p-2.5 my-2 rounded bg-code text-accent font-mono text-xs overflow-x-auto border border-border/60 select-text"
|
||||
>
|
||||
{firstLine && (
|
||||
<div className="text-[10px] text-faint uppercase font-sans pb-1 select-none">
|
||||
{firstLine}
|
||||
</div>
|
||||
)}
|
||||
<pre className="whitespace-pre overflow-x-auto font-mono text-status-ok leading-relaxed">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
} else if (part) {
|
||||
nodes.push(...renderInlineTokens(part));
|
||||
}
|
||||
});
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return renderInlineTokens(text);
|
||||
}
|
||||
|
||||
export function MarkdownViewer({ content, className = '' }: { content: string; className?: string }) {
|
||||
if (!content) return null;
|
||||
|
||||
const lines = content.split('\n');
|
||||
const renderedNodes: React.ReactNode[] = [];
|
||||
let inCodeBlock = false;
|
||||
let codeBlockLines: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.trim().startsWith('```')) {
|
||||
if (inCodeBlock) {
|
||||
renderedNodes.push(
|
||||
<pre key={`code-${i}`} className="p-2.5 my-2 rounded bg-panel-elevated text-accent font-mono text-xs overflow-x-auto border border-border/60">
|
||||
<code>{codeBlockLines.join('\n')}</code>
|
||||
</pre>
|
||||
);
|
||||
codeBlockLines = [];
|
||||
inCodeBlock = false;
|
||||
} else {
|
||||
inCodeBlock = true;
|
||||
codeBlockLines = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inCodeBlock) {
|
||||
codeBlockLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = line.trim();
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
renderedNodes.push(
|
||||
<h3 key={i} className="text-sm font-bold text-main mt-2 mb-1">
|
||||
{renderInlineMarkdown(trimmed.slice(4))}
|
||||
</h3>
|
||||
);
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
renderedNodes.push(
|
||||
<h2 key={i} className="text-base font-bold text-main mt-3 mb-1">
|
||||
{renderInlineMarkdown(trimmed.slice(3))}
|
||||
</h2>
|
||||
);
|
||||
} else if (trimmed.startsWith('# ')) {
|
||||
renderedNodes.push(
|
||||
<h1 key={i} className="text-lg font-bold text-main mt-3 mb-1.5">
|
||||
{renderInlineMarkdown(trimmed.slice(2))}
|
||||
</h1>
|
||||
);
|
||||
} else if (trimmed.startsWith('> ')) {
|
||||
renderedNodes.push(
|
||||
<blockquote key={i} className="border-l-2 border-accent pl-2.5 my-1 text-muted italic">
|
||||
{renderInlineMarkdown(trimmed.slice(2))}
|
||||
</blockquote>
|
||||
);
|
||||
} else if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
|
||||
renderedNodes.push(
|
||||
<div key={i} className="flex items-start gap-1.5 my-0.5">
|
||||
<span className="text-accent mt-0.5">•</span>
|
||||
<span className="flex-1 text-main">{renderInlineMarkdown(trimmed.slice(2))}</span>
|
||||
</div>
|
||||
);
|
||||
} else if (/^\d+\.\s/.test(trimmed)) {
|
||||
const matchNum = trimmed.match(/^(\d+)\.\s(.*)$/);
|
||||
renderedNodes.push(
|
||||
<div key={i} className="flex items-start gap-1.5 my-0.5">
|
||||
<span className="text-muted text-[11px] font-mono mt-0.5">{matchNum ? matchNum[1] + '.' : '•'}</span>
|
||||
<span className="flex-1 text-main">{renderInlineMarkdown(matchNum ? matchNum[2] : trimmed)}</span>
|
||||
</div>
|
||||
);
|
||||
} else if (trimmed === '') {
|
||||
renderedNodes.push(<div key={i} className="h-2" />);
|
||||
} else {
|
||||
renderedNodes.push(
|
||||
<div key={i} className="my-0.5 text-main leading-relaxed">
|
||||
{renderInlineMarkdown(line)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (inCodeBlock && codeBlockLines.length > 0) {
|
||||
renderedNodes.push(
|
||||
<pre key="code-end" className="p-2.5 my-2 rounded bg-panel-elevated text-accent font-mono text-xs overflow-x-auto border border-border/60">
|
||||
<code>{codeBlockLines.join('\n')}</code>
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={`markdown-body text-xs ${className}`}>{renderedNodes}</div>;
|
||||
}
|
||||
@@ -14,7 +14,6 @@ export interface User {
|
||||
email: string;
|
||||
username?: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
role: UserRole;
|
||||
isActive: boolean;
|
||||
alarmChannels?: string[];
|
||||
@@ -53,17 +52,14 @@ export interface Thought {
|
||||
source: 'manual' | 'voice' | 'webhook';
|
||||
status: ThoughtStatus;
|
||||
isCompleted: boolean;
|
||||
convertedType?: 'task' | 'document' | 'list_item' | 'document_block';
|
||||
convertedType?: 'task' | 'document' | 'list_item';
|
||||
convertedRefId?: string;
|
||||
docId?: string;
|
||||
blockId?: string;
|
||||
dueDate?: string;
|
||||
taskListId?: string;
|
||||
kanbanStatus?: string;
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
alarmSent?: boolean;
|
||||
sortOrder?: number;
|
||||
createdAt: string;
|
||||
triagedAt?: string;
|
||||
}
|
||||
@@ -82,8 +78,6 @@ export interface TaskListShare {
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
permission?: 'editor' | 'viewer';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -91,7 +85,6 @@ export interface TaskListMember {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
avatarUrl?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
@@ -105,7 +98,6 @@ export interface TaskList {
|
||||
ownerId?: string;
|
||||
ownerName?: string;
|
||||
isOwner?: boolean;
|
||||
permission?: 'owner' | 'editor' | 'viewer';
|
||||
members?: TaskListMember[];
|
||||
shares?: TaskListShare[];
|
||||
columns?: KanbanColumn[];
|
||||
@@ -139,7 +131,6 @@ export interface TeamMember {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
avatarUrl?: string;
|
||||
role: 'admin' | 'member';
|
||||
joinedAt: string;
|
||||
}
|
||||
@@ -157,37 +148,16 @@ export interface SystemSettings {
|
||||
smtpConfiguredViaEnv?: boolean;
|
||||
}
|
||||
|
||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
|
||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
|
||||
|
||||
export interface DocumentBlock {
|
||||
id: string;
|
||||
docId?: string;
|
||||
documentId?: string;
|
||||
taskId?: string;
|
||||
type: BlockType;
|
||||
content: string;
|
||||
checked?: boolean;
|
||||
lang?: string;
|
||||
level?: number;
|
||||
position?: number;
|
||||
collapsed?: boolean;
|
||||
sortOrder?: number;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface DocBlocksPatchRequest {
|
||||
upserted?: DocumentBlock[];
|
||||
deletedIds?: string[];
|
||||
blockOrder?: string[];
|
||||
}
|
||||
|
||||
export interface DocBlocksPatchEvent {
|
||||
docId: string;
|
||||
updatedBy: string;
|
||||
updatedAt: string;
|
||||
upserted?: DocumentBlock[];
|
||||
deletedIds?: string[];
|
||||
blockOrder?: string[];
|
||||
}
|
||||
|
||||
export interface DocShare {
|
||||
@@ -205,14 +175,19 @@ export interface DocShare {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DocLockInfo {
|
||||
docId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id: string;
|
||||
userId: string;
|
||||
spaceId?: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
folderId?: string;
|
||||
markdownCache?: string;
|
||||
content: string;
|
||||
blocks: DocumentBlock[];
|
||||
tags: string[];
|
||||
@@ -227,36 +202,6 @@ export interface Document {
|
||||
isOwner?: boolean;
|
||||
ownerName?: string;
|
||||
sharedWith?: DocShare[];
|
||||
sharedFolderId?: string;
|
||||
subFolderPath?: string;
|
||||
isSharedFolder?: boolean;
|
||||
folderPermission?: string;
|
||||
}
|
||||
|
||||
export interface SharedFolder {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
ownerName?: string;
|
||||
name: string;
|
||||
mountPath: string;
|
||||
isOwner: boolean;
|
||||
permission: 'owner' | 'write' | 'read';
|
||||
shares?: FolderShare[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FolderShare {
|
||||
id: string;
|
||||
folderId: string;
|
||||
targetType: 'user' | 'team';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
permission: 'read' | 'write';
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TagSummary {
|
||||
@@ -294,7 +239,6 @@ export interface SpaceMember {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
avatarUrl?: string;
|
||||
role: 'owner' | 'editor' | 'viewer';
|
||||
joinedAt: string;
|
||||
}
|
||||
@@ -305,7 +249,6 @@ export interface UnifiedShare {
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
targetEmail?: string;
|
||||
avatarUrl?: string;
|
||||
permission?: 'editor' | 'viewer';
|
||||
role?: string;
|
||||
createdBy?: string;
|
||||
@@ -352,30 +295,13 @@ export interface ListItem {
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface ListShare {
|
||||
id: string;
|
||||
listId: string;
|
||||
targetType: 'team' | 'user';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
targetEmail?: string;
|
||||
role: 'editor' | 'viewer';
|
||||
createdBy?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SharedList {
|
||||
id: string;
|
||||
userId?: string;
|
||||
ownerName?: string;
|
||||
isOwner?: boolean;
|
||||
role?: string;
|
||||
spaceId?: string;
|
||||
spaceId: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
listType: 'shopping' | 'checklist' | 'vacation';
|
||||
items: ListItem[];
|
||||
shares?: ListShare[];
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -405,7 +331,6 @@ export interface WSMessage<T = any> {
|
||||
event: string;
|
||||
space_id?: string;
|
||||
list_id?: string;
|
||||
listId?: string;
|
||||
payload: T;
|
||||
timestamp: string;
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { MobileActionProvider } from './context/MobileActionContext';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<MobileActionProvider>
|
||||
<App />
|
||||
</MobileActionProvider>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -10,7 +9,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/config"
|
||||
@@ -45,7 +43,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
|
||||
hub := ws.NewHub()
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
|
||||
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
@@ -119,6 +117,7 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
||||
"/api/v1/tasks",
|
||||
"/api/v1/organizations",
|
||||
"/api/v1/teams",
|
||||
"/api/v1/spaces",
|
||||
"/api/v1/lists",
|
||||
}
|
||||
for _, ep := range arrayEndpoints {
|
||||
@@ -191,6 +190,7 @@ func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/triage/deck",
|
||||
"/api/v1/lists",
|
||||
"/api/v1/spaces",
|
||||
"/api/v1/tasks",
|
||||
"/api/v1/documents",
|
||||
}
|
||||
@@ -219,7 +219,7 @@ func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
router := srv.Router()
|
||||
|
||||
// Verify stale token from non-existent user is rejected (401)
|
||||
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old", 1)
|
||||
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old")
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -548,113 +548,113 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
|
||||
t.Fatalf("failed to get normal user: %v", err)
|
||||
}
|
||||
|
||||
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion)
|
||||
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion)
|
||||
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
// 1. Admin creates a list
|
||||
createListBody := []byte(`{"title":"Einkauf WG","icon":"🛒","listType":"shopping"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/lists", bytes.NewReader(createListBody))
|
||||
// 1. Admin creates a space
|
||||
createSpaceBody := []byte(`{"name":"Einkauf WG","icon":"🛒","description":"Gemeinsame Einkaufsliste"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/spaces", bytes.NewReader(createSpaceBody))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created from /api/v1/lists, got %d: %s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("expected 201 Created from /api/v1/spaces, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var createdList domain.SharedList
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &createdList)
|
||||
if createdList.ID == "" {
|
||||
t.Fatalf("expected created list with ID, got %+v", createdList)
|
||||
var createdSpace domain.Space
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &createdSpace)
|
||||
if createdSpace.ID == "" {
|
||||
t.Fatalf("expected created space with ID, got %+v", createdSpace)
|
||||
}
|
||||
|
||||
// 2. Admin shares list with normalUser by username "user"
|
||||
addShareBody := []byte(`{"targetType":"user","targetId":"user","role":"editor"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addShareBody))
|
||||
// 2. Admin invites normalUser by username "user"
|
||||
addMemberBody := []byte(`{"userId":"user","role":"editor"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addMemberBody))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created from add list share, got %d: %s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("expected 201 Created from add space member, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var newShare domain.ListShare
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &newShare)
|
||||
if newShare.TargetID != normalUser.ID {
|
||||
t.Fatalf("expected share targetId %s, got %s", normalUser.ID, newShare.TargetID)
|
||||
var newMember domain.SpaceMember
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &newMember)
|
||||
if newMember.UserID != normalUser.ID {
|
||||
t.Fatalf("expected member userId %s, got %s", normalUser.ID, newMember.UserID)
|
||||
}
|
||||
|
||||
// 3. User checks their lists; the shared list must be listed
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil)
|
||||
// 3. User checks their spaces; the shared space must be listed
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+userToken)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for user lists, got %d", rec.Code)
|
||||
t.Fatalf("expected 200 OK for user spaces, got %d", rec.Code)
|
||||
}
|
||||
var userLists []domain.SharedList
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userLists)
|
||||
var userSpaces []domain.Space
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userSpaces)
|
||||
found := false
|
||||
for _, l := range userLists {
|
||||
if l.ID == createdList.ID {
|
||||
for _, s := range userSpaces {
|
||||
if s.ID == createdSpace.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected list %s to be visible to user, got %+v", createdList.ID, userLists)
|
||||
t.Fatalf("expected space %s to be visible to user, got %+v", createdSpace.ID, userSpaces)
|
||||
}
|
||||
|
||||
// 4. Admin removes user share from list
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/lists/"+createdList.ID+"/shares/"+newShare.ID, nil)
|
||||
// 4. Admin removes user from space
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/spaces/"+createdSpace.ID+"/members/"+normalUser.ID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for remove share, got %d: %s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("expected 200 OK for remove member, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 5. Test sharing list with a team
|
||||
// 5. Test adding a team to a space
|
||||
team, err := store.CreateTeam("", "WG Team", "Haushalt")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create team: %v", err)
|
||||
}
|
||||
_ = store.AddTeamMember(team.ID, normalUser.ID, "member")
|
||||
|
||||
addTeamBody := []byte(`{"targetType":"team","targetId":"` + team.ID + `","role":"editor"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addTeamBody))
|
||||
addTeamBody := []byte(`{"teamId":"` + team.ID + `","role":"editor"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addTeamBody))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 201 or 200 for add list team share, got %d: %s", rec.Code, rec.Body.String())
|
||||
t.Fatalf("expected 201 or 200 for add space team, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// User should see the list again because their team was added
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil)
|
||||
// User should see the space again because their team was added
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+userToken)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for user lists after team share add, got %d", rec.Code)
|
||||
t.Fatalf("expected 200 OK for user spaces after team add, got %d", rec.Code)
|
||||
}
|
||||
userLists = nil
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userLists)
|
||||
userSpaces = nil
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userSpaces)
|
||||
found = false
|
||||
for _, l := range userLists {
|
||||
if l.ID == createdList.ID {
|
||||
for _, s := range userSpaces {
|
||||
if s.ID == createdSpace.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected list %s to be visible to user via team share, got %+v", createdList.ID, userLists)
|
||||
t.Fatalf("expected space %s to be visible to user via team, got %+v", createdSpace.ID, userSpaces)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -663,7 +663,7 @@ func TestWorkspaceTagsAPI(t *testing.T) {
|
||||
router := srv.Router()
|
||||
|
||||
user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member")
|
||||
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||
|
||||
// Create document with hashtags
|
||||
blocks := []domain.DocumentBlock{
|
||||
@@ -749,554 +749,3 @@ func TestWorkspaceTagsAPI(t *testing.T) {
|
||||
t.Fatalf("expected 200 OK from DELETE /api/v1/folders, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookTriageBadgeAndIngestion(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test_webhook.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTSecret: "test-jwt-secret-key",
|
||||
AllowRegistration: false,
|
||||
}
|
||||
srv := NewServer(cfg, store, ws.NewHub(), nil)
|
||||
router := srv.Router()
|
||||
|
||||
// 1. Setup user
|
||||
setupBody := []byte(`{
|
||||
"email": "smarthome_owner@example.com",
|
||||
"password": "secretpassword",
|
||||
"displayName": "Home User",
|
||||
"instanceName": "MindSift Home",
|
||||
"allowRegistration": false,
|
||||
"seedSampleData": false
|
||||
}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("setup failed: %s", rec.Body.String())
|
||||
}
|
||||
var authResp struct {
|
||||
Token string `json:"token"`
|
||||
User *domain.User `json:"user"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &authResp)
|
||||
|
||||
// 2. Create API key for user
|
||||
apiKey, err := store.CreateAPIKey(authResp.User.ID, "Home Assistant Webhook")
|
||||
if err != nil || apiKey == nil || apiKey.RawKey == "" {
|
||||
t.Fatalf("failed to create API key: %v", err)
|
||||
}
|
||||
plainKey := apiKey.RawKey
|
||||
|
||||
// 3. Post to /api/v1/inbox using X-API-Key without passing "source"
|
||||
inboxBody := []byte(`{
|
||||
"content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm",
|
||||
"priority": "urgent"
|
||||
}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(inboxBody))
|
||||
req.Header.Set("X-API-Key", plainKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created from /api/v1/inbox with API key, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var createdThought domain.Thought
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &createdThought)
|
||||
if createdThought.Source != "webhook" {
|
||||
t.Fatalf("expected created thought to have source='webhook' when called with API key, got '%s'", createdThought.Source)
|
||||
}
|
||||
|
||||
// 4. Post to /api/v1/webhooks/ingest using ?token=... with message alias
|
||||
webhookBody := []byte(`{
|
||||
"message": "Waschmaschine im Keller fertig",
|
||||
"priority": "high",
|
||||
"tags": ["automation"]
|
||||
}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/webhooks/ingest?token="+plainKey, bytes.NewReader(webhookBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created from /api/v1/webhooks/ingest, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var ingestResp struct {
|
||||
Status string `json:"status"`
|
||||
Thought domain.Thought `json:"thought"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &ingestResp)
|
||||
if ingestResp.Thought.Source != "webhook" {
|
||||
t.Fatalf("expected ingested webhook thought to have source='webhook', got '%s'", ingestResp.Thought.Source)
|
||||
}
|
||||
if !strings.Contains(ingestResp.Thought.Content, "Waschmaschine") {
|
||||
t.Fatalf("expected thought content to contain 'Waschmaschine', got '%s'", ingestResp.Thought.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSEGlobalEndpoint(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test_sse.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
hub := ws.NewHub()
|
||||
cfg := &config.Config{
|
||||
JWTSecret: "test-jwt-secret-key",
|
||||
AllowRegistration: false,
|
||||
}
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
router := srv.Router()
|
||||
|
||||
setupBody := []byte(`{
|
||||
"email": "sse_user@example.com",
|
||||
"password": "secretpassword",
|
||||
"displayName": "SSE User",
|
||||
"instanceName": "MindSift SSE",
|
||||
"allowRegistration": false,
|
||||
"seedSampleData": false
|
||||
}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("setup failed: %s", rec.Body.String())
|
||||
}
|
||||
var authResp struct {
|
||||
Token string `json:"token"`
|
||||
User *domain.User `json:"user"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &authResp)
|
||||
token := authResp.Token
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/events?token="+token, nil).WithContext(ctx)
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
contentType := rec.Header().Get("Content-Type")
|
||||
if !strings.Contains(contentType, "text/event-stream") {
|
||||
t.Fatalf("expected Content-Type text/event-stream, got %s", contentType)
|
||||
}
|
||||
bodyStr := rec.Body.String()
|
||||
if !strings.Contains(bodyStr, "SSE_CONNECTED") {
|
||||
t.Fatalf("expected SSE_CONNECTED event in body, got: %s", bodyStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecoupledFolderSharingAPI(t *testing.T) {
|
||||
srv, store, tokenA := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
|
||||
userB, err := store.CreateUser("user_b@example.com", "hash", "User B", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user B: %v", err)
|
||||
}
|
||||
tokenB, err := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token B: %v", err)
|
||||
}
|
||||
|
||||
// 1. User A creates a shared folder "TeamProjekt"
|
||||
shPayload := map[string]string{"name": "TeamProjekt", "mountPath": "TeamProjekt"}
|
||||
b, _ := json.Marshal(shPayload)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/folders/shared", bytes.NewReader(b))
|
||||
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create shared folder failed: %s", rec.Body.String())
|
||||
}
|
||||
var shFolder domain.SharedFolder
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &shFolder)
|
||||
|
||||
// 2. User A creates a document inside "TeamProjekt"
|
||||
docPayload := map[string]interface{}{
|
||||
"title": "Spezifikation",
|
||||
"folderPath": "TeamProjekt",
|
||||
"blocks": []domain.DocumentBlock{
|
||||
{ID: "blk_1", Type: "text", Content: "Projekt Details", Level: 0, Position: 1000},
|
||||
},
|
||||
}
|
||||
b, _ = json.Marshal(docPayload)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/documents", bytes.NewReader(b))
|
||||
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create doc failed: %s", rec.Body.String())
|
||||
}
|
||||
var createdDoc domain.Document
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &createdDoc)
|
||||
|
||||
// 3. User A shares the folder with User B (write permission)
|
||||
sharePayload := map[string]interface{}{
|
||||
"userId": userB.ID,
|
||||
"targetType": "user",
|
||||
"permission": "write",
|
||||
}
|
||||
b, _ = json.Marshal(sharePayload)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/workspace/folders/shared/"+shFolder.ID+"/shares", bytes.NewReader(b))
|
||||
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("share folder failed: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// 4. User B gets their documents list -> must see the document in "TeamProjekt"
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("get docs userB failed: %s", rec.Body.String())
|
||||
}
|
||||
var docsB []domain.Document
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &docsB)
|
||||
var foundDocB *domain.Document
|
||||
for i := range docsB {
|
||||
if docsB[i].ID == createdDoc.ID {
|
||||
foundDocB = &docsB[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if foundDocB == nil {
|
||||
t.Fatalf("User B did not see shared document!")
|
||||
}
|
||||
if foundDocB.FolderPath != "TeamProjekt" {
|
||||
t.Fatalf("expected User B initial folderPath 'TeamProjekt', got %q", foundDocB.FolderPath)
|
||||
}
|
||||
|
||||
// 5. User B moves their mount point to "MeineOrdner/TeamProjekt"
|
||||
movePayload := map[string]string{
|
||||
"sourcePath": "TeamProjekt",
|
||||
"targetParentPath": "MeineOrdner",
|
||||
}
|
||||
b, _ = json.Marshal(movePayload)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/folders/move", bytes.NewReader(b))
|
||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("User B move folder failed: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// 6. DECOUPLED CHECK:
|
||||
// User B's document path must now be "MeineOrdner/TeamProjekt"
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("User B get doc failed: %s", rec.Body.String())
|
||||
}
|
||||
var docBAfterMove domain.Document
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &docBAfterMove)
|
||||
if docBAfterMove.FolderPath != "MeineOrdner/TeamProjekt" {
|
||||
t.Fatalf("expected User B folderPath 'MeineOrdner/TeamProjekt', got %q", docBAfterMove.FolderPath)
|
||||
}
|
||||
|
||||
// User A's document path must STILL be "TeamProjekt" (completely decoupled!)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("User A get doc failed: %s", rec.Body.String())
|
||||
}
|
||||
var docAAfterMove domain.Document
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &docAAfterMove)
|
||||
if docAAfterMove.FolderPath != "TeamProjekt" {
|
||||
t.Fatalf("User A's folder hierarchy was altered! Expected 'TeamProjekt', got %q", docAAfterMove.FolderPath)
|
||||
}
|
||||
|
||||
// 7. Synchronous contents check:
|
||||
// User B (read-only recipient) cannot move documents within the shared folder:
|
||||
moveDocPayload := map[string]string{
|
||||
"folderPath": "MeineOrdner/TeamProjekt/Archiv",
|
||||
}
|
||||
b, _ = json.Marshal(moveDocPayload)
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+createdDoc.ID+"/move", bytes.NewReader(b))
|
||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("Expected User B move doc in read-only shared folder to fail, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// User A (the owner) moves the document into a subfolder: "TeamProjekt/Archiv"
|
||||
moveDocPayload = map[string]string{
|
||||
"folderPath": "TeamProjekt/Archiv",
|
||||
}
|
||||
b, _ = json.Marshal(moveDocPayload)
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+createdDoc.ID+"/move", bytes.NewReader(b))
|
||||
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("User A move doc into subfolder failed: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// User B must now see the document synchronized at "MeineOrdner/TeamProjekt/Archiv"!
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+createdDoc.ID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("User B get doc failed: %s", rec.Body.String())
|
||||
}
|
||||
var docBSynced domain.Document
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &docBSynced)
|
||||
if docBSynced.FolderPath != "MeineOrdner/TeamProjekt/Archiv" {
|
||||
t.Fatalf("expected User B to see subfolder sync at 'MeineOrdner/TeamProjekt/Archiv', got %q", docBSynced.FolderPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenBlacklistingAndTokenVersionRevocation(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
|
||||
// 1. Create a fresh user with known password
|
||||
password := "initialPassword123"
|
||||
hash, _ := auth.HashPassword(password)
|
||||
user, err := store.CreateUser("revocation_test@example.com", hash, "Revocation Tester", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create user: %v", err)
|
||||
}
|
||||
|
||||
// 2. Perform login to get initial JWT
|
||||
loginBody, _ := json.Marshal(map[string]string{
|
||||
"login": user.Email,
|
||||
"password": password,
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("login failed: %s", rec.Body.String())
|
||||
}
|
||||
var loginResp struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &loginResp)
|
||||
token1 := loginResp.Token
|
||||
if token1 == "" {
|
||||
t.Fatalf("expected token in login response")
|
||||
}
|
||||
|
||||
// 3. Verify token1 works
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token1)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("token1 should be valid, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 4. Test explicit logout (blacklists token1)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token1)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("logout failed: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Token1 must now be blacklisted and rejected (401)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token1)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 Unauthorized for blacklisted token1, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Also check authenticateUserFromReq rejects token1
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/ws?token="+token1, nil)
|
||||
if uid := srv.authenticateUserFromReq(req); uid != "" {
|
||||
t.Fatalf("authenticateUserFromReq should reject blacklisted token1, got uid %q", uid)
|
||||
}
|
||||
|
||||
// 5. Login again to get token2
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var loginResp2 struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &loginResp2)
|
||||
token2 := loginResp2.Token
|
||||
|
||||
// Token2 works
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token2)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("token2 should be valid, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 6. Change password via UpdateUserPassword -> increments token_version
|
||||
newPassword := "newSecretPassword456"
|
||||
newHash, _ := auth.HashPassword(newPassword)
|
||||
if err := store.UpdateUserPassword(user.ID, newHash); err != nil {
|
||||
t.Fatalf("failed to update password: %v", err)
|
||||
}
|
||||
|
||||
// Token2 must now be REJECTED (401) because its token_version is outdated!
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token2)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 Unauthorized for token2 after password change, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 7. Login with new password to get token3
|
||||
loginNewBody, _ := json.Marshal(map[string]string{
|
||||
"login": user.Email,
|
||||
"password": newPassword,
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginNewBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
var loginResp3 struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &loginResp3)
|
||||
token3 := loginResp3.Token
|
||||
|
||||
// Token3 works
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token3)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("token3 should be valid, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// 8. Test revoke-all endpoint -> increments token_version and invalidates all existing sessions
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/revoke-all", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token3)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("revoke-all failed: %s", rec.Body.String())
|
||||
}
|
||||
|
||||
// Token3 must now be REJECTED (401)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token3)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 Unauthorized for token3 after revoke-all, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentSharingStrictlyReadOnly(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
|
||||
userA, _ := store.CreateUser("user_a_ro@example.com", "hash", "User A", "member")
|
||||
tokenA, _ := auth.GenerateToken(srv.cfg.JWTSecret, userA.ID, userA.Email, userA.DisplayName, userA.TokenVersion)
|
||||
|
||||
userB, _ := store.CreateUser("user_b_ro@example.com", "hash", "User B", "member")
|
||||
tokenB, _ := auth.GenerateToken(srv.cfg.JWTSecret, userB.ID, userB.Email, userB.DisplayName, userB.TokenVersion)
|
||||
|
||||
// Create document owned by User A
|
||||
blocks := []domain.DocumentBlock{
|
||||
{ID: "b1", Type: "bullet", Content: "Row 1", Level: 0},
|
||||
{ID: "b2", Type: "bullet", Content: "Row 2", Level: 0},
|
||||
}
|
||||
doc, err := store.CreateDocument(userA.ID, "", "Read Only Test Doc", "📝", "", blocks, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
// Share with User B (forced read-only)
|
||||
_, err = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "read")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. User B can read document
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+doc.ID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("User B should be able to read doc, got status: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 2. User B tries to update document via PUT -> must fail (not owner)
|
||||
newTitle := "Hacked Title"
|
||||
updateBody, _ := json.Marshal(map[string]interface{}{"title": newTitle})
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(updateBody))
|
||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected error for User B updating document, got 200 OK")
|
||||
}
|
||||
|
||||
// 3. User B tries to patch document blocks -> must fail (not owner)
|
||||
patchBody, _ := json.Marshal(domain.DocBlocksPatchRequest{
|
||||
Upserted: []domain.DocumentBlock{{ID: "b2", Content: "Hacked by User B"}},
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+doc.ID+"/blocks", bytes.NewReader(patchBody))
|
||||
req.Header.Set("Authorization", "Bearer "+tokenB)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Fatalf("expected error for User B patching document blocks, got 200 OK")
|
||||
}
|
||||
|
||||
// 4. User A (owner) can successfully update document
|
||||
ownerTitle := "Owner Updated Title"
|
||||
ownerBody, _ := json.Marshal(map[string]interface{}{"title": ownerTitle})
|
||||
req = httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader(ownerBody))
|
||||
req.Header.Set("Authorization", "Bearer "+tokenA)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("owner should be able to update document, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -18,10 +18,9 @@ type contextKey string
|
||||
const UserIDContextKey contextKey = "mindsift_user_id"
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
TokenVersion int `json:"tokenVersion"`
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -35,24 +34,14 @@ func CheckPassword(password, hash string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func GenerateToken(secret, userID, email, displayName string, tokenVersion int) (string, error) {
|
||||
if tokenVersion <= 0 {
|
||||
tokenVersion = 1
|
||||
}
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
jti := hex.EncodeToString(b)
|
||||
|
||||
now := time.Now()
|
||||
func GenerateToken(secret, userID, email, displayName string) (string, error) {
|
||||
claims := &Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
TokenVersion: tokenVersion,
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: jti,
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "mindsift",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -15,7 +14,6 @@ type Config struct {
|
||||
BaseURL string
|
||||
AllowRegistration bool
|
||||
LogLevel string
|
||||
Debug bool
|
||||
DemoMode bool
|
||||
|
||||
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
|
||||
@@ -42,14 +40,7 @@ func Load() *Config {
|
||||
jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")
|
||||
baseURL := getEnv("BASE_URL", "http://localhost:"+port)
|
||||
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
|
||||
|
||||
debugVal := strings.ToLower(getEnv("DEBUG", "false"))
|
||||
debug := debugVal == "true" || debugVal == "1" || debugVal == "yes" || debugVal == "on"
|
||||
|
||||
logLevel := strings.ToLower(getEnv("LOG_LEVEL", "info"))
|
||||
if debug {
|
||||
logLevel = "debug"
|
||||
}
|
||||
logLevel := getEnv("LOG_LEVEL", "info")
|
||||
|
||||
demoVal := strings.ToLower(getEnv("MINDA_DEMO", getEnv("MINDSIFT_DEMO", getEnv("DEMO_MODE", "false"))))
|
||||
demoMode := demoVal == "true" || demoVal == "1" || demoVal == "yes" || demoVal == "on"
|
||||
@@ -69,7 +60,6 @@ func Load() *Config {
|
||||
BaseURL: baseURL,
|
||||
AllowRegistration: allowReg,
|
||||
LogLevel: logLevel,
|
||||
Debug: debug,
|
||||
DemoMode: demoMode,
|
||||
SMTPHost: smtpHost,
|
||||
SMTPPort: smtpPort,
|
||||
@@ -80,20 +70,6 @@ func Load() *Config {
|
||||
}
|
||||
}
|
||||
|
||||
// SlogLevel parses Config.LogLevel into a standard slog.Level.
|
||||
func (c *Config) SlogLevel() slog.Level {
|
||||
switch strings.ToLower(strings.TrimSpace(c.LogLevel)) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if val, ok := os.LookupEnv(key); ok && strings.TrimSpace(val) != "" {
|
||||
return strings.TrimSpace(val)
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigLogLevelAndDebug(t *testing.T) {
|
||||
os.Setenv("PORT", "9090")
|
||||
defer os.Unsetenv("PORT")
|
||||
|
||||
// 1. Default info
|
||||
os.Unsetenv("LOG_LEVEL")
|
||||
os.Unsetenv("DEBUG")
|
||||
cfg := Load()
|
||||
if cfg.SlogLevel() != slog.LevelInfo {
|
||||
t.Fatalf("expected LevelInfo, got %v", cfg.SlogLevel())
|
||||
}
|
||||
|
||||
// 2. LOG_LEVEL=debug
|
||||
os.Setenv("LOG_LEVEL", "debug")
|
||||
cfg = Load()
|
||||
if cfg.SlogLevel() != slog.LevelDebug {
|
||||
t.Fatalf("expected LevelDebug, got %v", cfg.SlogLevel())
|
||||
}
|
||||
|
||||
// 3. LOG_LEVEL=warn
|
||||
os.Setenv("LOG_LEVEL", "warn")
|
||||
cfg = Load()
|
||||
if cfg.SlogLevel() != slog.LevelWarn {
|
||||
t.Fatalf("expected LevelWarn, got %v", cfg.SlogLevel())
|
||||
}
|
||||
|
||||
// 4. LOG_LEVEL=error
|
||||
os.Setenv("LOG_LEVEL", "error")
|
||||
cfg = Load()
|
||||
if cfg.SlogLevel() != slog.LevelError {
|
||||
t.Fatalf("expected LevelError, got %v", cfg.SlogLevel())
|
||||
}
|
||||
|
||||
// 5. DEBUG=true overrides LOG_LEVEL
|
||||
os.Setenv("LOG_LEVEL", "error")
|
||||
os.Setenv("DEBUG", "true")
|
||||
cfg = Load()
|
||||
if cfg.SlogLevel() != slog.LevelDebug {
|
||||
t.Fatalf("expected LevelDebug when DEBUG=true, got %v", cfg.SlogLevel())
|
||||
}
|
||||
|
||||
// 6. DEBUG=1 overrides LOG_LEVEL
|
||||
os.Setenv("DEBUG", "1")
|
||||
cfg = Load()
|
||||
if cfg.SlogLevel() != slog.LevelDebug {
|
||||
t.Fatalf("expected LevelDebug when DEBUG=1, got %v", cfg.SlogLevel())
|
||||
}
|
||||
os.Unsetenv("DEBUG")
|
||||
os.Unsetenv("LOG_LEVEL")
|
||||
}
|
||||
@@ -98,10 +98,8 @@ type User struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
PasswordHash string `json:"-"`
|
||||
DisplayName string `json:"displayName"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Role string `json:"role"` // admin | member
|
||||
IsActive bool `json:"isActive"`
|
||||
TokenVersion int `json:"tokenVersion"`
|
||||
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
|
||||
AlarmEmail string `json:"alarmEmail"`
|
||||
DiscordWebhookURL string `json:"discordWebhookUrl"`
|
||||
@@ -148,34 +146,18 @@ type Thought struct {
|
||||
Source string `json:"source"` // manual | voice | webhook
|
||||
Status ThoughtStatus `json:"status"`
|
||||
IsCompleted bool `json:"isCompleted"`
|
||||
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item | document_block
|
||||
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item
|
||||
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
|
||||
DocID string `json:"docId,omitempty"`
|
||||
BlockID string `json:"blockId,omitempty"`
|
||||
DueDate string `json:"dueDate,omitempty"`
|
||||
TaskListID string `json:"taskListId,omitempty"`
|
||||
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Subtasks []SubTask `json:"subtasks"`
|
||||
AlarmSent bool `json:"alarmSent,omitempty"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
TriagedAt string `json:"triagedAt,omitempty"`
|
||||
}
|
||||
|
||||
type ThoughtReorderItem struct {
|
||||
ID string `json:"id"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
KanbanStatus *string `json:"kanbanStatus,omitempty"`
|
||||
}
|
||||
|
||||
type ListItemReorderItem struct {
|
||||
ID string `json:"id"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Category *string `json:"category,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
type TaskListShare struct {
|
||||
ID string `json:"id"`
|
||||
TaskListID string `json:"taskListId"`
|
||||
@@ -183,8 +165,6 @@ type TaskListShare struct {
|
||||
TargetID string `json:"targetId"`
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Permission string `json:"permission"` // editor | viewer
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
@@ -192,7 +172,6 @@ type TaskListMember struct {
|
||||
UserID string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Role string `json:"role"` // owner | member
|
||||
}
|
||||
|
||||
@@ -212,7 +191,6 @@ type TaskList struct {
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Permission string `json:"permission,omitempty"` // owner | editor | viewer
|
||||
Members []TaskListMember `json:"members,omitempty"`
|
||||
Shares []TaskListShare `json:"shares,omitempty"`
|
||||
Columns []KanbanColumn `json:"columns,omitempty"`
|
||||
@@ -246,7 +224,6 @@ type TeamMember struct {
|
||||
UserID string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Role string `json:"role"` // admin | member
|
||||
JoinedAt string `json:"joinedAt"`
|
||||
}
|
||||
@@ -265,67 +242,13 @@ type SystemSettings struct {
|
||||
}
|
||||
|
||||
type DocumentBlock struct {
|
||||
ID string `json:"id"`
|
||||
DocumentID string `json:"documentId,omitempty"`
|
||||
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress
|
||||
Content string `json:"content"`
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Level int `json:"level"`
|
||||
Position float64 `json:"position"`
|
||||
Collapsed bool `json:"collapsed,omitempty"`
|
||||
TaskID string `json:"taskId,omitempty"`
|
||||
}
|
||||
|
||||
func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
|
||||
var sb strings.Builder
|
||||
for _, b := range blocks {
|
||||
lvl := b.Level
|
||||
if lvl < 0 {
|
||||
lvl = 0
|
||||
}
|
||||
indent := strings.Repeat(" ", lvl)
|
||||
switch b.Type {
|
||||
case "h1":
|
||||
sb.WriteString(indent + "- # " + b.Content + "\n")
|
||||
case "h2":
|
||||
sb.WriteString(indent + "- ## " + b.Content + "\n")
|
||||
case "h3":
|
||||
sb.WriteString(indent + "- ### " + b.Content + "\n")
|
||||
case "todo":
|
||||
mark := "[ ]"
|
||||
if b.Checked {
|
||||
mark = "[x]"
|
||||
}
|
||||
sb.WriteString(indent + "- " + mark + " " + b.Content + "\n")
|
||||
case "code":
|
||||
sb.WriteString(indent + "- ```" + b.Lang + "\n" + b.Content + "\n" + indent + " ```\n")
|
||||
case "divider":
|
||||
sb.WriteString(indent + "- ---\n")
|
||||
default:
|
||||
sb.WriteString(indent + "- " + b.Content + "\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
type DocBlocksPatchRequest struct {
|
||||
Upserted []DocumentBlock `json:"upserted,omitempty"`
|
||||
DeletedIDs []string `json:"deletedIds,omitempty"`
|
||||
BlockOrder []string `json:"blockOrder,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
type DocBlocksPatchEvent struct {
|
||||
DocID string `json:"docId"`
|
||||
Upserted []DocumentBlock `json:"upserted,omitempty"`
|
||||
DeletedIDs []string `json:"deletedIds,omitempty"`
|
||||
BlockOrder []string `json:"blockOrder,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress
|
||||
Content string `json:"content"`
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Level int `json:"level,omitempty"`
|
||||
Collapsed bool `json:"collapsed,omitempty"`
|
||||
}
|
||||
|
||||
type DocShare struct {
|
||||
@@ -342,32 +265,33 @@ type DocShare struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type DocLockInfo struct {
|
||||
DocID string `json:"docId"`
|
||||
UserID string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
SpaceID string `json:"spaceId,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Content string `json:"content"`
|
||||
MarkdownCache string `json:"markdownCache,omitempty"`
|
||||
Blocks []DocumentBlock `json:"blocks"`
|
||||
Tags []string `json:"tags"`
|
||||
FolderID string `json:"folderId,omitempty"`
|
||||
FolderPath string `json:"folderPath,omitempty"`
|
||||
IsPinned bool `json:"isPinned"`
|
||||
IsArchived bool `json:"isArchived"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
SpaceID string `json:"spaceId,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Content string `json:"content"`
|
||||
Blocks []DocumentBlock `json:"blocks"`
|
||||
Tags []string `json:"tags"`
|
||||
FolderPath string `json:"folderPath,omitempty"`
|
||||
IsPinned bool `json:"isPinned"`
|
||||
IsArchived bool `json:"isArchived"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
|
||||
// Collaboration & Permissions
|
||||
Permission string `json:"permission"` // owner | write | read
|
||||
IsOwner bool `json:"isOwner"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
SharedWith []DocShare `json:"sharedWith,omitempty"`
|
||||
SharedFolderID string `json:"sharedFolderId,omitempty"`
|
||||
SubFolderPath string `json:"subFolderPath,omitempty"`
|
||||
IsSharedFolder bool `json:"isSharedFolder,omitempty"`
|
||||
FolderPermission string `json:"folderPermission,omitempty"`
|
||||
Permission string `json:"permission"` // owner | write | read
|
||||
IsOwner bool `json:"isOwner"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
SharedWith []DocShare `json:"sharedWith,omitempty"`
|
||||
}
|
||||
|
||||
type TagSummary struct {
|
||||
@@ -400,18 +324,40 @@ type TagDetails struct {
|
||||
TotalMatches int `json:"totalMatches"`
|
||||
}
|
||||
|
||||
type ListShare struct {
|
||||
type SpaceMember struct {
|
||||
SpaceID string `json:"spaceId"`
|
||||
UserID string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"` // owner | editor | viewer
|
||||
JoinedAt string `json:"joinedAt"`
|
||||
}
|
||||
|
||||
type SpaceShare struct {
|
||||
ID string `json:"id"`
|
||||
ListID string `json:"listId"`
|
||||
SpaceID string `json:"spaceId"`
|
||||
TargetType string `json:"targetType"` // team | user
|
||||
TargetID string `json:"targetId"`
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role string `json:"role"` // editor | viewer
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Space struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description"`
|
||||
InviteCode string `json:"inviteCode"`
|
||||
IsPersonal bool `json:"isPersonal"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Members []SpaceMember `json:"members"`
|
||||
Shares []SpaceShare `json:"shares,omitempty"`
|
||||
ListsCount int `json:"listsCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type ListItem struct {
|
||||
ID string `json:"id"`
|
||||
ListID string `json:"listId"`
|
||||
@@ -427,18 +373,14 @@ type ListItem struct {
|
||||
}
|
||||
|
||||
type SharedList struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Role string `json:"role,omitempty"` // editor | viewer
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
ListType string `json:"listType"` // shopping | checklist | vacation
|
||||
Items []ListItem `json:"items"`
|
||||
Shares []ListShare `json:"shares,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"spaceId"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
ListType string `json:"listType"` // shopping | checklist | vacation
|
||||
Items []ListItem `json:"items"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
@@ -455,7 +397,8 @@ type APIKey struct {
|
||||
|
||||
type WSMessage struct {
|
||||
Event string `json:"event"` // ITEM_CREATED | ITEM_UPDATED | ITEM_TOGGLED | ITEM_DELETED | LIST_CLEARED | USER_PRESENCE
|
||||
ListID string `json:"list_id"`
|
||||
SpaceID string `json:"space_id"`
|
||||
ListID string `json:"list_id,omitempty"`
|
||||
Payload interface{} `json:"payload"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
@@ -476,32 +419,7 @@ type FullExport struct {
|
||||
User User `json:"user"`
|
||||
Thoughts []Thought `json:"thoughts"`
|
||||
Documents []Document `json:"documents"`
|
||||
Spaces []Space `json:"spaces"`
|
||||
Lists []SharedList `json:"lists"`
|
||||
TaskLists []TaskList `json:"taskLists,omitempty"`
|
||||
}
|
||||
|
||||
type SharedFolder struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
Name string `json:"name"`
|
||||
MountPath string `json:"mountPath"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Permission string `json:"permission"` // owner | write | read
|
||||
Shares []FolderShare `json:"shares,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type FolderShare struct {
|
||||
ID string `json:"id"`
|
||||
FolderID string `json:"folderId"`
|
||||
TargetType string `json:"targetType"` // team | user
|
||||
TargetID string `json:"targetId"`
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Permission string `json:"permission"` // read | write
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -48,8 +48,12 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("expected converted document, got status=%s type=%s ref=%s", triaged.Status, triaged.ConvertedType, triaged.ConvertedRefID)
|
||||
}
|
||||
|
||||
// 3. Test Standalone Shopping List & Sharing
|
||||
lst, err := store.CreateList(user.ID, "REWE Einkauf", "🛒", "shopping")
|
||||
// 3. Test Shared Space & Shopping List
|
||||
sp, err := store.CreateSpace(user.ID, "Test WG", "🍕", "Test Space", false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSpace failed: %v", err)
|
||||
}
|
||||
lst, err := store.CreateList(sp.ID, "REWE Einkauf", "🛒", "shopping")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateList failed: %v", err)
|
||||
}
|
||||
@@ -62,26 +66,26 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("ToggleListItem failed: %v", err)
|
||||
}
|
||||
|
||||
// Test AddListShare & RemoveListShare
|
||||
// Test AddSpaceMember & RemoveSpaceMember
|
||||
u2, err := store.CreateUser("collab_space@example.com", "hash", "Collab User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser u2 failed: %v", err)
|
||||
}
|
||||
share, err := store.AddListShare(user.ID, lst.ID, "user", u2.ID, "editor")
|
||||
if err != nil || share.TargetID != u2.ID {
|
||||
t.Fatalf("AddListShare failed: %v", err)
|
||||
member, err := store.AddSpaceMember(user.ID, sp.ID, u2.ID, "editor")
|
||||
if err != nil || member.UserID != u2.ID {
|
||||
t.Fatalf("AddSpaceMember failed: %v", err)
|
||||
}
|
||||
shares, err := store.GetListShares(lst.ID)
|
||||
if err != nil || len(shares) != 1 {
|
||||
t.Fatalf("expected 1 share, got %d", len(shares))
|
||||
members, err := store.GetSpaceMembers(sp.ID)
|
||||
if err != nil || len(members) != 2 {
|
||||
t.Fatalf("expected 2 members, got %d", len(members))
|
||||
}
|
||||
err = store.RemoveListShare(user.ID, lst.ID, share.ID)
|
||||
err = store.RemoveSpaceMember(user.ID, sp.ID, u2.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveListShare failed: %v", err)
|
||||
t.Fatalf("RemoveSpaceMember failed: %v", err)
|
||||
}
|
||||
sharesAfter, _ := store.GetListShares(lst.ID)
|
||||
if len(sharesAfter) != 0 {
|
||||
t.Fatalf("expected 0 shares after remove, got %d", len(sharesAfter))
|
||||
membersAfter, _ := store.GetSpaceMembers(sp.ID)
|
||||
if len(membersAfter) != 1 {
|
||||
t.Fatalf("expected 1 member after remove, got %d", len(membersAfter))
|
||||
}
|
||||
|
||||
// 4. Test Webhook API Key generation & validation
|
||||
@@ -95,7 +99,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// 5. Test TaskLists CRUD
|
||||
taskList, err := store.CreateTaskList(user.ID, "", "", "Haus & Garten", "🏡", "#10b981")
|
||||
taskList, err := store.CreateTaskList(user.ID, sp.ID, "", "Haus & Garten", "🏡", "#10b981")
|
||||
if err != nil || taskList.ID == "" {
|
||||
t.Fatalf("CreateTaskList failed: %v", err)
|
||||
}
|
||||
@@ -119,7 +123,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test TaskList Sharing with Team
|
||||
shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, "editor", user.ID)
|
||||
shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, user.ID)
|
||||
if err != nil || shareTeam.ID == "" {
|
||||
t.Fatalf("AddTaskListShare team failed: %v", err)
|
||||
}
|
||||
@@ -148,45 +152,6 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser collab failed: %v", err)
|
||||
}
|
||||
|
||||
// Test read-only TaskList share
|
||||
ownerTask, err := store.CreateThoughtWithDetails(user.ID, "Rasen mähen", "normal", "manual", "", taskList.ID, "todo", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateThoughtWithDetails in own list failed: %v", err)
|
||||
}
|
||||
if _, err := store.UpdateThoughtTriage(user.ID, ownerTask.ID, "keep", "task", "", "", taskList.ID); err != nil {
|
||||
t.Fatalf("UpdateThoughtTriage in own list failed: %v", err)
|
||||
}
|
||||
if _, err := store.AddTaskListShare(taskList.ID, "user", user2.ID, "viewer", user.ID); err != nil {
|
||||
t.Fatalf("AddTaskListShare viewer failed: %v", err)
|
||||
}
|
||||
if perm := store.GetTaskListPermission(user2.ID, taskList.ID); perm != "viewer" {
|
||||
t.Fatalf("expected viewer permission, got %q", perm)
|
||||
}
|
||||
viewerTasks, err := store.GetTasks(user2.ID, taskList.ID)
|
||||
if err != nil || len(viewerTasks) == 0 {
|
||||
t.Fatalf("viewer should see tasks of shared list: %v", err)
|
||||
}
|
||||
done := true
|
||||
if _, err := store.UpdateThoughtDetails(user2.ID, ownerTask.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not update tasks, got %v", err)
|
||||
}
|
||||
if _, err := store.CreateThoughtWithDetails(user2.ID, "Hack", "normal", "manual", "", taskList.ID, "todo", ""); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not create tasks, got %v", err)
|
||||
}
|
||||
if err := store.DeleteThought(user2.ID, ownerTask.ID); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not delete tasks, got %v", err)
|
||||
}
|
||||
newTitle := "Umbenannt"
|
||||
if _, err := store.UpdateTaskList(user2.ID, taskList.ID, &newTitle, nil, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not update task list, got %v", err)
|
||||
}
|
||||
if _, err := store.AddTaskListShare(taskList.ID, "user", user2.ID, "editor", user.ID); err != nil {
|
||||
t.Fatalf("upgrading share to editor failed: %v", err)
|
||||
}
|
||||
if !store.CanEditTaskList(user2.ID, taskList.ID) {
|
||||
t.Fatalf("editor share should allow editing")
|
||||
}
|
||||
newDoc, err := store.CreateDocument(user.ID, "", "Test Note", "📄", "", []domain.DocumentBlock{
|
||||
{ID: "blk_1", Type: "bullet", Content: "Geheim", Level: 0},
|
||||
}, []string{"test"}, false)
|
||||
@@ -195,8 +160,8 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// Share with user2 as "read"
|
||||
docShare, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read")
|
||||
if err != nil || docShare.Permission != "read" {
|
||||
share, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read")
|
||||
if err != nil || share.Permission != "read" {
|
||||
t.Fatalf("CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
@@ -213,10 +178,16 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded")
|
||||
}
|
||||
|
||||
// user2 is a read-only collaborator -> editing must fail
|
||||
// Upgrade to write permission
|
||||
_, err = store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "write")
|
||||
if err != nil {
|
||||
t.Fatalf("Upgrade CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
// user2 edits writeable doc -> must succeed
|
||||
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected UpdateDocument to fail for read-only collaborator, but succeeded")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateDocument failed for write collaborator: %v", err)
|
||||
}
|
||||
|
||||
// Remove share
|
||||
@@ -365,6 +336,7 @@ func TestEnsureDemoAccountsAndLoginLookup(t *testing.T) {
|
||||
func TestFolderManagementAndDeletion(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "folder_test.db")
|
||||
docDir := filepath.Join(tmpDir, "workspace")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
@@ -377,7 +349,7 @@ func TestFolderManagementAndDeletion(t *testing.T) {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Create folders
|
||||
// 1. Create folders on disk
|
||||
f1, err := store.CreateFolder(user.ID, "Projekte/MindSift")
|
||||
if err != nil || f1 != "Projekte/MindSift" {
|
||||
t.Fatalf("CreateFolder failed: %v, f1=%s", err, f1)
|
||||
@@ -433,6 +405,12 @@ func TestFolderManagementAndDeletion(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("expected doc2 to be deleted")
|
||||
}
|
||||
|
||||
// Verify disk directory was deleted
|
||||
diskFolder := filepath.Join(docDir, user.ID, "Projekte/MindSift")
|
||||
if _, err := os.Stat(diskFolder); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected directory %s to be deleted on disk", diskFolder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) {
|
||||
@@ -538,242 +516,3 @@ func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) {
|
||||
t.Fatalf("expected vorrat and kühlregal, got %v", tagMapAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test_db_first.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
userA, err := store.EnsureDefaultUser()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureDefaultUser failed: %v", err)
|
||||
}
|
||||
userB, err := store.CreateUser("userb@example.com", "hash", "User B", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser userB failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Create a document in User A's folder "Privat" and share with User B (write)
|
||||
initialBlocks := []domain.DocumentBlock{
|
||||
{ID: "b1", Type: "bullet", Content: "Zeile 1 Original", Level: 0},
|
||||
{ID: "b2", Type: "bullet", Content: "Zeile 2 Original", Level: 0},
|
||||
}
|
||||
doc, err := store.CreateDocument(userA.ID, "", "Gemeinsame Notiz", "📝", "Privat", initialBlocks, []string{"folder:Privat", "notiz"}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
// Verify folder:* tag was stripped
|
||||
for _, tg := range doc.Tags {
|
||||
if tg == "folder:Privat" {
|
||||
t.Fatalf("expected folder:* tag to be stripped, got tags: %v", doc.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "write")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
// User B moves the individually shared note into "Arbeit/Eingang"
|
||||
_, err = store.MoveDocument(userB.ID, doc.ID, "Arbeit/Eingang")
|
||||
if err != nil {
|
||||
t.Fatalf("MoveDocument by userB failed: %v", err)
|
||||
}
|
||||
|
||||
// User B is a read-only collaborator -> PatchDocumentBlocks must fail
|
||||
_, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{
|
||||
Upserted: []domain.DocumentBlock{
|
||||
{ID: "b2", Type: "bullet", Content: "Zeile 2 von B bearbeitet", Level: 0},
|
||||
{ID: "b3", Type: "bullet", Content: "Zeile 3 neu von B", Level: 0},
|
||||
},
|
||||
BlockOrder: []string{"b1", "b2", "b3"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected PatchDocumentBlocks by read-only userB to fail, but succeeded")
|
||||
}
|
||||
|
||||
// User A's client only knew about b1, b2 when sending its patch (adds b4)
|
||||
patchedDocA, _, err := store.PatchDocumentBlocks(userA.ID, doc.ID, domain.DocBlocksPatchRequest{
|
||||
Upserted: []domain.DocumentBlock{
|
||||
{ID: "b1", Type: "bullet", Content: "Zeile 1 von A bearbeitet", Level: 0},
|
||||
{ID: "b4", Type: "bullet", Content: "Zeile 4 neu von A", Level: 0},
|
||||
},
|
||||
BlockOrder: []string{"b1", "b2", "b4"}, // stale order without b3!
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PatchDocumentBlocks by userA failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify User A's blocks are preserved: b1, b2, b4 (3 blocks)
|
||||
if len(patchedDocA.Blocks) != 3 {
|
||||
t.Fatalf("expected 3 blocks after patch, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks)
|
||||
}
|
||||
blockContent := make(map[string]string)
|
||||
for _, b := range patchedDocA.Blocks {
|
||||
blockContent[b.ID] = b.Content
|
||||
}
|
||||
if blockContent["b1"] != "Zeile 1 von A bearbeitet" {
|
||||
t.Fatalf("expected b1 updated by A, got %q", blockContent["b1"])
|
||||
}
|
||||
if blockContent["b4"] != "Zeile 4 neu von A" {
|
||||
t.Fatalf("expected b4 added by A, got %q", blockContent["b4"])
|
||||
}
|
||||
|
||||
// Verify folder independence: User A still sees "Privat", User B still sees "Arbeit/Eingang"
|
||||
docForA, err := store.GetDocumentByID(userA.ID, doc.ID)
|
||||
if err != nil || docForA.FolderPath != "Privat" {
|
||||
t.Fatalf("expected User A FolderPath 'Privat', got %q (err=%v)", docForA.FolderPath, err)
|
||||
}
|
||||
docForB, err := store.GetDocumentByID(userB.ID, doc.ID)
|
||||
if err != nil || docForB.FolderPath != "Arbeit/Eingang" {
|
||||
t.Fatalf("expected User B FolderPath 'Arbeit/Eingang', got %q (err=%v)", docForB.FolderPath, err)
|
||||
}
|
||||
|
||||
foldersA, err := store.GetFolders(userA.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFolders for userA failed: %v", err)
|
||||
}
|
||||
for _, f := range foldersA {
|
||||
if f == "Arbeit" || f == "Arbeit/Eingang" {
|
||||
t.Fatalf("User B's private folder leaked into User A's folder list: %v", foldersA)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Shared Folder & Empty Subfolder Sync
|
||||
_, err = store.CreateFolder(userA.ID, "TeamProjekt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFolder TeamProjekt failed: %v", err)
|
||||
}
|
||||
sf, err := store.CreateSharedFolder(userA.ID, "TeamProjekt", "TeamProjekt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSharedFolder failed: %v", err)
|
||||
}
|
||||
_, err = store.CreateFolderShare(userA.ID, sf.ID, "user", userB.ID, "write")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFolderShare failed: %v", err)
|
||||
}
|
||||
|
||||
// User B moves their mount root of the shared folder to "ExterneProjekte/TeamProjekt"
|
||||
err = store.UpdateFolderMount(userB.ID, sf.ID, "ExterneProjekte/TeamProjekt")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateFolderMount failed: %v", err)
|
||||
}
|
||||
|
||||
// User A creates an EMPTY subfolder inside "TeamProjekt/Konzepte"
|
||||
_, err = store.CreateFolder(userA.ID, "TeamProjekt/Konzepte")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFolder TeamProjekt/Konzepte failed: %v", err)
|
||||
}
|
||||
|
||||
// User B must immediately see "ExterneProjekte/TeamProjekt/Konzepte" in GetFolders without any document inside!
|
||||
foldersB, err := store.GetFolders(userB.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFolders for userB failed: %v", err)
|
||||
}
|
||||
foundSubfolderForB := false
|
||||
for _, f := range foldersB {
|
||||
if f == "ExterneProjekte/TeamProjekt/Konzepte" {
|
||||
foundSubfolderForB = true
|
||||
}
|
||||
}
|
||||
if !foundSubfolderForB {
|
||||
t.Fatalf("expected User B to see empty shared subfolder 'ExterneProjekte/TeamProjekt/Konzepte', got folders: %v", foldersB)
|
||||
}
|
||||
|
||||
// 3. MoveFolder inside a shared folder
|
||||
newPath, _, err := store.MoveFolder(userA.ID, "TeamProjekt/Konzepte", "TeamProjekt/Archiv")
|
||||
if err != nil {
|
||||
t.Fatalf("MoveFolder failed: %v", err)
|
||||
}
|
||||
if newPath != "TeamProjekt/Archiv/Konzepte" {
|
||||
t.Fatalf("expected newPath 'TeamProjekt/Archiv/Konzepte', got %q", newPath)
|
||||
}
|
||||
|
||||
// User B must now see "ExterneProjekte/TeamProjekt/Archiv/Konzepte"
|
||||
foldersBAfterMove, err := store.GetFolders(userB.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFolders after MoveFolder failed: %v", err)
|
||||
}
|
||||
foundMovedForB := false
|
||||
for _, f := range foldersBAfterMove {
|
||||
if f == "ExterneProjekte/TeamProjekt/Archiv/Konzepte" {
|
||||
foundMovedForB = true
|
||||
}
|
||||
}
|
||||
if !foundMovedForB {
|
||||
t.Fatalf("expected User B to see moved shared subfolder 'ExterneProjekte/TeamProjekt/Archiv/Konzepte', got %v", foldersBAfterMove)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushDocumentStateAndSchema(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "tabula_rasa_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
user, err := store.CreateUser("outliner@example.com", "hash", "Outliner User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
|
||||
doc, err := store.CreateDocument(user.ID, "", "Initial Note", "📝", "Projekte", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
blocks := []domain.DocumentBlock{
|
||||
{
|
||||
ID: "blk_root_1",
|
||||
Type: "text",
|
||||
Content: "Architektur Übersicht #backend #go",
|
||||
Level: 0,
|
||||
},
|
||||
{
|
||||
ID: "blk_child_1",
|
||||
Type: "todo",
|
||||
Content: "SQLite WAL Modus aktivieren #sqlite",
|
||||
Level: 1,
|
||||
Checked: true,
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.FlushDocumentState(doc.ID, "Architektur Notiz", blocks); err != nil {
|
||||
t.Fatalf("FlushDocumentState failed: %v", err)
|
||||
}
|
||||
|
||||
updatedDoc, err := store.GetDocumentByID(user.ID, doc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocumentByID failed: %v", err)
|
||||
}
|
||||
if updatedDoc.Title != "Architektur Notiz" {
|
||||
t.Fatalf("expected title 'Architektur Notiz', got '%s'", updatedDoc.Title)
|
||||
}
|
||||
if len(updatedDoc.Blocks) != 2 {
|
||||
t.Fatalf("expected 2 blocks, got %d", len(updatedDoc.Blocks))
|
||||
}
|
||||
if updatedDoc.Blocks[1].Level != 1 || updatedDoc.Blocks[1].Position != 2000 {
|
||||
t.Fatalf("expected block[1] level=1 position=2000, got level=%d position=%v", updatedDoc.Blocks[1].Level, updatedDoc.Blocks[1].Position)
|
||||
}
|
||||
if updatedDoc.MarkdownCache == "" {
|
||||
t.Fatalf("expected markdown_cache to be populated")
|
||||
}
|
||||
|
||||
tags, err := store.GetWorkspaceTags(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkspaceTags failed: %v", err)
|
||||
}
|
||||
if len(tags) != 3 {
|
||||
t.Fatalf("expected 3 extracted tags in block_tags (#backend, #go, #sqlite), got %d: %+v", len(tags), tags)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,8 @@ package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -14,106 +12,56 @@ import (
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
ReadBufferSize: 2048,
|
||||
WriteBufferSize: 2048,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Self-hosted reverse proxy compatible
|
||||
},
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
roomID string
|
||||
userID string
|
||||
send chan []byte
|
||||
subscribedRooms map[string]bool
|
||||
sendMu sync.RWMutex
|
||||
closed bool
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
spaceID string
|
||||
userID string
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
func (c *Client) closeSend() {
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
if !c.closed {
|
||||
c.closed = true
|
||||
close(c.send)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) trySend(data []byte) {
|
||||
c.sendMu.RLock()
|
||||
defer c.sendMu.RUnlock()
|
||||
if c.closed {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
type SSEClient struct {
|
||||
id string
|
||||
userID string
|
||||
roomID string
|
||||
send chan []byte
|
||||
subscribedRooms map[string]bool
|
||||
sendMu sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *SSEClient) closeSend() {
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
if !c.closed {
|
||||
c.closed = true
|
||||
close(c.send)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SSEClient) trySend(data []byte) {
|
||||
c.sendMu.RLock()
|
||||
defer c.sendMu.RUnlock()
|
||||
if c.closed {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.send <- data:
|
||||
default:
|
||||
}
|
||||
type docLockItem struct {
|
||||
docID string
|
||||
userID string
|
||||
userName string
|
||||
lockedAt time.Time
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
rooms map[string]map[*Client]bool
|
||||
userRooms map[string]map[*Client]bool
|
||||
allClients map[*Client]bool
|
||||
sseClients map[*SSEClient]bool
|
||||
sseUserRooms map[string]map[*SSEClient]bool
|
||||
sseRooms map[string]map[*SSEClient]bool
|
||||
closed bool
|
||||
mu sync.RWMutex
|
||||
spaceRooms map[string]map[*Client]bool
|
||||
userRooms map[string]map[*Client]bool
|
||||
allClients map[*Client]bool
|
||||
locks map[string]*docLockItem
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
rooms: make(map[string]map[*Client]bool),
|
||||
userRooms: make(map[string]map[*Client]bool),
|
||||
allClients: make(map[*Client]bool),
|
||||
sseClients: make(map[*SSEClient]bool),
|
||||
sseUserRooms: make(map[string]map[*SSEClient]bool),
|
||||
sseRooms: make(map[string]map[*SSEClient]bool),
|
||||
h := &Hub{
|
||||
spaceRooms: make(map[string]map[*Client]bool),
|
||||
userRooms: make(map[string]map[*Client]bool),
|
||||
allClients: make(map[*Client]bool),
|
||||
locks: make(map[string]*docLockItem),
|
||||
}
|
||||
go h.cleanupLocksLoop()
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Hub) Register(c *Client) {
|
||||
h.mu.Lock()
|
||||
h.allClients[c] = true
|
||||
if c.roomID != "" {
|
||||
if h.rooms[c.roomID] == nil {
|
||||
h.rooms[c.roomID] = make(map[*Client]bool)
|
||||
if c.spaceID != "" {
|
||||
if h.spaceRooms[c.spaceID] == nil {
|
||||
h.spaceRooms[c.spaceID] = make(map[*Client]bool)
|
||||
}
|
||||
h.rooms[c.roomID][c] = true
|
||||
h.spaceRooms[c.spaceID][c] = true
|
||||
}
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if h.userRooms[c.userID] == nil {
|
||||
@@ -121,11 +69,11 @@ func (h *Hub) Register(c *Client) {
|
||||
}
|
||||
h.userRooms[c.userID][c] = true
|
||||
}
|
||||
onlineCount := len(h.allClients) + len(h.sseClients)
|
||||
onlineCount := len(h.allClients)
|
||||
h.mu.Unlock()
|
||||
|
||||
if c.roomID != "" {
|
||||
h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{
|
||||
if c.spaceID != "" {
|
||||
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{
|
||||
"onlineCount": onlineCount,
|
||||
})
|
||||
}
|
||||
@@ -134,172 +82,159 @@ func (h *Hub) Register(c *Client) {
|
||||
func (h *Hub) Unregister(c *Client) {
|
||||
h.mu.Lock()
|
||||
delete(h.allClients, c)
|
||||
if c.roomID != "" {
|
||||
if room, ok := h.rooms[c.roomID]; ok {
|
||||
if c.spaceID != "" {
|
||||
if room, ok := h.spaceRooms[c.spaceID]; ok {
|
||||
delete(room, c)
|
||||
if len(room) == 0 {
|
||||
delete(h.rooms, c.roomID)
|
||||
delete(h.spaceRooms, c.spaceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if c.subscribedRooms != nil {
|
||||
for rID := range c.subscribedRooms {
|
||||
if room, ok := h.rooms[rID]; ok {
|
||||
delete(room, c)
|
||||
if len(room) == 0 {
|
||||
delete(h.rooms, rID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userRemaining := 0
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if uRoom, ok := h.userRooms[c.userID]; ok {
|
||||
delete(uRoom, c)
|
||||
if len(uRoom) == 0 {
|
||||
userRemaining = len(uRoom)
|
||||
if userRemaining == 0 {
|
||||
delete(h.userRooms, c.userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.closeSend()
|
||||
h.mu.Unlock()
|
||||
}
|
||||
close(c.send)
|
||||
|
||||
func (h *Hub) RegisterSSE(c *SSEClient) {
|
||||
h.mu.Lock()
|
||||
h.sseClients[c] = true
|
||||
if c.roomID != "" {
|
||||
if h.sseRooms[c.roomID] == nil {
|
||||
h.sseRooms[c.roomID] = make(map[*SSEClient]bool)
|
||||
}
|
||||
h.sseRooms[c.roomID][c] = true
|
||||
}
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if h.sseUserRooms[c.userID] == nil {
|
||||
h.sseUserRooms[c.userID] = make(map[*SSEClient]bool)
|
||||
}
|
||||
h.sseUserRooms[c.userID][c] = true
|
||||
}
|
||||
if c.subscribedRooms != nil {
|
||||
for rID := range c.subscribedRooms {
|
||||
if h.sseRooms[rID] == nil {
|
||||
h.sseRooms[rID] = make(map[*SSEClient]bool)
|
||||
// If this user has no other active client connections, release their document locks
|
||||
var releasedDocIDs []string
|
||||
if userRemaining == 0 && c.userID != "" {
|
||||
now := time.Now()
|
||||
for docID, l := range h.locks {
|
||||
if l.userID == c.userID || l.expiresAt.Before(now) {
|
||||
releasedDocIDs = append(releasedDocIDs, docID)
|
||||
delete(h.locks, docID)
|
||||
}
|
||||
h.sseRooms[rID][c] = true
|
||||
}
|
||||
}
|
||||
onlineCount := len(h.allClients) + len(h.sseClients)
|
||||
h.mu.Unlock()
|
||||
|
||||
if c.roomID != "" {
|
||||
h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{
|
||||
"onlineCount": onlineCount,
|
||||
})
|
||||
// Broadcast unlock events for released locks
|
||||
for _, docID := range releasedDocIDs {
|
||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) UnregisterSSE(c *SSEClient) {
|
||||
h.mu.Lock()
|
||||
delete(h.sseClients, c)
|
||||
if c.roomID != "" {
|
||||
if room, ok := h.sseRooms[c.roomID]; ok {
|
||||
delete(room, c)
|
||||
if len(room) == 0 {
|
||||
delete(h.sseRooms, c.roomID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.subscribedRooms != nil {
|
||||
for rID := range c.subscribedRooms {
|
||||
if room, ok := h.sseRooms[rID]; ok {
|
||||
delete(room, c)
|
||||
if len(room) == 0 {
|
||||
delete(h.sseRooms, rID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if uRoom, ok := h.sseUserRooms[c.userID]; ok {
|
||||
delete(uRoom, c)
|
||||
if len(uRoom) == 0 {
|
||||
delete(h.sseUserRooms, c.userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.closeSend()
|
||||
h.mu.Unlock()
|
||||
}
|
||||
// Document Locking Methods
|
||||
|
||||
func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool {
|
||||
func (h *Hub) AcquireDocLock(docID, userID, userName string, ttl time.Duration) (*domain.DocLockInfo, bool, *domain.DocLockInfo) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for client := range h.sseClients {
|
||||
if client.id == sseID {
|
||||
if h.sseRooms[roomID] == nil {
|
||||
h.sseRooms[roomID] = make(map[*SSEClient]bool)
|
||||
|
||||
now := time.Now()
|
||||
existing, exists := h.locks[docID]
|
||||
if exists && existing.expiresAt.After(now) {
|
||||
if existing.userID != userID {
|
||||
// Locked by another user
|
||||
info := &domain.DocLockInfo{
|
||||
DocID: existing.docID,
|
||||
UserID: existing.userID,
|
||||
UserName: existing.userName,
|
||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
h.sseRooms[roomID][client] = true
|
||||
if client.subscribedRooms == nil {
|
||||
client.subscribedRooms = make(map[string]bool)
|
||||
}
|
||||
client.subscribedRooms[roomID] = true
|
||||
return true
|
||||
return nil, false, info
|
||||
}
|
||||
// Same user extending lock
|
||||
existing.expiresAt = now.Add(ttl)
|
||||
if userName != "" {
|
||||
existing.userName = userName
|
||||
}
|
||||
info := &domain.DocLockInfo{
|
||||
DocID: existing.docID,
|
||||
UserID: existing.userID,
|
||||
UserName: existing.userName,
|
||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
// Create fresh lock
|
||||
item := &docLockItem{
|
||||
docID: docID,
|
||||
userID: userID,
|
||||
userName: userName,
|
||||
lockedAt: now,
|
||||
expiresAt: now.Add(ttl),
|
||||
}
|
||||
h.locks[docID] = item
|
||||
info := &domain.DocLockInfo{
|
||||
DocID: item.docID,
|
||||
UserID: item.userID,
|
||||
UserName: item.userName,
|
||||
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
func (h *Hub) HeartbeatDocLock(docID, userID string, ttl time.Duration) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
item, ok := h.locks[docID]
|
||||
if !ok || item.userID != userID || item.expiresAt.Before(now) {
|
||||
return false
|
||||
}
|
||||
item.expiresAt = now.Add(ttl)
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Hub) ReleaseDocLock(docID, userID string, isAdmin bool) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
item, ok := h.locks[docID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if item.userID == userID || isAdmin || userID == "admin" {
|
||||
delete(h.locks, docID)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Hub) LeaveSSERoomByID(sseID, roomID string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for client := range h.sseClients {
|
||||
if client.id == sseID {
|
||||
if room, ok := h.sseRooms[roomID]; ok {
|
||||
delete(room, client)
|
||||
if len(room) == 0 {
|
||||
delete(h.sseRooms, roomID)
|
||||
}
|
||||
}
|
||||
if client.subscribedRooms != nil {
|
||||
delete(client.subscribedRooms, roomID)
|
||||
}
|
||||
return true
|
||||
}
|
||||
func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
item, ok := h.locks[docID]
|
||||
if !ok || item.expiresAt.Before(time.Now()) {
|
||||
return nil
|
||||
}
|
||||
return &domain.DocLockInfo{
|
||||
DocID: item.docID,
|
||||
UserID: item.userID,
|
||||
UserName: item.userName,
|
||||
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Hub) cleanupLocksLoop() {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
|
||||
// Close gracefully disconnects all WebSocket and SSE connections so HTTP shutdown completes immediately.
|
||||
func (h *Hub) Close() {
|
||||
h.mu.Lock()
|
||||
if h.closed {
|
||||
for range ticker.C {
|
||||
h.mu.Lock()
|
||||
now := time.Now()
|
||||
var expiredDocIDs []string
|
||||
for docID, l := range h.locks {
|
||||
if l.expiresAt.Before(now) {
|
||||
expiredDocIDs = append(expiredDocIDs, docID)
|
||||
delete(h.locks, docID)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
h.closed = true
|
||||
|
||||
allWsClients := make([]*Client, 0, len(h.allClients))
|
||||
for c := range h.allClients {
|
||||
allWsClients = append(allWsClients, c)
|
||||
}
|
||||
|
||||
allSSEClients := make([]*SSEClient, 0, len(h.sseClients))
|
||||
for c := range h.sseClients {
|
||||
allSSEClients = append(allSSEClients, c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, c := range allWsClients {
|
||||
_ = c.conn.Close()
|
||||
c.closeSend()
|
||||
}
|
||||
|
||||
for _, c := range allSSEClients {
|
||||
c.closeSend()
|
||||
for _, docID := range expiredDocIDs {
|
||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,17 +252,13 @@ func (h *Hub) BroadcastToAll(event string, payload interface{}) {
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
if h.closed {
|
||||
h.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
for client := range h.allClients {
|
||||
client.trySend(data)
|
||||
}
|
||||
for sseClient := range h.sseClients {
|
||||
sseClient.trySend(data)
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,20 +274,14 @@ func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) {
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
if h.closed {
|
||||
h.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
if room, ok := h.userRooms[userID]; ok {
|
||||
for client := range room {
|
||||
client.trySend(data)
|
||||
}
|
||||
}
|
||||
if sseRoom, ok := h.sseUserRooms[userID]; ok {
|
||||
for sseClient := range sseRoom {
|
||||
sseClient.trySend(data)
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -366,16 +291,13 @@ func (h *Hub) BroadcastTaskEvent(userID, event string, payload interface{}) {
|
||||
}
|
||||
|
||||
func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
|
||||
if docID != "" {
|
||||
h.BroadcastRoom("doc:"+docID, event, payload, nil)
|
||||
} else {
|
||||
h.BroadcastToAll(event, payload)
|
||||
}
|
||||
h.BroadcastToAll(event, payload)
|
||||
}
|
||||
|
||||
func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||
msg := domain.WSMessage{
|
||||
Event: event,
|
||||
SpaceID: spaceID,
|
||||
ListID: listID,
|
||||
Payload: payload,
|
||||
Timestamp: domain.NowISO(),
|
||||
@@ -386,116 +308,32 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
if h.closed {
|
||||
h.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
targetRoom := listID
|
||||
if targetRoom == "" {
|
||||
targetRoom = spaceID
|
||||
}
|
||||
|
||||
if room, ok := h.rooms[targetRoom]; ok {
|
||||
for client := range room {
|
||||
client.trySend(data)
|
||||
}
|
||||
}
|
||||
if sseRoom, ok := h.sseRooms[targetRoom]; ok {
|
||||
for sseClient := range sseRoom {
|
||||
sseClient.trySend(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) BroadcastListEvent(listID, event string, payload interface{}) {
|
||||
h.Broadcast("", listID, event, payload)
|
||||
}
|
||||
|
||||
func (h *Hub) JoinRoom(c *Client, roomID string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.rooms[roomID] == nil {
|
||||
h.rooms[roomID] = make(map[*Client]bool)
|
||||
}
|
||||
h.rooms[roomID][c] = true
|
||||
if c.subscribedRooms == nil {
|
||||
c.subscribedRooms = make(map[string]bool)
|
||||
}
|
||||
c.subscribedRooms[roomID] = true
|
||||
}
|
||||
|
||||
func (h *Hub) LeaveRoom(c *Client, roomID string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if room, ok := h.rooms[roomID]; ok {
|
||||
delete(room, c)
|
||||
if len(room) == 0 {
|
||||
delete(h.rooms, roomID)
|
||||
}
|
||||
}
|
||||
if c.subscribedRooms != nil {
|
||||
delete(c.subscribedRooms, roomID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) BroadcastRoom(roomID, event string, payload interface{}, excludeClient *Client) {
|
||||
msg := domain.WSMessage{
|
||||
Event: event,
|
||||
Payload: payload,
|
||||
Timestamp: domain.NowISO(),
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
room, ok := h.spaceRooms[spaceID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
if h.closed {
|
||||
h.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
if room, ok := h.rooms[roomID]; ok {
|
||||
for client := range room {
|
||||
if excludeClient != nil && client == excludeClient {
|
||||
continue
|
||||
}
|
||||
client.trySend(data)
|
||||
}
|
||||
}
|
||||
if sseRoom, ok := h.sseRooms[roomID]; ok {
|
||||
for sseClient := range sseRoom {
|
||||
sseClient.trySend(data)
|
||||
for client := range room {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
// Slow client buffer full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SendEvent(event string, payload interface{}) {
|
||||
msg := domain.WSMessage{
|
||||
Event: event,
|
||||
Payload: payload,
|
||||
Timestamp: domain.NowISO(),
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err == nil {
|
||||
c.trySend(data)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, roomID, userID string) {
|
||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID string) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
slog.Error("WebSocket upgrade error", "room_id", roomID, "user_id", userID, "error", err)
|
||||
log.Printf("[ws] upgrade error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
roomID: roomID,
|
||||
spaceID: spaceID,
|
||||
userID: userID,
|
||||
send: make(chan []byte, 128),
|
||||
}
|
||||
@@ -505,80 +343,13 @@ func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, roomID, userID str
|
||||
go client.readPump()
|
||||
}
|
||||
|
||||
func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request, roomID, userID string, initialRooms []string) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
client := &SSEClient{
|
||||
id: domain.NewID("sse"),
|
||||
userID: userID,
|
||||
roomID: roomID,
|
||||
send: make(chan []byte, 128),
|
||||
subscribedRooms: make(map[string]bool),
|
||||
}
|
||||
|
||||
for _, room := range initialRooms {
|
||||
if strings.TrimSpace(room) != "" {
|
||||
client.subscribedRooms[strings.TrimSpace(room)] = true
|
||||
}
|
||||
}
|
||||
|
||||
h.RegisterSSE(client)
|
||||
defer h.UnregisterSSE(client)
|
||||
|
||||
// Send initial connected comment and event
|
||||
fmt.Fprintf(w, ": connected (transport: sse, id: %s)\n\n", client.id)
|
||||
connectedMsg, _ := json.Marshal(domain.WSMessage{
|
||||
Event: "SSE_CONNECTED",
|
||||
Payload: map[string]interface{}{
|
||||
"sseId": client.id,
|
||||
"transport": "sse",
|
||||
"userId": userID,
|
||||
},
|
||||
Timestamp: domain.NowISO(),
|
||||
})
|
||||
fmt.Fprintf(w, "data: %s\n\n", connectedMsg)
|
||||
flusher.Flush()
|
||||
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
notify := r.Context().Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-notify:
|
||||
return
|
||||
case msg, ok := <-client.send:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", msg)
|
||||
flusher.Flush()
|
||||
case <-ticker.C:
|
||||
// Periodic ping comment to keep HTTP connection alive and prevent proxy timeouts
|
||||
fmt.Fprintf(w, ": ping\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
c.hub.Unregister(c)
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
|
||||
c.conn.SetReadLimit(4 * 1024 * 1024) // 4MB for large document CRDT updates
|
||||
c.conn.SetReadLimit(16384)
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
@@ -591,11 +362,10 @@ func (c *Client) readPump() {
|
||||
break
|
||||
}
|
||||
|
||||
// Handle client-initiated lock, CRDT, room subscription, and presence messages
|
||||
// Handle client-initiated lock and unlock messages
|
||||
var incoming struct {
|
||||
Event string `json:"event"`
|
||||
DocID string `json:"docId"`
|
||||
Room string `json:"room"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
if json.Unmarshal(message, &incoming) == nil && c.userID != "" && c.userID != "anonymous" {
|
||||
@@ -608,22 +378,18 @@ func (c *Client) readPump() {
|
||||
docID = p.DocID
|
||||
}
|
||||
|
||||
// Room subscription without requiring docID
|
||||
if incoming.Event == "JOIN_ROOM" {
|
||||
room := incoming.Room
|
||||
if room == "" {
|
||||
room = docID
|
||||
}
|
||||
if room != "" {
|
||||
c.hub.JoinRoom(c, room)
|
||||
}
|
||||
} else if incoming.Event == "LEAVE_ROOM" {
|
||||
room := incoming.Room
|
||||
if room == "" {
|
||||
room = docID
|
||||
}
|
||||
if room != "" {
|
||||
c.hub.LeaveRoom(c, room)
|
||||
if docID != "" {
|
||||
switch incoming.Event {
|
||||
case "LOCK_DOC":
|
||||
if info, ok, _ := c.hub.AcquireDocLock(docID, c.userID, "", 30*time.Second); ok && info != nil {
|
||||
c.hub.BroadcastDocEvent(docID, "DOC_LOCKED", info)
|
||||
}
|
||||
case "HEARTBEAT_DOC":
|
||||
c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second)
|
||||
case "UNLOCK_DOC":
|
||||
if c.hub.ReleaseDocLock(docID, c.userID, false) {
|
||||
c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHubCloseAndGracefulShutdown(t *testing.T) {
|
||||
hub := NewHub()
|
||||
|
||||
// Register multiple dummy SSE clients
|
||||
var sseClients []*SSEClient
|
||||
for i := 0; i < 5; i++ {
|
||||
client := &SSEClient{
|
||||
id: "sse-test",
|
||||
userID: "user-1",
|
||||
send: make(chan []byte, 10),
|
||||
}
|
||||
hub.RegisterSSE(client)
|
||||
sseClients = append(sseClients, client)
|
||||
}
|
||||
|
||||
// Verify broadcast works
|
||||
hub.BroadcastToAll("TEST_EVENT", map[string]string{"msg": "hello"})
|
||||
|
||||
// Concurrently invoke Close() and Broadcast() to verify no panics or deadlocks
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 50; i++ {
|
||||
hub.BroadcastToAll("SPAM", map[string]int{"i": i})
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
hub.Close()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Verify that all SSE client channels are closed
|
||||
for _, c := range sseClients {
|
||||
select {
|
||||
case _, ok := <-c.send:
|
||||
if ok {
|
||||
// Drained a message, check next
|
||||
_, ok = <-c.send
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ CREATE TABLE IF NOT EXISTS thoughts (
|
||||
converted_type TEXT DEFAULT '',
|
||||
converted_ref_id TEXT DEFAULT '',
|
||||
due_date TEXT DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
triaged_at TEXT DEFAULT '',
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
@@ -32,7 +31,6 @@ CREATE TABLE IF NOT EXISTS thoughts (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_thoughts_user_due ON thoughts(user_id, due_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS spaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
|
Before Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 249 KiB |
|
Before Width: | Height: | Size: 315 KiB |