diff --git a/BRAIN.md b/BRAIN.md index 41ced99..facbfd1 100644 --- a/BRAIN.md +++ b/BRAIN.md @@ -58,10 +58,19 @@ - `Shift+Tab`: Block ausrücken. - `Shift+Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks. - `Backspace`: Auf leerem Block rückt aus oder entfernt den Bullet-Point. - - **Zoom-in Hoisting:** Klick auf den Bullet-Dot zoomt tief in diesen Ast hinein (Fokusmodus) mit Breadcrumbs-Navigation und „Zoom aufheben"-Button. + - **Zoom-in Hoisting:** Klick auf den Bullet-Dot zoomt tief in diesen Ast hinein (Fokusmodus) mit Breadcrumbs-Navigation (zurückspringen per Klick auf die grauen Breadcrumbs). - **Drag & Drop:** Bullet-Dots dienen als Anfasser zum Verschieben und Umsortieren von Blöcken und ganzen Unterbäumen. - **Slash-Commands (`/`):** Schnelle Umwandlung in `/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/table`, `/callout`, `/progress`, `/divider`. - Physische Speicherung auf Festplatte unter `/data/workspace//...` als Standard-`.md`-Dateien mit portabler 2-Leerzeichen-Einrückung (` - [ ] ` bzw. ` - `), vollständig kompatibel mit externen Outlinern und Markdown-Viewern. +- [x] **Zentrale Hilfeseite & Dokumentation (`HelpView.tsx`, `App.tsx`):** + - Vollständige, übersichtliche Hilfe- und Tastatur-Dokumentation für Outliner, Triage, Aufgaben, geteilte Listen und Tastatur-Shortcuts. + - Direkt erreichbar über das Benutzer-Profil-Menü („Hilfe & Anleitung") sowie den „Hilfe"-Button im Workspace-Header. +- [x] **Workspace UX-Refinements & Datei-Explorer Drag & Drop (`WorkspaceView.tsx`, `sqlite.go`):** + - **Dateien in Ordner ziehen:** Notizen können im Datei-Explorer per Drag & Drop direkt auf beliebige Ordner oder in das Hauptverzeichnis gezogen werden. Das alte Ordner-Dropdown im Menü wurde entfernt. + - **Angepinnte Notizen (Favoriten):** Dokumente mit `isPinned: true` werden direkt ganz oben im Datei-Explorer in einer eigenen Schnellzugriffs-Sektion dargestellt. + - **Bereinigter Editor:** Störende Tasten-Legende unter den Notizen entfernt; klobiger Rahmen um das Notiz-Icon entfernt; Standard-Icon ist der Zettel (`📄`). + - **Breadcrumbs & Zoom:** Überflüssiger „Zoom aufheben"-Button entfernt; alle Breadcrumbs-Elemente inklusive Dokumententitel erscheinen einheitlich dezent in Grau ohne störende Link-Unterstreichung. + - **Globale To-Dos:** Tab- und Bereichs-Titel auf prägnantes „Globale To-Dos" gekürzt, unnötiger Erklärungstext entfernt. - [x] **Globaler Workspace To-Do Rollup Dashboard (`WorkspaceView.tsx`):** - Aggregierte Übersicht aller To-Dos (`- [ ] ` / `- [x] `) aus sämtlichen Notiz-Dateien über alle Ordner hinweg. - Übersichtliche Gruppierung nach Ordner ➔ Dokument ➔ Aufgaben mit einklappbaren Akkordeons und Zähler für offene Aufgaben. diff --git a/README.md b/README.md index 5317e32..7af3108 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ ### *Der Kopf frei in drei Sekunden.* [![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) -[![Markdown](https://img.shields.io/badge/Datenhoheit-Pure%20Markdown%20%26%20JSON-18181b?style=flat-square)](#) +[![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%20Outliner-18181b?style=flat-square)](#) [![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* Klassische Produktivitäts- und Notiz-Apps scheitern im Alltag oft an derselben Hürde: **Sie verlangen Struktur im falschen Moment.** -Wenn dir mitten im Gespräch, unterwegs oder kurz vor dem Feierabend ein wichtiger Gedanke kommt, willst du keinen Ordner auswählen, keine Datenbank-Eigenschaften pflegen und kein Formular ausfüllen. Du willst den Gedanken in drei Sekunden loswerden – und sicher sein, dass er nicht verloren geht. +Wenn dir mitten im Gespräch, unterwegs oder kurz vor Feierabend ein wichtiger Gedanke kommt, willst du keinen Ordner auswählen, keine Datenbank-Eigenschaften pflegen und kein Formular ausfüllen. Du willst den Gedanken in drei Sekunden loswerden – und sicher sein, dass er nicht verloren geht. **MindSift trennt das Erfassen radikal vom Organisieren:** -1. **Sofort abladen (Quick Dump):** Oben in der Eingabeleiste tippen, `Enter` drücken, fertig. Dein Kopf ist wieder frei. Wer möchte, kann einen Eintrag aber auch direkt bei der Eingabe einer Aufgabenliste, einer Einkaufsliste oder einer Notiz zuordnen. -2. **Spielerisch sichten (Triage):** Wenn du später Ruhe hast, arbeitest du deinen Gedanken-Stapel Karte für Karte durch. Nach links verwerfen, auf „Später“ schieben oder mit einem Klick in eine echte Aufgabe, ein Dokument oder einen Listeneintrag verwandeln. -3. **Nahtlos weiterarbeiten:** Aus flüchtigen Ideen werden strukturierte Aufgaben-Boards, verlinkte Wiki-Dokumente oder geteilte Listen für Familie und Team. +1. **Sofort abladen (Quick Dump):** Oben in der Eingabeleiste tippen, `Enter` drücken, fertig. Dein Kopf ist wieder frei. Alternativ kannst du einen Eintrag schon beim Schreiben direkt einer Aufgabenliste, Einkaufsliste oder Notiz zuweisen. +2. **Spielerisch sichten (Triage):** Wenn du später Ruhe hast, sichtest du deinen Gedanken-Stapel Karte für Karte per Tastatur oder Swipe. Nach links verwerfen, auf „Später“ schieben oder mit einem Tastendruck in eine Aufgabe, ein Dokument oder einen Listeneintrag verwandeln. +3. **Strukturiert vertiefen:** Aus spontanen Notizen werden hierarchische Outliner-Dokumente, vernetzte Notizbäume, Kanban-Boards oder geteilte Listen für Familie und Team. --- ## ✨ Was MindSift auszeichnet -### ⚡ Quick-Dump & Triage-Deck -* **Immer griffbereit:** Die Schnell-Eingabeleiste sitzt prominent ganz oben – jederzeit erreichbar per Klick oder Tastendruck (`C` oder `/`). -* **Entscheiden ohne Stress:** Im Triage-Deck siehst du immer genau eine Karte im Fokus, während darunter deine Warteschlange bereitliegt. Bearbeite den Text direkt auf der Karte, vergib ein Fälligkeitsdatum oder sortiere ihn per Swipe und Tastatur-Kürzel weg. +### ⚡ Quick-Dump & Triage-Deck (Das 3-Sekunden-Prinzip) +* **Blitz-Erfassung:** Die Schnelleingabeleiste sitzt prominent ganz oben im Header – jederzeit erreichbar per Klick oder Tastendruck (`C` oder `/`). +* **Entscheiden mit 1 Taste:** Im Triage-Deck steht immer genau ein Gedanke im Fokus: + * `H` oder `←`: Wegwerfen / Erledigt + * `L` oder `→`: Sofort zur Aufgabe machen + * `T`: Termin-Aufgabe mit Fälligkeitsdatum + * `D`: In Workspace-Notiz umwandeln + * `Leertaste`: Später (ans Ende des Stapels schieben) + +### 📝 Workspace: Der hierarchische Logseq-Outliner +* **Jeder Gedanke ein Bullet-Point:** Schreibe Notizen als flexible, hierarchische Gliederung: + * `Enter`: Neuer Bullet-Point auf derselben Ebene + * `Tab` / `Shift + Tab`: Unterordnen (einrücken) und ausrücken + * `Shift + Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks +* **Zoom-in Hoisting:** Klicke auf den Bullet-Punkt (`•`), um tief in einen Ast hineinzuzoomen (Fokus-Modus). Über die dezenten Breadcrumbs oben springst du jederzeit wieder zurück. +* **Slash-Befehle (`/`):** Tippe einfach `/` für `/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/table`, `/callout` oder `/progress`. +* **Datei-Explorer mit Drag & Drop:** Verschiebe Dokumente mit der Maus direkt per Drag & Drop in Ordner oder zurück ins Hauptverzeichnis. +* **Angepinnte Favoriten:** Hefte wichtige Notizen mit einem Klick an – sie erscheinen prominent ganz oben im Explorer. +* **100 % Standard-Markdown auf der Festplatte:** Alle Notizen werden unter `/data/workspace//...` als echte `.md`-Dateien gespeichert (mit 2-Leerzeichen-Einrückung). Vollständig kompatibel mit Obsidian, Logseq oder einfachen Text-Editoren – kein Vendor-Lock-in! + +### 📋 Globale To-Dos: Das automatische Aufgaben-Rollup +* **Alles auf einen Blick:** MindSift durchsucht all deine Notizen und sammelt sämtliche Checkboxen (`- [ ]`) in einem zentralen Rollup-Dashboard. +* **Ordner-Drilldown:** Isoliere einzelne Projekt-Ordner mit einem Klick. +* **Echtzeit-2-Wege-Sync:** Hakst du eine Aufgabe im Rollup ab, wird sie im selben Augenblick direkt in der physischen `.md`-Datei auf deiner Festplatte als `- [x]` aktualisiert! ### ✅ Aufgabenlisten & Kanban-Board -* **Mehrere Aufgabenlisten:** Trenne Berufliches, Privates, Projekte oder gemeinsame Team-Aufgaben sauber in eigenen Listen. -* **Liste oder Kanban:** Wechsle jederzeit mit einem Klick zwischen einer kompakten Listenansicht und einem übersichtlichen **3-Spalten-Kanban-Board** (*Zu erledigen*, *In Bearbeitung*, *Erledigt*). -* **Detailtiefe bei Bedarf:** Füge Aufgaben Fälligkeiten, Prioritäten und ausführliche Notizen hinzu – oder wandle eine wachsende Aufgabe direkt in ein großes Workspace-Dokument um. +* **Mehrere Listen:** Trenne Berufliches, Privates, Homelab oder Team-Projekte sauber in eigenen Listen. +* **Liste oder Kanban:** Wechsle mit einem Klick zwischen kompakter Listenansicht und dem **3-Spalten-Kanban-Board** (*Zu erledigen*, *In Arbeit*, *Erledigt*). +* **Teilen & Avatar-Stack:** Teile Aufgabenlisten mit Spaces und Teams. Der integrierte Avatar-Stack zeigt mit kleinen Initialen-Bubbles und Tooltips sofort, wer Zugriff hat. -### 📝 Workspace: Komfort wie in Word, gespeichert als echtes Markdown -* **Formatieren auf Knopfdruck:** Nutze die übersichtliche Werkzeugleiste für Überschriften, Fett/Kursiv, Checklisten, Code-Blöcke, Hervorhebungen, Tabellen und Fortschrittsbalken – ganz ohne Markdown-Befehle auswendig lernen zu müssen. -* **Vernetztes Wiki:** Verknüpfe Dokumente untereinander über interne `[[Wiki-Links]]` und organisiere dein Wissen in einer klaren Ordner- und Unterordner-Struktur. -* **Volle Datenhoheit (Kein Lock-in):** Jedes Dokument wird im Hintergrund als saubere, standardisierte `.md`-Datei in deiner Ordnerstruktur auf der Festplatte abgelegt. Du kannst deine Notizen jederzeit direkt aus dem Dateisystem kopieren, sichern oder mit anderen Programmen nutzen. +### 🛒 Geteilte Echtzeit-Listen (Einkauf & Checklisten) +* **Gemeinsam abhaken:** Ob Wocheneinkauf oder Urlaubs-Packliste – teile Listen in Echtzeit über WebSocket. +* **Live-Synchronisation:** Wird ein Artikel im Laden abgehakt, verschwindet er sofort bei allen anderen Teilnehmern. -### 🛒 Geteilte Echtzeit-Listen -* **Gemeinsam abhaken:** Ob Wocheneinkauf, Packliste für den Urlaub oder Checkliste fürs Projekt – teile Spaces per Einladungscode mit Partnern, Familie oder Kollegen. -* **Live-Synchronisation:** Hakst du im Supermarkt einen Artikel ab, verschwindet er im selben Augenblick auf den Bildschirmen aller anderen Mitglieder. +### 🏢 Organisationen, Teams & Administration +* **Geführte Ersteinrichtung:** Beim allerersten Start führt dich MindSift automatisch durch die Einrichtung deines Administrator-Kontos. +* **3-Stufen-Rollensystem:** Server-Admin, Team-Admin und reguläres Mitglied. +* **Einladungscodes & Registrierungs-Schalter:** Schalte die öffentliche Registrierung bequem per Schiebeschalter ab und lade neue Mitglieder gezielt per Einladungscode ein. +* **Server-Isolation:** Bestimme zentral, ob Nutzer strikt privat für sich arbeiten, sich nur innerhalb ihrer Teams sehen oder serverweit kooperieren. -### 🏢 Organisationen, Teams & Berechtigungskonzept -* **Geführte Ersteinrichtung:** Beim ersten Start begrüßt dich ein Einrichtungs-Assistent, mit dem du dein Administrator-Konto und die Sicherheitseinstellungen deines Servers festlegst. -* **Geschlossene Gesellschaft oder offenes Team:** Schalte die öffentliche Registrierung ab und vergib gezielte **Einladungscodes**, oder lege Benutzer direkt über die Administration an. -* **Drei klare Rollen:** Unterscheide zwischen **Server-Admin** (volle Server- und Mail-Steuerung), **Team-Admin** (darf Nutzer einladen sowie Organisationen und Teams verwalten) und **Mitglied**. -* **Sichtbarkeits-Isolation nach Maß:** Bestimme als Administrator zentral, ob Nutzer komplett isoliert für sich arbeiten (*Strikt Privat*), nur Mitglieder ihrer eigenen Teams sehen (*Teams isoliert*) oder serverweit zusammenarbeiten (*Offen*). +### 🔔 Multi-Channel-Alarme +* **Pünktlich erinnert:** Automatische Benachrichtigung vor Fälligkeit wahlweise per **E-Mail (SMTP)**, **Discord-Webhook** oder **eigenem Webhook-Endpunkt**. +* **Eigene API-Schlüssel:** Sende System-Alarme, Server-Logs oder Skripte per einfachem `curl`-Befehl direkt in deine Inbox. -### 🔔 Multi-Channel-Alarme & Eigene Webhooks -* **Keine Frist mehr verpassen:** Lass dich vor Fälligkeit einer Aufgabe automatisch benachrichtigen – frei wählbar per **E-Mail**, **Discord-Nachricht** oder **eigenem Webhook**. -* **Offen für deine Automatisierung:** Erzeuge persönliche API-Schlüssel in deinen Kontoeinstellungen und schicke System-Alarme, Server-Statusmeldungen oder eigene Skripte per einfachem `curl`-Befehl direkt als Aufgabe oder Notiz in dein MindSift. - -### 🖤 Fokussiertes, eckiges Dark-Design -* **Ruhe fürs Auge:** Ein klares, monochromes dunkles Design mit präzisen Kanten und einer einzigen, gezielt eingesetzten Akzentfarbe. Keine Ablenkung, keine überladenen Menüs, volle Konzentration auf deine Inhalte. +### 🎨 Single-File CSS Theming & Hilfe +* **1 Datei = 1 Theme:** Vollständiges Design-System gesteuert über genau eine CSS-Datei (`dark`, `light`, `high-contrast`, `soft`). +* **Integrierte Hilfeseite:** Ein Klick auf das Profil-Menü oder den Hilfe-Button öffnet eine strukturierte Dokumentation inklusive Tastatur-Cheat-Sheet. --- -## 👥 Für wen ist MindSift gemacht? +## 🚀 Schnellstart -* **Für Vielbeschäftigte & Kreative**, die tagsüber hunderte Gedanken, To-Dos und Links im Kopf jonglieren und ein System suchen, das schneller ist als jede klassische Notiz-App. -* **Für Familien, Paare & WGs**, die gemeinsame Einkaufslisten, Erledigungen und Planungen ohne Werbe-Apps und ohne fremde Cloud synchronisieren möchten. -* **Für Teams, Selbstständige & Homelab-Enthusiasten**, die Aufgaben, Projektdokumentation, Server-Webhooks und Benutzerrechte souverän auf der eigenen Infrastruktur betreiben wollen. +MindSift läuft als leichtgewichtiger, einzelner Container und bringt Webserver, API, Datenbank und Frontend direkt mit. + +### Option 1: Der 1-Zeiler (Empfohlen) + +Führe diesen Befehl im Terminal deines Servers aus. Er erstellt das Verzeichnis, lädt die Konfiguration, erzeugt ein sicheres JWT-Geheimnis und startet den Container: + +```bash +curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/install.sh | bash +``` --- -## 🚀 In 60 Sekunden starten +### Option 2: Schneller Start mit zwei `curl`-Befehlen -MindSift läuft als einzelner, ressourcenschonender Container und bringt alles mit, was du brauchst. +Falls du die Dateien direkt ohne Skript herunterladen möchtest: -### Start mit Docker Compose +```bash +mkdir -p mindsift && cd mindsift +curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/docker-compose.yml -o docker-compose.yml +curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/.env.example -o .env +docker compose up -d +``` -Lege eine `docker-compose.yml` an: +--- + +### Option 3: Manuelle `docker-compose.yml` + +Erstelle eine Datei namens `docker-compose.yml`: ```yaml services: @@ -93,10 +125,9 @@ services: environment: - PORT=8080 - DATA_DIR=/data - - JWT_SECRET=bitte-hier-ein-langes-sicheres-geheimnis-eintragen + - JWT_SECRET=hier-ein-langes-sicheres-zufalls-geheimnis-eintragen - ALLOW_REGISTRATION=false - # Optional: Eigener SMTP-Server für E-Mail-Alarme - # (kann alternativ auch später direkt in der Admin-Oberfläche eingetragen werden) + # Optional: Mailserver für E-Mail-Alarme (auch im Admin-UI einstellbar) - SMTP_HOST= - SMTP_PORT=587 - SMTP_USER= @@ -105,20 +136,34 @@ services: - SMTP_TLS=true ``` -Container starten: +Starte den Container: ```bash docker compose up -d ``` -Öffne anschließend **`http://localhost:8080`** im Browser. Beim allerersten Aufruf führt dich MindSift automatisch durch die **Ersteinrichtung** für dein Administrator-Konto. +--- + +### 🌐 Ersteinrichtung aufrufen + +Öffne deinen Browser unter **`http://localhost:8080`** (bzw. die IP deines Servers). +MindSift erkennt den Erststart automatisch und führt dich durch den Einrichtungs-Assistenten zum Erstellen des Administrator-Kontos. + +--- + +## 👥 Für wen ist MindSift gemacht? + +* **Für Macher & Wissensarbeiter**, die tagsüber dutzende Gedanken, Links und Aufgaben festhalten müssen, ohne aus dem Arbeitsfluss gerissen zu werden. +* **Für Outliner-Fans**, die die Flexibilität von Logseq schätzen, aber ihre Notizen in einer klaren Ordnerstruktur auf dem eigenen Server haben wollen. +* **Für Familien & WGs**, die gemeinsame Einkaufs- und Packlisten ohne Werbe-Apps und fremde Clouds synchronisieren möchten. +* **Für Homelab- & Self-Hosting-Begeisterte**, die volle Datenhoheit, saubere Markdown-Dateien und automatisierte Webhooks auf eigener Hardware schätzen. --- ## 🔒 Deine Daten gehören dir -* **100 % Self-Hosted:** Keine externen Cloud-Dienste, keine versteckte Telemetrie, kein Tracking. -* **Portabel & Transparent:** Alle Workspace-Notizen liegen als lesbare Markdown-Dateien im Datenverzeichnis; zusätzlich kannst du jederzeit ein komplettes Backup deiner Daten als JSON-Datei herunterladen. +* **100 % Self-Hosted:** Keine fremden Cloud-Dienste, keine Telemetrie, kein Tracking. +* **Kein Vendor-Lock-in:** Alle Notizen liegen als lesbare `.md`-Dateien im Ordner `./data/workspace`. Backups können jederzeit als JSON exportiert werden. --- diff --git a/cmd/server/main.go b/cmd/server/main.go index da1b50e..b52aeb4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -34,6 +34,15 @@ func main() { // Seed SMTP defaults from environment variables into DB if not yet configured in DB store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS) + if cfg.DemoMode { + log.Printf("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...") + if err := store.EnsureDemoAccounts(); err != nil { + log.Printf("⚠️ Fehler beim Anlegen der Demo-Accounts: %v", err) + } else { + log.Printf("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)") + } + } + wsHub := ws.NewHub() staticFS, err := fs.Sub(embeddedDist, "dist") diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..fc22f8d --- /dev/null +++ b/compose.yaml @@ -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} diff --git a/docker-compose.yml b/docker-compose.yml index d06a86f..fc22f8d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,5 @@ services: mindsift: - build: - context: . - dockerfile: Dockerfile image: git.dsit.me/sandrodach/mindsift:dev container_name: mindsift restart: unless-stopped @@ -17,6 +14,8 @@ services: - BASE_URL=${BASE_URL:-http://localhost:8080} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false} - LOG_LEVEL=${LOG_LEVEL:-info} + # Demo mode: if true, automatically seeds demo accounts admin/admin and user/user + - MINDA_DEMO=${MINDA_DEMO:-false} # Optional SMTP / Mail Configuration (can also be configured in Admin UI) - SMTP_HOST=${SMTP_HOST:-} - SMTP_PORT=${SMTP_PORT:-587} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 493114c..5f09e67 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,9 +11,17 @@ import { ChevronDown, RotateCcw, Palette, + HelpCircle, } from 'lucide-react'; import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types'; -import { api, clearToken, getToken, triggerHaptic } from './lib/api'; +import { + api, + clearToken, + getToken, + triggerHaptic, + connectWebSocket, + disconnectWebSocket, +} from './lib/api'; import { AVAILABLE_THEMES, initThemeOnBoot, @@ -28,6 +36,7 @@ import { SharedListsView } from './features/lists/SharedListsView'; import { SetupAndAuthView } from './features/auth/SetupAndAuthView'; import { UserSettingsView } from './features/settings/UserSettingsView'; import { AdminSettingsView } from './features/settings/AdminSettingsView'; +import { HelpView } from './features/help/HelpView'; type ActiveTab = | 'triage' @@ -35,7 +44,8 @@ type ActiveTab = | 'workspace' | 'lists' | 'user_settings' - | 'admin_settings'; + | 'admin_settings' + | 'help'; interface ErrorBoundaryState { hasError: boolean; @@ -92,12 +102,14 @@ export const App: React.FC = () => { tasksOpenCount: 0, tasksDoneCount: 0, docsCount: 0, + docsTodosOpenCount: 0, listsCount: 0, discardedCount: 0, }); const [deck, setDeck] = useState([]); const [lists, setLists] = useState([]); const [activeTab, setActiveTab] = useState('triage'); + const [previousTab, setPreviousTab] = useState('triage'); const [selectedDocId, setSelectedDocId] = useState(null); const [captureFocusCount, setCaptureFocusCount] = useState(0); const [refreshRevision, setRefreshRevision] = useState(0); @@ -106,6 +118,13 @@ export const App: React.FC = () => { const [showProfileMenu, setShowProfileMenu] = useState(false); const dropdownRef = useRef(null); + const handleOpenHelp = () => { + triggerHaptic(10); + setPreviousTab((prev) => (activeTab === 'help' ? prev : activeTab)); + setActiveTab('help'); + setShowProfileMenu(false); + }; + const handleChangeTheme = (themeId: ThemeId) => { triggerHaptic(10); applyTheme(themeId); @@ -182,6 +201,35 @@ export const App: React.FC = () => { }; }, []); + // WebSocket Connection Management + useEffect(() => { + if (user && getToken()) { + connectWebSocket(); + } else { + disconnectWebSocket(); + } + }, [user?.id]); + + // Refresh stats on task/doc real-time events + useEffect(() => { + const handleWs = (e: any) => { + const msg = e.detail; + if (!msg) return; + if ( + typeof msg.event === 'string' && + (msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_') || msg.event.startsWith('SPACE_')) + ) { + api.getMe().then((res) => { + setStats(res.stats); + }).catch(() => {}); + } + }; + window.addEventListener('mindsift:ws', handleWs); + return () => { + window.removeEventListener('mindsift:ws', handleWs); + }; + }, []); + // Close profile dropdown on outside click useEffect(() => { const handleClickOutside = (e: MouseEvent) => { @@ -205,7 +253,10 @@ export const App: React.FC = () => { target: 'inbox' | 'task' | 'list_item' | 'document' = 'inbox' ) => { if (target === 'inbox' && thought.status === 'inbox') { - setDeck((prev) => [thought, ...prev]); + setDeck((prev) => { + if (prev.some((t) => t.id === thought.id)) return prev; + return [thought, ...prev]; + }); setStats((prev) => ({ ...prev, inboxCount: prev.inboxCount + 1 })); } else if (target === 'document' && thought.convertedRefId) { setSelectedDocId(thought.convertedRefId); @@ -228,6 +279,7 @@ export const App: React.FC = () => { const handleLogout = async () => { triggerHaptic(20); + disconnectWebSocket(); clearToken(); setUser(null); setShowProfileMenu(false); @@ -270,14 +322,14 @@ export const App: React.FC = () => {
{/* Top Header Bar */}
-
+
{/* Logo & Headline */}
{ setActiveTab('triage'); handleTriggerAddButton(); }} - className="flex items-center gap-2.5 cursor-pointer group select-none flex-shrink-0" + className="h-9 flex items-center gap-2.5 cursor-pointer group select-none flex-shrink-0" >
@@ -302,22 +354,22 @@ export const App: React.FC = () => {
{/* User Profile Pill & Dropdown Menu */} -
+
{showProfileMenu && ( @@ -375,6 +427,16 @@ export const App: React.FC = () => { Kontoeinstellungen + + {isStaff && (
{/* Navigation tabs attached to header */} -
-
- - setSetupPassword(e.target.value)} - placeholder="Mindestens 4 Zeichen" - minLength={4} - required - className="w-full h-8 px-2.5 input-field" - /> +
+
+ + setSetupPassword(e.target.value)} + placeholder="Mindestens 4 Zeichen" + minLength={4} + required + className="w-full h-8 px-2.5 input-field" + /> +
+
+ + 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 && ( + + Passwörter stimmen nicht überein + + )} +
@@ -399,13 +430,44 @@ export const SetupAndAuthView: React.FC = ({ {authTab === 'login' && (
+ {authStatus.demoMode && ( +
+
+ 🧪 Demo-Modus aktiv + Klick zum Ausfüllen: +
+
+ + +
+
+ )} +
- + setEmail(e.target.value)} - placeholder="name@beispiel.de" + placeholder="admin oder name@beispiel.de" required autoFocus className="w-full h-8 px-2.5 input-field" diff --git a/frontend/src/features/capture/InstantCaptureBar.tsx b/frontend/src/features/capture/InstantCaptureBar.tsx index 832505c..c2d5f82 100644 --- a/frontend/src/features/capture/InstantCaptureBar.tsx +++ b/frontend/src/features/capture/InstantCaptureBar.tsx @@ -37,11 +37,29 @@ export const InstantCaptureBar: React.FC = ({ const [isListening, setIsListening] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [justSavedLabel, setJustSavedLabel] = useState(null); + const [isMultiline, setIsMultiline] = useState(false); - const inputRef = useRef(null); + const inputRef = useRef(null); const containerRef = useRef(null); const recognitionRef = useRef(null); + const adjustHeight = (el?: HTMLTextAreaElement | null) => { + const targetEl = el || inputRef.current; + if (!targetEl) return; + targetEl.style.height = 'auto'; + if (targetEl.value) { + const scrollH = targetEl.scrollHeight; + targetEl.style.height = `${Math.min(scrollH, 140)}px`; + setIsMultiline(targetEl.value.includes('\n') || scrollH > 28); + } else { + setIsMultiline(false); + } + }; + + useEffect(() => { + adjustHeight(); + }, [content]); + useEffect(() => { inputRef.current?.focus(); }, [autoFocusTrigger]); @@ -77,6 +95,7 @@ export const InstantCaptureBar: React.FC = ({ inputRef.current?.focus(); } else if (e.key === 'Escape' && document.activeElement === inputRef.current) { setContent(''); + setIsMultiline(false); setShowOptions(false); inputRef.current?.blur(); } @@ -154,12 +173,16 @@ export const InstantCaptureBar: React.FC = ({ // Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers setContent(''); + setIsMultiline(false); setPriority('normal'); setDueDate(''); setTarget('inbox'); setShowOptions(false); setIsSubmitting(true); triggerHaptic(15); + if (inputRef.current) { + inputRef.current.style.height = 'auto'; + } const savedMsg = effectiveTarget === 'task' @@ -210,25 +233,36 @@ export const InstantCaptureBar: React.FC = ({ return (
- {/* Quick-Capture Input Bar (Radius controlled by --radius-pill) */} + {/* Quick-Capture Input Bar (Radius controlled by --radius-pill / multiline) */}
-
+
- setContent(e.target.value)} + onChange={(e) => { + setContent(e.target.value); + adjustHeight(e.target); + }} onKeyDown={(e) => { - if (e.key === 'Enter' && !e.shiftKey) { + if (e.key === 'Enter' && (!e.shiftKey || e.ctrlKey || e.metaKey)) { e.preventDefault(); executeCapture(); } @@ -237,24 +271,29 @@ export const InstantCaptureBar: React.FC = ({ isListening ? 'Ich höre zu…' : target === 'task' - ? 'Neue Aufgabe direkt anlegen… (Enter)' + ? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)' : target === 'list_item' ? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…` : target === 'document' - ? 'Neue Workspace-Notiz direkt anlegen…' - : 'Quick Dump: Gedanke einfach reinrotzen & später swipen… (C / Enter)' + ? 'Neue Workspace-Notiz direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)' + : 'Quick Dump: Gedanke reinrotzen… (C / Enter, Shift+Enter für Mehrzeiler)' } - className="w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none" + className="w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 overflow-y-auto" + style={{ minHeight: '22px', maxHeight: '140px' }} autoComplete="off" /> -
+
{/* Target & Options Toggle Pill */} + +
+

+ + Hilfe & Dokumentation +

+

+ Alles Wichtige zur Tastaturbedienung, Outliner-Notizen, Triage und Zusammenarbeit +

+
+
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder="In Hilfe suchen…" + className="w-full h-8 pl-8 pr-3 input-field text-xs bg-canvas" + /> +
+
+ + {/* Category Filter Pills */} +
+ {[ + { 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 ( + + ); + })} +
+ + {/* Grid of Documentation Cards */} +
+ {/* SECTION: WORKSPACE & OUTLINER */} + {(activeCategory === 'all' || activeCategory === 'workspace') && ( +
+
+

+ + Workspace: Der hierarchische Logseq-Outliner +

+

+ Jede Notiz in MindSift ist ein flexibler hierarchischer Outliner und wird als Standard-Markdown auf der Festplatte gespeichert. +

+
+ +
+
+

+ + Flüssige Tastatursteuerung +

+
    +
  • + Enter: Erzeugt sofort einen neuen Bullet-Point auf derselben Ebene. +
  • +
  • + Tab: Rückt den aktuellen Bullet-Point ein und ordnet ihn dem vorherigen Punkt unter. +
  • +
  • + Shift + Tab: Rückt den Bullet-Point wieder nach links aus (verringert die Tiefe). +
  • +
  • + Shift + Enter: Erzeugt einen weichen Zeilenumbruch innerhalb desselben Textblocks. +
  • +
  • + Backspace: Auf einem leeren Block rückt er die Ebene aus bzw. löscht den leeren Bullet. +
  • +
+
+ +
+

+ + Slash-Befehle (/) +

+

+ Tippe einfach / in einen leeren Block, um den Typ flexibel zu wechseln: +

+
+ /todo (Checkbox-Aufgabe) + /h1 (Haupt-Überschrift) + /h2 (Abschnitts-Titel) + /h3 (Unter-Überschrift) + /code (Code-Block) + /table (Markdown-Tabelle) + /callout (Hinweis-Box) + /progress (Fortschritt 0-100%) +
+
+ +
+

+ + Ordnerstruktur & Drag & Drop +

+

+ Notizen können im linken Datei-Explorer per Drag & Drop direkt auf beliebige Ordner gezogen werden, um sie zu verschieben. + Ziehst du eine Notiz auf das Hauptverzeichnis, wird sie aus allen Unterordnern herausgenommen. +

+
+ +
+

+ + Zoom-Hoisting & Favoriten anpinnen +

+
    +
  • + Klick auf den Bullet-Punkt (•): Fokussiert tief in diesen Ast hinein (Zoom-in Hoisting). Du kannst über die Breadcrumbs ganz oben jederzeit zurückspringen. +
  • +
  • + Bullet-Punkt ziehen: Verschiebt den Block samt aller eingerückten Unterelemente an eine neue Position. +
  • +
  • + Anpinnen: Wichtige Notizen heften sich ganz oben im Datei-Explorer unter „Angepinnte Notizen" an. +
  • +
+
+
+
+ )} + + {/* SECTION: TRIAGE & QUICK DUMP */} + {(activeCategory === 'all' || activeCategory === 'triage') && ( +
+
+

+ + Triage & Quick Dump: Der Kopf frei in 3 Sekunden +

+

+ Sofortiges Erfassen ohne Reibung. Gedanken werden gesammelt und anschließend im Triage-Deck mit 1 Taste entschieden. +

+
+ +
+
+

1. Blitz-Erfassung

+

+ Drücke überall C oder /, tippe den Gedanken ein und drücke Enter. +

+
+ +
+

2. Triage Entscheidungs-Tasten

+
    +
  • H oder ← : Wegwerfen / Erledigt
  • +
  • L oder → : Als Aufgabe anlegen
  • +
  • T : Aufgabe mit Fälligkeitsdatum
  • +
  • D : In Workspace-Notiz umwandeln
  • +
  • Leertaste : Später (ans Ende schieben)
  • +
+
+ +
+

3. Direkt-Zuordnung

+

+ Über das Dropdown neben der Eingabeleiste kannst du einen Eintrag schon beim Schreiben direkt einer Aufgabenliste, Einkaufsliste oder Notiz zuweisen. +

+
+
+
+ )} + + {/* SECTION: TASKS & LISTS */} + {(activeCategory === 'all' || activeCategory === 'tasks') && ( +
+
+

+ + Aufgaben, Listen & Team-Zusammenarbeit +

+

+ Verwalte Aufgabenlisten, Einkaufslisten und teile sie mit Teams und Räumen in Echtzeit. +

+
+ +
+
+

+ + Aufgabenlisten teilen & Avatar-Stack +

+

+ Jede Aufgabenliste kann über den Button „Teilen" 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. +

+
+ +
+

+ + Globale To-Dos Rollup +

+

+ Im Workspace-Tab „Globale To-Dos" werden automatisch alle offenen Checkboxen (- [ ]) aus sämtlichen Notizen und Ordnern aggregiert. + Ein Klick auf die Checkbox synchronisiert die Aufgabe in Echtzeit direkt in der physischen Markdown-Datei! +

+
+
+
+ )} + + {/* SECTION: SHORTCUTS CHEAT SHEET */} + {(activeCategory === 'all' || activeCategory === 'shortcuts') && ( +
+
+

+ + Tastatur-Kürzel Gesamtübersicht +

+

+ Schneller arbeiten ohne Maus: Alle Hotkeys auf einen Blick. +

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TasteBereichFunktion
C oder /GlobalFokus sofort in die Schnellerfassungsleiste setzen
EnterSchnellerfassungGedanken sofort speichern
EscSchnellerfassungEingabe leeren & Menüs schließen
EnterWorkspace OutlinerNeuen Bullet-Point auf gleicher Ebene anlegen
TabWorkspace OutlinerBullet-Point einrücken (dem vorherigen unterordnen)
Shift + TabWorkspace OutlinerBullet-Point ausrücken (Ebene verringern)
Shift + EnterWorkspace OutlinerWeicher Zeilenumbruch innerhalb des aktuellen Blocks
BackspaceWorkspace OutlinerBei leerem Block: Ebene ausrücken bzw. Bullet löschen
/Workspace OutlinerSlash-Command-Menü öffnen (/todo, /h1, /code, ...)
H oder ←Triage-DeckGedanken verwerfen / erledigen
L oder →Triage-DeckGedanken sofort zur Aufgabe machen
TTriage-DeckAufgabe mit Fälligkeitsdatum anlegen
DTriage-DeckGedanken in Notiz/Dokument umwandeln
LeertasteTriage-DeckKarte überspringen („Später" – ans Ende schieben)
+
+
+ )} +
+
+ ); +}; diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 54c0203..e1bdbff 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Plus, CheckCircle2, @@ -10,17 +10,62 @@ import { Radio, Sparkles, UserPlus, + X, } from 'lucide-react'; -import { ListItem, SharedList, Space, WSMessage } from '../../lib/types'; +import { ListItem, SharedList, Space, User, WSMessage } from '../../lib/types'; import { api, buildWebSocketURL, triggerHaptic } from '../../lib/api'; interface SharedListsViewProps { onRefreshGlobal: () => void; + currentUser?: User | null; } -const CATEGORIES = ['Obst & Gemüse', 'Kühlregal', 'Vorrat & Konserven', 'Backwaren', 'Getränke', 'Drogerie', 'Sonstiges']; +export function parseListItemInput(rawText: string, defaultQty?: string): { + content: string; + quantity: string; + category?: string; +} { + let text = rawText.trim(); + const hashtagRegex = /#([a-zA-Z0-9äöüÄÖÜß_-]+)/g; + const matches = Array.from(text.matchAll(hashtagRegex)); -export const SharedListsView: React.FC = ({ 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 = ({ onRefreshGlobal, currentUser }) => { const [spaces, setSpaces] = useState([]); const [activeSpace, setActiveSpace] = useState(null); const [lists, setLists] = useState([]); @@ -28,10 +73,8 @@ export const SharedListsView: React.FC = ({ onRefreshGloba const [newItemText, setNewItemText] = useState(''); const [newItemQty, setNewItemQty] = useState(''); - const [newItemCategory, setNewItemCategory] = useState(''); const [wsConnected, setWsConnected] = useState(false); - const [onlineUsers, setOnlineUsers] = useState(1); const [copiedInvite, setCopiedInvite] = useState(false); const [showNewListModal, setShowNewListModal] = useState(false); @@ -43,13 +86,25 @@ export const SharedListsView: React.FC = ({ onRefreshGloba const [showNewSpaceModal, setShowNewSpaceModal] = useState(false); const [newSpaceName, setNewSpaceName] = useState(''); + const [showMembersModal, setShowMembersModal] = useState(false); + const [collaborators, setCollaborators] = useState([]); + const [inviteTarget, setInviteTarget] = useState(''); + const [inviteRole, setInviteRole] = useState<'editor' | 'viewer'>('editor'); + const [invitingLoading, setInvitingLoading] = useState(false); + const [inviteError, setInviteError] = useState(null); + const wsRef = useRef(null); const loadSpaces = async () => { const sp = await api.getSpaces(); setSpaces(sp); - if (sp.length > 0 && !activeSpace) { - setActiveSpace(sp[0]); + if (sp.length > 0) { + if (!activeSpace) { + setActiveSpace(sp[0]); + } else { + const found = sp.find((s) => s.id === activeSpace.id); + if (found) setActiveSpace(found); + } } }; @@ -90,9 +145,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba ws.onmessage = (evt) => { try { const msg: WSMessage = JSON.parse(evt.data); - if (msg.event === 'USER_PRESENCE' && msg.payload?.onlineCount) { - setOnlineUsers(msg.payload.onlineCount); - } else if (msg.event === 'ITEM_CREATED' && msg.list_id) { + if (msg.event === 'ITEM_CREATED' && msg.list_id) { const newItem: ListItem = msg.payload; setLists((prev) => prev.map((l) => { @@ -125,6 +178,8 @@ export const SharedListsView: React.FC = ({ onRefreshGloba setLists((prev) => prev.map((l) => (l.id === msg.list_id ? updatedList : l))); } else if (msg.event === 'LIST_CREATED' || msg.event === 'LIST_DELETED') { loadListsForSpace(activeSpace.id); + } else if (msg.event === 'MEMBER_ADDED' || msg.event === 'MEMBER_REMOVED') { + loadSpaces(); } } catch { // ignore malformed frames @@ -139,6 +194,23 @@ export const SharedListsView: React.FC = ({ onRefreshGloba }; }, [activeSpace?.id]); + useEffect(() => { + const handleGlobalWs = (e: any) => { + const msg = e.detail; + if (!msg) return; + if ( + msg.event === 'SPACE_JOINED' || + msg.event === 'SPACE_LEFT' || + msg.event === 'MEMBER_ADDED' || + msg.event === 'MEMBER_REMOVED' + ) { + loadSpaces(); + } + }; + window.addEventListener('mindsift:ws', handleGlobalWs); + return () => window.removeEventListener('mindsift:ws', handleGlobalWs); + }, []); + const activeList = lists.find((l) => l.id === activeListId) || lists[0] || null; const handleAddItem = async (e: React.FormEvent) => { @@ -146,15 +218,12 @@ export const SharedListsView: React.FC = ({ onRefreshGloba if (!activeList || !newItemText.trim()) return; triggerHaptic(15); - const text = newItemText.trim(); - const qty = newItemQty.trim(); - const cat = newItemCategory || undefined; - + const parsed = parseListItemInput(newItemText, newItemQty); setNewItemText(''); setNewItemQty(''); try { - const item = await api.createListItem(activeList.id, text, qty, cat); + const item = await api.createListItem(activeList.id, parsed.content, parsed.category, parsed.quantity); setLists((prev) => prev.map((l) => (l.id === activeList.id ? { ...l, items: [item, ...l.items] } : l)) ); @@ -245,9 +314,123 @@ export const SharedListsView: React.FC = ({ onRefreshGloba setTimeout(() => setCopiedInvite(false), 2000); }; + const handleAddMember = async (e: React.FormEvent) => { + e.preventDefault(); + if (!activeSpace) return; + const target = inviteTarget.trim(); + if (!target) return; + setInvitingLoading(true); + setInviteError(null); + try { + const isId = collaborators.some((u) => u.id === target); + const newMember = await api.addSpaceMember(activeSpace.id, { + userId: isId ? target : undefined, + email: !isId ? target : undefined, + role: inviteRole, + }); + setActiveSpace((prev) => + prev + ? { + ...prev, + members: [...(prev.members || []).filter((m) => m.userId !== newMember.userId), newMember], + } + : prev + ); + setSpaces((prev) => + prev.map((s) => + s.id === activeSpace.id + ? { + ...s, + members: [...(s.members || []).filter((m) => m.userId !== newMember.userId), newMember], + } + : s + ) + ); + setInviteTarget(''); + onRefreshGlobal(); + } catch (err: any) { + setInviteError(err.message || 'Mitglied konnte nicht eingeladen werden'); + } finally { + setInvitingLoading(false); + } + }; + + const handleRemoveMember = async (targetUserId: string) => { + if (!activeSpace) return; + if (!window.confirm('Dieses Mitglied wirklich aus der Einkaufsliste / dem Bereich entfernen?')) return; + setInvitingLoading(true); + setInviteError(null); + try { + await api.removeSpaceMember(activeSpace.id, targetUserId); + setActiveSpace((prev) => + prev + ? { + ...prev, + members: (prev.members || []).filter((m) => m.userId !== targetUserId), + } + : prev + ); + setSpaces((prev) => + prev.map((s) => + s.id === activeSpace.id + ? { + ...s, + members: (s.members || []).filter((m) => m.userId !== targetUserId), + } + : s + ) + ); + onRefreshGlobal(); + } catch (err: any) { + setInviteError(err.message || 'Mitglied konnte nicht entfernt werden'); + } finally { + setInvitingLoading(false); + } + }; + const openItems = activeList?.items.filter((i) => !i.isCompleted) || []; const completedItems = activeList?.items.filter((i) => i.isCompleted) || []; + const groupedOpenItems = useMemo(() => { + const groupsMap = new Map(); + 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 (
{/* Top Space Bar & Realtime Indicator */} @@ -283,31 +466,34 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
- {/* Realtime Status & Invite Code Pill */} + {/* Realtime Status & Invite User Button */} {activeSpace && (
- - {wsConnected ? `Echtzeit-Sync (${onlineUsers} aktiv)` : 'Offline-Puffer'} +
@@ -435,14 +621,14 @@ export const SharedListsView: React.FC = ({ onRefreshGloba {/* Active Shared List Card */} {activeList ? (
- {/* Quick-Add Pill Form */} + {/* Quick-Add Form */}
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" />
@@ -463,28 +649,9 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
- - {/* Category Quick-Select Pills */} -
- Kategorie: - {CATEGORIES.map((cat) => ( - - ))} -
- {/* Open Items */} + {/* Open Items Grouped by Hashtags / Categories */}
{openItems.length === 0 ? (
@@ -492,39 +659,60 @@ export const SharedListsView: React.FC = ({ onRefreshGloba Alles erledigt. Keine offenen Punkte auf dieser Liste.
) : ( - openItems.map((item) => ( -
handleToggleItem(item)} - className="px-3 py-2 bg-canvas hover:bg-panel-elevated flex items-center justify-between gap-3 cursor-pointer transition-colors group" - > -
- - - {item.content} - - {item.quantity && ( - - {item.quantity} + groupedOpenItems.map((group) => ( +
+ {/* Category Group Header (only when categories are in use) */} + {group.label && ( +
+ + {group.label} - )} - {item.category && ( - - {item.category} + + {group.items.length} {group.items.length === 1 ? 'Eintrag' : 'Einträge'} - )} -
+
+ )} - + {group.items.map((item) => { + const cat = getItemCategory(item); + const displayContent = getItemDisplayContent(item); + + return ( +
handleToggleItem(item)} + className="px-3 py-2 bg-canvas hover:bg-panel-elevated flex items-center justify-between gap-3 cursor-pointer transition-colors group" + > +
+ + + {displayContent} + + {item.quantity && ( + + {item.quantity} + + )} + {cat && ( + + #{cat} + + )} +
+ + +
+ ); + })}
)) )} @@ -546,31 +734,206 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
- {completedItems.map((item) => ( -
handleToggleItem(item)} - className="px-3 py-1.5 bg-canvas/50 hover:bg-canvas/80 flex items-center justify-between gap-3 cursor-pointer transition-colors" - > -
- - - {item.content} - - {item.quantity && ( - ({item.quantity}) + {completedItems.map((item) => { + const cat = getItemCategory(item); + const displayContent = getItemDisplayContent(item); + + return ( +
handleToggleItem(item)} + className="px-3 py-1.5 bg-canvas/50 hover:bg-canvas/80 flex items-center justify-between gap-3 cursor-pointer transition-colors" + > +
+ + + {displayContent} + + {item.quantity && ( + ({item.quantity}) + )} + {cat && ( + + #{cat} + + )} +
+ {item.completedByName && ( + ✓ {item.completedByName} )}
- {item.completedByName && ( - ✓ {item.completedByName} - )} -
- ))} + ); + })}
)}
) : null} + + {/* Space & Shopping List Members Modal */} + {showMembersModal && activeSpace && ( +
+
+ {/* Modal Header */} +
+
+ +

+ Einkaufsliste teilen: {activeSpace.name} +

+
+ +
+ + {inviteError && ( +
+ {inviteError} +
+ )} + + {/* Invite User Form */} +
+
User direkt einladen
+
+ + +
+ + {/* Direct Username / Email input if user not selected from list */} + {(!inviteTarget || !collaborators.some((u) => u.id === inviteTarget)) && ( + 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" + /> + )} + +
+ +
+
+ + {/* Current Space Members List */} +
+
+ Mitglieder in dieser Liste ({activeSpace.members?.length || 1}) +
+
+ {(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 ( +
+
+
+ {(m.displayName || m.email || '?')[0].toUpperCase()} +
+
+
+ {m.displayName || m.email} {isMe && (Du)} +
+
{m.email}
+
+
+ +
+ + {isOwner ? 'Eigentümer' : m.role === 'editor' ? 'Bearbeiter' : 'Leser'} + + {canRemove && ( + + )} +
+
+ ); + })} +
+
+ + {/* Invite Code Alternative */} +
+ Alternativ per Code beitreten: + +
+
+
+ )}
); }; diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index d48ea80..0f1a057 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef, useMemo } from 'react'; import { Check, Plus, @@ -15,8 +15,9 @@ import { Layers, Share2, Users, + CheckSquare, } from 'lucide-react'; -import { TaskList, Thought, Space } from '../../lib/types'; +import { TaskList, Thought, Space, SubTask } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; const getInitials = (name?: string): string => { @@ -67,6 +68,10 @@ export const TasksView: React.FC = ({ const [activeNotesTaskId, setActiveNotesTaskId] = useState(null); const [notesDraft, setNotesDraft] = useState(''); + // Subtask creation state + const [addingSubtaskTaskId, setAddingSubtaskTaskId] = useState(null); + const [subtaskDraft, setSubtaskDraft] = useState(''); + const loadData = async () => { setLoading(true); try { @@ -91,6 +96,80 @@ export const TasksView: React.FC = ({ loadData(); }, [refreshTrigger, selectedListId]); + const selectedListIdRef = useRef(selectedListId); + selectedListIdRef.current = selectedListId; + + const activeNotesTaskIdRef = useRef(activeNotesTaskId); + activeNotesTaskIdRef.current = activeNotesTaskId; + + const onRefreshStatsRef = useRef(onRefreshStats); + onRefreshStatsRef.current = onRefreshStats; + + // Live-Sync WebSocket listener for tasks (registered once on mount) + useEffect(() => { + const handleWs = (e: any) => { + const msg = e.detail; + if (!msg || !msg.event) return; + + if (msg.event === 'TASK_CREATED') { + const created = msg.payload as Thought; + if ( + created && + (created.status === 'converted' || + created.kanbanStatus || + created.taskListId || + created.isCompleted !== undefined) + ) { + setTasks((prev) => { + if (prev.some((t) => t.id === created.id)) return prev; + if (selectedListIdRef.current !== 'all' && created.taskListId !== selectedListIdRef.current) return prev; + return [created, ...prev]; + }); + onRefreshStatsRef.current(); + } + } else if (msg.event === 'TASK_UPDATED') { + const updated = msg.payload as Thought; + if (updated && updated.id) { + setTasks((prev) => { + const exists = prev.some((t) => t.id === updated.id); + if (!exists) { + if ( + (updated.status === 'converted' || + updated.kanbanStatus || + updated.taskListId || + updated.isCompleted !== undefined) && + (selectedListIdRef.current === 'all' || updated.taskListId === selectedListIdRef.current) + ) { + return [updated, ...prev]; + } + return prev; + } + if (selectedListIdRef.current !== 'all' && updated.taskListId !== selectedListIdRef.current) { + return prev.filter((t) => t.id !== updated.id); + } + return prev.map((t) => { + if (t.id !== updated.id) return t; + return { + ...updated, + notes: activeNotesTaskIdRef.current === updated.id ? t.notes : updated.notes, + }; + }); + }); + onRefreshStatsRef.current(); + } + } else if (msg.event === 'TASK_DELETED') { + const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id; + if (deletedId) { + setTasks((prev) => prev.filter((t) => t.id !== deletedId)); + onRefreshStatsRef.current(); + } + } + }; + + window.addEventListener('mindsift:ws', handleWs); + return () => window.removeEventListener('mindsift:ws', handleWs); + }, []); + const handleToggleDone = async (task: Thought) => { triggerHaptic(15); const nextState = !task.isCompleted; @@ -125,6 +204,168 @@ export const TasksView: React.FC = ({ 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 ( +
+
+ + + + {done}/{total} Unteraufgaben + + + + {percent}% + +
+
+
+
+
+ ); + }; + + const renderSubtaskList = (task: Thought) => { + const hasSubtasks = task.subtasks && task.subtasks.length > 0; + const isAdding = addingSubtaskTaskId === task.id; + + if (!hasSubtasks && !isAdding) return null; + + return ( +
+ {hasSubtasks && ( +
+ {task.subtasks!.map((sub) => ( +
+ + +
+ ))} +
+ )} + + {isAdding && ( +
{ + e.preventDefault(); + handleAddSubtask(task, subtaskDraft); + }} + className="flex items-center gap-1 pt-1" + > + 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" + /> + + +
+ )} +
+ ); + }; + const handleConvertToDoc = async (task: Thought) => { triggerHaptic(20); const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' }); @@ -168,7 +409,10 @@ export const TasksView: React.FC = ({ target: 'task', taskListId: selectedListId === 'all' ? '' : selectedListId, }); - setTasks((prev) => [created, ...prev]); + setTasks((prev) => { + if (prev.some((t) => t.id === created.id)) return prev; + return [created, ...prev]; + }); onRefreshStats(); } catch { loadData(); @@ -234,7 +478,18 @@ export const TasksView: React.FC = ({ } }; - const safeTasks = Array.isArray(tasks) ? tasks : []; + const safeTasks = useMemo(() => { + if (!Array.isArray(tasks)) return []; + const seen = new Set(); + const unique: Thought[] = []; + for (const t of tasks) { + if (t && t.id && !seen.has(t.id)) { + seen.add(t.id); + unique.push(t); + } + } + return unique; + }, [tasks]); const safeTaskLists = Array.isArray(taskLists) ? taskLists : []; const safeSpaces = Array.isArray(spaces) ? spaces : []; const safeDiscarded = Array.isArray(discarded) ? discarded : []; @@ -645,7 +900,7 @@ export const TasksView: React.FC = ({ className="panel-surface p-2.5 space-y-2 hover:border-strong transition-colors" >
-

+

{task.content}

+ {/* Subtasks Progress Bar on Title */} + {renderTaskProgressBar(task)} + {/* Notes Preview / Inline Editor */} {activeNotesTaskId === task.id ? (