diff --git a/BRAIN.md b/BRAIN.md index 9cfe421..c232d29 100644 --- a/BRAIN.md +++ b/BRAIN.md @@ -1,5 +1,5 @@ # 🧠 BRAIN.md — MindSift Internal Dev Cheat Sheet & Architecture Log -> **Zweck:** Zentrales, technisch präzises Nachschlagewerk für Entwicklung, Design-System, Datenbank-Architektur, State-Machines und aktuellen Umsetzungsstand. Wird bei jeder Änderung aktuell gehalten. +> **Zweck:** Zentrales, technisch präzises Nachschlagewerk für Entwicklung, Design-System, Datenbank-Architektur, State-Machines, Backend-APIs und aktuellen Umsetzungsstand. Wird bei jeder Änderung aktuell gehalten. --- @@ -18,11 +18,29 @@ - **Quick Dump (Standard):** Text oben eingeben + `Enter` wirft den Gedanken sofort als Quick Dump (`status: 'inbox'`) ins Triage-Deck. - **Direkt-Zuordnung bei der Eingabe:** Über das Auswahl-Menü (`Quick Dump ▾`) in der Eingabeleiste kann ein Eintrag alternativ direkt als **Aufgabe** (`target: 'task'`), direkt auf eine **Geteilte Liste** (`target: 'list_item'`) oder direkt als **Workspace-Notiz** (`target: 'document'`) gespeichert werden. - **Triage-Entscheidung & Warteschlange:** Im Triage-Deck per Swipe/Klick **Wegwerfen** (`←` / `H`), **Später** (`Leertaste` – ans Ende schieben), **Auf Liste**, **Als Notiz** (`D`), **Mit Termin** (`T`) oder **Als Aufgabe** (`→` / `L`). Inklusive Inline-Textbearbeitung und Warteschlangen-Liste unter der aktiven Karte. -- [x] **Root-Cause Fixes für Browser-Hänger & Cross-Browser-Kompatibilität (Chrome & Firefox):** - - **SQLite Connection-Pool Deadlock behoben (`internal/storage/sqlite.go`):** Vorher war `db.SetMaxOpenConns(1)` aktiv, während `GetAllListsForUser`, `GetListsForSpace` und `GetSpacesForUser` innerhalb von `for rows.Next()` (bei offenem Cursor!) Unterabfragen (`s.GetListItems`, `s.GetSpaceMembers`) aufriefen. Sobald eine Liste existierte, blockierte die einzige DB-Verbindung dauerhaft und alle nachfolgenden Requests (`POST /api/v1/inbox` etc.) hingen bis zum 15-Minuten-TCP-Timeout. Jetzt werden alle `rows` strikt vor jeder Unterabfrage geschlossen (`rows.Close()`), WAL-Pragmas + `busy_timeout(10000)` per DSN übergeben und `MaxOpenConns(8)` gesetzt. - - **Stale JWT User-Auflösung (`internal/api/server.go`):** Wenn nach einem Container-Neustart eine neue SQLite-DB initialisiert wird, löst `authMiddleware` alte Browser-Tokens automatisch auf `EnsureDefaultUser()` (mit `sync.Mutex` gegen Race-Conditions) auf. - - **Service-Worker & Cache-Busting (`frontend/public/sw.js`, `frontend/index.html`, `internal/api/server.go`):** Kein Caching veralteter Bundles in Firefox/Chrome; `Cache-Control: no-store` auf allen `/api/*`-Routen und `index.html`. - - **Optimistisches Input-Clearing & Request-Timeout (`frontend/src/lib/api.ts`, `InstantCaptureBar.tsx`):** Eingabefeld leert sich sofort (<10ms) bei `Enter`, alle Fetch-Calls haben einen 8-Sekunden-`AbortController`-Timeout. +- [x] **Mehrere Aufgabenlisten & Kanban-Board (`TasksView.tsx`, `models.go`, `sqlite.go`):** + - Beliebig viele Aufgabenlisten (z.B. Privat, Arbeit, Homelab) mit eigenem Farb- und Icon-Code. + - Teilen von Aufgabenlisten mit Teams/Spaces. + - Umschalter zwischen kompakter Listenansicht und 3-Spalten-Kanban-Board (`Zu erledigen`, `In Arbeit`, `Erledigt`) mit 1-Klick-Statuswechsel, Fälligkeitsdaten und Notizfeldern. +- [x] **Benutzer- & Rollensteuerung + Admin-Panel (`SettingsModal.tsx`, `server.go`, `sqlite.go`):** + - Benutzerrollen: `admin` und `member`. + - Admin-Verwaltungsbereich zur Erstellung von Accounts, Rollenwechsel, Deaktivierung und Passwort-Reset. + - Server-weite Sharing-Isolations-Modi: `open` (alle finden sich), `teams_only` (nur innerhalb von Teams/Organisationen sichtbar) und `strict_private` (kein Teilen). + - Organisationen & Teams zur Strukturierung von Benutzergruppen. +- [x] **Workspace Markdown-Wiki mit Notion-/Word-Toolbar & Disk-Sync (`WorkspaceView.tsx`, `sqlite.go`):** + - Word-/Notion-artige Formatierungsleiste oben (Fett, Kursiv, Durchgestrichen, Code, Links, Überschriften H1-H3, Checklisten, Aufzählungen, Callouts, Markdown-Tabellen, Fortschrittsbalken). + - Interne Wiki-Links (`[[Dokumenttitel]]`) mit Klick-Navigation und Auto-Erkennung. + - Physische Speicherung auf Festplatte unter `/data/workspace//...` als Standard-`.md`-Dateien zur einfachen Daten-Extraktion, synchronisiert mit SQLite. +- [x] **Webhooks, Alarm-Scheduler & Discord-Integration (`SettingsModal.tsx`, `server.go`):** + - "Webhooks & Export" aus dem Header in das Benutzereinstellungen-Modal verlagert. + - Eigene API-Tokens mit curl-Beispielen für Skripte und externe Alarme. + - Hintergrund-Goroutine im Backend prüft fällige Aufgaben und sendet rechtzeitig (z.B. 15, 30 oder 60 Min. vor Fälligkeit) formatierte Discord-Embeds via Webhook. + - Vollständiger JSON-DB-Export auf Knopfdruck. +- [x] **Cross-Browser & Performance Fixes (Chrome & Firefox):** + - SQLite Connection-Pool Deadlock durch striktes Schließen von `sql.Rows` vor Unterabfragen behoben. + - Stale Token Auto-Resolution via `EnsureDefaultUser()`. + - `Cache-Control: no-store` auf allen API-Routen und `index.html`. + - Optimistisches Leeren des Capture-Inputs und 8-Sekunden-Fetch-Timeout. --- @@ -66,7 +84,58 @@ Alle Farben, Abstände und Radien werden zentral in [`frontend/src/index.css`](f --- -## ⚙️ 2. State-Machine: Quick Dump, Direkt-Zuordnung & Triage +## 🗄️ 2. Datenbank-Architektur (SQLite Schema & Tabellen) + +MindSift speichert alle Daten lokal und hochperformant in SQLite (`/data/mindsift.db`). + +### Tabellen-Übersicht + +1. **`users`** + - `id TEXT PRIMARY KEY`, `email TEXT UNIQUE`, `password_hash TEXT`, `display_name TEXT` + - `role TEXT DEFAULT 'member'` (`'admin'` | `'member'`) + - `is_active INTEGER DEFAULT 1` + - `discord_webhook_url TEXT DEFAULT ''` + - `notify_before_minutes INTEGER DEFAULT 15` + - `created_at DATETIME`, `updated_at DATETIME` + +2. **`thoughts` (Quick Dumps, Triage & Aufgaben)** + - `id TEXT PRIMARY KEY`, `user_id TEXT`, `content TEXT`, `source TEXT`, `priority TEXT` + - `status TEXT` (`'inbox'` | `'converted'` | `'discarded'`) + - `converted_type TEXT` (`'task'` | `'document'` | `'list_item'`) + - `converted_ref_id TEXT` (Referenz auf Document oder ListItem) + - `task_list_id TEXT` (FK auf `task_lists.id`) + - `kanban_status TEXT DEFAULT 'todo'` (`'todo'` | `'in_progress'` | `'done'`) + - `notes TEXT DEFAULT ''` (zusätzliche Aufgabenbeschreibung/Notizen) + - `due_date DATETIME` (Fälligkeitsdatum) + - `alarm_sent INTEGER DEFAULT 0` (Flag für Discord-Benachrichtigung) + - `created_at DATETIME`, `updated_at DATETIME` + +3. **`task_lists` (Mehrere Aufgabenlisten)** + - `id TEXT PRIMARY KEY`, `user_id TEXT`, `space_id TEXT` (optional für Team-Sharing) + - `name TEXT NOT NULL`, `color TEXT DEFAULT '#2563eb'`, `icon TEXT DEFAULT 'list'` + - `is_default INTEGER DEFAULT 0`, `created_at DATETIME`, `updated_at DATETIME` + +4. **`organizations` & `teams` & `team_members`** + - `organizations`: `id TEXT PRIMARY KEY`, `name TEXT`, `owner_id TEXT`, `created_at DATETIME`, `updated_at DATETIME` + - `teams`: `id TEXT PRIMARY KEY`, `organization_id TEXT`, `name TEXT`, `created_at DATETIME` + - `team_members`: `team_id TEXT`, `user_id TEXT`, `role TEXT DEFAULT 'member'`, `joined_at DATETIME`, `PRIMARY KEY (team_id, user_id)` + +5. **`system_settings` (Admin-Konfiguration)** + - `key TEXT PRIMARY KEY`, `value TEXT`, `updated_at DATETIME` + - Wichtige Schlüssel: + - `sharing_isolation_mode`: `'open'` | `'teams_only'` | `'strict_private'` + - `allow_public_registration`: `'true'` | `'false'` + +6. **`documents` & `document_blocks` (Workspace & Disk Sync)** + - `documents`: `id TEXT PRIMARY KEY`, `user_id TEXT`, `space_id TEXT`, `title TEXT`, `folder_path TEXT DEFAULT '/'`, `is_archived INTEGER`, `created_at DATETIME`, `updated_at DATETIME` + - `document_blocks`: `id TEXT PRIMARY KEY`, `document_id TEXT`, `block_type TEXT` (`paragraph`, `heading1`, `heading2`, `heading3`, `todo`, `bullet`, `callout`, `code`, `table`, `progress`), `content TEXT`, `order_index INTEGER`, `metadata TEXT`, `updated_at DATETIME` + +7. **`spaces`, `space_members`, `shared_lists`, `list_items`, `api_keys`** + - Verwaltung geteilter Einkaufs-/Checklisten und privater Ingestion-Keys. + +--- + +## ⚙️ 3. State-Machine: Quick Dump, Direkt-Zuordnung & Triage ``` [ HEADER QUICK-CAPTURE BAR ] @@ -87,19 +156,66 @@ Alle Farben, Abstände und Radien werden zentral in [`frontend/src/index.css`](f ``` ### API-Endpunkt für Capture (`POST /api/v1/inbox`) -Akzeptiert neben `content`, `priority`, `source`, `dueDate` auch direkt: -- `target`: `'inbox'` (Standard Quick Dump) | `'task'` | `'list_item'` | `'document'` +Akzeptiert: +- `content`: Fließtext des Gedankens +- `priority`: `'low'` | `'medium'` | `'high'` +- `target`: `'inbox'` (Standard) | `'task'` | `'list_item'` | `'document'` +- `taskListId`: ID der Aufgabenliste (wenn `target === 'task'`) - `targetListId`: ID der Zielliste (wenn `target === 'list_item'`) +- `dueDate`: Optionales ISO-Datum --- -## 🛡️ 3. Kritische Backend-Regeln (SQLite & HTTP) +## 🔔 4. Discord-Alarm-Scheduler & Webhook-Architektur + +1. **Hintergrund-Ticker im Go-Backend (`internal/api/server.go`):** + - Ein Hintergrund-Goroutine-Ticker läuft alle 60 Sekunden. + - Fragt `GetDueTasksForAlarm()` ab: Sucht nach offenen Aufgaben (`status = 'converted'`, `converted_type = 'task'`, `kanban_status != 'done'`), deren `due_date` in der Vorwarnzeit liegt (`due_date <= now + notifyBeforeMinutes`) und bei denen `alarm_sent == 0` ist. + - Sendet ein JSON-Embed an die in den Benutzer-Einstellungen hinterlegte `discord_webhook_url`: + ```json + { + "username": "MindSift Reminder", + "embeds": [{ + "title": "⏰ Aufgabe fällig: ", + "description": "", + "color": 2450411, + "fields": [ + {"name": "Fällig am", "value": "", "inline": true}, + {"name": "Priorität", "value": "", "inline": true} + ] + }] + } + ``` + - Markiert den Gedanken in SQLite mit `MarkTaskAlarmSent(taskID)` als benachrichtigt. + +2. **Eingehende Webhooks / Private API-Tokens:** + - Erreichbar unter `POST /api/v1/inbox` mit Header `X-API-Key: ms_...`. + - Ermöglicht direkte Einbindung in Shell-Skripte, Crons oder Drittsysteme via `curl`. + +--- + +## 📝 5. Workspace: Word/Notion-Toolbar, Wiki-Links & Disk-Sync + +1. **Formatierungs-Werkzeuge:** + - Markdown-Syntax wird in Echtzeit formatiert und visuell aufbereitet. + - Buttons für H1, H2, H3, Fett (`**`), Kursiv (`*`), Durchgestrichen (`~~`), Code-Blöcke (```` ``` ````), Aufzählungen (`- `), Todos (`- [ ] `), Callouts (`> `), Markdown-Tabellen (`| Spalte 1 | Spalte 2 |`) und Fortschrittsbalken (`[progress:65%]`). +2. **Wiki-Links (`[[Dokumenttitel]]`):** + - Interne Querverweise werden als interaktive Links gerendert. + - Ein Klick auf `[[Dokumenttitel]]` durchsucht die Workspace-Dokumente und öffnet das verlinkte Dokument direkt im Editor. +3. **Physische Speicherung auf Festplatte:** + - Jede Notiz wird zusätzlich zur SQLite-Tabelle als reine `.md`-Datei unter `/data/workspace///.md` gespeichert. + - Ermöglicht jederzeitige manuelle Datensicherung, Git-Versionierung oder Backup-Extraktion ohne Datenbank-Abhängigkeit. + +--- + +## 🛡️ 6. Kritische Backend-Regeln (SQLite & HTTP) 1. **NIEMALS verschachtelte Queries bei offenem `sql.Rows`-Cursor ausführen:** - In [`internal/storage/sqlite.go`](file:///home/sandro/Dokumente/git/MindSift/internal/storage/sqlite.go) muss jede Schleife `for rows.Next()` zuerst alle Zeilen in ein Slice einlesen und sofort `_ = rows.Close()` aufrufen, **bevor** Folgeabfragen (`GetListItems`, `GetSpaceMembers`, `QueryRow` etc.) ausgeführt werden. 2. **SQLite DSN-Pragmas:** - Jede Verbindung im `sql.DB`-Pool erhält über den DSN automatisch: `?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)` + - `MaxOpenConns(8)` und `MaxIdleConns(4)` konfiguriert. 3. **Thread-Safe Default User (`EnsureDefaultUser`):** - Durch `s.initMu.Lock()` abgesichert, damit parallele Browser-Requests beim ersten Laden niemals in einen `UNIQUE constraint`-Konflikt laufen. 4. **Kein Browser-Caching auf API & `index.html`:** @@ -107,7 +223,7 @@ Akzeptiert neben `content`, `priority`, `source`, `dueDate` auch direkt: --- -## 📡 4. WebSocket Realtime Protocol (Geteilte Listen) +## 📡 7. WebSocket Realtime Protocol (Geteilte Listen) Verbindungs-URL: `ws(s):///api/v1/ws/spaces/:spaceId?token=` @@ -120,7 +236,7 @@ Verbindungs-URL: `ws(s):///api/v1/ws/spaces/:spaceId?token=` --- -## ⌨️ 5. Tastatur-Shortcuts (Desktop) +## ⌨️ 8. Tastatur-Shortcuts (Desktop) | Taste | Kontext | Aktion | | :--- | :--- | :--- | @@ -135,7 +251,7 @@ Verbindungs-URL: `ws(s):///api/v1/ws/spaces/:spaceId?token=` --- -## 🚀 6. Gitea CI/CD & Docker Workflow +## 🚀 9. Gitea CI/CD & Docker Workflow - **Arbeits-Branch:** `dev` (`origin/dev`) - **Ziel-Image:** `git.dsit.me/sandrodach/mindsift:dev` diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3aa0cc0..b7c874d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,14 +4,7 @@ import { CheckSquare, FileText, ShoppingCart, - Webhook, Brain, - LogOut, - User as UserIcon, - Bell, - Shield, - ChevronRight, - Sparkles, } from 'lucide-react'; import { DashboardStats, SharedList, Thought, User } from './lib/types'; import { api, clearToken, getToken, triggerHaptic } from './lib/api'; @@ -20,9 +13,9 @@ import { TriageDeck } from './features/triage/TriageDeck'; import { TasksView } from './features/triage/TasksView'; import { WorkspaceView } from './features/workspace/WorkspaceView'; import { SharedListsView } from './features/lists/SharedListsView'; -import { EcosystemView } from './features/settings/EcosystemView'; +import { SettingsModal } from './features/settings/SettingsModal'; -type ActiveTab = 'triage' | 'tasks' | 'workspace' | 'lists' | 'ecosystem'; +type ActiveTab = 'triage' | 'tasks' | 'workspace' | 'lists'; export const App: React.FC = () => { const [user, setUser] = useState(null); @@ -41,14 +34,7 @@ export const App: React.FC = () => { const [captureFocusCount, setCaptureFocusCount] = useState(0); const [refreshRevision, setRefreshRevision] = useState(0); - // Profile & Auth Modal State (aligned with Template Screen 2) const [showProfileModal, setShowProfileModal] = useState(false); - const [authMode, setAuthMode] = useState<'login' | 'register'>('login'); - const [email, setEmail] = useState(''); - const [password, setPassword] = useState(''); - const [displayName, setDisplayName] = useState(''); - const [authError, setAuthError] = useState(''); - const [notificationsEnabled, setNotificationsEnabled] = useState(true); const refreshAll = async () => { try { @@ -129,26 +115,6 @@ export const App: React.FC = () => { setCaptureFocusCount((c) => c + 1); }; - const handleAuthSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setAuthError(''); - triggerHaptic(15); - try { - if (authMode === 'login') { - await api.login(email, password); - } else { - await api.register(email, password, displayName); - } - setShowProfileModal(false); - setEmail(''); - setPassword(''); - setDisplayName(''); - await refreshAll(); - } catch (err: any) { - setAuthError(err.message || 'Authentifizierung fehlgeschlagen'); - } - }; - const handleLogout = async () => { triggerHaptic(20); clearToken(); @@ -270,16 +236,6 @@ export const App: React.FC = () => { Geteilte Listen - @@ -316,137 +272,20 @@ export const App: React.FC = () => { {activeTab === 'lists' && ( )} - - {activeTab === 'ecosystem' && ( - - )} - {/* Profile & Settings Modal styled identically to Template Screen 2 */} + {/* Profile, Settings, Discord Alarms, Webhooks, Teams & Admin Modal */} {showProfileModal && ( -
-
- {/* Screen 2: Top Profile Card */} -
-
- {user?.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'} -
-

- {user?.displayName || 'Standard Benutzer'} -

-

- {user?.email || 'lokal@mindsift.local'} -

- - {/* Template Screen 2: Blue Pill Edit Button */} - -
- - {/* Settings Sections */} -
- {/* Notification toggle */} -
-
-
- -
- Benachrichtigungen -
- -
- - {/* Security info */} -
-
-
- -
-
- Sicherheit - Lokale SQLite VerschlĂźsselung -
-
- -
-
- - {/* Auth Switcher Form if switching accounts */} -
- {authMode === 'register' && ( - setDisplayName(e.target.value)} - placeholder="Anzeigename" - className="w-full h-8 px-2.5 bg-[#16171a] text-xs text-white placeholder-zinc-500 border border-white/[0.08] focus:border-[#1d8cf8] focus:outline-none" - required - /> - )} - setEmail(e.target.value)} - placeholder="E-Mail" - className="w-full h-8 px-2.5 bg-[#16171a] text-xs text-white placeholder-zinc-500 border border-white/[0.08] focus:border-[#1d8cf8] focus:outline-none" - required - /> - setPassword(e.target.value)} - placeholder="Passwort" - className="w-full h-8 px-2.5 bg-[#16171a] text-xs text-white placeholder-zinc-500 border border-white/[0.08] focus:border-[#1d8cf8] focus:outline-none" - required - /> - - {authError &&

{authError}

} - - -
- - {/* Log Out Row matching Template Screen 2 */} -
- - -
-
-
+ setShowProfileModal(false)} + onUserUpdated={(u) => { + setUser(u); + refreshAll(); + }} + onLoggedOut={handleLogout} + /> )} ); diff --git a/frontend/src/features/settings/SettingsModal.tsx b/frontend/src/features/settings/SettingsModal.tsx new file mode 100644 index 0000000..db0da22 --- /dev/null +++ b/frontend/src/features/settings/SettingsModal.tsx @@ -0,0 +1,953 @@ +import React, { useEffect, useState } from 'react'; +import { + X, + User as UserIcon, + Shield, + Bell, + Key, + Users, + Settings as SettingsIcon, + Plus, + Trash2, + Check, + Copy, + RefreshCw, + LogOut, + Terminal, + Download, + AlertTriangle, + Lock, + Share2, +} from 'lucide-react'; +import { APIKey, Organization, SystemSettings, Team, User } from '../../lib/types'; +import { api, clearToken, getToken, triggerHaptic } from '../../lib/api'; + +interface SettingsModalProps { + currentUser: User | null; + onClose: () => void; + onUserUpdated: (u: User) => void; + onLoggedOut: () => void; +} + +type SettingsTab = 'profile' | 'alarms' | 'webhooks' | 'teams' | 'admin'; + +export const SettingsModal: React.FC = ({ + currentUser, + onClose, + onUserUpdated, + onLoggedOut, +}) => { + const [activeTab, setActiveTab] = useState('profile'); + + // --- Profile State --- + const [displayName, setDisplayName] = useState(currentUser?.displayName || ''); + const [email, setEmail] = useState(currentUser?.email || ''); + const [newPassword, setNewPassword] = useState(''); + const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); + + // --- Alarms / Discord State --- + const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser?.discordWebhookUrl || ''); + const [notifyBeforeMinutes, setNotifyBeforeMinutes] = useState(currentUser?.notifyBeforeMinutes || 30); + const [alarmMsg, setAlarmMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); + const [testingDiscord, setTestingDiscord] = useState(false); + + // --- Webhooks / API Keys State --- + const [apiKeys, setApiKeys] = useState([]); + const [newKeyName, setNewKeyName] = useState(''); + const [newlyCreatedKey, setNewlyCreatedKey] = useState(null); + const [copiedKey, setCopiedKey] = useState(false); + + // --- Organizations & Teams State --- + const [orgs, setOrgs] = useState([]); + const [teams, setTeams] = useState([]); + const [newOrgName, setNewOrgName] = useState(''); + const [selectedOrgId, setSelectedOrgId] = useState(''); + const [newTeamName, setNewTeamName] = useState(''); + const [newTeamDesc, setNewTeamDesc] = useState(''); + + // --- Admin State --- + const [adminUsers, setAdminUsers] = useState([]); + const [systemSettings, setSystemSettings] = useState({ + sharingMode: 'teams_only', + allowRegistration: true, + instanceName: 'MindSift', + }); + const [newAdminUserEmail, setNewAdminUserEmail] = useState(''); + const [newAdminUserPassword, setNewAdminUserPassword] = useState(''); + const [newAdminUserDisplayName, setNewAdminUserDisplayName] = useState(''); + const [newAdminUserRole, setNewAdminUserRole] = useState<'member' | 'admin'>('member'); + const [resetPwdUserId, setResetPwdUserId] = useState(null); + const [resetPwdVal, setResetPwdVal] = useState(''); + + useEffect(() => { + loadKeys(); + loadOrgsAndTeams(); + if (currentUser?.role === 'admin') { + loadAdminData(); + } + }, []); + + const loadKeys = async () => { + try { + const keys = await api.getAPIKeys(); + setApiKeys(keys); + } catch { + // ignore + } + }; + + const loadOrgsAndTeams = async () => { + try { + const [o, t] = await Promise.all([api.getOrganizations(), api.getUserTeams()]); + setOrgs(o); + setTeams(t); + if (o.length > 0 && !selectedOrgId) { + setSelectedOrgId(o[0].id); + } + } catch { + // ignore + } + }; + + const loadAdminData = async () => { + try { + const [u, s] = await Promise.all([api.adminGetUsers(), api.adminGetSettings()]); + setAdminUsers(u); + setSystemSettings(s); + } catch { + // ignore + } + }; + + // --- Profile Handlers --- + const handleSaveProfile = async (e: React.FormEvent) => { + e.preventDefault(); + setProfileMsg(null); + try { + const patch: any = { displayName, email }; + if (newPassword.trim()) { + patch.password = newPassword.trim(); + } + const updated = await api.updateSettings(patch); + onUserUpdated(updated); + setNewPassword(''); + setProfileMsg({ type: 'ok', text: 'Profil erfolgreich gespeichert.' }); + } catch (err: any) { + setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' }); + } + }; + + // --- Alarms Handlers --- + const handleSaveAlarms = async (e: React.FormEvent) => { + e.preventDefault(); + setAlarmMsg(null); + try { + const updated = await api.updateSettings({ + discordWebhookUrl: discordWebhookUrl.trim(), + notifyBeforeMinutes: Number(notifyBeforeMinutes), + }); + onUserUpdated(updated); + setAlarmMsg({ type: 'ok', text: 'Discord Alarm-Einstellungen gespeichert.' }); + } catch (err: any) { + setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' }); + } + }; + + const handleTestDiscordWebhook = async () => { + if (!discordWebhookUrl.trim()) { + setAlarmMsg({ type: 'err', text: 'Bitte zuerst eine Discord Webhook URL eingeben.' }); + return; + } + setTestingDiscord(true); + setAlarmMsg(null); + try { + const res = await fetch(discordWebhookUrl.trim(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: '🔔 **MindSift Test-Alarm**: Verbindung zu Discord erfolgreich hergestellt!', + embeds: [ + { + title: 'MindSift Aufgaben-Alarm eingerichtet', + description: 'Du erhältst hier Benachrichtigungen, wenn Aufgaben fällig werden.', + color: 3066993, + footer: { text: 'MindSift System Test' }, + }, + ], + }), + }); + if (!res.ok) { + throw new Error(`Discord antwortete mit Status ${res.status}`); + } + setAlarmMsg({ type: 'ok', text: '✅ Test-Nachricht erfolgreich an Discord gesendet!' }); + } catch (err: any) { + setAlarmMsg({ type: 'err', text: `Fehler: ${err.message}` }); + } finally { + setTestingDiscord(false); + } + }; + + // --- Webhook / Key Handlers --- + const handleCreateAPIKey = async (e: React.FormEvent) => { + e.preventDefault(); + triggerHaptic(15); + try { + const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant / Script'); + if (created.rawKey) { + setNewlyCreatedKey(created.rawKey); + } + setNewKeyName(''); + loadKeys(); + } catch (err: any) { + alert(err.message); + } + }; + + const handleDeleteAPIKey = async (id: string) => { + if (!window.confirm('Schlüssel widerrufen?')) return; + await api.deleteAPIKey(id); + loadKeys(); + }; + + const handleCopy = (text: string) => { + navigator.clipboard.writeText(text); + setCopiedKey(true); + setTimeout(() => setCopiedKey(false), 2000); + }; + + const handleExportData = async () => { + const data = await api.exportAll(); + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: 'application/json;charset=utf-8', + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + // --- Orgs & Teams Handlers --- + const handleCreateOrg = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newOrgName.trim()) return; + try { + const created = await api.createOrganization(newOrgName.trim()); + setOrgs((prev) => [...prev, created]); + setSelectedOrgId(created.id); + setNewOrgName(''); + } catch (err: any) { + alert(err.message); + } + }; + + const handleCreateTeam = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedOrgId || !newTeamName.trim()) return; + try { + const created = await api.createOrgTeam(selectedOrgId, newTeamName.trim(), newTeamDesc.trim()); + setTeams((prev) => [...prev, created]); + setNewTeamName(''); + setNewTeamDesc(''); + } catch (err: any) { + alert(err.message); + } + }; + + // --- Admin Handlers --- + const handleUpdateSystemSettings = async (patch: Partial) => { + const next = { ...systemSettings, ...patch }; + setSystemSettings(next); + try { + await api.adminUpdateSettings(next); + } catch (err: any) { + alert(err.message); + } + }; + + const handleAdminCreateUser = async (e: React.FormEvent) => { + e.preventDefault(); + try { + const created = await api.adminCreateUser({ + email: newAdminUserEmail, + password: newAdminUserPassword, + displayName: newAdminUserDisplayName || newAdminUserEmail.split('@')[0], + role: newAdminUserRole, + }); + setAdminUsers((prev) => [...prev, created]); + setNewAdminUserEmail(''); + setNewAdminUserPassword(''); + setNewAdminUserDisplayName(''); + } catch (err: any) { + alert(err.message); + } + }; + + const handleToggleUserActive = async (u: User) => { + try { + const updated = await api.adminUpdateUser(u.id, { isActive: !u.isActive }); + setAdminUsers((prev) => prev.map((item) => (item.id === u.id ? updated : item))); + } catch (err: any) { + alert(err.message); + } + }; + + const handleToggleUserRole = async (u: User) => { + const nextRole = u.role === 'admin' ? 'member' : 'admin'; + try { + const updated = await api.adminUpdateUser(u.id, { role: nextRole }); + setAdminUsers((prev) => prev.map((item) => (item.id === u.id ? updated : item))); + } catch (err: any) { + alert(err.message); + } + }; + + const handleConfirmResetPassword = async (userId: string) => { + if (!resetPwdVal.trim()) return; + try { + await api.adminResetPassword(userId, resetPwdVal.trim()); + alert('Passwort erfolgreich zurückgesetzt.'); + setResetPwdUserId(null); + setResetPwdVal(''); + } catch (err: any) { + alert(err.message); + } + }; + + const handleAdminDeleteUser = async (userId: string) => { + if (!window.confirm('Benutzer unwiderruflich löschen? Alle Daten dieses Nutzers werden gelöscht.')) return; + try { + await api.adminDeleteUser(userId); + setAdminUsers((prev) => prev.filter((u) => u.id !== userId)); + } catch (err: any) { + alert(err.message); + } + }; + + const baseUrl = window.location.origin; + const curlExample = `curl -X POST ${baseUrl}/api/v1/inbox \\ + -H "Authorization: Bearer ${newlyCreatedKey || apiKeys[0]?.keyPrefix || 'DEIN_API_KEY'}" \\ + -H "Content-Type: application/json" \\ + -d '{"content": "Server-Backup abgeschlossen #homelab #backup", "priority": "high", "target": "task"}'`; + + return ( +
+
+ {/* Header */} +
+
+ +

+ Konto-Einstellungen & Administration +

+
+ +
+ + {/* Modal Navigation Tabs */} +
+ + + + + + + + + {currentUser?.role === 'admin' && ( + + )} +
+ + {/* Modal Body */} +
+ {/* TAB 1: PROFIL & SICHERHEIT */} + {activeTab === 'profile' && ( +
+
+

Benutzerprofil bearbeiten

+

+ Passe deinen Anzeigenamen und dein Anmeldekennwort an. +

+
+ + {profileMsg && ( +
+ {profileMsg.text} +
+ )} + +
+
+ + setDisplayName(e.target.value)} + required + className="w-full h-8 px-2.5 input-field text-white" + /> +
+ +
+ + setEmail(e.target.value)} + required + className="w-full h-8 px-2.5 input-field text-white" + /> +
+ +
+ + setNewPassword(e.target.value)} + placeholder="Mindestens 4 Zeichen" + className="w-full h-8 px-2.5 input-field text-white" + /> +
+ +
+ + + +
+
+
+ )} + + {/* TAB 2: ALARME & DISCORD */} + {activeTab === 'alarms' && ( +
+
+

Aufgaben-Alarme & Discord Benachrichtigungen

+

+ Trage deine Discord Webhook URL ein, damit der MindSift Server dich automatisch erinnert, sobald Aufgaben fällig werden. +

+
+ + {alarmMsg && ( +
+ {alarmMsg.text} +
+ )} + +
+
+ + setDiscordWebhookUrl(e.target.value)} + placeholder="https://discord.com/api/webhooks/123456789/token..." + className="w-full h-8 px-2.5 input-field text-white font-mono text-xs" + /> +
+ +
+ + +
+ +
+ + + +
+
+
+ )} + + {/* TAB 3: WEBHOOKS & EXPORT */} + {activeTab === 'webhooks' && ( +
+
+

Private Webhook Keys & Daten-Export

+

+ Erstelle API-Keys fĂźr eigene Bash-Skripte, Home Assistant oder Cronjobs, um Gedanken direkt per Curl zu erfassen. +

+
+ + {/* API Key Creator */} +
+ setNewKeyName(e.target.value)} + placeholder="Key Bezeichnung (z.B. Smart Home, Backup Skript)…" + className="flex-1 h-8 px-2.5 input-field text-xs text-white" + /> + +
+ + {newlyCreatedKey && ( +
+
+

Neuer Key (jetzt kopieren):

+ {newlyCreatedKey} +
+ +
+ )} + + {/* Keys list */} +
+ {apiKeys.length === 0 ? ( +
Noch keine API-SchlĂźssel angelegt.
+ ) : ( + apiKeys.map((k) => ( +
+
+

{k.name}

+

Präfix: {k.keyPrefix}••••••••

+
+ +
+ )) + )} +
+ + {/* Interactive Curl Example */} +
+
+ + Curl Schnellbefehl fĂźr eigene Skripte: + + +
+
+                  {curlExample}
+                
+
+ + {/* Data Export Button */} +
+
+

Vollständiger Daten-Export

+

Sichere alle Notizen, Aufgaben, Listen und Metadaten als JSON.

+
+ +
+
+ )} + + {/* TAB 4: ORGANISATIONEN & TEAMS */} + {activeTab === 'teams' && ( +
+
+

Organisationen & Teams grĂźnden

+

+ Erstelle Organisationen und unterteile sie in Teams, um Aufgabenlisten und Workspace-Dokumente gemeinsam zu bearbeiten. +

+
+ + {/* Org Creator */} +
+

Neue Organisation

+
+ setNewOrgName(e.target.value)} + placeholder="Name der Organisation (z.B. Familie, Startup, Homelab Crew)…" + className="flex-1 h-8 px-2.5 input-field text-xs text-white" + /> + +
+
+ + {/* Team Creator inside Org */} + {orgs.length > 0 && ( +
+

Neues Team in Organisation

+
+
+ + setNewTeamName(e.target.value)} + placeholder="Team Name (z.B. DevOps, Einkauf)…" + required + className="flex-1 h-8 px-2.5 input-field text-xs text-white" + /> +
+
+ setNewTeamDesc(e.target.value)} + placeholder="Optionale Teambeschreibung…" + className="flex-1 h-8 px-2.5 input-field text-xs text-white" + /> + +
+
+
+ )} + + {/* Active Teams List */} +
+

Deine Teams ({teams.length}):

+
+ {teams.length === 0 ? ( +
Du bist noch keinem Team beigetreten.
+ ) : ( + teams.map((t) => ( +
+
+

+ + {t.name} +

+ {t.description &&

{t.description}

} +
+ ID: {t.id} +
+ )) + )} +
+
+
+ )} + + {/* TAB 5: ADMIN BEREICH */} + {activeTab === 'admin' && currentUser?.role === 'admin' && ( +
+
+

Server-Administration & Benutzersteuerung

+

+ Steuere die Instanz-Einstellungen, das Sharing-Verhalten und verwalte registrierte Benutzer. +

+
+ + {/* Server Sharing Isolation Setting */} +
+

+ + Sharing-Isolationsmodus +

+
+ + + + + +
+ +
+ Öffentliche Registrierung erlauben: + +
+
+ + {/* User Creation by Admin */} +
+

Neuen Benutzer anlegen

+
+
+ setNewAdminUserEmail(e.target.value)} + placeholder="E-Mail" + required + className="h-8 px-2.5 input-field text-white" + /> + setNewAdminUserPassword(e.target.value)} + placeholder="Initial-Passwort" + required + className="h-8 px-2.5 input-field text-white" + /> +
+ setNewAdminUserDisplayName(e.target.value)} + placeholder="Anzeigename" + className="flex-1 h-8 px-2.5 input-field text-white" + /> + +
+
+
+ +
+
+
+ + {/* Users Table */} +
+

Registrierte Benutzer ({adminUsers.length}):

+
+ {adminUsers.map((u) => ( +
+
+
+ {u.displayName} + + {u.role} + + {!u.isActive && ( + + Deaktiviert + + )} +
+

{u.email}

+ + {/* Reset password inline form */} + {resetPwdUserId === u.id && ( +
+ setResetPwdVal(e.target.value)} + placeholder="Neues Passwort…" + className="h-6 px-2 input-field text-xs text-white" + /> + + +
+ )} +
+ +
+ + + + {u.id !== currentUser.id && ( + + )} +
+
+ ))} +
+
+
+ )} +
+
+
+ ); +}; diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 274a0b4..331f01c 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -1,16 +1,25 @@ import React, { useEffect, useState } from 'react'; import { Check, - CheckCircle2, - Circle, + Plus, Calendar, Trash2, RotateCcw, FileText, Tag, - AlertCircle, + LayoutList, + Columns, + ArrowRight, + ArrowLeft, + ChevronRight, + ChevronDown, + Edit3, + X, + Clock, + Layers, + Share2, } from 'lucide-react'; -import { Thought } from '../../lib/types'; +import { TaskList, Thought, Space } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; interface TasksViewProps { @@ -24,19 +33,41 @@ export const TasksView: React.FC = ({ onOpenDocument, refreshTrigger = 0, }) => { + const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list'); const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open'); const [tasks, setTasks] = useState([]); + const [taskLists, setTaskLists] = useState([]); + const [selectedListId, setSelectedListId] = useState('all'); + const [spaces, setSpaces] = useState([]); const [discarded, setDiscarded] = useState([]); const [loading, setLoading] = useState(true); + // Quick Task Add state + const [quickTitle, setQuickTitle] = useState(''); + const [quickDueDate, setQuickDueDate] = useState(''); + + // New TaskList modal state + const [showNewListModal, setShowNewListModal] = useState(false); + const [newListTitle, setNewListTitle] = useState(''); + const [newListIcon, setNewListIcon] = useState('📋'); + const [newListSpaceId, setNewListSpaceId] = useState(''); + + // Active notes inline edit ID + const [activeNotesTaskId, setActiveNotesTaskId] = useState(null); + const [notesDraft, setNotesDraft] = useState(''); + const loadData = async () => { setLoading(true); try { - const [allTasks, allDiscarded] = await Promise.all([ - api.getTasks(), + const [allTasks, allLists, allSpaces, allDiscarded] = await Promise.all([ + api.getTasks(selectedListId === 'all' ? undefined : selectedListId), + api.getTaskLists(), + api.getSpaces(), api.getThoughts('discarded'), ]); setTasks(allTasks); + setTaskLists(allLists); + setSpaces(allSpaces); setDiscarded(allDiscarded); } finally { setLoading(false); @@ -45,15 +76,26 @@ export const TasksView: React.FC = ({ useEffect(() => { loadData(); - }, [refreshTrigger]); + }, [refreshTrigger, selectedListId]); const handleToggleDone = async (task: Thought) => { triggerHaptic(15); const nextState = !task.isCompleted; + const nextKanban = nextState ? 'done' : 'todo'; setTasks((prev) => - prev.map((t) => (t.id === task.id ? { ...t, isCompleted: nextState } : t)) + prev.map((t) => (t.id === task.id ? { ...t, isCompleted: nextState, kanbanStatus: nextKanban } : t)) ); - await api.updateThought(task.id, { isCompleted: nextState }); + await api.updateThought(task.id, { isCompleted: nextState, kanbanStatus: nextKanban }); + onRefreshStats(); + }; + + const handleKanbanMove = async (task: Thought, nextStatus: 'todo' | 'in_progress' | 'done') => { + triggerHaptic(15); + const isCompleted = nextStatus === 'done'; + setTasks((prev) => + prev.map((t) => (t.id === task.id ? { ...t, kanbanStatus: nextStatus, isCompleted } : t)) + ); + await api.updateThought(task.id, { kanbanStatus: nextStatus, isCompleted }); onRefreshStats(); }; @@ -62,6 +104,12 @@ export const TasksView: React.FC = ({ await api.updateThought(task.id, { dueDate: newDate }); }; + const handleSaveNotes = async (task: Thought) => { + setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, notes: notesDraft } : t))); + await api.updateThought(task.id, { notes: notesDraft }); + setActiveNotesTaskId(null); + }; + const handleConvertToDoc = async (task: Thought) => { triggerHaptic(20); const updated = await api.triageAction(task.id, 'keep', { convertTo: 'document' }); @@ -93,189 +141,738 @@ export const TasksView: React.FC = ({ onRefreshStats(); }; + const handleQuickAddTask = async (e: React.FormEvent) => { + e.preventDefault(); + const title = quickTitle.trim(); + if (!title) return; + triggerHaptic(10); + setQuickTitle(''); + + try { + const created = await api.captureThought(title, 'normal', 'manual', quickDueDate, { + target: 'task', + taskListId: selectedListId === 'all' ? '' : selectedListId, + }); + setTasks((prev) => [created, ...prev]); + onRefreshStats(); + } catch { + loadData(); + } + }; + + const handleCreateTaskList = async (e: React.FormEvent) => { + e.preventDefault(); + const title = newListTitle.trim(); + if (!title) return; + try { + const created = await api.createTaskList({ + title, + icon: newListIcon, + spaceId: newListSpaceId, + }); + setTaskLists((prev) => [...prev, created]); + setSelectedListId(created.id); + setShowNewListModal(false); + setNewListTitle(''); + } catch (err: any) { + alert(err.message || 'Fehler beim Erstellen der Aufgabenliste'); + } + }; + + const handleDeleteTaskList = async (listId: string) => { + if (!window.confirm('Möchten Sie diese Aufgabenliste wirklich löschen? Die enthaltenen Aufgaben bleiben erhalten.')) return; + try { + await api.deleteTaskList(listId); + setTaskLists((prev) => prev.filter((l) => l.id !== listId)); + setSelectedListId('all'); + } catch (err: any) { + alert(err.message); + } + }; + const openTasks = tasks.filter((t) => !t.isCompleted); const doneTasks = tasks.filter((t) => t.isCompleted); + const todoKanban = tasks.filter((t) => (!t.kanbanStatus || t.kanbanStatus === 'todo') && !t.isCompleted); + const inProgressKanban = tasks.filter((t) => t.kanbanStatus === 'in_progress' && !t.isCompleted); + const doneKanban = tasks.filter((t) => t.kanbanStatus === 'done' || t.isCompleted); + + const currentSelectedList = taskLists.find((l) => l.id === selectedListId); + return ( -
- {/* Filter Tabs */} -
-
+
+ {/* Top Header: Task Lists Tabs & View Mode Switcher */} +
+ {/* Task Lists selector tabs */} +
- - - -
- - {filter === 'discarded' && discarded.length > 0 && ( - - )} -
- - {loading ? ( -
Lade Aufgaben…
- ) : filter === 'discarded' ? ( -
- {discarded.length === 0 ? ( -
- Keine verworfenen Gedanken im Papierkorb. -
- ) : ( - discarded.map((item) => ( -
-
- -

{item.content}

-
-
- - -
-
- )) - )} -
- ) : ( -
- {(filter === 'open' ? openTasks : doneTasks).length === 0 ? ( -
- {filter === 'open' - ? 'Keine offenen Aufgaben vorhanden.' - : 'Noch keine erledigten Aufgaben.'} -
- ) : ( - (filter === 'open' ? openTasks : doneTasks).map((task) => ( -
( +
+ + {tl.icon} + {tl.title} + {tl.tasksCount !== undefined && tl.tasksCount > 0 && ( + + {tl.tasksCount} + + )} + {tl.spaceId && ( + + )} + + {selectedListId === tl.id && ( + + )} +
+ ))} -
-

+ +

+ + {/* View Switcher: List vs Kanban */} +
+
+ + +
+
+
+ + {/* Quick Add Task Input in active list */} +
+ + setQuickTitle(e.target.value)} + placeholder={ + currentSelectedList + ? `Aufgabe zu "${currentSelectedList.title}" hinzufügen… (Enter)` + : 'Neue Aufgabe anlegen… (Enter)' + } + className="flex-1 bg-transparent px-2 py-1 text-sm text-white placeholder-zinc-500 focus:outline-none" + /> +
+ setQuickDueDate(e.target.value)} + className="h-7 px-2 bg-canvas border border-border text-xs text-zinc-300 focus:outline-none" + title="Fälligkeitsdatum" + /> + +
+ + + {/* NEW LIST MODAL */} + {showNewListModal && ( +
+
+
+

+ + Neue Aufgabenliste erstellen +

+ +
+ +
+
+ +
+ setNewListIcon(e.target.value)} + maxLength={2} + className="w-10 h-8 text-center input-field text-sm" + title="Icon (Emoji)" + /> + setNewListTitle(e.target.value)} + placeholder="z.B. Homelab, Einkäufe, Steuern" + autoFocus + required + className="flex-1 h-8 input-field px-2.5 text-xs text-white" + /> +
+
+ + {spaces.length > 0 && ( +
+ + +
+ )} + +
+ + +
+
+
+
+ )} + + {/* VIEW MODES: LIST OR KANBAN */} + {viewMode === 'kanban' ? ( + /* KANBAN BOARD (3 COLUMNS) */ +
+ {/* COLUMN 1: TODO */} +
+
+ + + Zu erledigen + + + {todoKanban.length} + +
+ +
+ {todoKanban.map((task) => ( +
+
+

{task.content}

+ +
-
- {task.priority === 'urgent' && ( - - Dringend - - )} - {task.priority === 'high' && ( - - Wichtig - - )} - {task.tags?.map((tag) => ( - +