Compare commits
177
Commits
33e1223e69
..
v0.1
| 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 | ||
|
|
49a49094de | ||
|
|
177b69234c | ||
|
|
d45b3cd0b3 | ||
|
|
e37302f73c | ||
|
|
60c302e23e | ||
|
|
9ab9af3a9e | ||
|
|
e2772d99ea | ||
|
|
354129b2d4 | ||
|
|
6d07352e80 | ||
|
|
646ed6d960 | ||
|
|
518a0903dd | ||
|
|
b0403e09bc | ||
|
|
f1d9daca60 | ||
|
|
eafe75faf0 | ||
|
|
4874b79439 | ||
|
|
b9d5529648 | ||
|
|
b59bd3a265 | ||
|
|
b3e3fa27c4 | ||
|
|
c02c7d20de | ||
|
|
90a6062c7a | ||
|
|
7c47f245e2 | ||
|
|
20c50aa8d6 | ||
|
|
332dbb456a | ||
|
|
b5df4a34b4 | ||
|
|
0afa2f9c2d | ||
|
|
cfc0477b5c | ||
|
|
1a37ba9342 | ||
|
|
107361bb0f | ||
|
|
bc41f7896d | ||
|
|
e2b20198e4 | ||
|
|
31847ffaa3 | ||
|
|
5c6f162c96 | ||
|
|
d34178aa1a | ||
|
|
1310f8102e | ||
|
|
c4b2a00348 | ||
|
|
1be0909344 | ||
|
|
1009487cce | ||
|
|
0affb827a4 | ||
|
|
920716be25 | ||
|
|
51244c736c | ||
|
|
7bc5fbe001 | ||
|
|
6e09888a44 | ||
|
|
af7a9eb200 | ||
|
|
ec79a2493e | ||
|
|
290848a93e | ||
|
|
1168c7e2ea | ||
|
|
714a8551c7 | ||
|
|
38e9c28c63 | ||
|
|
7f312fef14 | ||
|
|
41c64c4924 | ||
|
|
d338c4cb8a | ||
|
|
9b21fd92e1 | ||
|
|
d49ef7971d | ||
|
|
84e0b6a4bd | ||
|
|
dccf1a91e3 | ||
|
|
1ada978f9e | ||
|
|
3c550373eb | ||
|
|
05de76cce4 | ||
|
|
577893172b | ||
|
|
0ec1a8b7f7 | ||
|
|
537b916533 | ||
|
|
63db1a0f90 | ||
|
|
e6a791bcca | ||
|
|
87efb98bc1 | ||
|
|
14e3176e55 | ||
|
|
275442e9e2 | ||
|
|
5b5b7b1810 | ||
|
|
29d202cc4e | ||
|
|
fb805b30a1 |
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
|
||||
BASE_URL=http://localhost:8080
|
||||
ALLOW_REGISTRATION=false
|
||||
# Logging level: debug, info, warn, error (Default: info)
|
||||
LOG_LEVEL=info
|
||||
# LOG_LEVEL=debug
|
||||
# DEBUG=true
|
||||
|
||||
# Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI)
|
||||
SMTP_HOST=
|
||||
|
||||
@@ -1,307 +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` / `team_admin`) angelegt werden.
|
||||
- [x] **Profile-Dropdown & Vollbild-Einstellungsseiten statt kleinem Modal (`App.tsx`, `UserSettingsView.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).
|
||||
- **Administration** (`AdminSettingsView.tsx` — nur sichtbar für `admin` und `team_admin`; Vollbild-Ansicht für Benutzer, Rollen, Einladungscodes, Organisationen & Teams, Server-Isolation und SMTP-Mailserver).
|
||||
- **Abmelden** (bereinigt JWT-Token und führt zurück zum Login-Bildschirm).
|
||||
- 3-stufiges Rollensystem: `admin` (Server-Admin), `team_admin` (Team-Admin mit Berechtigung zum Einladen und Verwalten von Teams) und `member` (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 und „Zoom aufheben"-Button.
|
||||
- **Drag & Drop:** Bullet-Dots dienen als Anfasser zum Verschieben und Umsortieren von Blöcken und ganzen Unterbäumen.
|
||||
- **Slash-Commands (`/`):** Schnelle Umwandlung in `/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/table`, `/callout`, `/progress`, `/divider`.
|
||||
- Physische Speicherung auf Festplatte unter `/data/workspace/<user_id>/...` als Standard-`.md`-Dateien mit portabler 2-Leerzeichen-Einrückung (` - [ ] ` bzw. ` - `), vollständig kompatibel mit externen Outlinern und Markdown-Viewern.
|
||||
- [x] **Globaler Workspace To-Do Rollup Dashboard (`WorkspaceView.tsx`):**
|
||||
- Aggregierte Übersicht aller To-Dos (`- [ ] ` / `- [x] `) aus sämtlichen Notiz-Dateien über alle Ordner hinweg.
|
||||
- Übersichtliche Gruppierung nach Ordner ➔ Dokument ➔ Aufgaben mit einklappbaren Akkordeons und Zähler für offene Aufgaben.
|
||||
- **Ordner-Drilldown:** Klick auf einen Ordner isoliert nur diesen Zweig für fokussiertes Abarbeiten, inklusive Zurücksetzen-Filter.
|
||||
- **Live 2-Wege-Sync:** Abhaken oder Aktivieren eines To-Dos im Rollup synchronisiert direkt in Echtzeit zurück in die SQLite-Datenbank und die physische `.md`-Datei auf Festplatte.
|
||||
- [x] **Aufgabenlisten-Teilen & Avatar-Stack (`TasksView.tsx`, `models.go`, `sqlite.go`):**
|
||||
- Aufgabenlisten können direkt mit Teams/Spaces geteilt werden.
|
||||
- Avatar-Stack (farbige Initialen-Bubbles mit Hover-Tooltips) im Header der Liste und in den Tabs visualisiert, wem eine Liste gehört und mit welchen Teammitgliedern sie geteilt ist.
|
||||
- [x] **Admin-Schalter für öffentliche Selbstregistrierung (`AdminSettingsView.tsx`):**
|
||||
- Unintuitiver Button in den Admin-Einstellungen unter Server & Isolation durch einen flüssig animierten Schiebeschalter (`rounded-pill`) mit eindeutigen Status-Beschriftungen ersetzt.
|
||||
- [x] **Cross-Browser & Performance Fixes (Chrome & Firefox):**
|
||||
- SQLite Connection-Pool Deadlock durch striktes Schließen von `sql.Rows` vor Unterabfragen behoben.
|
||||
- `Cache-Control: no-store` auf allen API-Routen und `index.html`.
|
||||
- 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`, `team_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'`, `team_id TEXT` (optional direkte Team-Zuweisung)
|
||||
- `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).
|
||||
+173
@@ -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).
|
||||
+109
@@ -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!
|
||||
@@ -4,128 +4,195 @@
|
||||
### *Der Kopf frei in drei Sekunden.*
|
||||
|
||||
[](#)
|
||||
[](#-in-60-sekunden-starten)
|
||||
[](#)
|
||||
[](LICENSE)
|
||||
[](#-schnellstart)
|
||||
[](#-deine-daten-gehören-dir)
|
||||
|
||||
**MindSift** ist deine zentrale Schaltstelle für spontane Gedanken, strukturierte Aufgaben, vernetztes Wissen und gemeinsame Echtzeit-Listen — kompromisslos schnell, ablenkungsfrei gestaltet 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) • [Für wen?](#-für-wen-ist-mindsift-gemacht) • [Schnellstart](#-in-60-sekunden-starten)
|
||||
<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>
|
||||
|
||||
## 💡 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.**
|
||||
Wenn dir mitten im Gespräch, unterwegs oder kurz vor dem Feierabend ein wichtiger Gedanke kommt, willst du keinen Ordner auswählen, keine Datenbank-Eigenschaften pflegen und kein Formular ausfüllen. Du willst den Gedanken in drei Sekunden loswerden – und sicher sein, dass er nicht verloren geht.
|
||||
## 💡 Die Philosophie
|
||||
|
||||
**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. Wer möchte, kann einen Eintrag aber auch direkt bei der Eingabe einer Aufgabenliste, einer Einkaufsliste oder einer Notiz zuordnen.
|
||||
2. **Spielerisch sichten (Triage):** Wenn du später Ruhe hast, arbeitest du deinen Gedanken-Stapel Karte für Karte durch. Nach links verwerfen, auf „Später“ schieben oder mit einem Klick in eine echte Aufgabe, ein Dokument oder einen Listeneintrag verwandeln.
|
||||
3. **Nahtlos weiterarbeiten:** Aus flüchtigen Ideen werden strukturierte Aufgaben-Boards, verlinkte Wiki-Dokumente oder geteilte Listen für Familie und Team.
|
||||
1. **Abladen:** In die Eingabeleiste tippen, `Enter`, fertig. Wer will, weist den Gedanken gleich einer Aufgabe, Liste oder Notiz zu.
|
||||
2. **Sichten (Triage):** Später gehst du den Stapel Karte für Karte durch: verwerfen, zur Aufgabe machen, in eine Notiz oder Liste verwandeln.
|
||||
3. **Vertiefen:** Aus losen Ideen werden Aufgaben mit Unteraufgaben, vernetzte Outliner-Notizen oder geteilte Listen für Familie und Team.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Was MindSift auszeichnet
|
||||
## ✨ Funktionen
|
||||
|
||||
### ⚡ Quick-Dump & Triage-Deck
|
||||
* **Immer griffbereit:** Die Schnell-Eingabeleiste sitzt prominent ganz oben – jederzeit erreichbar per Klick oder Tastendruck (`C` oder `/`).
|
||||
* **Entscheiden ohne Stress:** Im Triage-Deck siehst du immer genau eine Karte im Fokus, während darunter deine Warteschlange bereitliegt. Bearbeite den Text direkt auf der Karte, vergib ein Fälligkeitsdatum oder sortiere ihn per Swipe und Tastatur-Kürzel weg.
|
||||
### ⚡ Schnellerfassung & Triage
|
||||
|
||||
### ✅ Aufgabenlisten & Kanban-Board
|
||||
* **Mehrere Aufgabenlisten:** Trenne Berufliches, Privates, Projekte oder gemeinsame Team-Aufgaben sauber in eigenen Listen.
|
||||
* **Liste oder Kanban:** Wechsle jederzeit mit einem Klick zwischen einer kompakten Listenansicht und einem übersichtlichen **3-Spalten-Kanban-Board** (*Zu erledigen*, *In Bearbeitung*, *Erledigt*).
|
||||
* **Detailtiefe bei Bedarf:** Füge Aufgaben Fälligkeiten, Prioritäten und ausführliche Notizen hinzu – oder wandle eine wachsende Aufgabe direkt in ein großes Workspace-Dokument um.
|
||||
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe (Browser-Funktion).
|
||||
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
|
||||
* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste.
|
||||
* Papierkorb mit Wiederherstellen.
|
||||
|
||||
### 📝 Workspace: Komfort wie in Word, gespeichert als echtes Markdown
|
||||
* **Formatieren auf Knopfdruck:** Nutze die übersichtliche Werkzeugleiste für Überschriften, Fett/Kursiv, Checklisten, Code-Blöcke, Hervorhebungen, Tabellen und Fortschrittsbalken – ganz ohne Markdown-Befehle auswendig lernen zu müssen.
|
||||
* **Vernetztes Wiki:** Verknüpfe Dokumente untereinander über interne `[[Wiki-Links]]` und organisiere dein Wissen in einer klaren Ordner- und Unterordner-Struktur.
|
||||
* **Volle Datenhoheit (Kein Lock-in):** Jedes Dokument wird im Hintergrund als saubere, standardisierte `.md`-Datei in deiner Ordnerstruktur auf der Festplatte abgelegt. Du kannst deine Notizen jederzeit direkt aus dem Dateisystem kopieren, sichern oder mit anderen Programmen nutzen.
|
||||
<p align="center">
|
||||
<img src="screenshots/Mobile_Triage.png" alt="MindSift Mobile Triage" width="280px" />
|
||||
</p>
|
||||
|
||||
### 🛒 Geteilte Echtzeit-Listen
|
||||
* **Gemeinsam abhaken:** Ob Wocheneinkauf, Packliste für den Urlaub oder Checkliste fürs Projekt – teile Spaces per Einladungscode mit Partnern, Familie oder Kollegen.
|
||||
* **Live-Synchronisation:** Hakst du im Supermarkt einen Artikel ab, verschwindet er im selben Augenblick auf den Bildschirmen aller anderen Mitglieder.
|
||||
### ✅ Aufgaben & Kanban
|
||||
|
||||
### 🏢 Organisationen, Teams & Berechtigungskonzept
|
||||
* **Geführte Ersteinrichtung:** Beim ersten Start begrüßt dich ein Einrichtungs-Assistent, mit dem du dein Administrator-Konto und die Sicherheitseinstellungen deines Servers festlegst.
|
||||
* **Geschlossene Gesellschaft oder offenes Team:** Schalte die öffentliche Registrierung ab und vergib gezielte **Einladungscodes**, oder lege Benutzer direkt über die Administration an.
|
||||
* **Drei klare Rollen:** Unterscheide zwischen **Server-Admin** (volle Server- und Mail-Steuerung), **Team-Admin** (darf Nutzer einladen sowie Organisationen und Teams verwalten) und **Mitglied**.
|
||||
* **Sichtbarkeits-Isolation nach Maß:** Bestimme als Administrator zentral, ob Nutzer komplett isoliert für sich arbeiten (*Strikt Privat*), nur Mitglieder ihrer eigenen Teams sehen (*Teams isoliert*) oder serverweit zusammenarbeiten (*Offen*).
|
||||
* Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar.
|
||||
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
|
||||
* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board.
|
||||
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
|
||||
* **Alarme** bei Fälligkeit per E-Mail, Discord oder eigenem Webhook.
|
||||
|
||||
### 🔔 Multi-Channel-Alarme & Eigene Webhooks
|
||||
* **Keine Frist mehr verpassen:** Lass dich vor Fälligkeit einer Aufgabe automatisch benachrichtigen – frei wählbar per **E-Mail**, **Discord-Nachricht** oder **eigenem Webhook**.
|
||||
* **Offen für deine Automatisierung:** Erzeuge persönliche API-Schlüssel in deinen Kontoeinstellungen und schicke System-Alarme, Server-Statusmeldungen oder eigene Skripte per einfachem `curl`-Befehl direkt als Aufgabe oder Notiz in dein MindSift.
|
||||
<p align="center">
|
||||
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
|
||||
</p>
|
||||
|
||||
### 🖤 Fokussiertes, eckiges Dark-Design
|
||||
* **Ruhe fürs Auge:** Ein klares, monochromes dunkles Design mit präzisen Kanten und einer einzigen, gezielt eingesetzten Akzentfarbe. Keine Ablenkung, keine überladenen Menüs, volle Konzentration auf deine Inhalte.
|
||||
### 📝 Workspace: Outliner-Notizen
|
||||
|
||||
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste.
|
||||
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren.
|
||||
* Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`.
|
||||
* Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
|
||||
* Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`.
|
||||
* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt.
|
||||
* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen **zum Lesen** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace Outliner" width="90%" />
|
||||
</p>
|
||||
|
||||
### 🛒 Geteilte Listen
|
||||
|
||||
* Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien.
|
||||
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/Listen_desktop.png" alt="MindSift Geteilte Listen" width="90%" />
|
||||
</p>
|
||||
|
||||
### 🏢 Benutzer, Gruppen, Administration
|
||||
|
||||
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
|
||||
* Gruppen mit Gruppen-Admins für Freigaben.
|
||||
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
|
||||
* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA.
|
||||
|
||||
### 🔌 Integrationen
|
||||
|
||||
* **Webhook / API-Key:** `POST /api/v1/webhooks/ingest` legt Aufgaben aus Smarthome, Skripten oder Automationen an.
|
||||
* Persönliche API-Keys, Datenexport als JSON.
|
||||
|
||||
---
|
||||
|
||||
## 👥 Für wen ist MindSift gemacht?
|
||||
## 🚀 Schnellstart
|
||||
|
||||
* **Für Vielbeschäftigte & Kreative**, die tagsüber hunderte Gedanken, To-Dos und Links im Kopf jonglieren und ein System suchen, das schneller ist als jede klassische Notiz-App.
|
||||
* **Für Familien, Paare & WGs**, die gemeinsame Einkaufslisten, Erledigungen und Planungen ohne Werbe-Apps und ohne fremde Cloud synchronisieren möchten.
|
||||
* **Für Teams, Selbstständige & Homelab-Enthusiasten**, die Aufgaben, Projektdokumentation, Server-Webhooks und Benutzerrechte souverän auf der eigenen Infrastruktur betreiben wollen.
|
||||
MindSift läuft als einzelner Container mit Webserver, API, Datenbank und Frontend.
|
||||
|
||||
---
|
||||
### Option 1: Installationsskript
|
||||
|
||||
## 🚀 In 60 Sekunden starten
|
||||
```bash
|
||||
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/install.sh | bash
|
||||
```
|
||||
|
||||
MindSift läuft als einzelner, ressourcenschonender Container und bringt alles mit, was du brauchst.
|
||||
Das Skript legt ein Verzeichnis an, lädt `docker-compose.yml` und `.env.example`, erzeugt ein `JWT_SECRET` und startet den Container.
|
||||
|
||||
### Start mit Docker Compose
|
||||
### Option 2: Docker Compose
|
||||
|
||||
Lege eine `docker-compose.yml` an:
|
||||
```bash
|
||||
mkdir -p mindsift && cd mindsift
|
||||
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/docker-compose.yml -o docker-compose.yml
|
||||
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/.env.example -o .env
|
||||
# In .env: JWT_SECRET durch einen langen Zufallswert ersetzen!
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Minimal:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mindsift:
|
||||
image: git.dsit.me/sandrodach/mindsift:dev
|
||||
container_name: mindsift
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
ports: ["8080:8080"]
|
||||
volumes: ["./data:/data"]
|
||||
environment:
|
||||
- PORT=8080
|
||||
- DATA_DIR=/data
|
||||
- JWT_SECRET=bitte-hier-ein-langes-sicheres-geheimnis-eintragen
|
||||
- ALLOW_REGISTRATION=false
|
||||
# Optional: Eigener SMTP-Server für E-Mail-Alarme
|
||||
# (kann alternativ auch später direkt in der Admin-Oberfläche eingetragen werden)
|
||||
- SMTP_HOST=
|
||||
- SMTP_PORT=587
|
||||
- SMTP_USER=
|
||||
- SMTP_PASS=
|
||||
- SMTP_FROM=
|
||||
- SMTP_TLS=true
|
||||
- JWT_SECRET=hier-ein-langer-zufaelliger-wert
|
||||
- BASE_URL=https://mindsift.example.com
|
||||
```
|
||||
|
||||
Container starten:
|
||||
### 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
|
||||
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
|
||||
```
|
||||
|
||||
Öffne anschließend **`http://localhost:8080`** im Browser. Beim allerersten Aufruf führt dich MindSift automatisch durch die **Ersteinrichtung** für dein Administrator-Konto.
|
||||
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Vet und Tests beim Image-Build aus.
|
||||
|
||||
**Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite) · React 18, TypeScript, Vite, Tailwind.
|
||||
|
||||
---
|
||||
|
||||
## 📚 Dokumentation
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Deine Daten gehören dir
|
||||
|
||||
* **100 % Self-Hosted:** Keine externen Cloud-Dienste, keine versteckte Telemetrie, kein Tracking.
|
||||
* **Portabel & Transparent:** Alle Workspace-Notizen liegen als lesbare Markdown-Dateien im Datenverzeichnis; zusätzlich kannst du jederzeit ein komplettes Backup deiner Daten als JSON-Datei herunterladen.
|
||||
* Selbst gehostet, keine Telemetrie, keine Fremddienste. Ausgehende Verbindungen gibt es nur zu den Alarm-Zielen, die du selbst einrichtest (SMTP, Discord, Webhook).
|
||||
* Alle Inhalte liegen in einer einzigen SQLite-Datei; Notizen lassen sich als Markdown exportieren, alle Daten eines Benutzers als JSON.
|
||||
|
||||
---
|
||||
|
||||
## 📄 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">
|
||||
<sub>Gebaut für maximale Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
|
||||
</div>
|
||||
<sub>Gebaut für Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
|
||||
</div>
|
||||
+39
-11
@@ -4,7 +4,8 @@ import (
|
||||
"context"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -23,38 +24,60 @@ var embeddedDist embed.FS
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
log.Printf("🧠 Starting MindSift Server on port %s (DB: %s)", cfg.Port, cfg.DatabasePath)
|
||||
level := cfg.SlogLevel()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
slog.Info("MindSift Server starting", "port", cfg.Port, "log_level", level.String())
|
||||
|
||||
store, err := storage.NewStore(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize storage: %v", err)
|
||||
slog.Error("Failed to initialize storage", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Seed SMTP defaults from environment variables into DB if not yet configured in DB
|
||||
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS)
|
||||
|
||||
if cfg.DemoMode {
|
||||
slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
|
||||
if err := store.EnsureDemoAccounts(); err != nil {
|
||||
slog.Warn("⚠️ Fehler beim Anlegen der Demo-Accounts", "error", err)
|
||||
} else {
|
||||
slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
|
||||
}
|
||||
}
|
||||
|
||||
wsHub := ws.NewHub()
|
||||
|
||||
staticFS, err := fs.Sub(embeddedDist, "dist")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to mount embedded dist filesystem: %v", err)
|
||||
slog.Error("Failed to mount embedded dist filesystem", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
srv := api.NewServer(cfg, store, wsHub, staticFS)
|
||||
|
||||
serverCtx, serverCancel := context.WithCancel(context.Background())
|
||||
defer serverCancel()
|
||||
|
||||
httpServer := &http.Server{
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: srv.Router(),
|
||||
Addr: ":" + cfg.Port,
|
||||
Handler: srv.Router(),
|
||||
BaseContext: func(l net.Listener) context.Context {
|
||||
return serverCtx
|
||||
},
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("✨ MindSift ready at http://0.0.0.0:%s", cfg.Port)
|
||||
slog.Info("✨ MindSift ready", "url", "http://0.0.0.0:"+cfg.Port)
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("HTTP server error: %v", err)
|
||||
slog.Error("HTTP server error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -62,8 +85,13 @@ func main() {
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
log.Println("Shutting down MindSift server gracefully...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
slog.Info("Shutting down MindSift server gracefully...")
|
||||
serverCancel()
|
||||
wsHub.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = httpServer.Shutdown(ctx)
|
||||
if err := httpServer.Shutdown(ctx); err != nil {
|
||||
slog.Warn("HTTP server shutdown finished with error", "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
mindsift:
|
||||
image: git.dsit.me/sandrodach/mindsift:dev
|
||||
container_name: mindsift
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-8080}:8080"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
environment:
|
||||
- PORT=8080
|
||||
- DATA_DIR=/data
|
||||
- JWT_SECRET=${JWT_SECRET:-change_this_to_a_secure_random_key_min_32_chars}
|
||||
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
# Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging
|
||||
# - LOG_LEVEL=debug
|
||||
# - DEBUG=true
|
||||
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
|
||||
- MINDA_DEMO=${MINDA_DEMO:-false}
|
||||
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
|
||||
- SMTP_HOST=${SMTP_HOST:-}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-}
|
||||
- SMTP_PASS=${SMTP_PASS:-}
|
||||
- SMTP_FROM=${SMTP_FROM:-}
|
||||
- SMTP_TLS=${SMTP_TLS:-true}
|
||||
+5
-3
@@ -1,8 +1,5 @@
|
||||
services:
|
||||
mindsift:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: git.dsit.me/sandrodach/mindsift:dev
|
||||
container_name: mindsift
|
||||
restart: unless-stopped
|
||||
@@ -17,6 +14,11 @@ services:
|
||||
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
# Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging
|
||||
# - LOG_LEVEL=debug
|
||||
# - DEBUG=true
|
||||
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
|
||||
- MINDA_DEMO=${MINDA_DEMO:-false}
|
||||
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
|
||||
- SMTP_HOST=${SMTP_HOST:-}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
|
||||
+16
-5
@@ -4,12 +4,23 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#0b0f19" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="MindSift" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="MindSift — Der Kopf frei in drei Sekunden. Instant Capture, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='26' fill='%23131314'/><circle cx='50' cy='50' r='32' fill='none' stroke='%238ab4f8' stroke-width='7'/><path d='M36 52 L46 62 L66 38' fill='none' stroke='%23c58af9' stroke-width='8' stroke-linecap='round' stroke-linejoin='round'/></svg>" />
|
||||
<title>MindSift — Der Kopf frei in drei Sekunden</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
<title>MindSift</title>
|
||||
<script>
|
||||
window.__deferredPwaPrompt = null;
|
||||
window.addEventListener('beforeinstallprompt', function (e) {
|
||||
e.preventDefault();
|
||||
window.__deferredPwaPrompt = e;
|
||||
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
|
||||
<div id="root"></div>
|
||||
@@ -19,9 +30,9 @@
|
||||
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
|
||||
}
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then((regs) => {
|
||||
regs.forEach((r) => r.unregister());
|
||||
}).catch(() => {});
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Generated
+88
-1
@@ -8,10 +8,14 @@
|
||||
"name": "mindsift-frontend",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"yjs": "^13.6.33"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
@@ -1224,6 +1228,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
"version": "1.26.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
|
||||
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -1832,6 +1842,16 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/isomorphic.js": {
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
|
||||
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "1.21.7",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
|
||||
@@ -1874,6 +1894,27 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/lib0": {
|
||||
"version": "0.2.119",
|
||||
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.119.tgz",
|
||||
"integrity": "sha512-vh+TiejVy8Xm+hCdnRfn9BthGNvNUZdEXltNjVZL3TzRIKP3s1lnQh8pWd/Q0wCgY0FPAGVVjFF4ZiHUV3LFgA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"isomorphic.js": "^0.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
|
||||
"0gentesthtml": "bin/gentesthtml.js",
|
||||
"0serve": "bin/0serve.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.4.tgz",
|
||||
@@ -2242,6 +2283,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||
@@ -2733,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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yjs": {
|
||||
"version": "13.6.33",
|
||||
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.33.tgz",
|
||||
"integrity": "sha512-q/UYvr1gOk4XII+Cu1CW2dINjYqJJFifTzogEaIARznrvX7uOlHsdTEXcnOBMuIsrCGEEKdNxi74kyMLZlKVfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.99"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,10 @@
|
||||
"test:run": "echo 'Frontend checks passed'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" rx="128" fill="#8ab4f8"/>
|
||||
<g transform="translate(80, 80) scale(14.666)" fill="none" stroke="#0b0f19" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
|
||||
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
|
||||
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
|
||||
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/>
|
||||
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/>
|
||||
<path d="M3.477 10.896a4 4 0 0 1 .585-.396"/>
|
||||
<path d="M19.938 10.5a4 4 0 0 1 .585.396"/>
|
||||
<path d="M6 18a4 4 0 0 1-1.967-.516"/>
|
||||
<path d="M19.967 17.484A4 4 0 0 1 18 18"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 851 B |
@@ -1,18 +1,26 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "MindSift — Der Kopf frei in drei Sekunden",
|
||||
"short_name": "MindSift",
|
||||
"description": "Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
|
||||
"start_url": "/?mode=capture",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0b0f19",
|
||||
"theme_color": "#0b0f19",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='26' fill='%23131314'/><circle cx='50' cy='50' r='32' fill='none' stroke='%238ab4f8' stroke-width='7'/><path d='M36 52 L46 62 L66 38' fill='none' stroke='%23c58af9' stroke-width='8' stroke-linecap='round' stroke-linejoin='round'/></svg>",
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "192x192 512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
|
||||
+1061
-89
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,393 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Users,
|
||||
User as UserIcon,
|
||||
X,
|
||||
Plus,
|
||||
Trash2,
|
||||
Lock,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { User, Team, UnifiedShare } from '../lib/types';
|
||||
import { UserAutocomplete } from '../features/UserAutocomplete';
|
||||
import { UserAvatar } from './UserAvatar';
|
||||
|
||||
export interface UnifiedShareModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
resourceName: string;
|
||||
resourceIcon?: string | React.ReactNode;
|
||||
isOwner: boolean;
|
||||
currentUserId?: string;
|
||||
shares: UnifiedShare[];
|
||||
teams: Team[];
|
||||
collaborators: User[];
|
||||
allowRoleSelection?: boolean; // Default true (editor vs viewer)
|
||||
fixedPermission?: 'editor' | 'viewer'; // When allowRoleSelection is false, force fixedPermission (default 'viewer' for documents, 'editor' for tasks/lists)
|
||||
onAddShare: (targetType: 'team' | 'user', targetId: string, permission: 'editor' | 'viewer') => Promise<void>;
|
||||
onRemoveShare: (shareId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title = 'Freigeben & Berechtigungen',
|
||||
resourceName,
|
||||
resourceIcon,
|
||||
isOwner,
|
||||
currentUserId,
|
||||
shares,
|
||||
teams,
|
||||
collaborators,
|
||||
allowRoleSelection = true,
|
||||
fixedPermission,
|
||||
onAddShare,
|
||||
onRemoveShare,
|
||||
}) => {
|
||||
const effectiveFixedPerm = fixedPermission || 'viewer';
|
||||
const [shareType, setShareType] = useState<'team' | 'user'>('team');
|
||||
const [selectedTeamId, setSelectedTeamId] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>(
|
||||
allowRoleSelection ? 'editor' : effectiveFixedPerm
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const targetId = shareType === 'team' ? selectedTeamId : selectedUser?.id;
|
||||
if (!targetId) {
|
||||
setError(shareType === 'team' ? 'Bitte eine Gruppe auswählen' : 'Bitte einen Benutzer auswählen');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const permToUse = allowRoleSelection ? selectedPermission : effectiveFixedPerm;
|
||||
await onAddShare(shareType, targetId, permToUse);
|
||||
setSelectedTeamId('');
|
||||
setSelectedUser(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Fehler beim Hinzufügen der Freigabe');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (shareId: string) => {
|
||||
if (!window.confirm('Möchtest du diese Freigabe wirklich aufheben?')) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await onRemoveShare(shareId);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Fehler beim Entfernen der Freigabe');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const excludedUserIds = [
|
||||
...(currentUserId ? [currentUserId] : []),
|
||||
...shares.filter((s) => s.targetType === 'user').map((s) => s.targetId),
|
||||
];
|
||||
|
||||
const availableTeams = teams.filter(
|
||||
(t) => !shares.some((s) => s.targetType === 'team' && s.targetId === t.id)
|
||||
);
|
||||
|
||||
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 */}
|
||||
<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">
|
||||
{resourceIcon ? (
|
||||
typeof resourceIcon === 'string' ? (
|
||||
<span className="text-base">{resourceIcon}</span>
|
||||
) : (
|
||||
resourceIcon
|
||||
)
|
||||
) : (
|
||||
<Users className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-bold text-main truncate">{title}</h3>
|
||||
<p className="text-[11px] text-muted truncate">
|
||||
Objekt: <span className="font-semibold text-main">{resourceName}</span>
|
||||
</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>
|
||||
|
||||
{error && (
|
||||
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Share Section (Only if owner / manager) */}
|
||||
{isOwner ? (
|
||||
<form onSubmit={handleAdd} 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">Freigabe hinzufügen:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShareType('team');
|
||||
setSelectedUser(null);
|
||||
setError(null);
|
||||
}}
|
||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
|
||||
shareType === 'team'
|
||||
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
<span>Gruppe</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShareType('user');
|
||||
setSelectedTeamId('');
|
||||
setError(null);
|
||||
}}
|
||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
|
||||
shareType === 'user'
|
||||
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<UserIcon className="w-3.5 h-3.5" />
|
||||
<span>Benutzer</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shareType === 'team' ? (
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<select
|
||||
value={selectedTeamId}
|
||||
onChange={(e) => setSelectedTeamId(e.target.value)}
|
||||
className="input-field bg-panel h-8 px-2.5 text-xs flex-1 cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="" className="bg-panel text-muted">-- Gruppe auswählen --</option>
|
||||
{availableTeams.map((t) => (
|
||||
<option key={t.id} value={t.id} className="bg-panel text-main">
|
||||
👥 {t.name} ({t.members?.length || 1} Mitglieder)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{allowRoleSelection ? (
|
||||
<select
|
||||
value={selectedPermission}
|
||||
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
||||
className="input-field bg-panel h-8 px-2.5 text-xs w-full sm:w-36 cursor-pointer shrink-0"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted px-2 py-1 bg-panel rounded border border-border shrink-0 self-center">
|
||||
{effectiveFixedPerm === 'editor' ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-accent" />
|
||||
Bearbeiten
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-3 h-3 text-faint" />
|
||||
Nur Lesen
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="flex-1">
|
||||
<UserAutocomplete
|
||||
users={collaborators}
|
||||
selectedUser={selectedUser}
|
||||
onSelectUser={setSelectedUser}
|
||||
excludeUserIds={excludedUserIds}
|
||||
disabled={loading}
|
||||
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
||||
/>
|
||||
</div>
|
||||
{allowRoleSelection ? (
|
||||
<select
|
||||
value={selectedPermission}
|
||||
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
||||
className="input-field bg-panel h-8 px-2.5 text-xs w-full sm:w-36 cursor-pointer shrink-0"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||
</select>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-[11px] text-muted px-2 py-1 bg-panel rounded border border-border shrink-0 self-center">
|
||||
{effectiveFixedPerm === 'editor' ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-accent" />
|
||||
Bearbeiten
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Lock className="w-3 h-3 text-faint" />
|
||||
Nur Lesen
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || (shareType === 'team' ? !selectedTeamId : !selectedUser)}
|
||||
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>
|
||||
{loading
|
||||
? 'Wird gespeichert…'
|
||||
: shareType === 'team'
|
||||
? 'Gruppe freigeben'
|
||||
: 'Benutzer hinzufügen'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="p-3 bg-panel-elevated border border-border rounded-card text-xs text-muted flex items-center gap-2">
|
||||
<Lock className="w-4 h-4 text-faint shrink-0" />
|
||||
<span>Nur der Eigentümer kann neue Freigaben einrichten.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Shares List */}
|
||||
<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>Aktive Freigaben</span>
|
||||
<span className="font-mono text-[11px] font-normal lowercase">
|
||||
{shares.length} {shares.length === 1 ? 'Freigabe' : 'Freigaben'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-0.5">
|
||||
{shares.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">
|
||||
<Lock className="w-4 h-4 text-faint shrink-0" />
|
||||
<span>Dieses Element ist aktuell privat (nur für dich sichtbar).</span>
|
||||
</div>
|
||||
) : (
|
||||
shares.map((sh) => {
|
||||
const isTeam = sh.targetType === 'team';
|
||||
const roleLabel =
|
||||
sh.permission === 'viewer' || sh.role === 'viewer' ? 'Leser' : 'Bearbeiter';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sh.id}
|
||||
className="p-2.5 bg-canvas border border-border rounded-btn flex items-center justify-between gap-3 text-xs hover:border-border/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{isTeam ? (
|
||||
<div className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 bg-accent/15 text-accent border border-accent/25">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
) : (
|
||||
<UserAvatar
|
||||
avatarUrl={sh.avatarUrl || collaborators.find((c) => c.id === sh.targetId)?.avatarUrl}
|
||||
name={sh.targetName || sh.targetEmail}
|
||||
size="sm"
|
||||
className="border border-border/80"
|
||||
/>
|
||||
)}
|
||||
<div className="truncate">
|
||||
<div className="font-medium text-main truncate flex items-center gap-1.5">
|
||||
<span className="truncate">{sh.targetName || (isTeam ? 'Gruppe' : sh.targetEmail || 'Benutzer')}</span>
|
||||
<span
|
||||
className={`text-[9px] uppercase font-semibold px-1 py-0.2 rounded ${
|
||||
isTeam ? 'badge-neutral' : 'bg-surface-2 text-faint'
|
||||
}`}
|
||||
>
|
||||
{isTeam ? 'Gruppe' : 'Benutzer'}
|
||||
</span>
|
||||
</div>
|
||||
{sh.targetEmail && sh.targetEmail !== sh.targetName && (
|
||||
<div className="text-[11px] text-muted truncate">{sh.targetEmail}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded font-semibold ${
|
||||
roleLabel === 'Bearbeiter'
|
||||
? 'bg-accent/15 text-accent border border-accent/30'
|
||||
: 'bg-surface-2 text-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
{roleLabel}
|
||||
</span>
|
||||
{isOwner && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(sh.id)}
|
||||
disabled={loading}
|
||||
className="p-1 text-faint hover:text-status-err rounded transition-colors cursor-pointer"
|
||||
title="Freigabe entfernen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-2 border-t border-border flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-action text-xs h-8 px-4 cursor-pointer"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -0,0 +1,202 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Search, X, Check } from 'lucide-react';
|
||||
import { User } from '../lib/types';
|
||||
import { UserAvatar } from '../components/UserAvatar';
|
||||
|
||||
interface UserAutocompleteProps {
|
||||
users: User[];
|
||||
selectedUser: User | null;
|
||||
onSelectUser: (user: User | null) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
excludeUserIds?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
users,
|
||||
selectedUser,
|
||||
onSelectUser,
|
||||
placeholder = 'Benutzer suchen (Name, @Benutzername oder E-Mail)…',
|
||||
disabled = false,
|
||||
excludeUserIds = [],
|
||||
className = '',
|
||||
}) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Filter users based on query and exclusions
|
||||
const filteredUsers = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const available = users.filter((u) => !excludeUserIds.includes(u.id));
|
||||
if (!q) {
|
||||
return available.slice(0, 15);
|
||||
}
|
||||
return available
|
||||
.filter((u) => {
|
||||
const nameMatch = (u.displayName || '').toLowerCase().includes(q);
|
||||
const emailMatch = (u.email || '').toLowerCase().includes(q);
|
||||
const userMatch = (u.username || '').toLowerCase().includes(q);
|
||||
return nameMatch || emailMatch || userMatch;
|
||||
})
|
||||
.slice(0, 15);
|
||||
}, [users, query, excludeUserIds]);
|
||||
|
||||
const handleSelect = (user: User) => {
|
||||
onSelectUser(user);
|
||||
setQuery('');
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onSelectUser(null);
|
||||
setQuery('');
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
setIsOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev < filteredUsers.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : filteredUsers.length - 1
|
||||
);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (highlightedIndex >= 0 && highlightedIndex < filteredUsers.length) {
|
||||
handleSelect(filteredUsers[highlightedIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative ${className}`}>
|
||||
{selectedUser ? (
|
||||
<div className="h-8 px-2.5 bg-panel-elevated border border-accent/40 rounded-btn flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<UserAvatar user={selectedUser} size="xs" />
|
||||
<span className="font-semibold text-main truncate">
|
||||
{selectedUser.displayName || selectedUser.email}
|
||||
</span>
|
||||
{selectedUser.username && (
|
||||
<span className="text-[11px] text-muted truncate">
|
||||
@{selectedUser.username}
|
||||
</span>
|
||||
)}
|
||||
{selectedUser.email && selectedUser.displayName && (
|
||||
<span className="text-[11px] text-muted truncate hidden sm:inline">
|
||||
({selectedUser.email})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="text-muted hover:text-main p-0.5 rounded hover:bg-canvas transition-colors"
|
||||
title="Auswahl aufheben"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
setHighlightedIndex(-1);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="w-full h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs placeholder:text-muted transition-colors disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-56 overflow-y-auto bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="p-3 text-xs text-muted text-center">
|
||||
Kein passender Benutzer gefunden
|
||||
</div>
|
||||
) : (
|
||||
filteredUsers.map((u, idx) => {
|
||||
const isHighlighted = idx === highlightedIndex;
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(u)}
|
||||
onMouseEnter={() => setHighlightedIndex(idx)}
|
||||
className={`w-full px-2.5 py-1.5 flex items-center justify-between gap-2 text-left text-xs transition-colors cursor-pointer ${
|
||||
isHighlighted
|
||||
? 'bg-accent/15 text-main font-medium'
|
||||
: 'hover:bg-panel-elevated text-main'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<UserAvatar user={u} size="xs" />
|
||||
<div className="truncate">
|
||||
<span className="font-semibold text-main">
|
||||
{u.displayName || u.email}
|
||||
</span>
|
||||
{u.username && (
|
||||
<span className="ml-1 text-[11px] text-muted">
|
||||
@{u.username}
|
||||
</span>
|
||||
)}
|
||||
{u.email && u.displayName && (
|
||||
<span className="ml-1.5 text-[10px] text-muted truncate">
|
||||
{u.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isHighlighted && (
|
||||
<Check className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
UserPlus,
|
||||
LogIn,
|
||||
Server,
|
||||
Share2,
|
||||
Users,
|
||||
Check,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
@@ -27,6 +27,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
const [setupName, setSetupName] = useState('');
|
||||
const [setupEmail, setSetupEmail] = useState('');
|
||||
const [setupPassword, setSetupPassword] = useState('');
|
||||
const [setupPasswordConfirm, setSetupPasswordConfirm] = useState('');
|
||||
const [instanceName, setInstanceName] = useState(authStatus.instanceName || 'MindSift');
|
||||
const [sharingMode, setSharingMode] = useState<'open' | 'teams_only' | 'strict_private'>(
|
||||
'teams_only'
|
||||
@@ -47,6 +48,13 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
const handleCompleteSetup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (setupPassword !== setupPasswordConfirm) {
|
||||
setError('Die eingegebenen Passwörter stimmen nicht überein.');
|
||||
triggerHaptic(25);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
@@ -170,17 +178,40 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted mb-1">Administrator-Passwort *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={setupPassword}
|
||||
onChange={(e) => setSetupPassword(e.target.value)}
|
||||
placeholder="Mindestens 4 Zeichen"
|
||||
minLength={4}
|
||||
required
|
||||
className="w-full h-8 px-2.5 input-field"
|
||||
/>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-muted mb-1">Administrator-Passwort *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={setupPassword}
|
||||
onChange={(e) => setSetupPassword(e.target.value)}
|
||||
placeholder="Mindestens 4 Zeichen"
|
||||
minLength={4}
|
||||
required
|
||||
className="w-full h-8 px-2.5 input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-muted mb-1">Passwort wiederholen *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={setupPasswordConfirm}
|
||||
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
|
||||
placeholder="Passwort erneut eingeben"
|
||||
minLength={4}
|
||||
required
|
||||
className={`w-full h-8 px-2.5 input-field ${
|
||||
setupPasswordConfirm && setupPassword !== setupPasswordConfirm
|
||||
? 'border-status-err focus:border-status-err'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
{setupPasswordConfirm && setupPassword !== setupPasswordConfirm && (
|
||||
<span className="text-[10px] text-status-err mt-1 block">
|
||||
Passwörter stimmen nicht überein
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -204,7 +235,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
|
||||
<div>
|
||||
<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>
|
||||
</label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
@@ -325,12 +356,9 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
||||
<Brain className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-sm font-bold text-main tracking-tight leading-none">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</h1>
|
||||
<p className="text-[11px] text-muted mt-0.5">Der Kopf frei in drei Sekunden</p>
|
||||
</div>
|
||||
<h1 className="text-sm font-bold text-main tracking-tight leading-none">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</h1>
|
||||
</div>
|
||||
<Lock className="w-4 h-4 text-faint" />
|
||||
</div>
|
||||
@@ -399,13 +427,44 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
|
||||
{authTab === 'login' && (
|
||||
<form onSubmit={handleLogin} className="space-y-3.5">
|
||||
{authStatus.demoMode && (
|
||||
<div className="p-2.5 rounded border border-accent/30 bg-accent/10 space-y-1.5">
|
||||
<div className="flex items-center justify-between text-[11px] font-semibold text-accent">
|
||||
<span>🧪 Demo-Modus aktiv</span>
|
||||
<span className="text-[10px] text-muted font-normal">Klick zum Ausfüllen:</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEmail('admin');
|
||||
setPassword('admin');
|
||||
}}
|
||||
className="flex-1 py-1 px-2 text-[11px] rounded bg-panel hover:bg-panel-elevated border border-border text-main font-mono transition-colors text-center"
|
||||
>
|
||||
admin / admin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEmail('user');
|
||||
setPassword('user');
|
||||
}}
|
||||
className="flex-1 py-1 px-2 text-[11px] rounded bg-panel hover:bg-panel-elevated border border-border text-main font-mono transition-colors text-center"
|
||||
>
|
||||
user / user
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-muted mb-1">E-Mail-Adresse</label>
|
||||
<label className="block text-muted mb-1">Benutzername oder E-Mail-Adresse</label>
|
||||
<input
|
||||
type="email"
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@beispiel.de"
|
||||
placeholder="admin oder name@beispiel.de"
|
||||
required
|
||||
autoFocus
|
||||
className="w-full h-8 px-2.5 input-field"
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
FileText,
|
||||
Layers,
|
||||
ChevronDown,
|
||||
AlertTriangle,
|
||||
Flame,
|
||||
} from 'lucide-react';
|
||||
import { PriorityLevel, SharedList, Thought } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
@@ -21,26 +23,54 @@ interface InstantCaptureBarProps {
|
||||
onCaptured: (thought: Thought, target?: CaptureTarget) => void;
|
||||
lists?: SharedList[];
|
||||
autoFocusTrigger?: number;
|
||||
inlineOptions?: boolean;
|
||||
defaultShowOptions?: boolean;
|
||||
hideTargetPicker?: boolean;
|
||||
fixedTarget?: CaptureTarget;
|
||||
}
|
||||
|
||||
export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
onCaptured,
|
||||
lists = [],
|
||||
autoFocusTrigger = 0,
|
||||
inlineOptions = false,
|
||||
defaultShowOptions = false,
|
||||
hideTargetPicker = false,
|
||||
fixedTarget,
|
||||
}) => {
|
||||
const [content, setContent] = useState('');
|
||||
const [priority, setPriority] = useState<PriorityLevel>('normal');
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [target, setTarget] = useState<CaptureTarget>('inbox');
|
||||
const [target, setTarget] = useState<CaptureTarget>(fixedTarget || 'inbox');
|
||||
const [targetListId, setTargetListId] = useState<string>('');
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
|
||||
const [isMultiline, setIsMultiline] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLFormElement>(null);
|
||||
const recognitionRef = useRef<any>(null);
|
||||
const baseVoiceTextRef = useRef('');
|
||||
const hasVoiceInputRef = useRef(false);
|
||||
|
||||
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
||||
const targetEl = el || inputRef.current;
|
||||
if (!targetEl) return;
|
||||
targetEl.style.height = 'auto';
|
||||
if (targetEl.value) {
|
||||
const scrollH = targetEl.scrollHeight;
|
||||
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
|
||||
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
|
||||
} else {
|
||||
setIsMultiline(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
@@ -54,6 +84,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (inlineOptions) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setShowOptions(false);
|
||||
@@ -61,7 +92,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
}, [inlineOptions]);
|
||||
|
||||
// Global keyboard shortcut: 'c' or '/' focuses the Instant Capture input immediately
|
||||
useEffect(() => {
|
||||
@@ -77,6 +108,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
inputRef.current?.focus();
|
||||
} else if (e.key === 'Escape' && document.activeElement === inputRef.current) {
|
||||
setContent('');
|
||||
setIsMultiline(false);
|
||||
setShowOptions(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
@@ -101,6 +133,9 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
}
|
||||
|
||||
try {
|
||||
baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim();
|
||||
hasVoiceInputRef.current = true;
|
||||
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.lang = 'de-DE';
|
||||
recognition.continuous = false;
|
||||
@@ -116,10 +151,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
transcript += event.results[i][0].transcript;
|
||||
}
|
||||
setContent((prev) => {
|
||||
const base = prev.replace(/\s*\[…\]$/, '').trim();
|
||||
return base ? `${base} ${transcript}` : transcript;
|
||||
});
|
||||
const base = baseVoiceTextRef.current;
|
||||
setContent(base ? `${base} ${transcript}` : transcript);
|
||||
};
|
||||
|
||||
recognition.onerror = () => {
|
||||
@@ -145,21 +178,26 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || isSubmitting) return;
|
||||
|
||||
const effectiveTarget = overrideTarget || target;
|
||||
const effectiveTarget = fixedTarget || overrideTarget || target;
|
||||
const effectiveListId =
|
||||
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
|
||||
const capturedPriority = priority;
|
||||
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
|
||||
setContent('');
|
||||
setIsMultiline(false);
|
||||
setPriority('normal');
|
||||
setDueDate('');
|
||||
setTarget('inbox');
|
||||
setShowOptions(false);
|
||||
setTarget(fixedTarget || 'inbox');
|
||||
setShowOptions(defaultShowOptions || hideTargetPicker);
|
||||
setIsSubmitting(true);
|
||||
triggerHaptic(15);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = 'auto';
|
||||
}
|
||||
|
||||
const savedMsg =
|
||||
effectiveTarget === 'task'
|
||||
@@ -168,7 +206,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
? 'Auf Liste!'
|
||||
: effectiveTarget === 'document'
|
||||
? 'Als Notiz!'
|
||||
: 'Im Dump!';
|
||||
: 'In Triage!';
|
||||
setJustSavedLabel(savedMsg);
|
||||
setTimeout(() => setJustSavedLabel(null), 1600);
|
||||
inputRef.current?.focus();
|
||||
@@ -210,25 +248,36 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
return (
|
||||
<div className="w-full">
|
||||
<form ref={containerRef} onSubmit={handleSubmit} className="relative">
|
||||
{/* Quick-Capture Input Bar (Radius controlled by --radius-pill) */}
|
||||
{/* Quick-Capture Input Bar (Radius controlled by --radius-pill / multiline) */}
|
||||
<div
|
||||
className={`flex items-center h-9 pl-1.5 pr-1.5 bg-panel border capture-pill transition-colors duration-150 ${
|
||||
className={`flex ${
|
||||
isMultiline ? 'items-end py-1.5 min-h-[36px]' : 'items-center h-9'
|
||||
} pl-1.5 pr-1.5 bg-panel border capture-pill ${
|
||||
isMultiline ? 'capture-bar-multiline' : 'rounded-full'
|
||||
} transition-all duration-150 ${
|
||||
isListening
|
||||
? 'border-accent bg-panel'
|
||||
: 'border-border focus-within:border-accent'
|
||||
}`}
|
||||
>
|
||||
<div className="w-6 h-6 capture-pill bg-accent text-on-accent flex items-center justify-center mr-2 flex-shrink-0">
|
||||
<div
|
||||
className={`w-6 h-6 capture-pill rounded-full bg-accent text-on-accent flex items-center justify-center mr-2 flex-shrink-0 ${
|
||||
isMultiline ? 'self-start mt-1' : ''
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-3 h-3" />
|
||||
</div>
|
||||
|
||||
<input
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
rows={1}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setContent(e.target.value);
|
||||
adjustHeight(e.target);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (e.key === 'Enter' && (!e.shiftKey || e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
executeCapture();
|
||||
}
|
||||
@@ -236,52 +285,61 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
placeholder={
|
||||
isListening
|
||||
? 'Ich höre zu…'
|
||||
: hideTargetPicker || fixedTarget === 'inbox'
|
||||
? 'Quick Dump erfassen… (Enter zum Speichern)'
|
||||
: target === 'task'
|
||||
? 'Neue Aufgabe direkt anlegen… (Enter)'
|
||||
? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
|
||||
: target === 'list_item'
|
||||
? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…`
|
||||
: target === 'document'
|
||||
? 'Neue Workspace-Notiz direkt anlegen…'
|
||||
: 'Quick Dump: Gedanke einfach reinrotzen & später swipen… (C / Enter)'
|
||||
? 'Neue Workspace-Notiz direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
|
||||
: 'Quick Dump: Gedanke reinrotzen… (C / Enter, Shift+Enter für Mehrzeiler)'
|
||||
}
|
||||
className="w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none"
|
||||
className="w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 overflow-y-auto"
|
||||
style={{ minHeight: '22px', maxHeight: '140px' }}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2 flex-shrink-0">
|
||||
{/* Target & Options Toggle Pill */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOptions((prev) => !prev)}
|
||||
title="Ziel wählen (Quick Dump, direkt als Aufgabe, auf Liste oder als Notiz) & Optionen"
|
||||
className={`h-6 px-2.5 capture-pill text-[11px] flex items-center gap-1 transition-colors ${
|
||||
showOptions || target !== 'inbox' || priority !== 'normal' || dueDate
|
||||
? 'bg-accent-dim text-accent border border-accent-border font-medium'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{target === 'task' ? (
|
||||
<CheckSquare className="w-3 h-3" />
|
||||
) : target === 'list_item' ? (
|
||||
<ShoppingCart className="w-3 h-3" />
|
||||
) : target === 'document' ? (
|
||||
<FileText className="w-3 h-3" />
|
||||
) : (
|
||||
<Layers className="w-3 h-3" />
|
||||
)}
|
||||
<span className="hidden md:inline max-w-[110px] truncate">
|
||||
{getTargetLabel()}
|
||||
</span>
|
||||
{dueDate && <span className="hidden lg:inline text-[10px] opacity-80">• {dueDate.slice(5)}</span>}
|
||||
<ChevronDown className="w-3 h-3 opacity-70" />
|
||||
</button>
|
||||
<div
|
||||
className={`flex items-center gap-1 ml-2 flex-shrink-0 ${
|
||||
isMultiline ? 'self-end mb-0.5' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Target & Options Toggle Pill (Hidden when target picker is disabled) */}
|
||||
{!hideTargetPicker && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOptions((prev) => !prev)}
|
||||
title="Ziel wählen (Quick Dump, direkt als Aufgabe, auf Liste oder als Notiz) & Optionen"
|
||||
className={`h-6 px-2.5 capture-pill rounded-full text-[11px] flex items-center gap-1 transition-colors cursor-pointer ${
|
||||
showOptions || target !== 'inbox' || priority !== 'normal' || dueDate
|
||||
? 'bg-accent-dim text-accent border border-accent-border font-medium'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{target === 'task' ? (
|
||||
<CheckSquare className="w-3 h-3" />
|
||||
) : target === 'list_item' ? (
|
||||
<ShoppingCart className="w-3 h-3" />
|
||||
) : target === 'document' ? (
|
||||
<FileText className="w-3 h-3" />
|
||||
) : (
|
||||
<Layers className="w-3 h-3" />
|
||||
)}
|
||||
<span className={`${inlineOptions ? 'inline' : 'hidden md:inline'} max-w-[110px] truncate`}>
|
||||
{getTargetLabel()}
|
||||
</span>
|
||||
{dueDate && <span className="hidden lg:inline text-[10px] opacity-80">• {dueDate.slice(5)}</span>}
|
||||
<ChevronDown className={`w-3 h-3 opacity-70 transition-transform ${showOptions ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Voice Dictation Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleVoiceDictation}
|
||||
title="Spracheingabe"
|
||||
className={`w-6 h-6 capture-pill flex items-center justify-center transition-colors ${
|
||||
className={`w-6 h-6 capture-pill rounded-full flex items-center justify-center transition-colors ${
|
||||
isListening
|
||||
? 'bg-accent-dim text-accent'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
||||
@@ -294,7 +352,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!content.trim() || isSubmitting}
|
||||
className={`h-6 px-2.5 capture-pill font-medium text-[11px] flex items-center gap-1 transition-all ${
|
||||
className={`h-6 px-2.5 capture-pill rounded-full font-medium text-[11px] flex items-center gap-1 transition-all ${
|
||||
content.trim()
|
||||
? 'bg-accent hover:bg-accent-hover text-on-accent cursor-pointer shadow-sm'
|
||||
: 'bg-panel-elevated text-faint cursor-not-allowed'
|
||||
@@ -303,13 +361,15 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
{justSavedLabel ? (
|
||||
<>
|
||||
<Check className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">{justSavedLabel}</span>
|
||||
<span className="inline">{justSavedLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">
|
||||
{target === 'task'
|
||||
<span className="inline">
|
||||
{hideTargetPicker || (fixedTarget || target) === 'inbox'
|
||||
? 'Quick Dump'
|
||||
: target === 'task'
|
||||
? 'Als Aufgabe'
|
||||
: target === 'list_item'
|
||||
? 'Auf Liste'
|
||||
@@ -323,172 +383,187 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable Target & Metadata Panel (Theme-controlled Dropdown) */}
|
||||
{/* Expandable Target & Metadata Panel (Theme-controlled Dropdown or Inline) */}
|
||||
{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">
|
||||
{/* Row 1: Where should this entry go? */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted">
|
||||
Wohin damit? {content.trim() ? '(Klick speichert sofort oder wählt Ziel)' : '(Ziel für Eingabe wählen)'}
|
||||
</span>
|
||||
<span className="text-[11px] text-faint">
|
||||
Standard: Quick Dump für späteres Swipen
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
inlineOptions
|
||||
? 'mt-3 p-3 panel-surface border border-border rounded-xl space-y-3 text-xs animate-in fade-in duration-150'
|
||||
: 'absolute top-full left-0 right-0 mt-1.5 z-50 p-3 panel-surface shadow-panel space-y-3 text-xs'
|
||||
}
|
||||
>
|
||||
{/* Row 1: Where should this entry go? (Omitted when hideTargetPicker is true) */}
|
||||
{!hideTargetPicker && (
|
||||
<>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted">
|
||||
Wohin damit? {content.trim() ? '(Klick speichert sofort oder wählt Ziel)' : '(Ziel für Eingabe wählen)'}
|
||||
</span>
|
||||
<span className="text-[11px] text-faint">
|
||||
Standard: Quick Dump für späteres Swipen
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
|
||||
{/* Option 1: Quick Dump (Triage) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget('inbox');
|
||||
if (content.trim()) {
|
||||
executeCapture('inbox');
|
||||
}
|
||||
}}
|
||||
className={`h-8 px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors ${
|
||||
target === 'inbox'
|
||||
? 'bg-accent-dim border-accent text-main font-medium'
|
||||
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||
}`}
|
||||
>
|
||||
<Layers className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span className="truncate">Quick Dump (Triage)</span>
|
||||
</button>
|
||||
|
||||
{/* Option 2: Direct Task */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget('task');
|
||||
if (content.trim()) {
|
||||
executeCapture('task');
|
||||
}
|
||||
}}
|
||||
className={`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 (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
|
||||
{/* Option 1: Quick Dump (Triage) */}
|
||||
<button
|
||||
key={lst.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget('list_item');
|
||||
setTargetListId(lst.id);
|
||||
setTarget('inbox');
|
||||
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 ${
|
||||
isSelected
|
||||
? 'bg-accent text-on-accent border-accent font-medium'
|
||||
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||
target === 'inbox'
|
||||
? 'bg-accent-dim border-accent text-main font-medium'
|
||||
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||
}`}
|
||||
>
|
||||
<span>{lst.icon}</span>
|
||||
<span>{lst.title}</span>
|
||||
<Layers className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span className="truncate">Quick Dump (Triage)</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Option 2: Direct Task */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget('task');
|
||||
if (content.trim()) {
|
||||
executeCapture('task');
|
||||
}
|
||||
}}
|
||||
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||
target === 'task'
|
||||
? 'bg-accent-dim border-accent text-main font-medium'
|
||||
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||
}`}
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span className="truncate">Direkt als Aufgabe</span>
|
||||
</button>
|
||||
|
||||
{/* Option 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 */}
|
||||
<div className="pt-2 border-t border-border flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted mr-1">Priorität:</span>
|
||||
<div className={`${!hideTargetPicker ? 'pt-2 border-t border-border' : ''} flex flex-wrap items-center justify-between gap-2.5`}>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-muted text-[11px] font-semibold mr-1">Priorität:</span>
|
||||
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
|
||||
const labels: Record<PriorityLevel, string> = {
|
||||
low: 'Niedrig',
|
||||
normal: 'Normal',
|
||||
high: 'Hoch',
|
||||
urgent: 'Dringend',
|
||||
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 = priority === p;
|
||||
const item = config[p];
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setPriority(p)}
|
||||
className={`px-2.5 py-0.5 border rounded-btn transition-colors text-xs ${
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setPriority(p);
|
||||
}}
|
||||
className={`min-h-[30px] px-2.5 py-1 border rounded-btn text-xs flex items-center gap-1 transition-colors cursor-pointer ${
|
||||
active
|
||||
? 'bg-accent text-on-accent border-accent font-medium'
|
||||
? item.activeClass
|
||||
: 'bg-canvas text-muted hover:text-main border-border'
|
||||
}`}
|
||||
>
|
||||
{labels[p]}
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3 text-accent" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted text-[11px] font-semibold flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||||
Fällig am:
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Layers,
|
||||
FileText,
|
||||
CheckSquare,
|
||||
Keyboard,
|
||||
Pin,
|
||||
FolderTree,
|
||||
Folder,
|
||||
Sparkles,
|
||||
Command,
|
||||
CornerDownLeft,
|
||||
CornerUpLeft,
|
||||
MoveVertical,
|
||||
Check,
|
||||
Share2,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface HelpViewProps {
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
type HelpCategory = 'all' | 'workspace' | 'triage' | 'tasks' | 'shortcuts';
|
||||
|
||||
export const HelpView: React.FC<HelpViewProps> = () => {
|
||||
const [activeCategory, setActiveCategory] = useState<HelpCategory>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4 pb-12">
|
||||
{/* Header Bar */}
|
||||
<div className="panel-surface p-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-base sm:text-lg font-bold text-main flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-accent" />
|
||||
<span>Hilfe & Dokumentation</span>
|
||||
</h1>
|
||||
<p className="text-xs text-muted">
|
||||
Alles Wichtige zur Tastaturbedienung, Outliner-Notizen, Triage und Zusammenarbeit
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="w-3.5 h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="In Hilfe suchen…"
|
||||
className="w-full h-8 pl-8 pr-3 input-field text-xs bg-canvas"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter Pills */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{[
|
||||
{ id: 'all', label: 'Alle Themen', icon: Sparkles },
|
||||
{ id: 'workspace', label: 'Workspace & Outliner', icon: FileText },
|
||||
{ id: 'triage', label: 'Triage & Quick-Dump', icon: Layers },
|
||||
{ id: 'tasks', label: 'Aufgaben & Listen', icon: CheckSquare },
|
||||
{ id: 'shortcuts', label: 'Tastatur-Kürzel', icon: Keyboard },
|
||||
].map((cat) => {
|
||||
const Icon = cat.icon;
|
||||
const isActive = activeCategory === cat.id;
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(cat.id as HelpCategory)}
|
||||
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
|
||||
isActive
|
||||
? 'bg-panel-elevated border-accent text-main font-semibold'
|
||||
: 'bg-panel border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 text-accent" />
|
||||
<span>{cat.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Grid of Documentation Cards */}
|
||||
<div className="space-y-4">
|
||||
{/* SECTION: WORKSPACE & OUTLINER */}
|
||||
{(activeCategory === 'all' || activeCategory === 'workspace') && (
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div className="border-b border-border pb-2.5">
|
||||
<h2 className="text-sm font-bold text-main flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-accent" />
|
||||
<span>Workspace: Der hierarchische Logseq-Outliner</span>
|
||||
</h2>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Jede Notiz in MindSift ist ein flexibler hierarchischer Outliner und wird als Standard-Markdown auf der Festplatte gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
|
||||
<div className="p-3 bg-canvas border border-border rounded-card space-y-2">
|
||||
<h3 className="font-semibold text-main flex items-center gap-1.5 text-xs">
|
||||
<Keyboard className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Flüssige Tastatursteuerung</span>
|
||||
</h3>
|
||||
<ul className="space-y-1.5 text-muted leading-relaxed">
|
||||
<li>
|
||||
<strong className="text-main">Enter:</strong> Erzeugt sofort einen neuen Bullet-Point auf derselben Ebene.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Tab:</strong> Rückt den aktuellen Bullet-Point ein und ordnet ihn dem vorherigen Punkt unter.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Shift + Tab:</strong> Rückt den Bullet-Point wieder nach links aus (verringert die Tiefe).
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Shift + Enter:</strong> Erzeugt einen weichen Zeilenumbruch innerhalb desselben Textblocks.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Backspace:</strong> Auf einem leeren Block rückt er die Ebene aus bzw. löscht den leeren Bullet.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<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 text-xs">
|
||||
<Command className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Slash-Befehle (/)</span>
|
||||
</h3>
|
||||
<p className="text-muted leading-relaxed">
|
||||
Tippe einfach <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">/</code> in einen leeren Block, um den Typ flexibel zu wechseln:
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-1 text-[11px] font-mono text-muted">
|
||||
<span>/todo (Checkbox-Aufgabe)</span>
|
||||
<span>/h1 (Haupt-Überschrift)</span>
|
||||
<span>/h2 (Abschnitts-Titel)</span>
|
||||
<span>/h3 (Unter-Überschrift)</span>
|
||||
<span>/code (Code-Block)</span>
|
||||
<span>/table (Markdown-Tabelle)</span>
|
||||
<span>/callout (Hinweis-Box)</span>
|
||||
<span>/progress (Fortschritt 0-100%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 text-xs">
|
||||
<FolderTree className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Ordnerstruktur & Drag & Drop</span>
|
||||
</h3>
|
||||
<p className="text-muted leading-relaxed">
|
||||
Notizen können im linken Datei-Explorer per <strong>Drag & Drop</strong> direkt auf beliebige Ordner gezogen werden, um sie zu verschieben.
|
||||
Ziehst du eine Notiz auf das Hauptverzeichnis, wird sie aus allen Unterordnern herausgenommen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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 text-xs">
|
||||
<Pin className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Zoom-Hoisting & Favoriten anpinnen</span>
|
||||
</h3>
|
||||
<ul className="space-y-1.5 text-muted leading-relaxed">
|
||||
<li>
|
||||
<strong>Klick auf den Bullet-Punkt (•):</strong> Fokussiert tief in diesen Ast hinein (Zoom-in Hoisting). Du kannst über die Breadcrumbs ganz oben jederzeit zurückspringen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Bullet-Punkt ziehen:</strong> Verschiebt den Block samt aller eingerückten Unterelemente an eine neue Position.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Anpinnen:</strong> Wichtige Notizen heften sich ganz oben im Datei-Explorer unter „Angepinnte Notizen" an.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SECTION: TRIAGE & QUICK DUMP */}
|
||||
{(activeCategory === 'all' || activeCategory === 'triage') && (
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div className="border-b border-border pb-2.5">
|
||||
<h2 className="text-sm font-bold text-main flex items-center gap-2">
|
||||
<Layers className="w-4 h-4 text-accent" />
|
||||
<span>Triage & Quick Dump: Der Kopf frei in 3 Sekunden</span>
|
||||
</h2>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Sofortiges Erfassen ohne Reibung. Gedanken werden gesammelt und anschließend im Triage-Deck mit 1 Taste entschieden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-xs">
|
||||
<div className="p-3 bg-canvas border border-border rounded-card space-y-1.5">
|
||||
<h4 className="font-semibold text-main">1. Blitz-Erfassung</h4>
|
||||
<p className="text-muted leading-relaxed">
|
||||
Drücke überall <kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px]">C</kbd> oder <kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px]">/</kbd>, tippe den Gedanken ein und drücke <kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px]">Enter</kbd>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-canvas border border-border rounded-card space-y-1.5">
|
||||
<h4 className="font-semibold text-main">2. Triage Entscheidungs-Tasten</h4>
|
||||
<ul className="text-muted space-y-1">
|
||||
<li><strong className="text-status-err">H</strong> oder <strong>←</strong> : Wegwerfen / Erledigt</li>
|
||||
<li><strong className="text-accent">L</strong> oder <strong>→</strong> : Als Aufgabe anlegen</li>
|
||||
<li><strong>T</strong> : Aufgabe mit Fälligkeitsdatum</li>
|
||||
<li><strong>D</strong> : In Workspace-Notiz umwandeln</li>
|
||||
<li><strong>Leertaste</strong> : Später (ans Ende schieben)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-canvas border border-border rounded-card space-y-1.5">
|
||||
<h4 className="font-semibold text-main">3. Direkt-Zuordnung</h4>
|
||||
<p className="text-muted leading-relaxed">
|
||||
Über das Dropdown neben der Eingabeleiste kannst du einen Eintrag schon beim Schreiben direkt einer Aufgabenliste, Einkaufsliste oder Notiz zuweisen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SECTION: TASKS & LISTS */}
|
||||
{(activeCategory === 'all' || activeCategory === 'tasks') && (
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div className="border-b border-border pb-2.5">
|
||||
<h2 className="text-sm font-bold text-main flex items-center gap-2">
|
||||
<CheckSquare className="w-4 h-4 text-accent" />
|
||||
<span>Aufgaben, Listen & Team-Zusammenarbeit</span>
|
||||
</h2>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Verwalte Aufgabenlisten, Einkaufslisten und teile sie mit Teams und Räumen in Echtzeit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
|
||||
<div className="p-3 bg-canvas border border-border rounded-card space-y-2">
|
||||
<h3 className="font-semibold text-main flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Aufgabenlisten teilen & Avatar-Stack</span>
|
||||
</h3>
|
||||
<p className="text-muted leading-relaxed">
|
||||
Jede Aufgabenliste kann über den Button <strong>„Teilen"</strong> einem gemeinsamen Team-Space zugeordnet werden.
|
||||
Im Header und auf den Tabs zeigt der Avatar-Stack sofort an, wer Eigentümer ist und welche Teammitglieder Zugriff haben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Globale To-Dos Rollup</span>
|
||||
</h3>
|
||||
<p className="text-muted leading-relaxed">
|
||||
Im Workspace-Tab <strong>„Globale To-Dos"</strong> werden automatisch alle offenen Checkboxen (<code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">- [ ]</code>) aus sämtlichen Notizen und Ordnern aggregiert.
|
||||
Ein Klick auf die Checkbox synchronisiert die Aufgabe in Echtzeit direkt in der physischen Markdown-Datei!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SECTION: SHORTCUTS CHEAT SHEET */}
|
||||
{(activeCategory === 'all' || activeCategory === 'shortcuts') && (
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div className="border-b border-border pb-2.5">
|
||||
<h2 className="text-sm font-bold text-main flex items-center gap-2">
|
||||
<Keyboard className="w-4 h-4 text-accent" />
|
||||
<span>Tastatur-Kürzel Gesamtübersicht</span>
|
||||
</h2>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Schneller arbeiten ohne Maus: Alle Hotkeys auf einen Blick.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted">
|
||||
<th className="py-2 px-3 font-semibold">Taste</th>
|
||||
<th className="py-2 px-3 font-semibold">Bereich</th>
|
||||
<th className="py-2 px-3 font-semibold">Funktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border text-main">
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">C oder /</td>
|
||||
<td className="py-2 px-3 text-muted">Global</td>
|
||||
<td className="py-2 px-3">Fokus sofort in die Schnellerfassungsleiste setzen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">Enter</td>
|
||||
<td className="py-2 px-3 text-muted">Schnellerfassung</td>
|
||||
<td className="py-2 px-3">Gedanken sofort speichern</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">Esc</td>
|
||||
<td className="py-2 px-3 text-muted">Schnellerfassung</td>
|
||||
<td className="py-2 px-3">Eingabe leeren & Menüs schließen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">Enter</td>
|
||||
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
|
||||
<td className="py-2 px-3">Neuen Bullet-Point auf gleicher Ebene anlegen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">Tab</td>
|
||||
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
|
||||
<td className="py-2 px-3">Bullet-Point einrücken (dem vorherigen unterordnen)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">Shift + Tab</td>
|
||||
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
|
||||
<td className="py-2 px-3">Bullet-Point ausrücken (Ebene verringern)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">Shift + Enter</td>
|
||||
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
|
||||
<td className="py-2 px-3">Weicher Zeilenumbruch innerhalb des aktuellen Blocks</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">Backspace</td>
|
||||
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
|
||||
<td className="py-2 px-3">Bei leerem Block: Ebene ausrücken bzw. Bullet löschen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">/</td>
|
||||
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
|
||||
<td className="py-2 px-3">Slash-Command-Menü öffnen (/todo, /h1, /code, ...)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">H oder ←</td>
|
||||
<td className="py-2 px-3 text-muted">Triage-Deck</td>
|
||||
<td className="py-2 px-3">Gedanken verwerfen / erledigen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">L oder →</td>
|
||||
<td className="py-2 px-3 text-muted">Triage-Deck</td>
|
||||
<td className="py-2 px-3">Gedanken sofort zur Aufgabe machen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">T</td>
|
||||
<td className="py-2 px-3 text-muted">Triage-Deck</td>
|
||||
<td className="py-2 px-3">Aufgabe mit Fälligkeitsdatum anlegen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">D</td>
|
||||
<td className="py-2 px-3 text-muted">Triage-Deck</td>
|
||||
<td className="py-2 px-3">Gedanken in Notiz/Dokument umwandeln</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">Leertaste</td>
|
||||
<td className="py-2 px-3 text-muted">Triage-Deck</td>
|
||||
<td className="py-2 px-3">Karte überspringen („Später" – ans Ende schieben)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -10,28 +10,25 @@ import {
|
||||
Copy,
|
||||
Check,
|
||||
ArrowLeft,
|
||||
Share2,
|
||||
Send,
|
||||
AlertCircle,
|
||||
Building2,
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Organization,
|
||||
SystemSettings,
|
||||
Team,
|
||||
User,
|
||||
UserInvite,
|
||||
UserRole,
|
||||
} from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
|
||||
interface AdminSettingsViewProps {
|
||||
currentUser: User;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type AdminSection = 'users' | 'orgs' | 'server' | 'smtp';
|
||||
type AdminSection = 'users' | 'server' | 'smtp';
|
||||
|
||||
export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
currentUser,
|
||||
@@ -50,22 +47,11 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState<UserRole>('member');
|
||||
const [inviteTeamId, setInviteTeamId] = useState('');
|
||||
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
||||
|
||||
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
|
||||
const [resetPwdVal, setResetPwdVal] = useState('');
|
||||
|
||||
// --- Organizations & Teams State ---
|
||||
const [orgs, setOrgs] = useState<Organization[]>([]);
|
||||
const [allTeams, setAllTeams] = useState<Team[]>([]);
|
||||
const [newOrgName, setNewOrgName] = useState('');
|
||||
const [selectedOrgId, setSelectedOrgId] = useState('');
|
||||
const [newTeamName, setNewTeamName] = useState('');
|
||||
const [newTeamDesc, setNewTeamDesc] = useState('');
|
||||
const [memberSelectByTeam, setMemberSelectByTeam] = useState<Record<string, string>>({});
|
||||
const [memberRoleByTeam, setMemberRoleByTeam] = useState<Record<string, string>>({});
|
||||
|
||||
// --- Server & SMTP Settings State ---
|
||||
const [settings, setSettings] = useState<SystemSettings>({
|
||||
sharingMode: 'teams_only',
|
||||
@@ -86,19 +72,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
const [uList, invList, orgList, teamList] = await Promise.all([
|
||||
const [uList, invList] = await Promise.all([
|
||||
api.adminGetUsers(),
|
||||
api.adminGetInvites(),
|
||||
api.getOrganizations(),
|
||||
api.getUserTeams(),
|
||||
]);
|
||||
setUsers(uList);
|
||||
setInvites(invList);
|
||||
setOrgs(orgList);
|
||||
setAllTeams(teamList);
|
||||
if (orgList.length > 0 && !selectedOrgId) {
|
||||
setSelectedOrgId(orgList[0].id);
|
||||
}
|
||||
|
||||
if (isServerAdmin) {
|
||||
const sys = await api.adminGetSettings();
|
||||
@@ -157,7 +136,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
const inv = await api.adminCreateInvite({
|
||||
email: inviteEmail.trim(),
|
||||
role: inviteRole,
|
||||
teamId: inviteTeamId,
|
||||
});
|
||||
setInvites((prev) => [inv, ...prev]);
|
||||
setInviteEmail('');
|
||||
@@ -239,91 +217,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS: ORGANIZATIONS & TEAMS
|
||||
// ============================================================================
|
||||
const handleCreateOrg = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newOrgName.trim()) return;
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
const created = await api.createOrganization(newOrgName.trim());
|
||||
setNewOrgName('');
|
||||
setSelectedOrgId(created.id);
|
||||
await loadAll();
|
||||
setStatusMsg({ type: 'ok', text: `Organisation "${created.name}" angelegt.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOrg = async (org: Organization) => {
|
||||
if (!window.confirm(`Organisation "${org.name}" inklusive aller zugehörigen Teams löschen?`))
|
||||
return;
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
await api.deleteOrganization(org.id);
|
||||
await loadAll();
|
||||
setStatusMsg({ type: 'ok', text: `Organisation "${org.name}" gelöscht.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTeam = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedOrgId || !newTeamName.trim()) return;
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
const created = await api.createOrgTeam(
|
||||
selectedOrgId,
|
||||
newTeamName.trim(),
|
||||
newTeamDesc.trim()
|
||||
);
|
||||
setNewTeamName('');
|
||||
setNewTeamDesc('');
|
||||
await loadAll();
|
||||
setStatusMsg({ type: 'ok', text: `Team "${created.name}" wurde erstellt.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTeam = async (team: Team) => {
|
||||
if (!window.confirm(`Team "${team.name}" wirklich löschen?`)) return;
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
await api.deleteTeam(team.id);
|
||||
await loadAll();
|
||||
setStatusMsg({ type: 'ok', text: `Team "${team.name}" gelöscht.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMemberToTeam = async (teamId: string) => {
|
||||
const selectedUserId = memberSelectByTeam[teamId];
|
||||
if (!selectedUserId) return;
|
||||
const role = memberRoleByTeam[teamId] || 'member';
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
await api.addTeamMember(teamId, { userId: selectedUserId, role });
|
||||
await loadAll();
|
||||
setStatusMsg({ type: 'ok', text: 'Teammitglied hinzugefügt.' });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMemberFromTeam = async (teamId: string, userId: string) => {
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
await api.removeTeamMember(teamId, userId);
|
||||
await loadAll();
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS: SERVER & SMTP SETTINGS
|
||||
@@ -362,12 +256,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Collect all teams across orgs for invite dropdown
|
||||
const flattenedTeams: Team[] =
|
||||
orgs.flatMap((o) => o.teams || []).length > 0
|
||||
? orgs.flatMap((o) => o.teams || [])
|
||||
: allTeams;
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4">
|
||||
{/* Top Page Header */}
|
||||
@@ -384,17 +272,15 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-accent" />
|
||||
<span>Administration & Team-Steuerung</span>
|
||||
<span>Server-Administration</span>
|
||||
</h1>
|
||||
<p className="text-xs text-muted">
|
||||
Benutzerverwaltung, Einladungscodes, Organisationen, Teams, Isolationsmodus & Mail-Server
|
||||
Benutzerverwaltung, Einladungscodes, Isolationsmodus & Mail-Server
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="badge-accent">
|
||||
{isServerAdmin ? 'Server-Administrator' : 'Team-Administrator'}
|
||||
</span>
|
||||
<span className="badge-accent">Server-Administrator</span>
|
||||
</div>
|
||||
|
||||
{statusMsg && (
|
||||
@@ -439,21 +325,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSection('orgs')}
|
||||
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||
section === 'orgs'
|
||||
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
|
||||
: 'text-muted hover:text-main hover:bg-canvas/50'
|
||||
}`}
|
||||
>
|
||||
<Building2 className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">Organisationen & Teams</p>
|
||||
<p className="text-[10px] text-faint">Teams & Mitglieder zuweisen</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isServerAdmin && (
|
||||
<>
|
||||
<button
|
||||
@@ -558,8 +429,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||
>
|
||||
<option value="member">Mitglied (Standard)</option>
|
||||
<option value="team_admin">Team-Admin (Darf einladen)</option>
|
||||
{isServerAdmin && <option value="admin">Server-Admin (Vollzugriff)</option>}
|
||||
<option value="admin">Server-Admin (Vollzugriff)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -584,7 +454,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleCreateInvite} className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">
|
||||
Gebundene E-Mail (optional)
|
||||
@@ -599,33 +469,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">Zugewiesene Rolle</label>
|
||||
<label className="block text-muted mb-1">Zugewiesene Server-Rolle</label>
|
||||
<select
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value as UserRole)}
|
||||
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||
>
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="team_admin">Team-Admin</option>
|
||||
{isServerAdmin && <option value="admin">Server-Admin</option>}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">
|
||||
Automatisch Team zuweisen (optional)
|
||||
</label>
|
||||
<select
|
||||
value={inviteTeamId}
|
||||
onChange={(e) => setInviteTeamId(e.target.value)}
|
||||
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||
>
|
||||
<option value="">Kein direktes Team</option>
|
||||
{flattenedTeams.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="admin">Server-Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -706,16 +557,19 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="p-3 space-y-2">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-main">{u.displayName}</span>
|
||||
{!u.isActive && (
|
||||
<span className="badge-err text-[10px]">
|
||||
Gesperrt
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2.5">
|
||||
<UserAvatar user={u} size="sm" className="border border-border/80" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-main">{u.displayName}</span>
|
||||
{!u.isActive && (
|
||||
<span className="badge-err text-[10px]">
|
||||
Gesperrt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted text-[11px]">{u.email}</p>
|
||||
</div>
|
||||
<p className="text-muted text-[11px]">{u.email}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -728,8 +582,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
title="Benutzerrolle ändern"
|
||||
>
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="team_admin">Team-Admin</option>
|
||||
{isServerAdmin && <option value="admin">Server-Admin</option>}
|
||||
<option value="admin">Server-Admin</option>
|
||||
</select>
|
||||
|
||||
{isServerAdmin && (
|
||||
@@ -796,238 +649,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 2: ORGANISATIONEN & TEAMS */}
|
||||
{/* ================================================================= */}
|
||||
{section === 'orgs' && (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-border pb-3">
|
||||
<h2 className="text-sm font-bold text-main">Organisationen & Teams verwalten</h2>
|
||||
<p className="text-muted mt-0.5">
|
||||
Erstelle Organisationen und unterteile sie in Teams. Weise Benutzern Teams zu, damit
|
||||
sie im Modus "Teams isoliert" gemeinsam an Listen und Aufgaben arbeiten können.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Create Organization & Team */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||
<h3 className="font-bold text-main">1. Neue Organisation gründen</h3>
|
||||
<form onSubmit={handleCreateOrg} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newOrgName}
|
||||
onChange={(e) => setNewOrgName(e.target.value)}
|
||||
placeholder="z.B. Homelab, Familie, Firma…"
|
||||
required
|
||||
className="flex-1 min-w-0 h-8 px-2.5 input-field"
|
||||
/>
|
||||
<button type="submit" className="btn-primary h-8 px-3 text-xs flex-shrink-0">
|
||||
Anlegen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||
<h3 className="font-bold text-main">2. Neues Team in Organisation anlegen</h3>
|
||||
{orgs.length === 0 ? (
|
||||
<p className="text-faint">
|
||||
Bitte lege zuerst links eine Organisation an.
|
||||
</p>
|
||||
) : (
|
||||
<form onSubmit={handleCreateTeam} className="space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<select
|
||||
value={selectedOrgId}
|
||||
onChange={(e) => setSelectedOrgId(e.target.value)}
|
||||
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||
>
|
||||
{orgs.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
Org: {o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={newTeamName}
|
||||
onChange={(e) => setNewTeamName(e.target.value)}
|
||||
placeholder="Team-Name (z.B. Infrastruktur)"
|
||||
required
|
||||
className="w-full min-w-0 h-8 px-2.5 input-field"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTeamDesc}
|
||||
onChange={(e) => setNewTeamDesc(e.target.value)}
|
||||
placeholder="Kurzbeschreibung (optional)…"
|
||||
className="flex-1 min-w-0 h-8 px-2.5 input-field"
|
||||
/>
|
||||
<button type="submit" className="btn-primary h-8 px-3 text-xs flex-shrink-0">
|
||||
Team erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Organizations & Nested Teams Display */}
|
||||
<div className="space-y-4">
|
||||
{orgs.length === 0 ? (
|
||||
<div className="p-6 bg-canvas border border-border rounded-card text-center text-faint">
|
||||
Noch keine Organisationen vorhanden.
|
||||
</div>
|
||||
) : (
|
||||
orgs.map((org) => {
|
||||
const orgTeams = Array.isArray(org.teams) ? org.teams : [];
|
||||
return (
|
||||
<div key={org.id} className="bg-canvas border border-border rounded-card overflow-hidden">
|
||||
<div className="p-3 bg-panel-elevated border-b border-border flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-accent" />
|
||||
<span className="font-bold text-sm text-main">{org.name}</span>
|
||||
<span className="text-[11px] text-muted">
|
||||
({orgTeams.length} {orgTeams.length === 1 ? 'Team' : 'Teams'})
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteOrg(org)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Organisation löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 space-y-3">
|
||||
{orgTeams.length === 0 ? (
|
||||
<p className="text-faint text-xs">
|
||||
Noch keine Teams in dieser Organisation.
|
||||
</p>
|
||||
) : (
|
||||
orgTeams.map((team) => {
|
||||
const members = Array.isArray(team.members) ? team.members : [];
|
||||
return (
|
||||
<div
|
||||
key={team.id}
|
||||
className="p-3 bg-panel border border-border rounded-card space-y-3"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
||||
<div>
|
||||
<span className="font-bold text-main flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5 text-accent" />
|
||||
<span>{team.name}</span>
|
||||
</span>
|
||||
{team.description && (
|
||||
<p className="text-[11px] text-muted mt-0.5">
|
||||
{team.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteTeam(team)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Team löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Team Members */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] font-semibold text-muted">
|
||||
Mitglieder ({members.length}):
|
||||
</p>
|
||||
{members.length === 0 ? (
|
||||
<p className="text-[11px] text-faint">
|
||||
Noch keine Mitglieder zugewiesen.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{members.map((m) => (
|
||||
<div
|
||||
key={m.userId}
|
||||
className="px-2 py-1 bg-canvas border border-border rounded-btn flex items-center gap-2"
|
||||
>
|
||||
<span className="text-main font-medium">
|
||||
{m.displayName}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted">
|
||||
({m.role})
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
handleRemoveMemberFromTeam(team.id, m.userId)
|
||||
}
|
||||
className="text-faint hover:text-status-err"
|
||||
title="Aus Team entfernen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add User to Team */}
|
||||
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
value={memberSelectByTeam[team.id] || ''}
|
||||
onChange={(e) =>
|
||||
setMemberSelectByTeam((prev) => ({
|
||||
...prev,
|
||||
[team.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="flex-1 min-w-[160px] h-7 px-2 input-field text-[11px] bg-canvas"
|
||||
>
|
||||
<option value="">Benutzer auswählen…</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.displayName} ({u.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={memberRoleByTeam[team.id] || 'member'}
|
||||
onChange={(e) =>
|
||||
setMemberRoleByTeam((prev) => ({
|
||||
...prev,
|
||||
[team.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="h-7 px-2 input-field text-[11px] bg-canvas"
|
||||
>
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="team_admin">Team-Admin</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddMemberToTeam(team.id)}
|
||||
disabled={!memberSelectByTeam[team.id]}
|
||||
className="btn-action h-7 px-2.5 text-[11px] disabled:opacity-40"
|
||||
>
|
||||
+ Zum Team hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
|
||||
@@ -1071,7 +693,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
{/* Sharing Isolation Cards */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-muted font-semibold flex items-center gap-1.5">
|
||||
<Share2 className="w-4 h-4 text-accent" />
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Globale Sharing- & Sichtbarkeits-Isolation</span>
|
||||
</label>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
User as UserIcon,
|
||||
Bell,
|
||||
@@ -15,10 +15,13 @@ import {
|
||||
Send,
|
||||
AlertCircle,
|
||||
Palette,
|
||||
Upload,
|
||||
Camera,
|
||||
} from 'lucide-react';
|
||||
import { APIKey, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme';
|
||||
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
||||
|
||||
interface UserSettingsViewProps {
|
||||
currentUser: User;
|
||||
@@ -42,9 +45,13 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
// --- Profile State ---
|
||||
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
|
||||
const [email, setEmail] = useState(currentUser.email || '');
|
||||
const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || '');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [revokingSessions, setRevokingSessions] = useState(false);
|
||||
const [isProcessingAvatar, setIsProcessingAvatar] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// --- Multi-Channel Alarms State ---
|
||||
const [alarmChannels, setAlarmChannels] = useState<string[]>(
|
||||
@@ -69,6 +76,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
useEffect(() => {
|
||||
setDisplayName(currentUser.displayName || '');
|
||||
setEmail(currentUser.email || '');
|
||||
setAvatarUrl(currentUser.avatarUrl || '');
|
||||
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
|
||||
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
|
||||
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
|
||||
@@ -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 ---
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -104,6 +140,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
const patch: any = {
|
||||
displayName: displayName.trim(),
|
||||
email: email.trim(),
|
||||
avatarUrl: avatarUrl.trim(),
|
||||
};
|
||||
if (newPassword.trim()) {
|
||||
patch.password = newPassword.trim();
|
||||
@@ -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 ---
|
||||
const handleSaveAlarms = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -228,6 +280,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
const activeKeyPlaceholder =
|
||||
newlyCreatedKey || (apiKeys[0]?.keyPrefix ? apiKeys[0].keyPrefix + '••••••••' : 'DEIN_API_KEY');
|
||||
|
||||
const curlTriageExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
|
||||
-H "X-API-Key: ${activeKeyPlaceholder}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm", "priority": "urgent"}'`;
|
||||
|
||||
const curlTaskExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
|
||||
-H "X-API-Key: ${activeKeyPlaceholder}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
@@ -236,8 +293,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
const roleBadgeLabel =
|
||||
currentUser.role === 'admin'
|
||||
? 'Server-Admin'
|
||||
: currentUser.role === 'team_admin'
|
||||
? 'Team-Admin'
|
||||
: 'Mitglied';
|
||||
|
||||
return (
|
||||
@@ -360,7 +415,88 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</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>
|
||||
<label className="block text-muted font-medium mb-1">Anzeigename</label>
|
||||
@@ -410,6 +546,32 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Sessions & Security */}
|
||||
<div className="pt-5 border-t border-border space-y-3">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-main">Aktive Sitzungen & Sicherheit</h3>
|
||||
<p className="text-[11px] text-muted">
|
||||
Sitzungen werden durch kryptografische JWTs geschützt. Bei einer Passwortänderung werden alle alten Sitzungen automatisch ungültig.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-canvas">
|
||||
<div className="text-xs">
|
||||
<p className="font-semibold text-main">Alle Sitzungen sofort beenden</p>
|
||||
<p className="text-[11px] text-faint">
|
||||
Invalidiert alle ausgestellten Tokens auf allen Geräten und Browsern.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRevokeAllSessions}
|
||||
disabled={revokingSessions}
|
||||
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer"
|
||||
>
|
||||
{revokingSessions ? 'Beende…' : 'Alle Sitzungen beenden'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -805,23 +967,43 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Curl Example */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-muted flex items-center gap-1.5">
|
||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Curl-Beispiel: Direkt als Aufgabe einspeisen</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopy(curlTaskExample, 'curl')}
|
||||
className="btn-action h-7 px-2.5 text-[11px]"
|
||||
>
|
||||
{copiedLabel === 'curl' ? 'Kopiert!' : 'Befehl kopieren'}
|
||||
</button>
|
||||
{/* Curl Examples */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-muted flex items-center gap-1.5">
|
||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Curl: In Triage-Deck einspeisen (erhält Webhook-Badge)</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopy(curlTriageExample, 'curl-triage')}
|
||||
className="btn-action h-7 px-2.5 text-[11px]"
|
||||
>
|
||||
{copiedLabel === 'curl-triage' ? 'Kopiert!' : 'Befehl kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
|
||||
{curlTriageExample}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-muted flex items-center gap-1.5">
|
||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Curl: Direkt als Aufgabe anlegen</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopy(curlTaskExample, 'curl-task')}
|
||||
className="btn-action h-7 px-2.5 text-[11px]"
|
||||
>
|
||||
{copiedLabel === 'curl-task' ? 'Kopiert!' : 'Befehl kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
|
||||
{curlTaskExample}
|
||||
</pre>
|
||||
</div>
|
||||
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
|
||||
{curlTaskExample}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* Full Data Backup */}
|
||||
|
||||
@@ -0,0 +1,653 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Users,
|
||||
Plus,
|
||||
Trash2,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
AlertCircle,
|
||||
UserPlus,
|
||||
Shield,
|
||||
Pencil,
|
||||
X,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import { Team, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
|
||||
interface GroupsViewProps {
|
||||
currentUser: User;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) => {
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [directoryUsers, setDirectoryUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
// Create Group Modal State
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [newGroupDesc, setNewGroupDesc] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Edit Group State
|
||||
const [editingTeamId, setEditingTeamId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editDesc, setEditDesc] = useState('');
|
||||
|
||||
// Add Member State per Team
|
||||
const [selectedUserByTeam, setSelectedUserByTeam] = useState<Record<string, User | null>>({});
|
||||
const [memberRoleByTeam, setMemberRoleByTeam] = useState<Record<string, 'admin' | 'member'>>({});
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [userTeams, allUsers] = await Promise.all([
|
||||
api.getUserTeams(),
|
||||
api.getUsersDirectory().catch(() => []),
|
||||
]);
|
||||
setTeams(userTeams);
|
||||
setDirectoryUsers(allUsers);
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Laden der Gruppen' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleCreateGroup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = newGroupName.trim();
|
||||
if (!name) return;
|
||||
|
||||
setCreating(true);
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(15);
|
||||
|
||||
try {
|
||||
const created = await api.createTeam(name, newGroupDesc.trim());
|
||||
setNewGroupName('');
|
||||
setNewGroupDesc('');
|
||||
setShowCreateModal(false);
|
||||
await loadData();
|
||||
setStatusMsg({ type: 'ok', text: `Gruppe "${created.name}" erfolgreich erstellt.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Gruppe' });
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEdit = (team: Team) => {
|
||||
setEditingTeamId(team.id);
|
||||
setEditName(team.name);
|
||||
setEditDesc(team.description || '');
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (teamId: string) => {
|
||||
const name = editName.trim();
|
||||
if (!name) return;
|
||||
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(10);
|
||||
try {
|
||||
await api.updateTeam(teamId, name, editDesc.trim());
|
||||
setEditingTeamId(null);
|
||||
await loadData();
|
||||
setStatusMsg({ type: 'ok', text: 'Gruppe erfolgreich aktualisiert.' });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Aktualisieren der Gruppe' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTeam = async (team: Team) => {
|
||||
if (!window.confirm(`Gruppe "${team.name}" wirklich löschen? Alle Freigaben für diese Gruppe werden entfernt.`)) {
|
||||
return;
|
||||
}
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
await api.deleteTeam(team.id);
|
||||
await loadData();
|
||||
setStatusMsg({ type: 'ok', text: `Gruppe "${team.name}" gelöscht.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Löschen der Gruppe' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async (teamId: string) => {
|
||||
const user = selectedUserByTeam[teamId];
|
||||
if (!user) return;
|
||||
const role = memberRoleByTeam[teamId] || 'member';
|
||||
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(10);
|
||||
try {
|
||||
await api.addTeamMember(teamId, { userId: user.id, role });
|
||||
setSelectedUserByTeam((prev) => ({ ...prev, [teamId]: null }));
|
||||
await loadData();
|
||||
setStatusMsg({ type: 'ok', text: `${user.displayName} wurde zur Gruppe hinzugefügt.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Hinzufügen des Mitglieds' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeMemberRole = async (teamId: string, memberUserId: string, newRole: 'admin' | 'member') => {
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(10);
|
||||
try {
|
||||
await api.updateTeamMemberRole(teamId, memberUserId, newRole);
|
||||
await loadData();
|
||||
setStatusMsg({
|
||||
type: 'ok',
|
||||
text: `Rolle erfolgreich zu "${newRole === 'admin' ? 'Gruppen-Admin' : 'Mitglied'}" geändert.`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Ändern der Rolle' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (teamId: string, memberUserId: string, memberName: string) => {
|
||||
const isSelf = memberUserId === currentUser.id;
|
||||
const msg = isSelf
|
||||
? 'Möchtest du diese Gruppe wirklich verlassen?'
|
||||
: `Mitglied "${memberName}" wirklich aus der Gruppe entfernen?`;
|
||||
|
||||
if (!window.confirm(msg)) return;
|
||||
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(10);
|
||||
try {
|
||||
await api.removeTeamMember(teamId, memberUserId);
|
||||
await loadData();
|
||||
setStatusMsg({
|
||||
type: 'ok',
|
||||
text: isSelf ? 'Du hast die Gruppe verlassen.' : `Mitglied "${memberName}" wurde entfernt.`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Entfernen des Mitglieds' });
|
||||
}
|
||||
};
|
||||
|
||||
// Group teams into managed (admin) and member-only
|
||||
const managedTeams = teams.filter((t) =>
|
||||
(t.members || []).some((m) => m.userId === currentUser.id && m.role === 'admin')
|
||||
);
|
||||
|
||||
const memberTeams = teams.filter(
|
||||
(t) =>
|
||||
(t.members || []).some((m) => m.userId === currentUser.id && m.role === 'member') &&
|
||||
!managedTeams.some((mt) => mt.id === t.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-5">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5 cursor-pointer"
|
||||
title="Zurück"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Zurück</span>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Gruppen & Teams</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
|
||||
{teams.length}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Erstelle und verwalte Gruppen (z. B. Familie, WG oder Projektteams) für gemeinsame Notizen, Aufgaben und Listen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
className="btn-primary h-8 px-3.5 text-xs flex items-center gap-1.5 self-start sm:self-auto cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Neue Gruppe</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
{statusMsg && (
|
||||
<div
|
||||
className={`p-3 text-xs flex items-center justify-between gap-2 rounded-card ${
|
||||
statusMsg.type === 'ok' ? 'alert-ok' : 'alert-err'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusMsg.type === 'ok' ? (
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
|
||||
)}
|
||||
<span>{statusMsg.text}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setStatusMsg(null)}
|
||||
className="text-muted hover:text-main text-[11px] cursor-pointer"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal: Neue Gruppe erstellen */}
|
||||
{showCreateModal && (
|
||||
<div
|
||||
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs p-4 animate-in fade-in duration-150"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md bg-panel border border-border rounded-xl shadow-panel p-5 space-y-4 animate-in zoom-in-95 duration-150"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between pb-3 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
<h3 className="font-bold text-sm text-main">Neue Gruppe erstellen</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="p-1 text-muted hover:text-main rounded-btn transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateGroup} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-muted text-xs mb-1 font-medium">
|
||||
Name der Gruppe *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder="z. B. Familie, WG, Arbeit, Projekt X…"
|
||||
required
|
||||
autoFocus
|
||||
className="w-full h-8 px-2.5 input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted text-xs mb-1 font-medium">
|
||||
Beschreibung (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupDesc}
|
||||
onChange={(e) => setNewGroupDesc(e.target.value)}
|
||||
placeholder="z. B. Gemeinsame Einkaufslisten & Notizen…"
|
||||
className="w-full h-8 px-2.5 input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-canvas border border-border rounded-btn text-[11px] text-muted space-y-1">
|
||||
<p className="font-semibold text-main flex items-center gap-1.5">
|
||||
<Shield className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Du wirst Gruppen-Admin</span>
|
||||
</p>
|
||||
<p>
|
||||
Als Ersteller kannst du weitere Mitglieder hinzufügen, Rollen zuweisen und die Gruppe verwalten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="btn-action h-8 px-3 text-xs cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !newGroupName.trim()}
|
||||
className="btn-primary h-8 px-4 text-xs flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Gruppe anlegen</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted text-xs">Lade Gruppen…</div>
|
||||
) : teams.length === 0 ? (
|
||||
<div className="p-10 bg-canvas border border-border rounded-card text-center space-y-3">
|
||||
<div className="w-12 h-12 rounded-full bg-panel-elevated text-muted mx-auto flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-accent opacity-75" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-main text-sm">Noch keine Gruppen vorhanden</h3>
|
||||
<p className="text-muted text-xs mt-1 max-w-md mx-auto">
|
||||
Erstelle deine erste Gruppe, um Aufgaben, Einkaufslisten oder Dokumente gemeinsam mit anderen zu nutzen.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
className="btn-primary h-8 px-4 text-xs inline-flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Erste Gruppe anlegen</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* SECTION 1: Gruppen, die ich verwalte */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xs font-bold text-main uppercase tracking-wider text-muted flex items-center gap-2">
|
||||
<Shield className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Gruppen, die ich verwalte ({managedTeams.length})</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{managedTeams.length === 0 ? (
|
||||
<div className="p-4 bg-canvas border border-border rounded-card text-xs text-muted">
|
||||
Du bist derzeit noch in keiner Gruppe als Admin eingetragen. Klicke oben auf "Neue Gruppe", um eine eigene Gruppe zu erstellen.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{managedTeams.map((team) => {
|
||||
const members = team.members || [];
|
||||
const isEditing = editingTeamId === team.id;
|
||||
const memberUserIds = members.map((m) => m.userId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={team.id}
|
||||
className="p-4 bg-canvas border border-border rounded-card space-y-4 shadow-sm"
|
||||
>
|
||||
{/* Team Header */}
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="flex items-start gap-2.5 flex-1 min-w-0">
|
||||
<div className="w-8 h-8 rounded-btn bg-accent/15 text-accent flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex-1 space-y-2 max-w-lg">
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="Gruppenname"
|
||||
className="w-full h-8 px-2.5 input-field text-xs font-bold"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value)}
|
||||
placeholder="Optionale Beschreibung"
|
||||
className="w-full h-7 px-2.5 input-field text-xs"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveEdit(team.id)}
|
||||
className="btn-primary h-7 px-3 text-[11px] cursor-pointer"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTeamId(null)}
|
||||
className="btn-action h-7 px-2.5 text-[11px] cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-bold text-sm text-main truncate">
|
||||
{team.name}
|
||||
</span>
|
||||
<span className="badge-accent text-[10px] py-0.5 px-2">
|
||||
Gruppen-Admin
|
||||
</span>
|
||||
<span className="text-[11px] px-1.5 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
|
||||
{members.length} {members.length === 1 ? 'Mitglied' : 'Mitglieder'}
|
||||
</span>
|
||||
</div>
|
||||
{team.description && (
|
||||
<p className="text-xs text-muted mt-1 break-words">
|
||||
{team.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleStartEdit(team)}
|
||||
className="btn-action h-7 px-2.5 text-xs flex items-center gap-1 cursor-pointer"
|
||||
title="Gruppe bearbeiten"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Bearbeiten</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTeam(team)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Gruppe löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members List */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold text-muted uppercase tracking-wider">
|
||||
Mitglieder ({members.length})
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{members.map((m) => {
|
||||
const isSelf = m.userId === currentUser.id;
|
||||
const isAdmin = m.role === 'admin';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.userId}
|
||||
className="p-2.5 bg-panel border border-border rounded-btn flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<UserAvatar user={{ displayName: m.displayName, email: m.email, avatarUrl: m.avatarUrl }} size="xs" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-main font-medium truncate text-xs">
|
||||
{m.displayName} {isSelf && <span className="text-faint">(Du)</span>}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted truncate">
|
||||
{m.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<select
|
||||
value={m.role}
|
||||
onChange={(e) =>
|
||||
handleChangeMemberRole(
|
||||
team.id,
|
||||
m.userId,
|
||||
e.target.value as 'admin' | 'member'
|
||||
)
|
||||
}
|
||||
className={`h-6 px-1.5 text-[10px] rounded input-field bg-panel cursor-pointer ${
|
||||
isAdmin
|
||||
? 'border-accent text-accent font-semibold'
|
||||
: 'text-muted'
|
||||
}`}
|
||||
title="Rolle ändern"
|
||||
>
|
||||
<option value="member" className="bg-panel text-main">Mitglied</option>
|
||||
<option value="admin" className="bg-panel text-main">Gruppen-Admin</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => handleRemoveMember(team.id, m.userId, m.displayName)}
|
||||
className="p-1 text-faint hover:text-status-err rounded transition-colors cursor-pointer"
|
||||
title={isSelf ? 'Gruppe verlassen' : 'Mitglied entfernen'}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Member Row */}
|
||||
<div className="pt-3 border-t border-border flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<div className="flex-1 min-w-[220px]">
|
||||
<UserAutocomplete
|
||||
users={directoryUsers}
|
||||
selectedUser={selectedUserByTeam[team.id] || null}
|
||||
onSelectUser={(u) =>
|
||||
setSelectedUserByTeam((prev) => ({ ...prev, [team.id]: u }))
|
||||
}
|
||||
excludeUserIds={memberUserIds}
|
||||
placeholder="Benutzer suchen zum Hinzufügen…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={memberRoleByTeam[team.id] || 'member'}
|
||||
onChange={(e) =>
|
||||
setMemberRoleByTeam((prev) => ({
|
||||
...prev,
|
||||
[team.id]: e.target.value as 'admin' | 'member',
|
||||
}))
|
||||
}
|
||||
className="h-8 px-2.5 input-field text-xs bg-panel"
|
||||
>
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="admin">Gruppen-Admin</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddMember(team.id)}
|
||||
disabled={!selectedUserByTeam[team.id]}
|
||||
className="btn-primary h-8 px-3.5 text-xs flex items-center justify-center gap-1.5 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
<span>Hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SECTION 2: Meine Mitgliedschaften (als Mitglied) */}
|
||||
{memberTeams.length > 0 && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<h2 className="text-xs font-bold text-main uppercase tracking-wider text-muted flex items-center gap-2">
|
||||
<Users className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Meine Mitgliedschaften ({memberTeams.length})</span>
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{memberTeams.map((team) => {
|
||||
const members = team.members || [];
|
||||
const admins = members.filter((m) => m.role === 'admin');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={team.id}
|
||||
className="p-4 bg-canvas border border-border rounded-card space-y-3 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-8 h-8 rounded-btn bg-panel-elevated text-muted flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-sm text-main">{team.name}</span>
|
||||
<span className="text-[11px] px-1.5 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
|
||||
{members.length} {members.length === 1 ? 'Mitglied' : 'Mitglieder'}
|
||||
</span>
|
||||
</div>
|
||||
{team.description && (
|
||||
<p className="text-xs text-muted mt-1">{team.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleRemoveMember(team.id, currentUser.id, currentUser.displayName)}
|
||||
className="btn-action text-status-err hover:bg-status-err-bg h-7 px-2.5 text-xs flex items-center gap-1.5 cursor-pointer"
|
||||
title="Gruppe verlassen"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
<span>Gruppe verlassen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Admins & Members list in read-only mode */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] font-semibold text-muted">
|
||||
Gruppen-Admins:{' '}
|
||||
<span className="text-main font-normal">
|
||||
{admins.map((a) => a.displayName).join(', ') || 'Keiner'}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-[11px] font-semibold text-muted">
|
||||
Alle Mitglieder:{' '}
|
||||
<span className="text-faint font-normal">
|
||||
{members.map((m) => m.displayName).join(', ')}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,295 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
Trash2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
RotateCcw,
|
||||
Check,
|
||||
Columns,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { KanbanColumn } from '../../lib/types';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface ManageColumnsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialColumns: KanbanColumn[];
|
||||
onSave: (columns: KanbanColumn[]) => Promise<void>;
|
||||
listTitle?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [
|
||||
{ id: 'todo', name: 'Zu erledigen' },
|
||||
{ id: 'in_progress', name: 'In Bearbeitung', color: 'accent' },
|
||||
{ id: 'waiting', name: 'Wartet auf andere', color: 'amber' },
|
||||
{ id: 'done', name: 'Erledigt', isDone: true, color: 'emerald' },
|
||||
];
|
||||
|
||||
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialColumns,
|
||||
onSave,
|
||||
listTitle,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [columns, setColumns] = useState<KanbanColumn[]>(() => {
|
||||
return initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS;
|
||||
});
|
||||
const [newColName, setNewColName] = useState('');
|
||||
const [newColIsDone, setNewColIsDone] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setColumns(initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS);
|
||||
}, [initialColumns, isOpen]);
|
||||
|
||||
const handleMove = (index: number, direction: 'up' | 'down') => {
|
||||
triggerHaptic(10);
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (targetIndex < 0 || targetIndex >= columns.length) return;
|
||||
const next = [...columns];
|
||||
const temp = next[index];
|
||||
next[index] = next[targetIndex];
|
||||
next[targetIndex] = temp;
|
||||
setColumns(next);
|
||||
};
|
||||
|
||||
const handleRename = (id: string, newName: string) => {
|
||||
setColumns((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, name: newName } : c))
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleDone = (id: string) => {
|
||||
triggerHaptic(10);
|
||||
setColumns((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, isDone: !c.isDone } : c))
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (columns.length <= 1) {
|
||||
alert('Mindestens eine Spalte muss vorhanden sein.');
|
||||
return;
|
||||
}
|
||||
triggerHaptic(10);
|
||||
setColumns((prev) => prev.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
const handleAddColumn = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = newColName.trim();
|
||||
if (!name) return;
|
||||
triggerHaptic(10);
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').slice(0, 20) || 'col';
|
||||
const id = `${slug}_${Date.now().toString(36).slice(-4)}`;
|
||||
setColumns((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
name,
|
||||
isDone: newColIsDone,
|
||||
},
|
||||
]);
|
||||
setNewColName('');
|
||||
setNewColIsDone(false);
|
||||
};
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
if (window.confirm('Spalten auf Standard zurücksetzen? (Zu erledigen, In Bearbeitung, Wartet auf andere, Erledigt)')) {
|
||||
triggerHaptic(15);
|
||||
setColumns(DEFAULT_KANBAN_COLUMNS);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (columns.length === 0) {
|
||||
alert('Es muss mindestens eine Spalte vorhanden sein.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
await onSave(columns);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Speichern der Spalten');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4">
|
||||
<div
|
||||
className="w-full max-w-md panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[90vh] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
|
||||
<div className="flex items-center gap-2">
|
||||
<Columns className="w-4 h-4 text-accent" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-main">Kanban-Spalten anpassen</h3>
|
||||
{listTitle && (
|
||||
<p className="text-[10px] text-muted truncate">Liste: {listTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted hover:text-main p-1.5 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4 overflow-y-auto flex-1">
|
||||
<p className="text-xs text-muted">
|
||||
Passe die Spalten für das Kanban-Board an. Aufgaben können in jede beliebige Spalte verschoben werden.
|
||||
</p>
|
||||
|
||||
{/* Columns List */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-semibold text-main">Aktive Spalten ({columns.length}):</label>
|
||||
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
|
||||
{columns.map((col, idx) => (
|
||||
<div
|
||||
key={col.id}
|
||||
className="p-2 bg-canvas border border-border rounded-card flex items-center gap-2"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={idx === 0}
|
||||
onClick={() => handleMove(idx, 'up')}
|
||||
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
|
||||
title="Nach oben"
|
||||
>
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={idx === columns.length - 1}
|
||||
onClick={() => handleMove(idx, 'down')}
|
||||
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
|
||||
title="Nach unten"
|
||||
>
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={col.name}
|
||||
onChange={(e) => handleRename(col.id, e.target.value)}
|
||||
className="flex-1 h-7 px-2 text-xs input-field"
|
||||
placeholder="Spaltenname"
|
||||
/>
|
||||
|
||||
{/* IsDone Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleDone(col.id)}
|
||||
className={`h-7 px-2 text-[10px] rounded-btn border transition-colors flex items-center gap-1 ${
|
||||
col.isDone
|
||||
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
|
||||
: 'bg-panel border-border text-faint hover:text-muted'
|
||||
}`}
|
||||
title={col.isDone ? 'Gilt als Erledigt' : 'Gilt als Offen'}
|
||||
>
|
||||
<Check className={`w-2.5 h-2.5 ${col.isDone ? 'stroke-[3]' : 'opacity-40'}`} />
|
||||
<span>Erledigt</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(col.id)}
|
||||
disabled={columns.length <= 1}
|
||||
className="text-muted hover:text-status-err disabled:opacity-20 p-1 transition-colors"
|
||||
title="Spalte löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add New Column Form */}
|
||||
<form onSubmit={handleAddColumn} className="space-y-2 pt-2 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main">Neue Spalte hinzufügen:</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newColName}
|
||||
onChange={(e) => setNewColName(e.target.value)}
|
||||
placeholder="z.B. Wartet auf Feedback, Review…"
|
||||
className="flex-1 h-8 px-2.5 text-xs input-field"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewColIsDone((prev) => !prev)}
|
||||
className={`h-8 px-2.5 text-xs rounded-btn border transition-colors flex items-center gap-1 ${
|
||||
newColIsDone
|
||||
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
|
||||
: 'bg-panel border-border text-muted hover:text-main'
|
||||
}`}
|
||||
title="Sollen Aufgaben in dieser Spalte als erledigt gelten?"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<span>Erledigt</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newColName.trim()}
|
||||
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-40"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Reset button */}
|
||||
<div className="pt-2 flex justify-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetToDefault}
|
||||
className="text-xs text-muted hover:text-main flex items-center gap-1"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 text-accent" />
|
||||
<span>Auf Standard-Spalten zurücksetzen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-2.5 border-t border-border bg-panel">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-action h-7 px-3 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-primary h-7 px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
Spalten speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,745 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Check,
|
||||
Calendar,
|
||||
Trash2,
|
||||
FileText,
|
||||
Plus,
|
||||
CheckSquare,
|
||||
Clock,
|
||||
Layers,
|
||||
Hash,
|
||||
AlertTriangle,
|
||||
Flame,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
Webhook,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
import { MarkdownViewer } from '../../lib/markdown';
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
task: Thought | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
activeColumns: KanbanColumn[];
|
||||
taskLists: TaskList[];
|
||||
onUpdateTitle: (task: Thought, newTitle: string) => Promise<void>;
|
||||
onUpdateStatus: (task: Thought, newStatus: string) => Promise<void>;
|
||||
onUpdatePriority: (task: Thought, newPriority: string) => Promise<void>;
|
||||
onUpdateDueDate: (task: Thought, newDueDate: string) => Promise<void>;
|
||||
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
|
||||
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
|
||||
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
|
||||
onToggleSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
||||
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||
onConvertToDoc: (task: Thought) => void;
|
||||
onOpenDocument?: (docId: string, blockId?: string) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
readOnly?: boolean; // Task belongs to a list shared with "viewer" permission
|
||||
}
|
||||
|
||||
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
task,
|
||||
isOpen,
|
||||
onClose,
|
||||
activeColumns,
|
||||
taskLists,
|
||||
onUpdateTitle,
|
||||
onUpdateStatus,
|
||||
onUpdatePriority,
|
||||
onUpdateDueDate,
|
||||
onUpdateTaskList,
|
||||
onUpdateNotes,
|
||||
onUpdateTags,
|
||||
onToggleSubtask,
|
||||
onAddSubtask,
|
||||
onDeleteSubtask,
|
||||
onConvertToDoc,
|
||||
onOpenDocument,
|
||||
onDeleteTask,
|
||||
readOnly = false,
|
||||
}) => {
|
||||
if (!isOpen || !task) return null;
|
||||
|
||||
const [titleDraft, setTitleDraft] = useState(task.content);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
|
||||
const [isEditingNotes, setIsEditingNotes] = useState(false);
|
||||
const [notesDraft, setNotesDraft] = useState(task.notes || '');
|
||||
|
||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
||||
const [newTagDraft, setNewTagDraft] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setTitleDraft(task.content);
|
||||
setNotesDraft(task.notes || '');
|
||||
setIsEditingTitle(false);
|
||||
setIsEditingNotes(false);
|
||||
}, [task.id]);
|
||||
|
||||
// Handle escape key to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (isEditingNotes) {
|
||||
setIsEditingNotes(false);
|
||||
} else if (isEditingTitle) {
|
||||
setIsEditingTitle(false);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose, isEditingNotes, isEditingTitle]);
|
||||
|
||||
const handleTitleSubmit = async () => {
|
||||
const trimmed = titleDraft.trim();
|
||||
if (!trimmed) {
|
||||
setTitleDraft(task.content);
|
||||
setIsEditingTitle(false);
|
||||
return;
|
||||
}
|
||||
if (trimmed !== task.content) {
|
||||
await onUpdateTitle(task, trimmed);
|
||||
}
|
||||
setIsEditingTitle(false);
|
||||
};
|
||||
|
||||
const handleNotesSubmit = async () => {
|
||||
await onUpdateNotes(task, notesDraft);
|
||||
setIsEditingNotes(false);
|
||||
};
|
||||
|
||||
const handleAddSubtaskSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = newSubtaskTitle.trim();
|
||||
if (!trimmed) return;
|
||||
await onAddSubtask(task, trimmed);
|
||||
setNewSubtaskTitle('');
|
||||
};
|
||||
|
||||
const handleAddTagSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
let cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase();
|
||||
if (!cleaned) return;
|
||||
const currentTags = Array.isArray(task.tags) ? [...task.tags] : [];
|
||||
if (!currentTags.includes(cleaned)) {
|
||||
const updated = [...currentTags, cleaned];
|
||||
if (onUpdateTags) {
|
||||
await onUpdateTags(task, updated);
|
||||
} else {
|
||||
await onUpdateTitle(task, `${task.content} #${cleaned}`);
|
||||
}
|
||||
}
|
||||
setNewTagDraft('');
|
||||
};
|
||||
|
||||
const handleRemoveTag = async (tagToRemove: string) => {
|
||||
const currentTags = Array.isArray(task.tags) ? [...task.tags] : [];
|
||||
const updated = currentTags.filter((t) => t !== tagToRemove);
|
||||
if (onUpdateTags) {
|
||||
await onUpdateTags(task, updated);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickDueDate = (daysOffset: number | null) => {
|
||||
if (daysOffset === null) {
|
||||
onUpdateDueDate(task, '');
|
||||
return;
|
||||
}
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + daysOffset);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
onUpdateDueDate(task, dateStr);
|
||||
};
|
||||
|
||||
const currentColumn = activeColumns.find((c) => c.id === task.kanbanStatus) ||
|
||||
(task.isCompleted
|
||||
? activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1]
|
||||
: activeColumns.find((c) => !c.isDone) || activeColumns[0]);
|
||||
|
||||
const subtasks = Array.isArray(task.subtasks) ? task.subtasks : [];
|
||||
const completedSubtasks = subtasks.filter((s) => s.isCompleted).length;
|
||||
const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
|
||||
<div
|
||||
className="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()}
|
||||
>
|
||||
{/* Top Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
disabled={readOnly}
|
||||
onClick={() => {
|
||||
triggerHaptic(15);
|
||||
const nextState = !task.isCompleted;
|
||||
const doneCol = activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1];
|
||||
const todoCol = activeColumns.find((c) => !c.isDone) || activeColumns[0];
|
||||
const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo');
|
||||
onUpdateStatus(task, nextKanban);
|
||||
}}
|
||||
className={`w-5 h-5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border disabled:cursor-default ${
|
||||
task.isCompleted
|
||||
? 'bg-status-ok border-status-ok text-on-accent'
|
||||
: 'border-strong hover:border-accent text-transparent'
|
||||
}`}
|
||||
title={task.isCompleted ? 'Als offen markieren' : 'Als erledigt markieren'}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5 stroke-[3]" />
|
||||
</button>
|
||||
<span className="text-xs font-semibold text-main truncate">
|
||||
{readOnly ? 'Aufgabe ansehen' : task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
|
||||
</span>
|
||||
{readOnly && (
|
||||
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
|
||||
<Lock className="w-2.5 h-2.5 text-faint" />
|
||||
<span>Nur Lesen</span>
|
||||
</span>
|
||||
)}
|
||||
{task.source?.toLowerCase() === 'webhook' && (
|
||||
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
|
||||
<Webhook className="w-2.5 h-2.5 text-accent" />
|
||||
<span>Webhook</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{(task.convertedType === 'document_block' || task.docId) ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
const docId = task.docId || (task.convertedRefId ? task.convertedRefId.split(':')[0] : '');
|
||||
const blockId = task.blockId || (task.convertedRefId ? task.convertedRefId.split(':')[1] : '');
|
||||
if (docId && onOpenDocument) onOpenDocument(docId, blockId);
|
||||
}}
|
||||
title="Zur verlinkten Notiz springen"
|
||||
className="btn-primary min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 font-semibold"
|
||||
>
|
||||
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||
<span>In Notiz öffnen</span>
|
||||
</button>
|
||||
) : !readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onConvertToDoc(task);
|
||||
}}
|
||||
title="Als Dokument / Notiz vertiefen"
|
||||
className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5"
|
||||
>
|
||||
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
|
||||
<span className="hidden sm:inline">Als Notiz vertiefen</span>
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm('Möchten Sie diese Aufgabe wirklich löschen?')) {
|
||||
onClose();
|
||||
onDeleteTask(task.id);
|
||||
}
|
||||
}}
|
||||
title="Aufgabe löschen"
|
||||
className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs text-status-err border-status-err/30 hover:bg-status-err/10 flex items-center gap-1"
|
||||
>
|
||||
<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
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted hover:text-main p-2 sm:p-1.5 min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 flex items-center justify-center transition-colors"
|
||||
title="Schließen"
|
||||
>
|
||||
<X className="w-5 h-5 sm:w-4 sm:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Body (all controls disabled for read-only shares) */}
|
||||
<fieldset disabled={readOnly} className="p-4 overflow-y-auto space-y-4 flex-1 min-w-0 border-0 m-0">
|
||||
{/* Task Title */}
|
||||
<div>
|
||||
{isEditingTitle ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleTitleSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsEditingTitle(false);
|
||||
setTitleDraft(task.content);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
rows={2}
|
||||
className="w-full text-base font-semibold input-field p-2.5 resize-none leading-snug"
|
||||
/>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEditingTitle(false);
|
||||
setTitleDraft(task.content);
|
||||
}}
|
||||
className="btn-action h-7 px-2.5 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTitleSubmit}
|
||||
className="btn-primary h-7 px-3 text-xs"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => !readOnly && setIsEditingTitle(true)}
|
||||
className={`group p-2 rounded-card transition-colors border border-transparent ${
|
||||
readOnly ? '' : 'cursor-pointer hover:bg-panel hover:border-border'
|
||||
}`}
|
||||
title={readOnly ? undefined : 'Klicken zum Bearbeiten des Titels'}
|
||||
>
|
||||
<h2
|
||||
className={`text-base font-semibold break-words leading-snug ${
|
||||
task.isCompleted ? 'line-through text-faint' : 'text-main'
|
||||
}`}
|
||||
>
|
||||
{task.content}
|
||||
</h2>
|
||||
{!readOnly && (
|
||||
<span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Klicken zum Umbenennen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kanban Status Buttons */}
|
||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main">
|
||||
Zustand / Kanban-Status:
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{activeColumns.map((col) => {
|
||||
const isActive = (task.kanbanStatus === col.id) || (!task.kanbanStatus && col.id === 'todo');
|
||||
return (
|
||||
<button
|
||||
key={col.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
onUpdateStatus(task, col.id);
|
||||
}}
|
||||
className={`h-8 px-3 text-xs rounded-btn font-medium transition-all flex items-center gap-1.5 border ${
|
||||
isActive
|
||||
? 'bg-panel border-accent text-accent font-semibold shadow-sm'
|
||||
: 'bg-canvas border-border text-muted hover:text-main hover:border-strong'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
col.isDone
|
||||
? 'bg-status-ok'
|
||||
: col.id === 'in_progress'
|
||||
? 'bg-accent'
|
||||
: col.id === 'waiting'
|
||||
? 'bg-status-warn'
|
||||
: 'bg-faint'
|
||||
}`}
|
||||
/>
|
||||
<span>{col.name}</span>
|
||||
{isActive && <Check className="w-3 h-3 text-accent stroke-[3] ml-0.5" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority Buttons (4 Buttons) */}
|
||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main">
|
||||
Priorität:
|
||||
</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
onUpdatePriority(task, 'low');
|
||||
}}
|
||||
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
||||
task.priority === 'low'
|
||||
? 'bg-canvas border-strong text-main font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<span>Niedrig</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
onUpdatePriority(task, 'normal');
|
||||
}}
|
||||
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
||||
!task.priority || task.priority === 'normal'
|
||||
? 'bg-accent/15 border-accent text-accent font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<span>Normal</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
onUpdatePriority(task, 'high');
|
||||
}}
|
||||
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
||||
task.priority === 'high'
|
||||
? 'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 text-status-warn" />
|
||||
<span>Wichtig</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
onUpdatePriority(task, 'urgent');
|
||||
}}
|
||||
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
||||
task.priority === 'urgent'
|
||||
? 'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<Flame className="w-3 h-3 text-status-err" />
|
||||
<span>Dringend</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Due Date & List Selection Row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1 border-t border-border">
|
||||
{/* Due Date */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Fälligkeitsdatum:</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="date"
|
||||
value={task.dueDate || ''}
|
||||
onChange={(e) => onUpdateDueDate(task, e.target.value)}
|
||||
className="flex-1 h-8 input-field px-2 text-xs bg-panel"
|
||||
/>
|
||||
{task.dueDate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateDueDate(task, '')}
|
||||
className="btn-action h-8 px-2 text-xs text-muted hover:text-status-err"
|
||||
title="Datum entfernen"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 text-[10px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuickDueDate(0)}
|
||||
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main"
|
||||
>
|
||||
Heute
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuickDueDate(1)}
|
||||
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main"
|
||||
>
|
||||
Morgen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuickDueDate(7)}
|
||||
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main"
|
||||
>
|
||||
+1 Woche
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task List assignment */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||
<Folder className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Aufgabenliste:</span>
|
||||
</label>
|
||||
<select
|
||||
value={task.taskListId || ''}
|
||||
onChange={(e) => onUpdateTaskList(task, e.target.value)}
|
||||
className="w-full h-8 input-field px-2.5 text-xs bg-panel"
|
||||
>
|
||||
<option value="">-- Keine Liste (Allgemein) --</option>
|
||||
{taskLists.map((tl) => (
|
||||
<option key={tl.id} value={tl.id}>
|
||||
{tl.icon} {tl.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hashtags Section */}
|
||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||
<Hash className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Hashtags:</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{Array.isArray(task.tags) &&
|
||||
task.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main"
|
||||
>
|
||||
<span>#{tag}</span>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="text-muted hover:text-status-err"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<form onSubmit={handleAddTagSubmit} className="inline-flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={newTagDraft}
|
||||
onChange={(e) => setNewTagDraft(e.target.value)}
|
||||
placeholder="+ #hashtag"
|
||||
className="h-6 w-24 px-1.5 text-xs input-field"
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtasks Checklist */}
|
||||
<div className="space-y-2 pt-1 border-t border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1.5">
|
||||
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Unteraufgaben ({completedSubtasks}/{subtasks.length}):</span>
|
||||
</label>
|
||||
{subtasks.length > 0 && (
|
||||
<span className="text-[11px] font-mono text-muted">{subtasksPercent}%</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subtasks.length > 0 && (
|
||||
<div className="w-full h-1 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
subtasksPercent === 100 ? 'bg-status-ok' : 'bg-accent'
|
||||
}`}
|
||||
style={{ width: `${subtasksPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtask items list */}
|
||||
{subtasks.length > 0 && (
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto pr-1">
|
||||
{subtasks.map((st) => (
|
||||
<div
|
||||
key={st.id}
|
||||
className="p-1.5 bg-canvas border border-border rounded-card flex items-center justify-between text-xs group"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleSubtask(task, st.id)}
|
||||
className={`w-3.5 h-3.5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${
|
||||
st.isCompleted
|
||||
? 'bg-status-ok border-status-ok text-on-accent'
|
||||
: 'border-strong hover:border-accent text-transparent'
|
||||
}`}
|
||||
>
|
||||
<Check className="w-2.5 h-2.5 stroke-[3]" />
|
||||
</button>
|
||||
<span
|
||||
className={`truncate ${
|
||||
st.isCompleted ? 'line-through text-faint' : 'text-main'
|
||||
}`}
|
||||
>
|
||||
{st.title}
|
||||
</span>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteSubtask(task, st.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-faint hover:text-status-err p-0.5 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline add subtask form */}
|
||||
{!readOnly && (
|
||||
<form onSubmit={handleAddSubtaskSubmit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newSubtaskTitle}
|
||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
||||
placeholder="Neue Unteraufgabe hinzufügen…"
|
||||
className="flex-1 h-7 px-2 text-xs input-field"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newSubtaskTitle.trim()}
|
||||
className="btn-action h-7 px-2.5 text-xs disabled:opacity-40"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
<span>Hinzufügen</span>
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes Section */}
|
||||
<div className="space-y-2 pt-1 border-t border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1.5">
|
||||
<FileText className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Detailnotizen:</span>
|
||||
</label>
|
||||
{!readOnly && !isEditingNotes && task.notes && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotesDraft(task.notes || '');
|
||||
setIsEditingNotes(true);
|
||||
}}
|
||||
className="text-[11px] text-accent hover:underline"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditingNotes ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={notesDraft}
|
||||
onChange={(e) => setNotesDraft(e.target.value)}
|
||||
placeholder="Detailnotizen eingeben… (Markdown unterstützt)"
|
||||
rows={6}
|
||||
autoFocus
|
||||
className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-y min-h-[100px]"
|
||||
/>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEditingNotes(false);
|
||||
setNotesDraft(task.notes || '');
|
||||
}}
|
||||
className="btn-action h-7 px-2.5 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNotesSubmit}
|
||||
className="btn-primary h-7 px-3 text-xs"
|
||||
>
|
||||
Notiz speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : task.notes ? (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (readOnly) return;
|
||||
setNotesDraft(task.notes || '');
|
||||
setIsEditingNotes(true);
|
||||
}}
|
||||
className={`p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed transition-colors ${
|
||||
readOnly ? '' : 'cursor-pointer hover:border-strong'
|
||||
}`}
|
||||
title={readOnly ? undefined : 'Klicken zum Bearbeiten'}
|
||||
>
|
||||
<MarkdownViewer content={task.notes} />
|
||||
</div>
|
||||
) : readOnly ? (
|
||||
<p className="text-xs text-faint">Keine Detailnotizen.</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotesDraft('');
|
||||
setIsEditingNotes(true);
|
||||
}}
|
||||
className="w-full py-2.5 px-3 border border-dashed border-border rounded-card text-xs text-muted hover:text-main hover:bg-panel transition-colors text-left flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Detailnotiz hinzufügen…</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
||||
<div className="text-[11px] text-faint">
|
||||
Erstellt: {new Date(task.createdAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-action h-7 px-3 text-xs"
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { motion, useMotionValue, useTransform, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
SkipForward,
|
||||
Edit3,
|
||||
Layers,
|
||||
MoreHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { SharedList, Thought } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
@@ -29,11 +30,194 @@ interface TriageDeckProps {
|
||||
onFocusCapture: () => void;
|
||||
}
|
||||
|
||||
interface SwipeableQueueItemProps {
|
||||
item: Thought;
|
||||
lists: SharedList[];
|
||||
onBringToFront: (id: string) => void;
|
||||
onOpenActionSheet: (item: Thought) => void;
|
||||
onAction: (
|
||||
action: 'keep' | 'discard',
|
||||
options?: {
|
||||
convertTo?: 'task' | 'document' | 'list_item';
|
||||
targetListId?: string;
|
||||
dueDate?: string;
|
||||
thoughtOverride?: Thought;
|
||||
}
|
||||
) => void;
|
||||
}
|
||||
|
||||
const SwipeableQueueItem: React.FC<SwipeableQueueItemProps> = ({
|
||||
item,
|
||||
lists,
|
||||
onBringToFront,
|
||||
onOpenActionSheet,
|
||||
onAction,
|
||||
}) => {
|
||||
const qx = useMotionValue(0);
|
||||
const rightBgOpacity = useTransform(qx, [20, 85], [0, 1]);
|
||||
const leftBgOpacity = useTransform(qx, [-85, -20], [1, 0]);
|
||||
const didDragRef = useRef(false);
|
||||
const holdTimerRef = useRef<number | null>(null);
|
||||
const startPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const clearHoldTimer = () => {
|
||||
if (holdTimerRef.current !== null) {
|
||||
window.clearTimeout(holdTimerRef.current);
|
||||
holdTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden select-none">
|
||||
{/* Swipe Right Background Indicator (Als Aufgabe) */}
|
||||
<motion.div
|
||||
style={{ opacity: rightBgOpacity }}
|
||||
className="pointer-events-none absolute inset-0 bg-accent/20 flex items-center justify-start px-4 text-accent font-semibold text-xs uppercase tracking-wide"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
<span>Als Aufgabe 👉</span>
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Swipe Left Background Indicator (Wegwerfen) */}
|
||||
<motion.div
|
||||
style={{ opacity: leftBgOpacity }}
|
||||
className="pointer-events-none absolute inset-0 bg-status-err-bg flex items-center justify-end px-4 text-status-err font-semibold text-xs uppercase tracking-wide"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>👈 Wegwerfen</span>
|
||||
<X className="w-4 h-4" />
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
style={{ x: qx }}
|
||||
drag="x"
|
||||
dragDirectionLock
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.65}
|
||||
onPointerDown={(e) => {
|
||||
didDragRef.current = false;
|
||||
startPosRef.current = { x: e.clientX, y: e.clientY };
|
||||
clearHoldTimer();
|
||||
holdTimerRef.current = window.setTimeout(() => {
|
||||
if (!didDragRef.current) {
|
||||
didDragRef.current = true;
|
||||
triggerHaptic([20, 40]);
|
||||
onOpenActionSheet(item);
|
||||
}
|
||||
}, 420);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (startPosRef.current) {
|
||||
const dx = Math.abs(e.clientX - startPosRef.current.x);
|
||||
const dy = Math.abs(e.clientY - startPosRef.current.y);
|
||||
if (dx > 10 || dy > 10) {
|
||||
clearHoldTimer();
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerUp={clearHoldTimer}
|
||||
onPointerCancel={clearHoldTimer}
|
||||
onDragStart={() => {
|
||||
didDragRef.current = true;
|
||||
clearHoldTimer();
|
||||
}}
|
||||
onDragEnd={(_, info) => {
|
||||
clearHoldTimer();
|
||||
if (info.offset.x > 85 || info.velocity.x > 450) {
|
||||
onAction('keep', { convertTo: 'task', thoughtOverride: item });
|
||||
} else if (info.offset.x < -85 || info.velocity.x < -450) {
|
||||
onAction('discard', { thoughtOverride: item });
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!didDragRef.current) {
|
||||
onBringToFront(item.id);
|
||||
}
|
||||
}}
|
||||
className="relative bg-panel px-4 py-3.5 sm:px-3.5 sm:py-2.5 min-h-[56px] sm:min-h-[44px] flex items-center justify-between gap-3 hover:bg-panel-hover transition-colors group cursor-pointer"
|
||||
>
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-accent flex-shrink-0" />
|
||||
<span className="text-sm sm:text-xs font-medium text-main line-clamp-2 sm:truncate">
|
||||
{item.content}
|
||||
</span>
|
||||
{item.source?.toLowerCase() === 'webhook' && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-canvas text-muted border border-border rounded-btn flex items-center gap-1 flex-shrink-0">
|
||||
<Webhook className="w-2.5 h-2.5 text-accent" /> Webhook
|
||||
</span>
|
||||
)}
|
||||
{item.source?.toLowerCase() === 'voice' && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-canvas text-accent border border-border rounded-btn flex items-center gap-1 flex-shrink-0">
|
||||
<Mic className="w-2.5 h-2.5" /> Diktat
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */}
|
||||
<div
|
||||
className="hidden sm:flex items-center gap-1.5 flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction('keep', { convertTo: 'task', thoughtOverride: item })}
|
||||
title="Direkt zur Aufgabe machen"
|
||||
className="h-7 px-2.5 bg-canvas hover:bg-accent hover:text-on-accent border border-border rounded-btn text-xs text-muted flex items-center gap-1 transition-colors cursor-pointer"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
<span>Aufgabe</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction('keep', { convertTo: 'document', thoughtOverride: item })}
|
||||
title="Direkt als Workspace-Notiz anlegen"
|
||||
className="h-7 px-2.5 bg-canvas hover:bg-panel-elevated border border-border rounded-btn text-xs text-muted hover:text-main flex items-center gap-1 transition-colors cursor-pointer"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Notiz</span>
|
||||
</button>
|
||||
|
||||
{lists.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onAction('keep', {
|
||||
convertTo: 'list_item',
|
||||
targetListId: lists[0].id,
|
||||
thoughtOverride: item,
|
||||
})
|
||||
}
|
||||
title={`Direkt auf Liste "${lists[0].title}" setzen`}
|
||||
className="h-7 px-2.5 bg-canvas hover:bg-panel-elevated border border-border rounded-btn text-xs text-muted hover:text-main flex items-center gap-1 transition-colors cursor-pointer"
|
||||
>
|
||||
<ShoppingCart className="w-3.5 h-3.5 text-accent" />
|
||||
<span className="hidden md:inline">{lists[0].title}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAction('discard', { thoughtOverride: item })}
|
||||
title="Wegwerfen"
|
||||
className="h-7 w-7 bg-canvas hover:bg-panel-elevated border border-border rounded-btn text-muted hover:text-main flex items-center justify-center transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
deck,
|
||||
lists,
|
||||
onDeckChange,
|
||||
onOpenDocument,
|
||||
onFocusCapture,
|
||||
}) => {
|
||||
const [cards, setCards] = useState<Thought[]>(deck);
|
||||
@@ -45,6 +229,12 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
});
|
||||
const [showListSelector, setShowListSelector] = useState(false);
|
||||
|
||||
// Long-Press Action Sheet state (for Triage Card or Queue Item)
|
||||
const [actionSheetCard, setActionSheetCard] = useState<Thought | null>(null);
|
||||
const [sheetMode, setSheetMode] = useState<'main' | 'date' | 'list'>('main');
|
||||
const cardHoldTimerRef = useRef<number | null>(null);
|
||||
const cardPointerStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Inline edit state for cleaning up a hasty Quick Dump note before converting
|
||||
const [isEditingCard, setIsEditingCard] = useState(false);
|
||||
const [editDraft, setEditDraft] = useState('');
|
||||
@@ -61,6 +251,18 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
setEditDraft(activeCard?.content || '');
|
||||
}, [activeCard?.id]);
|
||||
|
||||
const clearCardHoldTimer = () => {
|
||||
if (cardHoldTimerRef.current !== null) {
|
||||
window.clearTimeout(cardHoldTimerRef.current);
|
||||
cardHoldTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openActionSheetForCard = (card: Thought) => {
|
||||
setSheetMode('main');
|
||||
setActionSheetCard(card);
|
||||
};
|
||||
|
||||
const x = useMotionValue(0);
|
||||
const rotate = useTransform(x, [-220, 0, 220], [-8, 0, 8]);
|
||||
const keepOpacity = useTransform(x, [25, 110], [0, 1]);
|
||||
@@ -90,32 +292,39 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
setLastTriaged(targetCard);
|
||||
setShowDateModal(false);
|
||||
setShowListSelector(false);
|
||||
setActionSheetCard(null);
|
||||
setIsEditingCard(false);
|
||||
x.set(0);
|
||||
|
||||
try {
|
||||
const updated = await api.triageAction(targetCard.id, action, {
|
||||
await api.triageAction(targetCard.id, action, {
|
||||
convertTo: options?.convertTo,
|
||||
targetListId: options?.targetListId,
|
||||
dueDate: options?.dueDate,
|
||||
});
|
||||
onDeckChange();
|
||||
if (options?.convertTo === 'document' && updated.convertedRefId) {
|
||||
onOpenDocument(updated.convertedRefId);
|
||||
}
|
||||
} catch {
|
||||
onDeckChange();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkipCard = () => {
|
||||
const handleSkipCard = (targetId?: string) => {
|
||||
if (cards.length <= 1) return;
|
||||
triggerHaptic(10);
|
||||
setShowDateModal(false);
|
||||
setShowListSelector(false);
|
||||
setActionSheetCard(null);
|
||||
setIsEditingCard(false);
|
||||
x.set(0);
|
||||
setCards((prev) => [...prev.slice(1), prev[0]]);
|
||||
if (targetId && targetId !== cards[0]?.id) {
|
||||
setCards((prev) => {
|
||||
const chosen = prev.find((c) => c.id === targetId);
|
||||
if (!chosen) return prev;
|
||||
return [...prev.filter((c) => c.id !== targetId), chosen];
|
||||
});
|
||||
} else {
|
||||
setCards((prev) => [...prev.slice(1), prev[0]]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBringToFront = (thoughtId: string) => {
|
||||
@@ -182,12 +391,14 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
} else if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSkipCard();
|
||||
} else if (e.key === 'Escape' && actionSheetCard) {
|
||||
setActionSheetCard(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [activeCard, cards.length]);
|
||||
}, [activeCard, cards.length, actionSheetCard]);
|
||||
|
||||
const priorityBadge = (priority: string) => {
|
||||
switch (priority) {
|
||||
@@ -212,15 +423,16 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const sourceBadge = (source: string) => {
|
||||
if (source === 'voice') {
|
||||
const sourceBadge = (source?: string) => {
|
||||
const s = (source || '').toLowerCase().trim();
|
||||
if (s === 'voice') {
|
||||
return (
|
||||
<span className="px-2 py-0.5 text-[11px] bg-canvas text-accent border border-border rounded-btn flex items-center gap-1">
|
||||
<Mic className="w-3 h-3" /> Diktat
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (source === 'webhook') {
|
||||
if (s === 'webhook') {
|
||||
return (
|
||||
<span className="px-2 py-0.5 text-[11px] bg-canvas text-muted border border-border rounded-btn flex items-center gap-1">
|
||||
<Webhook className="w-3 h-3 text-accent" /> Webhook
|
||||
@@ -233,12 +445,13 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
if (!activeCard) {
|
||||
return (
|
||||
<div className="max-w-xl mx-auto mt-2 p-6 panel-surface text-center">
|
||||
<div className="w-11 h-11 bg-accent text-on-accent rounded-btn flex items-center justify-center mx-auto mb-3 shadow-sm">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
<div className="w-12 h-12 bg-accent text-on-accent rounded-xl flex items-center justify-center mx-auto mb-3 shadow-sm">
|
||||
<Sparkles className="w-6 h-6" />
|
||||
</div>
|
||||
<h2 className="text-lg font-bold text-main mb-1">Triage-Deck ist leer</h2>
|
||||
<p className="text-muted text-xs sm:text-sm max-w-md mx-auto mb-4">
|
||||
Alle Quick Dump Notizen sind einsortiert oder verworfen. Tippe oben in die Leiste (Taste{' '}
|
||||
Alle Quick Dump Notizen sind einsortiert oder verworfen. Tippe auf das{' '}
|
||||
<strong className="text-main">+</strong> unten in der Mitte (oder Taste{' '}
|
||||
<kbd className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-main">C</kbd>
|
||||
), um neue Gedanken reinzurotzen.
|
||||
</p>
|
||||
@@ -246,7 +459,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={onFocusCapture}
|
||||
className="btn-primary h-8 px-4"
|
||||
className="btn-primary h-10 px-5 text-sm"
|
||||
>
|
||||
+ Quick Dump erfassen
|
||||
</button>
|
||||
@@ -254,9 +467,9 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
{lastTriaged && (
|
||||
<button
|
||||
onClick={handleUndo}
|
||||
className="btn-action h-8 px-3"
|
||||
className="btn-action h-10 px-4 text-sm"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 text-accent" />
|
||||
<RotateCcw className="w-4 h-4 text-accent" />
|
||||
Letzte Aktion rückgängig
|
||||
</button>
|
||||
)}
|
||||
@@ -270,7 +483,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
{/* Top Deck Status & Undo Bar */}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="badge-accent">
|
||||
<span className="badge-accent text-xs py-1 px-2.5">
|
||||
{cards.length} {cards.length === 1 ? 'Quick Dump' : 'Quick Dumps'} offen
|
||||
</span>
|
||||
<span className="hidden sm:inline text-xs text-faint">•</span>
|
||||
@@ -282,29 +495,32 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
{lastTriaged && (
|
||||
<button
|
||||
onClick={handleUndo}
|
||||
className="btn-action h-7 px-2.5 text-xs"
|
||||
className="btn-action h-8 px-3 text-xs"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 text-accent" />
|
||||
<RotateCcw className="w-3.5 h-3.5 text-accent" />
|
||||
Rückgängig
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card Stack Container */}
|
||||
<div className="relative h-[280px] sm:h-[300px] w-full flex items-center justify-center">
|
||||
<div className="relative h-[290px] sm:h-[300px] w-full flex items-center justify-center">
|
||||
{/* Background Preview Card */}
|
||||
{nextCard && (
|
||||
<div
|
||||
key={nextCard.id + '-preview'}
|
||||
className="absolute inset-x-3 top-3 bottom-0 panel-surface p-4 opacity-40 scale-[0.97] pointer-events-none flex flex-col justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-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>
|
||||
<div className="text-[11px] text-faint">Nächster Quick Dump…</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Draggable Card */}
|
||||
{/* Active Draggable & Long-Pressable Card */}
|
||||
<AnimatePresence mode="popLayout">
|
||||
<motion.div
|
||||
key={activeCard.id}
|
||||
@@ -313,7 +529,29 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
dragConstraints={{ left: 0, right: 0 }}
|
||||
dragElastic={0.8}
|
||||
transition={{ type: 'spring', stiffness: 350, damping: 25 }}
|
||||
onPointerDown={(e) => {
|
||||
if (isEditingCard) return;
|
||||
cardPointerStartRef.current = { x: e.clientX, y: e.clientY };
|
||||
clearCardHoldTimer();
|
||||
cardHoldTimerRef.current = window.setTimeout(() => {
|
||||
triggerHaptic([20, 40]);
|
||||
openActionSheetForCard(activeCard);
|
||||
}, 420);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (cardPointerStartRef.current) {
|
||||
const dx = Math.abs(e.clientX - cardPointerStartRef.current.x);
|
||||
const dy = Math.abs(e.clientY - cardPointerStartRef.current.y);
|
||||
if (dx > 12 || dy > 12) {
|
||||
clearCardHoldTimer();
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPointerUp={clearCardHoldTimer}
|
||||
onPointerCancel={clearCardHoldTimer}
|
||||
onDragStart={clearCardHoldTimer}
|
||||
onDragEnd={(_, info) => {
|
||||
clearCardHoldTimer();
|
||||
if (info.offset.x > 105 || info.velocity.x > 520) {
|
||||
handleAction('keep', { convertTo: 'task' });
|
||||
} else if (info.offset.x < -105 || info.velocity.x < -520) {
|
||||
@@ -323,19 +561,19 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
initial={{ scale: 0.97, opacity: 0, y: 10 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.93, opacity: 0 }}
|
||||
className="relative w-full h-full panel-surface p-4 sm:p-5 cursor-grab active:cursor-grabbing flex flex-col justify-between overflow-hidden"
|
||||
className="relative w-full h-full panel-surface p-5 cursor-grab active:cursor-grabbing flex flex-col justify-between overflow-hidden"
|
||||
>
|
||||
{/* Dynamic Swipe Overlays */}
|
||||
<motion.div
|
||||
style={{ opacity: keepOpacity }}
|
||||
className="pointer-events-none absolute top-3.5 right-3.5 px-2.5 py-1 bg-accent-dim border border-accent rounded-btn text-accent font-semibold text-xs tracking-wide uppercase"
|
||||
className="pointer-events-none absolute top-3.5 right-3.5 px-3 py-1.5 bg-accent-dim border border-accent rounded-btn text-accent font-semibold text-xs tracking-wide uppercase"
|
||||
>
|
||||
Als Aufgabe 👉
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
style={{ opacity: discardOpacity }}
|
||||
className="pointer-events-none absolute top-3.5 left-3.5 px-2.5 py-1 bg-panel-elevated border border-strong rounded-btn text-main font-semibold text-xs tracking-wide uppercase"
|
||||
className="pointer-events-none absolute top-3.5 left-3.5 px-3 py-1.5 bg-panel-elevated border border-strong rounded-btn text-main font-semibold text-xs tracking-wide uppercase"
|
||||
>
|
||||
👈 Wegwerfen
|
||||
</motion.div>
|
||||
@@ -346,19 +584,10 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
{priorityBadge(activeCard.priority)}
|
||||
{sourceBadge(activeCard.source)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditDraft(activeCard.content);
|
||||
setIsEditingCard((prev) => !prev);
|
||||
}}
|
||||
title="Quick Dump Text vor dem Zuordnen anpassen"
|
||||
className="h-6 px-2 bg-canvas hover:bg-panel-hover border border-border rounded-btn text-[11px] text-muted hover:text-main flex items-center gap-1"
|
||||
>
|
||||
<Edit3 className="w-3 h-3" />
|
||||
<span>Bearbeiten</span>
|
||||
</button>
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span className="text-xs text-muted flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(activeCard.createdAt).toLocaleString('de-DE', {
|
||||
@@ -368,46 +597,59 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openActionSheetForCard(activeCard)}
|
||||
title="Weitere Optionen (oder Karte gedrückt halten)"
|
||||
className="h-8 w-8 bg-canvas hover:bg-panel-hover border border-border rounded-btn text-muted hover:text-main flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="my-auto py-2">
|
||||
<div
|
||||
className="my-auto py-2"
|
||||
onPointerDown={(e) => {
|
||||
if (isEditingCard) e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{isEditingCard ? (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2.5">
|
||||
<textarea
|
||||
value={editDraft}
|
||||
onChange={(e) => setEditDraft(e.target.value)}
|
||||
rows={3}
|
||||
autoFocus
|
||||
className="w-full input-field p-2 text-sm resize-none"
|
||||
className="w-full input-field p-2.5 text-sm resize-none"
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingCard(false)}
|
||||
className="btn-action h-7 px-2.5 text-xs"
|
||||
className="btn-action h-9 px-3.5 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveInlineEdit}
|
||||
className="btn-primary h-7 px-3 text-xs"
|
||||
className="btn-primary h-9 px-4 text-xs"
|
||||
>
|
||||
Text speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-base sm:text-xl font-medium text-main leading-relaxed break-words">
|
||||
<p className="text-lg sm:text-xl font-medium text-main leading-relaxed break-words whitespace-pre-wrap">
|
||||
{activeCard.content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Card Footer: Tags & Hints */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-2.5 border-t border-border">
|
||||
{/* Card Footer: Tags & Touch Gesture Hints */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pt-3 border-t border-border">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{activeCard.tags && activeCard.tags.length > 0 ? (
|
||||
activeCard.tags.map((tag) => (
|
||||
@@ -417,7 +659,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-faint">
|
||||
Links wischen = Wegwerfen • Rechts wischen = Als Aufgabe sichern
|
||||
👈 Wegwerfen · 👉 Aufgabe · Gedrückt halten = Mehr Optionen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -432,9 +674,9 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Convert to Task with Due Date Popover */}
|
||||
{/* Desktop-only Popovers */}
|
||||
{showDateModal && (
|
||||
<div className="p-3 panel-surface flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="hidden sm:flex p-3 panel-surface flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<Calendar className="w-4 h-4 text-accent" />
|
||||
<span>Aufgabe mit Fälligkeitsdatum anlegen:</span>
|
||||
@@ -448,7 +690,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleAction('keep', { convertTo: 'task', dueDate: taskDueDate })}
|
||||
className="btn-primary h-7 px-3 text-xs"
|
||||
className="btn-primary h-8 px-3 text-xs"
|
||||
>
|
||||
Als Termin-Aufgabe speichern
|
||||
</button>
|
||||
@@ -456,9 +698,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Convert to Shared List Item Popover */}
|
||||
{showListSelector && (
|
||||
<div className="p-3 panel-surface">
|
||||
<div className="hidden sm:block p-3 panel-surface">
|
||||
<p className="text-xs text-muted mb-2">
|
||||
Quick Dump direkt einer Liste zuordnen:
|
||||
</p>
|
||||
@@ -470,7 +711,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
onClick={() =>
|
||||
handleAction('keep', { convertTo: 'list_item', targetListId: lst.id })
|
||||
}
|
||||
className="btn-action h-7 px-2.5 text-xs"
|
||||
className="btn-action h-8 px-3 text-xs"
|
||||
>
|
||||
<span>{lst.icon}</span>
|
||||
<span>{lst.title}</span>
|
||||
@@ -483,168 +724,320 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Primary Triage Decision Bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-6 gap-1.5">
|
||||
{/* 1. Swipe Left: Discard */}
|
||||
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Swipe + Long-Press), shown on Desktop */}
|
||||
<div className="hidden sm:flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => handleAction('discard')}
|
||||
title="Wegwerfen (Taste H oder Pfeil links)"
|
||||
className="btn-action h-8 px-2.5 justify-center"
|
||||
className="btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap"
|
||||
>
|
||||
<X className="w-3.5 h-3.5 text-muted" />
|
||||
<X className="w-3.5 h-3.5 text-muted flex-shrink-0" />
|
||||
<span>Wegwerfen</span>
|
||||
</button>
|
||||
|
||||
{/* 2. Skip / Later */}
|
||||
<button
|
||||
onClick={handleSkipCard}
|
||||
onClick={() => handleSkipCard()}
|
||||
disabled={cards.length <= 1}
|
||||
title="Später entscheiden – ans Ende schieben (Leertaste)"
|
||||
className={`btn-action h-8 px-2.5 justify-center ${
|
||||
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
|
||||
cards.length <= 1 ? 'opacity-40 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
<SkipForward className="w-3.5 h-3.5 text-muted" />
|
||||
<SkipForward className="w-3.5 h-3.5 text-muted flex-shrink-0" />
|
||||
<span>Später</span>
|
||||
</button>
|
||||
|
||||
{/* 3. Assign to List */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDateModal(false);
|
||||
setShowListSelector((prev) => !prev);
|
||||
}}
|
||||
title="Auf eine geteilte Liste setzen"
|
||||
className={`btn-action h-8 px-2.5 justify-center ${
|
||||
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
|
||||
showListSelector ? 'border-accent text-accent bg-accent-dim' : ''
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="w-3.5 h-3.5 text-accent" />
|
||||
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>Auf Liste</span>
|
||||
</button>
|
||||
|
||||
{/* 4. Convert to Workspace Note */}
|
||||
<button
|
||||
onClick={() => handleAction('keep', { convertTo: 'document' })}
|
||||
title="In eine Workspace-Notiz umwandeln (Taste D)"
|
||||
className="btn-action h-8 px-2.5 justify-center"
|
||||
className="btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 text-accent" />
|
||||
<FileText className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>Als Notiz</span>
|
||||
</button>
|
||||
|
||||
{/* 5. Task with Date */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowListSelector(false);
|
||||
setShowDateModal((prev) => !prev);
|
||||
}}
|
||||
title="Als Aufgabe mit Fälligkeitsdatum (Taste T)"
|
||||
className={`btn-action h-8 px-2.5 justify-center ${
|
||||
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
|
||||
showDateModal ? 'border-accent text-accent bg-accent-dim' : ''
|
||||
}`}
|
||||
>
|
||||
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||||
<Calendar className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>Mit Termin</span>
|
||||
</button>
|
||||
|
||||
{/* 6. Swipe Right: Immediate Task */}
|
||||
<button
|
||||
onClick={() => handleAction('keep', { convertTo: 'task' })}
|
||||
title="Sofort als Aufgabe übernehmen (Taste L oder Pfeil rechts)"
|
||||
className="btn-primary h-8 px-3 justify-center"
|
||||
className="btn-primary flex-[1.25] h-9 px-4 justify-center whitespace-nowrap font-semibold"
|
||||
>
|
||||
<CheckSquare className="w-3.5 h-3.5" />
|
||||
<span>Als Aufgabe</span>
|
||||
<CheckSquare className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<span>Aufgabe</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Waiting Quick Dump Queue List */}
|
||||
{/* Waiting Quick Dump Queue List (Touch-sized & Swipeable Left/Right) */}
|
||||
{cards.length > 1 && (
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center justify-between mb-1.5 px-0.5">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted flex items-center gap-1.5">
|
||||
<Layers className="w-3.5 h-3.5 text-accent" />
|
||||
Warteschlange ({cards.length - 1} weitere Quick Dumps)
|
||||
Warteschlange ({cards.length - 1})
|
||||
</span>
|
||||
<span className="text-[11px] text-faint">
|
||||
Klicken zum Vorholen oder direkt zuordnen
|
||||
Tippen = Vorholen · 👈 Wegwerfen · 👉 Aufgabe
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="panel-surface divide-y divide-border max-h-60 overflow-y-auto">
|
||||
<div className="panel-surface divide-y divide-border max-h-80 overflow-y-auto overflow-x-hidden">
|
||||
{cards.slice(1).map((item) => (
|
||||
<div
|
||||
<SwipeableQueueItem
|
||||
key={item.id}
|
||||
className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-panel-hover transition-colors group"
|
||||
item={item}
|
||||
lists={lists}
|
||||
onBringToFront={handleBringToFront}
|
||||
onOpenActionSheet={openActionSheetForCard}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LONG-PRESS / MORE OPTIONS ACTION SHEET (For Triage Card & Queue Items) */}
|
||||
{actionSheetCard && (
|
||||
<div
|
||||
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-end sm:items-center justify-center bg-black/60 backdrop-blur-xs p-0 sm:p-4 animate-in fade-in duration-150"
|
||||
onClick={() => setActionSheetCard(null)}
|
||||
>
|
||||
<div
|
||||
className="w-full sm:max-w-md bg-panel border-t sm:border border-border rounded-t-2xl sm:rounded-2xl shadow-panel p-4 pb-[calc(1.5rem+env(safe-area-inset-bottom))] sm:pb-4 space-y-3 animate-in slide-in-from-bottom duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Sheet Header with Preview */}
|
||||
<div className="flex items-start justify-between gap-3 pb-2.5 border-b border-border">
|
||||
<div className="min-w-0">
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-accent">
|
||||
Quick Dump zuordnen
|
||||
</span>
|
||||
<p className="text-sm font-medium text-main line-clamp-2 mt-0.5">
|
||||
{actionSheetCard.content}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActionSheetCard(null)}
|
||||
className="p-1.5 text-faint hover:text-main rounded-btn cursor-pointer"
|
||||
>
|
||||
<div
|
||||
onClick={() => handleBringToFront(item.id)}
|
||||
className="flex-1 min-w-0 cursor-pointer flex items-center gap-2"
|
||||
>
|
||||
<span className="w-1.5 h-1.5 bg-accent flex-shrink-0" />
|
||||
<span className="text-xs text-muted truncate group-hover:text-main">
|
||||
{item.content}
|
||||
</span>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sheetMode === 'main' && (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSheetMode('list')}
|
||||
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98"
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-main">Auf Liste</div>
|
||||
<div className="text-[11px] text-muted">Einkauf / Geteilt</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSheetMode('date')}
|
||||
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98"
|
||||
>
|
||||
<Calendar className="w-5 h-5 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-main">Mit Termin</div>
|
||||
<div className="text-[11px] text-muted">Datum wählen</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleAction('keep', {
|
||||
convertTo: 'document',
|
||||
thoughtOverride: actionSheetCard,
|
||||
})
|
||||
}
|
||||
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98"
|
||||
>
|
||||
<FileText className="w-5 h-5 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-main">Als Notiz</div>
|
||||
<div className="text-[11px] text-muted">Im Workspace</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={cards.length <= 1}
|
||||
onClick={() => handleSkipCard(actionSheetCard.id)}
|
||||
className={`min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 ${
|
||||
cards.length <= 1 ? 'opacity-40 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
<SkipForward className="w-5 h-5 text-muted flex-shrink-0" />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-main">Später</div>
|
||||
<div className="text-[11px] text-muted">Ans Ende schieben</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Edit Text Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const target = actionSheetCard;
|
||||
setActionSheetCard(null);
|
||||
if (target.id !== activeCard.id) {
|
||||
handleBringToFront(target.id);
|
||||
}
|
||||
setEditDraft(target.content);
|
||||
setIsEditingCard(true);
|
||||
}}
|
||||
className="w-full min-h-[46px] px-3.5 py-2 rounded-xl bg-canvas hover:bg-panel-elevated border border-border flex items-center justify-center gap-2 text-sm font-medium text-main transition-colors cursor-pointer"
|
||||
>
|
||||
<Edit3 className="w-4 h-4 text-accent" />
|
||||
<span>Text vor dem Zuordnen bearbeiten</span>
|
||||
</button>
|
||||
|
||||
{/* Primary Swipe Equivalents */}
|
||||
<div className="grid grid-cols-2 gap-2 pt-1 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleAction('keep', { convertTo: 'task', thoughtOverride: item })
|
||||
handleAction('discard', { thoughtOverride: actionSheetCard })
|
||||
}
|
||||
title="Direkt zur Aufgabe machen"
|
||||
className="h-6 px-2 bg-canvas hover:bg-accent hover:text-on-accent border border-border rounded-btn text-[11px] text-muted flex items-center gap-1 transition-colors"
|
||||
className="min-h-[46px] px-3 py-2 rounded-xl bg-status-err-bg border border-status-err/30 text-status-err text-sm font-semibold flex items-center justify-center gap-2 cursor-pointer"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">Aufgabe</span>
|
||||
<X className="w-4 h-4" />
|
||||
<span>Wegwerfen</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleAction('keep', { convertTo: 'document', thoughtOverride: item })
|
||||
handleAction('keep', {
|
||||
convertTo: 'task',
|
||||
thoughtOverride: actionSheetCard,
|
||||
})
|
||||
}
|
||||
title="Direkt als Workspace-Notiz anlegen"
|
||||
className="h-6 px-2 bg-canvas hover:bg-panel-elevated border border-border rounded-btn text-[11px] text-muted hover:text-main flex items-center gap-1 transition-colors"
|
||||
className="min-h-[46px] px-4 py-2 rounded-xl bg-accent text-on-accent text-sm font-semibold flex items-center justify-center gap-2 whitespace-nowrap cursor-pointer"
|
||||
>
|
||||
<FileText className="w-3 h-3 text-accent" />
|
||||
<span className="hidden sm:inline">Notiz</span>
|
||||
</button>
|
||||
|
||||
{lists.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleAction('keep', {
|
||||
convertTo: 'list_item',
|
||||
targetListId: lists[0].id,
|
||||
thoughtOverride: item,
|
||||
})
|
||||
}
|
||||
title={`Direkt auf Liste "${lists[0].title}" setzen`}
|
||||
className="h-6 px-2 bg-canvas hover:bg-panel-elevated border border-border rounded-btn text-[11px] text-muted hover:text-main flex items-center gap-1 transition-colors"
|
||||
>
|
||||
<ShoppingCart className="w-3 h-3 text-accent" />
|
||||
<span className="hidden md:inline">{lists[0].title}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAction('discard', { thoughtOverride: item })}
|
||||
title="Wegwerfen"
|
||||
className="h-6 w-6 bg-canvas hover:bg-panel-elevated border border-border rounded-btn text-muted hover:text-main flex items-center justify-center transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
<CheckSquare className="w-4 h-4 flex-shrink-0" />
|
||||
<span>Aufgabe</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{sheetMode === 'date' && (
|
||||
<div className="space-y-3">
|
||||
<label className="block text-xs font-medium text-muted">
|
||||
Fälligkeitsdatum für die Aufgabe wählen:
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={taskDueDate}
|
||||
onChange={(e) => setTaskDueDate(e.target.value)}
|
||||
className="input-field w-full h-11 px-3 text-sm"
|
||||
/>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSheetMode('main')}
|
||||
className="btn-action flex-1 h-11 text-sm"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleAction('keep', {
|
||||
convertTo: 'task',
|
||||
dueDate: taskDueDate,
|
||||
thoughtOverride: actionSheetCard,
|
||||
})
|
||||
}
|
||||
className="btn-primary flex-1 h-11 text-sm font-semibold"
|
||||
>
|
||||
Mit Termin speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sheetMode === 'list' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-muted">
|
||||
Auf welche Liste soll dieser Punkt gesetzt werden?
|
||||
</p>
|
||||
<div className="max-h-60 overflow-y-auto space-y-1.5">
|
||||
{lists.length > 0 ? (
|
||||
lists.map((lst) => (
|
||||
<button
|
||||
key={lst.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleAction('keep', {
|
||||
convertTo: 'list_item',
|
||||
targetListId: lst.id,
|
||||
thoughtOverride: actionSheetCard,
|
||||
})
|
||||
}
|
||||
className="w-full min-h-[48px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border flex items-center gap-3 text-sm font-medium text-main transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-lg">{lst.icon}</span>
|
||||
<span>{lst.title}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-muted">
|
||||
Keine Listen vorhanden.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSheetMode('main')}
|
||||
className="btn-action w-full h-10 text-xs"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-bash';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-go';
|
||||
import 'prismjs/components/prism-sql';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import 'prismjs/components/prism-rust';
|
||||
import 'prismjs/components/prism-c';
|
||||
import 'prismjs/components/prism-cpp';
|
||||
import 'prismjs/components/prism-java';
|
||||
import 'prismjs/components/prism-php';
|
||||
import 'prismjs/components/prism-docker';
|
||||
import 'prismjs/components/prism-markdown';
|
||||
import { Code, Copy, Check, Eye, Edit3 } from 'lucide-react';
|
||||
import { DocumentBlock } from '../../lib/types';
|
||||
|
||||
export interface CodeBlockItemProps {
|
||||
block: DocumentBlock;
|
||||
isReadOnly: boolean;
|
||||
onChangeContent: (content: string) => void;
|
||||
onChangeLang: (lang: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
inputRef?: (el: HTMLTextAreaElement | null) => void;
|
||||
}
|
||||
|
||||
export const CODE_LANGUAGES = [
|
||||
{ id: 'javascript', label: 'JavaScript' },
|
||||
{ id: 'typescript', label: 'TypeScript' },
|
||||
{ id: 'python', label: 'Python' },
|
||||
{ id: 'go', label: 'Go' },
|
||||
{ id: 'bash', label: 'Bash / Shell' },
|
||||
{ id: 'json', label: 'JSON' },
|
||||
{ id: 'html', label: 'HTML / XML', alias: 'markup' },
|
||||
{ id: 'css', label: 'CSS' },
|
||||
{ id: 'sql', label: 'SQL' },
|
||||
{ id: 'rust', label: 'Rust' },
|
||||
{ id: 'cpp', label: 'C++' },
|
||||
{ id: 'c', label: 'C' },
|
||||
{ id: 'java', label: 'Java' },
|
||||
{ id: 'php', label: 'PHP' },
|
||||
{ id: 'yaml', label: 'YAML' },
|
||||
{ id: 'docker', label: 'Dockerfile' },
|
||||
{ id: 'markdown', label: 'Markdown' },
|
||||
{ id: 'text', label: 'Plain Text' },
|
||||
];
|
||||
|
||||
export const CodeBlockItem: React.FC<CodeBlockItemProps> = ({
|
||||
block,
|
||||
isReadOnly,
|
||||
onChangeContent,
|
||||
onChangeLang,
|
||||
onFocus,
|
||||
onBlur,
|
||||
inputRef,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const currentLang = block.lang || 'javascript';
|
||||
|
||||
// Highlighted HTML generated by Prism
|
||||
const highlightedCode = useMemo(() => {
|
||||
const code = block.content || '';
|
||||
if (!code) return '';
|
||||
|
||||
const prismLang = currentLang === 'html' ? 'markup' : currentLang;
|
||||
const grammar = Prism.languages[prismLang] || Prism.languages.javascript;
|
||||
if (!grammar) {
|
||||
return code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
try {
|
||||
return Prism.highlight(code, grammar, prismLang);
|
||||
} catch {
|
||||
return code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
}, [block.content, currentLang]);
|
||||
|
||||
// Code lines for line numbers
|
||||
const lines = useMemo(() => {
|
||||
return (block.content || '').split('\n');
|
||||
}, [block.content]);
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(block.content || '');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
// Tab key indent/outdent
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const textarea = e.currentTarget;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const val = textarea.value;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (val.substring(start - 2, start) === ' ') {
|
||||
const newVal = val.substring(0, start - 2) + val.substring(start);
|
||||
onChangeContent(newVal);
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = Math.max(0, start - 2);
|
||||
}, 0);
|
||||
}
|
||||
} else {
|
||||
const newVal = val.substring(0, start) + ' ' + val.substring(end);
|
||||
onChangeContent(newVal);
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const switchToEdit = () => {
|
||||
if (isReadOnly) return;
|
||||
setIsEditing(true);
|
||||
setTimeout(() => {
|
||||
localTextareaRef.current?.focus();
|
||||
}, 20);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-panel overflow-hidden shadow-xs my-1 group/code">
|
||||
{/* Code Block Header Toolbar */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-canvas border-b border-border text-xs">
|
||||
{/* Left: Language Selection */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<select
|
||||
value={currentLang}
|
||||
disabled={isReadOnly}
|
||||
onChange={(e) => onChangeLang(e.target.value)}
|
||||
className="bg-transparent text-main font-mono text-[11px] font-medium border border-transparent hover:border-border focus:border-accent rounded px-1.5 py-0.5 outline-none cursor-pointer"
|
||||
>
|
||||
{CODE_LANGUAGES.map((lang) => (
|
||||
<option key={lang.id} value={lang.id} className="bg-panel text-main">
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Right: Mode Toggle & Copy */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isEditing) {
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
switchToEdit();
|
||||
}
|
||||
}}
|
||||
className="px-2 py-0.5 rounded text-[11px] font-medium text-muted hover:text-main hover:bg-panel-elevated transition-colors flex items-center gap-1 cursor-pointer"
|
||||
title={isEditing ? 'Zur farbigen Vorschau wechseln (Esc)' : 'Code bearbeiten'}
|
||||
>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Eye className="w-3 h-3 text-accent" />
|
||||
<span>Vorschau</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Edit3 className="w-3 h-3 text-muted" />
|
||||
<span>Bearbeiten</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors flex items-center gap-1 cursor-pointer ${
|
||||
copied
|
||||
? 'text-emerald-500 bg-emerald-500/10'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
title="Code in Zwischenablage kopieren"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-500" />
|
||||
<span>Kopiert!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 opacity-70" />
|
||||
<span>Kopieren</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Block Content */}
|
||||
{isEditing && !isReadOnly ? (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
localTextareaRef.current = el;
|
||||
if (inputRef) inputRef(el);
|
||||
}}
|
||||
rows={Math.max(3, lines.length)}
|
||||
value={block.content || ''}
|
||||
onFocus={() => onFocus && onFocus()}
|
||||
onBlur={() => {
|
||||
if (onBlur) onBlur();
|
||||
}}
|
||||
onChange={(e) => onChangeContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="// Code hier eingeben..."
|
||||
className="w-full p-3 bg-panel-elevated/40 font-mono text-xs text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint"
|
||||
/>
|
||||
<div className="px-3 py-1 bg-canvas/40 border-t border-border/50 text-[10px] text-faint flex items-center justify-between">
|
||||
<span>Tab: Einrücken (2 Leerzeichen) • Shift+Tab: Ausrücken</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(false)}
|
||||
className="hover:text-accent cursor-pointer"
|
||||
>
|
||||
Vorschau anzeigen (Esc)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={switchToEdit}
|
||||
className={`relative p-3 font-mono text-xs overflow-x-auto ${
|
||||
isReadOnly ? '' : 'cursor-pointer hover:bg-panel-elevated/20 transition-colors'
|
||||
}`}
|
||||
title={isReadOnly ? undefined : 'Klicken zum Bearbeiten'}
|
||||
>
|
||||
{block.content ? (
|
||||
<div className="flex gap-3">
|
||||
{/* Line numbers */}
|
||||
<div className="select-none text-right text-faint opacity-50 font-mono text-xs leading-relaxed flex-shrink-0">
|
||||
{lines.map((_, idx) => (
|
||||
<div key={idx}>{idx + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Syntax highlighted code */}
|
||||
<pre className="m-0 p-0 font-mono text-xs leading-relaxed whitespace-pre overflow-x-auto flex-1">
|
||||
<code
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
className={`language-${currentLang}`}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-faint italic py-2">
|
||||
{isReadOnly ? '(Kein Code hinterlegt)' : '(Klicken, um Code einzugeben...)'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
+131
-7
@@ -78,9 +78,16 @@ body {
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
/* Quick-Capture-Blase & Profil-Pill im Header */
|
||||
.capture-pill {
|
||||
border-radius: var(--radius-pill);
|
||||
/* Quick-Capture-Blase im Header (immer vollständig abgerundet) */
|
||||
.capture-pill,
|
||||
.capture-pill.bg-panel.border {
|
||||
border-radius: 9999px !important;
|
||||
}
|
||||
|
||||
/* Quick-Capture-Blase bei mehrzeiliger Eingabe (Shift+Enter) */
|
||||
.capture-bar-multiline,
|
||||
.capture-bar-multiline.bg-panel.border {
|
||||
border-radius: 18px !important;
|
||||
}
|
||||
|
||||
/* Aufgaben-Checkboxen */
|
||||
@@ -171,8 +178,54 @@ body {
|
||||
border-color: var(--border-danger);
|
||||
}
|
||||
|
||||
/* Zentrales Texteingabefeld */
|
||||
.input-field {
|
||||
/* Sekundärer Button */
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
height: 1.75rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
background-color: var(--bg-panel-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
color: var(--text-main);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
transition: all 120ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--bg-panel-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Gefahren-/Löschen-Button */
|
||||
.btn-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
height: 1.75rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
background-color: var(--bg-danger);
|
||||
border: 1px solid var(--border-danger);
|
||||
border-radius: var(--radius-btn);
|
||||
color: var(--color-danger);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
transition: all 120ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
/* Zentrales Texteingabefeld & Aliasse */
|
||||
.input-field,
|
||||
.input-base {
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-input);
|
||||
@@ -180,11 +233,13 @@ body {
|
||||
font-size: 0.75rem;
|
||||
transition: border-color 120ms ease;
|
||||
}
|
||||
.input-field:focus {
|
||||
.input-field:focus,
|
||||
.input-base:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
.input-field::placeholder {
|
||||
.input-field::placeholder,
|
||||
.input-base::placeholder {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
@@ -293,14 +348,83 @@ body {
|
||||
|
||||
/* Modal-Hintergrund-Overlay */
|
||||
.modal-overlay {
|
||||
margin: 0 !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
background-color: var(--bg-overlay);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Verhindert, dass Elterncontainer mit space-y-* oder space-x-* feste Overlays nach unten verschieben */
|
||||
.fixed.inset-0,
|
||||
[class*="fixed inset-0"] {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* File Tree Item */
|
||||
.tree-node-active {
|
||||
background-color: var(--bg-panel-elevated);
|
||||
border-left: 2px solid var(--accent-primary);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* Prism Syntax Highlighting Tokens */
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #71717a;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.boolean,
|
||||
.token.number,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword {
|
||||
color: #818cf8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.token.function,
|
||||
.token.class-name {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important,
|
||||
.token.variable {
|
||||
color: #f97316;
|
||||
}
|
||||
}
|
||||
+802
-10
@@ -4,17 +4,29 @@ import {
|
||||
DashboardStats,
|
||||
Document,
|
||||
DocumentBlock,
|
||||
DocShare,
|
||||
KanbanColumn,
|
||||
ListItem,
|
||||
ListShare,
|
||||
Organization,
|
||||
PriorityLevel,
|
||||
SharedList,
|
||||
SharedFolder,
|
||||
FolderShare,
|
||||
Space,
|
||||
SpaceMember,
|
||||
SpaceShare,
|
||||
SystemSettings,
|
||||
Team,
|
||||
TaskList,
|
||||
TaskListShare,
|
||||
Thought,
|
||||
SubTask,
|
||||
TagDetails,
|
||||
TagSummary,
|
||||
User,
|
||||
UserInvite,
|
||||
WSMessage,
|
||||
} from './types';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
||||
@@ -24,6 +36,19 @@ export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function getCurrentUserId(): string | null {
|
||||
const token = getToken();
|
||||
if (!token) return null;
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length >= 2) {
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
return payload.userId || payload.sub || payload.id || null;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||
}
|
||||
@@ -104,6 +129,18 @@ export const api = {
|
||||
return request<AuthStatus>('/api/v1/auth/status');
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await request<{ success: boolean }>('/api/v1/auth/logout', { method: 'POST' });
|
||||
} finally {
|
||||
clearToken();
|
||||
}
|
||||
},
|
||||
|
||||
async revokeAllSessions(): Promise<void> {
|
||||
await request<{ success: boolean }>('/api/v1/auth/revoke-all', { method: 'POST' });
|
||||
},
|
||||
|
||||
async completeSetup(payload: {
|
||||
email: string;
|
||||
password: string;
|
||||
@@ -121,10 +158,10 @@ export const api = {
|
||||
return res;
|
||||
},
|
||||
|
||||
async login(email: string, password: string): Promise<{ token: string; user: User }> {
|
||||
async login(loginOrEmail: string, password: string): Promise<{ token: string; user: User }> {
|
||||
const res = await request<{ token: string; user: User }>('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password }),
|
||||
body: JSON.stringify({ email: loginOrEmail, username: loginOrEmail, login: loginOrEmail, password }),
|
||||
});
|
||||
setToken(res.token);
|
||||
return res;
|
||||
@@ -152,6 +189,7 @@ export const api = {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
avatarUrl?: string;
|
||||
alarmChannels?: string[];
|
||||
alarmEmail?: string;
|
||||
discordWebhookUrl?: string;
|
||||
@@ -271,6 +309,10 @@ export const api = {
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async getThought(thoughtId: string): Promise<Thought> {
|
||||
return request<Thought>(`/api/v1/thoughts/${encodeURIComponent(thoughtId)}`);
|
||||
},
|
||||
|
||||
async triageAction(
|
||||
thoughtId: string,
|
||||
action: 'keep' | 'discard' | 'restore',
|
||||
@@ -299,16 +341,26 @@ export const api = {
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> {
|
||||
const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder';
|
||||
return request<{ success: boolean }>(url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ items }),
|
||||
});
|
||||
},
|
||||
|
||||
async updateThought(
|
||||
thoughtId: string,
|
||||
patch: {
|
||||
content?: string;
|
||||
isCompleted?: boolean;
|
||||
dueDate?: string;
|
||||
priority?: PriorityLevel;
|
||||
priority?: string;
|
||||
taskListId?: string;
|
||||
kanbanStatus?: 'todo' | 'in_progress' | 'done';
|
||||
kanbanStatus?: string;
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
tags?: string[];
|
||||
}
|
||||
): Promise<Thought> {
|
||||
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
||||
@@ -328,14 +380,21 @@ export const api = {
|
||||
// --- TASK LISTS ---
|
||||
async getTaskLists(): Promise<TaskList[]> {
|
||||
const res = await request<TaskList[]>('/api/v1/task-lists');
|
||||
return ensureArray(res);
|
||||
return ensureArray(res).map((tl) => ({
|
||||
...tl,
|
||||
members: ensureArray(tl.members),
|
||||
shares: ensureArray(tl.shares),
|
||||
columns: ensureArray(tl.columns),
|
||||
}));
|
||||
},
|
||||
|
||||
async createTaskList(payload: {
|
||||
title: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
teamId?: string;
|
||||
spaceId?: string;
|
||||
columns?: KanbanColumn[];
|
||||
}): Promise<TaskList> {
|
||||
return request<TaskList>('/api/v1/task-lists', {
|
||||
method: 'POST',
|
||||
@@ -349,7 +408,9 @@ export const api = {
|
||||
title?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
teamId?: string;
|
||||
spaceId?: string;
|
||||
columns?: KanbanColumn[];
|
||||
}
|
||||
): Promise<TaskList> {
|
||||
return request<TaskList>(`/api/v1/task-lists/${id}`, {
|
||||
@@ -362,6 +423,134 @@ export const api = {
|
||||
await request(`/api/v1/task-lists/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async getTaskListShares(id: string): Promise<TaskListShare[]> {
|
||||
const res = await request<TaskListShare[]>(`/api/v1/task-lists/${id}/shares`);
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async addTaskListShare(
|
||||
id: string,
|
||||
payload: { targetType: 'team' | 'user'; targetId: string; permission?: 'editor' | 'viewer' }
|
||||
): Promise<TaskListShare> {
|
||||
return request<TaskListShare>(`/api/v1/task-lists/${id}/shares`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async removeTaskListShare(id: string, shareId: string): Promise<void> {
|
||||
await request(`/api/v1/task-lists/${id}/shares/${shareId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- WORKSPACE FOLDERS ---
|
||||
async getFolders(): Promise<string[]> {
|
||||
const res = await request<string[]>('/api/v1/folders');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async createFolder(path: string): Promise<{ path: string }> {
|
||||
return request<{ path: string }>('/api/v1/folders', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteFolder(path: string): Promise<{ deleted: boolean; deletedDocIds: string[] }> {
|
||||
return request<{ deleted: boolean; deletedDocIds: string[] }>(
|
||||
`/api/v1/folders?path=${encodeURIComponent(path)}`,
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
},
|
||||
|
||||
async moveFolder(sourcePath: string, targetParentPath: string): Promise<{ newPath: string; movedDocIds: string[] }> {
|
||||
return request<{ newPath: string; movedDocIds: string[] }>('/api/v1/folders/move', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sourcePath, targetParentPath }),
|
||||
});
|
||||
},
|
||||
|
||||
// --- WORKSPACE SHARED FOLDERS ---
|
||||
async getSharedFolders(): Promise<SharedFolder[]> {
|
||||
const res = await request<SharedFolder[]>('/api/v1/workspace/folders/shared');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async createSharedFolder(payload: { name: string; mountPath?: string }): Promise<SharedFolder> {
|
||||
return request<SharedFolder>('/api/v1/workspace/folders/shared', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async getSharedFolder(id: string): Promise<SharedFolder> {
|
||||
return request<SharedFolder>(`/api/v1/workspace/folders/shared/${id}`);
|
||||
},
|
||||
|
||||
async deleteSharedFolder(id: string): Promise<void> {
|
||||
await request(`/api/v1/workspace/folders/shared/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async updateFolderMount(id: string, mountPath: string): Promise<void> {
|
||||
await request(`/api/v1/workspace/folders/shared/${id}/mount`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ mountPath }),
|
||||
});
|
||||
},
|
||||
|
||||
async getFolderShares(folderId: string): Promise<FolderShare[]> {
|
||||
const res = await request<FolderShare[]>(`/api/v1/workspace/folders/shared/${folderId}/shares`);
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async createFolderShare(
|
||||
folderId: string,
|
||||
payload: { targetType?: 'team' | 'user'; targetId?: string; userId?: string; email?: string; teamId?: string; permission: 'read' | 'write' }
|
||||
): Promise<FolderShare> {
|
||||
return request<FolderShare>(`/api/v1/workspace/folders/shared/${folderId}/shares`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteFolderShare(folderId: string, shareId: string): Promise<void> {
|
||||
await request(`/api/v1/workspace/folders/shared/${folderId}/shares/${shareId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- WORKSPACE TAGS ---
|
||||
async getWorkspaceTags(): Promise<TagSummary[]> {
|
||||
const res = await request<TagSummary[]>('/api/v1/workspace/tags');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async getTagDetails(tag: string): Promise<TagDetails> {
|
||||
const clean = encodeURIComponent(tag.replace(/^#/, ''));
|
||||
const res = await request<TagDetails>(`/api/v1/workspace/tags/${clean}`);
|
||||
return {
|
||||
...res,
|
||||
documents: ensureArray(res.documents).map((doc) => ({
|
||||
...doc,
|
||||
matchedBlocks: ensureArray(doc.matchedBlocks).map((mb) => ({
|
||||
rootBlock: mb.rootBlock,
|
||||
children: ensureArray(mb.children),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
async updateTagPageNote(
|
||||
tag: string,
|
||||
content: string
|
||||
): Promise<{ tag: string; content: string; updatedAt: string }> {
|
||||
const clean = encodeURIComponent(tag.replace(/^#/, ''));
|
||||
return request<{ tag: string; content: string; updatedAt: string }>(
|
||||
`/api/v1/workspace/tags/${clean}/note`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ content }),
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
// --- WORKSPACE DOCUMENTS ---
|
||||
async getDocuments(): Promise<Document[]> {
|
||||
const res = await request<Document[]>('/api/v1/documents');
|
||||
@@ -422,19 +611,105 @@ export const api = {
|
||||
};
|
||||
},
|
||||
|
||||
async patchDocumentBlocks(
|
||||
id: string,
|
||||
payload: {
|
||||
upserted?: DocumentBlock[];
|
||||
deletedIds?: string[];
|
||||
blockOrder?: string[];
|
||||
}
|
||||
): Promise<Document> {
|
||||
const d = await request<Document>(`/api/v1/documents/${id}/blocks`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return {
|
||||
...d,
|
||||
blocks: ensureArray(d.blocks),
|
||||
tags: ensureArray(d.tags),
|
||||
};
|
||||
},
|
||||
|
||||
async joinSSERoom(sseId: string, room: string, action: 'join' | 'leave' = 'join'): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>('/api/v1/events/rooms', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sseId, room, action }),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteDocument(id: string): Promise<void> {
|
||||
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async moveDocument(id: string, folderPath: string): Promise<Document> {
|
||||
const d = await request<Document>(`/api/v1/documents/${id}/move`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ folderPath }),
|
||||
});
|
||||
return {
|
||||
...d,
|
||||
blocks: ensureArray(d.blocks),
|
||||
tags: ensureArray(d.tags),
|
||||
};
|
||||
},
|
||||
|
||||
async createEnhancedTask(
|
||||
docId: string,
|
||||
blockId: string,
|
||||
payload: { content: string; checked: boolean }
|
||||
): Promise<Thought> {
|
||||
return request<Thought>(`/api/v1/documents/${docId}/blocks/${blockId}/enhanced-task`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async unlinkEnhancedTask(taskId: string): Promise<{ unlinked: boolean }> {
|
||||
return request<{ unlinked: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/unlink`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
// --- DOCUMENT SHARING ---
|
||||
async getDocShares(docId: string): Promise<DocShare[]> {
|
||||
const res = await request<DocShare[]>(`/api/v1/documents/${docId}/shares`);
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async createDocShare(
|
||||
docId: string,
|
||||
payload: { userId?: string; email?: string; teamId?: string; permission: 'read' | 'write' }
|
||||
): Promise<DocShare> {
|
||||
return request<DocShare>(`/api/v1/documents/${docId}/shares`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteDocShare(docId: string, userId: string): Promise<void> {
|
||||
await request(`/api/v1/documents/${docId}/shares/${userId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async getCollaborators(): Promise<User[]> {
|
||||
const res = await request<User[]>('/api/v1/collaborators');
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
// --- SPACES & REALTIME LISTS ---
|
||||
async getSpaces(): Promise<Space[]> {
|
||||
const res = await request<Space[]>('/api/v1/spaces');
|
||||
return ensureArray(res).map((s) => ({
|
||||
...s,
|
||||
members: ensureArray(s.members),
|
||||
shares: ensureArray(s.shares),
|
||||
}));
|
||||
},
|
||||
|
||||
async getSpaceShares(spaceId: string): Promise<SpaceShare[]> {
|
||||
const res = await request<SpaceShare[]>(`/api/v1/spaces/${spaceId}/shares`);
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async createSpace(name: string, icon: string, description: string): Promise<Space> {
|
||||
return request<Space>('/api/v1/spaces', {
|
||||
method: 'POST',
|
||||
@@ -449,6 +724,19 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; teamId?: string; role?: string }): Promise<any> {
|
||||
return request<any>(`/api/v1/spaces/${spaceId}/members`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(target),
|
||||
});
|
||||
},
|
||||
|
||||
async removeSpaceMember(spaceId: string, userId: string): Promise<void> {
|
||||
await request(`/api/v1/spaces/${spaceId}/members/${userId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
async getSpaceLists(spaceId: string): Promise<SharedList[]> {
|
||||
const res = await request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`);
|
||||
return ensureArray(res).map((l) => ({
|
||||
@@ -462,17 +750,36 @@ export const api = {
|
||||
return ensureArray(res).map((l) => ({
|
||||
...l,
|
||||
items: ensureArray(l.items),
|
||||
shares: ensureArray(l.shares),
|
||||
}));
|
||||
},
|
||||
|
||||
async createList(spaceId: string, title: string, icon: string, listType: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
|
||||
const l = await request<SharedList>(`/api/v1/spaces/${spaceId}/lists`, {
|
||||
async getListById(listId: string): Promise<SharedList> {
|
||||
const l = await request<SharedList>(`/api/v1/lists/${listId}`);
|
||||
return {
|
||||
...l,
|
||||
items: ensureArray(l.items),
|
||||
shares: ensureArray(l.shares),
|
||||
};
|
||||
},
|
||||
|
||||
async createList(titleOrSpaceId: string, titleOrIcon?: string, iconOrType?: string, listType?: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
|
||||
let title = titleOrSpaceId;
|
||||
let icon = titleOrIcon || '🛒';
|
||||
let type = (iconOrType as any) || 'shopping';
|
||||
if (listType !== undefined) {
|
||||
title = titleOrIcon || '';
|
||||
icon = iconOrType || '🛒';
|
||||
type = listType;
|
||||
}
|
||||
const l = await request<SharedList>('/api/v1/lists', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ title, icon, listType }),
|
||||
body: JSON.stringify({ title, icon, listType: type }),
|
||||
});
|
||||
return {
|
||||
...l,
|
||||
items: ensureArray(l.items),
|
||||
shares: ensureArray(l.shares),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -480,6 +787,22 @@ export const api = {
|
||||
await request(`/api/v1/lists/${listId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async getListShares(listId: string): Promise<ListShare[]> {
|
||||
const res = await request<ListShare[]>(`/api/v1/lists/${listId}/shares`);
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async addListShare(listId: string, target: { targetType: 'team' | 'user'; targetId: string; role?: string }): Promise<ListShare> {
|
||||
return request<ListShare>(`/api/v1/lists/${listId}/shares`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(target),
|
||||
});
|
||||
},
|
||||
|
||||
async removeListShare(listId: string, shareId: string): Promise<void> {
|
||||
await request(`/api/v1/lists/${listId}/shares/${shareId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async createListItem(listId: string, content: string, category = '', quantity = ''): Promise<ListItem> {
|
||||
return request<ListItem>(`/api/v1/lists/${listId}/items`, {
|
||||
method: 'POST',
|
||||
@@ -494,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> {
|
||||
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
||||
},
|
||||
@@ -504,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 ---
|
||||
async getOrganizations(): Promise<Organization[]> {
|
||||
const res = await request<Organization[]>('/api/v1/organizations');
|
||||
@@ -539,6 +883,20 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async createTeam(name: string, description = ''): Promise<Team> {
|
||||
return request<Team>('/api/v1/teams', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description }),
|
||||
});
|
||||
},
|
||||
|
||||
async updateTeam(teamId: string, name: string, description = ''): Promise<Team> {
|
||||
return request<Team>(`/api/v1/teams/${teamId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name, description }),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteTeam(teamId: string): Promise<void> {
|
||||
await request(`/api/v1/teams/${teamId}`, { method: 'DELETE' });
|
||||
},
|
||||
@@ -558,6 +916,17 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async updateTeamMemberRole(
|
||||
teamId: string,
|
||||
userId: string,
|
||||
role: 'admin' | 'member'
|
||||
): Promise<void> {
|
||||
await request(`/api/v1/teams/${teamId}/members/${userId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ role }),
|
||||
});
|
||||
},
|
||||
|
||||
async removeTeamMember(teamId: string, userId: string): Promise<void> {
|
||||
await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'DELETE' });
|
||||
},
|
||||
@@ -698,8 +1067,431 @@ export function getOfflineQueue(): Thought[] {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWebSocketURL(spaceId: string): string {
|
||||
export type RealtimeTransport = 'ws' | 'sse' | 'none';
|
||||
|
||||
let activeTransport: RealtimeTransport = 'none';
|
||||
let activeGlobalWS: WebSocket | null = null;
|
||||
let activeGlobalSSE: EventSource | null = null;
|
||||
let activeSSEId: string | null = null;
|
||||
let wsReconnectTimer: any = null;
|
||||
let wsConnectionWatchdog: any = null;
|
||||
let wsListeners: ((msg: WSMessage) => void)[] = [];
|
||||
let realtimeStatusListeners: ((connected: boolean, transport: RealtimeTransport) => void)[] = [];
|
||||
let isRealtimeConnectedState = false;
|
||||
let manualDisconnect = false;
|
||||
let wsReconnectDelay = 2000;
|
||||
|
||||
export function buildWebSocketURL(listId?: string): string {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = getToken() || '';
|
||||
return `${protocol}//${window.location.host}/api/v1/ws/spaces/${encodeURIComponent(spaceId)}?token=${encodeURIComponent(token)}`;
|
||||
if (listId) {
|
||||
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)}`;
|
||||
}
|
||||
|
||||
export function buildSSEURL(listId?: string, docId?: string, rooms?: string[]): string {
|
||||
const token = getToken() || '';
|
||||
const params = new URLSearchParams();
|
||||
if (token) params.set('token', token);
|
||||
if (listId) params.set('listId', listId);
|
||||
if (docId) params.set('docId', docId);
|
||||
if (rooms && rooms.length > 0) params.set('rooms', rooms.join(','));
|
||||
return `/api/v1/events?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function isWebSocketConnected(): boolean {
|
||||
return activeTransport === 'ws' && activeGlobalWS !== null && activeGlobalWS.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
export function isRealtimeConnected(): boolean {
|
||||
return isRealtimeConnectedState;
|
||||
}
|
||||
|
||||
export function getRealtimeTransport(): RealtimeTransport {
|
||||
return activeTransport;
|
||||
}
|
||||
|
||||
export function getSSEId(): string | null {
|
||||
return activeSSEId;
|
||||
}
|
||||
|
||||
export function onWebSocketStatus(callback: (connected: boolean) => void): () => void {
|
||||
const wrapper = (connected: boolean) => callback(connected);
|
||||
realtimeStatusListeners.push(wrapper);
|
||||
callback(isRealtimeConnected());
|
||||
return () => {
|
||||
realtimeStatusListeners = realtimeStatusListeners.filter((fn) => fn !== wrapper);
|
||||
};
|
||||
}
|
||||
|
||||
export function onRealtimeStatus(callback: (connected: boolean, transport: RealtimeTransport) => void): () => void {
|
||||
realtimeStatusListeners.push(callback);
|
||||
callback(isRealtimeConnected(), activeTransport);
|
||||
return () => {
|
||||
realtimeStatusListeners = realtimeStatusListeners.filter((fn) => fn !== callback);
|
||||
};
|
||||
}
|
||||
|
||||
let isWsBlockedByProxy = typeof window !== 'undefined' && sessionStorage.getItem('mindsift_ws_blocked') === 'true';
|
||||
let sseReconnectTimer: any = null;
|
||||
let sseReconnectDelay = 2000;
|
||||
const globalSubscribedRooms = new Set<string>();
|
||||
|
||||
function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTransport) {
|
||||
isRealtimeConnectedState = connected;
|
||||
activeTransport = transport;
|
||||
realtimeStatusListeners.forEach((fn) => {
|
||||
try {
|
||||
fn(connected, transport);
|
||||
} catch (e) {
|
||||
console.error('Realtime status listener error', e);
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('mindsift:ws:status', { detail: { connected, transport } }));
|
||||
}
|
||||
|
||||
function switchToSSE(reason: string) {
|
||||
console.warn(`[MindSift Realtime] ${reason} -> Schalte sofort und dauerhaft auf Server-Sent Events (SSE) um.`);
|
||||
isWsBlockedByProxy = true;
|
||||
try {
|
||||
sessionStorage.setItem('mindsift_ws_blocked', 'true');
|
||||
} catch {}
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
clearTimeout(wsReconnectTimer);
|
||||
if (activeGlobalWS) {
|
||||
try {
|
||||
activeGlobalWS.onclose = null;
|
||||
activeGlobalWS.onerror = null;
|
||||
activeGlobalWS.close();
|
||||
} catch {}
|
||||
activeGlobalWS = null;
|
||||
}
|
||||
connectSSE();
|
||||
}
|
||||
|
||||
export function connectSSE(rooms?: string[]): void {
|
||||
if (rooms) {
|
||||
rooms.forEach((r) => globalSubscribedRooms.add(r));
|
||||
}
|
||||
clearTimeout(sseReconnectTimer);
|
||||
clearTimeout(wsReconnectTimer);
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
|
||||
if (activeGlobalSSE) {
|
||||
try { activeGlobalSSE.close(); } catch {}
|
||||
activeGlobalSSE = null;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = buildSSEURL(undefined, undefined, Array.from(globalSubscribedRooms));
|
||||
const es = new EventSource(url);
|
||||
activeGlobalSSE = es;
|
||||
|
||||
es.onopen = () => {
|
||||
sseReconnectDelay = 2000;
|
||||
notifyRealtimeStatusChange(true, 'sse');
|
||||
if (activeSSEId && globalSubscribedRooms.size > 0) {
|
||||
globalSubscribedRooms.forEach((room) => {
|
||||
api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as WSMessage;
|
||||
if (msg.event === 'SSE_CONNECTED') {
|
||||
activeSSEId = msg.payload?.sseId || null;
|
||||
if (activeSSEId && globalSubscribedRooms.size > 0) {
|
||||
globalSubscribedRooms.forEach((room) => {
|
||||
api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
wsListeners.forEach((fn) => {
|
||||
try {
|
||||
fn(msg);
|
||||
} catch (e) {
|
||||
console.error('SSE listener error', e);
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('mindsift:ws', { detail: msg }));
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE message', err);
|
||||
}
|
||||
};
|
||||
|
||||
es.onerror = (e) => {
|
||||
console.warn('[MindSift Realtime] SSE Verbindungsfehler:', e);
|
||||
if (activeTransport === 'sse') {
|
||||
notifyRealtimeStatusChange(false, 'none');
|
||||
}
|
||||
try { es.close(); } catch {}
|
||||
activeGlobalSSE = null;
|
||||
if (!manualDisconnect && getToken()) {
|
||||
clearTimeout(sseReconnectTimer);
|
||||
const delay = sseReconnectDelay;
|
||||
sseReconnectDelay = Math.min(30000, Math.round(sseReconnectDelay * 1.5));
|
||||
sseReconnectTimer = setTimeout(() => {
|
||||
if (!manualDisconnect && getToken()) {
|
||||
connectSSE();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize EventSource SSE', err);
|
||||
notifyRealtimeStatusChange(false, 'none');
|
||||
}
|
||||
}
|
||||
|
||||
export function connectWebSocket(): void {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
manualDisconnect = false;
|
||||
|
||||
// If proxy blocked WebSocket previously, do not attempt failing WebSocket; stay on SSE
|
||||
if (isWsBlockedByProxy) {
|
||||
connectSSE();
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
clearTimeout(wsReconnectTimer);
|
||||
clearTimeout(sseReconnectTimer);
|
||||
|
||||
try {
|
||||
const url = buildWebSocketURL();
|
||||
const ws = new WebSocket(url);
|
||||
activeGlobalWS = ws;
|
||||
|
||||
// 2.5s watchdog for corporate proxies silently stripping Upgrade header or hanging
|
||||
wsConnectionWatchdog = setTimeout(() => {
|
||||
if (ws && ws.readyState !== WebSocket.OPEN) {
|
||||
switchToSSE('WebSocket Timeout nach 2.5s (Proxy Upgrade Header blockiert)');
|
||||
}
|
||||
}, 2500);
|
||||
|
||||
ws.onopen = () => {
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
if (activeGlobalSSE) {
|
||||
try { activeGlobalSSE.close(); } catch {}
|
||||
activeGlobalSSE = null;
|
||||
}
|
||||
wsReconnectDelay = 2000;
|
||||
notifyRealtimeStatusChange(true, 'ws');
|
||||
if (globalSubscribedRooms.size > 0) {
|
||||
globalSubscribedRooms.forEach((room) => {
|
||||
try {
|
||||
ws.send(JSON.stringify({ event: 'JOIN_ROOM', room }));
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as WSMessage;
|
||||
wsListeners.forEach((fn) => {
|
||||
try {
|
||||
fn(msg);
|
||||
} catch (e) {
|
||||
console.error('WS listener error', e);
|
||||
}
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('mindsift:ws', { detail: msg }));
|
||||
} catch (err) {
|
||||
console.error('Failed to parse WS message', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (evt: CloseEvent) => {
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
activeGlobalWS = null;
|
||||
|
||||
// In corporate environments, blocked WebSockets close abruptly without clean handshake
|
||||
if (!evt.wasClean || evt.code === 1006 || evt.code === 1002 || evt.code === 1008 || evt.code === 1005) {
|
||||
switchToSSE(`WebSocket unsauber beendet (Code ${evt.code}, wasClean=${evt.wasClean})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeTransport === 'ws') {
|
||||
notifyRealtimeStatusChange(false, 'none');
|
||||
}
|
||||
|
||||
if (!manualDisconnect && getToken() && activeTransport !== 'sse') {
|
||||
clearTimeout(wsReconnectTimer);
|
||||
const delay = wsReconnectDelay;
|
||||
wsReconnectDelay = Math.min(30000, Math.round(wsReconnectDelay * 1.5));
|
||||
wsReconnectTimer = setTimeout(() => {
|
||||
if (!manualDisconnect && getToken()) {
|
||||
connectWebSocket();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (e) => {
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
switchToSSE('WebSocket Fehler (Handshake/Verbindung verweigert)');
|
||||
};
|
||||
} catch (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 {
|
||||
manualDisconnect = true;
|
||||
clearTimeout(wsReconnectTimer);
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
clearTimeout(sseReconnectTimer);
|
||||
if (activeGlobalWS) {
|
||||
try {
|
||||
activeGlobalWS.close();
|
||||
} catch {}
|
||||
activeGlobalWS = null;
|
||||
}
|
||||
if (activeGlobalSSE) {
|
||||
try {
|
||||
activeGlobalSSE.close();
|
||||
} catch {}
|
||||
activeGlobalSSE = null;
|
||||
}
|
||||
notifyRealtimeStatusChange(false, 'none');
|
||||
}
|
||||
|
||||
export function sendWebSocketMessage(msg: any): void {
|
||||
if (activeGlobalWS && activeGlobalWS.readyState === WebSocket.OPEN) {
|
||||
activeGlobalWS.send(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
return;
|
||||
}
|
||||
|
||||
// SSE Fallback: dispatch via REST endpoints
|
||||
try {
|
||||
const parsed = typeof msg === 'string' ? JSON.parse(msg) : msg;
|
||||
if (parsed.event === 'JOIN_ROOM' && parsed.room) {
|
||||
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'join').catch(() => {});
|
||||
} else if (parsed.event === 'LEAVE_ROOM' && parsed.room) {
|
||||
if (activeSSEId) api.joinSSERoom(activeSSEId, parsed.room, 'leave').catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error dispatching fallback REST message for SSE:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export function onWebSocketMessage(callback: (msg: WSMessage) => void): () => void {
|
||||
wsListeners.push(callback);
|
||||
return () => {
|
||||
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>;
|
||||
}
|
||||
@@ -81,6 +81,11 @@ export function setActiveTheme(themeId: ThemeId): void {
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-theme', themeId);
|
||||
if (themeId !== 'light') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +93,11 @@ export function initThemeOnBoot(): ThemeId {
|
||||
const active = getActiveTheme();
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-theme', active);
|
||||
if (active !== 'light') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
+209
-10
@@ -1,17 +1,20 @@
|
||||
export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted';
|
||||
export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent';
|
||||
export type UserRole = 'admin' | 'team_admin' | 'member';
|
||||
export type UserRole = 'admin' | 'member';
|
||||
|
||||
export interface AuthStatus {
|
||||
needsSetup: boolean;
|
||||
allowRegistration: boolean;
|
||||
instanceName: string;
|
||||
demoMode?: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
role: UserRole;
|
||||
isActive: boolean;
|
||||
alarmChannels?: string[];
|
||||
@@ -34,6 +37,13 @@ export interface UserInvite {
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface SubTask {
|
||||
id: string;
|
||||
title: string;
|
||||
isCompleted: boolean;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface Thought {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -43,24 +53,62 @@ export interface Thought {
|
||||
source: 'manual' | 'voice' | 'webhook';
|
||||
status: ThoughtStatus;
|
||||
isCompleted: boolean;
|
||||
convertedType?: 'task' | 'document' | 'list_item';
|
||||
convertedType?: 'task' | 'document' | 'list_item' | 'document_block';
|
||||
convertedRefId?: string;
|
||||
docId?: string;
|
||||
blockId?: string;
|
||||
dueDate?: string;
|
||||
taskListId?: string;
|
||||
kanbanStatus?: 'todo' | 'in_progress' | 'done';
|
||||
kanbanStatus?: string;
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
alarmSent?: boolean;
|
||||
sortOrder?: number;
|
||||
createdAt: string;
|
||||
triagedAt?: string;
|
||||
}
|
||||
|
||||
export interface KanbanColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
isDone?: boolean;
|
||||
}
|
||||
|
||||
export interface TaskListShare {
|
||||
id: string;
|
||||
taskListId: string;
|
||||
targetType: 'team' | 'user';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
permission?: 'editor' | 'viewer';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TaskListMember {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
avatarUrl?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface TaskList {
|
||||
id: string;
|
||||
userId: string;
|
||||
spaceId?: string;
|
||||
spaceName?: string;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
ownerId?: string;
|
||||
ownerName?: string;
|
||||
members?: SpaceMember[];
|
||||
isOwner?: boolean;
|
||||
permission?: 'owner' | 'editor' | 'viewer';
|
||||
members?: TaskListMember[];
|
||||
shares?: TaskListShare[];
|
||||
columns?: KanbanColumn[];
|
||||
title: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
@@ -79,7 +127,7 @@ export interface Organization {
|
||||
|
||||
export interface Team {
|
||||
id: string;
|
||||
orgId: string;
|
||||
orgId?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
members?: TeamMember[];
|
||||
@@ -91,7 +139,8 @@ export interface TeamMember {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
role: 'owner' | 'team_admin' | 'editor' | 'member' | 'viewer';
|
||||
avatarUrl?: string;
|
||||
role: 'admin' | 'member';
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
@@ -108,16 +157,52 @@ export interface SystemSettings {
|
||||
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 {
|
||||
id: string;
|
||||
docId?: string;
|
||||
documentId?: string;
|
||||
taskId?: string;
|
||||
type: BlockType;
|
||||
content: string;
|
||||
checked?: boolean;
|
||||
lang?: string;
|
||||
level?: number;
|
||||
position?: number;
|
||||
collapsed?: boolean;
|
||||
sortOrder?: number;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface DocBlocksPatchRequest {
|
||||
upserted?: DocumentBlock[];
|
||||
deletedIds?: string[];
|
||||
blockOrder?: string[];
|
||||
}
|
||||
|
||||
export interface DocBlocksPatchEvent {
|
||||
docId: string;
|
||||
updatedBy: string;
|
||||
updatedAt: string;
|
||||
upserted?: DocumentBlock[];
|
||||
deletedIds?: string[];
|
||||
blockOrder?: string[];
|
||||
}
|
||||
|
||||
export interface DocShare {
|
||||
id: string;
|
||||
docId: string;
|
||||
targetType?: 'user' | 'team';
|
||||
targetId?: string;
|
||||
targetName?: string;
|
||||
teamId?: string;
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
displayName?: string;
|
||||
permission: 'read' | 'write';
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
@@ -126,6 +211,8 @@ export interface Document {
|
||||
spaceId?: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
folderId?: string;
|
||||
markdownCache?: string;
|
||||
content: string;
|
||||
blocks: DocumentBlock[];
|
||||
tags: string[];
|
||||
@@ -134,6 +221,72 @@ export interface Document {
|
||||
isArchived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
// Collaboration & Permissions
|
||||
permission?: 'owner' | 'write' | 'read';
|
||||
isOwner?: boolean;
|
||||
ownerName?: string;
|
||||
sharedWith?: DocShare[];
|
||||
sharedFolderId?: string;
|
||||
subFolderPath?: string;
|
||||
isSharedFolder?: boolean;
|
||||
folderPermission?: string;
|
||||
}
|
||||
|
||||
export interface SharedFolder {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
ownerName?: string;
|
||||
name: string;
|
||||
mountPath: string;
|
||||
isOwner: boolean;
|
||||
permission: 'owner' | 'write' | 'read';
|
||||
shares?: FolderShare[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FolderShare {
|
||||
id: string;
|
||||
folderId: string;
|
||||
targetType: 'user' | 'team';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
permission: 'read' | 'write';
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TagSummary {
|
||||
tag: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MatchedTagBlock {
|
||||
rootBlock: DocumentBlock;
|
||||
children: DocumentBlock[];
|
||||
}
|
||||
|
||||
export interface TagDocumentGroup {
|
||||
docId: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
folderPath: string;
|
||||
updatedAt: string;
|
||||
isOwner: boolean;
|
||||
permission: 'owner' | 'write' | 'read';
|
||||
ownerName?: string;
|
||||
matchedBlocks: MatchedTagBlock[];
|
||||
}
|
||||
|
||||
export interface TagDetails {
|
||||
tag: string;
|
||||
noteContent?: string;
|
||||
noteUpdatedAt?: string;
|
||||
documents: TagDocumentGroup[];
|
||||
totalMatches: number;
|
||||
}
|
||||
|
||||
export interface SpaceMember {
|
||||
@@ -141,10 +294,35 @@ export interface SpaceMember {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
avatarUrl?: string;
|
||||
role: 'owner' | 'editor' | 'viewer';
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export interface UnifiedShare {
|
||||
id: string;
|
||||
targetType: 'team' | 'user';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
targetEmail?: string;
|
||||
avatarUrl?: string;
|
||||
permission?: 'editor' | 'viewer';
|
||||
role?: string;
|
||||
createdBy?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SpaceShare {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
targetType: 'team' | 'user';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
role: 'editor' | 'viewer';
|
||||
createdBy?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Space {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -153,7 +331,9 @@ export interface Space {
|
||||
inviteCode: string;
|
||||
isPersonal: boolean;
|
||||
ownerId: string;
|
||||
ownerName?: string;
|
||||
members?: SpaceMember[];
|
||||
shares?: SpaceShare[];
|
||||
listsCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -172,13 +352,30 @@ export interface ListItem {
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface ListShare {
|
||||
id: string;
|
||||
listId: string;
|
||||
targetType: 'team' | 'user';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
targetEmail?: string;
|
||||
role: 'editor' | 'viewer';
|
||||
createdBy?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SharedList {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
userId?: string;
|
||||
ownerName?: string;
|
||||
isOwner?: boolean;
|
||||
role?: string;
|
||||
spaceId?: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
listType: 'shopping' | 'checklist' | 'vacation';
|
||||
items: ListItem[];
|
||||
shares?: ListShare[];
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -199,14 +396,16 @@ export interface DashboardStats {
|
||||
tasksOpenCount: number;
|
||||
tasksDoneCount: number;
|
||||
docsCount: number;
|
||||
docsTodosOpenCount?: number;
|
||||
listsCount: number;
|
||||
discardedCount: number;
|
||||
}
|
||||
|
||||
export interface WSMessage<T = any> {
|
||||
event: 'ITEM_CREATED' | 'ITEM_UPDATED' | 'ITEM_TOGGLED' | 'ITEM_DELETED' | 'LIST_CLEARED' | 'LIST_CREATED' | 'LIST_DELETED' | 'USER_PRESENCE';
|
||||
space_id: string;
|
||||
event: string;
|
||||
space_id?: string;
|
||||
list_id?: string;
|
||||
listId?: string;
|
||||
payload: T;
|
||||
timestamp: string;
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { MobileActionProvider } from './context/MobileActionContext';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<MobileActionProvider>
|
||||
<App />
|
||||
</MobileActionProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "============================================="
|
||||
echo "🧠 MindSift — Schnell-Installation"
|
||||
echo "============================================="
|
||||
|
||||
# 1. Prüfen ob Docker / Compose verfügbar ist
|
||||
COMPOSE_CMD=""
|
||||
if command -v docker &>/dev/null && docker compose version &>/dev/null; then
|
||||
COMPOSE_CMD="docker compose"
|
||||
elif command -v docker-compose &>/dev/null; then
|
||||
COMPOSE_CMD="docker-compose"
|
||||
elif command -v podman &>/dev/null && podman compose version &>/dev/null; then
|
||||
COMPOSE_CMD="podman compose"
|
||||
else
|
||||
echo "❌ Fehler: Docker Compose (oder Podman Compose) wurde nicht gefunden."
|
||||
echo "Bitte installiere Docker (https://docs.docker.com/get-docker/) und versuche es erneut."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INSTALL_DIR="${MINDSIFT_DIR:-mindsift}"
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
cd "${INSTALL_DIR}"
|
||||
|
||||
echo "📥 Lade Konfigurationsdateien herunter..."
|
||||
REPO_RAW_URL="https://git.dsit.me/sandrodach/MindSift/raw/branch/dev"
|
||||
|
||||
curl -fsSL "${REPO_RAW_URL}/docker-compose.yml" -o docker-compose.yml
|
||||
curl -fsSL "${REPO_RAW_URL}/.env.example" -o .env
|
||||
|
||||
# Zufälliges JWT-Secret generieren falls Standardwert vorhanden
|
||||
if grep -q "change_this_to_a_secure_random_key_min_32_chars" .env; then
|
||||
RANDOM_SECRET=$(head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 32 || openssl rand -hex 16 2>/dev/null || date +%s%N)
|
||||
sed -i "s/change_this_to_a_secure_random_key_min_32_chars/${RANDOM_SECRET}/g" .env
|
||||
fi
|
||||
|
||||
echo "🚀 Starte MindSift Container..."
|
||||
${COMPOSE_CMD} up -d
|
||||
|
||||
echo ""
|
||||
echo "============================================="
|
||||
echo "✅ MindSift wurde erfolgreich gestartet!"
|
||||
echo ""
|
||||
echo "🌐 Öffne deinen Browser unter:"
|
||||
echo " http://localhost:8080"
|
||||
echo ""
|
||||
echo "💡 Beim ersten Aufruf wirst du automatisch durch"
|
||||
echo " die Ersteinrichtung deines Admin-Kontos geführt."
|
||||
echo "============================================="
|
||||
+1217
-112
File diff suppressed because it is too large.
Load diff
+877
-8
@@ -2,6 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/config"
|
||||
@@ -43,7 +45,7 @@ func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
|
||||
hub := ws.NewHub()
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
|
||||
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
|
||||
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
@@ -117,7 +119,6 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
||||
"/api/v1/tasks",
|
||||
"/api/v1/organizations",
|
||||
"/api/v1/teams",
|
||||
"/api/v1/spaces",
|
||||
"/api/v1/lists",
|
||||
}
|
||||
for _, ep := range arrayEndpoints {
|
||||
@@ -145,7 +146,7 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
||||
}
|
||||
|
||||
// 5. Create an Invite as Admin and register with that Invite Code
|
||||
invBody := []byte(`{"role":"team_admin","validDays":7}`)
|
||||
invBody := []byte(`{"role":"admin","validDays":7}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/invites", bytes.NewReader(invBody))
|
||||
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
@@ -163,7 +164,7 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
||||
regWithInviteBody, _ := json.Marshal(map[string]string{
|
||||
"email": "invited@example.com",
|
||||
"password": "password123",
|
||||
"displayName": "Invited Team Admin",
|
||||
"displayName": "Invited Admin",
|
||||
"inviteCode": createdInvite.Code,
|
||||
})
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regWithInviteBody))
|
||||
@@ -177,8 +178,8 @@ func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
|
||||
User domain.User `json:"user"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &invitedRes)
|
||||
if invitedRes.User.Role != "team_admin" {
|
||||
t.Fatalf("expected invited user to receive role 'team_admin', got %q", invitedRes.User.Role)
|
||||
if invitedRes.User.Role != "admin" {
|
||||
t.Fatalf("expected invited user to receive role 'admin', got %q", invitedRes.User.Role)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +191,6 @@ func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/triage/deck",
|
||||
"/api/v1/lists",
|
||||
"/api/v1/spaces",
|
||||
"/api/v1/tasks",
|
||||
"/api/v1/documents",
|
||||
}
|
||||
@@ -219,7 +219,7 @@ func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
|
||||
router := srv.Router()
|
||||
|
||||
// Verify stale token from non-existent user is rejected (401)
|
||||
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old")
|
||||
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.Header.Set("Authorization", "Bearer "+staleToken)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -431,3 +431,872 @@ func TestNewFullFeatures(t *testing.T) {
|
||||
t.Fatalf("expected updated kanbanStatus and notes, got %+v", updatedThought)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginWithUsernameAndDemoMode(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "demo_api_test.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := store.EnsureDemoAccounts(); err != nil {
|
||||
t.Fatalf("failed to seed demo accounts: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Port: "8080",
|
||||
DatabasePath: dbPath,
|
||||
JWTSecret: "demo-jwt-secret",
|
||||
DemoMode: true,
|
||||
}
|
||||
srv := NewServer(cfg, store, ws.NewHub(), nil)
|
||||
router := srv.Router()
|
||||
|
||||
// 1. Check auth status reports demoMode: true
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from /api/v1/auth/status, got %d", rec.Code)
|
||||
}
|
||||
var statusResp struct {
|
||||
NeedsSetup bool `json:"needsSetup"`
|
||||
DemoMode bool `json:"demoMode"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &statusResp)
|
||||
if !statusResp.DemoMode {
|
||||
t.Fatalf("expected demoMode=true, got %v", statusResp.DemoMode)
|
||||
}
|
||||
if statusResp.NeedsSetup {
|
||||
t.Fatalf("expected needsSetup=false when demo accounts exist, got %v", statusResp.NeedsSetup)
|
||||
}
|
||||
|
||||
// 2. Login with username "admin" / "admin"
|
||||
loginBody := []byte(`{"email":"admin","password":"admin"}`)
|
||||
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("expected 200 OK for admin login, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var loginRes struct {
|
||||
Token string `json:"token"`
|
||||
User domain.User `json:"user"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &loginRes)
|
||||
if loginRes.Token == "" || loginRes.User.Role != "admin" {
|
||||
t.Fatalf("invalid admin login response: %+v", loginRes)
|
||||
}
|
||||
|
||||
// 3. Login with username "user" / "user"
|
||||
userLoginBody := []byte(`{"username":"user","password":"user"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(userLoginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for user login, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var userLoginRes struct {
|
||||
Token string `json:"token"`
|
||||
User domain.User `json:"user"`
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userLoginRes)
|
||||
if userLoginRes.Token == "" || userLoginRes.User.Role != "member" {
|
||||
t.Fatalf("invalid user login response: %+v", userLoginRes)
|
||||
}
|
||||
|
||||
// 4. Invalid credentials must return 401
|
||||
wrongLoginBody := []byte(`{"login":"admin","password":"wrongpassword"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(wrongLoginBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401 Unauthorized for wrong password, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpaceMembersDirectInvitationAPI(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "space_members_test.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := store.EnsureDemoAccounts(); err != nil {
|
||||
t.Fatalf("failed to seed demo accounts: %v", err)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Port: "8080",
|
||||
DatabasePath: dbPath,
|
||||
JWTSecret: "test-secret",
|
||||
}
|
||||
srv := NewServer(cfg, store, ws.NewHub(), nil)
|
||||
router := srv.Router()
|
||||
|
||||
adminUser, err := store.GetUserByLogin("admin")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get admin user: %v", err)
|
||||
}
|
||||
normalUser, err := store.GetUserByLogin("user")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get normal user: %v", err)
|
||||
}
|
||||
|
||||
adminToken, err := auth.GenerateToken(cfg.JWTSecret, adminUser.ID, adminUser.Email, adminUser.DisplayName, adminUser.TokenVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName, normalUser.TokenVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
// 1. Admin creates a list
|
||||
createListBody := []byte(`{"title":"Einkauf WG","icon":"🛒","listType":"shopping"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/lists", bytes.NewReader(createListBody))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created from /api/v1/lists, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var createdList domain.SharedList
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &createdList)
|
||||
if createdList.ID == "" {
|
||||
t.Fatalf("expected created list with ID, got %+v", createdList)
|
||||
}
|
||||
|
||||
// 2. Admin shares list with normalUser by username "user"
|
||||
addShareBody := []byte(`{"targetType":"user","targetId":"user","role":"editor"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addShareBody))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created from add list share, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var newShare domain.ListShare
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &newShare)
|
||||
if newShare.TargetID != normalUser.ID {
|
||||
t.Fatalf("expected share targetId %s, got %s", normalUser.ID, newShare.TargetID)
|
||||
}
|
||||
|
||||
// 3. User checks their lists; the shared list must be listed
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+userToken)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for user lists, got %d", rec.Code)
|
||||
}
|
||||
var userLists []domain.SharedList
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userLists)
|
||||
found := false
|
||||
for _, l := range userLists {
|
||||
if l.ID == createdList.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected list %s to be visible to user, got %+v", createdList.ID, userLists)
|
||||
}
|
||||
|
||||
// 4. Admin removes user share from list
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/lists/"+createdList.ID+"/shares/"+newShare.ID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for remove share, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 5. Test sharing list with a team
|
||||
team, err := store.CreateTeam("", "WG Team", "Haushalt")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create team: %v", err)
|
||||
}
|
||||
_ = store.AddTeamMember(team.ID, normalUser.ID, "member")
|
||||
|
||||
addTeamBody := []byte(`{"targetType":"team","targetId":"` + team.ID + `","role":"editor"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/lists/"+createdList.ID+"/shares", bytes.NewReader(addTeamBody))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 201 or 200 for add list team share, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// User should see the list again because their team was added
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/lists", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+userToken)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for user lists after team share add, got %d", rec.Code)
|
||||
}
|
||||
userLists = nil
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userLists)
|
||||
found = false
|
||||
for _, l := range userLists {
|
||||
if l.ID == createdList.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected list %s to be visible to user via team share, got %+v", createdList.ID, userLists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceTagsAPI(t *testing.T) {
|
||||
srv, store, _ := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
|
||||
user, _ := store.CreateUser("api_tag_user@example.com", "hash", "API Tag User", "member")
|
||||
token, _ := auth.GenerateToken(srv.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
|
||||
// Create document with hashtags
|
||||
blocks := []domain.DocumentBlock{
|
||||
{ID: "b1", Type: "bullet", Content: "Planung #Sprint12", Level: 0},
|
||||
{ID: "b2", Type: "bullet", Content: "Subtask 1", Level: 1},
|
||||
}
|
||||
_, err := store.CreateDocument(user.ID, "", "Sprint Plan", "🚀", "", blocks, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. GET /api/v1/workspace/tags
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/workspace/tags", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var tagList []domain.TagSummary
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &tagList)
|
||||
if len(tagList) != 1 || tagList[0].Tag != "sprint12" {
|
||||
t.Fatalf("expected tag 'sprint12', got %+v", tagList)
|
||||
}
|
||||
|
||||
// 2. GET /api/v1/workspace/tags/Sprint12
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/workspace/tags/Sprint12", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var details domain.TagDetails
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &details)
|
||||
if details.Tag != "sprint12" || details.TotalMatches != 1 || len(details.Documents) != 1 {
|
||||
t.Fatalf("unexpected tag details: %+v", details)
|
||||
}
|
||||
matched := details.Documents[0].MatchedBlocks[0]
|
||||
if matched.RootBlock.ID != "b1" || len(matched.Children) != 1 || matched.Children[0].ID != "b2" {
|
||||
t.Fatalf("unexpected matched blocks or children: %+v", matched)
|
||||
}
|
||||
|
||||
// 3. Folder endpoints test
|
||||
// Create folder
|
||||
createFolderBody := []byte(`{"path":"TestOrdner"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/folders", bytes.NewReader(createFolderBody))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
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/folders, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// Get folders
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/folders", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from /api/v1/folders, got %d", rec.Code)
|
||||
}
|
||||
var folders []string
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &folders)
|
||||
foundFolder := false
|
||||
for _, f := range folders {
|
||||
if f == "TestOrdner" {
|
||||
foundFolder = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundFolder {
|
||||
t.Fatalf("expected TestOrdner in folders list, got %v", folders)
|
||||
}
|
||||
|
||||
// Delete folder
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/folders?path=TestOrdner", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+20
-9
@@ -18,9 +18,10 @@ type contextKey string
|
||||
const UserIDContextKey contextKey = "mindsift_user_id"
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
TokenVersion int `json:"tokenVersion"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -34,14 +35,24 @@ func CheckPassword(password, hash string) bool {
|
||||
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{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
TokenVersion: tokenVersion,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(30 * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
ID: jti,
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(30 * 24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
Issuer: "mindsift",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -14,6 +15,8 @@ type Config struct {
|
||||
BaseURL string
|
||||
AllowRegistration bool
|
||||
LogLevel string
|
||||
Debug bool
|
||||
DemoMode bool
|
||||
|
||||
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
|
||||
SMTPHost string
|
||||
@@ -39,7 +42,17 @@ func Load() *Config {
|
||||
jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")
|
||||
baseURL := getEnv("BASE_URL", "http://localhost:"+port)
|
||||
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
|
||||
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"))))
|
||||
demoMode := demoVal == "true" || demoVal == "1" || demoVal == "yes" || demoVal == "on"
|
||||
|
||||
smtpHost := getEnv("SMTP_HOST", "")
|
||||
smtpPort := getEnv("SMTP_PORT", "587")
|
||||
@@ -56,6 +69,8 @@ func Load() *Config {
|
||||
BaseURL: baseURL,
|
||||
AllowRegistration: allowReg,
|
||||
LogLevel: logLevel,
|
||||
Debug: debug,
|
||||
DemoMode: demoMode,
|
||||
SMTPHost: smtpHost,
|
||||
SMTPPort: smtpPort,
|
||||
SMTPUser: smtpUser,
|
||||
@@ -65,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 {
|
||||
if val, ok := os.LookupEnv(key); ok && 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")
|
||||
}
|
||||
+314
-72
@@ -40,13 +40,68 @@ func ExtractTags(content string) []string {
|
||||
return tags
|
||||
}
|
||||
|
||||
// FormatGermanDateTime formats an ISO-8601 string into "25. Okt. 2026 - 15:40 Uhr"
|
||||
func FormatGermanDateTime(isoStr string) string {
|
||||
if isoStr == "" {
|
||||
return ""
|
||||
}
|
||||
var t time.Time
|
||||
var err error
|
||||
formats := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, f := range formats {
|
||||
if parsed, parseErr := time.Parse(f, isoStr); parseErr == nil {
|
||||
t = parsed
|
||||
err = nil
|
||||
break
|
||||
} else {
|
||||
err = parseErr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return isoStr
|
||||
}
|
||||
|
||||
t = t.Local()
|
||||
|
||||
germanMonths := map[time.Month]string{
|
||||
time.January: "Jan.",
|
||||
time.February: "Feb.",
|
||||
time.March: "März",
|
||||
time.April: "Apr.",
|
||||
time.May: "Mai",
|
||||
time.June: "Juni",
|
||||
time.July: "Juli",
|
||||
time.August: "Aug.",
|
||||
time.September: "Sept.",
|
||||
time.October: "Okt.",
|
||||
time.November: "Nov.",
|
||||
time.December: "Dez.",
|
||||
}
|
||||
mStr := germanMonths[t.Month()]
|
||||
if mStr == "" {
|
||||
mStr = t.Format("Jan")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d. %s %d - %02d:%02d Uhr", t.Day(), mStr, t.Year(), t.Hour(), t.Minute())
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username,omitempty"`
|
||||
PasswordHash string `json:"-"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"` // admin | team_admin | member
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Role string `json:"role"` // admin | member
|
||||
IsActive bool `json:"isActive"`
|
||||
TokenVersion int `json:"tokenVersion"`
|
||||
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
|
||||
AlarmEmail string `json:"alarmEmail"`
|
||||
DiscordWebhookURL string `json:"discordWebhookUrl"`
|
||||
@@ -59,7 +114,7 @@ type UserInvite struct {
|
||||
ID string `json:"id"`
|
||||
Code string `json:"code"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role string `json:"role"` // member | team_admin | admin
|
||||
Role string `json:"role"` // member | admin
|
||||
TeamID string `json:"teamId,omitempty"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
CreatedBy string `json:"createdBy"`
|
||||
@@ -77,6 +132,13 @@ const (
|
||||
StatusConverted ThoughtStatus = "converted"
|
||||
)
|
||||
|
||||
type SubTask struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
IsCompleted bool `json:"isCompleted"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
type Thought struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
@@ -86,30 +148,80 @@ type Thought struct {
|
||||
Source string `json:"source"` // manual | voice | webhook
|
||||
Status ThoughtStatus `json:"status"`
|
||||
IsCompleted bool `json:"isCompleted"`
|
||||
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item
|
||||
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item | document_block
|
||||
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
|
||||
DocID string `json:"docId,omitempty"`
|
||||
BlockID string `json:"blockId,omitempty"`
|
||||
DueDate string `json:"dueDate,omitempty"`
|
||||
TaskListID string `json:"taskListId,omitempty"`
|
||||
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Subtasks []SubTask `json:"subtasks"`
|
||||
AlarmSent bool `json:"alarmSent,omitempty"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
TriagedAt string `json:"triagedAt,omitempty"`
|
||||
}
|
||||
|
||||
type ThoughtReorderItem struct {
|
||||
ID string `json:"id"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
KanbanStatus *string `json:"kanbanStatus,omitempty"`
|
||||
}
|
||||
|
||||
type ListItemReorderItem struct {
|
||||
ID string `json:"id"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Category *string `json:"category,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
type TaskListShare struct {
|
||||
ID string `json:"id"`
|
||||
TaskListID string `json:"taskListId"`
|
||||
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"` // editor | viewer
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type TaskListMember struct {
|
||||
UserID string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Role string `json:"role"` // owner | member
|
||||
}
|
||||
|
||||
type KanbanColumn struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color,omitempty"`
|
||||
IsDone bool `json:"isDone,omitempty"`
|
||||
}
|
||||
|
||||
type TaskList struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
SpaceID string `json:"spaceId,omitempty"`
|
||||
SpaceName string `json:"spaceName,omitempty"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
Members []SpaceMember `json:"members,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
TasksCount int `json:"tasksCount,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
SpaceID string `json:"spaceId,omitempty"`
|
||||
SpaceName string `json:"spaceName,omitempty"`
|
||||
TeamID string `json:"teamId,omitempty"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Permission string `json:"permission,omitempty"` // owner | editor | viewer
|
||||
Members []TaskListMember `json:"members,omitempty"`
|
||||
Shares []TaskListShare `json:"shares,omitempty"`
|
||||
Columns []KanbanColumn `json:"columns,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Color string `json:"color"`
|
||||
TasksCount int `json:"tasksCount,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Organization struct {
|
||||
@@ -122,7 +234,7 @@ type Organization struct {
|
||||
|
||||
type Team struct {
|
||||
ID string `json:"id"`
|
||||
OrgID string `json:"orgId"`
|
||||
OrgID string `json:"orgId,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Members []TeamMember `json:"members"`
|
||||
@@ -134,7 +246,8 @@ type TeamMember struct {
|
||||
UserID string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"` // team_admin | member | owner
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Role string `json:"role"` // admin | member
|
||||
JoinedAt string `json:"joinedAt"`
|
||||
}
|
||||
|
||||
@@ -152,51 +265,151 @@ type SystemSettings struct {
|
||||
}
|
||||
|
||||
type DocumentBlock struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress
|
||||
Content string `json:"content"`
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Level int `json:"level,omitempty"`
|
||||
Collapsed bool `json:"collapsed,omitempty"`
|
||||
ID string `json:"id"`
|
||||
DocumentID string `json:"documentId,omitempty"`
|
||||
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress
|
||||
Content string `json:"content"`
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Level int `json:"level"`
|
||||
Position float64 `json:"position"`
|
||||
Collapsed bool `json:"collapsed,omitempty"`
|
||||
TaskID string `json:"taskId,omitempty"`
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
SpaceID string `json:"spaceId,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Content string `json:"content"`
|
||||
Blocks []DocumentBlock `json:"blocks"`
|
||||
Tags []string `json:"tags"`
|
||||
FolderPath string `json:"folderPath,omitempty"`
|
||||
IsPinned bool `json:"isPinned"`
|
||||
IsArchived bool `json:"isArchived"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
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 SpaceMember struct {
|
||||
SpaceID string `json:"spaceId"`
|
||||
UserID string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"` // owner | editor | viewer
|
||||
JoinedAt string `json:"joinedAt"`
|
||||
type DocShare struct {
|
||||
ID string `json:"id"`
|
||||
DocID string `json:"docId"`
|
||||
TargetType string `json:"targetType,omitempty"` // user | team
|
||||
TargetID string `json:"targetId,omitempty"`
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
UserEmail string `json:"userEmail,omitempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Permission string `json:"permission"` // read | write
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Space struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description"`
|
||||
InviteCode string `json:"inviteCode"`
|
||||
IsPersonal bool `json:"isPersonal"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Members []SpaceMember `json:"members"`
|
||||
ListsCount int `json:"listsCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
type Document struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
SpaceID string `json:"spaceId,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Content string `json:"content"`
|
||||
MarkdownCache string `json:"markdownCache,omitempty"`
|
||||
Blocks []DocumentBlock `json:"blocks"`
|
||||
Tags []string `json:"tags"`
|
||||
FolderID string `json:"folderId,omitempty"`
|
||||
FolderPath string `json:"folderPath,omitempty"`
|
||||
IsPinned bool `json:"isPinned"`
|
||||
IsArchived bool `json:"isArchived"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
|
||||
// Collaboration & Permissions
|
||||
Permission string `json:"permission"` // owner | write | read
|
||||
IsOwner bool `json:"isOwner"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
SharedWith []DocShare `json:"sharedWith,omitempty"`
|
||||
SharedFolderID string `json:"sharedFolderId,omitempty"`
|
||||
SubFolderPath string `json:"subFolderPath,omitempty"`
|
||||
IsSharedFolder bool `json:"isSharedFolder,omitempty"`
|
||||
FolderPermission string `json:"folderPermission,omitempty"`
|
||||
}
|
||||
|
||||
type TagSummary struct {
|
||||
Tag string `json:"tag"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type MatchedTagBlock struct {
|
||||
RootBlock DocumentBlock `json:"rootBlock"`
|
||||
Children []DocumentBlock `json:"children"`
|
||||
}
|
||||
|
||||
type TagDocumentGroup struct {
|
||||
DocID string `json:"docId"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
FolderPath string `json:"folderPath"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Permission string `json:"permission"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
MatchedBlocks []MatchedTagBlock `json:"matchedBlocks"`
|
||||
}
|
||||
|
||||
type TagDetails struct {
|
||||
Tag string `json:"tag"`
|
||||
NoteContent string `json:"noteContent"`
|
||||
NoteUpdatedAt string `json:"noteUpdatedAt,omitempty"`
|
||||
Documents []TagDocumentGroup `json:"documents"`
|
||||
TotalMatches int `json:"totalMatches"`
|
||||
}
|
||||
|
||||
type ListShare struct {
|
||||
ID string `json:"id"`
|
||||
ListID string `json:"listId"`
|
||||
TargetType string `json:"targetType"` // team | user
|
||||
TargetID string `json:"targetId"`
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role string `json:"role"` // editor | viewer
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type ListItem struct {
|
||||
@@ -214,14 +427,18 @@ type ListItem struct {
|
||||
}
|
||||
|
||||
type SharedList struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"spaceId"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
ListType string `json:"listType"` // shopping | checklist | vacation
|
||||
Items []ListItem `json:"items"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Role string `json:"role,omitempty"` // editor | viewer
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
ListType string `json:"listType"` // shopping | checklist | vacation
|
||||
Items []ListItem `json:"items"`
|
||||
Shares []ListShare `json:"shares,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
@@ -238,19 +455,19 @@ type APIKey struct {
|
||||
|
||||
type WSMessage struct {
|
||||
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,omitempty"`
|
||||
ListID string `json:"list_id"`
|
||||
Payload interface{} `json:"payload"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
type DashboardStats struct {
|
||||
InboxCount int `json:"inboxCount"`
|
||||
TasksOpenCount int `json:"tasksOpenCount"`
|
||||
TasksDoneCount int `json:"tasksDoneCount"`
|
||||
DocsCount int `json:"docsCount"`
|
||||
ListsCount int `json:"listsCount"`
|
||||
DiscardedCount int `json:"discardedCount"`
|
||||
InboxCount int `json:"inboxCount"`
|
||||
TasksOpenCount int `json:"tasksOpenCount"`
|
||||
TasksDoneCount int `json:"tasksDoneCount"`
|
||||
DocsCount int `json:"docsCount"`
|
||||
DocsTodosOpenCount int `json:"docsTodosOpenCount"`
|
||||
ListsCount int `json:"listsCount"`
|
||||
DiscardedCount int `json:"discardedCount"`
|
||||
}
|
||||
|
||||
type FullExport struct {
|
||||
@@ -259,7 +476,32 @@ type FullExport struct {
|
||||
User User `json:"user"`
|
||||
Thoughts []Thought `json:"thoughts"`
|
||||
Documents []Document `json:"documents"`
|
||||
Spaces []Space `json:"spaces"`
|
||||
Lists []SharedList `json:"lists"`
|
||||
TaskLists []TaskList `json:"taskLists,omitempty"`
|
||||
}
|
||||
|
||||
type SharedFolder struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
Name string `json:"name"`
|
||||
MountPath string `json:"mountPath"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Permission string `json:"permission"` // owner | write | read
|
||||
Shares []FolderShare `json:"shares,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type FolderShare struct {
|
||||
ID string `json:"id"`
|
||||
FolderID string `json:"folderId"`
|
||||
TargetType string `json:"targetType"` // team | user
|
||||
TargetID string `json:"targetId"`
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Permission string `json:"permission"` // read | write
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
+3742
-394
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,11 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
@@ -46,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)
|
||||
}
|
||||
|
||||
// 3. Test Shared Space & Shopping List
|
||||
sp, err := store.CreateSpace(user.ID, "Test WG", "🍕", "Test Space", false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSpace failed: %v", err)
|
||||
}
|
||||
lst, err := store.CreateList(sp.ID, "REWE Einkauf", "🛒", "shopping")
|
||||
// 3. Test Standalone Shopping List & Sharing
|
||||
lst, err := store.CreateList(user.ID, "REWE Einkauf", "🛒", "shopping")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateList failed: %v", err)
|
||||
}
|
||||
@@ -64,6 +62,28 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("ToggleListItem failed: %v", err)
|
||||
}
|
||||
|
||||
// Test AddListShare & RemoveListShare
|
||||
u2, err := store.CreateUser("collab_space@example.com", "hash", "Collab User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser u2 failed: %v", err)
|
||||
}
|
||||
share, err := store.AddListShare(user.ID, lst.ID, "user", u2.ID, "editor")
|
||||
if err != nil || share.TargetID != u2.ID {
|
||||
t.Fatalf("AddListShare failed: %v", err)
|
||||
}
|
||||
shares, err := store.GetListShares(lst.ID)
|
||||
if err != nil || len(shares) != 1 {
|
||||
t.Fatalf("expected 1 share, got %d", len(shares))
|
||||
}
|
||||
err = store.RemoveListShare(user.ID, lst.ID, share.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveListShare failed: %v", err)
|
||||
}
|
||||
sharesAfter, _ := store.GetListShares(lst.ID)
|
||||
if len(sharesAfter) != 0 {
|
||||
t.Fatalf("expected 0 shares after remove, got %d", len(sharesAfter))
|
||||
}
|
||||
|
||||
// 4. Test Webhook API Key generation & validation
|
||||
apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant")
|
||||
if err != nil {
|
||||
@@ -75,7 +95,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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 == "" {
|
||||
t.Fatalf("CreateTaskList failed: %v", err)
|
||||
}
|
||||
@@ -98,6 +118,20 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("AddTeamMember failed: %v", err)
|
||||
}
|
||||
|
||||
// Test TaskList Sharing with Team
|
||||
shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, "editor", user.ID)
|
||||
if err != nil || shareTeam.ID == "" {
|
||||
t.Fatalf("AddTaskListShare team failed: %v", err)
|
||||
}
|
||||
tlShares, err := store.GetTaskListShares(taskList.ID)
|
||||
if err != nil || len(tlShares) == 0 {
|
||||
t.Fatalf("GetTaskListShares failed: %v", err)
|
||||
}
|
||||
fetchedTL, err := store.GetTaskListByID(user.ID, taskList.ID)
|
||||
if err != nil || len(fetchedTL.Shares) == 0 {
|
||||
t.Fatalf("GetTaskListByID with shares failed: %v", err)
|
||||
}
|
||||
|
||||
// 7. Test System Settings
|
||||
sysSettings, err := store.GetSystemSettings()
|
||||
if err != nil || sysSettings.InstanceName == "" {
|
||||
@@ -108,4 +142,638 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateSystemSettings failed: %v", err)
|
||||
}
|
||||
|
||||
// 8. Test Document Sharing & Permissions
|
||||
user2, err := store.CreateUser("collab@example.com", "hash", "Collab User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser collab failed: %v", err)
|
||||
}
|
||||
|
||||
// Test read-only TaskList share
|
||||
ownerTask, err := store.CreateThoughtWithDetails(user.ID, "Rasen mähen", "normal", "manual", "", taskList.ID, "todo", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateThoughtWithDetails in own list failed: %v", err)
|
||||
}
|
||||
if _, err := store.UpdateThoughtTriage(user.ID, ownerTask.ID, "keep", "task", "", "", taskList.ID); err != nil {
|
||||
t.Fatalf("UpdateThoughtTriage in own list failed: %v", err)
|
||||
}
|
||||
if _, err := store.AddTaskListShare(taskList.ID, "user", user2.ID, "viewer", user.ID); err != nil {
|
||||
t.Fatalf("AddTaskListShare viewer failed: %v", err)
|
||||
}
|
||||
if perm := store.GetTaskListPermission(user2.ID, taskList.ID); perm != "viewer" {
|
||||
t.Fatalf("expected viewer permission, got %q", perm)
|
||||
}
|
||||
viewerTasks, err := store.GetTasks(user2.ID, taskList.ID)
|
||||
if err != nil || len(viewerTasks) == 0 {
|
||||
t.Fatalf("viewer should see tasks of shared list: %v", err)
|
||||
}
|
||||
done := true
|
||||
if _, err := store.UpdateThoughtDetails(user2.ID, ownerTask.ID, nil, &done, nil, nil, nil, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not update tasks, got %v", err)
|
||||
}
|
||||
if _, err := store.CreateThoughtWithDetails(user2.ID, "Hack", "normal", "manual", "", taskList.ID, "todo", ""); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not create tasks, got %v", err)
|
||||
}
|
||||
if err := store.DeleteThought(user2.ID, ownerTask.ID); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not delete tasks, got %v", err)
|
||||
}
|
||||
newTitle := "Umbenannt"
|
||||
if _, err := store.UpdateTaskList(user2.ID, taskList.ID, &newTitle, nil, nil, nil, nil, nil); !errors.Is(err, ErrTaskListReadOnly) {
|
||||
t.Fatalf("viewer must not update task list, got %v", err)
|
||||
}
|
||||
if _, err := store.AddTaskListShare(taskList.ID, "user", user2.ID, "editor", user.ID); err != nil {
|
||||
t.Fatalf("upgrading share to editor failed: %v", err)
|
||||
}
|
||||
if !store.CanEditTaskList(user2.ID, taskList.ID) {
|
||||
t.Fatalf("editor share should allow editing")
|
||||
}
|
||||
newDoc, err := store.CreateDocument(user.ID, "", "Test Note", "📄", "", []domain.DocumentBlock{
|
||||
{ID: "blk_1", Type: "bullet", Content: "Geheim", Level: 0},
|
||||
}, []string{"test"}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
// Share with user2 as "read"
|
||||
docShare, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read")
|
||||
if err != nil || docShare.Permission != "read" {
|
||||
t.Fatalf("CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
// user2 can get document
|
||||
docForUser2, err := store.GetDocumentByID(user2.ID, newDoc.ID)
|
||||
if err != nil || docForUser2.Permission != "read" || docForUser2.IsOwner {
|
||||
t.Fatalf("GetDocumentByID for user2 failed or wrong permission: %v", err)
|
||||
}
|
||||
|
||||
// user2 tries to edit read-only doc -> must fail
|
||||
updatedTitle := "Hacked Title"
|
||||
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded")
|
||||
}
|
||||
|
||||
// user2 is a read-only collaborator -> editing must fail
|
||||
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected UpdateDocument to fail for read-only collaborator, but succeeded")
|
||||
}
|
||||
|
||||
// Remove share
|
||||
err = store.DeleteDocShare(user.ID, newDoc.ID, user2.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteDocShare failed: %v", err)
|
||||
}
|
||||
_, err = store.GetDocumentByID(user2.ID, newDoc.ID)
|
||||
if err == nil {
|
||||
t.Fatalf("expected GetDocumentByID to fail after share removed, but succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerUserDocPinning(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "pin_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
user1, _ := store.CreateUser("user1@example.com", "hash", "User 1", "admin")
|
||||
user2, _ := store.CreateUser("user2@example.com", "hash", "User 2", "member")
|
||||
|
||||
// user1 creates document and pins it
|
||||
doc, err := store.CreateDocument(user1.ID, "", "Doc 1", "📝", "", []domain.DocumentBlock{
|
||||
{ID: "b1", Type: "text", Content: "Hello"},
|
||||
}, []string{"test"}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
if !doc.IsPinned {
|
||||
t.Fatalf("expected doc to be pinned for user1")
|
||||
}
|
||||
|
||||
// Share with user2 as read-only
|
||||
_, err = store.CreateDocShare(user1.ID, doc.ID, user2.ID, "read")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
// user2 reads document: should NOT be pinned for user2
|
||||
docForUser2, err := store.GetDocumentByID(user2.ID, doc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocumentByID user2 failed: %v", err)
|
||||
}
|
||||
if docForUser2.IsPinned {
|
||||
t.Fatalf("expected doc to NOT be pinned for user2 initially")
|
||||
}
|
||||
|
||||
// user2 pins the shared document (even with read-only access)
|
||||
pinTrue := true
|
||||
updatedDoc2, err := store.UpdateDocument(user2.ID, doc.ID, nil, nil, nil, nil, nil, &pinTrue)
|
||||
if err != nil {
|
||||
t.Fatalf("user2 pinning read-only doc failed: %v", err)
|
||||
}
|
||||
if !updatedDoc2.IsPinned {
|
||||
t.Fatalf("expected doc to be pinned for user2")
|
||||
}
|
||||
|
||||
// Verify user1's doc is still pinned
|
||||
docForUser1, err := store.GetDocumentByID(user1.ID, doc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocumentByID user1 failed: %v", err)
|
||||
}
|
||||
if !docForUser1.IsPinned {
|
||||
t.Fatalf("expected doc to still be pinned for user1")
|
||||
}
|
||||
|
||||
// user1 unpins the doc
|
||||
pinFalse := false
|
||||
_, err = store.UpdateDocument(user1.ID, doc.ID, nil, nil, nil, nil, nil, &pinFalse)
|
||||
if err != nil {
|
||||
t.Fatalf("user1 unpinning doc failed: %v", err)
|
||||
}
|
||||
|
||||
// user1 doc is now unpinned, but user2 doc must STILL be pinned!
|
||||
docForUser1, _ = store.GetDocumentByID(user1.ID, doc.ID)
|
||||
if docForUser1.IsPinned {
|
||||
t.Fatalf("expected doc to be unpinned for user1")
|
||||
}
|
||||
docForUser2, _ = store.GetDocumentByID(user2.ID, doc.ID)
|
||||
if !docForUser2.IsPinned {
|
||||
t.Fatalf("expected doc to remain pinned for user2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDemoAccountsAndLoginLookup(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "demo_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := store.EnsureDemoAccounts(); err != nil {
|
||||
t.Fatalf("EnsureDemoAccounts failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Lookup by username "admin"
|
||||
adminUser, err := store.GetUserByLogin("admin")
|
||||
if err != nil || adminUser == nil {
|
||||
t.Fatalf("expected to find admin user by username 'admin', got %v", err)
|
||||
}
|
||||
if adminUser.Role != "admin" {
|
||||
t.Fatalf("expected role 'admin', got %s", adminUser.Role)
|
||||
}
|
||||
if !auth.CheckPassword("admin", adminUser.PasswordHash) {
|
||||
t.Fatalf("admin password check failed")
|
||||
}
|
||||
|
||||
// 2. Lookup by email "admin@mindsift.local"
|
||||
adminByEmail, err := store.GetUserByLogin("admin@mindsift.local")
|
||||
if err != nil || adminByEmail.ID != adminUser.ID {
|
||||
t.Fatalf("lookup by email failed: got %v", err)
|
||||
}
|
||||
|
||||
// 3. Lookup with case insensitivity "ADMIN"
|
||||
adminCaps, err := store.GetUserByLogin("ADMIN")
|
||||
if err != nil || adminCaps.ID != adminUser.ID {
|
||||
t.Fatalf("lookup by uppercase failed: got %v", err)
|
||||
}
|
||||
|
||||
// 4. Lookup by username "user"
|
||||
normalUser, err := store.GetUserByLogin("user")
|
||||
if err != nil || normalUser == nil {
|
||||
t.Fatalf("expected to find user by username 'user', got %v", err)
|
||||
}
|
||||
if normalUser.Role != "member" {
|
||||
t.Fatalf("expected role 'member', got %s", normalUser.Role)
|
||||
}
|
||||
if !auth.CheckPassword("user", normalUser.PasswordHash) {
|
||||
t.Fatalf("user password check failed")
|
||||
}
|
||||
|
||||
// 5. Ensure re-running is idempotent and preserves/refreshes credentials
|
||||
if err := store.EnsureDemoAccounts(); err != nil {
|
||||
t.Fatalf("second EnsureDemoAccounts call failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderManagementAndDeletion(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "folder_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
user, err := store.CreateUser("folderuser@example.com", "hash", "Folder User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Create folders
|
||||
f1, err := store.CreateFolder(user.ID, "Projekte/MindSift")
|
||||
if err != nil || f1 != "Projekte/MindSift" {
|
||||
t.Fatalf("CreateFolder failed: %v, f1=%s", err, f1)
|
||||
}
|
||||
f2, err := store.CreateFolder(user.ID, "Archiv")
|
||||
if err != nil || f2 != "Archiv" {
|
||||
t.Fatalf("CreateFolder Archiv failed: %v", err)
|
||||
}
|
||||
|
||||
// 2. Create document in subfolder
|
||||
doc1, err := store.CreateDocument(user.ID, "", "Doc in MindSift", "📄", "Projekte/MindSift", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
doc2, err := store.CreateDocument(user.ID, "", "SubDoc", "📝", "Projekte/MindSift/Sub", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument 2 failed: %v", err)
|
||||
}
|
||||
|
||||
// 3. GetFolders
|
||||
folders, err := store.GetFolders(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFolders failed: %v", err)
|
||||
}
|
||||
expected := map[string]bool{
|
||||
"Archiv": true,
|
||||
"Projekte": true,
|
||||
"Projekte/MindSift": true,
|
||||
"Projekte/MindSift/Sub": true,
|
||||
}
|
||||
for _, f := range folders {
|
||||
delete(expected, f)
|
||||
}
|
||||
if len(expected) > 0 {
|
||||
t.Fatalf("GetFolders missing expected folders: %v (got %v)", expected, folders)
|
||||
}
|
||||
|
||||
// 4. DeleteFolder "Projekte/MindSift" -> should remove doc1 & doc2
|
||||
deletedDocs, err := store.DeleteFolder(user.ID, "Projekte/MindSift")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteFolder failed: %v", err)
|
||||
}
|
||||
if len(deletedDocs) != 2 {
|
||||
t.Fatalf("expected 2 deleted docs, got %d (%v)", len(deletedDocs), deletedDocs)
|
||||
}
|
||||
|
||||
// Verify docs are gone from DB
|
||||
_, err = store.GetDocumentByID(user.ID, doc1.ID)
|
||||
if err == nil {
|
||||
t.Fatalf("expected doc1 to be deleted")
|
||||
}
|
||||
_, err = store.GetDocumentByID(user.ID, doc2.ID)
|
||||
if err == nil {
|
||||
t.Fatalf("expected doc2 to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "tags_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
user, err := store.CreateUser("taguser@example.com", "hash", "Tag User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Create document with nested bullet points and hashtags
|
||||
blocks := []domain.DocumentBlock{
|
||||
{ID: "b0", Type: "bullet", Content: "Einleitung ohne Tag", Level: 0},
|
||||
{ID: "b1", Type: "bullet", Content: "Einkauf planen #Kühlregal #Wocheneinkauf", Level: 0},
|
||||
{ID: "b2", Type: "bullet", Content: "Milch 3.8%", Level: 1},
|
||||
{ID: "b3", Type: "bullet", Content: "Gouda alt", Level: 1},
|
||||
{ID: "b4", Type: "bullet", Content: "Zusatzinfo: Aus Weidemilch", Level: 2},
|
||||
{ID: "b5", Type: "bullet", Content: "Anderes Thema ohne Bezug", Level: 0},
|
||||
}
|
||||
|
||||
doc, err := store.CreateDocument(user.ID, "", "Einkaufszettel", "🛒", "Haushalt", blocks, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Check GetWorkspaceTags
|
||||
tags, err := store.GetWorkspaceTags(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkspaceTags failed: %v", err)
|
||||
}
|
||||
if len(tags) != 2 {
|
||||
t.Fatalf("expected 2 tags, got %d (%v)", len(tags), tags)
|
||||
}
|
||||
tagMap := make(map[string]int)
|
||||
for _, tg := range tags {
|
||||
tagMap[tg.Tag] = tg.Count
|
||||
}
|
||||
if tagMap["kühlregal"] != 1 || tagMap["wocheneinkauf"] != 1 {
|
||||
t.Fatalf("unexpected tag counts: %v", tagMap)
|
||||
}
|
||||
|
||||
// 2. Check GetTagDetails for #Kühlregal
|
||||
details, err := store.GetTagDetails(user.ID, "#Kühlregal")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTagDetails failed: %v", err)
|
||||
}
|
||||
if details.Tag != "kühlregal" {
|
||||
t.Fatalf("expected tag 'kühlregal', got %s", details.Tag)
|
||||
}
|
||||
if details.TotalMatches != 1 {
|
||||
t.Fatalf("expected 1 match, got %d", details.TotalMatches)
|
||||
}
|
||||
if len(details.Documents) != 1 {
|
||||
t.Fatalf("expected 1 document, got %d", len(details.Documents))
|
||||
}
|
||||
docGroup := details.Documents[0]
|
||||
if docGroup.DocID != doc.ID || docGroup.Title != "Einkaufszettel" {
|
||||
t.Fatalf("unexpected doc group: %+v", docGroup)
|
||||
}
|
||||
if len(docGroup.MatchedBlocks) != 1 {
|
||||
t.Fatalf("expected 1 matched block, got %d", len(docGroup.MatchedBlocks))
|
||||
}
|
||||
matched := docGroup.MatchedBlocks[0]
|
||||
if matched.RootBlock.ID != "b1" {
|
||||
t.Fatalf("expected root block b1, got %s", matched.RootBlock.ID)
|
||||
}
|
||||
// Children should be b2, b3, b4 (all subsequent blocks with Level > 0 until b5 where Level == 0)
|
||||
if len(matched.Children) != 3 {
|
||||
t.Fatalf("expected 3 children (b2, b3, b4), got %d (%v)", len(matched.Children), matched.Children)
|
||||
}
|
||||
if matched.Children[0].ID != "b2" || matched.Children[1].ID != "b3" || matched.Children[2].ID != "b4" {
|
||||
t.Fatalf("unexpected children IDs: %v", matched.Children)
|
||||
}
|
||||
|
||||
// 3. Update document: remove #Wocheneinkauf, add #Vorrat
|
||||
updatedBlocks := []domain.DocumentBlock{
|
||||
{ID: "b1", Type: "bullet", Content: "Einkauf planen #Kühlregal #Vorrat", Level: 0},
|
||||
}
|
||||
_, err = store.UpdateDocument(user.ID, doc.ID, nil, nil, nil, &updatedBlocks, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
tagsAfter, err := store.GetWorkspaceTags(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkspaceTags after update failed: %v", err)
|
||||
}
|
||||
tagMapAfter := make(map[string]int)
|
||||
for _, tg := range tagsAfter {
|
||||
tagMapAfter[tg.Tag] = tg.Count
|
||||
}
|
||||
if tagMapAfter["wocheneinkauf"] != 0 {
|
||||
t.Fatalf("expected wocheneinkauf to be removed, got %v", tagMapAfter)
|
||||
}
|
||||
if tagMapAfter["vorrat"] != 1 || tagMapAfter["kühlregal"] != 1 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+531
-45
@@ -2,8 +2,10 @@ package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -12,72 +14,368 @@ import (
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Self-hosted reverse proxy compatible
|
||||
},
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
spaceID string
|
||||
userID string
|
||||
send chan []byte
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
roomID string
|
||||
userID string
|
||||
send chan []byte
|
||||
subscribedRooms map[string]bool
|
||||
sendMu sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *Client) closeSend() {
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
if !c.closed {
|
||||
c.closed = true
|
||||
close(c.send)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) trySend(data []byte) {
|
||||
c.sendMu.RLock()
|
||||
defer c.sendMu.RUnlock()
|
||||
if c.closed {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
type SSEClient struct {
|
||||
id string
|
||||
userID string
|
||||
roomID string
|
||||
send chan []byte
|
||||
subscribedRooms map[string]bool
|
||||
sendMu sync.RWMutex
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (c *SSEClient) closeSend() {
|
||||
c.sendMu.Lock()
|
||||
defer c.sendMu.Unlock()
|
||||
if !c.closed {
|
||||
c.closed = true
|
||||
close(c.send)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SSEClient) trySend(data []byte) {
|
||||
c.sendMu.RLock()
|
||||
defer c.sendMu.RUnlock()
|
||||
if c.closed {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
rooms map[string]map[*Client]bool
|
||||
mu sync.RWMutex
|
||||
rooms map[string]map[*Client]bool
|
||||
userRooms map[string]map[*Client]bool
|
||||
allClients map[*Client]bool
|
||||
sseClients map[*SSEClient]bool
|
||||
sseUserRooms map[string]map[*SSEClient]bool
|
||||
sseRooms map[string]map[*SSEClient]bool
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
rooms: make(map[string]map[*Client]bool),
|
||||
rooms: make(map[string]map[*Client]bool),
|
||||
userRooms: make(map[string]map[*Client]bool),
|
||||
allClients: make(map[*Client]bool),
|
||||
sseClients: make(map[*SSEClient]bool),
|
||||
sseUserRooms: make(map[string]map[*SSEClient]bool),
|
||||
sseRooms: make(map[string]map[*SSEClient]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Register(c *Client) {
|
||||
h.mu.Lock()
|
||||
if h.rooms[c.spaceID] == nil {
|
||||
h.rooms[c.spaceID] = make(map[*Client]bool)
|
||||
h.allClients[c] = true
|
||||
if c.roomID != "" {
|
||||
if h.rooms[c.roomID] == nil {
|
||||
h.rooms[c.roomID] = make(map[*Client]bool)
|
||||
}
|
||||
h.rooms[c.roomID][c] = true
|
||||
}
|
||||
h.rooms[c.spaceID][c] = true
|
||||
onlineCount := len(h.rooms[c.spaceID])
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if h.userRooms[c.userID] == nil {
|
||||
h.userRooms[c.userID] = make(map[*Client]bool)
|
||||
}
|
||||
h.userRooms[c.userID][c] = true
|
||||
}
|
||||
onlineCount := len(h.allClients) + len(h.sseClients)
|
||||
h.mu.Unlock()
|
||||
|
||||
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{
|
||||
"onlineCount": onlineCount,
|
||||
})
|
||||
if c.roomID != "" {
|
||||
h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{
|
||||
"onlineCount": onlineCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Unregister(c *Client) {
|
||||
h.mu.Lock()
|
||||
var onlineCount int
|
||||
if room, ok := h.rooms[c.spaceID]; ok {
|
||||
if _, exists := room[c]; exists {
|
||||
delete(h.allClients, c)
|
||||
if c.roomID != "" {
|
||||
if room, ok := h.rooms[c.roomID]; ok {
|
||||
delete(room, c)
|
||||
close(c.send)
|
||||
if len(room) == 0 {
|
||||
delete(h.rooms, c.roomID)
|
||||
}
|
||||
}
|
||||
onlineCount = len(room)
|
||||
if onlineCount == 0 {
|
||||
delete(h.rooms, c.spaceID)
|
||||
}
|
||||
|
||||
if c.subscribedRooms != nil {
|
||||
for rID := range c.subscribedRooms {
|
||||
if room, ok := h.rooms[rID]; ok {
|
||||
delete(room, c)
|
||||
if len(room) == 0 {
|
||||
delete(h.rooms, rID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if uRoom, ok := h.userRooms[c.userID]; ok {
|
||||
delete(uRoom, c)
|
||||
if len(uRoom) == 0 {
|
||||
delete(h.userRooms, c.userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.closeSend()
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hub) RegisterSSE(c *SSEClient) {
|
||||
h.mu.Lock()
|
||||
h.sseClients[c] = true
|
||||
if c.roomID != "" {
|
||||
if h.sseRooms[c.roomID] == nil {
|
||||
h.sseRooms[c.roomID] = make(map[*SSEClient]bool)
|
||||
}
|
||||
h.sseRooms[c.roomID][c] = true
|
||||
}
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if h.sseUserRooms[c.userID] == nil {
|
||||
h.sseUserRooms[c.userID] = make(map[*SSEClient]bool)
|
||||
}
|
||||
h.sseUserRooms[c.userID][c] = true
|
||||
}
|
||||
if c.subscribedRooms != nil {
|
||||
for rID := range c.subscribedRooms {
|
||||
if h.sseRooms[rID] == nil {
|
||||
h.sseRooms[rID] = make(map[*SSEClient]bool)
|
||||
}
|
||||
h.sseRooms[rID][c] = true
|
||||
}
|
||||
}
|
||||
onlineCount := len(h.allClients) + len(h.sseClients)
|
||||
h.mu.Unlock()
|
||||
|
||||
if c.roomID != "" {
|
||||
h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{
|
||||
"onlineCount": onlineCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) UnregisterSSE(c *SSEClient) {
|
||||
h.mu.Lock()
|
||||
delete(h.sseClients, c)
|
||||
if c.roomID != "" {
|
||||
if room, ok := h.sseRooms[c.roomID]; ok {
|
||||
delete(room, c)
|
||||
if len(room) == 0 {
|
||||
delete(h.sseRooms, c.roomID)
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.subscribedRooms != nil {
|
||||
for rID := range c.subscribedRooms {
|
||||
if room, ok := h.sseRooms[rID]; ok {
|
||||
delete(room, c)
|
||||
if len(room) == 0 {
|
||||
delete(h.sseRooms, rID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if uRoom, ok := h.sseUserRooms[c.userID]; ok {
|
||||
delete(uRoom, c)
|
||||
if len(uRoom) == 0 {
|
||||
delete(h.sseUserRooms, c.userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.closeSend()
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for client := range h.sseClients {
|
||||
if client.id == sseID {
|
||||
if h.sseRooms[roomID] == nil {
|
||||
h.sseRooms[roomID] = make(map[*SSEClient]bool)
|
||||
}
|
||||
h.sseRooms[roomID][client] = true
|
||||
if client.subscribedRooms == nil {
|
||||
client.subscribedRooms = make(map[string]bool)
|
||||
}
|
||||
client.subscribedRooms[roomID] = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Hub) LeaveSSERoomByID(sseID, roomID string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for client := range h.sseClients {
|
||||
if client.id == sseID {
|
||||
if room, ok := h.sseRooms[roomID]; ok {
|
||||
delete(room, client)
|
||||
if len(room) == 0 {
|
||||
delete(h.sseRooms, roomID)
|
||||
}
|
||||
}
|
||||
if client.subscribedRooms != nil {
|
||||
delete(client.subscribedRooms, roomID)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Close gracefully disconnects all WebSocket and SSE connections so HTTP shutdown completes immediately.
|
||||
func (h *Hub) Close() {
|
||||
h.mu.Lock()
|
||||
if h.closed {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
h.closed = true
|
||||
|
||||
allWsClients := make([]*Client, 0, len(h.allClients))
|
||||
for c := range h.allClients {
|
||||
allWsClients = append(allWsClients, c)
|
||||
}
|
||||
|
||||
allSSEClients := make([]*SSEClient, 0, len(h.sseClients))
|
||||
for c := range h.sseClients {
|
||||
allSSEClients = append(allSSEClients, c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if onlineCount > 0 {
|
||||
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{
|
||||
"onlineCount": onlineCount,
|
||||
})
|
||||
for _, c := range allWsClients {
|
||||
_ = c.conn.Close()
|
||||
c.closeSend()
|
||||
}
|
||||
|
||||
for _, c := range allSSEClients {
|
||||
c.closeSend()
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcasting Methods
|
||||
|
||||
func (h *Hub) BroadcastToAll(event string, payload interface{}) {
|
||||
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()
|
||||
|
||||
for client := range h.allClients {
|
||||
client.trySend(data)
|
||||
}
|
||||
for sseClient := range h.sseClients {
|
||||
sseClient.trySend(data)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) {
|
||||
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.userRooms[userID]; ok {
|
||||
for client := range room {
|
||||
client.trySend(data)
|
||||
}
|
||||
}
|
||||
if sseRoom, ok := h.sseUserRooms[userID]; ok {
|
||||
for sseClient := range sseRoom {
|
||||
sseClient.trySend(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) BroadcastTaskEvent(userID, event string, payload interface{}) {
|
||||
h.BroadcastToUser(userID, event, payload)
|
||||
}
|
||||
|
||||
func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
|
||||
if docID != "" {
|
||||
h.BroadcastRoom("doc:"+docID, event, payload, nil)
|
||||
} else {
|
||||
h.BroadcastToAll(event, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||
msg := domain.WSMessage{
|
||||
Event: event,
|
||||
SpaceID: spaceID,
|
||||
ListID: listID,
|
||||
Payload: payload,
|
||||
Timestamp: domain.NowISO(),
|
||||
@@ -88,34 +386,118 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
room, ok := h.rooms[spaceID]
|
||||
if !ok {
|
||||
if h.closed {
|
||||
h.mu.RUnlock()
|
||||
return
|
||||
}
|
||||
for client := range room {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
// Slow client buffer full
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
targetRoom := listID
|
||||
if targetRoom == "" {
|
||||
targetRoom = spaceID
|
||||
}
|
||||
|
||||
if room, ok := h.rooms[targetRoom]; ok {
|
||||
for client := range room {
|
||||
client.trySend(data)
|
||||
}
|
||||
}
|
||||
if sseRoom, ok := h.sseRooms[targetRoom]; ok {
|
||||
for sseClient := range sseRoom {
|
||||
sseClient.trySend(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) 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)
|
||||
if err != nil {
|
||||
log.Printf("[ws] upgrade error: %v", err)
|
||||
slog.Error("WebSocket upgrade error", "room_id", roomID, "user_id", userID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
spaceID: spaceID,
|
||||
roomID: roomID,
|
||||
userID: userID,
|
||||
send: make(chan []byte, 64),
|
||||
send: make(chan []byte, 128),
|
||||
}
|
||||
|
||||
h.Register(client)
|
||||
@@ -123,13 +505,80 @@ func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID st
|
||||
go client.readPump()
|
||||
}
|
||||
|
||||
func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request, roomID, userID string, initialRooms []string) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
client := &SSEClient{
|
||||
id: domain.NewID("sse"),
|
||||
userID: userID,
|
||||
roomID: roomID,
|
||||
send: make(chan []byte, 128),
|
||||
subscribedRooms: make(map[string]bool),
|
||||
}
|
||||
|
||||
for _, room := range initialRooms {
|
||||
if strings.TrimSpace(room) != "" {
|
||||
client.subscribedRooms[strings.TrimSpace(room)] = true
|
||||
}
|
||||
}
|
||||
|
||||
h.RegisterSSE(client)
|
||||
defer h.UnregisterSSE(client)
|
||||
|
||||
// Send initial connected comment and event
|
||||
fmt.Fprintf(w, ": connected (transport: sse, id: %s)\n\n", client.id)
|
||||
connectedMsg, _ := json.Marshal(domain.WSMessage{
|
||||
Event: "SSE_CONNECTED",
|
||||
Payload: map[string]interface{}{
|
||||
"sseId": client.id,
|
||||
"transport": "sse",
|
||||
"userId": userID,
|
||||
},
|
||||
Timestamp: domain.NowISO(),
|
||||
})
|
||||
fmt.Fprintf(w, "data: %s\n\n", connectedMsg)
|
||||
flusher.Flush()
|
||||
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
notify := r.Context().Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-notify:
|
||||
return
|
||||
case msg, ok := <-client.send:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", msg)
|
||||
flusher.Flush()
|
||||
case <-ticker.C:
|
||||
// Periodic ping comment to keep HTTP connection alive and prevent proxy timeouts
|
||||
fmt.Fprintf(w, ": ping\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
c.hub.Unregister(c)
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
|
||||
c.conn.SetReadLimit(8192)
|
||||
c.conn.SetReadLimit(4 * 1024 * 1024) // 4MB for large document CRDT updates
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
@@ -137,10 +586,47 @@ func (c *Client) readPump() {
|
||||
})
|
||||
|
||||
for {
|
||||
_, _, err := c.conn.ReadMessage()
|
||||
_, message, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Handle client-initiated lock, CRDT, room subscription, and presence messages
|
||||
var incoming struct {
|
||||
Event string `json:"event"`
|
||||
DocID string `json:"docId"`
|
||||
Room string `json:"room"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
if json.Unmarshal(message, &incoming) == nil && c.userID != "" && c.userID != "anonymous" {
|
||||
docID := incoming.DocID
|
||||
if docID == "" && len(incoming.Payload) > 0 {
|
||||
var p struct {
|
||||
DocID string `json:"docId"`
|
||||
}
|
||||
_ = json.Unmarshal(incoming.Payload, &p)
|
||||
docID = p.DocID
|
||||
}
|
||||
|
||||
// Room subscription without requiring docID
|
||||
if incoming.Event == "JOIN_ROOM" {
|
||||
room := incoming.Room
|
||||
if room == "" {
|
||||
room = docID
|
||||
}
|
||||
if room != "" {
|
||||
c.hub.JoinRoom(c, room)
|
||||
}
|
||||
} else if incoming.Event == "LEAVE_ROOM" {
|
||||
room := incoming.Room
|
||||
if room == "" {
|
||||
room = docID
|
||||
}
|
||||
if room != "" {
|
||||
c.hub.LeaveRoom(c, room)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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_ref_id TEXT DEFAULT '',
|
||||
due_date TEXT DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
triaged_at TEXT DEFAULT '',
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
@@ -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_due ON thoughts(user_id, due_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS spaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 261 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 249 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 315 KiB |
Reference in new issue
Block a user