Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6c197aba7 | ||
|
|
bc13235da6 | ||
|
|
4e9c4ffa8c | ||
|
|
8a32cdd525 | ||
|
|
6896a29285 | ||
|
|
554029a099 | ||
|
|
c85f31eee5 | ||
|
|
1c71b09f20 | ||
|
|
6a261e3453 | ||
|
|
fdbf691a8d | ||
|
|
5cf6b59377 | ||
|
|
6129681035 | ||
|
|
63a5885155 | ||
|
|
fd95c2f8da | ||
|
|
94acb2638e | ||
|
|
d39d1d1122 | ||
|
|
df2f2bac67 | ||
|
|
bce103e2c6 | ||
|
|
122ebccf8d | ||
|
|
e7eb94be45 | ||
|
|
2ecf71cc42 | ||
|
|
fd89f92b0c | ||
|
|
04fa774d10 | ||
|
|
aea05e868a | ||
|
|
1bdd7d245d | ||
|
|
266026da4c | ||
|
|
183199401d | ||
|
|
ee8d586e2f | ||
|
|
a1c7a65d84 | ||
|
|
996f27133c | ||
|
|
0e06cc5577 | ||
|
|
50b017f387 | ||
|
|
f7e70b649f | ||
|
|
8769aceb05 | ||
|
|
5db126128e | ||
|
|
f74ff7c63b | ||
|
|
37948d3a6a | ||
|
|
3d780856b0 | ||
|
|
8ee7b135af | ||
|
|
894a2e3b76 | ||
|
|
59ddca91f3 | ||
|
|
7baab8faa2 | ||
|
|
5a931e9bb6 | ||
|
|
60fd1c9ae7 | ||
|
|
23eff21520 | ||
|
|
d11153309f | ||
|
|
7a8d759bf2 | ||
|
|
149cfdc244 | ||
|
|
928cc72491 | ||
|
|
5144f7b01b | ||
|
|
ffa14b01a2 | ||
|
|
d3dd4c8cc6 | ||
|
|
27b3567008 | ||
|
|
4850f9dfad | ||
|
|
6c1898d489 | ||
|
|
1776e87029 | ||
|
|
634195b629 | ||
|
|
cedf5aebff | ||
|
|
06f5f7043c | ||
|
|
1bcf6b4f18 | ||
|
|
48fe61fbce | ||
|
|
177e24fdc8 | ||
|
|
95977ba15a | ||
|
|
85e4010435 | ||
|
|
86867b0d6f | ||
|
|
f3b3f07e04 | ||
|
|
297f3ed111 | ||
|
|
f90b0d5eb9 | ||
|
|
5b9cfe8a9a | ||
|
|
6343d62bab | ||
|
|
80bb74b5b2 | ||
|
|
53e1ba322d | ||
|
|
58f02e0927 | ||
|
|
ef8cf8f022 | ||
|
|
0bdeb5f320 | ||
|
|
fe2cfc2b79 | ||
|
|
3480859d8a | ||
|
|
10d5c6d563 | ||
|
|
0e06f6761d | ||
|
|
baac20ea10 | ||
|
|
832f2f9c2b | ||
|
|
155d2daa02 | ||
|
|
c2ed76c6a4 | ||
|
|
e088ef6974 | ||
|
|
ed673b0707 | ||
|
|
2f92e1f49a | ||
|
|
35fd682fcd | ||
|
|
3fcd164fd8 | ||
|
|
f2ead7513a | ||
|
|
1e47f3aeb7 | ||
|
|
e8723cbf82 | ||
|
|
f9ef03af7f | ||
|
|
c420155dc0 | ||
|
|
444442fdf0 | ||
|
|
8b043c4809 | ||
|
|
0b9046cb7a | ||
|
|
212cb32bb0 | ||
|
|
cb0999325f | ||
|
|
e389c7bebc | ||
|
|
634fdcac11 | ||
|
|
f1af10ce1c | ||
|
|
5cfa390ee0 | ||
|
|
0008e9c420 | ||
|
|
09725611b5 | ||
|
|
35d4657a25 | ||
|
|
c0661cddae | ||
|
|
3b4a8b125b | ||
|
|
0d6c603e9c |
No files matched your search
@@ -5,7 +5,10 @@ DATABASE_URL=sqlite:///data/mindsift.db
|
|||||||
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
|
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
|
||||||
BASE_URL=http://localhost:8080
|
BASE_URL=http://localhost:8080
|
||||||
ALLOW_REGISTRATION=false
|
ALLOW_REGISTRATION=false
|
||||||
|
# Logging level: debug, info, warn, error (Default: info)
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
# LOG_LEVEL=debug
|
||||||
|
# DEBUG=true
|
||||||
|
|
||||||
# Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI)
|
# Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI)
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
|
|||||||
@@ -1,317 +0,0 @@
|
|||||||
# 🧠 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.
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# 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`.
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# 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"}'
|
||||||
|
```
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 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)).
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# 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)).
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 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)).
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
# 🛡️ 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)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 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).
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
# 🎨 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,172 +5,194 @@
|
|||||||
|
|
||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](LICENSE)
|
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
[Philosophie](#-die-philosophie) • [Funktionen](#-was-mindsift-auszeichnet) • [Schnellstart](#-schnellstart) • [Für wen?](#-für-wen-ist-mindsift-gemacht)
|
<p align="center">
|
||||||
|
<img src="screenshots/Triage_desktop.png" alt="MindSift Triage Desktop" width="85%" />
|
||||||
|
</p>
|
||||||
|
|
||||||
---
|
[Philosophie](#-die-philosophie) • [Funktionen](#-funktionen) • [Schnellstart](#-schnellstart) • [Betrieb](#-betrieb--konfiguration) • [Entwicklung](#-entwicklung) • [Dokumentation](#-dokumentation)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
## 💡 Die Philosophie: *Erst raus aus dem Kopf – später sortieren*
|
---
|
||||||
|
|
||||||
Klassische Produktivitäts- und Notiz-Apps scheitern im Alltag oft an derselben Hürde: **Sie verlangen Struktur im falschen Moment.**
|
## 💡 Die Philosophie
|
||||||
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.
|
|
||||||
|
|
||||||
**MindSift trennt das Erfassen radikal vom Organisieren:**
|
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**:
|
||||||
|
|
||||||
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.
|
1. **Abladen:** In die Eingabeleiste tippen, `Enter`, fertig. Wer will, weist den Gedanken gleich einer Aufgabe, Liste oder Notiz zu.
|
||||||
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.
|
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. **Strukturiert vertiefen:** Aus spontanen Notizen werden hierarchische Outliner-Dokumente, vernetzte Notizbäume, Kanban-Boards oder geteilte Listen für Familie und Team.
|
3. **Vertiefen:** Aus losen Ideen werden Aufgaben mit Unteraufgaben, vernetzte Outliner-Notizen oder geteilte Listen für Familie und Team.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Was MindSift auszeichnet
|
## ✨ Funktionen
|
||||||
|
|
||||||
### ⚡ Quick-Dump & Triage-Deck (Das 3-Sekunden-Prinzip)
|
### ⚡ Schnellerfassung & Triage
|
||||||
* **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)
|
|
||||||
|
|
||||||
### 📝 Workspace: Der hierarchische Logseq-Outliner
|
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe (Browser-Funktion).
|
||||||
* **Jeder Gedanke ein Bullet-Point:** Schreibe Notizen als flexible, hierarchische Gliederung:
|
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
|
||||||
* `Enter`: Neuer Bullet-Point auf derselben Ebene
|
* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste.
|
||||||
* `Tab` / `Shift + Tab`: Unterordnen (einrücken) und ausrücken
|
* Papierkorb mit Wiederherstellen.
|
||||||
* `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!
|
|
||||||
|
|
||||||
### 📋 Globale To-Dos: Das automatische Aufgaben-Rollup
|
<p align="center">
|
||||||
* **Alles auf einen Blick:** MindSift durchsucht all deine Notizen und sammelt sämtliche Checkboxen (`- [ ]`) in einem zentralen Rollup-Dashboard.
|
<img src="screenshots/Mobile_Triage.png" alt="MindSift Mobile Triage" width="280px" />
|
||||||
* **Ordner-Drilldown:** Isoliere einzelne Projekt-Ordner mit einem Klick.
|
</p>
|
||||||
* **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!
|
|
||||||
|
|
||||||
### ✅ Aufgabenlisten & Kanban-Board
|
### ✅ Aufgaben & Kanban
|
||||||
* **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.
|
|
||||||
|
|
||||||
### 🛒 Geteilte Echtzeit-Listen (Einkauf & Checklisten)
|
* Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar.
|
||||||
* **Gemeinsam abhaken:** Ob Wocheneinkauf oder Urlaubs-Packliste – teile Listen in Echtzeit über WebSocket.
|
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
|
||||||
* **Live-Synchronisation:** Wird ein Artikel im Laden abgehakt, verschwindet er sofort bei allen anderen Teilnehmern.
|
* 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.
|
||||||
|
|
||||||
### 🏢 Organisationen, Teams & Administration
|
<p align="center">
|
||||||
* **Geführte Ersteinrichtung:** Beim allerersten Start führt dich MindSift automatisch durch die Einrichtung deines Administrator-Kontos.
|
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
|
||||||
* **3-Stufen-Rollensystem:** Server-Admin, Team-Admin und reguläres Mitglied.
|
</p>
|
||||||
* **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.
|
|
||||||
|
|
||||||
### 🔔 Multi-Channel-Alarme
|
### 📝 Workspace: Outliner-Notizen
|
||||||
* **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.
|
|
||||||
|
|
||||||
### 🎨 Single-File CSS Theming & Hilfe
|
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste.
|
||||||
* **1 Datei = 1 Theme:** Vollständiges Design-System gesteuert über genau eine CSS-Datei (`dark`, `light`, `high-contrast`, `soft`).
|
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren.
|
||||||
* **Integrierte Hilfeseite:** Ein Klick auf das Profil-Menü oder den Hilfe-Button öffnet eine strukturierte Dokumentation inklusive Tastatur-Cheat-Sheet.
|
* 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚀 Schnellstart
|
## 🚀 Schnellstart
|
||||||
|
|
||||||
MindSift läuft als leichtgewichtiger, einzelner Container und bringt Webserver, API, Datenbank und Frontend direkt mit.
|
MindSift läuft als einzelner Container mit Webserver, API, Datenbank und Frontend.
|
||||||
|
|
||||||
### Option 1: Der 1-Zeiler (Empfohlen)
|
### Option 1: Installationsskript
|
||||||
|
|
||||||
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
|
```bash
|
||||||
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/install.sh | 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: Schneller Start mit zwei `curl`-Befehlen
|
### Option 2: Docker Compose
|
||||||
|
|
||||||
Falls du die Dateien direkt ohne Skript herunterladen möchtest:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p mindsift && cd mindsift
|
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/docker-compose.yml -o docker-compose.yml
|
||||||
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/.env.example -o .env
|
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
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Minimal:
|
||||||
|
|
||||||
### Option 3: Manuelle `docker-compose.yml`
|
|
||||||
|
|
||||||
Erstelle eine Datei namens `docker-compose.yml`:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
mindsift:
|
mindsift:
|
||||||
image: git.dsit.me/sandrodach/mindsift:dev
|
image: git.dsit.me/sandrodach/mindsift:dev
|
||||||
container_name: mindsift
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports: ["8080:8080"]
|
||||||
- "8080:8080"
|
volumes: ["./data:/data"]
|
||||||
volumes:
|
|
||||||
- ./data:/data
|
|
||||||
environment:
|
environment:
|
||||||
- PORT=8080
|
- JWT_SECRET=hier-ein-langer-zufaelliger-wert
|
||||||
- DATA_DIR=/data
|
- BASE_URL=https://mindsift.example.com
|
||||||
- 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Starte den Container:
|
### 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.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
# 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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
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.
|
||||||
|
|
||||||
### 🌐 Ersteinrichtung aufrufen
|
**Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite) · React 18, TypeScript, Vite, Tailwind.
|
||||||
|
|
||||||
Ö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?
|
## 📚 Dokumentation
|
||||||
|
|
||||||
* **Für Macher & Wissensarbeiter**, die tagsüber dutzende Gedanken, Links und Aufgaben festhalten müssen, ohne aus dem Arbeitsfluss gerissen zu werden.
|
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).
|
||||||
* **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
|
## 🔒 Deine Daten gehören dir
|
||||||
|
|
||||||
* **100 % Self-Hosted:** Keine fremden Cloud-Dienste, keine Telemetrie, kein Tracking.
|
* Selbst gehostet, keine Telemetrie, keine Fremddienste. Ausgehende Verbindungen gibt es nur zu den Alarm-Zielen, die du selbst einrichtest (SMTP, Discord, Webhook).
|
||||||
* **Kein Vendor-Lock-in:** Alle Notizen liegen als lesbare `.md`-Dateien im Ordner `./data/workspace`. Backups können jederzeit als JSON exportiert werden.
|
* Alle Inhalte liegen in einer einzigen SQLite-Datei; Notizen lassen sich als Markdown exportieren, alle Daten eines Benutzers als JSON.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📄 Lizenz
|
## 📄 Lizenz
|
||||||
|
|
||||||
Veröffentlicht unter der **MIT-Lizenz** – siehe [LICENSE](LICENSE).
|
Im Repository liegt derzeit keine `LICENSE`-Datei; die Lizenz ist damit nicht festgelegt.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<sub>Gebaut für maximale Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
|
<sub>Gebaut für Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
|
||||||
</div>
|
</div>
|
||||||
@@ -4,7 +4,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -23,11 +24,16 @@ var embeddedDist embed.FS
|
|||||||
func main() {
|
func main() {
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
log.Printf("🧠 Starting MindSift Server on port %s (DB: %s)", cfg.Port, cfg.DatabasePath)
|
level := cfg.SlogLevel()
|
||||||
|
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
|
||||||
|
slog.Info("MindSift Server starting", "port", cfg.Port, "log_level", level.String())
|
||||||
|
|
||||||
store, err := storage.NewStore(cfg.DatabasePath)
|
store, err := storage.NewStore(cfg.DatabasePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to initialize storage: %v", err)
|
slog.Error("Failed to initialize storage", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
@@ -35,11 +41,11 @@ func main() {
|
|||||||
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS)
|
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS)
|
||||||
|
|
||||||
if cfg.DemoMode {
|
if cfg.DemoMode {
|
||||||
log.Printf("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
|
slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
|
||||||
if err := store.EnsureDemoAccounts(); err != nil {
|
if err := store.EnsureDemoAccounts(); err != nil {
|
||||||
log.Printf("⚠️ Fehler beim Anlegen der Demo-Accounts: %v", err)
|
slog.Warn("⚠️ Fehler beim Anlegen der Demo-Accounts", "error", err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
|
slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,23 +53,31 @@ func main() {
|
|||||||
|
|
||||||
staticFS, err := fs.Sub(embeddedDist, "dist")
|
staticFS, err := fs.Sub(embeddedDist, "dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to mount embedded dist filesystem: %v", err)
|
slog.Error("Failed to mount embedded dist filesystem", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv := api.NewServer(cfg, store, wsHub, staticFS)
|
srv := api.NewServer(cfg, store, wsHub, staticFS)
|
||||||
|
|
||||||
|
serverCtx, serverCancel := context.WithCancel(context.Background())
|
||||||
|
defer serverCancel()
|
||||||
|
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: ":" + cfg.Port,
|
||||||
Handler: srv.Router(),
|
Handler: srv.Router(),
|
||||||
|
BaseContext: func(l net.Listener) context.Context {
|
||||||
|
return serverCtx
|
||||||
|
},
|
||||||
ReadTimeout: 15 * time.Second,
|
ReadTimeout: 15 * time.Second,
|
||||||
WriteTimeout: 30 * time.Second,
|
WriteTimeout: 30 * time.Second,
|
||||||
IdleTimeout: 60 * time.Second,
|
IdleTimeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
log.Printf("✨ MindSift ready at http://0.0.0.0:%s", cfg.Port)
|
slog.Info("✨ MindSift ready", "url", "http://0.0.0.0:"+cfg.Port)
|
||||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
log.Fatalf("HTTP server error: %v", err)
|
slog.Error("HTTP server error", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -71,8 +85,13 @@ func main() {
|
|||||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||||
<-stop
|
<-stop
|
||||||
|
|
||||||
log.Println("Shutting down MindSift server gracefully...")
|
slog.Info("Shutting down MindSift server gracefully...")
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
serverCancel()
|
||||||
|
wsHub.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
_ = httpServer.Shutdown(ctx)
|
if err := httpServer.Shutdown(ctx); err != nil {
|
||||||
|
slog.Warn("HTTP server shutdown finished with error", "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,9 @@ services:
|
|||||||
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
||||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
- 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
|
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
|
||||||
- MINDA_DEMO=${MINDA_DEMO:-false}
|
- MINDA_DEMO=${MINDA_DEMO:-false}
|
||||||
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
|
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ services:
|
|||||||
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
||||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
- 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
|
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
|
||||||
- MINDA_DEMO=${MINDA_DEMO:-false}
|
- MINDA_DEMO=${MINDA_DEMO:-false}
|
||||||
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
|
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"y-indexeddb": "^9.0.12",
|
||||||
|
"yjs": "^13.6.33"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
@@ -1840,6 +1842,16 @@
|
|||||||
"node": ">=0.12.0"
|
"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": {
|
"node_modules/jiti": {
|
||||||
"version": "1.21.7",
|
"version": "1.21.7",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||||
@@ -1882,6 +1894,27 @@
|
|||||||
"node": ">=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": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.4",
|
"version": "3.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.4.tgz",
|
||||||
@@ -2750,12 +2783,49 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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,5 +1,14 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
<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="#0b0f19"/>
|
<rect width="512" height="512" rx="128" fill="#8ab4f8"/>
|
||||||
<circle cx="256" cy="256" r="164" fill="none" stroke="#8ab4f8" stroke-width="36"/>
|
<g transform="translate(80, 80) scale(14.666)" fill="none" stroke="#0b0f19" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M184 266 L236 318 L338 194" fill="none" stroke="#c58af9" stroke-width="40" 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>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 851 B |
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Layers,
|
Layers,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
@@ -18,8 +18,13 @@ import {
|
|||||||
Smartphone,
|
Smartphone,
|
||||||
Share,
|
Share,
|
||||||
Users,
|
Users,
|
||||||
|
Calendar,
|
||||||
|
AlertTriangle,
|
||||||
|
Flame,
|
||||||
|
Mic,
|
||||||
|
MicOff,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
|
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User } from './lib/types';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
clearToken,
|
clearToken,
|
||||||
@@ -44,6 +49,9 @@ import { UserSettingsView } from './features/settings/UserSettingsView';
|
|||||||
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
||||||
import { HelpView } from './features/help/HelpView';
|
import { HelpView } from './features/help/HelpView';
|
||||||
import { GroupsView } from './features/teams/GroupsView';
|
import { GroupsView } from './features/teams/GroupsView';
|
||||||
|
import { useMobileActions } from './context/MobileActionContext';
|
||||||
|
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
||||||
|
import { UserAvatar } from './components/UserAvatar';
|
||||||
|
|
||||||
type ActiveTab =
|
type ActiveTab =
|
||||||
| 'triage'
|
| 'triage'
|
||||||
@@ -119,6 +127,8 @@ export const App: React.FC = () => {
|
|||||||
const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
|
const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
|
||||||
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
|
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
|
||||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
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 [captureFocusCount, setCaptureFocusCount] = useState(0);
|
||||||
const [refreshRevision, setRefreshRevision] = useState(0);
|
const [refreshRevision, setRefreshRevision] = useState(0);
|
||||||
|
|
||||||
@@ -129,6 +139,189 @@ export const App: React.FC = () => {
|
|||||||
// Mobile Navigation & Quick-Action State
|
// Mobile Navigation & Quick-Action State
|
||||||
const [showMobileCapture, setShowMobileCapture] = useState(false);
|
const [showMobileCapture, setShowMobileCapture] = useState(false);
|
||||||
const [showMobileProfile, setShowMobileProfile] = 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
|
// Mobile Web-App (PWA) Installation State
|
||||||
const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => {
|
const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => {
|
||||||
@@ -223,7 +416,7 @@ export const App: React.FC = () => {
|
|||||||
setActiveThemeState(themeId);
|
setActiveThemeState(themeId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshAll = async () => {
|
const refreshAll = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [meRes, deckRes, listsRes] = await Promise.all([
|
const [meRes, deckRes, listsRes] = await Promise.all([
|
||||||
api.getMe(),
|
api.getMe(),
|
||||||
@@ -237,7 +430,7 @@ export const App: React.FC = () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// If token expired or invalid, api.ts dispatches mindsift:unauthorized
|
// If token expired or invalid, api.ts dispatches mindsift:unauthorized
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const checkBootAndAuth = async () => {
|
const checkBootAndAuth = async () => {
|
||||||
setBootLoading(true);
|
setBootLoading(true);
|
||||||
@@ -358,15 +551,21 @@ export const App: React.FC = () => {
|
|||||||
refreshAll();
|
refreshAll();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenDocument = (docId: string) => {
|
const handleOpenDocument = (docId: string, blockId?: string) => {
|
||||||
setSelectedDocId(docId);
|
setSelectedDocId(docId);
|
||||||
|
setSelectedBlockId(blockId || null);
|
||||||
setActiveTab('workspace');
|
setActiveTab('workspace');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenTask = useCallback((taskId: string) => {
|
||||||
|
setSelectedTaskId(taskId);
|
||||||
|
setActiveTab('tasks');
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleTriggerAddButton = () => {
|
const handleTriggerAddButton = () => {
|
||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
if (window.innerWidth < 640) {
|
if (window.innerWidth < 640) {
|
||||||
setShowMobileCapture(true);
|
openMobileTriageCapture();
|
||||||
} else {
|
} else {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
setCaptureFocusCount((c) => c + 1);
|
setCaptureFocusCount((c) => c + 1);
|
||||||
@@ -376,7 +575,11 @@ export const App: React.FC = () => {
|
|||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
disconnectWebSocket();
|
disconnectWebSocket();
|
||||||
clearToken();
|
try {
|
||||||
|
await api.logout();
|
||||||
|
} catch {
|
||||||
|
clearToken();
|
||||||
|
}
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setShowProfileMenu(false);
|
setShowProfileMenu(false);
|
||||||
setActiveTab('triage');
|
setActiveTab('triage');
|
||||||
@@ -409,27 +612,53 @@ export const App: React.FC = () => {
|
|||||||
const isServerAdmin = user.role === 'admin';
|
const isServerAdmin = user.role === 'admin';
|
||||||
const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied';
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30">
|
<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 */}
|
{/* 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">
|
<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 Larger Logo Left (Triage Home) & Profile Avatar Right */}
|
{/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */}
|
||||||
<div className="sm:hidden flex items-center justify-between h-14 px-4 pt-1">
|
<div className="sm:hidden flex items-center justify-between h-14 px-4 pt-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(12);
|
||||||
setActiveTab('triage');
|
setIsMenuOpen((prev) => !prev);
|
||||||
}}
|
}}
|
||||||
className="h-11 px-2 -ml-2 rounded-xl flex items-center gap-2.5 cursor-pointer select-none active:scale-95 transition-transform"
|
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 ${
|
||||||
title="Zur Startseite (Triage)"
|
isMenuOpen ? 'bg-panel border border-accent/40 shadow-sm' : 'hover:bg-panel/60'
|
||||||
|
}`}
|
||||||
|
title="Menü & Ansichts-Optionen öffnen"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 bg-accent text-on-accent rounded-xl shadow-sm flex items-center justify-center">
|
<div className="w-8 h-8 bg-accent text-on-accent rounded-xl shadow-sm flex items-center justify-center flex-shrink-0">
|
||||||
<Brain className="w-4 h-4" />
|
<Brain className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-base text-main tracking-tight">
|
<div className="flex items-center gap-1.5">
|
||||||
{authStatus.instanceName || 'MindSift'}
|
<div className="flex flex-col">
|
||||||
</span>
|
<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>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -462,9 +691,7 @@ export const App: React.FC = () => {
|
|||||||
}`}
|
}`}
|
||||||
title="Profil & Einstellungen"
|
title="Profil & Einstellungen"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 rounded-full bg-accent text-on-accent text-xs font-bold shadow-sm flex items-center justify-center">
|
<UserAvatar user={user} size="sm" className="shadow-sm" />
|
||||||
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -482,9 +709,12 @@ export const App: React.FC = () => {
|
|||||||
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
||||||
<Brain className="w-4 h-4" />
|
<Brain className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
<div className="flex flex-col">
|
||||||
{authStatus.instanceName || 'MindSift'}
|
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
||||||
</span>
|
{authStatus.instanceName || 'MindSift'}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">v0.1</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
||||||
@@ -506,9 +736,7 @@ 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"
|
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"
|
title="Benutzermenü öffnen"
|
||||||
>
|
>
|
||||||
<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">
|
<UserAvatar user={user} size="sm" />
|
||||||
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
|
||||||
</div>
|
|
||||||
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
||||||
{user.displayName}
|
{user.displayName}
|
||||||
</span>
|
</span>
|
||||||
@@ -518,12 +746,15 @@ export const App: React.FC = () => {
|
|||||||
{showProfileMenu && (
|
{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">
|
<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 */}
|
{/* User Info Header */}
|
||||||
<div className="p-3 bg-canvas">
|
<div className="p-3 bg-canvas flex items-center gap-2.5">
|
||||||
<p className="font-bold text-main truncate">{user.displayName}</p>
|
<UserAvatar user={user} size="md" className="border border-border/70" />
|
||||||
<p className="text-[11px] text-muted truncate">{user.email}</p>
|
<div className="min-w-0 flex-1">
|
||||||
<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">
|
<p className="font-bold text-main truncate">{user.displayName}</p>
|
||||||
{roleLabel}
|
<p className="text-[11px] text-muted truncate">{user.email}</p>
|
||||||
</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Theme Quick Switcher */}
|
{/* Theme Quick Switcher */}
|
||||||
@@ -649,24 +880,6 @@ export const App: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
triggerHaptic(10);
|
|
||||||
setActiveTab('tasks');
|
|
||||||
}}
|
|
||||||
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
|
|
||||||
>
|
|
||||||
<CheckSquare className="w-4 h-4" />
|
|
||||||
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
|
|
||||||
<span>Aufgaben</span>
|
|
||||||
{stats.tasksOpenCount > 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.tasksOpenCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
@@ -685,6 +898,24 @@ export const App: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
triggerHaptic(10);
|
||||||
|
setActiveTab('tasks');
|
||||||
|
}}
|
||||||
|
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
|
||||||
|
>
|
||||||
|
<CheckSquare className="w-4 h-4" />
|
||||||
|
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
|
||||||
|
<span>Aufgaben</span>
|
||||||
|
{stats.tasksOpenCount > 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.tasksOpenCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
@@ -715,6 +946,8 @@ export const App: React.FC = () => {
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
|
||||||
|
|
||||||
{/* Main Container */}
|
{/* 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">
|
<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')}>
|
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
||||||
@@ -735,6 +968,7 @@ export const App: React.FC = () => {
|
|||||||
onRefreshStats={refreshAll}
|
onRefreshStats={refreshAll}
|
||||||
onOpenDocument={handleOpenDocument}
|
onOpenDocument={handleOpenDocument}
|
||||||
refreshTrigger={refreshRevision}
|
refreshTrigger={refreshRevision}
|
||||||
|
initialSelectedTaskId={selectedTaskId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -742,7 +976,9 @@ export const App: React.FC = () => {
|
|||||||
<WorkspaceView
|
<WorkspaceView
|
||||||
currentUser={user}
|
currentUser={user}
|
||||||
initialDocId={selectedDocId}
|
initialDocId={selectedDocId}
|
||||||
|
initialBlockId={selectedBlockId}
|
||||||
onStatsChange={refreshAll}
|
onStatsChange={refreshAll}
|
||||||
|
onOpenTask={handleOpenTask}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -809,45 +1045,7 @@ export const App: React.FC = () => {
|
|||||||
<span className="text-[10px] leading-tight">Triage</span>
|
<span className="text-[10px] leading-tight">Triage</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Col 2: Aufgaben */}
|
{/* Col 2: Workspace */}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
triggerHaptic(10);
|
|
||||||
setActiveTab('tasks');
|
|
||||||
}}
|
|
||||||
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 === 'tasks' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
|
||||||
}`}
|
|
||||||
title="Aufgaben"
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<CheckSquare className="w-5 h-5" />
|
|
||||||
{stats.tasksOpenCount > 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.tasksOpenCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -870,6 +1068,48 @@ export const App: React.FC = () => {
|
|||||||
<span className="text-[10px] leading-tight">Workspace</span>
|
<span className="text-[10px] leading-tight">Workspace</span>
|
||||||
</button>
|
</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 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
triggerHaptic(10);
|
||||||
|
setActiveTab('tasks');
|
||||||
|
}}
|
||||||
|
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 === 'tasks' ? 'text-accent font-semibold' : 'text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
title="Aufgaben"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<CheckSquare className="w-5 h-5" />
|
||||||
|
{stats.tasksOpenCount > 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.tasksOpenCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] leading-tight">Aufgaben</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Col 5: Listen */}
|
{/* Col 5: Listen */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -887,44 +1127,149 @@ export const App: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* MOBILE QUICK-ADD BOTTOM SHEET / MODAL */}
|
{/* MOBILE QUICK-ADD BOTTOM SHEET / MODAL (DESIGN-ALIGNED WITH TASKS VIEW) */}
|
||||||
{showMobileCapture && (
|
{showMobileCapture && (
|
||||||
<div
|
<div
|
||||||
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"
|
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"
|
||||||
onClick={() => setShowMobileCapture(false)}
|
onClick={() => setShowMobileCapture(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
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"
|
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"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between pb-3 border-b border-border mb-3">
|
<div className="flex items-center justify-between pb-3 border-b border-border mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-6 h-6 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
|
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
|
||||||
<Plus className="w-4 h-4" />
|
<Layers className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-sm font-semibold text-main">Schnell erfassen</h3>
|
<h3 className="text-sm font-semibold text-main">Quick Dump erfassen</h3>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowMobileCapture(false)}
|
onClick={() => setShowMobileCapture(false)}
|
||||||
className="p-1 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
className="p-1.5 text-faint hover:text-main rounded-btn transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* InstantCaptureBar */}
|
<form onSubmit={handleMobileCreateTriage} className="space-y-3">
|
||||||
<div className="pt-1">
|
{/* Text Input with Voice Dictation */}
|
||||||
<InstantCaptureBar
|
<div>
|
||||||
onCaptured={(thought, target) => {
|
<label className="text-[11px] font-semibold text-muted block mb-1">
|
||||||
handleCaptured(thought, target);
|
Gedanke / Quick Dump
|
||||||
setShowMobileCapture(false);
|
</label>
|
||||||
}}
|
<div className="relative">
|
||||||
lists={lists}
|
<input
|
||||||
autoFocusTrigger={Date.now()}
|
type="text"
|
||||||
/>
|
autoFocus
|
||||||
</div>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -932,7 +1277,7 @@ export const App: React.FC = () => {
|
|||||||
{/* MOBILE USER PROFILE SHEET */}
|
{/* MOBILE USER PROFILE SHEET */}
|
||||||
{showMobileProfile && (
|
{showMobileProfile && (
|
||||||
<div
|
<div
|
||||||
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"
|
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"
|
||||||
onClick={() => setShowMobileProfile(false)}
|
onClick={() => setShowMobileProfile(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -942,9 +1287,7 @@ export const App: React.FC = () => {
|
|||||||
{/* User Info Header */}
|
{/* User Info Header */}
|
||||||
<div className="flex items-center justify-between pb-2">
|
<div className="flex items-center justify-between pb-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<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">
|
<UserAvatar user={user} size="lg" className="border border-border/80 shadow-sm" />
|
||||||
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-bold text-sm text-main">{user.displayName}</p>
|
<p className="font-bold text-sm text-main">{user.displayName}</p>
|
||||||
<p className="text-xs text-muted">{user.email}</p>
|
<p className="text-xs text-muted">{user.email}</p>
|
||||||
@@ -1091,7 +1434,7 @@ export const App: React.FC = () => {
|
|||||||
{/* IMMEDIATE MOBILE WEB-APP INSTALLATION OFFER SHEET */}
|
{/* IMMEDIATE MOBILE WEB-APP INSTALLATION OFFER SHEET */}
|
||||||
{showPwaInstallModal && !isStandaloneApp && (
|
{showPwaInstallModal && !isStandaloneApp && (
|
||||||
<div
|
<div
|
||||||
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"
|
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"
|
||||||
onClick={handleDismissPwaModal}
|
onClick={handleDismissPwaModal}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
Lock,
|
Lock,
|
||||||
Share2,
|
|
||||||
Check,
|
Check,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { User, Team, UnifiedShare } from '../lib/types';
|
import { User, Team, UnifiedShare } from '../lib/types';
|
||||||
import { UserAutocomplete } from '../features/UserAutocomplete';
|
import { UserAutocomplete } from '../features/UserAutocomplete';
|
||||||
|
import { UserAvatar } from './UserAvatar';
|
||||||
|
|
||||||
export interface UnifiedShareModalProps {
|
export interface UnifiedShareModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -24,6 +24,7 @@ export interface UnifiedShareModalProps {
|
|||||||
teams: Team[];
|
teams: Team[];
|
||||||
collaborators: User[];
|
collaborators: User[];
|
||||||
allowRoleSelection?: boolean; // Default true (editor vs viewer)
|
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>;
|
onAddShare: (targetType: 'team' | 'user', targetId: string, permission: 'editor' | 'viewer') => Promise<void>;
|
||||||
onRemoveShare: (shareId: string) => Promise<void>;
|
onRemoveShare: (shareId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -40,13 +41,17 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
teams,
|
teams,
|
||||||
collaborators,
|
collaborators,
|
||||||
allowRoleSelection = true,
|
allowRoleSelection = true,
|
||||||
|
fixedPermission,
|
||||||
onAddShare,
|
onAddShare,
|
||||||
onRemoveShare,
|
onRemoveShare,
|
||||||
}) => {
|
}) => {
|
||||||
|
const effectiveFixedPerm = fixedPermission || 'viewer';
|
||||||
const [shareType, setShareType] = useState<'team' | 'user'>('team');
|
const [shareType, setShareType] = useState<'team' | 'user'>('team');
|
||||||
const [selectedTeamId, setSelectedTeamId] = useState('');
|
const [selectedTeamId, setSelectedTeamId] = useState('');
|
||||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>('editor');
|
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>(
|
||||||
|
allowRoleSelection ? 'editor' : effectiveFixedPerm
|
||||||
|
);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -63,7 +68,8 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await onAddShare(shareType, targetId, selectedPermission);
|
const permToUse = allowRoleSelection ? selectedPermission : effectiveFixedPerm;
|
||||||
|
await onAddShare(shareType, targetId, permToUse);
|
||||||
setSelectedTeamId('');
|
setSelectedTeamId('');
|
||||||
setSelectedUser(null);
|
setSelectedUser(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -97,7 +103,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
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) => {
|
onClick={(e) => {
|
||||||
if (e.target === e.currentTarget) onClose();
|
if (e.target === e.currentTarget) onClose();
|
||||||
}}
|
}}
|
||||||
@@ -114,7 +120,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
resourceIcon
|
resourceIcon
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<Share2 className="w-4 h-4" />
|
<Users className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
@@ -196,7 +202,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{allowRoleSelection && (
|
{allowRoleSelection ? (
|
||||||
<select
|
<select
|
||||||
value={selectedPermission}
|
value={selectedPermission}
|
||||||
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
||||||
@@ -206,6 +212,20 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -220,7 +240,7 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{allowRoleSelection && (
|
{allowRoleSelection ? (
|
||||||
<select
|
<select
|
||||||
value={selectedPermission}
|
value={selectedPermission}
|
||||||
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
||||||
@@ -230,6 +250,20 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -285,15 +319,18 @@ 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"
|
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">
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
<div
|
{isTeam ? (
|
||||||
className={`w-7 h-7 rounded-btn flex items-center justify-center shrink-0 ${
|
<div className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 bg-accent/15 text-accent border border-accent/25">
|
||||||
isTeam
|
<Users className="w-3.5 h-3.5" />
|
||||||
? 'bg-accent/15 text-accent'
|
</div>
|
||||||
: 'bg-surface-2 text-main border border-border'
|
) : (
|
||||||
}`}
|
<UserAvatar
|
||||||
>
|
avatarUrl={sh.avatarUrl || collaborators.find((c) => c.id === sh.targetId)?.avatarUrl}
|
||||||
{isTeam ? <Users className="w-3.5 h-3.5" /> : <UserIcon className="w-3.5 h-3.5" />}
|
name={sh.targetName || sh.targetEmail}
|
||||||
</div>
|
size="sm"
|
||||||
|
className="border border-border/80"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
<div className="font-medium text-main truncate flex items-center gap-1.5">
|
<div className="font-medium text-main truncate flex items-center gap-1.5">
|
||||||
<span className="truncate">{sh.targetName || (isTeam ? 'Gruppe' : sh.targetEmail || 'Benutzer')}</span>
|
<span className="truncate">{sh.targetName || (isTeam ? 'Gruppe' : sh.targetEmail || 'Benutzer')}</span>
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Search, X, Check } from 'lucide-react';
|
import { Search, X, Check } from 'lucide-react';
|
||||||
import { User } from '../lib/types';
|
import { User } from '../lib/types';
|
||||||
|
import { UserAvatar } from '../components/UserAvatar';
|
||||||
|
|
||||||
interface UserAutocompleteProps {
|
interface UserAutocompleteProps {
|
||||||
users: User[];
|
users: User[];
|
||||||
@@ -101,9 +102,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
{selectedUser ? (
|
{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="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">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="w-5 h-5 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
|
<UserAvatar user={selectedUser} size="xs" />
|
||||||
{(selectedUser.displayName || selectedUser.email || '?')[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span className="font-semibold text-main truncate">
|
<span className="font-semibold text-main truncate">
|
||||||
{selectedUser.displayName || selectedUser.email}
|
{selectedUser.displayName || selectedUser.email}
|
||||||
</span>
|
</span>
|
||||||
@@ -170,9 +169,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<div className="w-5 h-5 rounded-full bg-accent/20 text-accent font-bold text-[10px] flex items-center justify-center shrink-0">
|
<UserAvatar user={u} size="xs" />
|
||||||
{(u.displayName || u.email || '?')[0].toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
<span className="font-semibold text-main">
|
<span className="font-semibold text-main">
|
||||||
{u.displayName || u.email}
|
{u.displayName || u.email}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
UserPlus,
|
UserPlus,
|
||||||
LogIn,
|
LogIn,
|
||||||
Server,
|
Server,
|
||||||
Share2,
|
Users,
|
||||||
Check,
|
Check,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -235,7 +235,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-muted mb-1.5 flex items-center gap-1">
|
<label className="block text-muted mb-1.5 flex items-center gap-1">
|
||||||
<Share2 className="w-3 h-3 text-accent" />
|
<Users className="w-3 h-3 text-accent" />
|
||||||
<span>Sichtbarkeit & Sharing-Isolation zwischen Benutzern</span>
|
<span>Sichtbarkeit & Sharing-Isolation zwischen Benutzern</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Layers,
|
Layers,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
AlertTriangle,
|
||||||
|
Flame,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PriorityLevel, SharedList, Thought } from '../../lib/types';
|
import { PriorityLevel, SharedList, Thought } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
@@ -21,19 +23,27 @@ interface InstantCaptureBarProps {
|
|||||||
onCaptured: (thought: Thought, target?: CaptureTarget) => void;
|
onCaptured: (thought: Thought, target?: CaptureTarget) => void;
|
||||||
lists?: SharedList[];
|
lists?: SharedList[];
|
||||||
autoFocusTrigger?: number;
|
autoFocusTrigger?: number;
|
||||||
|
inlineOptions?: boolean;
|
||||||
|
defaultShowOptions?: boolean;
|
||||||
|
hideTargetPicker?: boolean;
|
||||||
|
fixedTarget?: CaptureTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||||
onCaptured,
|
onCaptured,
|
||||||
lists = [],
|
lists = [],
|
||||||
autoFocusTrigger = 0,
|
autoFocusTrigger = 0,
|
||||||
|
inlineOptions = false,
|
||||||
|
defaultShowOptions = false,
|
||||||
|
hideTargetPicker = false,
|
||||||
|
fixedTarget,
|
||||||
}) => {
|
}) => {
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [priority, setPriority] = useState<PriorityLevel>('normal');
|
const [priority, setPriority] = useState<PriorityLevel>('normal');
|
||||||
const [dueDate, setDueDate] = useState('');
|
const [dueDate, setDueDate] = useState('');
|
||||||
const [target, setTarget] = useState<CaptureTarget>('inbox');
|
const [target, setTarget] = useState<CaptureTarget>(fixedTarget || 'inbox');
|
||||||
const [targetListId, setTargetListId] = useState<string>('');
|
const [targetListId, setTargetListId] = useState<string>('');
|
||||||
const [showOptions, setShowOptions] = useState(false);
|
const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker);
|
||||||
const [isListening, setIsListening] = useState(false);
|
const [isListening, setIsListening] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
|
const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
|
||||||
@@ -42,6 +52,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const containerRef = useRef<HTMLFormElement>(null);
|
const containerRef = useRef<HTMLFormElement>(null);
|
||||||
const recognitionRef = useRef<any>(null);
|
const recognitionRef = useRef<any>(null);
|
||||||
|
const baseVoiceTextRef = useRef('');
|
||||||
|
const hasVoiceInputRef = useRef(false);
|
||||||
|
|
||||||
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
||||||
const targetEl = el || inputRef.current;
|
const targetEl = el || inputRef.current;
|
||||||
@@ -72,6 +84,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
|
|
||||||
// Close dropdown on outside click
|
// Close dropdown on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (inlineOptions) return;
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
setShowOptions(false);
|
setShowOptions(false);
|
||||||
@@ -79,7 +92,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
};
|
};
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, []);
|
}, [inlineOptions]);
|
||||||
|
|
||||||
// Global keyboard shortcut: 'c' or '/' focuses the Instant Capture input immediately
|
// Global keyboard shortcut: 'c' or '/' focuses the Instant Capture input immediately
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -120,6 +133,9 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim();
|
||||||
|
hasVoiceInputRef.current = true;
|
||||||
|
|
||||||
const recognition = new SpeechRecognition();
|
const recognition = new SpeechRecognition();
|
||||||
recognition.lang = 'de-DE';
|
recognition.lang = 'de-DE';
|
||||||
recognition.continuous = false;
|
recognition.continuous = false;
|
||||||
@@ -135,10 +151,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
for (let i = 0; i < event.results.length; i++) {
|
for (let i = 0; i < event.results.length; i++) {
|
||||||
transcript += event.results[i][0].transcript;
|
transcript += event.results[i][0].transcript;
|
||||||
}
|
}
|
||||||
setContent((prev) => {
|
const base = baseVoiceTextRef.current;
|
||||||
const base = prev.replace(/\s*\[…\]$/, '').trim();
|
setContent(base ? `${base} ${transcript}` : transcript);
|
||||||
return base ? `${base} ${transcript}` : transcript;
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
recognition.onerror = () => {
|
recognition.onerror = () => {
|
||||||
@@ -164,20 +178,21 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
if (!trimmed || isSubmitting) return;
|
if (!trimmed || isSubmitting) return;
|
||||||
|
|
||||||
const effectiveTarget = overrideTarget || target;
|
const effectiveTarget = fixedTarget || overrideTarget || target;
|
||||||
const effectiveListId =
|
const effectiveListId =
|
||||||
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
|
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
|
||||||
const capturedPriority = priority;
|
const capturedPriority = priority;
|
||||||
const capturedDueDate = dueDate;
|
const capturedDueDate = dueDate;
|
||||||
const capturedSource = isListening ? 'voice' : 'manual';
|
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
|
||||||
|
hasVoiceInputRef.current = false;
|
||||||
|
|
||||||
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
|
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
|
||||||
setContent('');
|
setContent('');
|
||||||
setIsMultiline(false);
|
setIsMultiline(false);
|
||||||
setPriority('normal');
|
setPriority('normal');
|
||||||
setDueDate('');
|
setDueDate('');
|
||||||
setTarget('inbox');
|
setTarget(fixedTarget || 'inbox');
|
||||||
setShowOptions(false);
|
setShowOptions(defaultShowOptions || hideTargetPicker);
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
@@ -191,7 +206,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
? 'Auf Liste!'
|
? 'Auf Liste!'
|
||||||
: effectiveTarget === 'document'
|
: effectiveTarget === 'document'
|
||||||
? 'Als Notiz!'
|
? 'Als Notiz!'
|
||||||
: 'Im Dump!';
|
: 'In Triage!';
|
||||||
setJustSavedLabel(savedMsg);
|
setJustSavedLabel(savedMsg);
|
||||||
setTimeout(() => setJustSavedLabel(null), 1600);
|
setTimeout(() => setJustSavedLabel(null), 1600);
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
@@ -270,6 +285,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
placeholder={
|
placeholder={
|
||||||
isListening
|
isListening
|
||||||
? 'Ich höre zu…'
|
? 'Ich höre zu…'
|
||||||
|
: hideTargetPicker || fixedTarget === 'inbox'
|
||||||
|
? 'Quick Dump erfassen… (Enter zum Speichern)'
|
||||||
: target === 'task'
|
: target === 'task'
|
||||||
? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
|
? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
|
||||||
: target === 'list_item'
|
: target === 'list_item'
|
||||||
@@ -288,32 +305,34 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
isMultiline ? 'self-end mb-0.5' : ''
|
isMultiline ? 'self-end mb-0.5' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Target & Options Toggle Pill */}
|
{/* Target & Options Toggle Pill (Hidden when target picker is disabled) */}
|
||||||
<button
|
{!hideTargetPicker && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setShowOptions((prev) => !prev)}
|
type="button"
|
||||||
title="Ziel wählen (Quick Dump, direkt als Aufgabe, auf Liste oder als Notiz) & Optionen"
|
onClick={() => setShowOptions((prev) => !prev)}
|
||||||
className={`h-6 px-2.5 capture-pill rounded-full text-[11px] flex items-center gap-1 transition-colors ${
|
title="Ziel wählen (Quick Dump, direkt als Aufgabe, auf Liste oder als Notiz) & Optionen"
|
||||||
showOptions || target !== 'inbox' || priority !== 'normal' || dueDate
|
className={`h-6 px-2.5 capture-pill rounded-full text-[11px] flex items-center gap-1 transition-colors cursor-pointer ${
|
||||||
? 'bg-accent-dim text-accent border border-accent-border font-medium'
|
showOptions || target !== 'inbox' || priority !== 'normal' || dueDate
|
||||||
: 'text-muted hover:text-main hover:bg-panel-elevated border border-transparent'
|
? '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 === 'task' ? (
|
||||||
) : target === 'list_item' ? (
|
<CheckSquare className="w-3 h-3" />
|
||||||
<ShoppingCart className="w-3 h-3" />
|
) : target === 'list_item' ? (
|
||||||
) : target === 'document' ? (
|
<ShoppingCart className="w-3 h-3" />
|
||||||
<FileText className="w-3 h-3" />
|
) : target === 'document' ? (
|
||||||
) : (
|
<FileText className="w-3 h-3" />
|
||||||
<Layers className="w-3 h-3" />
|
) : (
|
||||||
)}
|
<Layers className="w-3 h-3" />
|
||||||
<span className="hidden md:inline max-w-[110px] truncate">
|
)}
|
||||||
{getTargetLabel()}
|
<span className={`${inlineOptions ? 'inline' : 'hidden md:inline'} max-w-[110px] truncate`}>
|
||||||
</span>
|
{getTargetLabel()}
|
||||||
{dueDate && <span className="hidden lg:inline text-[10px] opacity-80">• {dueDate.slice(5)}</span>}
|
</span>
|
||||||
<ChevronDown className="w-3 h-3 opacity-70" />
|
{dueDate && <span className="hidden lg:inline text-[10px] opacity-80">• {dueDate.slice(5)}</span>}
|
||||||
</button>
|
<ChevronDown className={`w-3 h-3 opacity-70 transition-transform ${showOptions ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Voice Dictation Button */}
|
{/* Voice Dictation Button */}
|
||||||
<button
|
<button
|
||||||
@@ -342,13 +361,15 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
{justSavedLabel ? (
|
{justSavedLabel ? (
|
||||||
<>
|
<>
|
||||||
<Check className="w-3 h-3" />
|
<Check className="w-3 h-3" />
|
||||||
<span className="hidden sm:inline">{justSavedLabel}</span>
|
<span className="inline">{justSavedLabel}</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ArrowUp className="w-3 h-3" />
|
<ArrowUp className="w-3 h-3" />
|
||||||
<span className="hidden sm:inline">
|
<span className="inline">
|
||||||
{target === 'task'
|
{hideTargetPicker || (fixedTarget || target) === 'inbox'
|
||||||
|
? 'Quick Dump'
|
||||||
|
: target === 'task'
|
||||||
? 'Als Aufgabe'
|
? 'Als Aufgabe'
|
||||||
: target === 'list_item'
|
: target === 'list_item'
|
||||||
? 'Auf Liste'
|
? 'Auf Liste'
|
||||||
@@ -362,172 +383,187 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expandable Target & Metadata Panel (Theme-controlled Dropdown) */}
|
{/* Expandable Target & Metadata Panel (Theme-controlled Dropdown or Inline) */}
|
||||||
{showOptions && (
|
{showOptions && (
|
||||||
<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">
|
<div
|
||||||
{/* Row 1: Where should this entry go? */}
|
className={
|
||||||
<div>
|
inlineOptions
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
? 'mt-3 p-3 panel-surface border border-border rounded-xl space-y-3 text-xs animate-in fade-in duration-150'
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted">
|
: 'absolute top-full left-0 right-0 mt-1.5 z-50 p-3 panel-surface shadow-panel space-y-3 text-xs'
|
||||||
Wohin damit? {content.trim() ? '(Klick speichert sofort oder wählt Ziel)' : '(Ziel für Eingabe wählen)'}
|
}
|
||||||
</span>
|
>
|
||||||
<span className="text-[11px] text-faint">
|
{/* Row 1: Where should this entry go? (Omitted when hideTargetPicker is true) */}
|
||||||
Standard: Quick Dump für späteres Swipen
|
{!hideTargetPicker && (
|
||||||
</span>
|
<>
|
||||||
</div>
|
<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">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
|
||||||
{/* Option 1: Quick Dump (Triage) */}
|
{/* 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={`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 */}
|
|
||||||
<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
|
<button
|
||||||
key={lst.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTarget('list_item');
|
setTarget('inbox');
|
||||||
setTargetListId(lst.id);
|
|
||||||
if (content.trim()) {
|
if (content.trim()) {
|
||||||
executeCapture('list_item', lst.id);
|
executeCapture('inbox');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`h-7 px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors ${
|
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||||
isSelected
|
target === 'inbox'
|
||||||
? 'bg-accent text-on-accent border-accent font-medium'
|
? 'bg-accent-dim border-accent text-main font-medium'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{lst.icon}</span>
|
<Layers className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||||
<span>{lst.title}</span>
|
<span className="truncate">Quick Dump (Triage)</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
|
||||||
})}
|
{/* Option 2: Direct Task */}
|
||||||
</div>
|
<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 3: Direct List Item */}
|
||||||
|
<button
|
||||||
|
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');
|
||||||
|
if (content.trim()) {
|
||||||
|
executeCapture('document');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
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'
|
||||||
|
: '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');
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Row 2: Priority & Due Date */}
|
{/* Row 2: Priority & Due Date */}
|
||||||
<div className="pt-2 border-t border-border flex flex-wrap items-center justify-between gap-2">
|
<div className={`${!hideTargetPicker ? 'pt-2 border-t border-border' : ''} flex flex-wrap items-center justify-between gap-2.5`}>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
<span className="text-muted mr-1">Priorität:</span>
|
<span className="text-muted text-[11px] font-semibold mr-1">Priorität:</span>
|
||||||
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
|
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
|
||||||
const labels: Record<PriorityLevel, string> = {
|
const config: Record<PriorityLevel, { label: string; icon?: React.ReactNode; activeClass: string }> = {
|
||||||
low: 'Niedrig',
|
low: { label: 'Niedrig', activeClass: 'bg-canvas border-strong text-main font-semibold shadow-xs' },
|
||||||
normal: 'Normal',
|
normal: { label: 'Normal', activeClass: 'bg-accent/15 border-accent text-accent font-semibold shadow-xs' },
|
||||||
high: 'Hoch',
|
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: 'Dringend',
|
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 = priority === p;
|
const active = priority === p;
|
||||||
|
const item = config[p];
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPriority(p)}
|
onClick={() => {
|
||||||
className={`px-2.5 py-0.5 border rounded-btn transition-colors text-xs ${
|
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 ${
|
||||||
active
|
active
|
||||||
? 'bg-accent text-on-accent border-accent font-medium'
|
? item.activeClass
|
||||||
: 'bg-canvas text-muted hover:text-main border-border'
|
: 'bg-canvas text-muted hover:text-main border-border'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{labels[p]}
|
{item.icon}
|
||||||
|
<span>{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-muted flex items-center gap-1">
|
<span className="text-muted text-[11px] font-semibold flex items-center gap-1">
|
||||||
<Calendar className="w-3 h-3 text-accent" />
|
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||||||
Fällig am:
|
Fällig am:
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={dueDate}
|
value={dueDate}
|
||||||
onChange={(e) => setDueDate(e.target.value)}
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
className="input-field px-2 py-0.5 text-xs"
|
className="input-field px-2.5 py-1 text-xs min-h-[30px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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="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">
|
<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">
|
<h3 className="font-semibold text-main flex items-center gap-1.5">
|
||||||
<Share2 className="w-3.5 h-3.5 text-accent" />
|
<Users className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Aufgabenlisten teilen & Avatar-Stack</span>
|
<span>Aufgabenlisten teilen & Avatar-Stack</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted leading-relaxed">
|
<p className="text-muted leading-relaxed">
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Share2,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
SystemSettings,
|
SystemSettings,
|
||||||
@@ -22,6 +21,7 @@ import {
|
|||||||
UserRole,
|
UserRole,
|
||||||
} from '../../lib/types';
|
} from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
|
import { UserAvatar } from '../../components/UserAvatar';
|
||||||
|
|
||||||
interface AdminSettingsViewProps {
|
interface AdminSettingsViewProps {
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
@@ -557,16 +557,19 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
{users.map((u) => (
|
{users.map((u) => (
|
||||||
<div key={u.id} className="p-3 space-y-2">
|
<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 flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
<div>
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="flex items-center gap-2">
|
<UserAvatar user={u} size="sm" className="border border-border/80" />
|
||||||
<span className="font-bold text-main">{u.displayName}</span>
|
<div>
|
||||||
{!u.isActive && (
|
<div className="flex items-center gap-2">
|
||||||
<span className="badge-err text-[10px]">
|
<span className="font-bold text-main">{u.displayName}</span>
|
||||||
Gesperrt
|
{!u.isActive && (
|
||||||
</span>
|
<span className="badge-err text-[10px]">
|
||||||
)}
|
Gesperrt
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-muted text-[11px]">{u.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted text-[11px]">{u.email}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -690,7 +693,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
{/* Sharing Isolation Cards */}
|
{/* Sharing Isolation Cards */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-muted font-semibold flex items-center gap-1.5">
|
<label className="block text-muted font-semibold flex items-center gap-1.5">
|
||||||
<Share2 className="w-4 h-4 text-accent" />
|
<Users className="w-4 h-4 text-accent" />
|
||||||
<span>Globale Sharing- & Sichtbarkeits-Isolation</span>
|
<span>Globale Sharing- & Sichtbarkeits-Isolation</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
Bell,
|
Bell,
|
||||||
@@ -15,10 +15,13 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Palette,
|
Palette,
|
||||||
|
Upload,
|
||||||
|
Camera,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { APIKey, User } from '../../lib/types';
|
import { APIKey, User } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme';
|
import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme';
|
||||||
|
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
||||||
|
|
||||||
interface UserSettingsViewProps {
|
interface UserSettingsViewProps {
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
@@ -42,9 +45,13 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
// --- Profile State ---
|
// --- Profile State ---
|
||||||
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
|
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
|
||||||
const [email, setEmail] = useState(currentUser.email || '');
|
const [email, setEmail] = useState(currentUser.email || '');
|
||||||
|
const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || '');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||||
const [savingProfile, setSavingProfile] = useState(false);
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
|
const [revokingSessions, setRevokingSessions] = useState(false);
|
||||||
|
const [isProcessingAvatar, setIsProcessingAvatar] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// --- Multi-Channel Alarms State ---
|
// --- Multi-Channel Alarms State ---
|
||||||
const [alarmChannels, setAlarmChannels] = useState<string[]>(
|
const [alarmChannels, setAlarmChannels] = useState<string[]>(
|
||||||
@@ -69,6 +76,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayName(currentUser.displayName || '');
|
setDisplayName(currentUser.displayName || '');
|
||||||
setEmail(currentUser.email || '');
|
setEmail(currentUser.email || '');
|
||||||
|
setAvatarUrl(currentUser.avatarUrl || '');
|
||||||
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
|
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
|
||||||
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
|
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
|
||||||
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
|
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
|
||||||
@@ -95,6 +103,34 @@ 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 ---
|
// --- Handlers: Profile ---
|
||||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -104,6 +140,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
const patch: any = {
|
const patch: any = {
|
||||||
displayName: displayName.trim(),
|
displayName: displayName.trim(),
|
||||||
email: email.trim(),
|
email: email.trim(),
|
||||||
|
avatarUrl: avatarUrl.trim(),
|
||||||
};
|
};
|
||||||
if (newPassword.trim()) {
|
if (newPassword.trim()) {
|
||||||
patch.password = newPassword.trim();
|
patch.password = newPassword.trim();
|
||||||
@@ -119,6 +156,21 @@ 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 ---
|
// --- Handlers: Alarms ---
|
||||||
const handleSaveAlarms = async (e: React.FormEvent) => {
|
const handleSaveAlarms = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -228,6 +280,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
const activeKeyPlaceholder =
|
const activeKeyPlaceholder =
|
||||||
newlyCreatedKey || (apiKeys[0]?.keyPrefix ? apiKeys[0].keyPrefix + '••••••••' : 'DEIN_API_KEY');
|
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 \\
|
const curlTaskExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
|
||||||
-H "X-API-Key: ${activeKeyPlaceholder}" \\
|
-H "X-API-Key: ${activeKeyPlaceholder}" \\
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/json" \\
|
||||||
@@ -358,7 +415,88 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSaveProfile} className="space-y-4">
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-muted font-medium mb-1">Anzeigename</label>
|
<label className="block text-muted font-medium mb-1">Anzeigename</label>
|
||||||
@@ -408,6 +546,32 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -803,23 +967,43 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Curl Example */}
|
{/* Curl Examples */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-1.5">
|
||||||
<span className="font-semibold text-muted flex items-center gap-1.5">
|
<div className="flex items-center justify-between">
|
||||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
<span className="font-semibold text-muted flex items-center gap-1.5">
|
||||||
<span>Curl-Beispiel: Direkt als Aufgabe einspeisen</span>
|
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||||
</span>
|
<span>Curl: In Triage-Deck einspeisen (erhält Webhook-Badge)</span>
|
||||||
<button
|
</span>
|
||||||
onClick={() => handleCopy(curlTaskExample, 'curl')}
|
<button
|
||||||
className="btn-action h-7 px-2.5 text-[11px]"
|
onClick={() => handleCopy(curlTriageExample, 'curl-triage')}
|
||||||
>
|
className="btn-action h-7 px-2.5 text-[11px]"
|
||||||
{copiedLabel === 'curl' ? 'Kopiert!' : 'Befehl kopieren'}
|
>
|
||||||
</button>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
|
|
||||||
{curlTaskExample}
|
|
||||||
</pre>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Full Data Backup */}
|
{/* Full Data Backup */}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { Team, User } from '../../lib/types';
|
import { Team, User } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
import { UserAutocomplete } from '../UserAutocomplete';
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
|
import { UserAvatar } from '../../components/UserAvatar';
|
||||||
|
|
||||||
interface GroupsViewProps {
|
interface GroupsViewProps {
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
@@ -252,7 +253,7 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) =
|
|||||||
{/* Modal: Neue Gruppe erstellen */}
|
{/* Modal: Neue Gruppe erstellen */}
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
<div
|
<div
|
||||||
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"
|
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"
|
||||||
onClick={() => setShowCreateModal(false)}
|
onClick={() => setShowCreateModal(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -487,9 +488,7 @@ 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"
|
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">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<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">
|
<UserAvatar user={{ displayName: m.displayName, email: m.email, avatarUrl: m.avatarUrl }} size="xs" />
|
||||||
{(m.displayName || 'U').slice(0, 1).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-main font-medium truncate text-xs">
|
<p className="text-main font-medium truncate text-xs">
|
||||||
{m.displayName} {isSelf && <span className="text-faint">(Du)</span>}
|
{m.displayName} {isSelf && <span className="text-faint">(Du)</span>}
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4">
|
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4">
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-md panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[90vh] overflow-hidden"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ import {
|
|||||||
Flame,
|
Flame,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Folder,
|
Folder,
|
||||||
|
Webhook,
|
||||||
|
Lock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
|
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
|
||||||
import { triggerHaptic } from '../../lib/api';
|
import { triggerHaptic } from '../../lib/api';
|
||||||
|
import { MarkdownViewer } from '../../lib/markdown';
|
||||||
|
|
||||||
interface TaskDetailModalProps {
|
interface TaskDetailModalProps {
|
||||||
task: Thought | null;
|
task: Thought | null;
|
||||||
@@ -35,7 +38,9 @@ interface TaskDetailModalProps {
|
|||||||
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
||||||
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||||
onConvertToDoc: (task: Thought) => void;
|
onConvertToDoc: (task: Thought) => void;
|
||||||
|
onOpenDocument?: (docId: string, blockId?: string) => void;
|
||||||
onDeleteTask: (taskId: string) => void;
|
onDeleteTask: (taskId: string) => void;
|
||||||
|
readOnly?: boolean; // Task belongs to a list shared with "viewer" permission
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||||
@@ -55,7 +60,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
onAddSubtask,
|
onAddSubtask,
|
||||||
onDeleteSubtask,
|
onDeleteSubtask,
|
||||||
onConvertToDoc,
|
onConvertToDoc,
|
||||||
|
onOpenDocument,
|
||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
|
readOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
if (!isOpen || !task) return null;
|
if (!isOpen || !task) return null;
|
||||||
|
|
||||||
@@ -163,7 +170,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0;
|
const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
|
<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
|
<div
|
||||||
className="w-full max-w-2xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] overflow-hidden"
|
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()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
@@ -172,6 +179,7 @@ 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 justify-between px-4 py-3 border-b border-border bg-panel">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<button
|
<button
|
||||||
|
disabled={readOnly}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
const nextState = !task.isCompleted;
|
const nextState = !task.isCompleted;
|
||||||
@@ -180,7 +188,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo');
|
const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo');
|
||||||
onUpdateStatus(task, nextKanban);
|
onUpdateStatus(task, nextKanban);
|
||||||
}}
|
}}
|
||||||
className={`w-5 h-5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${
|
className={`w-5 h-5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border disabled:cursor-default ${
|
||||||
task.isCompleted
|
task.isCompleted
|
||||||
? 'bg-status-ok border-status-ok text-on-accent'
|
? 'bg-status-ok border-status-ok text-on-accent'
|
||||||
: 'border-strong hover:border-accent text-transparent'
|
: 'border-strong hover:border-accent text-transparent'
|
||||||
@@ -190,23 +198,53 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
<Check className="w-3.5 h-3.5 stroke-[3]" />
|
<Check className="w-3.5 h-3.5 stroke-[3]" />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs font-semibold text-main truncate">
|
<span className="text-xs font-semibold text-main truncate">
|
||||||
{task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
|
{readOnly ? 'Aufgabe ansehen' : task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
{(task.convertedType === 'document_block' || task.docId) ? (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
onClose();
|
onClick={() => {
|
||||||
onConvertToDoc(task);
|
onClose();
|
||||||
}}
|
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
|
||||||
title="Als Dokument / Notiz vertiefen"
|
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
|
||||||
className="btn-action h-7 px-2 text-xs flex items-center gap-1"
|
if (docId && onOpenDocument) onOpenDocument(docId, blockId);
|
||||||
>
|
}}
|
||||||
<FileText className="w-3.5 h-3.5 text-accent" />
|
title="Zur verlinkten Notiz springen"
|
||||||
<span className="hidden sm:inline">Als Notiz vertiefen</span>
|
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"
|
||||||
</button>
|
>
|
||||||
|
<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 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -216,23 +254,25 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Aufgabe löschen"
|
title="Aufgabe löschen"
|
||||||
className="text-muted hover:text-status-err p-1.5 transition-colors"
|
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"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||||
|
<span className="sm:hidden font-medium">Löschen</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-muted hover:text-main p-1.5 transition-colors"
|
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"
|
||||||
title="Schließen"
|
title="Schließen"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-5 h-5 sm:w-4 sm:h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Body */}
|
{/* Scrollable Body (all controls disabled for read-only shares) */}
|
||||||
<div className="p-4 overflow-y-auto space-y-4 flex-1">
|
<fieldset disabled={readOnly} className="p-4 overflow-y-auto space-y-4 flex-1 min-w-0 border-0 m-0">
|
||||||
{/* Task Title */}
|
{/* Task Title */}
|
||||||
<div>
|
<div>
|
||||||
{isEditingTitle ? (
|
{isEditingTitle ? (
|
||||||
@@ -275,9 +315,11 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
onClick={() => setIsEditingTitle(true)}
|
onClick={() => !readOnly && setIsEditingTitle(true)}
|
||||||
className="group cursor-pointer p-2 rounded-card hover:bg-panel transition-colors border border-transparent hover:border-border"
|
className={`group p-2 rounded-card transition-colors border border-transparent ${
|
||||||
title="Klicken zum Bearbeiten des Titels"
|
readOnly ? '' : 'cursor-pointer hover:bg-panel hover:border-border'
|
||||||
|
}`}
|
||||||
|
title={readOnly ? undefined : 'Klicken zum Bearbeiten des Titels'}
|
||||||
>
|
>
|
||||||
<h2
|
<h2
|
||||||
className={`text-base font-semibold break-words leading-snug ${
|
className={`text-base font-semibold break-words leading-snug ${
|
||||||
@@ -286,9 +328,11 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
>
|
>
|
||||||
{task.content}
|
{task.content}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity">
|
{!readOnly && (
|
||||||
Klicken zum Umbenennen
|
<span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
</span>
|
Klicken zum Umbenennen
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -334,29 +378,49 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Priority Buttons */}
|
{/* Priority Buttons (4 Buttons) */}
|
||||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
<div className="space-y-1.5 pt-1 border-t border-border">
|
||||||
<label className="block text-xs font-semibold text-main">
|
<label className="block text-xs font-semibold text-main">
|
||||||
Priorität:
|
Priorität:
|
||||||
</label>
|
</label>
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onUpdatePriority(task, 'normal')}
|
onClick={() => {
|
||||||
className={`h-7 px-3 text-xs rounded-btn font-medium transition-colors border ${
|
triggerHaptic(10);
|
||||||
!task.priority || task.priority === 'normal' || task.priority === 'low'
|
onUpdatePriority(task, 'low');
|
||||||
? 'bg-panel border-accent text-main font-semibold'
|
}}
|
||||||
|
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'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Normal / Keine
|
<span>Niedrig</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onUpdatePriority(task, 'high')}
|
onClick={() => {
|
||||||
className={`h-7 px-3 text-xs rounded-btn font-medium transition-colors border flex items-center gap-1 ${
|
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 ${
|
||||||
task.priority === 'high'
|
task.priority === 'high'
|
||||||
? 'bg-status-warn/15 border-status-warn text-status-warn font-semibold'
|
? 'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -365,10 +429,13 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onUpdatePriority(task, 'urgent')}
|
onClick={() => {
|
||||||
className={`h-7 px-3 text-xs rounded-btn font-medium transition-colors border flex items-center gap-1 ${
|
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 ${
|
||||||
task.priority === 'urgent'
|
task.priority === 'urgent'
|
||||||
? 'bg-status-err/15 border-status-err text-status-err font-semibold'
|
? 'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -464,15 +531,18 @@ 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"
|
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>
|
<span>#{tag}</span>
|
||||||
<button
|
{!readOnly && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => handleRemoveTag(tag)}
|
type="button"
|
||||||
className="text-muted hover:text-status-err"
|
onClick={() => handleRemoveTag(tag)}
|
||||||
>
|
className="text-muted hover:text-status-err"
|
||||||
<X className="w-3 h-3" />
|
>
|
||||||
</button>
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
{!readOnly && (
|
||||||
<form onSubmit={handleAddTagSubmit} className="inline-flex items-center gap-1">
|
<form onSubmit={handleAddTagSubmit} className="inline-flex items-center gap-1">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -482,6 +552,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
className="h-6 w-24 px-1.5 text-xs input-field"
|
className="h-6 w-24 px-1.5 text-xs input-field"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -536,6 +607,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
{st.title}
|
{st.title}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onDeleteSubtask(task, st.id)}
|
onClick={() => onDeleteSubtask(task, st.id)}
|
||||||
@@ -543,12 +615,14 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
>
|
>
|
||||||
<Trash2 className="w-3 h-3" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Inline add subtask form */}
|
{/* Inline add subtask form */}
|
||||||
|
{!readOnly && (
|
||||||
<form onSubmit={handleAddSubtaskSubmit} className="flex items-center gap-1.5">
|
<form onSubmit={handleAddSubtaskSubmit} className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -566,6 +640,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
<span>Hinzufügen</span>
|
<span>Hinzufügen</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes Section */}
|
{/* Notes Section */}
|
||||||
@@ -575,7 +650,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
<FileText className="w-3.5 h-3.5 text-accent" />
|
<FileText className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Detailnotizen:</span>
|
<span>Detailnotizen:</span>
|
||||||
</label>
|
</label>
|
||||||
{!isEditingNotes && task.notes && (
|
{!readOnly && !isEditingNotes && task.notes && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -622,14 +697,19 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
) : task.notes ? (
|
) : task.notes ? (
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (readOnly) return;
|
||||||
setNotesDraft(task.notes || '');
|
setNotesDraft(task.notes || '');
|
||||||
setIsEditingNotes(true);
|
setIsEditingNotes(true);
|
||||||
}}
|
}}
|
||||||
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"
|
className={`p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed transition-colors ${
|
||||||
title="Klicken zum Bearbeiten"
|
readOnly ? '' : 'cursor-pointer hover:border-strong'
|
||||||
|
}`}
|
||||||
|
title={readOnly ? undefined : 'Klicken zum Bearbeiten'}
|
||||||
>
|
>
|
||||||
{task.notes}
|
<MarkdownViewer content={task.notes} />
|
||||||
</div>
|
</div>
|
||||||
|
) : readOnly ? (
|
||||||
|
<p className="text-xs text-faint">Keine Detailnotizen.</p>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -644,7 +724,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</fieldset>
|
||||||
|
|
||||||
{/* Modal Footer */}
|
{/* Modal Footer */}
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
||||||
|
|||||||
@@ -139,11 +139,21 @@ 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"
|
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-3">
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||||
<span className="w-2 h-2 rounded-full bg-accent flex-shrink-0" />
|
<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">
|
<span className="text-sm sm:text-xs font-medium text-main line-clamp-2 sm:truncate">
|
||||||
{item.content}
|
{item.content}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */}
|
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */}
|
||||||
@@ -413,15 +423,16 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sourceBadge = (source: string) => {
|
const sourceBadge = (source?: string) => {
|
||||||
if (source === 'voice') {
|
const s = (source || '').toLowerCase().trim();
|
||||||
|
if (s === 'voice') {
|
||||||
return (
|
return (
|
||||||
<span className="px-2 py-0.5 text-[11px] bg-canvas text-accent border border-border rounded-btn flex items-center gap-1">
|
<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
|
<Mic className="w-3 h-3" /> Diktat
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (source === 'webhook') {
|
if (s === 'webhook') {
|
||||||
return (
|
return (
|
||||||
<span className="px-2 py-0.5 text-[11px] bg-canvas text-muted border border-border rounded-btn flex items-center gap-1">
|
<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
|
<Webhook className="w-3 h-3 text-accent" /> Webhook
|
||||||
@@ -500,7 +511,10 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
key={nextCard.id + '-preview'}
|
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"
|
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-2">{priorityBadge(nextCard.priority)}</div>
|
<div className="flex items-center gap-1.5">
|
||||||
|
{priorityBadge(nextCard.priority)}
|
||||||
|
{sourceBadge(nextCard.source)}
|
||||||
|
</div>
|
||||||
<p className="text-sm text-muted line-clamp-2">{nextCard.content}</p>
|
<p className="text-sm text-muted line-clamp-2">{nextCard.content}</p>
|
||||||
<div className="text-[11px] text-faint">Nächster Quick Dump…</div>
|
<div className="text-[11px] text-faint">Nächster Quick Dump…</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -811,7 +825,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
{/* LONG-PRESS / MORE OPTIONS ACTION SHEET (For Triage Card & Queue Items) */}
|
{/* LONG-PRESS / MORE OPTIONS ACTION SHEET (For Triage Card & Queue Items) */}
|
||||||
{actionSheetCard && (
|
{actionSheetCard && (
|
||||||
<div
|
<div
|
||||||
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"
|
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"
|
||||||
onClick={() => setActionSheetCard(null)}
|
onClick={() => setActionSheetCard(null)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Link as LinkIcon,
|
||||||
|
FileText,
|
||||||
|
Globe,
|
||||||
|
X,
|
||||||
|
Search,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
import { Document } from '../../lib/types';
|
||||||
|
|
||||||
|
export interface LinkModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onInsertLink: (markdown: string) => void;
|
||||||
|
availableDocs?: { id: string; title: string; icon?: string; folderPath?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LinkModal: React.FC<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,957 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
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,10 +348,21 @@ body {
|
|||||||
|
|
||||||
/* Modal-Hintergrund-Overlay */
|
/* Modal-Hintergrund-Overlay */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
|
margin: 0 !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
background-color: var(--bg-overlay);
|
background-color: var(--bg-overlay);
|
||||||
backdrop-filter: blur(4px);
|
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 */
|
/* File Tree Item */
|
||||||
.tree-node-active {
|
.tree-node-active {
|
||||||
background-color: var(--bg-panel-elevated);
|
background-color: var(--bg-panel-elevated);
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ import {
|
|||||||
Document,
|
Document,
|
||||||
DocumentBlock,
|
DocumentBlock,
|
||||||
DocShare,
|
DocShare,
|
||||||
DocLockInfo,
|
|
||||||
KanbanColumn,
|
KanbanColumn,
|
||||||
ListItem,
|
ListItem,
|
||||||
|
ListShare,
|
||||||
Organization,
|
Organization,
|
||||||
PriorityLevel,
|
PriorityLevel,
|
||||||
SharedList,
|
SharedList,
|
||||||
|
SharedFolder,
|
||||||
|
FolderShare,
|
||||||
Space,
|
Space,
|
||||||
SpaceMember,
|
SpaceMember,
|
||||||
SpaceShare,
|
SpaceShare,
|
||||||
@@ -34,6 +36,19 @@ export function getToken(): string | null {
|
|||||||
return localStorage.getItem(TOKEN_STORAGE_KEY);
|
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 {
|
export function setToken(token: string): void {
|
||||||
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||||
}
|
}
|
||||||
@@ -114,6 +129,18 @@ export const api = {
|
|||||||
return request<AuthStatus>('/api/v1/auth/status');
|
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: {
|
async completeSetup(payload: {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -162,6 +189,7 @@ export const api = {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
alarmChannels?: string[];
|
alarmChannels?: string[];
|
||||||
alarmEmail?: string;
|
alarmEmail?: string;
|
||||||
discordWebhookUrl?: string;
|
discordWebhookUrl?: string;
|
||||||
@@ -281,6 +309,10 @@ export const api = {
|
|||||||
return ensureArray(res);
|
return ensureArray(res);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getThought(thoughtId: string): Promise<Thought> {
|
||||||
|
return request<Thought>(`/api/v1/thoughts/${encodeURIComponent(thoughtId)}`);
|
||||||
|
},
|
||||||
|
|
||||||
async triageAction(
|
async triageAction(
|
||||||
thoughtId: string,
|
thoughtId: string,
|
||||||
action: 'keep' | 'discard' | 'restore',
|
action: 'keep' | 'discard' | 'restore',
|
||||||
@@ -309,6 +341,14 @@ export const api = {
|
|||||||
return ensureArray(res);
|
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(
|
async updateThought(
|
||||||
thoughtId: string,
|
thoughtId: string,
|
||||||
patch: {
|
patch: {
|
||||||
@@ -390,7 +430,7 @@ export const api = {
|
|||||||
|
|
||||||
async addTaskListShare(
|
async addTaskListShare(
|
||||||
id: string,
|
id: string,
|
||||||
payload: { targetType: 'team' | 'user'; targetId: string }
|
payload: { targetType: 'team' | 'user'; targetId: string; permission?: 'editor' | 'viewer' }
|
||||||
): Promise<TaskListShare> {
|
): Promise<TaskListShare> {
|
||||||
return request<TaskListShare>(`/api/v1/task-lists/${id}/shares`, {
|
return request<TaskListShare>(`/api/v1/task-lists/${id}/shares`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -422,6 +462,60 @@ 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 ---
|
// --- WORKSPACE TAGS ---
|
||||||
async getWorkspaceTags(): Promise<TagSummary[]> {
|
async getWorkspaceTags(): Promise<TagSummary[]> {
|
||||||
const res = await request<TagSummary[]>('/api/v1/workspace/tags');
|
const res = await request<TagSummary[]>('/api/v1/workspace/tags');
|
||||||
@@ -517,10 +611,65 @@ 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> {
|
async deleteDocument(id: string): Promise<void> {
|
||||||
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
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 ---
|
// --- DOCUMENT SHARING ---
|
||||||
async getDocShares(docId: string): Promise<DocShare[]> {
|
async getDocShares(docId: string): Promise<DocShare[]> {
|
||||||
const res = await request<DocShare[]>(`/api/v1/documents/${docId}/shares`);
|
const res = await request<DocShare[]>(`/api/v1/documents/${docId}/shares`);
|
||||||
@@ -546,44 +695,6 @@ export const api = {
|
|||||||
return ensureArray(res);
|
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 ---
|
// --- SPACES & REALTIME LISTS ---
|
||||||
async getSpaces(): Promise<Space[]> {
|
async getSpaces(): Promise<Space[]> {
|
||||||
const res = await request<Space[]>('/api/v1/spaces');
|
const res = await request<Space[]>('/api/v1/spaces');
|
||||||
@@ -639,17 +750,36 @@ export const api = {
|
|||||||
return ensureArray(res).map((l) => ({
|
return ensureArray(res).map((l) => ({
|
||||||
...l,
|
...l,
|
||||||
items: ensureArray(l.items),
|
items: ensureArray(l.items),
|
||||||
|
shares: ensureArray(l.shares),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
async createList(spaceId: string, title: string, icon: string, listType: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
|
async getListById(listId: string): Promise<SharedList> {
|
||||||
const l = await request<SharedList>(`/api/v1/spaces/${spaceId}/lists`, {
|
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', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ title, icon, listType }),
|
body: JSON.stringify({ title, icon, listType: type }),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...l,
|
...l,
|
||||||
items: ensureArray(l.items),
|
items: ensureArray(l.items),
|
||||||
|
shares: ensureArray(l.shares),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -657,6 +787,22 @@ export const api = {
|
|||||||
await request(`/api/v1/lists/${listId}`, { method: 'DELETE' });
|
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> {
|
async createListItem(listId: string, content: string, category = '', quantity = ''): Promise<ListItem> {
|
||||||
return request<ListItem>(`/api/v1/lists/${listId}/items`, {
|
return request<ListItem>(`/api/v1/lists/${listId}/items`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -671,6 +817,17 @@ 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> {
|
async deleteListItem(listId: string, itemId: string): Promise<void> {
|
||||||
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
@@ -681,6 +838,16 @@ 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 ---
|
// --- ORGANIZATIONS & TEAMS ---
|
||||||
async getOrganizations(): Promise<Organization[]> {
|
async getOrganizations(): Promise<Organization[]> {
|
||||||
const res = await request<Organization[]>('/api/v1/organizations');
|
const res = await request<Organization[]>('/api/v1/organizations');
|
||||||
@@ -900,34 +1067,233 @@ export function getOfflineQueue(): Thought[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let activeGlobalWS: WebSocket | null = null;
|
export type RealtimeTransport = 'ws' | 'sse' | 'none';
|
||||||
let wsReconnectTimer: any = null;
|
|
||||||
let wsListeners: ((msg: WSMessage) => void)[] = [];
|
|
||||||
let manualDisconnect = false;
|
|
||||||
|
|
||||||
export function buildWebSocketURL(spaceId?: string): string {
|
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 {
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
const token = getToken() || '';
|
const token = getToken() || '';
|
||||||
if (spaceId) {
|
if (listId) {
|
||||||
return `${protocol}//${window.location.host}/api/v1/ws/spaces/${encodeURIComponent(spaceId)}?token=${encodeURIComponent(token)}`;
|
return `${protocol}//${window.location.host}/api/v1/ws/lists/${encodeURIComponent(listId)}?token=${encodeURIComponent(token)}`;
|
||||||
}
|
}
|
||||||
return `${protocol}//${window.location.host}/api/v1/ws?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 {
|
export function connectWebSocket(): void {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
manualDisconnect = false;
|
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)) {
|
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearTimeout(wsConnectionWatchdog);
|
||||||
|
clearTimeout(wsReconnectTimer);
|
||||||
|
clearTimeout(sseReconnectTimer);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = buildWebSocketURL();
|
const url = buildWebSocketURL();
|
||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(url);
|
||||||
activeGlobalWS = ws;
|
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) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(event.data) as WSMessage;
|
const msg = JSON.parse(event.data) as WSMessage;
|
||||||
@@ -944,40 +1310,99 @@ export function connectWebSocket(): void {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onclose = () => {
|
ws.onclose = (evt: CloseEvent) => {
|
||||||
|
clearTimeout(wsConnectionWatchdog);
|
||||||
activeGlobalWS = null;
|
activeGlobalWS = null;
|
||||||
if (!manualDisconnect && getToken()) {
|
|
||||||
|
// 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') {
|
||||||
clearTimeout(wsReconnectTimer);
|
clearTimeout(wsReconnectTimer);
|
||||||
|
const delay = wsReconnectDelay;
|
||||||
|
wsReconnectDelay = Math.min(30000, Math.round(wsReconnectDelay * 1.5));
|
||||||
wsReconnectTimer = setTimeout(() => {
|
wsReconnectTimer = setTimeout(() => {
|
||||||
connectWebSocket();
|
if (!manualDisconnect && getToken()) {
|
||||||
}, 3000);
|
connectWebSocket();
|
||||||
|
}
|
||||||
|
}, delay);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onerror = () => {
|
ws.onerror = (e) => {
|
||||||
try {
|
clearTimeout(wsConnectionWatchdog);
|
||||||
ws.close();
|
switchToSSE('WebSocket Fehler (Handshake/Verbindung verweigert)');
|
||||||
} catch {}
|
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to connect WebSocket', e);
|
switchToSSE('WebSocket Exception beim Initialisieren');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
export function disconnectWebSocket(): void {
|
||||||
manualDisconnect = true;
|
manualDisconnect = true;
|
||||||
clearTimeout(wsReconnectTimer);
|
clearTimeout(wsReconnectTimer);
|
||||||
|
clearTimeout(wsConnectionWatchdog);
|
||||||
|
clearTimeout(sseReconnectTimer);
|
||||||
if (activeGlobalWS) {
|
if (activeGlobalWS) {
|
||||||
try {
|
try {
|
||||||
activeGlobalWS.close();
|
activeGlobalWS.close();
|
||||||
} catch {}
|
} catch {}
|
||||||
activeGlobalWS = null;
|
activeGlobalWS = null;
|
||||||
}
|
}
|
||||||
|
if (activeGlobalSSE) {
|
||||||
|
try {
|
||||||
|
activeGlobalSSE.close();
|
||||||
|
} catch {}
|
||||||
|
activeGlobalSSE = null;
|
||||||
|
}
|
||||||
|
notifyRealtimeStatusChange(false, 'none');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendWebSocketMessage(msg: any): void {
|
export function sendWebSocketMessage(msg: any): void {
|
||||||
if (activeGlobalWS && activeGlobalWS.readyState === WebSocket.OPEN) {
|
if (activeGlobalWS && activeGlobalWS.readyState === WebSocket.OPEN) {
|
||||||
activeGlobalWS.send(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -987,3 +1412,86 @@ export function onWebSocketMessage(callback: (msg: WSMessage) => void): () => vo
|
|||||||
wsListeners = wsListeners.filter((fn) => fn !== callback);
|
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(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
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,6 +14,7 @@ export interface User {
|
|||||||
email: string;
|
email: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
avatarUrl?: string;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
alarmChannels?: string[];
|
alarmChannels?: string[];
|
||||||
@@ -52,14 +53,17 @@ export interface Thought {
|
|||||||
source: 'manual' | 'voice' | 'webhook';
|
source: 'manual' | 'voice' | 'webhook';
|
||||||
status: ThoughtStatus;
|
status: ThoughtStatus;
|
||||||
isCompleted: boolean;
|
isCompleted: boolean;
|
||||||
convertedType?: 'task' | 'document' | 'list_item';
|
convertedType?: 'task' | 'document' | 'list_item' | 'document_block';
|
||||||
convertedRefId?: string;
|
convertedRefId?: string;
|
||||||
|
docId?: string;
|
||||||
|
blockId?: string;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
taskListId?: string;
|
taskListId?: string;
|
||||||
kanbanStatus?: string;
|
kanbanStatus?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
subtasks?: SubTask[];
|
subtasks?: SubTask[];
|
||||||
alarmSent?: boolean;
|
alarmSent?: boolean;
|
||||||
|
sortOrder?: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
triagedAt?: string;
|
triagedAt?: string;
|
||||||
}
|
}
|
||||||
@@ -78,6 +82,8 @@ export interface TaskListShare {
|
|||||||
targetId: string;
|
targetId: string;
|
||||||
targetName?: string;
|
targetName?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
permission?: 'editor' | 'viewer';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +91,7 @@ export interface TaskListMember {
|
|||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
avatarUrl?: string;
|
||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +105,7 @@ export interface TaskList {
|
|||||||
ownerId?: string;
|
ownerId?: string;
|
||||||
ownerName?: string;
|
ownerName?: string;
|
||||||
isOwner?: boolean;
|
isOwner?: boolean;
|
||||||
|
permission?: 'owner' | 'editor' | 'viewer';
|
||||||
members?: TaskListMember[];
|
members?: TaskListMember[];
|
||||||
shares?: TaskListShare[];
|
shares?: TaskListShare[];
|
||||||
columns?: KanbanColumn[];
|
columns?: KanbanColumn[];
|
||||||
@@ -131,6 +139,7 @@ export interface TeamMember {
|
|||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
avatarUrl?: string;
|
||||||
role: 'admin' | 'member';
|
role: 'admin' | 'member';
|
||||||
joinedAt: string;
|
joinedAt: string;
|
||||||
}
|
}
|
||||||
@@ -148,16 +157,37 @@ export interface SystemSettings {
|
|||||||
smtpConfiguredViaEnv?: boolean;
|
smtpConfiguredViaEnv?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
|
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
|
||||||
|
|
||||||
export interface DocumentBlock {
|
export interface DocumentBlock {
|
||||||
id: string;
|
id: string;
|
||||||
|
docId?: string;
|
||||||
|
documentId?: string;
|
||||||
|
taskId?: string;
|
||||||
type: BlockType;
|
type: BlockType;
|
||||||
content: string;
|
content: string;
|
||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
lang?: string;
|
lang?: string;
|
||||||
level?: number;
|
level?: number;
|
||||||
|
position?: number;
|
||||||
collapsed?: boolean;
|
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 {
|
export interface DocShare {
|
||||||
@@ -175,19 +205,14 @@ export interface DocShare {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocLockInfo {
|
|
||||||
docId: string;
|
|
||||||
userId: string;
|
|
||||||
userName: string;
|
|
||||||
expiresAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Document {
|
export interface Document {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
spaceId?: string;
|
spaceId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
folderId?: string;
|
||||||
|
markdownCache?: string;
|
||||||
content: string;
|
content: string;
|
||||||
blocks: DocumentBlock[];
|
blocks: DocumentBlock[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
@@ -202,6 +227,36 @@ export interface Document {
|
|||||||
isOwner?: boolean;
|
isOwner?: boolean;
|
||||||
ownerName?: string;
|
ownerName?: string;
|
||||||
sharedWith?: DocShare[];
|
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 {
|
export interface TagSummary {
|
||||||
@@ -239,6 +294,7 @@ export interface SpaceMember {
|
|||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
avatarUrl?: string;
|
||||||
role: 'owner' | 'editor' | 'viewer';
|
role: 'owner' | 'editor' | 'viewer';
|
||||||
joinedAt: string;
|
joinedAt: string;
|
||||||
}
|
}
|
||||||
@@ -249,6 +305,7 @@ export interface UnifiedShare {
|
|||||||
targetId: string;
|
targetId: string;
|
||||||
targetName?: string;
|
targetName?: string;
|
||||||
targetEmail?: string;
|
targetEmail?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
permission?: 'editor' | 'viewer';
|
permission?: 'editor' | 'viewer';
|
||||||
role?: string;
|
role?: string;
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
@@ -295,13 +352,30 @@ export interface ListItem {
|
|||||||
completedAt?: string;
|
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 {
|
export interface SharedList {
|
||||||
id: string;
|
id: string;
|
||||||
spaceId: string;
|
userId?: string;
|
||||||
|
ownerName?: string;
|
||||||
|
isOwner?: boolean;
|
||||||
|
role?: string;
|
||||||
|
spaceId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
listType: 'shopping' | 'checklist' | 'vacation';
|
listType: 'shopping' | 'checklist' | 'vacation';
|
||||||
items: ListItem[];
|
items: ListItem[];
|
||||||
|
shares?: ListShare[];
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
@@ -331,6 +405,7 @@ export interface WSMessage<T = any> {
|
|||||||
event: string;
|
event: string;
|
||||||
space_id?: string;
|
space_id?: string;
|
||||||
list_id?: string;
|
list_id?: string;
|
||||||
|
listId?: string;
|
||||||
payload: T;
|
payload: T;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import { MobileActionProvider } from './context/MobileActionContext';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<MobileActionProvider>
|
||||||
|
<App />
|
||||||
|
</MobileActionProvider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2,6 +2,7 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sandrodach/mindsift/internal/auth"
|
"github.com/sandrodach/mindsift/internal/auth"
|
||||||
"github.com/sandrodach/mindsift/internal/config"
|
"github.com/sandrodach/mindsift/internal/config"
|
||||||
@@ -43,7 +45,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
|
|||||||
hub := ws.NewHub()
|
hub := ws.NewHub()
|
||||||
srv := NewServer(cfg, store, hub, nil)
|
srv := NewServer(cfg, store, hub, nil)
|
||||||
|
|
||||||
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to generate token: %v", err)
|
t.Fatalf("failed to generate token: %v", err)
|
||||||
}
|
}
|
||||||
@@ -117,7 +119,6 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
|||||||
"/api/v1/tasks",
|
"/api/v1/tasks",
|
||||||
"/api/v1/organizations",
|
"/api/v1/organizations",
|
||||||
"/api/v1/teams",
|
"/api/v1/teams",
|
||||||
"/api/v1/spaces",
|
|
||||||
"/api/v1/lists",
|
"/api/v1/lists",
|
||||||
}
|
}
|
||||||
for _, ep := range arrayEndpoints {
|
for _, ep := range arrayEndpoints {
|
||||||
@@ -190,7 +191,6 @@ func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
|
|||||||
"/api/v1/auth/me",
|
"/api/v1/auth/me",
|
||||||
"/api/v1/triage/deck",
|
"/api/v1/triage/deck",
|
||||||
"/api/v1/lists",
|
"/api/v1/lists",
|
||||||
"/api/v1/spaces",
|
|
||||||
"/api/v1/tasks",
|
"/api/v1/tasks",
|
||||||
"/api/v1/documents",
|
"/api/v1/documents",
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
|||||||
router := srv.Router()
|
router := srv.Router()
|
||||||
|
|
||||||
// Verify stale token from non-existent user is rejected (401)
|
// Verify stale token from non-existent user is rejected (401)
|
||||||
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old")
|
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old", 1)
|
||||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+staleToken)
|
req.Header.Set("Authorization", "Bearer "+staleToken)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -548,113 +548,113 @@ func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
|
|||||||
t.Fatalf("failed to get normal user: %v", err)
|
t.Fatalf("failed to get normal user: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName)
|
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to generate token: %v", err)
|
t.Fatalf("failed to generate token: %v", err)
|
||||||
}
|
}
|
||||||
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName)
|
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to generate token: %v", err)
|
t.Fatalf("failed to generate token: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Admin creates a space
|
// 1. Admin creates a list
|
||||||
createSpaceBody := []byte(`{"name":"Einkauf WG","icon":"🛒","description":"Gemeinsame Einkaufsliste"}`)
|
createListBody := []byte(`{"title":"Einkauf WG","icon":"🛒","listType":"shopping"}`)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/spaces", bytes.NewReader(createSpaceBody))
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/lists", bytes.NewReader(createListBody))
|
||||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("expected 201 Created from /api/v1/spaces, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("expected 201 Created from /api/v1/lists, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
var createdSpace domain.Space
|
var createdList domain.SharedList
|
||||||
_ = json.Unmarshal(rec.Body.Bytes(), &createdSpace)
|
_ = json.Unmarshal(rec.Body.Bytes(), &createdList)
|
||||||
if createdSpace.ID == "" {
|
if createdList.ID == "" {
|
||||||
t.Fatalf("expected created space with ID, got %+v", createdSpace)
|
t.Fatalf("expected created list with ID, got %+v", createdList)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Admin invites normalUser by username "user"
|
// 2. Admin shares list with normalUser by username "user"
|
||||||
addMemberBody := []byte(`{"userId":"user","role":"editor"}`)
|
addShareBody := []byte(`{"targetType":"user","targetId":"user","role":"editor"}`)
|
||||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addMemberBody))
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addShareBody))
|
||||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusCreated {
|
if rec.Code != http.StatusCreated {
|
||||||
t.Fatalf("expected 201 Created from add space member, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("expected 201 Created from add list share, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
var newMember domain.SpaceMember
|
var newShare domain.ListShare
|
||||||
_ = json.Unmarshal(rec.Body.Bytes(), &newMember)
|
_ = json.Unmarshal(rec.Body.Bytes(), &newShare)
|
||||||
if newMember.UserID != normalUser.ID {
|
if newShare.TargetID != normalUser.ID {
|
||||||
t.Fatalf("expected member userId %s, got %s", normalUser.ID, newMember.UserID)
|
t.Fatalf("expected share targetId %s, got %s", normalUser.ID, newShare.TargetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. User checks their spaces; the shared space must be listed
|
// 3. User checks their lists; the shared list must be listed
|
||||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil)
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+userToken)
|
req.Header.Set("Authorization", "Bearer "+userToken)
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("expected 200 OK for user spaces, got %d", rec.Code)
|
t.Fatalf("expected 200 OK for user lists, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
var userSpaces []domain.Space
|
var userLists []domain.SharedList
|
||||||
_ = json.Unmarshal(rec.Body.Bytes(), &userSpaces)
|
_ = json.Unmarshal(rec.Body.Bytes(), &userLists)
|
||||||
found := false
|
found := false
|
||||||
for _, s := range userSpaces {
|
for _, l := range userLists {
|
||||||
if s.ID == createdSpace.ID {
|
if l.ID == createdList.ID {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
t.Fatalf("expected space %s to be visible to user, got %+v", createdSpace.ID, userSpaces)
|
t.Fatalf("expected list %s to be visible to user, got %+v", createdList.ID, userLists)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Admin removes user from space
|
// 4. Admin removes user share from list
|
||||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/spaces/"+createdSpace.ID+"/members/"+normalUser.ID, nil)
|
req = httptest.NewRequest(http.MethodDelete, "/api/v1/lists/"+createdList.ID+"/shares/"+newShare.ID, nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("expected 200 OK for remove member, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("expected 200 OK for remove share, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Test adding a team to a space
|
// 5. Test sharing list with a team
|
||||||
team, err := store.CreateTeam("", "WG Team", "Haushalt")
|
team, err := store.CreateTeam("", "WG Team", "Haushalt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create team: %v", err)
|
t.Fatalf("failed to create team: %v", err)
|
||||||
}
|
}
|
||||||
_ = store.AddTeamMember(team.ID, normalUser.ID, "member")
|
_ = store.AddTeamMember(team.ID, normalUser.ID, "member")
|
||||||
|
|
||||||
addTeamBody := []byte(`{"teamId":"` + team.ID + `","role":"editor"}`)
|
addTeamBody := []byte(`{"targetType":"team","targetId":"` + team.ID + `","role":"editor"}`)
|
||||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addTeamBody))
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addTeamBody))
|
||||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||||
t.Fatalf("expected 201 or 200 for add space team, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("expected 201 or 200 for add list team share, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// User should see the space again because their team was added
|
// User should see the list again because their team was added
|
||||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil)
|
req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+userToken)
|
req.Header.Set("Authorization", "Bearer "+userToken)
|
||||||
rec = httptest.NewRecorder()
|
rec = httptest.NewRecorder()
|
||||||
router.ServeHTTP(rec, req)
|
router.ServeHTTP(rec, req)
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("expected 200 OK for user spaces after team add, got %d", rec.Code)
|
t.Fatalf("expected 200 OK for user lists after team share add, got %d", rec.Code)
|
||||||
}
|
}
|
||||||
userSpaces = nil
|
userLists = nil
|
||||||
_ = json.Unmarshal(rec.Body.Bytes(), &userSpaces)
|
_ = json.Unmarshal(rec.Body.Bytes(), &userLists)
|
||||||
found = false
|
found = false
|
||||||
for _, s := range userSpaces {
|
for _, l := range userLists {
|
||||||
if s.ID == createdSpace.ID {
|
if l.ID == createdList.ID {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
t.Fatalf("expected space %s to be visible to user via team, got %+v", createdSpace.ID, userSpaces)
|
t.Fatalf("expected list %s to be visible to user via team share, got %+v", createdList.ID, userLists)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -663,7 +663,7 @@ func TestWorkspaceTagsAPI(t *testing.T) {
|
|||||||
router := srv.Router()
|
router := srv.Router()
|
||||||
|
|
||||||
user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member")
|
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)
|
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||||
|
|
||||||
// Create document with hashtags
|
// Create document with hashtags
|
||||||
blocks := []domain.DocumentBlock{
|
blocks := []domain.DocumentBlock{
|
||||||
@@ -749,3 +749,554 @@ func TestWorkspaceTagsAPI(t *testing.T) {
|
|||||||
t.Fatalf("expected 200 OK from DELETE /api/v1/folders, got %d", rec.Code)
|
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,9 +18,10 @@ type contextKey string
|
|||||||
const UserIDContextKey contextKey = "mindsift_user_id"
|
const UserIDContextKey contextKey = "mindsift_user_id"
|
||||||
|
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
|
TokenVersion int `json:"tokenVersion"`
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,14 +35,24 @@ func CheckPassword(password, hash string) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenerateToken(secret, userID, email, displayName string) (string, error) {
|
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()
|
||||||
claims := &Claims{
|
claims := &Claims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Email: email,
|
Email: email,
|
||||||
DisplayName: displayName,
|
DisplayName: displayName,
|
||||||
|
TokenVersion: tokenVersion,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
|
ID: jti,
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
Issuer: "mindsift",
|
Issuer: "mindsift",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,6 +15,7 @@ type Config struct {
|
|||||||
BaseURL string
|
BaseURL string
|
||||||
AllowRegistration bool
|
AllowRegistration bool
|
||||||
LogLevel string
|
LogLevel string
|
||||||
|
Debug bool
|
||||||
DemoMode bool
|
DemoMode bool
|
||||||
|
|
||||||
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
|
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
|
||||||
@@ -40,7 +42,14 @@ func Load() *Config {
|
|||||||
jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")
|
jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")
|
||||||
baseURL := getEnv("BASE_URL", "http://localhost:"+port)
|
baseURL := getEnv("BASE_URL", "http://localhost:"+port)
|
||||||
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
|
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
|
||||||
logLevel := getEnv("LOG_LEVEL", "info")
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
demoVal := strings.ToLower(getEnv("MINDA_DEMO", getEnv("MINDSIFT_DEMO", getEnv("DEMO_MODE", "false"))))
|
demoVal := strings.ToLower(getEnv("MINDA_DEMO", getEnv("MINDSIFT_DEMO", getEnv("DEMO_MODE", "false"))))
|
||||||
demoMode := demoVal == "true" || demoVal == "1" || demoVal == "yes" || demoVal == "on"
|
demoMode := demoVal == "true" || demoVal == "1" || demoVal == "yes" || demoVal == "on"
|
||||||
@@ -60,6 +69,7 @@ func Load() *Config {
|
|||||||
BaseURL: baseURL,
|
BaseURL: baseURL,
|
||||||
AllowRegistration: allowReg,
|
AllowRegistration: allowReg,
|
||||||
LogLevel: logLevel,
|
LogLevel: logLevel,
|
||||||
|
Debug: debug,
|
||||||
DemoMode: demoMode,
|
DemoMode: demoMode,
|
||||||
SMTPHost: smtpHost,
|
SMTPHost: smtpHost,
|
||||||
SMTPPort: smtpPort,
|
SMTPPort: smtpPort,
|
||||||
@@ -70,6 +80,20 @@ 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 {
|
func getEnv(key, fallback string) string {
|
||||||
if val, ok := os.LookupEnv(key); ok && strings.TrimSpace(val) != "" {
|
if val, ok := os.LookupEnv(key); ok && strings.TrimSpace(val) != "" {
|
||||||
return strings.TrimSpace(val)
|
return strings.TrimSpace(val)
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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,8 +98,10 @@ type User struct {
|
|||||||
Username string `json:"username,omitempty"`
|
Username string `json:"username,omitempty"`
|
||||||
PasswordHash string `json:"-"`
|
PasswordHash string `json:"-"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
|
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||||
Role string `json:"role"` // admin | member
|
Role string `json:"role"` // admin | member
|
||||||
IsActive bool `json:"isActive"`
|
IsActive bool `json:"isActive"`
|
||||||
|
TokenVersion int `json:"tokenVersion"`
|
||||||
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
|
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
|
||||||
AlarmEmail string `json:"alarmEmail"`
|
AlarmEmail string `json:"alarmEmail"`
|
||||||
DiscordWebhookURL string `json:"discordWebhookUrl"`
|
DiscordWebhookURL string `json:"discordWebhookUrl"`
|
||||||
@@ -146,18 +148,34 @@ type Thought struct {
|
|||||||
Source string `json:"source"` // manual | voice | webhook
|
Source string `json:"source"` // manual | voice | webhook
|
||||||
Status ThoughtStatus `json:"status"`
|
Status ThoughtStatus `json:"status"`
|
||||||
IsCompleted bool `json:"isCompleted"`
|
IsCompleted bool `json:"isCompleted"`
|
||||||
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item
|
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item | document_block
|
||||||
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
|
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
|
||||||
|
DocID string `json:"docId,omitempty"`
|
||||||
|
BlockID string `json:"blockId,omitempty"`
|
||||||
DueDate string `json:"dueDate,omitempty"`
|
DueDate string `json:"dueDate,omitempty"`
|
||||||
TaskListID string `json:"taskListId,omitempty"`
|
TaskListID string `json:"taskListId,omitempty"`
|
||||||
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
||||||
Notes string `json:"notes,omitempty"`
|
Notes string `json:"notes,omitempty"`
|
||||||
Subtasks []SubTask `json:"subtasks"`
|
Subtasks []SubTask `json:"subtasks"`
|
||||||
AlarmSent bool `json:"alarmSent,omitempty"`
|
AlarmSent bool `json:"alarmSent,omitempty"`
|
||||||
|
SortOrder int `json:"sortOrder"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
TriagedAt string `json:"triagedAt,omitempty"`
|
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 {
|
type TaskListShare struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
TaskListID string `json:"taskListId"`
|
TaskListID string `json:"taskListId"`
|
||||||
@@ -165,6 +183,8 @@ type TaskListShare struct {
|
|||||||
TargetID string `json:"targetId"`
|
TargetID string `json:"targetId"`
|
||||||
TargetName string `json:"targetName,omitempty"`
|
TargetName string `json:"targetName,omitempty"`
|
||||||
Email string `json:"email,omitempty"`
|
Email string `json:"email,omitempty"`
|
||||||
|
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||||
|
Permission string `json:"permission"` // editor | viewer
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +192,7 @@ type TaskListMember struct {
|
|||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||||
Role string `json:"role"` // owner | member
|
Role string `json:"role"` // owner | member
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +212,7 @@ type TaskList struct {
|
|||||||
TeamName string `json:"teamName,omitempty"`
|
TeamName string `json:"teamName,omitempty"`
|
||||||
OwnerName string `json:"ownerName,omitempty"`
|
OwnerName string `json:"ownerName,omitempty"`
|
||||||
IsOwner bool `json:"isOwner"`
|
IsOwner bool `json:"isOwner"`
|
||||||
|
Permission string `json:"permission,omitempty"` // owner | editor | viewer
|
||||||
Members []TaskListMember `json:"members,omitempty"`
|
Members []TaskListMember `json:"members,omitempty"`
|
||||||
Shares []TaskListShare `json:"shares,omitempty"`
|
Shares []TaskListShare `json:"shares,omitempty"`
|
||||||
Columns []KanbanColumn `json:"columns,omitempty"`
|
Columns []KanbanColumn `json:"columns,omitempty"`
|
||||||
@@ -224,6 +246,7 @@ type TeamMember struct {
|
|||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
|
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||||
Role string `json:"role"` // admin | member
|
Role string `json:"role"` // admin | member
|
||||||
JoinedAt string `json:"joinedAt"`
|
JoinedAt string `json:"joinedAt"`
|
||||||
}
|
}
|
||||||
@@ -242,13 +265,67 @@ type SystemSettings struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DocumentBlock struct {
|
type DocumentBlock struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress
|
DocumentID string `json:"documentId,omitempty"`
|
||||||
Content string `json:"content"`
|
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress
|
||||||
Checked bool `json:"checked,omitempty"`
|
Content string `json:"content"`
|
||||||
Lang string `json:"lang,omitempty"`
|
Checked bool `json:"checked,omitempty"`
|
||||||
Level int `json:"level,omitempty"`
|
Lang string `json:"lang,omitempty"`
|
||||||
Collapsed bool `json:"collapsed,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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DocShare struct {
|
type DocShare struct {
|
||||||
@@ -265,33 +342,32 @@ type DocShare struct {
|
|||||||
CreatedAt string `json:"createdAt"`
|
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 {
|
type Document struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
SpaceID string `json:"spaceId,omitempty"`
|
SpaceID string `json:"spaceId,omitempty"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Blocks []DocumentBlock `json:"blocks"`
|
MarkdownCache string `json:"markdownCache,omitempty"`
|
||||||
Tags []string `json:"tags"`
|
Blocks []DocumentBlock `json:"blocks"`
|
||||||
FolderPath string `json:"folderPath,omitempty"`
|
Tags []string `json:"tags"`
|
||||||
IsPinned bool `json:"isPinned"`
|
FolderID string `json:"folderId,omitempty"`
|
||||||
IsArchived bool `json:"isArchived"`
|
FolderPath string `json:"folderPath,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
IsPinned bool `json:"isPinned"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
IsArchived bool `json:"isArchived"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
|
||||||
// Collaboration & Permissions
|
// Collaboration & Permissions
|
||||||
Permission string `json:"permission"` // owner | write | read
|
Permission string `json:"permission"` // owner | write | read
|
||||||
IsOwner bool `json:"isOwner"`
|
IsOwner bool `json:"isOwner"`
|
||||||
OwnerName string `json:"ownerName,omitempty"`
|
OwnerName string `json:"ownerName,omitempty"`
|
||||||
SharedWith []DocShare `json:"sharedWith,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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TagSummary struct {
|
type TagSummary struct {
|
||||||
@@ -324,40 +400,18 @@ type TagDetails struct {
|
|||||||
TotalMatches int `json:"totalMatches"`
|
TotalMatches int `json:"totalMatches"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SpaceMember struct {
|
type ListShare 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"`
|
ID string `json:"id"`
|
||||||
SpaceID string `json:"spaceId"`
|
ListID string `json:"listId"`
|
||||||
TargetType string `json:"targetType"` // team | user
|
TargetType string `json:"targetType"` // team | user
|
||||||
TargetID string `json:"targetId"`
|
TargetID string `json:"targetId"`
|
||||||
TargetName string `json:"targetName,omitempty"`
|
TargetName string `json:"targetName,omitempty"`
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
Role string `json:"role"` // editor | viewer
|
Role string `json:"role"` // editor | viewer
|
||||||
CreatedBy string `json:"createdBy"`
|
CreatedBy string `json:"createdBy"`
|
||||||
CreatedAt string `json:"createdAt"`
|
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 {
|
type ListItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ListID string `json:"listId"`
|
ListID string `json:"listId"`
|
||||||
@@ -373,14 +427,18 @@ type ListItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SharedList struct {
|
type SharedList struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
SpaceID string `json:"spaceId"`
|
UserID string `json:"userId"`
|
||||||
Title string `json:"title"`
|
OwnerName string `json:"ownerName,omitempty"`
|
||||||
Icon string `json:"icon"`
|
IsOwner bool `json:"isOwner"`
|
||||||
ListType string `json:"listType"` // shopping | checklist | vacation
|
Role string `json:"role,omitempty"` // editor | viewer
|
||||||
Items []ListItem `json:"items"`
|
Title string `json:"title"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
Icon string `json:"icon"`
|
||||||
CreatedAt string `json:"createdAt"`
|
ListType string `json:"listType"` // shopping | checklist | vacation
|
||||||
|
Items []ListItem `json:"items"`
|
||||||
|
Shares []ListShare `json:"shares,omitempty"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIKey struct {
|
type APIKey struct {
|
||||||
@@ -397,8 +455,7 @@ type APIKey struct {
|
|||||||
|
|
||||||
type WSMessage struct {
|
type WSMessage struct {
|
||||||
Event string `json:"event"` // ITEM_CREATED | ITEM_UPDATED | ITEM_TOGGLED | ITEM_DELETED | LIST_CLEARED | USER_PRESENCE
|
Event string `json:"event"` // ITEM_CREATED | ITEM_UPDATED | ITEM_TOGGLED | ITEM_DELETED | LIST_CLEARED | USER_PRESENCE
|
||||||
SpaceID string `json:"space_id"`
|
ListID string `json:"list_id"`
|
||||||
ListID string `json:"list_id,omitempty"`
|
|
||||||
Payload interface{} `json:"payload"`
|
Payload interface{} `json:"payload"`
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
}
|
}
|
||||||
@@ -419,7 +476,32 @@ type FullExport struct {
|
|||||||
User User `json:"user"`
|
User User `json:"user"`
|
||||||
Thoughts []Thought `json:"thoughts"`
|
Thoughts []Thought `json:"thoughts"`
|
||||||
Documents []Document `json:"documents"`
|
Documents []Document `json:"documents"`
|
||||||
Spaces []Space `json:"spaces"`
|
|
||||||
Lists []SharedList `json:"lists"`
|
Lists []SharedList `json:"lists"`
|
||||||
TaskLists []TaskList `json:"taskLists,omitempty"`
|
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
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"errors"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -48,12 +48,8 @@ func TestStoreLifecycle(t *testing.T) {
|
|||||||
t.Fatalf("expected converted document, got status=%s type=%s ref=%s", triaged.Status, triaged.ConvertedType, triaged.ConvertedRefID)
|
t.Fatalf("expected converted document, got status=%s type=%s ref=%s", triaged.Status, triaged.ConvertedType, triaged.ConvertedRefID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Test Shared Space & Shopping List
|
// 3. Test Standalone Shopping List & Sharing
|
||||||
sp, err := store.CreateSpace(user.ID, "Test WG", "🍕", "Test Space", false)
|
lst, err := store.CreateList(user.ID, "REWE Einkauf", "🛒", "shopping")
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateSpace failed: %v", err)
|
|
||||||
}
|
|
||||||
lst, err := store.CreateList(sp.ID, "REWE Einkauf", "🛒", "shopping")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateList failed: %v", err)
|
t.Fatalf("CreateList failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -66,26 +62,26 @@ func TestStoreLifecycle(t *testing.T) {
|
|||||||
t.Fatalf("ToggleListItem failed: %v", err)
|
t.Fatalf("ToggleListItem failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test AddSpaceMember & RemoveSpaceMember
|
// Test AddListShare & RemoveListShare
|
||||||
u2, err := store.CreateUser("collab_space@example.com", "hash", "Collab User", "member")
|
u2, err := store.CreateUser("collab_space@example.com", "hash", "Collab User", "member")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateUser u2 failed: %v", err)
|
t.Fatalf("CreateUser u2 failed: %v", err)
|
||||||
}
|
}
|
||||||
member, err := store.AddSpaceMember(user.ID, sp.ID, u2.ID, "editor")
|
share, err := store.AddListShare(user.ID, lst.ID, "user", u2.ID, "editor")
|
||||||
if err != nil || member.UserID != u2.ID {
|
if err != nil || share.TargetID != u2.ID {
|
||||||
t.Fatalf("AddSpaceMember failed: %v", err)
|
t.Fatalf("AddListShare failed: %v", err)
|
||||||
}
|
}
|
||||||
members, err := store.GetSpaceMembers(sp.ID)
|
shares, err := store.GetListShares(lst.ID)
|
||||||
if err != nil || len(members) != 2 {
|
if err != nil || len(shares) != 1 {
|
||||||
t.Fatalf("expected 2 members, got %d", len(members))
|
t.Fatalf("expected 1 share, got %d", len(shares))
|
||||||
}
|
}
|
||||||
err = store.RemoveSpaceMember(user.ID, sp.ID, u2.ID)
|
err = store.RemoveListShare(user.ID, lst.ID, share.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RemoveSpaceMember failed: %v", err)
|
t.Fatalf("RemoveListShare failed: %v", err)
|
||||||
}
|
}
|
||||||
membersAfter, _ := store.GetSpaceMembers(sp.ID)
|
sharesAfter, _ := store.GetListShares(lst.ID)
|
||||||
if len(membersAfter) != 1 {
|
if len(sharesAfter) != 0 {
|
||||||
t.Fatalf("expected 1 member after remove, got %d", len(membersAfter))
|
t.Fatalf("expected 0 shares after remove, got %d", len(sharesAfter))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Test Webhook API Key generation & validation
|
// 4. Test Webhook API Key generation & validation
|
||||||
@@ -99,7 +95,7 @@ func TestStoreLifecycle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Test TaskLists CRUD
|
// 5. Test TaskLists CRUD
|
||||||
taskList, err := store.CreateTaskList(user.ID, sp.ID, "", "Haus & Garten", "🏡", "#10b981")
|
taskList, err := store.CreateTaskList(user.ID, "", "", "Haus & Garten", "🏡", "#10b981")
|
||||||
if err != nil || taskList.ID == "" {
|
if err != nil || taskList.ID == "" {
|
||||||
t.Fatalf("CreateTaskList failed: %v", err)
|
t.Fatalf("CreateTaskList failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -123,7 +119,7 @@ func TestStoreLifecycle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test TaskList Sharing with Team
|
// Test TaskList Sharing with Team
|
||||||
shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, user.ID)
|
shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, "editor", user.ID)
|
||||||
if err != nil || shareTeam.ID == "" {
|
if err != nil || shareTeam.ID == "" {
|
||||||
t.Fatalf("AddTaskListShare team failed: %v", err)
|
t.Fatalf("AddTaskListShare team failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -152,6 +148,45 @@ func TestStoreLifecycle(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateUser collab failed: %v", err)
|
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{
|
newDoc, err := store.CreateDocument(user.ID, "", "Test Note", "📄", "", []domain.DocumentBlock{
|
||||||
{ID: "blk_1", Type: "bullet", Content: "Geheim", Level: 0},
|
{ID: "blk_1", Type: "bullet", Content: "Geheim", Level: 0},
|
||||||
}, []string{"test"}, false)
|
}, []string{"test"}, false)
|
||||||
@@ -160,8 +195,8 @@ func TestStoreLifecycle(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Share with user2 as "read"
|
// Share with user2 as "read"
|
||||||
share, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read")
|
docShare, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read")
|
||||||
if err != nil || share.Permission != "read" {
|
if err != nil || docShare.Permission != "read" {
|
||||||
t.Fatalf("CreateDocShare failed: %v", err)
|
t.Fatalf("CreateDocShare failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,16 +213,10 @@ func TestStoreLifecycle(t *testing.T) {
|
|||||||
t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded")
|
t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade to write permission
|
// user2 is a read-only collaborator -> editing must fail
|
||||||
_, 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)
|
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
t.Fatalf("UpdateDocument failed for write collaborator: %v", err)
|
t.Fatalf("expected UpdateDocument to fail for read-only collaborator, but succeeded")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove share
|
// Remove share
|
||||||
@@ -336,7 +365,6 @@ func TestEnsureDemoAccountsAndLoginLookup(t *testing.T) {
|
|||||||
func TestFolderManagementAndDeletion(t *testing.T) {
|
func TestFolderManagementAndDeletion(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
dbPath := filepath.Join(tmpDir, "folder_test.db")
|
dbPath := filepath.Join(tmpDir, "folder_test.db")
|
||||||
docDir := filepath.Join(tmpDir, "workspace")
|
|
||||||
|
|
||||||
store, err := NewStore(dbPath)
|
store, err := NewStore(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -349,7 +377,7 @@ func TestFolderManagementAndDeletion(t *testing.T) {
|
|||||||
t.Fatalf("CreateUser failed: %v", err)
|
t.Fatalf("CreateUser failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Create folders on disk
|
// 1. Create folders
|
||||||
f1, err := store.CreateFolder(user.ID, "Projekte/MindSift")
|
f1, err := store.CreateFolder(user.ID, "Projekte/MindSift")
|
||||||
if err != nil || f1 != "Projekte/MindSift" {
|
if err != nil || f1 != "Projekte/MindSift" {
|
||||||
t.Fatalf("CreateFolder failed: %v, f1=%s", err, f1)
|
t.Fatalf("CreateFolder failed: %v, f1=%s", err, f1)
|
||||||
@@ -405,12 +433,6 @@ func TestFolderManagementAndDeletion(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatalf("expected doc2 to be deleted")
|
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) {
|
func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) {
|
||||||
@@ -516,3 +538,242 @@ func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) {
|
|||||||
t.Fatalf("expected vorrat and kühlregal, got %v", tagMapAfter)
|
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,8 +2,10 @@ package ws
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,56 +14,106 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
ReadBufferSize: 2048,
|
ReadBufferSize: 4096,
|
||||||
WriteBufferSize: 2048,
|
WriteBufferSize: 4096,
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
return true // Self-hosted reverse proxy compatible
|
return true // Self-hosted reverse proxy compatible
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
hub *Hub
|
hub *Hub
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
spaceID string
|
roomID string
|
||||||
userID string
|
userID string
|
||||||
send chan []byte
|
send chan []byte
|
||||||
|
subscribedRooms map[string]bool
|
||||||
|
sendMu sync.RWMutex
|
||||||
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type docLockItem struct {
|
func (c *Client) closeSend() {
|
||||||
docID string
|
c.sendMu.Lock()
|
||||||
userID string
|
defer c.sendMu.Unlock()
|
||||||
userName string
|
if !c.closed {
|
||||||
lockedAt time.Time
|
c.closed = true
|
||||||
expiresAt time.Time
|
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 Hub struct {
|
type Hub struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
spaceRooms map[string]map[*Client]bool
|
rooms map[string]map[*Client]bool
|
||||||
userRooms map[string]map[*Client]bool
|
userRooms map[string]map[*Client]bool
|
||||||
allClients map[*Client]bool
|
allClients map[*Client]bool
|
||||||
locks map[string]*docLockItem
|
sseClients map[*SSEClient]bool
|
||||||
|
sseUserRooms map[string]map[*SSEClient]bool
|
||||||
|
sseRooms map[string]map[*SSEClient]bool
|
||||||
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHub() *Hub {
|
func NewHub() *Hub {
|
||||||
h := &Hub{
|
return &Hub{
|
||||||
spaceRooms: make(map[string]map[*Client]bool),
|
rooms: make(map[string]map[*Client]bool),
|
||||||
userRooms: make(map[string]map[*Client]bool),
|
userRooms: make(map[string]map[*Client]bool),
|
||||||
allClients: make(map[*Client]bool),
|
allClients: make(map[*Client]bool),
|
||||||
locks: make(map[string]*docLockItem),
|
sseClients: make(map[*SSEClient]bool),
|
||||||
|
sseUserRooms: make(map[string]map[*SSEClient]bool),
|
||||||
|
sseRooms: make(map[string]map[*SSEClient]bool),
|
||||||
}
|
}
|
||||||
go h.cleanupLocksLoop()
|
|
||||||
return h
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) Register(c *Client) {
|
func (h *Hub) Register(c *Client) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
h.allClients[c] = true
|
h.allClients[c] = true
|
||||||
if c.spaceID != "" {
|
if c.roomID != "" {
|
||||||
if h.spaceRooms[c.spaceID] == nil {
|
if h.rooms[c.roomID] == nil {
|
||||||
h.spaceRooms[c.spaceID] = make(map[*Client]bool)
|
h.rooms[c.roomID] = make(map[*Client]bool)
|
||||||
}
|
}
|
||||||
h.spaceRooms[c.spaceID][c] = true
|
h.rooms[c.roomID][c] = true
|
||||||
}
|
}
|
||||||
if c.userID != "" && c.userID != "anonymous" {
|
if c.userID != "" && c.userID != "anonymous" {
|
||||||
if h.userRooms[c.userID] == nil {
|
if h.userRooms[c.userID] == nil {
|
||||||
@@ -69,11 +121,11 @@ func (h *Hub) Register(c *Client) {
|
|||||||
}
|
}
|
||||||
h.userRooms[c.userID][c] = true
|
h.userRooms[c.userID][c] = true
|
||||||
}
|
}
|
||||||
onlineCount := len(h.allClients)
|
onlineCount := len(h.allClients) + len(h.sseClients)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
if c.spaceID != "" {
|
if c.roomID != "" {
|
||||||
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{
|
h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{
|
||||||
"onlineCount": onlineCount,
|
"onlineCount": onlineCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -82,159 +134,172 @@ func (h *Hub) Register(c *Client) {
|
|||||||
func (h *Hub) Unregister(c *Client) {
|
func (h *Hub) Unregister(c *Client) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
delete(h.allClients, c)
|
delete(h.allClients, c)
|
||||||
if c.spaceID != "" {
|
if c.roomID != "" {
|
||||||
if room, ok := h.spaceRooms[c.spaceID]; ok {
|
if room, ok := h.rooms[c.roomID]; ok {
|
||||||
delete(room, c)
|
delete(room, c)
|
||||||
if len(room) == 0 {
|
if len(room) == 0 {
|
||||||
delete(h.spaceRooms, c.spaceID)
|
delete(h.rooms, c.roomID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
userRemaining := 0
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if c.userID != "" && c.userID != "anonymous" {
|
if c.userID != "" && c.userID != "anonymous" {
|
||||||
if uRoom, ok := h.userRooms[c.userID]; ok {
|
if uRoom, ok := h.userRooms[c.userID]; ok {
|
||||||
delete(uRoom, c)
|
delete(uRoom, c)
|
||||||
userRemaining = len(uRoom)
|
if len(uRoom) == 0 {
|
||||||
if userRemaining == 0 {
|
|
||||||
delete(h.userRooms, c.userID)
|
delete(h.userRooms, c.userID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
close(c.send)
|
c.closeSend()
|
||||||
|
h.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// If this user has no other active client connections, release their document locks
|
func (h *Hub) RegisterSSE(c *SSEClient) {
|
||||||
var releasedDocIDs []string
|
h.mu.Lock()
|
||||||
if userRemaining == 0 && c.userID != "" {
|
h.sseClients[c] = true
|
||||||
now := time.Now()
|
if c.roomID != "" {
|
||||||
for docID, l := range h.locks {
|
if h.sseRooms[c.roomID] == nil {
|
||||||
if l.userID == c.userID || l.expiresAt.Before(now) {
|
h.sseRooms[c.roomID] = make(map[*SSEClient]bool)
|
||||||
releasedDocIDs = append(releasedDocIDs, docID)
|
}
|
||||||
delete(h.locks, docID)
|
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)
|
||||||
}
|
}
|
||||||
|
h.sseRooms[rID][c] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
onlineCount := len(h.allClients) + len(h.sseClients)
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
// Broadcast unlock events for released locks
|
if c.roomID != "" {
|
||||||
for _, docID := range releasedDocIDs {
|
h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{
|
||||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
"onlineCount": onlineCount,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Document Locking Methods
|
func (h *Hub) UnregisterSSE(c *SSEClient) {
|
||||||
|
|
||||||
func (h *Hub) AcquireDocLock(docID, userID, userName string, ttl time.Duration) (*domain.DocLockInfo, bool, *domain.DocLockInfo) {
|
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
delete(h.sseClients, c)
|
||||||
|
if c.roomID != "" {
|
||||||
now := time.Now()
|
if room, ok := h.sseRooms[c.roomID]; ok {
|
||||||
existing, exists := h.locks[docID]
|
delete(room, c)
|
||||||
if exists && existing.expiresAt.After(now) {
|
if len(room) == 0 {
|
||||||
if existing.userID != userID {
|
delete(h.sseRooms, c.roomID)
|
||||||
// Locked by another user
|
|
||||||
info := &domain.DocLockInfo{
|
|
||||||
DocID: existing.docID,
|
|
||||||
UserID: existing.userID,
|
|
||||||
UserName: existing.userName,
|
|
||||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
|
||||||
}
|
}
|
||||||
return nil, false, info
|
|
||||||
}
|
}
|
||||||
// Same user extending lock
|
}
|
||||||
existing.expiresAt = now.Add(ttl)
|
if c.subscribedRooms != nil {
|
||||||
if userName != "" {
|
for rID := range c.subscribedRooms {
|
||||||
existing.userName = userName
|
if room, ok := h.sseRooms[rID]; ok {
|
||||||
|
delete(room, c)
|
||||||
|
if len(room) == 0 {
|
||||||
|
delete(h.sseRooms, rID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
info := &domain.DocLockInfo{
|
}
|
||||||
DocID: existing.docID,
|
if c.userID != "" && c.userID != "anonymous" {
|
||||||
UserID: existing.userID,
|
if uRoom, ok := h.sseUserRooms[c.userID]; ok {
|
||||||
UserName: existing.userName,
|
delete(uRoom, c)
|
||||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
if len(uRoom) == 0 {
|
||||||
|
delete(h.sseUserRooms, c.userID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return info, true, nil
|
|
||||||
}
|
}
|
||||||
|
c.closeSend()
|
||||||
// Create fresh lock
|
h.mu.Unlock()
|
||||||
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 {
|
func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
|
for client := range h.sseClients {
|
||||||
now := time.Now()
|
if client.id == sseID {
|
||||||
item, ok := h.locks[docID]
|
if h.sseRooms[roomID] == nil {
|
||||||
if !ok || item.userID != userID || item.expiresAt.Before(now) {
|
h.sseRooms[roomID] = make(map[*SSEClient]bool)
|
||||||
return false
|
}
|
||||||
}
|
h.sseRooms[roomID][client] = true
|
||||||
item.expiresAt = now.Add(ttl)
|
if client.subscribedRooms == nil {
|
||||||
return true
|
client.subscribedRooms = make(map[string]bool)
|
||||||
}
|
}
|
||||||
|
client.subscribedRooms[roomID] = true
|
||||||
func (h *Hub) ReleaseDocLock(docID, userID string, isAdmin bool) bool {
|
return true
|
||||||
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo {
|
func (h *Hub) LeaveSSERoomByID(sseID, roomID string) bool {
|
||||||
h.mu.RLock()
|
h.mu.Lock()
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.Unlock()
|
||||||
|
for client := range h.sseClients {
|
||||||
item, ok := h.locks[docID]
|
if client.id == sseID {
|
||||||
if !ok || item.expiresAt.Before(time.Now()) {
|
if room, ok := h.sseRooms[roomID]; ok {
|
||||||
return nil
|
delete(room, client)
|
||||||
}
|
if len(room) == 0 {
|
||||||
return &domain.DocLockInfo{
|
delete(h.sseRooms, roomID)
|
||||||
DocID: item.docID,
|
}
|
||||||
UserID: item.userID,
|
}
|
||||||
UserName: item.userName,
|
if client.subscribedRooms != nil {
|
||||||
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
delete(client.subscribedRooms, roomID)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) cleanupLocksLoop() {
|
|
||||||
ticker := time.NewTicker(2 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for range ticker.C {
|
|
||||||
h.mu.Lock()
|
// Close gracefully disconnects all WebSocket and SSE connections so HTTP shutdown completes immediately.
|
||||||
now := time.Now()
|
func (h *Hub) Close() {
|
||||||
var expiredDocIDs []string
|
h.mu.Lock()
|
||||||
for docID, l := range h.locks {
|
if h.closed {
|
||||||
if l.expiresAt.Before(now) {
|
|
||||||
expiredDocIDs = append(expiredDocIDs, docID)
|
|
||||||
delete(h.locks, docID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.closed = true
|
||||||
|
|
||||||
for _, docID := range expiredDocIDs {
|
allWsClients := make([]*Client, 0, len(h.allClients))
|
||||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,13 +317,17 @@ func (h *Hub) BroadcastToAll(event string, payload interface{}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
|
if h.closed {
|
||||||
|
h.mu.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
for client := range h.allClients {
|
for client := range h.allClients {
|
||||||
select {
|
client.trySend(data)
|
||||||
case client.send <- data:
|
}
|
||||||
default:
|
for sseClient := range h.sseClients {
|
||||||
}
|
sseClient.trySend(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,14 +343,20 @@ func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
|
if h.closed {
|
||||||
|
h.mu.RUnlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
defer h.mu.RUnlock()
|
defer h.mu.RUnlock()
|
||||||
|
|
||||||
if room, ok := h.userRooms[userID]; ok {
|
if room, ok := h.userRooms[userID]; ok {
|
||||||
for client := range room {
|
for client := range room {
|
||||||
select {
|
client.trySend(data)
|
||||||
case client.send <- data:
|
}
|
||||||
default:
|
}
|
||||||
}
|
if sseRoom, ok := h.sseUserRooms[userID]; ok {
|
||||||
|
for sseClient := range sseRoom {
|
||||||
|
sseClient.trySend(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,13 +366,16 @@ func (h *Hub) BroadcastTaskEvent(userID, event string, payload interface{}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
|
func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
|
||||||
h.BroadcastToAll(event, payload)
|
if docID != "" {
|
||||||
|
h.BroadcastRoom("doc:"+docID, event, payload, nil)
|
||||||
|
} else {
|
||||||
|
h.BroadcastToAll(event, payload)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||||
msg := domain.WSMessage{
|
msg := domain.WSMessage{
|
||||||
Event: event,
|
Event: event,
|
||||||
SpaceID: spaceID,
|
|
||||||
ListID: listID,
|
ListID: listID,
|
||||||
Payload: payload,
|
Payload: payload,
|
||||||
Timestamp: domain.NowISO(),
|
Timestamp: domain.NowISO(),
|
||||||
@@ -308,32 +386,116 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.mu.RLock()
|
h.mu.RLock()
|
||||||
defer h.mu.RUnlock()
|
if h.closed {
|
||||||
|
h.mu.RUnlock()
|
||||||
room, ok := h.spaceRooms[spaceID]
|
|
||||||
if !ok {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for client := range room {
|
defer h.mu.RUnlock()
|
||||||
select {
|
|
||||||
case client.send <- data:
|
targetRoom := listID
|
||||||
default:
|
if targetRoom == "" {
|
||||||
// Slow client buffer full
|
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) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID string) {
|
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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
conn, err := upgrader.Upgrade(w, r, nil)
|
conn, err := upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[ws] upgrade error: %v", err)
|
slog.Error("WebSocket upgrade error", "room_id", roomID, "user_id", userID, "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client := &Client{
|
client := &Client{
|
||||||
hub: h,
|
hub: h,
|
||||||
conn: conn,
|
conn: conn,
|
||||||
spaceID: spaceID,
|
roomID: roomID,
|
||||||
userID: userID,
|
userID: userID,
|
||||||
send: make(chan []byte, 128),
|
send: make(chan []byte, 128),
|
||||||
}
|
}
|
||||||
@@ -343,13 +505,80 @@ func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID st
|
|||||||
go client.readPump()
|
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() {
|
func (c *Client) readPump() {
|
||||||
defer func() {
|
defer func() {
|
||||||
c.hub.Unregister(c)
|
c.hub.Unregister(c)
|
||||||
_ = c.conn.Close()
|
_ = c.conn.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
c.conn.SetReadLimit(16384)
|
c.conn.SetReadLimit(4 * 1024 * 1024) // 4MB for large document CRDT updates
|
||||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
c.conn.SetPongHandler(func(string) error {
|
c.conn.SetPongHandler(func(string) error {
|
||||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||||
@@ -362,10 +591,11 @@ func (c *Client) readPump() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle client-initiated lock and unlock messages
|
// Handle client-initiated lock, CRDT, room subscription, and presence messages
|
||||||
var incoming struct {
|
var incoming struct {
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
DocID string `json:"docId"`
|
DocID string `json:"docId"`
|
||||||
|
Room string `json:"room"`
|
||||||
Payload json.RawMessage `json:"payload"`
|
Payload json.RawMessage `json:"payload"`
|
||||||
}
|
}
|
||||||
if json.Unmarshal(message, &incoming) == nil && c.userID != "" && c.userID != "anonymous" {
|
if json.Unmarshal(message, &incoming) == nil && c.userID != "" && c.userID != "anonymous" {
|
||||||
@@ -378,18 +608,22 @@ func (c *Client) readPump() {
|
|||||||
docID = p.DocID
|
docID = p.DocID
|
||||||
}
|
}
|
||||||
|
|
||||||
if docID != "" {
|
// Room subscription without requiring docID
|
||||||
switch incoming.Event {
|
if incoming.Event == "JOIN_ROOM" {
|
||||||
case "LOCK_DOC":
|
room := incoming.Room
|
||||||
if info, ok, _ := c.hub.AcquireDocLock(docID, c.userID, "", 30*time.Second); ok && info != nil {
|
if room == "" {
|
||||||
c.hub.BroadcastDocEvent(docID, "DOC_LOCKED", info)
|
room = docID
|
||||||
}
|
}
|
||||||
case "HEARTBEAT_DOC":
|
if room != "" {
|
||||||
c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second)
|
c.hub.JoinRoom(c, room)
|
||||||
case "UNLOCK_DOC":
|
}
|
||||||
if c.hub.ReleaseDocLock(docID, c.userID, false) {
|
} else if incoming.Event == "LEAVE_ROOM" {
|
||||||
c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
room := incoming.Room
|
||||||
}
|
if room == "" {
|
||||||
|
room = docID
|
||||||
|
}
|
||||||
|
if room != "" {
|
||||||
|
c.hub.LeaveRoom(c, room)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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,6 +24,7 @@ CREATE TABLE IF NOT EXISTS thoughts (
|
|||||||
converted_type TEXT DEFAULT '',
|
converted_type TEXT DEFAULT '',
|
||||||
converted_ref_id TEXT DEFAULT '',
|
converted_ref_id TEXT DEFAULT '',
|
||||||
due_date TEXT DEFAULT '',
|
due_date TEXT DEFAULT '',
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
triaged_at TEXT DEFAULT '',
|
triaged_at TEXT DEFAULT '',
|
||||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
@@ -31,6 +32,7 @@ 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_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_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 (
|
CREATE TABLE IF NOT EXISTS spaces (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
|
|||||||
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 249 KiB |
|
After Width: | Height: | Size: 315 KiB |