Dev #2

Merged
sandrodach merged 37 commits from dev into main 2026-10-25 12:14:23 +01:00
25 changed files with 6458 additions and 483 deletions

No files matched your search

+10 -1
View File
@@ -58,10 +58,19 @@
- `Shift+Tab`: Block ausrücken. - `Shift+Tab`: Block ausrücken.
- `Shift+Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks. - `Shift+Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks.
- `Backspace`: Auf leerem Block rückt aus oder entfernt den Bullet-Point. - `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. - **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`. - **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. - 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`):** - [x] **Globaler Workspace To-Do Rollup Dashboard (`WorkspaceView.tsx`):**
- Aggregierte Übersicht aller To-Dos (`- [ ] ` / `- [x] `) aus sämtlichen Notiz-Dateien über alle Ordner hinweg. - 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. - Übersichtliche Gruppierung nach Ordner ➔ Dokument ➔ Aufgaben mit einklappbaren Akkordeons und Zähler für offene Aufgaben.
+92 -47
View File
@@ -4,13 +4,13 @@
### *Der Kopf frei in drei Sekunden.* ### *Der Kopf frei in drei Sekunden.*
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-in-60-sekunden-starten) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart)
[![Markdown](https://img.shields.io/badge/Datenhoheit-Pure%20Markdown%20%26%20JSON-18181b?style=flat-square)](#) [![Markdown](https://img.shields.io/badge/Datenhoheit-Pure%20Markdown%20%26%20Outliner-18181b?style=flat-square)](#)
[![License](https://img.shields.io/badge/Lizenz-MIT-22c55e?style=flat-square)](LICENSE) [![License](https://img.shields.io/badge/Lizenz-MIT-22c55e?style=flat-square)](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* ## 💡 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.** 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:** **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. 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, 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. 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. **Nahtlos weiterarbeiten:** Aus flüchtigen Ideen werden strukturierte Aufgaben-Boards, verlinkte Wiki-Dokumente oder geteilte Listen für Familie und Team. 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 ## ✨ Was MindSift auszeichnet
### ⚡ Quick-Dump & Triage-Deck ### ⚡ Quick-Dump & Triage-Deck (Das 3-Sekunden-Prinzip)
* **Immer griffbereit:** Die Schnell-Eingabeleiste sitzt prominent ganz oben – jederzeit erreichbar per Klick oder Tastendruck (`C` oder `/`). * **Blitz-Erfassung:** Die Schnelleingabeleiste sitzt prominent ganz oben im Header – 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. * **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 ### ✅ Aufgabenlisten & Kanban-Board
* **Mehrere Aufgabenlisten:** Trenne Berufliches, Privates, Projekte oder gemeinsame Team-Aufgaben sauber in eigenen Listen. * **Mehrere Listen:** Trenne Berufliches, Privates, Homelab oder Team-Projekte 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*). * **Liste oder Kanban:** Wechsle mit einem Klick zwischen kompakter Listenansicht und dem **3-Spalten-Kanban-Board** (*Zu erledigen*, *In Arbeit*, *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. * **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 ### 🛒 Geteilte Echtzeit-Listen (Einkauf & Checklisten)
* **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. * **Gemeinsam abhaken:** Ob Wocheneinkauf oder Urlaubs-Packliste – teile Listen in Echtzeit über WebSocket.
* **Vernetztes Wiki:** Verknüpfe Dokumente untereinander über interne `[[Wiki-Links]]` und organisiere dein Wissen in einer klaren Ordner- und Unterordner-Struktur. * **Live-Synchronisation:** Wird ein Artikel im Laden abgehakt, verschwindet er sofort bei allen anderen Teilnehmern.
* **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 ### 🏢 Organisationen, Teams & Administration
* **Gemeinsam abhaken:** Ob Wocheneinkauf, Packliste für den Urlaub oder Checkliste fürs Projekt – teile Spaces per Einladungscode mit Partnern, Familie oder Kollegen. * **Geführte Ersteinrichtung:** Beim allerersten Start führt dich MindSift automatisch durch die Einrichtung deines Administrator-Kontos.
* **Live-Synchronisation:** Hakst du im Supermarkt einen Artikel ab, verschwindet er im selben Augenblick auf den Bildschirmen aller anderen Mitglieder. * **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 ### 🔔 Multi-Channel-Alarme
* **Geführte Ersteinrichtung:** Beim ersten Start begrüßt dich ein Einrichtungs-Assistent, mit dem du dein Administrator-Konto und die Sicherheitseinstellungen deines Servers festlegst. * **Pünktlich erinnert:** Automatische Benachrichtigung vor Fälligkeit wahlweise per **E-Mail (SMTP)**, **Discord-Webhook** oder **eigenem Webhook-Endpunkt**.
* **Geschlossene Gesellschaft oder offenes Team:** Schalte die öffentliche Registrierung ab und vergib gezielte **Einladungscodes**, oder lege Benutzer direkt über die Administration an. * **Eigene API-Schlüssel:** Sende System-Alarme, Server-Logs oder Skripte per einfachem `curl`-Befehl direkt in deine Inbox.
* **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 & Eigene Webhooks ### 🎨 Single-File CSS Theming & Hilfe
* **Keine Frist mehr verpassen:** Lass dich vor Fälligkeit einer Aufgabe automatisch benachrichtigen – frei wählbar per **E-Mail**, **Discord-Nachricht** oder **eigenem Webhook**. * **1 Datei = 1 Theme:** Vollständiges Design-System gesteuert über genau eine CSS-Datei (`dark`, `light`, `high-contrast`, `soft`).
* **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. * **Integrierte Hilfeseite:** Ein Klick auf das Profil-Menü oder den Hilfe-Button öffnet eine strukturierte Dokumentation inklusive Tastatur-Cheat-Sheet.
### 🖤 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.
--- ---
## 👥 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. MindSift läuft als leichtgewichtiger, einzelner Container und bringt Webserver, API, Datenbank und Frontend direkt mit.
* **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. ### 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 ```yaml
services: services:
@@ -93,10 +125,9 @@ services:
environment: environment:
- PORT=8080 - PORT=8080
- DATA_DIR=/data - DATA_DIR=/data
- JWT_SECRET=bitte-hier-ein-langes-sicheres-geheimnis-eintragen - JWT_SECRET=hier-ein-langes-sicheres-zufalls-geheimnis-eintragen
- ALLOW_REGISTRATION=false - ALLOW_REGISTRATION=false
# Optional: Eigener SMTP-Server für E-Mail-Alarme # Optional: Mailserver für E-Mail-Alarme (auch im Admin-UI einstellbar)
# (kann alternativ auch später direkt in der Admin-Oberfläche eingetragen werden)
- SMTP_HOST= - SMTP_HOST=
- SMTP_PORT=587 - SMTP_PORT=587
- SMTP_USER= - SMTP_USER=
@@ -105,20 +136,34 @@ services:
- SMTP_TLS=true - SMTP_TLS=true
``` ```
Container starten: Starte den Container:
```bash ```bash
docker compose up -d 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 ## 🔒 Deine Daten gehören dir
* **100 % Self-Hosted:** Keine externen Cloud-Dienste, keine versteckte Telemetrie, kein Tracking. * **100 % Self-Hosted:** Keine fremden Cloud-Dienste, keine 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. * **Kein Vendor-Lock-in:** Alle Notizen liegen als lesbare `.md`-Dateien im Ordner `./data/workspace`. Backups können jederzeit als JSON exportiert werden.
--- ---
+9
View File
@@ -34,6 +34,15 @@ func main() {
// Seed SMTP defaults from environment variables into DB if not yet configured in DB // 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) 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() wsHub := ws.NewHub()
staticFS, err := fs.Sub(embeddedDist, "dist") staticFS, err := fs.Sub(embeddedDist, "dist")
+25
View File
@@ -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
View File
@@ -1,8 +1,5 @@
services: services:
mindsift: mindsift:
build:
context: .
dockerfile: Dockerfile
image: git.dsit.me/sandrodach/mindsift:dev image: git.dsit.me/sandrodach/mindsift:dev
container_name: mindsift container_name: mindsift
restart: unless-stopped restart: unless-stopped
@@ -17,6 +14,8 @@ services:
- BASE_URL=${BASE_URL:-http://localhost:8080} - BASE_URL=${BASE_URL:-http://localhost:8080}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
- LOG_LEVEL=${LOG_LEVEL:-info} - LOG_LEVEL=${LOG_LEVEL:-info}
# 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) # Optional SMTP / Mail Configuration (can also be configured in Admin UI)
- SMTP_HOST=${SMTP_HOST:-} - SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587} - SMTP_PORT=${SMTP_PORT:-587}
+109 -36
View File
@@ -11,9 +11,17 @@ import {
ChevronDown, ChevronDown,
RotateCcw, RotateCcw,
Palette, Palette,
HelpCircle,
} from 'lucide-react'; } from 'lucide-react';
import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types'; 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 { import {
AVAILABLE_THEMES, AVAILABLE_THEMES,
initThemeOnBoot, initThemeOnBoot,
@@ -28,6 +36,7 @@ import { SharedListsView } from './features/lists/SharedListsView';
import { SetupAndAuthView } from './features/auth/SetupAndAuthView'; import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { UserSettingsView } from './features/settings/UserSettingsView'; import { UserSettingsView } from './features/settings/UserSettingsView';
import { AdminSettingsView } from './features/settings/AdminSettingsView'; import { AdminSettingsView } from './features/settings/AdminSettingsView';
import { HelpView } from './features/help/HelpView';
type ActiveTab = type ActiveTab =
| 'triage' | 'triage'
@@ -35,7 +44,8 @@ type ActiveTab =
| 'workspace' | 'workspace'
| 'lists' | 'lists'
| 'user_settings' | 'user_settings'
| 'admin_settings'; | 'admin_settings'
| 'help';
interface ErrorBoundaryState { interface ErrorBoundaryState {
hasError: boolean; hasError: boolean;
@@ -92,12 +102,14 @@ export const App: React.FC = () => {
tasksOpenCount: 0, tasksOpenCount: 0,
tasksDoneCount: 0, tasksDoneCount: 0,
docsCount: 0, docsCount: 0,
docsTodosOpenCount: 0,
listsCount: 0, listsCount: 0,
discardedCount: 0, discardedCount: 0,
}); });
const [deck, setDeck] = useState<Thought[]>([]); const [deck, setDeck] = useState<Thought[]>([]);
const [lists, setLists] = useState<SharedList[]>([]); const [lists, setLists] = useState<SharedList[]>([]);
const [activeTab, setActiveTab] = useState<ActiveTab>('triage'); const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
const [selectedDocId, setSelectedDocId] = useState<string | null>(null); const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [captureFocusCount, setCaptureFocusCount] = useState(0); const [captureFocusCount, setCaptureFocusCount] = useState(0);
const [refreshRevision, setRefreshRevision] = useState(0); const [refreshRevision, setRefreshRevision] = useState(0);
@@ -106,6 +118,13 @@ export const App: React.FC = () => {
const [showProfileMenu, setShowProfileMenu] = useState(false); const [showProfileMenu, setShowProfileMenu] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null); const dropdownRef = useRef<HTMLDivElement | null>(null);
const handleOpenHelp = () => {
triggerHaptic(10);
setPreviousTab((prev) => (activeTab === 'help' ? prev : activeTab));
setActiveTab('help');
setShowProfileMenu(false);
};
const handleChangeTheme = (themeId: ThemeId) => { const handleChangeTheme = (themeId: ThemeId) => {
triggerHaptic(10); triggerHaptic(10);
applyTheme(themeId); 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 // Close profile dropdown on outside click
useEffect(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
@@ -205,7 +253,10 @@ export const App: React.FC = () => {
target: 'inbox' | 'task' | 'list_item' | 'document' = 'inbox' target: 'inbox' | 'task' | 'list_item' | 'document' = 'inbox'
) => { ) => {
if (target === 'inbox' && thought.status === '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 })); setStats((prev) => ({ ...prev, inboxCount: prev.inboxCount + 1 }));
} else if (target === 'document' && thought.convertedRefId) { } else if (target === 'document' && thought.convertedRefId) {
setSelectedDocId(thought.convertedRefId); setSelectedDocId(thought.convertedRefId);
@@ -228,6 +279,7 @@ export const App: React.FC = () => {
const handleLogout = async () => { const handleLogout = async () => {
triggerHaptic(20); triggerHaptic(20);
disconnectWebSocket();
clearToken(); clearToken();
setUser(null); setUser(null);
setShowProfileMenu(false); 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"> <div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30">
{/* Top Header Bar */} {/* Top Header Bar */}
<header className="sticky top-0 z-30 bg-panel border-b border-border shadow-sm"> <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 */} {/* Logo & Headline */}
<div <div
onClick={() => { onClick={() => {
setActiveTab('triage'); setActiveTab('triage');
handleTriggerAddButton(); 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"> <div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
<Brain className="w-4 h-4" /> <Brain className="w-4 h-4" />
@@ -302,22 +354,22 @@ export const App: React.FC = () => {
</div> </div>
{/* User Profile Pill & Dropdown Menu */} {/* 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 <button
onClick={() => { onClick={() => {
triggerHaptic(10); triggerHaptic(10);
setShowProfileMenu((prev) => !prev); 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" 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'} {user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
</div> </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} {user.displayName}
</span> </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> </button>
{showProfileMenu && ( {showProfileMenu && (
@@ -375,6 +427,16 @@ export const App: React.FC = () => {
<span>Kontoeinstellungen</span> <span>Kontoeinstellungen</span>
</button> </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 && ( {isStaff && (
<button <button
onClick={() => { onClick={() => {
@@ -410,8 +472,8 @@ export const App: React.FC = () => {
</div> </div>
{/* Navigation tabs attached to header */} {/* 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"> <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"> <div className="flex items-stretch overflow-x-auto no-scrollbar">
<button <button
onClick={() => { onClick={() => {
triggerHaptic(10); triggerHaptic(10);
@@ -420,18 +482,14 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`} className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`}
> >
<Layers className="w-4 h-4" /> <Layers className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-2' : ''}`}>
<span>Triage</span> <span>Triage</span>
{stats.inboxCount > 0 && ( {stats.inboxCount > 0 && (
<span <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">
className={
activeTab === 'triage'
? 'badge-accent'
: 'badge-neutral'
}
>
{stats.inboxCount} {stats.inboxCount}
</span> </span>
)} )}
</span>
</button> </button>
<button <button
@@ -442,18 +500,14 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`} className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
> >
<CheckSquare className="w-4 h-4" /> <CheckSquare className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
<span>Aufgaben</span> <span>Aufgaben</span>
{stats.tasksOpenCount > 0 && ( {stats.tasksOpenCount > 0 && (
<span <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">
className={
activeTab === 'tasks'
? 'badge-accent'
: 'badge-neutral'
}
>
{stats.tasksOpenCount} {stats.tasksOpenCount}
</span> </span>
)} )}
</span>
</button> </button>
<button <button
@@ -464,15 +518,13 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`} className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`}
> >
<FileText className="w-4 h-4" /> <FileText className="w-4 h-4" />
<span className={`relative inline-flex items-center ${(stats.docsTodosOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
<span>Workspace</span> <span>Workspace</span>
<span {(stats.docsTodosOpenCount ?? 0) > 0 && (
className={ <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">
activeTab === 'workspace' {stats.docsTodosOpenCount}
? 'badge-accent' </span>
: 'badge-neutral' )}
}
>
{stats.docsCount}
</span> </span>
</button> </button>
@@ -484,7 +536,23 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`} className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`}
> >
<ShoppingCart className="w-4 h-4" /> <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> </button>
</div> </div>
</nav> </nav>
@@ -514,13 +582,18 @@ export const App: React.FC = () => {
{activeTab === 'workspace' && ( {activeTab === 'workspace' && (
<WorkspaceView <WorkspaceView
currentUser={user}
initialDocId={selectedDocId} initialDocId={selectedDocId}
onStatsChange={refreshAll} onStatsChange={refreshAll}
/> />
)} )}
{activeTab === 'help' && (
<HelpView onBack={() => setActiveTab(previousTab || 'triage')} />
)}
{activeTab === 'lists' && ( {activeTab === 'lists' && (
<SharedListsView onRefreshGlobal={refreshAll} /> <SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
)} )}
{activeTab === 'user_settings' && ( {activeTab === 'user_settings' && (
@@ -27,6 +27,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
const [setupName, setSetupName] = useState(''); const [setupName, setSetupName] = useState('');
const [setupEmail, setSetupEmail] = useState(''); const [setupEmail, setSetupEmail] = useState('');
const [setupPassword, setSetupPassword] = useState(''); const [setupPassword, setSetupPassword] = useState('');
const [setupPasswordConfirm, setSetupPasswordConfirm] = useState('');
const [instanceName, setInstanceName] = useState(authStatus.instanceName || 'MindSift'); const [instanceName, setInstanceName] = useState(authStatus.instanceName || 'MindSift');
const [sharingMode, setSharingMode] = useState<'open' | 'teams_only' | 'strict_private'>( const [sharingMode, setSharingMode] = useState<'open' | 'teams_only' | 'strict_private'>(
'teams_only' 'teams_only'
@@ -47,6 +48,13 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
const handleCompleteSetup = async (e: React.FormEvent) => { const handleCompleteSetup = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
if (setupPassword !== setupPasswordConfirm) {
setError('Die eingegebenen Passwörter stimmen nicht überein.');
triggerHaptic(25);
return;
}
setSubmitting(true); setSubmitting(true);
triggerHaptic(15); triggerHaptic(15);
try { try {
@@ -170,6 +178,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
</div> </div>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<label className="block text-muted mb-1">Administrator-Passwort *</label> <label className="block text-muted mb-1">Administrator-Passwort *</label>
<input <input
@@ -182,6 +191,28 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
className="w-full h-8 px-2.5 input-field" className="w-full h-8 px-2.5 input-field"
/> />
</div> </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> </div>
{/* Section 2: Server & Isolation */} {/* Section 2: Server & Isolation */}
@@ -399,13 +430,44 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
{authTab === 'login' && ( {authTab === 'login' && (
<form onSubmit={handleLogin} className="space-y-3.5"> <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> <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 <input
type="email" type="text"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="name@beispiel.de" placeholder="admin oder name@beispiel.de"
required required
autoFocus autoFocus
className="w-full h-8 px-2.5 input-field" 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 [isListening, setIsListening] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null); const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
const [isMultiline, setIsMultiline] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLFormElement>(null); const containerRef = useRef<HTMLFormElement>(null);
const recognitionRef = useRef<any>(null); const recognitionRef = useRef<any>(null);
const 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(() => { useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus();
}, [autoFocusTrigger]); }, [autoFocusTrigger]);
@@ -77,6 +95,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
inputRef.current?.focus(); inputRef.current?.focus();
} else if (e.key === 'Escape' && document.activeElement === inputRef.current) { } else if (e.key === 'Escape' && document.activeElement === inputRef.current) {
setContent(''); setContent('');
setIsMultiline(false);
setShowOptions(false); setShowOptions(false);
inputRef.current?.blur(); 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 // Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
setContent(''); setContent('');
setIsMultiline(false);
setPriority('normal'); setPriority('normal');
setDueDate(''); setDueDate('');
setTarget('inbox'); setTarget('inbox');
setShowOptions(false); setShowOptions(false);
setIsSubmitting(true); setIsSubmitting(true);
triggerHaptic(15); triggerHaptic(15);
if (inputRef.current) {
inputRef.current.style.height = 'auto';
}
const savedMsg = const savedMsg =
effectiveTarget === 'task' effectiveTarget === 'task'
@@ -210,25 +233,36 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
return ( return (
<div className="w-full"> <div className="w-full">
<form ref={containerRef} onSubmit={handleSubmit} className="relative"> <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 <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 isListening
? 'border-accent bg-panel' ? 'border-accent bg-panel'
: 'border-border focus-within:border-accent' : '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" /> <Sparkles className="w-3 h-3" />
</div> </div>
<input <textarea
ref={inputRef} ref={inputRef}
type="text" rows={1}
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => {
setContent(e.target.value);
adjustHeight(e.target);
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && (!e.shiftKey || e.ctrlKey || e.metaKey)) {
e.preventDefault(); e.preventDefault();
executeCapture(); executeCapture();
} }
@@ -237,24 +271,29 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
isListening isListening
? 'Ich höre zu…' ? 'Ich höre zu…'
: target === 'task' : target === 'task'
? 'Neue Aufgabe direkt anlegen… (Enter)' ? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
: target === 'list_item' : target === 'list_item'
? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…` ? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…`
: target === 'document' : target === 'document'
? 'Neue Workspace-Notiz direkt anlegen…' ? 'Neue Workspace-Notiz direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
: 'Quick Dump: Gedanke einfach reinrotzen & später swipen… (C / Enter)' : '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" 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 */} {/* Target & Options Toggle Pill */}
<button <button
type="button" type="button"
onClick={() => setShowOptions((prev) => !prev)} onClick={() => setShowOptions((prev) => !prev)}
title="Ziel wählen (Quick Dump, direkt als Aufgabe, auf Liste oder als Notiz) & Optionen" 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 showOptions || target !== 'inbox' || priority !== 'normal' || dueDate
? 'bg-accent-dim text-accent border border-accent-border font-medium' ? 'bg-accent-dim text-accent border border-accent-border font-medium'
: 'text-muted hover:text-main hover:bg-panel-elevated border border-transparent' : 'text-muted hover:text-main hover:bg-panel-elevated border border-transparent'
@@ -281,7 +320,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
type="button" type="button"
onClick={toggleVoiceDictation} onClick={toggleVoiceDictation}
title="Spracheingabe" 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 isListening
? 'bg-accent-dim text-accent' ? 'bg-accent-dim text-accent'
: 'text-muted hover:text-main hover:bg-panel-elevated' : 'text-muted hover:text-main hover:bg-panel-elevated'
@@ -294,7 +333,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
<button <button
type="submit" type="submit"
disabled={!content.trim() || isSubmitting} 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() content.trim()
? 'bg-accent hover:bg-accent-hover text-on-accent cursor-pointer shadow-sm' ? 'bg-accent hover:bg-accent-hover text-on-accent cursor-pointer shadow-sm'
: 'bg-panel-elevated text-faint cursor-not-allowed' : 'bg-panel-elevated text-faint cursor-not-allowed'
+374
View File
@@ -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>
);
};
+421 -58
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { import {
Plus, Plus,
CheckCircle2, CheckCircle2,
@@ -10,17 +10,62 @@ import {
Radio, Radio,
Sparkles, Sparkles,
UserPlus, UserPlus,
X,
} from 'lucide-react'; } 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'; import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api';
interface SharedListsViewProps { interface SharedListsViewProps {
onRefreshGlobal: () => void; 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 [spaces, setSpaces] = useState<Space[]>([]);
const [activeSpace, setActiveSpace] = useState<Space | null>(null); const [activeSpace, setActiveSpace] = useState<Space | null>(null);
const [lists, setLists] = useState<SharedList[]>([]); const [lists, setLists] = useState<SharedList[]>([]);
@@ -28,10 +73,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [newItemText, setNewItemText] = useState(''); const [newItemText, setNewItemText] = useState('');
const [newItemQty, setNewItemQty] = useState(''); const [newItemQty, setNewItemQty] = useState('');
const [newItemCategory, setNewItemCategory] = useState('');
const [wsConnected, setWsConnected] = useState(false); const [wsConnected, setWsConnected] = useState(false);
const [onlineUsers, setOnlineUsers] = useState(1);
const [copiedInvite, setCopiedInvite] = useState(false); const [copiedInvite, setCopiedInvite] = useState(false);
const [showNewListModal, setShowNewListModal] = useState(false); const [showNewListModal, setShowNewListModal] = useState(false);
@@ -43,13 +86,25 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [showNewSpaceModal, setShowNewSpaceModal] = useState(false); const [showNewSpaceModal, setShowNewSpaceModal] = useState(false);
const [newSpaceName, setNewSpaceName] = useState(''); 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 wsRef = useRef<WebSocket | null>(null);
const loadSpaces = async () => { const loadSpaces = async () => {
const sp = await api.getSpaces(); const sp = await api.getSpaces();
setSpaces(sp); setSpaces(sp);
if (sp.length > 0 && !activeSpace) { if (sp.length > 0) {
if (!activeSpace) {
setActiveSpace(sp[0]); 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) => { ws.onmessage = (evt) => {
try { try {
const msg: WSMessage = JSON.parse(evt.data); const msg: WSMessage = JSON.parse(evt.data);
if (msg.event === 'USER_PRESENCE' && msg.payload?.onlineCount) { if (msg.event === 'ITEM_CREATED' && msg.list_id) {
setOnlineUsers(msg.payload.onlineCount);
} else if (msg.event === 'ITEM_CREATED' && msg.list_id) {
const newItem: ListItem = msg.payload; const newItem: ListItem = msg.payload;
setLists((prev) => setLists((prev) =>
prev.map((l) => { 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))); setLists((prev) => prev.map((l) => (l.id === msg.list_id ? updatedList : l)));
} else if (msg.event === 'LIST_CREATED' || msg.event === 'LIST_DELETED') { } else if (msg.event === 'LIST_CREATED' || msg.event === 'LIST_DELETED') {
loadListsForSpace(activeSpace.id); loadListsForSpace(activeSpace.id);
} else if (msg.event === 'MEMBER_ADDED' || msg.event === 'MEMBER_REMOVED') {
loadSpaces();
} }
} catch { } catch {
// ignore malformed frames // ignore malformed frames
@@ -139,6 +194,23 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
}; };
}, [activeSpace?.id]); }, [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 activeList = lists.find((l) => l.id === activeListId) || lists[0] || null;
const handleAddItem = async (e: React.FormEvent) => { const handleAddItem = async (e: React.FormEvent) => {
@@ -146,15 +218,12 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
if (!activeList || !newItemText.trim()) return; if (!activeList || !newItemText.trim()) return;
triggerHaptic(15); triggerHaptic(15);
const text = newItemText.trim(); const parsed = parseListItemInput(newItemText, newItemQty);
const qty = newItemQty.trim();
const cat = newItemCategory || undefined;
setNewItemText(''); setNewItemText('');
setNewItemQty(''); setNewItemQty('');
try { 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) => setLists((prev) =>
prev.map((l) => (l.id === activeList.id ? { ...l, items: [item, ...l.items] } : l)) 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); 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 openItems = activeList?.items.filter((i) => !i.isCompleted) || [];
const completedItems = 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 ( return (
<div className="w-full space-y-3"> <div className="w-full space-y-3">
{/* Top Space Bar & Realtime Indicator */} {/* Top Space Bar & Realtime Indicator */}
@@ -283,31 +466,34 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</button> </button>
</div> </div>
{/* Realtime Status & Invite Code Pill */} {/* Realtime Status & Invite User Button */}
{activeSpace && ( {activeSpace && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <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 wsConnected
? 'badge-ok' ? 'badge-ok'
: 'badge-neutral' : 'badge-neutral'
}`} }`}
title={wsConnected ? 'Live synchronisiert (Online)' : 'Offline'}
> >
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} /> <Radio className={`w-4 h-4 ${wsConnected ? 'animate-pulse text-status-ok' : 'text-muted'}`} />
<span>{wsConnected ? `Echtzeit-Sync (${onlineUsers} aktiv)` : 'Offline-Puffer'}</span>
</div> </div>
<button <button
onClick={copyInviteCode} onClick={() => {
title="Einladungscode für Partner oder Familie kopieren" setShowMembersModal(true);
className="btn-action h-8" 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" /> <UserPlus className="w-3.5 h-3.5 text-accent" />
<span>Code: {activeSpace.inviteCode}</span> <span>User einladen</span>
{copiedInvite ? ( {activeSpace.members && activeSpace.members.length > 1 && (
<Check className="w-3.5 h-3.5 text-status-ok" /> <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}
<Copy className="w-3 h-3 text-muted" /> </span>
)} )}
</button> </button>
</div> </div>
@@ -435,14 +621,14 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{/* Active Shared List Card */} {/* Active Shared List Card */}
{activeList ? ( {activeList ? (
<div className="p-3 panel-surface"> <div className="p-3 panel-surface">
{/* Quick-Add Pill Form */} {/* Quick-Add Form */}
<form onSubmit={handleAddItem} className="space-y-2 mb-3"> <form onSubmit={handleAddItem} className="space-y-2 mb-3">
<div className="flex flex-col sm:flex-row gap-2"> <div className="flex flex-col sm:flex-row gap-2">
<input <input
type="text" type="text"
value={newItemText} value={newItemText}
onChange={(e) => setNewItemText(e.target.value)} 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" className="flex-1 h-9 px-3 input-field text-sm"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
@@ -463,28 +649,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</button> </button>
</div> </div>
</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> </form>
{/* Open Items */} {/* Open Items Grouped by Hashtags / Categories */}
<div className="border border-border divide-y divide-border rounded-card overflow-hidden"> <div className="border border-border divide-y divide-border rounded-card overflow-hidden">
{openItems.length === 0 ? ( {openItems.length === 0 ? (
<div className="py-6 text-center text-muted text-sm flex flex-col items-center gap-1.5"> <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> <span>Alles erledigt. Keine offenen Punkte auf dieser Liste.</span>
</div> </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 <div
key={item.id} key={item.id}
onClick={() => handleToggleItem(item)} 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"> <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" /> <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"> <span className="text-sm font-normal text-main truncate">
{item.content} {displayContent}
</span> </span>
{item.quantity && ( {item.quantity && (
<span className="badge-accent"> <span className="badge-accent">
{item.quantity} {item.quantity}
</span> </span>
)} )}
{item.category && ( {cat && (
<span className="hidden sm:inline px-2 py-0.2 badge-neutral text-xs"> <span className="px-2 py-0.5 text-xs font-medium text-accent bg-accent/10 border border-accent/25 rounded-full">
{item.category} #{cat}
</span> </span>
)} )}
</div> </div>
@@ -526,6 +711,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<Trash2 className="w-3 h-3" /> <Trash2 className="w-3 h-3" />
</button> </button>
</div> </div>
);
})}
</div>
)) ))
)} )}
</div> </div>
@@ -546,7 +734,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div> </div>
<div className="border border-border divide-y divide-border opacity-75 rounded-card overflow-hidden"> <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 <div
key={item.id} key={item.id}
onClick={() => handleToggleItem(item)} 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"> <div className="flex items-center gap-2.5 min-w-0">
<CheckCircle2 className="w-4 h-4 text-accent flex-shrink-0" /> <CheckCircle2 className="w-4 h-4 text-accent flex-shrink-0" />
<span className="text-sm text-muted line-through truncate"> <span className="text-sm text-muted line-through truncate">
{item.content} {displayContent}
</span> </span>
{item.quantity && ( {item.quantity && (
<span className="text-xs text-faint">({item.quantity})</span> <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> </div>
{item.completedByName && ( {item.completedByName && (
<span className="text-xs text-faint">✓ {item.completedByName}</span> <span className="text-xs text-faint">✓ {item.completedByName}</span>
)} )}
</div> </div>
))} );
})}
</div> </div>
</div> </div>
)} )}
</div> </div>
) : null} ) : 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> </div>
); );
}; };
+371 -17
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState, useRef, useMemo } from 'react';
import { import {
Check, Check,
Plus, Plus,
@@ -15,8 +15,9 @@ import {
Layers, Layers,
Share2, Share2,
Users, Users,
CheckSquare,
} from 'lucide-react'; } from 'lucide-react';
import { TaskList, Thought, Space } from '../../lib/types'; import { TaskList, Thought, Space, SubTask } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api'; import { api, triggerHaptic } from '../../lib/api';
const getInitials = (name?: string): string => { const getInitials = (name?: string): string => {
@@ -67,6 +68,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
const [activeNotesTaskId, setActiveNotesTaskId] = useState<string | null>(null); const [activeNotesTaskId, setActiveNotesTaskId] = useState<string | null>(null);
const [notesDraft, setNotesDraft] = useState(''); const [notesDraft, setNotesDraft] = useState('');
// Subtask creation state
const [addingSubtaskTaskId, setAddingSubtaskTaskId] = useState<string | null>(null);
const [subtaskDraft, setSubtaskDraft] = useState('');
const loadData = async () => { const loadData = async () => {
setLoading(true); setLoading(true);
try { try {
@@ -91,6 +96,80 @@ export const TasksView: React.FC<TasksViewProps> = ({
loadData(); loadData();
}, [refreshTrigger, selectedListId]); }, [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) => { const handleToggleDone = async (task: Thought) => {
triggerHaptic(15); triggerHaptic(15);
const nextState = !task.isCompleted; const nextState = !task.isCompleted;
@@ -125,6 +204,168 @@ export const TasksView: React.FC<TasksViewProps> = ({
setActiveNotesTaskId(null); 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) => { const handleConvertToDoc = async (task: Thought) => {
triggerHaptic(20); triggerHaptic(20);
const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' }); const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' });
@@ -168,7 +409,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
target: 'task', target: 'task',
taskListId: selectedListId === 'all' ? '' : selectedListId, taskListId: selectedListId === 'all' ? '' : selectedListId,
}); });
setTasks((prev) => [created, ...prev]); setTasks((prev) => {
if (prev.some((t) => t.id === created.id)) return prev;
return [created, ...prev];
});
onRefreshStats(); onRefreshStats();
} catch { } catch {
loadData(); 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 safeTaskLists = Array.isArray(taskLists) ? taskLists : [];
const safeSpaces = Array.isArray(spaces) ? spaces : []; const safeSpaces = Array.isArray(spaces) ? spaces : [];
const safeDiscarded = Array.isArray(discarded) ? discarded : []; 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" className="panel-surface p-2.5 space-y-2 hover:border-strong transition-colors"
> >
<div className="flex items-start justify-between gap-2"> <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} {task.content}
</p> </p>
<button <button
@@ -657,14 +912,25 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button> </button>
</div> </div>
{/* Subtasks Progress Bar on Title */}
{renderTaskProgressBar(task)}
{/* Notes Preview / Inline Editor */} {/* Notes Preview / Inline Editor */}
{activeNotesTaskId === task.id ? ( {activeNotesTaskId === task.id ? (
<div className="space-y-1"> <div className="space-y-1">
<textarea <textarea
value={notesDraft} value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)} onChange={(e) => setNotesDraft(e.target.value)}
placeholder="Detailnotizen eingeben…" onKeyDown={(e) => {
rows={2} 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" className="w-full input-field p-1.5 text-xs resize-none"
/> />
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
@@ -688,13 +954,16 @@ export const TasksView: React.FC<TasksViewProps> = ({
setActiveNotesTaskId(task.id); setActiveNotesTaskId(task.id);
setNotesDraft(task.notes || ''); 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" title="Klicken zum Bearbeiten der Notizen"
> >
{task.notes} {task.notes}
</div> </div>
) : null} ) : null}
{/* Subtasks Checklist & Inline Creator */}
{renderSubtaskList(task)}
{/* Footer & Movement Controls */} {/* 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 justify-between gap-1 pt-1 border-t border-subtle text-[11px]">
<div className="flex items-center gap-1.5 text-muted"> <div className="flex items-center gap-1.5 text-muted">
@@ -714,6 +983,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
+ Notiz + Notiz
</button> </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>
<div className="flex items-center gap-1"> <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" 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"> <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} {task.content}
</p> </p>
<button <button
@@ -764,14 +1045,25 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button> </button>
</div> </div>
{/* Subtasks Progress Bar on Title */}
{renderTaskProgressBar(task)}
{/* Notes Preview / Inline Editor */} {/* Notes Preview / Inline Editor */}
{activeNotesTaskId === task.id ? ( {activeNotesTaskId === task.id ? (
<div className="space-y-1"> <div className="space-y-1">
<textarea <textarea
value={notesDraft} value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)} onChange={(e) => setNotesDraft(e.target.value)}
placeholder="Detailnotizen eingeben…" onKeyDown={(e) => {
rows={2} 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" className="w-full input-field p-1.5 text-xs resize-none"
/> />
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
@@ -795,14 +1087,44 @@ export const TasksView: React.FC<TasksViewProps> = ({
setActiveNotesTaskId(task.id); setActiveNotesTaskId(task.id);
setNotesDraft(task.notes || ''); 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} {task.notes}
</div> </div>
) : null} ) : null}
{/* Subtasks Checklist & Inline Creator */}
{renderSubtaskList(task)}
{/* Movement Controls */} {/* Movement Controls */}
<div className="flex items-center justify-between gap-1 pt-1 border-t border-subtle text-[11px]"> <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 <button
onClick={() => handleKanbanMove(task, 'todo')} onClick={() => handleKanbanMove(task, 'todo')}
className="btn-action h-6 px-1.5 text-[10px] flex items-center gap-1" 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> </button>
</div> </div>
</div> </div>
</div>
))} ))}
{inProgressKanban.length === 0 && ( {inProgressKanban.length === 0 && (
<div className="p-4 text-center text-faint text-xs">Keine aktiven Aufgaben</div> <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} {task.content}
</p> </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]"> <div className="flex items-center justify-between gap-1 pt-1 border-t border-subtle text-[11px]">
<button <button
onClick={() => handleKanbanMove(task, 'todo')} onClick={() => handleKanbanMove(task, 'todo')}
@@ -991,14 +1318,25 @@ export const TasksView: React.FC<TasksViewProps> = ({
{task.content} {task.content}
</p> </p>
{/* Subtasks Progress Bar on Title */}
{renderTaskProgressBar(task)}
{/* Inline Notes or Editor */} {/* Inline Notes or Editor */}
{activeNotesTaskId === task.id ? ( {activeNotesTaskId === task.id ? (
<div className="space-y-1 pt-1"> <div className="space-y-1 pt-1">
<textarea <textarea
value={notesDraft} value={notesDraft}
onChange={(e) => setNotesDraft(e.target.value)} onChange={(e) => setNotesDraft(e.target.value)}
placeholder="Detailnotizen eingeben…" onKeyDown={(e) => {
rows={2} 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" className="w-full input-field p-2 text-xs resize-none"
/> />
<div className="flex justify-end gap-1.5"> <div className="flex justify-end gap-1.5">
@@ -1017,18 +1355,21 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div> </div>
</div> </div>
) : task.notes ? ( ) : task.notes ? (
<p <div
onClick={() => { onClick={() => {
setActiveNotesTaskId(task.id); setActiveNotesTaskId(task.id);
setNotesDraft(task.notes || ''); 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" title="Klicken zum Bearbeiten"
> >
{task.notes} {task.notes}
</p> </div>
) : null} ) : null}
{/* Subtasks Checklist & Inline Creator */}
{renderSubtaskList(task)}
<div className="flex flex-wrap items-center gap-1.5 mt-0.5 empty:hidden"> <div className="flex flex-wrap items-center gap-1.5 mt-0.5 empty:hidden">
{task.priority === 'urgent' && ( {task.priority === 'urgent' && (
<span className="badge-accent">Dringend</span> <span className="badge-accent">Dringend</span>
@@ -1052,6 +1393,19 @@ export const TasksView: React.FC<TasksViewProps> = ({
{/* Action Controls */} {/* Action Controls */}
<div className="flex items-center gap-2 self-end sm:self-center flex-shrink-0"> <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 && ( {!task.notes && activeNotesTaskId !== task.id && (
<button <button
onClick={() => { onClick={() => {
+1 -1
View File
@@ -400,7 +400,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</div> </div>
</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} {activeCard.content}
</p> </p>
)} )}
File diff suppressed because it is too large. Load diff
+62 -7
View File
@@ -78,9 +78,16 @@ body {
border-radius: var(--radius-card); border-radius: var(--radius-card);
} }
/* Quick-Capture-Blase & Profil-Pill im Header */ /* Quick-Capture-Blase im Header (immer vollständig abgerundet) */
.capture-pill { .capture-pill,
border-radius: var(--radius-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 */ /* Aufgaben-Checkboxen */
@@ -171,8 +178,54 @@ body {
border-color: var(--border-danger); border-color: var(--border-danger);
} }
/* Zentrales Texteingabefeld */ /* Sekundärer Button */
.input-field { .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); background-color: var(--bg-input);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: var(--radius-input); border-radius: var(--radius-input);
@@ -180,11 +233,13 @@ body {
font-size: 0.75rem; font-size: 0.75rem;
transition: border-color 120ms ease; transition: border-color 120ms ease;
} }
.input-field:focus { .input-field:focus,
.input-base:focus {
outline: none; outline: none;
border-color: var(--accent-primary); border-color: var(--accent-primary);
} }
.input-field::placeholder { .input-field::placeholder,
.input-base::placeholder {
color: var(--text-faint); color: var(--text-faint);
} }
+210 -3
View File
@@ -4,17 +4,24 @@ import {
DashboardStats, DashboardStats,
Document, Document,
DocumentBlock, DocumentBlock,
DocShare,
DocLockInfo,
ListItem, ListItem,
Organization, Organization,
PriorityLevel, PriorityLevel,
SharedList, SharedList,
Space, Space,
SpaceMember,
SystemSettings, SystemSettings,
Team, Team,
TaskList, TaskList,
Thought, Thought,
SubTask,
TagDetails,
TagSummary,
User, User,
UserInvite, UserInvite,
WSMessage,
} from './types'; } from './types';
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token'; const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
@@ -121,10 +128,10 @@ export const api = {
return res; 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', { const res = await request<{ token: string; user: User }>('/api/v1/auth/login', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password }), body: JSON.stringify({ email: loginOrEmail, username: loginOrEmail, login: loginOrEmail, password }),
}); });
setToken(res.token); setToken(res.token);
return res; return res;
@@ -309,6 +316,7 @@ export const api = {
taskListId?: string; taskListId?: string;
kanbanStatus?: 'todo' | 'in_progress' | 'done'; kanbanStatus?: 'todo' | 'in_progress' | 'done';
notes?: string; notes?: string;
subtasks?: SubTask[];
} }
): Promise<Thought> { ): Promise<Thought> {
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, { return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
@@ -362,6 +370,47 @@ export const api = {
await request(`/api/v1/task-lists/${id}`, { method: 'DELETE' }); 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 --- // --- WORKSPACE DOCUMENTS ---
async getDocuments(): Promise<Document[]> { async getDocuments(): Promise<Document[]> {
const res = await request<Document[]>('/api/v1/documents'); const res = await request<Document[]>('/api/v1/documents');
@@ -426,6 +475,69 @@ export const api = {
await request(`/api/v1/documents/${id}`, { method: 'DELETE' }); 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 --- // --- SPACES & REALTIME LISTS ---
async getSpaces(): Promise<Space[]> { async getSpaces(): Promise<Space[]> {
const res = await request<Space[]>('/api/v1/spaces'); const res = await request<Space[]>('/api/v1/spaces');
@@ -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[]> { async getSpaceLists(spaceId: string): Promise<SharedList[]> {
const res = await request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`); const res = await request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`);
return ensureArray(res).map((l) => ({ 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 protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = getToken() || ''; 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/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);
};
} }
+10
View File
@@ -81,6 +81,11 @@ export function setActiveTheme(themeId: ThemeId): void {
} }
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', themeId); 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(); const active = getActiveTheme();
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', active); document.documentElement.setAttribute('data-theme', active);
if (active !== 'light') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
} }
return active; return active;
} }
+65 -2
View File
@@ -6,11 +6,13 @@ export interface AuthStatus {
needsSetup: boolean; needsSetup: boolean;
allowRegistration: boolean; allowRegistration: boolean;
instanceName: string; instanceName: string;
demoMode?: boolean;
} }
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
username?: string;
displayName: string; displayName: string;
role: UserRole; role: UserRole;
isActive: boolean; isActive: boolean;
@@ -34,6 +36,13 @@ export interface UserInvite {
expiresAt: string; expiresAt: string;
} }
export interface SubTask {
id: string;
title: string;
isCompleted: boolean;
createdAt?: string;
}
export interface Thought { export interface Thought {
id: string; id: string;
userId: string; userId: string;
@@ -49,6 +58,7 @@ export interface Thought {
taskListId?: string; taskListId?: string;
kanbanStatus?: 'todo' | 'in_progress' | 'done'; kanbanStatus?: 'todo' | 'in_progress' | 'done';
notes?: string; notes?: string;
subtasks?: SubTask[];
alarmSent?: boolean; alarmSent?: boolean;
createdAt: string; createdAt: string;
triagedAt?: string; triagedAt?: string;
@@ -120,6 +130,24 @@ export interface DocumentBlock {
collapsed?: boolean; 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 { export interface Document {
id: string; id: string;
userId: string; userId: string;
@@ -134,6 +162,40 @@ export interface Document {
isArchived: boolean; isArchived: boolean;
createdAt: string; createdAt: string;
updatedAt: 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 { export interface SpaceMember {
@@ -199,13 +261,14 @@ export interface DashboardStats {
tasksOpenCount: number; tasksOpenCount: number;
tasksDoneCount: number; tasksDoneCount: number;
docsCount: number; docsCount: number;
docsTodosOpenCount?: number;
listsCount: number; listsCount: number;
discardedCount: number; discardedCount: number;
} }
export interface WSMessage<T = any> { export interface WSMessage<T = any> {
event: 'ITEM_CREATED' | 'ITEM_UPDATED' | 'ITEM_TOGGLED' | 'ITEM_DELETED' | 'LIST_CLEARED' | 'LIST_CREATED' | 'LIST_DELETED' | 'USER_PRESENCE'; event: string;
space_id: string; space_id?: string;
list_id?: string; list_id?: string;
payload: T; payload: T;
timestamp: string; timestamp: string;
Executable
+50
View File
@@ -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
View File
@@ -61,6 +61,7 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/auth/login", s.handleLogin) r.Post("/api/v1/auth/login", s.handleLogin)
r.Post("/api/v1/auth/quickstart", s.handleQuickStart) r.Post("/api/v1/auth/quickstart", s.handleQuickStart)
r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest) r.Post("/api/v1/webhooks/ingest", s.handleWebhookIngest)
r.Get("/api/v1/ws", s.handleWebSocket)
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket) r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
// Authenticated API Routes // Authenticated API Routes
@@ -71,6 +72,7 @@ func (s *Server) Router() http.Handler {
r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings) r.Patch("/api/v1/auth/settings", s.handleUpdateUserSettings)
r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm) r.Post("/api/v1/auth/alarms/test", s.handleTestUserAlarm)
r.Get("/api/v1/users/directory", s.handleGetUsersDirectory) 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/stats", s.handleStats)
r.Get("/api/v1/search", s.handleSearch) r.Get("/api/v1/search", s.handleSearch)
r.Get("/api/v1/export", s.handleExport) 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.Put("/api/v1/task-lists/{id}", s.handleUpdateTaskList)
r.Delete("/api/v1/task-lists/{id}", s.handleDeleteTaskList) 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.Get("/api/v1/documents", s.handleGetDocuments)
r.Post("/api/v1/documents", s.handleCreateDocument) r.Post("/api/v1/documents", s.handleCreateDocument)
r.Get("/api/v1/documents/{id}", s.handleGetDocument) r.Get("/api/v1/documents/{id}", s.handleGetDocument)
r.Put("/api/v1/documents/{id}", s.handleUpdateDocument) r.Put("/api/v1/documents/{id}", s.handleUpdateDocument)
r.Delete("/api/v1/documents/{id}", s.handleDeleteDocument) 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 // Shared Spaces & Realtime Lists
r.Get("/api/v1/spaces", s.handleGetSpaces) r.Get("/api/v1/spaces", s.handleGetSpaces)
r.Post("/api/v1/spaces", s.handleCreateSpace) r.Post("/api/v1/spaces", s.handleCreateSpace)
r.Post("/api/v1/spaces/join", s.handleJoinSpace) 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.Get("/api/v1/spaces/{id}/lists", s.handleGetSpaceLists)
r.Post("/api/v1/spaces/{id}/lists", s.handleCreateList) 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, "needsSetup": count == 0,
"allowRegistration": allowReg, "allowRegistration": allowReg,
"instanceName": instanceName, "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) { func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Email string `json:"email"` Email string `json:"email"`
Username string `json:"username"`
Login string `json:"login"`
Password string `json:"password"` Password string `json:"password"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 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 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) { 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 return
} }
if !user.IsActive { 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) 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) writeJSON(w, http.StatusOK, updated)
} }
@@ -644,17 +685,19 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
TaskListID *string `json:"taskListId"` TaskListID *string `json:"taskListId"`
KanbanStatus *string `json:"kanbanStatus"` KanbanStatus *string `json:"kanbanStatus"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
Subtasks *[]domain.SubTask `json:"subtasks"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges JSON") writeError(w, http.StatusBadRequest, "Ungültiges JSON")
return 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 { if err != nil {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
s.wsHub.BroadcastTaskEvent(updated.UserID, "TASK_UPDATED", updated)
writeJSON(w, http.StatusOK, 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()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
s.wsHub.BroadcastTaskEvent(userID, "TASK_DELETED", map[string]string{"id": thoughtID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) 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()) writeError(w, http.StatusBadRequest, err.Error())
return return
} }
s.wsHub.BroadcastDocEvent(doc.ID, "DOC_CREATED", doc)
writeJSON(w, http.StatusCreated, 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) { func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
userID := auth.GetUserID(r.Context()) userID := auth.GetUserID(r.Context())
docID := chi.URLParam(r, "id") 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 { var req struct {
Title *string `json:"title"` Title *string `json:"title"`
Icon *string `json:"icon"` Icon *string `json:"icon"`
@@ -746,6 +802,9 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error()) writeError(w, http.StatusBadRequest, err.Error())
return 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) 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()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
s.wsHub.BroadcastDocEvent(docID, "DOC_DELETED", map[string]string{"id": docID})
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true}) 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 --- // --- SPACES & REALTIME LISTS ---
func (s *Server) handleGetSpaces(w http.ResponseWriter, r *http.Request) { 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()) writeError(w, http.StatusBadRequest, err.Error())
return 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) 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) { func (s *Server) handleGetSpaceLists(w http.ResponseWriter, r *http.Request) {
spaceID := chi.URLParam(r, "id") spaceID := chi.URLParam(r, "id")
lists, err := s.store.GetListsForSpace(spaceID) 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) { func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
spaceID := chi.URLParam(r, "spaceId") spaceID := chi.URLParam(r, "spaceId")
token := r.URL.Query().Get("token") 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" userID := "anonymous"
if token != "" { if token != "" {
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil { if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
+280
View File
@@ -431,3 +431,283 @@ func TestNewFullFeatures(t *testing.T) {
t.Fatalf("expected updated kanbanStatus and notes, got %+v", updatedThought) 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)
}
}
+5
View File
@@ -14,6 +14,7 @@ type Config struct {
BaseURL string BaseURL string
AllowRegistration bool AllowRegistration bool
LogLevel string LogLevel string
DemoMode bool
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings) // Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
SMTPHost string SMTPHost string
@@ -41,6 +42,9 @@ func Load() *Config {
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true" allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
logLevel := getEnv("LOG_LEVEL", "info") 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", "") smtpHost := getEnv("SMTP_HOST", "")
smtpPort := getEnv("SMTP_PORT", "587") smtpPort := getEnv("SMTP_PORT", "587")
smtpUser := getEnv("SMTP_USER", "") smtpUser := getEnv("SMTP_USER", "")
@@ -56,6 +60,7 @@ func Load() *Config {
BaseURL: baseURL, BaseURL: baseURL,
AllowRegistration: allowReg, AllowRegistration: allowReg,
LogLevel: logLevel, LogLevel: logLevel,
DemoMode: demoMode,
SMTPHost: smtpHost, SMTPHost: smtpHost,
SMTPPort: smtpPort, SMTPPort: smtpPort,
SMTPUser: smtpUser, SMTPUser: smtpUser,
+114
View File
@@ -40,9 +40,62 @@ func ExtractTags(content string) []string {
return tags 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 { type User struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
Username string `json:"username,omitempty"`
PasswordHash string `json:"-"` PasswordHash string `json:"-"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Role string `json:"role"` // admin | team_admin | member Role string `json:"role"` // admin | team_admin | member
@@ -77,6 +130,13 @@ const (
StatusConverted ThoughtStatus = "converted" 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 { type Thought struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"userId"` UserID string `json:"userId"`
@@ -92,6 +152,7 @@ type Thought struct {
TaskListID string `json:"taskListId,omitempty"` TaskListID string `json:"taskListId,omitempty"`
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
Notes string `json:"notes,omitempty"` Notes string `json:"notes,omitempty"`
Subtasks []SubTask `json:"subtasks"`
AlarmSent bool `json:"alarmSent,omitempty"` AlarmSent bool `json:"alarmSent,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
TriagedAt string `json:"triagedAt,omitempty"` TriagedAt string `json:"triagedAt,omitempty"`
@@ -161,6 +222,24 @@ type DocumentBlock struct {
Collapsed bool `json:"collapsed,omitempty"` 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 { type Document struct {
ID string `json:"id"` ID string `json:"id"`
UserID string `json:"userId"` UserID string `json:"userId"`
@@ -175,6 +254,40 @@ type Document struct {
IsArchived bool `json:"isArchived"` IsArchived bool `json:"isArchived"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` 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 { type SpaceMember struct {
@@ -249,6 +362,7 @@ type DashboardStats struct {
TasksOpenCount int `json:"tasksOpenCount"` TasksOpenCount int `json:"tasksOpenCount"`
TasksDoneCount int `json:"tasksDoneCount"` TasksDoneCount int `json:"tasksDoneCount"`
DocsCount int `json:"docsCount"` DocsCount int `json:"docsCount"`
DocsTodosOpenCount int `json:"docsTodosOpenCount"`
ListsCount int `json:"listsCount"` ListsCount int `json:"listsCount"`
DiscardedCount int `json:"discardedCount"` DiscardedCount int `json:"discardedCount"`
} }
File diff suppressed because it is too large. Load diff
+393
View File
@@ -1,9 +1,11 @@
package storage package storage
import ( import (
"os"
"path/filepath" "path/filepath"
"testing" "testing"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/domain" "github.com/sandrodach/mindsift/internal/domain"
) )
@@ -64,6 +66,28 @@ func TestStoreLifecycle(t *testing.T) {
t.Fatalf("ToggleListItem failed: %v", err) 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 // 4. Test Webhook API Key generation & validation
apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant") apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant")
if err != nil { if err != nil {
@@ -108,4 +132,373 @@ func TestStoreLifecycle(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("UpdateSystemSettings failed: %v", err) 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
View File
@@ -12,8 +12,8 @@ import (
) )
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
ReadBufferSize: 1024, ReadBufferSize: 2048,
WriteBufferSize: 1024, WriteBufferSize: 2048,
CheckOrigin: func(r *http.Request) bool { CheckOrigin: func(r *http.Request) bool {
return true // Self-hosted reverse proxy compatible return true // Self-hosted reverse proxy compatible
}, },
@@ -27,53 +27,273 @@ type Client struct {
send chan []byte send chan []byte
} }
type docLockItem struct {
docID string
userID string
userName string
lockedAt time.Time
expiresAt time.Time
}
type Hub struct { type Hub struct {
mu sync.RWMutex 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 { func NewHub() *Hub {
return &Hub{ h := &Hub{
rooms: make(map[string]map[*Client]bool), 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) { func (h *Hub) Register(c *Client) {
h.mu.Lock() h.mu.Lock()
if h.rooms[c.spaceID] == nil { h.allClients[c] = true
h.rooms[c.spaceID] = make(map[*Client]bool) if c.spaceID != "" {
if h.spaceRooms[c.spaceID] == nil {
h.spaceRooms[c.spaceID] = make(map[*Client]bool)
} }
h.rooms[c.spaceID][c] = true h.spaceRooms[c.spaceID][c] = true
onlineCount := len(h.rooms[c.spaceID]) }
if c.userID != "" && c.userID != "anonymous" {
if h.userRooms[c.userID] == nil {
h.userRooms[c.userID] = make(map[*Client]bool)
}
h.userRooms[c.userID][c] = true
}
onlineCount := len(h.allClients)
h.mu.Unlock() h.mu.Unlock()
if c.spaceID != "" {
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{ h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{
"onlineCount": onlineCount, "onlineCount": onlineCount,
}) })
}
} }
func (h *Hub) Unregister(c *Client) { func (h *Hub) Unregister(c *Client) {
h.mu.Lock() h.mu.Lock()
var onlineCount int delete(h.allClients, c)
if room, ok := h.rooms[c.spaceID]; ok { if c.spaceID != "" {
if _, exists := room[c]; exists { if room, ok := h.spaceRooms[c.spaceID]; ok {
delete(room, c) 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) 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() h.mu.Unlock()
if onlineCount > 0 { // Broadcast unlock events for released locks
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{ for _, docID := range releasedDocIDs {
"onlineCount": onlineCount, 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{}) { func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
msg := domain.WSMessage{ msg := domain.WSMessage{
Event: event, Event: event,
@@ -90,7 +310,7 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() defer h.mu.RUnlock()
room, ok := h.rooms[spaceID] room, ok := h.spaceRooms[spaceID]
if !ok { if !ok {
return return
} }
@@ -115,7 +335,7 @@ func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID st
conn: conn, conn: conn,
spaceID: spaceID, spaceID: spaceID,
userID: userID, userID: userID,
send: make(chan []byte, 64), send: make(chan []byte, 128),
} }
h.Register(client) h.Register(client)
@@ -129,7 +349,7 @@ func (c *Client) readPump() {
_ = c.conn.Close() _ = c.conn.Close()
}() }()
c.conn.SetReadLimit(8192) c.conn.SetReadLimit(16384)
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) _ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
c.conn.SetPongHandler(func(string) error { c.conn.SetPongHandler(func(string) error {
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second)) _ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
@@ -137,10 +357,42 @@ func (c *Client) readPump() {
}) })
for { for {
_, _, err := c.conn.ReadMessage() _, message, err := c.conn.ReadMessage()
if err != nil { if err != nil {
break 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})
}
}
}
}
} }
} }