Merge pull request 'Dev' (#2) from dev into main
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
commit
5c6f162c96
25 files changed
+6458
-483
No files matched your search
@@ -58,10 +58,19 @@
|
||||
- `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.
|
||||
- **Zoom-in Hoisting:** Klick auf den Bullet-Dot zoomt tief in diesen Ast hinein (Fokusmodus) mit Breadcrumbs-Navigation (zurückspringen per Klick auf die grauen Breadcrumbs).
|
||||
- **Drag & Drop:** Bullet-Dots dienen als Anfasser zum Verschieben und Umsortieren von Blöcken und ganzen Unterbäumen.
|
||||
- **Slash-Commands (`/`):** Schnelle Umwandlung in `/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/table`, `/callout`, `/progress`, `/divider`.
|
||||
- Physische Speicherung auf Festplatte unter `/data/workspace/<user_id>/...` als Standard-`.md`-Dateien mit portabler 2-Leerzeichen-Einrückung (` - [ ] ` bzw. ` - `), vollständig kompatibel mit externen Outlinern und Markdown-Viewern.
|
||||
- [x] **Zentrale Hilfeseite & Dokumentation (`HelpView.tsx`, `App.tsx`):**
|
||||
- Vollständige, übersichtliche Hilfe- und Tastatur-Dokumentation für Outliner, Triage, Aufgaben, geteilte Listen und Tastatur-Shortcuts.
|
||||
- Direkt erreichbar über das Benutzer-Profil-Menü („Hilfe & Anleitung") sowie den „Hilfe"-Button im Workspace-Header.
|
||||
- [x] **Workspace UX-Refinements & Datei-Explorer Drag & Drop (`WorkspaceView.tsx`, `sqlite.go`):**
|
||||
- **Dateien in Ordner ziehen:** Notizen können im Datei-Explorer per Drag & Drop direkt auf beliebige Ordner oder in das Hauptverzeichnis gezogen werden. Das alte Ordner-Dropdown im Menü wurde entfernt.
|
||||
- **Angepinnte Notizen (Favoriten):** Dokumente mit `isPinned: true` werden direkt ganz oben im Datei-Explorer in einer eigenen Schnellzugriffs-Sektion dargestellt.
|
||||
- **Bereinigter Editor:** Störende Tasten-Legende unter den Notizen entfernt; klobiger Rahmen um das Notiz-Icon entfernt; Standard-Icon ist der Zettel (`📄`).
|
||||
- **Breadcrumbs & Zoom:** Überflüssiger „Zoom aufheben"-Button entfernt; alle Breadcrumbs-Elemente inklusive Dokumententitel erscheinen einheitlich dezent in Grau ohne störende Link-Unterstreichung.
|
||||
- **Globale To-Dos:** Tab- und Bereichs-Titel auf prägnantes „Globale To-Dos" gekürzt, unnötiger Erklärungstext entfernt.
|
||||
- [x] **Globaler Workspace To-Do Rollup Dashboard (`WorkspaceView.tsx`):**
|
||||
- Aggregierte Übersicht aller To-Dos (`- [ ] ` / `- [x] `) aus sämtlichen Notiz-Dateien über alle Ordner hinweg.
|
||||
- Übersichtliche Gruppierung nach Ordner ➔ Dokument ➔ Aufgaben mit einklappbaren Akkordeons und Zähler für offene Aufgaben.
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
### *Der Kopf frei in drei Sekunden.*
|
||||
|
||||
[](#)
|
||||
[](#-in-60-sekunden-starten)
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#)
|
||||
[](LICENSE)
|
||||
|
||||
**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 deine zentrale, kompromisslos schnelle Schaltstelle für spontane Gedanken, strukturierte Aufgaben, hierarchische Outliner-Notizen und gemeinsame Echtzeit-Listen — ablenkungsfrei gestaltet, lokal als Standard-Markdown gespeichert und zu 100 % unter deiner eigenen Kontrolle.
|
||||
|
||||
[Philosophie](#-die-philosophie) • [Funktionen](#-was-mindsift-auszeichnet) • [Für wen?](#-für-wen-ist-mindsift-gemacht) • [Schnellstart](#-in-60-sekunden-starten)
|
||||
[Philosophie](#-die-philosophie) • [Funktionen](#-was-mindsift-auszeichnet) • [Schnellstart](#-schnellstart) • [Für wen?](#-für-wen-ist-mindsift-gemacht)
|
||||
|
||||
---
|
||||
|
||||
@@ -19,66 +19,98 @@
|
||||
## 💡 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.
|
||||
Wenn dir mitten im Gespräch, unterwegs oder kurz vor Feierabend ein wichtiger Gedanke kommt, willst du keinen Ordner auswählen, keine Datenbank-Eigenschaften pflegen und kein Formular ausfüllen. Du willst den Gedanken in drei Sekunden loswerden – und sicher sein, dass er nicht verloren geht.
|
||||
|
||||
**MindSift trennt das Erfassen radikal vom Organisieren:**
|
||||
|
||||
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. **Sofort abladen (Quick Dump):** Oben in der Eingabeleiste tippen, `Enter` drücken, fertig. Dein Kopf ist wieder frei. Alternativ kannst du einen Eintrag schon beim Schreiben direkt einer Aufgabenliste, Einkaufsliste oder Notiz zuweisen.
|
||||
2. **Spielerisch sichten (Triage):** Wenn du später Ruhe hast, sichtest du deinen Gedanken-Stapel Karte für Karte per Tastatur oder Swipe. Nach links verwerfen, auf „Später“ schieben oder mit einem Tastendruck in eine Aufgabe, ein Dokument oder einen Listeneintrag verwandeln.
|
||||
3. **Strukturiert vertiefen:** Aus spontanen Notizen werden hierarchische Outliner-Dokumente, vernetzte Notizbäume, Kanban-Boards oder geteilte Listen für Familie und Team.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Was MindSift auszeichnet
|
||||
|
||||
### ⚡ 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.
|
||||
### ⚡ Quick-Dump & Triage-Deck (Das 3-Sekunden-Prinzip)
|
||||
* **Blitz-Erfassung:** Die Schnelleingabeleiste sitzt prominent ganz oben im Header – jederzeit erreichbar per Klick oder Tastendruck (`C` oder `/`).
|
||||
* **Entscheiden mit 1 Taste:** Im Triage-Deck steht immer genau ein Gedanke im Fokus:
|
||||
* `H` oder `←`: Wegwerfen / Erledigt
|
||||
* `L` oder `→`: Sofort zur Aufgabe machen
|
||||
* `T`: Termin-Aufgabe mit Fälligkeitsdatum
|
||||
* `D`: In Workspace-Notiz umwandeln
|
||||
* `Leertaste`: Später (ans Ende des Stapels schieben)
|
||||
|
||||
### 📝 Workspace: Der hierarchische Logseq-Outliner
|
||||
* **Jeder Gedanke ein Bullet-Point:** Schreibe Notizen als flexible, hierarchische Gliederung:
|
||||
* `Enter`: Neuer Bullet-Point auf derselben Ebene
|
||||
* `Tab` / `Shift + Tab`: Unterordnen (einrücken) und ausrücken
|
||||
* `Shift + Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks
|
||||
* **Zoom-in Hoisting:** Klicke auf den Bullet-Punkt (`•`), um tief in einen Ast hineinzuzoomen (Fokus-Modus). Über die dezenten Breadcrumbs oben springst du jederzeit wieder zurück.
|
||||
* **Slash-Befehle (`/`):** Tippe einfach `/` für `/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/table`, `/callout` oder `/progress`.
|
||||
* **Datei-Explorer mit Drag & Drop:** Verschiebe Dokumente mit der Maus direkt per Drag & Drop in Ordner oder zurück ins Hauptverzeichnis.
|
||||
* **Angepinnte Favoriten:** Hefte wichtige Notizen mit einem Klick an – sie erscheinen prominent ganz oben im Explorer.
|
||||
* **100 % Standard-Markdown auf der Festplatte:** Alle Notizen werden unter `/data/workspace/<user_id>/...` als echte `.md`-Dateien gespeichert (mit 2-Leerzeichen-Einrückung). Vollständig kompatibel mit Obsidian, Logseq oder einfachen Text-Editoren – kein Vendor-Lock-in!
|
||||
|
||||
### 📋 Globale To-Dos: Das automatische Aufgaben-Rollup
|
||||
* **Alles auf einen Blick:** MindSift durchsucht all deine Notizen und sammelt sämtliche Checkboxen (`- [ ]`) in einem zentralen Rollup-Dashboard.
|
||||
* **Ordner-Drilldown:** Isoliere einzelne Projekt-Ordner mit einem Klick.
|
||||
* **Echtzeit-2-Wege-Sync:** Hakst du eine Aufgabe im Rollup ab, wird sie im selben Augenblick direkt in der physischen `.md`-Datei auf deiner Festplatte als `- [x]` aktualisiert!
|
||||
|
||||
### ✅ 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.
|
||||
* **Mehrere Listen:** Trenne Berufliches, Privates, Homelab oder Team-Projekte sauber in eigenen Listen.
|
||||
* **Liste oder Kanban:** Wechsle mit einem Klick zwischen kompakter Listenansicht und dem **3-Spalten-Kanban-Board** (*Zu erledigen*, *In Arbeit*, *Erledigt*).
|
||||
* **Teilen & Avatar-Stack:** Teile Aufgabenlisten mit Spaces und Teams. Der integrierte Avatar-Stack zeigt mit kleinen Initialen-Bubbles und Tooltips sofort, wer Zugriff hat.
|
||||
|
||||
### 📝 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.
|
||||
### 🛒 Geteilte Echtzeit-Listen (Einkauf & Checklisten)
|
||||
* **Gemeinsam abhaken:** Ob Wocheneinkauf oder Urlaubs-Packliste – teile Listen in Echtzeit über WebSocket.
|
||||
* **Live-Synchronisation:** Wird ein Artikel im Laden abgehakt, verschwindet er sofort bei allen anderen Teilnehmern.
|
||||
|
||||
### 🛒 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.
|
||||
### 🏢 Organisationen, Teams & Administration
|
||||
* **Geführte Ersteinrichtung:** Beim allerersten Start führt dich MindSift automatisch durch die Einrichtung deines Administrator-Kontos.
|
||||
* **3-Stufen-Rollensystem:** Server-Admin, Team-Admin und reguläres Mitglied.
|
||||
* **Einladungscodes & Registrierungs-Schalter:** Schalte die öffentliche Registrierung bequem per Schiebeschalter ab und lade neue Mitglieder gezielt per Einladungscode ein.
|
||||
* **Server-Isolation:** Bestimme zentral, ob Nutzer strikt privat für sich arbeiten, sich nur innerhalb ihrer Teams sehen oder serverweit kooperieren.
|
||||
|
||||
### 🏢 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*).
|
||||
### 🔔 Multi-Channel-Alarme
|
||||
* **Pünktlich erinnert:** Automatische Benachrichtigung vor Fälligkeit wahlweise per **E-Mail (SMTP)**, **Discord-Webhook** oder **eigenem Webhook-Endpunkt**.
|
||||
* **Eigene API-Schlüssel:** Sende System-Alarme, Server-Logs oder Skripte per einfachem `curl`-Befehl direkt in deine Inbox.
|
||||
|
||||
### 🔔 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.
|
||||
|
||||
### 🖤 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.
|
||||
### 🎨 Single-File CSS Theming & Hilfe
|
||||
* **1 Datei = 1 Theme:** Vollständiges Design-System gesteuert über genau eine CSS-Datei (`dark`, `light`, `high-contrast`, `soft`).
|
||||
* **Integrierte Hilfeseite:** Ein Klick auf das Profil-Menü oder den Hilfe-Button öffnet eine strukturierte Dokumentation inklusive Tastatur-Cheat-Sheet.
|
||||
|
||||
---
|
||||
|
||||
## 👥 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 leichtgewichtiger, einzelner Container und bringt Webserver, API, Datenbank und Frontend direkt mit.
|
||||
|
||||
### Option 1: Der 1-Zeiler (Empfohlen)
|
||||
|
||||
Führe diesen Befehl im Terminal deines Servers aus. Er erstellt das Verzeichnis, lädt die Konfiguration, erzeugt ein sicheres JWT-Geheimnis und startet den Container:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 In 60 Sekunden starten
|
||||
### Option 2: Schneller Start mit zwei `curl`-Befehlen
|
||||
|
||||
MindSift läuft als einzelner, ressourcenschonender Container und bringt alles mit, was du brauchst.
|
||||
Falls du die Dateien direkt ohne Skript herunterladen möchtest:
|
||||
|
||||
### Start mit Docker Compose
|
||||
```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
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Lege eine `docker-compose.yml` an:
|
||||
---
|
||||
|
||||
### Option 3: Manuelle `docker-compose.yml`
|
||||
|
||||
Erstelle eine Datei namens `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
@@ -93,10 +125,9 @@ services:
|
||||
environment:
|
||||
- PORT=8080
|
||||
- DATA_DIR=/data
|
||||
- JWT_SECRET=bitte-hier-ein-langes-sicheres-geheimnis-eintragen
|
||||
- JWT_SECRET=hier-ein-langes-sicheres-zufalls-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)
|
||||
# Optional: Mailserver für E-Mail-Alarme (auch im Admin-UI einstellbar)
|
||||
- SMTP_HOST=
|
||||
- SMTP_PORT=587
|
||||
- SMTP_USER=
|
||||
@@ -105,20 +136,34 @@ services:
|
||||
- SMTP_TLS=true
|
||||
```
|
||||
|
||||
Container starten:
|
||||
Starte den Container:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Öffne anschließend **`http://localhost:8080`** im Browser. Beim allerersten Aufruf führt dich MindSift automatisch durch die **Ersteinrichtung** für dein Administrator-Konto.
|
||||
---
|
||||
|
||||
### 🌐 Ersteinrichtung aufrufen
|
||||
|
||||
Öffne deinen Browser unter **`http://localhost:8080`** (bzw. die IP deines Servers).
|
||||
MindSift erkennt den Erststart automatisch und führt dich durch den Einrichtungs-Assistenten zum Erstellen des Administrator-Kontos.
|
||||
|
||||
---
|
||||
|
||||
## 👥 Für wen ist MindSift gemacht?
|
||||
|
||||
* **Für Macher & Wissensarbeiter**, die tagsüber dutzende Gedanken, Links und Aufgaben festhalten müssen, ohne aus dem Arbeitsfluss gerissen zu werden.
|
||||
* **Für Outliner-Fans**, die die Flexibilität von Logseq schätzen, aber ihre Notizen in einer klaren Ordnerstruktur auf dem eigenen Server haben wollen.
|
||||
* **Für Familien & WGs**, die gemeinsame Einkaufs- und Packlisten ohne Werbe-Apps und fremde Clouds synchronisieren möchten.
|
||||
* **Für Homelab- & Self-Hosting-Begeisterte**, die volle Datenhoheit, saubere Markdown-Dateien und automatisierte Webhooks auf eigener Hardware schätzen.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Deine Daten gehören dir
|
||||
|
||||
* **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.
|
||||
* **100 % Self-Hosted:** Keine fremden Cloud-Dienste, keine Telemetrie, kein Tracking.
|
||||
* **Kein Vendor-Lock-in:** Alle Notizen liegen als lesbare `.md`-Dateien im Ordner `./data/workspace`. Backups können jederzeit als JSON exportiert werden.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -34,6 +34,15 @@ func main() {
|
||||
// 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 {
|
||||
log.Printf("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
|
||||
if err := store.EnsureDemoAccounts(); err != nil {
|
||||
log.Printf("⚠️ Fehler beim Anlegen der Demo-Accounts: %v", err)
|
||||
} else {
|
||||
log.Printf("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
|
||||
}
|
||||
}
|
||||
|
||||
wsHub := ws.NewHub()
|
||||
|
||||
staticFS, err := fs.Sub(embeddedDist, "dist")
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
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}
|
||||
# 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}
|
||||
+2
-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,8 @@ services:
|
||||
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
# 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}
|
||||
|
||||
+109
-36
@@ -11,9 +11,17 @@ import {
|
||||
ChevronDown,
|
||||
RotateCcw,
|
||||
Palette,
|
||||
HelpCircle,
|
||||
} from 'lucide-react';
|
||||
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
|
||||
import { api, clearToken, getToken, triggerHaptic } from './lib/api';
|
||||
import {
|
||||
api,
|
||||
clearToken,
|
||||
getToken,
|
||||
triggerHaptic,
|
||||
connectWebSocket,
|
||||
disconnectWebSocket,
|
||||
} from './lib/api';
|
||||
import {
|
||||
AVAILABLE_THEMES,
|
||||
initThemeOnBoot,
|
||||
@@ -28,6 +36,7 @@ import { SharedListsView } from './features/lists/SharedListsView';
|
||||
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
||||
import { UserSettingsView } from './features/settings/UserSettingsView';
|
||||
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
||||
import { HelpView } from './features/help/HelpView';
|
||||
|
||||
type ActiveTab =
|
||||
| 'triage'
|
||||
@@ -35,7 +44,8 @@ type ActiveTab =
|
||||
| 'workspace'
|
||||
| 'lists'
|
||||
| 'user_settings'
|
||||
| 'admin_settings';
|
||||
| 'admin_settings'
|
||||
| 'help';
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
@@ -92,12 +102,14 @@ export const App: React.FC = () => {
|
||||
tasksOpenCount: 0,
|
||||
tasksDoneCount: 0,
|
||||
docsCount: 0,
|
||||
docsTodosOpenCount: 0,
|
||||
listsCount: 0,
|
||||
discardedCount: 0,
|
||||
});
|
||||
const [deck, setDeck] = useState<Thought[]>([]);
|
||||
const [lists, setLists] = useState<SharedList[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
|
||||
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
|
||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
||||
const [captureFocusCount, setCaptureFocusCount] = useState(0);
|
||||
const [refreshRevision, setRefreshRevision] = useState(0);
|
||||
@@ -106,6 +118,13 @@ export const App: React.FC = () => {
|
||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleOpenHelp = () => {
|
||||
triggerHaptic(10);
|
||||
setPreviousTab((prev) => (activeTab === 'help' ? prev : activeTab));
|
||||
setActiveTab('help');
|
||||
setShowProfileMenu(false);
|
||||
};
|
||||
|
||||
const handleChangeTheme = (themeId: ThemeId) => {
|
||||
triggerHaptic(10);
|
||||
applyTheme(themeId);
|
||||
@@ -182,6 +201,35 @@ export const App: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// WebSocket Connection Management
|
||||
useEffect(() => {
|
||||
if (user && getToken()) {
|
||||
connectWebSocket();
|
||||
} else {
|
||||
disconnectWebSocket();
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
// Refresh stats on task/doc real-time events
|
||||
useEffect(() => {
|
||||
const handleWs = (e: any) => {
|
||||
const msg = e.detail;
|
||||
if (!msg) return;
|
||||
if (
|
||||
typeof msg.event === 'string' &&
|
||||
(msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_') || msg.event.startsWith('SPACE_'))
|
||||
) {
|
||||
api.getMe().then((res) => {
|
||||
setStats(res.stats);
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
window.addEventListener('mindsift:ws', handleWs);
|
||||
return () => {
|
||||
window.removeEventListener('mindsift:ws', handleWs);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Close profile dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
@@ -205,7 +253,10 @@ export const App: React.FC = () => {
|
||||
target: 'inbox' | 'task' | 'list_item' | 'document' = 'inbox'
|
||||
) => {
|
||||
if (target === 'inbox' && thought.status === 'inbox') {
|
||||
setDeck((prev) => [thought, ...prev]);
|
||||
setDeck((prev) => {
|
||||
if (prev.some((t) => t.id === thought.id)) return prev;
|
||||
return [thought, ...prev];
|
||||
});
|
||||
setStats((prev) => ({ ...prev, inboxCount: prev.inboxCount + 1 }));
|
||||
} else if (target === 'document' && thought.convertedRefId) {
|
||||
setSelectedDocId(thought.convertedRefId);
|
||||
@@ -228,6 +279,7 @@ export const App: React.FC = () => {
|
||||
|
||||
const handleLogout = async () => {
|
||||
triggerHaptic(20);
|
||||
disconnectWebSocket();
|
||||
clearToken();
|
||||
setUser(null);
|
||||
setShowProfileMenu(false);
|
||||
@@ -270,14 +322,14 @@ export const App: React.FC = () => {
|
||||
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30">
|
||||
{/* Top Header Bar */}
|
||||
<header className="sticky top-0 z-30 bg-panel border-b border-border shadow-sm">
|
||||
<div className="max-w-6xl mx-auto px-3 sm:px-4 py-2 flex items-center justify-between gap-3 sm:gap-6">
|
||||
<div className="max-w-6xl mx-auto px-3 sm:px-4 py-2 flex items-start justify-between gap-3 sm:gap-6">
|
||||
{/* Logo & Headline */}
|
||||
<div
|
||||
onClick={() => {
|
||||
setActiveTab('triage');
|
||||
handleTriggerAddButton();
|
||||
}}
|
||||
className="flex items-center gap-2.5 cursor-pointer group select-none flex-shrink-0"
|
||||
className="h-9 flex items-center gap-2.5 cursor-pointer group select-none flex-shrink-0"
|
||||
>
|
||||
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
|
||||
<Brain className="w-4 h-4" />
|
||||
@@ -302,22 +354,22 @@ export const App: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* User Profile Pill & Dropdown Menu */}
|
||||
<div className="relative flex items-center gap-2 flex-shrink-0" ref={dropdownRef}>
|
||||
<div className="relative h-9 flex items-center gap-2 flex-shrink-0" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowProfileMenu((prev) => !prev);
|
||||
}}
|
||||
className="h-8 pl-1 pr-2.5 capture-pill bg-panel hover:bg-panel-elevated border border-border text-xs font-medium text-main flex items-center gap-2 transition-colors"
|
||||
className="h-8 flex items-center gap-2 text-xs font-medium text-main hover:opacity-85 transition-opacity cursor-pointer group"
|
||||
title="Benutzermenü öffnen"
|
||||
>
|
||||
<div className="w-6 h-6 capture-pill bg-accent text-on-accent text-[10px] font-bold flex items-center justify-center">
|
||||
<div className="w-7 h-7 rounded-full bg-accent text-on-accent text-[11px] font-bold flex items-center justify-center flex-shrink-0">
|
||||
{user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
|
||||
</div>
|
||||
<span className="max-w-[120px] truncate hidden sm:inline">
|
||||
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
||||
{user.displayName}
|
||||
</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted" />
|
||||
<ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" />
|
||||
</button>
|
||||
|
||||
{showProfileMenu && (
|
||||
@@ -375,6 +427,16 @@ export const App: React.FC = () => {
|
||||
<span>Kontoeinstellungen</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleOpenHelp}
|
||||
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
|
||||
activeTab === 'help' ? 'text-accent font-semibold' : 'text-main'
|
||||
}`}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4 text-accent" />
|
||||
<span>Hilfe & Anleitung</span>
|
||||
</button>
|
||||
|
||||
{isStaff && (
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -410,8 +472,8 @@ export const App: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Navigation tabs attached to header */}
|
||||
<nav className="max-w-6xl mx-auto px-1 sm:px-2 overflow-x-auto no-scrollbar border-t border-border">
|
||||
<div className="flex items-stretch">
|
||||
<nav className="max-w-6xl mx-auto px-1 sm:px-2 border-t border-border flex items-stretch justify-between">
|
||||
<div className="flex items-stretch overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
@@ -420,18 +482,14 @@ export const App: React.FC = () => {
|
||||
className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`}
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-2' : ''}`}>
|
||||
<span>Triage</span>
|
||||
{stats.inboxCount > 0 && (
|
||||
<span
|
||||
className={
|
||||
activeTab === 'triage'
|
||||
? 'badge-accent'
|
||||
: 'badge-neutral'
|
||||
}
|
||||
>
|
||||
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
|
||||
{stats.inboxCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -442,18 +500,14 @@ export const App: React.FC = () => {
|
||||
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
|
||||
>
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
|
||||
<span>Aufgaben</span>
|
||||
{stats.tasksOpenCount > 0 && (
|
||||
<span
|
||||
className={
|
||||
activeTab === 'tasks'
|
||||
? 'badge-accent'
|
||||
: 'badge-neutral'
|
||||
}
|
||||
>
|
||||
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
|
||||
{stats.tasksOpenCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -464,15 +518,13 @@ export const App: React.FC = () => {
|
||||
className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`}
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${(stats.docsTodosOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
|
||||
<span>Workspace</span>
|
||||
<span
|
||||
className={
|
||||
activeTab === 'workspace'
|
||||
? 'badge-accent'
|
||||
: 'badge-neutral'
|
||||
}
|
||||
>
|
||||
{stats.docsCount}
|
||||
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
|
||||
{stats.docsTodosOpenCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -484,7 +536,23 @@ export const App: React.FC = () => {
|
||||
className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`}
|
||||
>
|
||||
<ShoppingCart className="w-4 h-4" />
|
||||
<span>Geteilte Listen</span>
|
||||
<span>Listen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Borderless Circled Question Mark Help Button in Tab Bar */}
|
||||
<div className="flex items-center pr-2 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenHelp}
|
||||
className={`p-1.5 transition-colors cursor-pointer rounded-full ${
|
||||
activeTab === 'help'
|
||||
? 'text-accent'
|
||||
: 'text-muted hover:text-main'
|
||||
}`}
|
||||
title="Hilfe & Dokumentation"
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -514,13 +582,18 @@ export const App: React.FC = () => {
|
||||
|
||||
{activeTab === 'workspace' && (
|
||||
<WorkspaceView
|
||||
currentUser={user}
|
||||
initialDocId={selectedDocId}
|
||||
onStatsChange={refreshAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'help' && (
|
||||
<HelpView onBack={() => setActiveTab(previousTab || 'triage')} />
|
||||
)}
|
||||
|
||||
{activeTab === 'lists' && (
|
||||
<SharedListsView onRefreshGlobal={refreshAll} />
|
||||
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
|
||||
)}
|
||||
|
||||
{activeTab === 'user_settings' && (
|
||||
|
||||
@@ -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,6 +178,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-muted mb-1">Administrator-Passwort *</label>
|
||||
<input
|
||||
@@ -182,6 +191,28 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
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>
|
||||
|
||||
{/* Section 2: Server & Isolation */}
|
||||
@@ -399,13 +430,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"
|
||||
|
||||
@@ -37,11 +37,29 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
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 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();
|
||||
}, [autoFocusTrigger]);
|
||||
@@ -77,6 +95,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();
|
||||
}
|
||||
@@ -154,12 +173,16 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
|
||||
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
|
||||
setContent('');
|
||||
setIsMultiline(false);
|
||||
setPriority('normal');
|
||||
setDueDate('');
|
||||
setTarget('inbox');
|
||||
setShowOptions(false);
|
||||
setIsSubmitting(true);
|
||||
triggerHaptic(15);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = 'auto';
|
||||
}
|
||||
|
||||
const savedMsg =
|
||||
effectiveTarget === 'task'
|
||||
@@ -210,25 +233,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();
|
||||
}
|
||||
@@ -237,24 +271,29 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
isListening
|
||||
? 'Ich höre zu…'
|
||||
: 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">
|
||||
<div
|
||||
className={`flex items-center gap-1 ml-2 flex-shrink-0 ${
|
||||
isMultiline ? 'self-end mb-0.5' : ''
|
||||
}`}
|
||||
>
|
||||
{/* 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 ${
|
||||
className={`h-6 px-2.5 capture-pill rounded-full text-[11px] flex items-center gap-1 transition-colors ${
|
||||
showOptions || target !== 'inbox' || priority !== 'normal' || dueDate
|
||||
? 'bg-accent-dim text-accent border border-accent-border font-medium'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated border border-transparent'
|
||||
@@ -281,7 +320,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
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 +333,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'
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
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> = ({ onBack }) => {
|
||||
const [activeCategory, setActiveCategory] = useState<HelpCategory>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto space-y-4 pb-12">
|
||||
{/* Header Bar */}
|
||||
<div className="panel-surface p-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="btn-action text-xs flex items-center gap-1.5"
|
||||
title="Zurück zur vorherigen Ansicht"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Zurück</span>
|
||||
</button>
|
||||
|
||||
<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>
|
||||
</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">
|
||||
<Share2 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>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
CheckCircle2,
|
||||
@@ -10,17 +10,62 @@ import {
|
||||
Radio,
|
||||
Sparkles,
|
||||
UserPlus,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { ListItem, SharedList, Space, WSMessage } from '../../lib/types';
|
||||
import { ListItem, SharedList, Space, User, WSMessage } from '../../lib/types';
|
||||
import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface SharedListsViewProps {
|
||||
onRefreshGlobal: () => void;
|
||||
currentUser?: User | null;
|
||||
}
|
||||
|
||||
const CATEGORIES = ['Obst & Gemüse', 'Kühlregal', 'Vorrat & Konserven', 'Backwaren', 'Getränke', 'Drogerie', 'Sonstiges'];
|
||||
export function parseListItemInput(rawText: string, defaultQty?: string): {
|
||||
content: string;
|
||||
quantity: string;
|
||||
category?: string;
|
||||
} {
|
||||
let text = rawText.trim();
|
||||
const hashtagRegex = /#([a-zA-Z0-9äöüÄÖÜß_-]+)/g;
|
||||
const matches = Array.from(text.matchAll(hashtagRegex));
|
||||
|
||||
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal }) => {
|
||||
let category: string | undefined = undefined;
|
||||
if (matches.length > 0) {
|
||||
category = matches[0][1];
|
||||
text = text.replace(hashtagRegex, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
if (!text && matches.length > 0) {
|
||||
text = matches[0][1];
|
||||
}
|
||||
|
||||
return {
|
||||
content: text,
|
||||
quantity: defaultQty?.trim() || '',
|
||||
category,
|
||||
};
|
||||
}
|
||||
|
||||
export function getItemCategory(item: ListItem): string {
|
||||
if (item.category && item.category.trim()) {
|
||||
return item.category.trim().replace(/^#/, '');
|
||||
}
|
||||
const match = item.content.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/);
|
||||
if (match) {
|
||||
return match[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function getItemDisplayContent(item: ListItem): string {
|
||||
if (item.content.includes('#')) {
|
||||
const cleaned = item.content.replace(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (cleaned) return cleaned;
|
||||
}
|
||||
return item.content;
|
||||
}
|
||||
|
||||
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => {
|
||||
const [spaces, setSpaces] = useState<Space[]>([]);
|
||||
const [activeSpace, setActiveSpace] = useState<Space | null>(null);
|
||||
const [lists, setLists] = useState<SharedList[]>([]);
|
||||
@@ -28,10 +73,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
|
||||
const [newItemText, setNewItemText] = useState('');
|
||||
const [newItemQty, setNewItemQty] = useState('');
|
||||
const [newItemCategory, setNewItemCategory] = useState('');
|
||||
|
||||
const [wsConnected, setWsConnected] = useState(false);
|
||||
const [onlineUsers, setOnlineUsers] = useState(1);
|
||||
const [copiedInvite, setCopiedInvite] = useState(false);
|
||||
|
||||
const [showNewListModal, setShowNewListModal] = useState(false);
|
||||
@@ -43,13 +86,25 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
const [showNewSpaceModal, setShowNewSpaceModal] = useState(false);
|
||||
const [newSpaceName, setNewSpaceName] = useState('');
|
||||
|
||||
const [showMembersModal, setShowMembersModal] = useState(false);
|
||||
const [collaborators, setCollaborators] = useState<User[]>([]);
|
||||
const [inviteTarget, setInviteTarget] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor');
|
||||
const [invitingLoading, setInvitingLoading] = useState(false);
|
||||
const [inviteError, setInviteError] = useState<string | null>(null);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const loadSpaces = async () => {
|
||||
const sp = await api.getSpaces();
|
||||
setSpaces(sp);
|
||||
if (sp.length > 0 && !activeSpace) {
|
||||
if (sp.length > 0) {
|
||||
if (!activeSpace) {
|
||||
setActiveSpace(sp[0]);
|
||||
} else {
|
||||
const found = sp.find((s) => s.id === activeSpace.id);
|
||||
if (found) setActiveSpace(found);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -90,9 +145,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
ws.onmessage = (evt) => {
|
||||
try {
|
||||
const msg: WSMessage = JSON.parse(evt.data);
|
||||
if (msg.event === 'USER_PRESENCE' && msg.payload?.onlineCount) {
|
||||
setOnlineUsers(msg.payload.onlineCount);
|
||||
} else if (msg.event === 'ITEM_CREATED' && msg.list_id) {
|
||||
if (msg.event === 'ITEM_CREATED' && msg.list_id) {
|
||||
const newItem: ListItem = msg.payload;
|
||||
setLists((prev) =>
|
||||
prev.map((l) => {
|
||||
@@ -125,6 +178,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
setLists((prev) => prev.map((l) => (l.id === msg.list_id ? updatedList : l)));
|
||||
} else if (msg.event === 'LIST_CREATED' || msg.event === 'LIST_DELETED') {
|
||||
loadListsForSpace(activeSpace.id);
|
||||
} else if (msg.event === 'MEMBER_ADDED' || msg.event === 'MEMBER_REMOVED') {
|
||||
loadSpaces();
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed frames
|
||||
@@ -139,6 +194,23 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
};
|
||||
}, [activeSpace?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleGlobalWs = (e: any) => {
|
||||
const msg = e.detail;
|
||||
if (!msg) return;
|
||||
if (
|
||||
msg.event === 'SPACE_JOINED' ||
|
||||
msg.event === 'SPACE_LEFT' ||
|
||||
msg.event === 'MEMBER_ADDED' ||
|
||||
msg.event === 'MEMBER_REMOVED'
|
||||
) {
|
||||
loadSpaces();
|
||||
}
|
||||
};
|
||||
window.addEventListener('mindsift:ws', handleGlobalWs);
|
||||
return () => window.removeEventListener('mindsift:ws', handleGlobalWs);
|
||||
}, []);
|
||||
|
||||
const activeList = lists.find((l) => l.id === activeListId) || lists[0] || null;
|
||||
|
||||
const handleAddItem = async (e: React.FormEvent) => {
|
||||
@@ -146,15 +218,12 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
if (!activeList || !newItemText.trim()) return;
|
||||
triggerHaptic(15);
|
||||
|
||||
const text = newItemText.trim();
|
||||
const qty = newItemQty.trim();
|
||||
const cat = newItemCategory || undefined;
|
||||
|
||||
const parsed = parseListItemInput(newItemText, newItemQty);
|
||||
setNewItemText('');
|
||||
setNewItemQty('');
|
||||
|
||||
try {
|
||||
const item = await api.createListItem(activeList.id, text, qty, cat);
|
||||
const item = await api.createListItem(activeList.id, parsed.content, parsed.category, parsed.quantity);
|
||||
setLists((prev) =>
|
||||
prev.map((l) => (l.id === activeList.id ? { ...l, items: [item, ...l.items] } : l))
|
||||
);
|
||||
@@ -245,9 +314,123 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
setTimeout(() => setCopiedInvite(false), 2000);
|
||||
};
|
||||
|
||||
const handleAddMember = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!activeSpace) return;
|
||||
const target = inviteTarget.trim();
|
||||
if (!target) return;
|
||||
setInvitingLoading(true);
|
||||
setInviteError(null);
|
||||
try {
|
||||
const isId = collaborators.some((u) => u.id === target);
|
||||
const newMember = await api.addSpaceMember(activeSpace.id, {
|
||||
userId: isId ? target : undefined,
|
||||
email: !isId ? target : undefined,
|
||||
role: inviteRole,
|
||||
});
|
||||
setActiveSpace((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
members: [...(prev.members || []).filter((m) => m.userId !== newMember.userId), newMember],
|
||||
}
|
||||
: prev
|
||||
);
|
||||
setSpaces((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === activeSpace.id
|
||||
? {
|
||||
...s,
|
||||
members: [...(s.members || []).filter((m) => m.userId !== newMember.userId), newMember],
|
||||
}
|
||||
: s
|
||||
)
|
||||
);
|
||||
setInviteTarget('');
|
||||
onRefreshGlobal();
|
||||
} catch (err: any) {
|
||||
setInviteError(err.message || 'Mitglied konnte nicht eingeladen werden');
|
||||
} finally {
|
||||
setInvitingLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (targetUserId: string) => {
|
||||
if (!activeSpace) return;
|
||||
if (!window.confirm('Dieses Mitglied wirklich aus der Einkaufsliste / dem Bereich entfernen?')) return;
|
||||
setInvitingLoading(true);
|
||||
setInviteError(null);
|
||||
try {
|
||||
await api.removeSpaceMember(activeSpace.id, targetUserId);
|
||||
setActiveSpace((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
members: (prev.members || []).filter((m) => m.userId !== targetUserId),
|
||||
}
|
||||
: prev
|
||||
);
|
||||
setSpaces((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === activeSpace.id
|
||||
? {
|
||||
...s,
|
||||
members: (s.members || []).filter((m) => m.userId !== targetUserId),
|
||||
}
|
||||
: s
|
||||
)
|
||||
);
|
||||
onRefreshGlobal();
|
||||
} catch (err: any) {
|
||||
setInviteError(err.message || 'Mitglied konnte nicht entfernt werden');
|
||||
} finally {
|
||||
setInvitingLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openItems = activeList?.items.filter((i) => !i.isCompleted) || [];
|
||||
const completedItems = activeList?.items.filter((i) => i.isCompleted) || [];
|
||||
|
||||
const groupedOpenItems = useMemo(() => {
|
||||
const groupsMap = new Map<string, ListItem[]>();
|
||||
const uncategorized: ListItem[] = [];
|
||||
|
||||
for (const item of openItems) {
|
||||
const cat = getItemCategory(item);
|
||||
if (!cat) {
|
||||
uncategorized.push(item);
|
||||
} else {
|
||||
const list = groupsMap.get(cat) || [];
|
||||
list.push(item);
|
||||
groupsMap.set(cat, list);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedCategories = Array.from(groupsMap.keys()).sort((a, b) =>
|
||||
a.localeCompare(b, 'de', { sensitivity: 'base' })
|
||||
);
|
||||
|
||||
const result: { category: string; label: string; items: ListItem[] }[] = [];
|
||||
|
||||
for (const cat of sortedCategories) {
|
||||
result.push({
|
||||
category: cat,
|
||||
label: `#${cat}`,
|
||||
items: groupsMap.get(cat)!,
|
||||
});
|
||||
}
|
||||
|
||||
if (uncategorized.length > 0) {
|
||||
result.push({
|
||||
category: '',
|
||||
label: result.length > 0 ? 'Allgemein' : '',
|
||||
items: uncategorized,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [openItems]);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3">
|
||||
{/* Top Space Bar & Realtime Indicator */}
|
||||
@@ -283,31 +466,34 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Realtime Status & Invite Code Pill */}
|
||||
{/* Realtime Status & Invite User Button */}
|
||||
{activeSpace && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`h-8 px-3 text-xs flex items-center gap-1.5 rounded-btn ${
|
||||
className={`h-8 w-8 flex items-center justify-center rounded-btn ${
|
||||
wsConnected
|
||||
? 'badge-ok'
|
||||
: 'badge-neutral'
|
||||
}`}
|
||||
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline'}
|
||||
>
|
||||
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
|
||||
<span>{wsConnected ? `Echtzeit-Sync (${onlineUsers} aktiv)` : 'Offline-Puffer'}</span>
|
||||
<Radio className={`w-4 h-4 ${wsConnected ? 'animate-pulse text-status-ok' : 'text-muted'}`} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={copyInviteCode}
|
||||
title="Einladungscode für Partner oder Familie kopieren"
|
||||
className="btn-action h-8"
|
||||
onClick={() => {
|
||||
setShowMembersModal(true);
|
||||
api.getUsersDirectory().then(setCollaborators).catch(() => {});
|
||||
}}
|
||||
title="User zu dieser Einkaufsliste einladen & verwalten"
|
||||
className="btn-action h-8 flex items-center gap-1.5"
|
||||
>
|
||||
<Users className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Code: {activeSpace.inviteCode}</span>
|
||||
{copiedInvite ? (
|
||||
<Check className="w-3.5 h-3.5 text-status-ok" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 text-muted" />
|
||||
<UserPlus className="w-3.5 h-3.5 text-accent" />
|
||||
<span>User einladen</span>
|
||||
{activeSpace.members && activeSpace.members.length > 1 && (
|
||||
<span className="ml-1 px-1.5 py-0.2 bg-surface-2 text-main text-[10px] font-semibold rounded-full border border-border">
|
||||
{activeSpace.members.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -435,14 +621,14 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
{/* Active Shared List Card */}
|
||||
{activeList ? (
|
||||
<div className="p-3 panel-surface">
|
||||
{/* Quick-Add Pill Form */}
|
||||
{/* Quick-Add Form */}
|
||||
<form onSubmit={handleAddItem} className="space-y-2 mb-3">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newItemText}
|
||||
onChange={(e) => setNewItemText(e.target.value)}
|
||||
placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch)…`}
|
||||
placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch #Kühlregal oder #Kaffee)…`}
|
||||
className="flex-1 h-9 px-3 input-field text-sm"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
@@ -463,28 +649,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Quick-Select Pills */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-xs text-muted mr-1">Kategorie:</span>
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => setNewItemCategory((prev) => (prev === cat ? '' : cat))}
|
||||
className={`px-2.5 py-0.5 text-xs transition-colors border rounded-pill ${
|
||||
newItemCategory === cat
|
||||
? 'bg-accent text-on-accent border-accent font-medium'
|
||||
: 'bg-canvas text-muted hover:text-main border-border'
|
||||
}`}
|
||||
>
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Open Items */}
|
||||
{/* Open Items Grouped by Hashtags / Categories */}
|
||||
<div className="border border-border divide-y divide-border rounded-card overflow-hidden">
|
||||
{openItems.length === 0 ? (
|
||||
<div className="py-6 text-center text-muted text-sm flex flex-col items-center gap-1.5">
|
||||
@@ -492,7 +659,25 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<span>Alles erledigt. Keine offenen Punkte auf dieser Liste.</span>
|
||||
</div>
|
||||
) : (
|
||||
openItems.map((item) => (
|
||||
groupedOpenItems.map((group) => (
|
||||
<div key={group.category || 'uncategorized'} className="divide-y divide-border">
|
||||
{/* Category Group Header (only when categories are in use) */}
|
||||
{group.label && (
|
||||
<div className="px-3 py-1.5 bg-surface-2/70 text-xs font-semibold text-accent flex items-center justify-between">
|
||||
<span className="flex items-center gap-1">
|
||||
<span>{group.label}</span>
|
||||
</span>
|
||||
<span className="text-[10px] text-muted font-normal">
|
||||
{group.items.length} {group.items.length === 1 ? 'Eintrag' : 'Einträge'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group.items.map((item) => {
|
||||
const cat = getItemCategory(item);
|
||||
const displayContent = getItemDisplayContent(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleToggleItem(item)}
|
||||
@@ -501,16 +686,16 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<Circle className="w-4 h-4 text-faint group-hover:text-accent flex-shrink-0 transition-colors" />
|
||||
<span className="text-sm font-normal text-main truncate">
|
||||
{item.content}
|
||||
{displayContent}
|
||||
</span>
|
||||
{item.quantity && (
|
||||
<span className="badge-accent">
|
||||
{item.quantity}
|
||||
</span>
|
||||
)}
|
||||
{item.category && (
|
||||
<span className="hidden sm:inline px-2 py-0.2 badge-neutral text-xs">
|
||||
{item.category}
|
||||
{cat && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium text-accent bg-accent/10 border border-accent/25 rounded-full">
|
||||
#{cat}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -526,6 +711,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -546,7 +734,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
</div>
|
||||
|
||||
<div className="border border-border divide-y divide-border opacity-75 rounded-card overflow-hidden">
|
||||
{completedItems.map((item) => (
|
||||
{completedItems.map((item) => {
|
||||
const cat = getItemCategory(item);
|
||||
const displayContent = getItemDisplayContent(item);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleToggleItem(item)}
|
||||
@@ -555,22 +747,193 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<CheckCircle2 className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<span className="text-sm text-muted line-through truncate">
|
||||
{item.content}
|
||||
{displayContent}
|
||||
</span>
|
||||
{item.quantity && (
|
||||
<span className="text-xs text-faint">({item.quantity})</span>
|
||||
)}
|
||||
{cat && (
|
||||
<span className="text-[11px] px-1.5 py-0.2 badge-neutral text-muted rounded-full">
|
||||
#{cat}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.completedByName && (
|
||||
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Space & Shopping List Members Modal */}
|
||||
{showMembersModal && activeSpace && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs">
|
||||
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between pb-2 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5 text-accent" />
|
||||
<h3 className="text-base font-bold text-main">
|
||||
Einkaufsliste teilen: {activeSpace.name}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowMembersModal(false);
|
||||
setInviteError(null);
|
||||
}}
|
||||
className="btn-icon"
|
||||
title="Schließen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{inviteError && (
|
||||
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
|
||||
{inviteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Invite User Form */}
|
||||
<form onSubmit={handleAddMember} className="space-y-2.5 p-3 bg-panel-elevated border border-border rounded-card">
|
||||
<div className="text-xs font-semibold text-main">User direkt einladen</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<select
|
||||
value={inviteTarget}
|
||||
onChange={(e) => setInviteTarget(e.target.value)}
|
||||
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs flex-1 cursor-pointer"
|
||||
>
|
||||
<option value="" className="bg-panel text-muted">User auswählen oder Name/E-Mail eingeben…</option>
|
||||
{collaborators
|
||||
.filter(
|
||||
(u) =>
|
||||
u.id !== currentUser?.id &&
|
||||
!(activeSpace.members || []).some((m) => m.userId === u.id)
|
||||
)
|
||||
.map((u) => (
|
||||
<option key={u.id} value={u.id} className="bg-panel text-main">
|
||||
{u.displayName || u.username || u.email} ({u.username ? `@${u.username}` : u.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value as 'editor' | 'viewer')}
|
||||
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full sm:w-36 cursor-pointer"
|
||||
>
|
||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Direct Username / Email input if user not selected from list */}
|
||||
{(!inviteTarget || !collaborators.some((u) => u.id === inviteTarget)) && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Oder Benutzername / E-Mail direkt eingeben…"
|
||||
value={inviteTarget}
|
||||
onChange={(e) => setInviteTarget(e.target.value)}
|
||||
className="input-field h-8 px-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs w-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={invitingLoading || !inviteTarget.trim()}
|
||||
className="btn-primary text-xs h-8 px-4 flex items-center gap-1.5"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
<span>{invitingLoading ? 'Wird hinzugefügt…' : 'Einladen'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Current Space Members List */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-muted uppercase tracking-wider">
|
||||
Mitglieder in dieser Liste ({activeSpace.members?.length || 1})
|
||||
</div>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{(activeSpace.members || []).map((m) => {
|
||||
const isOwner = m.role === 'owner' || m.userId === activeSpace.ownerId;
|
||||
const isMe = m.userId === currentUser?.id;
|
||||
const canRemove =
|
||||
(activeSpace.ownerId === currentUser?.id || currentUser?.role === 'admin') &&
|
||||
!isOwner;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.userId}
|
||||
className="p-2.5 bg-canvas border border-border rounded-btn flex items-center justify-between gap-3 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="w-7 h-7 rounded-full bg-surface-2 flex items-center justify-center text-xs font-bold text-accent shrink-0">
|
||||
{(m.displayName || m.email || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
<div className="truncate">
|
||||
<div className="font-medium text-main truncate">
|
||||
{m.displayName || m.email} {isMe && <span className="text-muted">(Du)</span>}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted truncate">{m.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded font-semibold ${
|
||||
isOwner
|
||||
? 'bg-accent/15 text-accent'
|
||||
: 'bg-surface-2 text-muted'
|
||||
}`}
|
||||
>
|
||||
{isOwner ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Leser'}
|
||||
</span>
|
||||
{canRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveMember(m.userId)}
|
||||
disabled={invitingLoading}
|
||||
className="btn-icon text-muted hover:text-status-danger p-1"
|
||||
title="Mitglied entfernen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invite Code Alternative */}
|
||||
<div className="pt-2 border-t border-border flex items-center justify-between text-xs text-muted">
|
||||
<span>Alternativ per Code beitreten:</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyInviteCode}
|
||||
className="btn-action h-7 text-xs flex items-center gap-1.5"
|
||||
title="Code kopieren"
|
||||
>
|
||||
<span>Code: {activeSpace.inviteCode}</span>
|
||||
{copiedInvite ? (
|
||||
<Check className="w-3 h-3 text-status-ok" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3 text-muted" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Plus,
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
Layers,
|
||||
Share2,
|
||||
Users,
|
||||
CheckSquare,
|
||||
} from 'lucide-react';
|
||||
import { TaskList, Thought, Space } from '../../lib/types';
|
||||
import { TaskList, Thought, Space, SubTask } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
|
||||
const getInitials = (name?: string): string => {
|
||||
@@ -67,6 +68,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const [activeNotesTaskId, setActiveNotesTaskId] = useState<string | null>(null);
|
||||
const [notesDraft, setNotesDraft] = useState('');
|
||||
|
||||
// Subtask creation state
|
||||
const [addingSubtaskTaskId, setAddingSubtaskTaskId] = useState<string | null>(null);
|
||||
const [subtaskDraft, setSubtaskDraft] = useState('');
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -91,6 +96,80 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
loadData();
|
||||
}, [refreshTrigger, selectedListId]);
|
||||
|
||||
const selectedListIdRef = useRef(selectedListId);
|
||||
selectedListIdRef.current = selectedListId;
|
||||
|
||||
const activeNotesTaskIdRef = useRef(activeNotesTaskId);
|
||||
activeNotesTaskIdRef.current = activeNotesTaskId;
|
||||
|
||||
const onRefreshStatsRef = useRef(onRefreshStats);
|
||||
onRefreshStatsRef.current = onRefreshStats;
|
||||
|
||||
// Live-Sync WebSocket listener for tasks (registered once on mount)
|
||||
useEffect(() => {
|
||||
const handleWs = (e: any) => {
|
||||
const msg = e.detail;
|
||||
if (!msg || !msg.event) return;
|
||||
|
||||
if (msg.event === 'TASK_CREATED') {
|
||||
const created = msg.payload as Thought;
|
||||
if (
|
||||
created &&
|
||||
(created.status === 'converted' ||
|
||||
created.kanbanStatus ||
|
||||
created.taskListId ||
|
||||
created.isCompleted !== undefined)
|
||||
) {
|
||||
setTasks((prev) => {
|
||||
if (prev.some((t) => t.id === created.id)) return prev;
|
||||
if (selectedListIdRef.current !== 'all' && created.taskListId !== selectedListIdRef.current) return prev;
|
||||
return [created, ...prev];
|
||||
});
|
||||
onRefreshStatsRef.current();
|
||||
}
|
||||
} else if (msg.event === 'TASK_UPDATED') {
|
||||
const updated = msg.payload as Thought;
|
||||
if (updated && updated.id) {
|
||||
setTasks((prev) => {
|
||||
const exists = prev.some((t) => t.id === updated.id);
|
||||
if (!exists) {
|
||||
if (
|
||||
(updated.status === 'converted' ||
|
||||
updated.kanbanStatus ||
|
||||
updated.taskListId ||
|
||||
updated.isCompleted !== undefined) &&
|
||||
(selectedListIdRef.current === 'all' || updated.taskListId === selectedListIdRef.current)
|
||||
) {
|
||||
return [updated, ...prev];
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
if (selectedListIdRef.current !== 'all' && updated.taskListId !== selectedListIdRef.current) {
|
||||
return prev.filter((t) => t.id !== updated.id);
|
||||
}
|
||||
return prev.map((t) => {
|
||||
if (t.id !== updated.id) return t;
|
||||
return {
|
||||
...updated,
|
||||
notes: activeNotesTaskIdRef.current === updated.id ? t.notes : updated.notes,
|
||||
};
|
||||
});
|
||||
});
|
||||
onRefreshStatsRef.current();
|
||||
}
|
||||
} else if (msg.event === 'TASK_DELETED') {
|
||||
const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id;
|
||||
if (deletedId) {
|
||||
setTasks((prev) => prev.filter((t) => t.id !== deletedId));
|
||||
onRefreshStatsRef.current();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mindsift:ws', handleWs);
|
||||
return () => window.removeEventListener('mindsift:ws', handleWs);
|
||||
}, []);
|
||||
|
||||
const handleToggleDone = async (task: Thought) => {
|
||||
triggerHaptic(15);
|
||||
const nextState = !task.isCompleted;
|
||||
@@ -125,6 +204,168 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setActiveNotesTaskId(null);
|
||||
};
|
||||
|
||||
const handleAddSubtask = async (task: Thought, title: string) => {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return;
|
||||
triggerHaptic(10);
|
||||
const newSub: SubTask = {
|
||||
id: `sub_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
title: trimmed,
|
||||
isCompleted: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const updatedSubtasks = [...(task.subtasks || []), newSub];
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
|
||||
);
|
||||
setSubtaskDraft('');
|
||||
await api.updateThought(task.id, { subtasks: updatedSubtasks });
|
||||
};
|
||||
|
||||
const handleToggleSubtask = async (task: Thought, subtaskId: string) => {
|
||||
triggerHaptic(10);
|
||||
const updatedSubtasks = (task.subtasks || []).map((s) =>
|
||||
s.id === subtaskId ? { ...s, isCompleted: !s.isCompleted } : s
|
||||
);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
|
||||
);
|
||||
await api.updateThought(task.id, { subtasks: updatedSubtasks });
|
||||
};
|
||||
|
||||
const handleDeleteSubtask = async (task: Thought, subtaskId: string) => {
|
||||
triggerHaptic(10);
|
||||
const updatedSubtasks = (task.subtasks || []).filter((s) => s.id !== subtaskId);
|
||||
setTasks((prev) =>
|
||||
prev.map((t) => (t.id === task.id ? { ...t, subtasks: updatedSubtasks } : t))
|
||||
);
|
||||
await api.updateThought(task.id, { subtasks: updatedSubtasks });
|
||||
};
|
||||
|
||||
const renderTaskProgressBar = (task: Thought) => {
|
||||
if (!task.subtasks || task.subtasks.length === 0) return null;
|
||||
const total = task.subtasks.length;
|
||||
const done = task.subtasks.filter((s) => s.isCompleted).length;
|
||||
const percent = Math.round((done / total) * 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-1 mt-1.5 mb-1 select-none"
|
||||
title={`${done} von ${total} Unteraufgaben erledigt (${percent}%)`}
|
||||
>
|
||||
<div className="flex items-center justify-between text-[10px] text-muted font-medium">
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckSquare className="w-2.5 h-2.5 text-accent" />
|
||||
<span>
|
||||
{done}/{total} Unteraufgaben
|
||||
</span>
|
||||
</span>
|
||||
<span className={percent === 100 ? 'text-status-ok font-bold' : 'text-muted'}>
|
||||
{percent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
percent === 100 ? 'bg-status-ok' : 'bg-accent'
|
||||
}`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSubtaskList = (task: Thought) => {
|
||||
const hasSubtasks = task.subtasks && task.subtasks.length > 0;
|
||||
const isAdding = addingSubtaskTaskId === task.id;
|
||||
|
||||
if (!hasSubtasks && !isAdding) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-1 pt-1 pb-0.5">
|
||||
{hasSubtasks && (
|
||||
<div className="space-y-0.5">
|
||||
{task.subtasks!.map((sub) => (
|
||||
<div
|
||||
key={sub.id}
|
||||
className="flex items-center justify-between group/sub py-0.5 px-1 rounded hover:bg-canvas text-xs transition-colors"
|
||||
>
|
||||
<label className="flex items-center gap-2 cursor-pointer flex-1 min-w-0 select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sub.isCompleted}
|
||||
onChange={() => handleToggleSubtask(task, sub.id)}
|
||||
className="checkbox-square w-3.5 h-3.5 accent-accent cursor-pointer flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
className={`truncate leading-snug ${
|
||||
sub.isCompleted ? 'line-through text-faint' : 'text-main'
|
||||
}`}
|
||||
>
|
||||
{sub.title}
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteSubtask(task, sub.id)}
|
||||
className="opacity-0 group-hover/sub:opacity-100 text-faint hover:text-status-err transition-opacity p-0.5 ml-1"
|
||||
title="Unteraufgabe entfernen"
|
||||
>
|
||||
<Trash2 className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdding && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddSubtask(task, subtaskDraft);
|
||||
}}
|
||||
className="flex items-center gap-1 pt-1"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={subtaskDraft}
|
||||
onChange={(e) => setSubtaskDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
setAddingSubtaskTaskId(null);
|
||||
setSubtaskDraft('');
|
||||
}
|
||||
}}
|
||||
placeholder="Neue Unteraufgabe… (Enter)"
|
||||
autoFocus
|
||||
className="flex-1 h-6 px-1.5 text-xs bg-canvas border border-accent rounded-btn text-main placeholder-faint focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!subtaskDraft.trim()}
|
||||
className="h-6 px-2 bg-accent text-on-accent text-[11px] rounded-btn font-medium disabled:opacity-40 cursor-pointer"
|
||||
title="Hinzufügen"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddingSubtaskTaskId(null);
|
||||
setSubtaskDraft('');
|
||||
}}
|
||||
className="h-6 px-1 text-muted hover:text-main cursor-pointer"
|
||||
title="Abbrechen"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleConvertToDoc = async (task: Thought) => {
|
||||
triggerHaptic(20);
|
||||
const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' });
|
||||
@@ -168,7 +409,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
target: 'task',
|
||||
taskListId: selectedListId === 'all' ? '' : selectedListId,
|
||||
});
|
||||
setTasks((prev) => [created, ...prev]);
|
||||
setTasks((prev) => {
|
||||
if (prev.some((t) => t.id === created.id)) return prev;
|
||||
return [created, ...prev];
|
||||
});
|
||||
onRefreshStats();
|
||||
} catch {
|
||||
loadData();
|
||||
@@ -234,7 +478,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const safeTasks = Array.isArray(tasks) ? tasks : [];
|
||||
const safeTasks = useMemo(() => {
|
||||
if (!Array.isArray(tasks)) return [];
|
||||
const seen = new Set<string>();
|
||||
const unique: Thought[] = [];
|
||||
for (const t of tasks) {
|
||||
if (t && t.id && !seen.has(t.id)) {
|
||||
seen.add(t.id);
|
||||
unique.push(t);
|
||||
}
|
||||
}
|
||||
return unique;
|
||||
}, [tasks]);
|
||||
const safeTaskLists = Array.isArray(taskLists) ? taskLists : [];
|
||||
const safeSpaces = Array.isArray(spaces) ? spaces : [];
|
||||
const safeDiscarded = Array.isArray(discarded) ? discarded : [];
|
||||
@@ -645,7 +900,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
className="panel-surface p-2.5 space-y-2 hover:border-strong transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-xs font-medium text-main break-words flex-1 leading-snug">
|
||||
<p className="text-xs font-medium text-main break-words flex-1 leading-snug whitespace-pre-wrap">
|
||||
{task.content}
|
||||
</p>
|
||||
<button
|
||||
@@ -657,14 +912,25 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subtasks Progress Bar on Title */}
|
||||
{renderTaskProgressBar(task)}
|
||||
|
||||
{/* Notes Preview / Inline Editor */}
|
||||
{activeNotesTaskId === task.id ? (
|
||||
<div className="space-y-1">
|
||||
<textarea
|
||||
value={notesDraft}
|
||||
onChange={(e) => setNotesDraft(e.target.value)}
|
||||
placeholder="Detailnotizen eingeben…"
|
||||
rows={2}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSaveNotes(task);
|
||||
} else if (e.key === 'Escape') {
|
||||
setActiveNotesTaskId(null);
|
||||
}
|
||||
}}
|
||||
placeholder="Detailnotizen eingeben… (Shift+Enter für Zeilenumbruch, Strg+Enter zum Speichern)"
|
||||
rows={3}
|
||||
className="w-full input-field p-1.5 text-xs resize-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-1">
|
||||
@@ -688,13 +954,16 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setActiveNotesTaskId(task.id);
|
||||
setNotesDraft(task.notes || '');
|
||||
}}
|
||||
className="p-1.5 bg-canvas border border-border text-[11px] text-muted break-words cursor-pointer hover:border-strong"
|
||||
className="p-1.5 bg-canvas border border-border text-[11px] text-muted break-words whitespace-pre-wrap leading-relaxed cursor-pointer hover:border-strong"
|
||||
title="Klicken zum Bearbeiten der Notizen"
|
||||
>
|
||||
{task.notes}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Subtasks Checklist & Inline Creator */}
|
||||
{renderSubtaskList(task)}
|
||||
|
||||
{/* Footer & Movement Controls */}
|
||||
<div className="flex items-center justify-between gap-1 pt-1 border-t border-subtle text-[11px]">
|
||||
<div className="flex items-center gap-1.5 text-muted">
|
||||
@@ -714,6 +983,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
+ Notiz
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddingSubtaskTaskId(task.id);
|
||||
setSubtaskDraft('');
|
||||
}}
|
||||
className="text-[10px] text-faint hover:text-main flex items-center gap-0.5 cursor-pointer"
|
||||
title="Unteraufgabe hinzufügen"
|
||||
>
|
||||
<Plus className="w-2.5 h-2.5 text-accent" />
|
||||
<span>+ Unteraufgabe</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -752,7 +1033,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
className="bg-panel border border-accent-border p-2.5 space-y-2 hover:border-accent transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-xs font-medium text-main break-words flex-1 leading-snug">
|
||||
<p className="text-xs font-medium text-main break-words flex-1 leading-snug whitespace-pre-wrap">
|
||||
{task.content}
|
||||
</p>
|
||||
<button
|
||||
@@ -764,14 +1045,25 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subtasks Progress Bar on Title */}
|
||||
{renderTaskProgressBar(task)}
|
||||
|
||||
{/* Notes Preview / Inline Editor */}
|
||||
{activeNotesTaskId === task.id ? (
|
||||
<div className="space-y-1">
|
||||
<textarea
|
||||
value={notesDraft}
|
||||
onChange={(e) => setNotesDraft(e.target.value)}
|
||||
placeholder="Detailnotizen eingeben…"
|
||||
rows={2}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSaveNotes(task);
|
||||
} else if (e.key === 'Escape') {
|
||||
setActiveNotesTaskId(null);
|
||||
}
|
||||
}}
|
||||
placeholder="Detailnotizen eingeben… (Shift+Enter für Zeilenumbruch, Strg+Enter zum Speichern)"
|
||||
rows={3}
|
||||
className="w-full input-field p-1.5 text-xs resize-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-1">
|
||||
@@ -795,14 +1087,44 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setActiveNotesTaskId(task.id);
|
||||
setNotesDraft(task.notes || '');
|
||||
}}
|
||||
className="p-1.5 bg-canvas border border-border text-[11px] text-muted break-words cursor-pointer hover:border-strong"
|
||||
className="p-1.5 bg-canvas border border-border text-[11px] text-muted break-words whitespace-pre-wrap leading-relaxed cursor-pointer hover:border-strong"
|
||||
>
|
||||
{task.notes}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Subtasks Checklist & Inline Creator */}
|
||||
{renderSubtaskList(task)}
|
||||
|
||||
{/* Movement Controls */}
|
||||
<div className="flex items-center justify-between gap-1 pt-1 border-t border-subtle text-[11px]">
|
||||
<div className="flex items-center gap-1.5 text-muted">
|
||||
{!task.notes && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveNotesTaskId(task.id);
|
||||
setNotesDraft('');
|
||||
}}
|
||||
className="text-[10px] text-faint hover:text-main"
|
||||
>
|
||||
+ Notiz
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddingSubtaskTaskId(task.id);
|
||||
setSubtaskDraft('');
|
||||
}}
|
||||
className="text-[10px] text-faint hover:text-main flex items-center gap-0.5 cursor-pointer"
|
||||
title="Unteraufgabe hinzufügen"
|
||||
>
|
||||
<Plus className="w-2.5 h-2.5 text-accent" />
|
||||
<span>+ Unteraufgabe</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => handleKanbanMove(task, 'todo')}
|
||||
className="btn-action h-6 px-1.5 text-[10px] flex items-center gap-1"
|
||||
@@ -822,6 +1144,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{inProgressKanban.length === 0 && (
|
||||
<div className="p-4 text-center text-faint text-xs">Keine aktiven Aufgaben</div>
|
||||
@@ -849,6 +1172,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
{task.content}
|
||||
</p>
|
||||
|
||||
{/* Subtasks Progress Bar & Checklist */}
|
||||
{renderTaskProgressBar(task)}
|
||||
{renderSubtaskList(task)}
|
||||
|
||||
<div className="flex items-center justify-between gap-1 pt-1 border-t border-subtle text-[11px]">
|
||||
<button
|
||||
onClick={() => handleKanbanMove(task, 'todo')}
|
||||
@@ -991,14 +1318,25 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
{task.content}
|
||||
</p>
|
||||
|
||||
{/* Subtasks Progress Bar on Title */}
|
||||
{renderTaskProgressBar(task)}
|
||||
|
||||
{/* Inline Notes or Editor */}
|
||||
{activeNotesTaskId === task.id ? (
|
||||
<div className="space-y-1 pt-1">
|
||||
<textarea
|
||||
value={notesDraft}
|
||||
onChange={(e) => setNotesDraft(e.target.value)}
|
||||
placeholder="Detailnotizen eingeben…"
|
||||
rows={2}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
handleSaveNotes(task);
|
||||
} else if (e.key === 'Escape') {
|
||||
setActiveNotesTaskId(null);
|
||||
}
|
||||
}}
|
||||
placeholder="Detailnotizen eingeben… (Shift+Enter für Zeilenumbruch, Strg+Enter zum Speichern)"
|
||||
rows={3}
|
||||
className="w-full input-field p-2 text-xs resize-none"
|
||||
/>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
@@ -1017,18 +1355,21 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : task.notes ? (
|
||||
<p
|
||||
<div
|
||||
onClick={() => {
|
||||
setActiveNotesTaskId(task.id);
|
||||
setNotesDraft(task.notes || '');
|
||||
}}
|
||||
className="text-xs text-muted bg-canvas p-1.5 border border-border cursor-pointer hover:border-strong"
|
||||
className="text-xs text-muted bg-canvas p-1.5 border border-border break-words whitespace-pre-wrap leading-relaxed cursor-pointer hover:border-strong"
|
||||
title="Klicken zum Bearbeiten"
|
||||
>
|
||||
{task.notes}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Subtasks Checklist & Inline Creator */}
|
||||
{renderSubtaskList(task)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-0.5 empty:hidden">
|
||||
{task.priority === 'urgent' && (
|
||||
<span className="badge-accent">Dringend</span>
|
||||
@@ -1052,6 +1393,19 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
{/* Action Controls */}
|
||||
<div className="flex items-center gap-2 self-end sm:self-center flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setAddingSubtaskTaskId(task.id);
|
||||
setSubtaskDraft('');
|
||||
}}
|
||||
className="btn-action text-xs"
|
||||
title="Unteraufgabe hinzufügen"
|
||||
>
|
||||
<Plus className="w-3 h-3 text-accent" />
|
||||
<span className="hidden md:inline">Unteraufgabe</span>
|
||||
</button>
|
||||
|
||||
{!task.notes && activeNotesTaskId !== task.id && (
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -400,7 +400,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-base sm:text-xl font-medium text-main leading-relaxed break-words">
|
||||
<p className="text-base sm:text-xl font-medium text-main leading-relaxed break-words whitespace-pre-wrap">
|
||||
{activeCard.content}
|
||||
</p>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+62
-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);
|
||||
}
|
||||
|
||||
|
||||
+210
-3
@@ -4,17 +4,24 @@ import {
|
||||
DashboardStats,
|
||||
Document,
|
||||
DocumentBlock,
|
||||
DocShare,
|
||||
DocLockInfo,
|
||||
ListItem,
|
||||
Organization,
|
||||
PriorityLevel,
|
||||
SharedList,
|
||||
Space,
|
||||
SpaceMember,
|
||||
SystemSettings,
|
||||
Team,
|
||||
TaskList,
|
||||
Thought,
|
||||
SubTask,
|
||||
TagDetails,
|
||||
TagSummary,
|
||||
User,
|
||||
UserInvite,
|
||||
WSMessage,
|
||||
} from './types';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
||||
@@ -121,10 +128,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;
|
||||
@@ -309,6 +316,7 @@ export const api = {
|
||||
taskListId?: string;
|
||||
kanbanStatus?: 'todo' | 'in_progress' | 'done';
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
}
|
||||
): Promise<Thought> {
|
||||
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
||||
@@ -362,6 +370,47 @@ export const api = {
|
||||
await request(`/api/v1/task-lists/${id}`, { 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' }
|
||||
);
|
||||
},
|
||||
|
||||
// --- 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),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
},
|
||||
|
||||
// --- WORKSPACE DOCUMENTS ---
|
||||
async getDocuments(): Promise<Document[]> {
|
||||
const res = await request<Document[]>('/api/v1/documents');
|
||||
@@ -426,6 +475,69 @@ export const api = {
|
||||
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- 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; 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);
|
||||
},
|
||||
|
||||
// --- DOCUMENT LOCKING ---
|
||||
async getDocLock(docId: string): Promise<{ locked: boolean; lock?: DocLockInfo }> {
|
||||
return request<{ locked: boolean; lock?: DocLockInfo }>(`/api/v1/documents/${docId}/lock`);
|
||||
},
|
||||
|
||||
async lockDocument(
|
||||
docId: string
|
||||
): Promise<{ locked: boolean; acquired: boolean; lock?: DocLockInfo; message?: string }> {
|
||||
try {
|
||||
return await request<{ locked: boolean; acquired: boolean; lock?: DocLockInfo; message?: string }>(
|
||||
`/api/v1/documents/${docId}/lock`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
} catch (e: any) {
|
||||
return { locked: true, acquired: false, message: e.message };
|
||||
}
|
||||
},
|
||||
|
||||
async heartbeatDocLock(docId: string): Promise<{ extended: boolean }> {
|
||||
try {
|
||||
return await request<{ extended: boolean }>(`/api/v1/documents/${docId}/lock/heartbeat`, {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch {
|
||||
return { extended: false };
|
||||
}
|
||||
},
|
||||
|
||||
async unlockDocument(docId: string): Promise<{ unlocked: boolean }> {
|
||||
try {
|
||||
return await request<{ unlocked: boolean }>(`/api/v1/documents/${docId}/lock`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch {
|
||||
return { unlocked: false };
|
||||
}
|
||||
},
|
||||
|
||||
// --- SPACES & REALTIME LISTS ---
|
||||
async getSpaces(): Promise<Space[]> {
|
||||
const res = await request<Space[]>('/api/v1/spaces');
|
||||
@@ -449,6 +561,19 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
async addSpaceMember(spaceId: string, target: { userId?: string; email?: string; role?: string }): Promise<SpaceMember> {
|
||||
return request<SpaceMember>(`/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) => ({
|
||||
@@ -698,8 +823,90 @@ export function getOfflineQueue(): Thought[] {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWebSocketURL(spaceId: string): string {
|
||||
let activeGlobalWS: WebSocket | null = null;
|
||||
let wsReconnectTimer: any = null;
|
||||
let wsListeners: ((msg: WSMessage) => void)[] = [];
|
||||
let manualDisconnect = false;
|
||||
|
||||
export function buildWebSocketURL(spaceId?: string): string {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = getToken() || '';
|
||||
if (spaceId) {
|
||||
return `${protocol}//${window.location.host}/api/v1/ws/spaces/${encodeURIComponent(spaceId)}?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
return `${protocol}//${window.location.host}/api/v1/ws?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export function connectWebSocket(): void {
|
||||
const token = getToken();
|
||||
if (!token) return;
|
||||
manualDisconnect = false;
|
||||
|
||||
if (activeGlobalWS && (activeGlobalWS.readyState === WebSocket.OPEN || activeGlobalWS.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = buildWebSocketURL();
|
||||
const ws = new WebSocket(url);
|
||||
activeGlobalWS = ws;
|
||||
|
||||
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 = () => {
|
||||
activeGlobalWS = null;
|
||||
if (!manualDisconnect && getToken()) {
|
||||
clearTimeout(wsReconnectTimer);
|
||||
wsReconnectTimer = setTimeout(() => {
|
||||
connectWebSocket();
|
||||
}, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {}
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('Failed to connect WebSocket', e);
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectWebSocket(): void {
|
||||
manualDisconnect = true;
|
||||
clearTimeout(wsReconnectTimer);
|
||||
if (activeGlobalWS) {
|
||||
try {
|
||||
activeGlobalWS.close();
|
||||
} catch {}
|
||||
activeGlobalWS = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function sendWebSocketMessage(msg: any): void {
|
||||
if (activeGlobalWS && activeGlobalWS.readyState === WebSocket.OPEN) {
|
||||
activeGlobalWS.send(typeof msg === 'string' ? msg : JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
export function onWebSocketMessage(callback: (msg: WSMessage) => void): () => void {
|
||||
wsListeners.push(callback);
|
||||
return () => {
|
||||
wsListeners = wsListeners.filter((fn) => fn !== callback);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -6,11 +6,13 @@ export interface AuthStatus {
|
||||
needsSetup: boolean;
|
||||
allowRegistration: boolean;
|
||||
instanceName: string;
|
||||
demoMode?: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
displayName: string;
|
||||
role: UserRole;
|
||||
isActive: boolean;
|
||||
@@ -34,6 +36,13 @@ export interface UserInvite {
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface SubTask {
|
||||
id: string;
|
||||
title: string;
|
||||
isCompleted: boolean;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface Thought {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -49,6 +58,7 @@ export interface Thought {
|
||||
taskListId?: string;
|
||||
kanbanStatus?: 'todo' | 'in_progress' | 'done';
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
alarmSent?: boolean;
|
||||
createdAt: string;
|
||||
triagedAt?: string;
|
||||
@@ -120,6 +130,24 @@ export interface DocumentBlock {
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export interface DocShare {
|
||||
id: string;
|
||||
docId: string;
|
||||
userId: string;
|
||||
userEmail?: string;
|
||||
displayName?: string;
|
||||
permission: 'read' | 'write';
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DocLockInfo {
|
||||
docId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -134,6 +162,40 @@ export interface Document {
|
||||
isArchived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
// Collaboration & Permissions
|
||||
permission?: 'owner' | 'write' | 'read';
|
||||
isOwner?: boolean;
|
||||
ownerName?: string;
|
||||
sharedWith?: DocShare[];
|
||||
}
|
||||
|
||||
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;
|
||||
documents: TagDocumentGroup[];
|
||||
totalMatches: number;
|
||||
}
|
||||
|
||||
export interface SpaceMember {
|
||||
@@ -199,13 +261,14 @@ 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;
|
||||
payload: T;
|
||||
timestamp: string;
|
||||
|
||||
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 "============================================="
|
||||
+327
-4
@@ -61,6 +61,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/api/v1/auth/login", s.handleLogin)
|
||||
r.Post("/api/v1/auth/quickstart", s.handleQuickStart)
|
||||
r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest)
|
||||
r.Get("/api/v1/ws", s.handleWebSocket)
|
||||
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
||||
|
||||
// Authenticated API Routes
|
||||
@@ -71,6 +72,7 @@ func (s *Server) Router() http.Handler {
|
||||
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
|
||||
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
|
||||
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory)
|
||||
r.Get("/api/v1/collaborators", s.handleGetCollaborators)
|
||||
r.Get("/api/v1/stats", s.handleStats)
|
||||
r.Get("/api/v1/search", s.handleSearch)
|
||||
r.Get("/api/v1/export", s.handleExport)
|
||||
@@ -93,17 +95,38 @@ func (s *Server) Router() http.Handler {
|
||||
r.Put("/api/v1/task-lists/{id}", s.handleUpdateTaskList)
|
||||
r.Delete("/api/v1/task-lists/{id}", s.handleDeleteTaskList)
|
||||
|
||||
// Workspace Documents
|
||||
// Workspace Documents & Folders
|
||||
r.Get("/api/v1/folders", s.handleGetFolders)
|
||||
r.Post("/api/v1/folders", s.handleCreateFolder)
|
||||
r.Delete("/api/v1/folders", s.handleDeleteFolder)
|
||||
|
||||
// Workspace Tags & Pages
|
||||
r.Get("/api/v1/workspace/tags", s.handleGetWorkspaceTags)
|
||||
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
|
||||
|
||||
r.Get("/api/v1/documents", s.handleGetDocuments)
|
||||
r.Post("/api/v1/documents", s.handleCreateDocument)
|
||||
r.Get("/api/v1/documents/{id}", s.handleGetDocument)
|
||||
r.Put("/api/v1/documents/{id}", s.handleUpdateDocument)
|
||||
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument)
|
||||
|
||||
// Document Sharing & Collaboration
|
||||
r.Get("/api/v1/documents/{id}/shares", s.handleGetDocShares)
|
||||
r.Post("/api/v1/documents/{id}/shares", s.handleCreateDocShare)
|
||||
r.Delete("/api/v1/documents/{id}/shares/{userId}", s.handleDeleteDocShare)
|
||||
|
||||
// Document Locking
|
||||
r.Get("/api/v1/documents/{id}/lock", s.handleGetDocLock)
|
||||
r.Post("/api/v1/documents/{id}/lock", s.handleLockDoc)
|
||||
r.Post("/api/v1/documents/{id}/lock/heartbeat", s.handleHeartbeatDocLock)
|
||||
r.Delete("/api/v1/documents/{id}/lock", s.handleUnlockDoc)
|
||||
|
||||
// Shared Spaces & Realtime Lists
|
||||
r.Get("/api/v1/spaces", s.handleGetSpaces)
|
||||
r.Post("/api/v1/spaces", s.handleCreateSpace)
|
||||
r.Post("/api/v1/spaces/join", s.handleJoinSpace)
|
||||
r.Post("/api/v1/spaces/{id}/members", s.handleAddSpaceMember)
|
||||
r.Delete("/api/v1/spaces/{id}/members/{userId}", s.handleRemoveSpaceMember)
|
||||
r.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists)
|
||||
r.Post("/api/v1/spaces/{id}/lists", s.handleCreateList)
|
||||
|
||||
@@ -288,6 +311,7 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"needsSetup": count == 0,
|
||||
"allowRegistration": allowReg,
|
||||
"instanceName": instanceName,
|
||||
"demoMode": s.cfg.DemoMode,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -438,6 +462,8 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Login string `json:"login"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -445,9 +471,22 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.store.GetUserByEmail(req.Email)
|
||||
identifier := strings.TrimSpace(req.Login)
|
||||
if identifier == "" {
|
||||
identifier = strings.TrimSpace(req.Username)
|
||||
}
|
||||
if identifier == "" {
|
||||
identifier = strings.TrimSpace(req.Email)
|
||||
}
|
||||
|
||||
if identifier == "" || req.Password == "" {
|
||||
writeError(w, http.StatusBadRequest, "Benutzername/E-Mail und Passwort erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.store.GetUserByLogin(identifier)
|
||||
if err != nil || !auth.CheckPassword(req.Password, user.PasswordHash) {
|
||||
writeError(w, http.StatusUnauthorized, "E-Mail oder Passwort ungültig")
|
||||
writeError(w, http.StatusUnauthorized, "Benutzername/E-Mail oder Passwort ungültig")
|
||||
return
|
||||
}
|
||||
if !user.IsActive {
|
||||
@@ -544,6 +583,7 @@ func (s *Server) handleCaptureInbox(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastTaskEvent(thought.UserID, "TASK_CREATED", thought)
|
||||
writeJSON(w, http.StatusCreated, thought)
|
||||
}
|
||||
|
||||
@@ -618,6 +658,7 @@ func (s *Server) handleTriageAction(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastTaskEvent(updated.UserID, "TASK_UPDATED", updated)
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
@@ -644,17 +685,19 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
TaskListID *string `json:"taskListId"`
|
||||
KanbanStatus *string `json:"kanbanStatus"`
|
||||
Notes *string `json:"notes"`
|
||||
Subtasks *[]domain.SubTask `json:"subtasks"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes)
|
||||
updated, err := s.store.UpdateThoughtDetails(userID, thoughtID, req.Content, req.IsCompleted, req.DueDate, req.Priority, req.TaskListID, req.KanbanStatus, req.Notes, req.Subtasks)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastTaskEvent(updated.UserID, "TASK_UPDATED", updated)
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
@@ -665,6 +708,7 @@ func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastTaskEvent(userID, "TASK_DELETED", map[string]string{"id": thoughtID})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -711,6 +755,7 @@ func (s *Server) handleCreateDocument(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastDocEvent(doc.ID, "DOC_CREATED", doc)
|
||||
writeJSON(w, http.StatusCreated, doc)
|
||||
}
|
||||
|
||||
@@ -728,6 +773,17 @@ func (s *Server) handleGetDocument(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
// Check document lock
|
||||
if lock := s.wsHub.GetDocLock(docID); lock != nil && lock.UserID != userID {
|
||||
w.WriteHeader(http.StatusLocked)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": "Dokument wird aktuell bearbeitet von " + lock.UserName,
|
||||
"currentLock": lock,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title *string `json:"title"`
|
||||
Icon *string `json:"icon"`
|
||||
@@ -746,6 +802,9 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if req.Title != nil || req.Icon != nil || req.FolderPath != nil || req.Blocks != nil || req.Tags != nil {
|
||||
s.wsHub.BroadcastDocEvent(docID, "DOC_UPDATED", doc)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, doc)
|
||||
}
|
||||
|
||||
@@ -756,9 +815,216 @@ func (s *Server) handleDeleteDocument(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
// --- WORKSPACE FOLDERS ---
|
||||
|
||||
func (s *Server) handleGetFolders(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
folders, err := s.store.GetFolders(userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, folders)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateFolder(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
cleanPath, err := s.store.CreateFolder(userID, req.Path)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": cleanPath})
|
||||
writeJSON(w, http.StatusCreated, map[string]string{"path": cleanPath})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteFolder(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
folderPath := r.URL.Query().Get("path")
|
||||
if folderPath == "" {
|
||||
writeError(w, http.StatusBadRequest, "Pfad erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
deletedDocIDs, err := s.store.DeleteFolder(userID, folderPath)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
for _, docID := range deletedDocIDs {
|
||||
s.wsHub.ReleaseDocLock(docID, userID, true)
|
||||
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
|
||||
}
|
||||
s.wsHub.BroadcastDocEvent("", "WORKSPACE_FOLDERS_UPDATED", map[string]string{"path": folderPath})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"deleted": true,
|
||||
"deletedDocIds": deletedDocIDs,
|
||||
})
|
||||
}
|
||||
|
||||
// --- WORKSPACE TAGS & PAGES ---
|
||||
|
||||
func (s *Server) handleGetWorkspaceTags(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
tags, err := s.store.GetWorkspaceTags(userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tags)
|
||||
}
|
||||
|
||||
func (s *Server) handleGetTagDetails(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
tag := chi.URLParam(r, "tag")
|
||||
details, err := s.store.GetTagDetails(userID, tag)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, details)
|
||||
}
|
||||
|
||||
// --- DOCUMENT SHARING & COLLABORATION ---
|
||||
|
||||
func (s *Server) handleGetDocShares(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
shares, err := s.store.GetDocShares(docID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, shares)
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
Permission string `json:"permission"` // read | write
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
target := req.UserID
|
||||
if target == "" {
|
||||
target = req.Email
|
||||
}
|
||||
if target == "" {
|
||||
writeError(w, http.StatusBadRequest, "Benutzer-ID oder E-Mail erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
share, err := s.store.CreateDocShare(userID, docID, target, req.Permission)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastToUser(share.UserID, "DOC_SHARED", share)
|
||||
s.wsHub.BroadcastDocEvent(docID, "DOC_SHARED", share)
|
||||
writeJSON(w, http.StatusCreated, share)
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteDocShare(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
targetUserID := chi.URLParam(r, "userId")
|
||||
if err := s.store.DeleteDocShare(userID, docID, targetUserID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastToUser(targetUserID, "DOC_UNSHARED", map[string]string{"docId": docID})
|
||||
s.wsHub.BroadcastDocEvent(docID, "DOC_UNSHARED", map[string]string{"docId": docID, "userId": targetUserID})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
users, err := s.store.GetCollaborators(userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
// --- DOCUMENT LOCKING ---
|
||||
|
||||
func (s *Server) handleGetDocLock(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
lock := s.wsHub.GetDocLock(docID)
|
||||
if lock == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": false})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": true, "lock": lock})
|
||||
}
|
||||
|
||||
func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
user, err := s.store.GetUserByID(userID)
|
||||
userName := "Ein Benutzer"
|
||||
if err == nil && user != nil && user.DisplayName != "" {
|
||||
userName = user.DisplayName
|
||||
}
|
||||
|
||||
info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 12*time.Second)
|
||||
if !acquired {
|
||||
w.WriteHeader(http.StatusLocked)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"locked": true,
|
||||
"acquired": false,
|
||||
"currentLock": currentLock,
|
||||
"message": "Wird aktuell bearbeitet von " + currentLock.UserName,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastDocEvent(docID, "DOC_LOCKED", info)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"locked": true,
|
||||
"acquired": true,
|
||||
"lock": info,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHeartbeatDocLock(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
ok := s.wsHub.HeartbeatDocLock(docID, userID, 12*time.Second)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"extended": ok})
|
||||
}
|
||||
|
||||
func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
user, _ := s.store.GetUserByID(userID)
|
||||
isAdmin := user != nil && user.Role == "admin"
|
||||
released := s.wsHub.ReleaseDocLock(docID, userID, isAdmin)
|
||||
if released {
|
||||
s.wsHub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"unlocked": released})
|
||||
}
|
||||
|
||||
// --- SPACES & REALTIME LISTS ---
|
||||
|
||||
func (s *Server) handleGetSpaces(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -806,9 +1072,61 @@ func (s *Server) handleJoinSpace(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastToUser(userID, "SPACE_JOINED", map[string]interface{}{"spaceId": sp.ID})
|
||||
s.wsHub.Broadcast(sp.ID, "", "MEMBER_ADDED", map[string]interface{}{"spaceId": sp.ID, "userId": userID})
|
||||
|
||||
writeJSON(w, http.StatusOK, sp)
|
||||
}
|
||||
|
||||
func (s *Server) handleAddSpaceMember(w http.ResponseWriter, r *http.Request) {
|
||||
currentUserID := auth.GetUserID(r.Context())
|
||||
spaceID := chi.URLParam(r, "id")
|
||||
var req struct {
|
||||
UserID string `json:"userId"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
target := req.UserID
|
||||
if target == "" {
|
||||
target = req.Email
|
||||
}
|
||||
if target == "" {
|
||||
writeError(w, http.StatusBadRequest, "Benutzer-ID, Benutzername oder E-Mail erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
member, err := s.store.AddSpaceMember(currentUserID, spaceID, target, req.Role)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastToUser(member.UserID, "SPACE_JOINED", map[string]interface{}{"spaceId": spaceID})
|
||||
s.wsHub.Broadcast(spaceID, "", "MEMBER_ADDED", member)
|
||||
|
||||
writeJSON(w, http.StatusCreated, member)
|
||||
}
|
||||
|
||||
func (s *Server) handleRemoveSpaceMember(w http.ResponseWriter, r *http.Request) {
|
||||
currentUserID := auth.GetUserID(r.Context())
|
||||
spaceID := chi.URLParam(r, "id")
|
||||
targetUserID := chi.URLParam(r, "userId")
|
||||
|
||||
if err := s.store.RemoveSpaceMember(currentUserID, spaceID, targetUserID); err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastToUser(targetUserID, "SPACE_LEFT", map[string]interface{}{"spaceId": spaceID})
|
||||
s.wsHub.Broadcast(spaceID, "", "MEMBER_REMOVED", map[string]interface{}{"spaceId": spaceID, "userId": targetUserID})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSpaceLists(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := chi.URLParam(r, "id")
|
||||
lists, err := s.store.GetListsForSpace(spaceID)
|
||||
@@ -954,6 +1272,11 @@ func (s *Server) handleClearCompletedItems(w http.ResponseWriter, r *http.Reques
|
||||
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := chi.URLParam(r, "spaceId")
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
||||
token = strings.TrimPrefix(authHdr, "Bearer ")
|
||||
}
|
||||
}
|
||||
userID := "anonymous"
|
||||
if token != "" {
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
||||
|
||||
@@ -431,3 +431,283 @@ 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)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
userToken, err := auth.GenerateToken(cfg.JWTSecret, normalUser.ID, normalUser.Email, normalUser.DisplayName)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
// 1. Admin creates a space
|
||||
createSpaceBody := []byte(`{"name":"Einkauf WG","icon":"🛒","description":"Gemeinsame Einkaufsliste"}`)
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/spaces", bytes.NewReader(createSpaceBody))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created from /api/v1/spaces, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var createdSpace domain.Space
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &createdSpace)
|
||||
if createdSpace.ID == "" {
|
||||
t.Fatalf("expected created space with ID, got %+v", createdSpace)
|
||||
}
|
||||
|
||||
// 2. Admin invites normalUser by username "user"
|
||||
addMemberBody := []byte(`{"userId":"user","role":"editor"}`)
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/spaces/"+createdSpace.ID+"/members", bytes.NewReader(addMemberBody))
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 Created from add space member, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var newMember domain.SpaceMember
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &newMember)
|
||||
if newMember.UserID != normalUser.ID {
|
||||
t.Fatalf("expected member userId %s, got %s", normalUser.ID, newMember.UserID)
|
||||
}
|
||||
|
||||
// 3. User checks their spaces; the shared space must be listed
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/spaces", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+userToken)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for user spaces, got %d", rec.Code)
|
||||
}
|
||||
var userSpaces []domain.Space
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &userSpaces)
|
||||
found := false
|
||||
for _, s := range userSpaces {
|
||||
if s.ID == createdSpace.ID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected space %s to be visible to user, got %+v", createdSpace.ID, userSpaces)
|
||||
}
|
||||
|
||||
// 4. Admin removes user from space
|
||||
req = httptest.NewRequest(http.MethodDelete, "/api/v1/spaces/"+createdSpace.ID+"/members/"+normalUser.ID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK for remove member, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ type Config struct {
|
||||
BaseURL string
|
||||
AllowRegistration bool
|
||||
LogLevel string
|
||||
DemoMode bool
|
||||
|
||||
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
|
||||
SMTPHost string
|
||||
@@ -41,6 +42,9 @@ func Load() *Config {
|
||||
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
|
||||
logLevel := getEnv("LOG_LEVEL", "info")
|
||||
|
||||
demoVal := strings.ToLower(getEnv("MINDA_DEMO", getEnv("MINDSIFT_DEMO", getEnv("DEMO_MODE", "false"))))
|
||||
demoMode := demoVal == "true" || demoVal == "1" || demoVal == "yes" || demoVal == "on"
|
||||
|
||||
smtpHost := getEnv("SMTP_HOST", "")
|
||||
smtpPort := getEnv("SMTP_PORT", "587")
|
||||
smtpUser := getEnv("SMTP_USER", "")
|
||||
@@ -56,6 +60,7 @@ func Load() *Config {
|
||||
BaseURL: baseURL,
|
||||
AllowRegistration: allowReg,
|
||||
LogLevel: logLevel,
|
||||
DemoMode: demoMode,
|
||||
SMTPHost: smtpHost,
|
||||
SMTPPort: smtpPort,
|
||||
SMTPUser: smtpUser,
|
||||
|
||||
@@ -40,9 +40,62 @@ 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
|
||||
@@ -77,6 +130,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"`
|
||||
@@ -92,6 +152,7 @@ type Thought struct {
|
||||
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"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
TriagedAt string `json:"triagedAt,omitempty"`
|
||||
@@ -161,6 +222,24 @@ type DocumentBlock struct {
|
||||
Collapsed bool `json:"collapsed,omitempty"`
|
||||
}
|
||||
|
||||
type DocShare struct {
|
||||
ID string `json:"id"`
|
||||
DocID string `json:"docId"`
|
||||
UserID string `json:"userId"`
|
||||
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 DocLockInfo struct {
|
||||
DocID string `json:"docId"`
|
||||
UserID string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
@@ -175,6 +254,40 @@ type Document struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
Documents []TagDocumentGroup `json:"documents"`
|
||||
TotalMatches int `json:"totalMatches"`
|
||||
}
|
||||
|
||||
type SpaceMember struct {
|
||||
@@ -249,6 +362,7 @@ type DashboardStats struct {
|
||||
TasksOpenCount int `json:"tasksOpenCount"`
|
||||
TasksDoneCount int `json:"tasksDoneCount"`
|
||||
DocsCount int `json:"docsCount"`
|
||||
DocsTodosOpenCount int `json:"docsTodosOpenCount"`
|
||||
ListsCount int `json:"listsCount"`
|
||||
DiscardedCount int `json:"discardedCount"`
|
||||
}
|
||||
|
||||
+960
-62
File diff suppressed because it is too large.
Load diff
@@ -1,9 +1,11 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
@@ -64,6 +66,28 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("ToggleListItem failed: %v", err)
|
||||
}
|
||||
|
||||
// Test AddSpaceMember & RemoveSpaceMember
|
||||
u2, err := store.CreateUser("collab_space@example.com", "hash", "Collab User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser u2 failed: %v", err)
|
||||
}
|
||||
member, err := store.AddSpaceMember(user.ID, sp.ID, u2.ID, "editor")
|
||||
if err != nil || member.UserID != u2.ID {
|
||||
t.Fatalf("AddSpaceMember failed: %v", err)
|
||||
}
|
||||
members, err := store.GetSpaceMembers(sp.ID)
|
||||
if err != nil || len(members) != 2 {
|
||||
t.Fatalf("expected 2 members, got %d", len(members))
|
||||
}
|
||||
err = store.RemoveSpaceMember(user.ID, sp.ID, u2.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveSpaceMember failed: %v", err)
|
||||
}
|
||||
membersAfter, _ := store.GetSpaceMembers(sp.ID)
|
||||
if len(membersAfter) != 1 {
|
||||
t.Fatalf("expected 1 member after remove, got %d", len(membersAfter))
|
||||
}
|
||||
|
||||
// 4. Test Webhook API Key generation & validation
|
||||
apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant")
|
||||
if err != nil {
|
||||
@@ -108,4 +132,373 @@ 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)
|
||||
}
|
||||
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"
|
||||
share, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read")
|
||||
if err != nil || share.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")
|
||||
}
|
||||
|
||||
// Upgrade to write permission
|
||||
_, err = store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "write")
|
||||
if err != nil {
|
||||
t.Fatalf("Upgrade CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
// user2 edits writeable doc -> must succeed
|
||||
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateDocument failed for write collaborator: %v", err)
|
||||
}
|
||||
|
||||
// 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")
|
||||
docDir := filepath.Join(tmpDir, "workspace")
|
||||
|
||||
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 on disk
|
||||
f1, err := store.CreateFolder(user.ID, "Projekte/MindSift")
|
||||
if err != nil || f1 != "Projekte/MindSift" {
|
||||
t.Fatalf("CreateFolder failed: %v, f1=%s", err, f1)
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
// Verify disk directory was deleted
|
||||
diskFolder := filepath.Join(docDir, user.ID, "Projekte/MindSift")
|
||||
if _, err := os.Stat(diskFolder); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected directory %s to be deleted on disk", diskFolder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+275
-23
@@ -12,8 +12,8 @@ import (
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
ReadBufferSize: 2048,
|
||||
WriteBufferSize: 2048,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Self-hosted reverse proxy compatible
|
||||
},
|
||||
@@ -27,53 +27,273 @@ type Client struct {
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
type docLockItem struct {
|
||||
docID string
|
||||
userID string
|
||||
userName string
|
||||
lockedAt time.Time
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
rooms map[string]map[*Client]bool
|
||||
spaceRooms map[string]map[*Client]bool
|
||||
userRooms map[string]map[*Client]bool
|
||||
allClients map[*Client]bool
|
||||
locks map[string]*docLockItem
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
rooms: make(map[string]map[*Client]bool),
|
||||
h := &Hub{
|
||||
spaceRooms: make(map[string]map[*Client]bool),
|
||||
userRooms: make(map[string]map[*Client]bool),
|
||||
allClients: make(map[*Client]bool),
|
||||
locks: make(map[string]*docLockItem),
|
||||
}
|
||||
go h.cleanupLocksLoop()
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Hub) Register(c *Client) {
|
||||
h.mu.Lock()
|
||||
if h.rooms[c.spaceID] == nil {
|
||||
h.rooms[c.spaceID] = make(map[*Client]bool)
|
||||
h.allClients[c] = true
|
||||
if c.spaceID != "" {
|
||||
if h.spaceRooms[c.spaceID] == nil {
|
||||
h.spaceRooms[c.spaceID] = make(map[*Client]bool)
|
||||
}
|
||||
h.rooms[c.spaceID][c] = true
|
||||
onlineCount := len(h.rooms[c.spaceID])
|
||||
h.spaceRooms[c.spaceID][c] = true
|
||||
}
|
||||
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)
|
||||
h.mu.Unlock()
|
||||
|
||||
if c.spaceID != "" {
|
||||
h.Broadcast(c.spaceID, "", "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.spaceID != "" {
|
||||
if room, ok := h.spaceRooms[c.spaceID]; ok {
|
||||
delete(room, c)
|
||||
if len(room) == 0 {
|
||||
delete(h.spaceRooms, c.spaceID)
|
||||
}
|
||||
}
|
||||
}
|
||||
userRemaining := 0
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if uRoom, ok := h.userRooms[c.userID]; ok {
|
||||
delete(uRoom, c)
|
||||
userRemaining = len(uRoom)
|
||||
if userRemaining == 0 {
|
||||
delete(h.userRooms, c.userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
close(c.send)
|
||||
|
||||
// If this user has no other active client connections, release their document locks
|
||||
var releasedDocIDs []string
|
||||
if userRemaining == 0 && c.userID != "" {
|
||||
now := time.Now()
|
||||
for docID, l := range h.locks {
|
||||
if l.userID == c.userID || l.expiresAt.Before(now) {
|
||||
releasedDocIDs = append(releasedDocIDs, docID)
|
||||
delete(h.locks, docID)
|
||||
}
|
||||
onlineCount = len(room)
|
||||
if onlineCount == 0 {
|
||||
delete(h.rooms, c.spaceID)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if onlineCount > 0 {
|
||||
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{
|
||||
"onlineCount": onlineCount,
|
||||
})
|
||||
// Broadcast unlock events for released locks
|
||||
for _, docID := range releasedDocIDs {
|
||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
}
|
||||
|
||||
// Document Locking Methods
|
||||
|
||||
func (h *Hub) AcquireDocLock(docID, userID, userName string, ttl time.Duration) (*domain.DocLockInfo, bool, *domain.DocLockInfo) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
existing, exists := h.locks[docID]
|
||||
if exists && existing.expiresAt.After(now) {
|
||||
if existing.userID != userID {
|
||||
// Locked by another user
|
||||
info := &domain.DocLockInfo{
|
||||
DocID: existing.docID,
|
||||
UserID: existing.userID,
|
||||
UserName: existing.userName,
|
||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
return nil, false, info
|
||||
}
|
||||
// Same user extending lock
|
||||
existing.expiresAt = now.Add(ttl)
|
||||
if userName != "" {
|
||||
existing.userName = userName
|
||||
}
|
||||
info := &domain.DocLockInfo{
|
||||
DocID: existing.docID,
|
||||
UserID: existing.userID,
|
||||
UserName: existing.userName,
|
||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
// Create fresh lock
|
||||
item := &docLockItem{
|
||||
docID: docID,
|
||||
userID: userID,
|
||||
userName: userName,
|
||||
lockedAt: now,
|
||||
expiresAt: now.Add(ttl),
|
||||
}
|
||||
h.locks[docID] = item
|
||||
info := &domain.DocLockInfo{
|
||||
DocID: item.docID,
|
||||
UserID: item.userID,
|
||||
UserName: item.userName,
|
||||
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
func (h *Hub) HeartbeatDocLock(docID, userID string, ttl time.Duration) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
item, ok := h.locks[docID]
|
||||
if !ok || item.userID != userID || item.expiresAt.Before(now) {
|
||||
return false
|
||||
}
|
||||
item.expiresAt = now.Add(ttl)
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Hub) ReleaseDocLock(docID, userID string, isAdmin bool) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
item, ok := h.locks[docID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if item.userID == userID || isAdmin || userID == "admin" {
|
||||
delete(h.locks, docID)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
item, ok := h.locks[docID]
|
||||
if !ok || item.expiresAt.Before(time.Now()) {
|
||||
return nil
|
||||
}
|
||||
return &domain.DocLockInfo{
|
||||
DocID: item.docID,
|
||||
UserID: item.userID,
|
||||
UserName: item.userName,
|
||||
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) cleanupLocksLoop() {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
h.mu.Lock()
|
||||
now := time.Now()
|
||||
var expiredDocIDs []string
|
||||
for docID, l := range h.locks {
|
||||
if l.expiresAt.Before(now) {
|
||||
expiredDocIDs = append(expiredDocIDs, docID)
|
||||
delete(h.locks, docID)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, docID := range expiredDocIDs {
|
||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
for client := range h.allClients {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
if room, ok := h.userRooms[userID]; ok {
|
||||
for client := range room {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) BroadcastTaskEvent(userID, event string, payload interface{}) {
|
||||
h.BroadcastToUser(userID, event, payload)
|
||||
}
|
||||
|
||||
func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
|
||||
h.BroadcastToAll(event, payload)
|
||||
}
|
||||
|
||||
func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||
msg := domain.WSMessage{
|
||||
Event: event,
|
||||
@@ -90,7 +310,7 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
room, ok := h.rooms[spaceID]
|
||||
room, ok := h.spaceRooms[spaceID]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -115,7 +335,7 @@ func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID st
|
||||
conn: conn,
|
||||
spaceID: spaceID,
|
||||
userID: userID,
|
||||
send: make(chan []byte, 64),
|
||||
send: make(chan []byte, 128),
|
||||
}
|
||||
|
||||
h.Register(client)
|
||||
@@ -129,7 +349,7 @@ func (c *Client) readPump() {
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
|
||||
c.conn.SetReadLimit(8192)
|
||||
c.conn.SetReadLimit(16384)
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
@@ -137,10 +357,42 @@ func (c *Client) readPump() {
|
||||
})
|
||||
|
||||
for {
|
||||
_, _, err := c.conn.ReadMessage()
|
||||
_, message, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Handle client-initiated lock and unlock messages
|
||||
var incoming struct {
|
||||
Event string `json:"event"`
|
||||
DocID string `json:"docId"`
|
||||
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
|
||||
}
|
||||
|
||||
if docID != "" {
|
||||
switch incoming.Event {
|
||||
case "LOCK_DOC":
|
||||
if info, ok, _ := c.hub.AcquireDocLock(docID, c.userID, "", 30*time.Second); ok && info != nil {
|
||||
c.hub.BroadcastDocEvent(docID, "DOC_LOCKED", info)
|
||||
}
|
||||
case "HEARTBEAT_DOC":
|
||||
c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second)
|
||||
case "UNLOCK_DOC":
|
||||
if c.hub.ReleaseDocLock(docID, c.userID, false) {
|
||||
c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user