Merge pull request 'Dev' (#1) from dev into main

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
sandrodach committed 2026-10-25 01:03:03 +02:00
commit 33e1223e69
38 files changed
+13358 -1992

No files matched your search

+9 -1
View File
@@ -4,5 +4,13 @@ DATA_DIR=/data
DATABASE_URL=sqlite:///data/mindsift.db DATABASE_URL=sqlite:///data/mindsift.db
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
BASE_URL=http://localhost:8080 BASE_URL=http://localhost:8080
ALLOW_REGISTRATION=true ALLOW_REGISTRATION=false
LOG_LEVEL=info LOG_LEVEL=info
# Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@mindsift.local
SMTP_TLS=true
+10 -46
View File
@@ -8,40 +8,8 @@ on:
- '*' - '*'
jobs: jobs:
test: build-test-push:
name: Lint, Build & Unit Tests name: Build, Test in Container & Push
runs-on: ubuntu-latest
if: github.ref != 'refs/heads/main'
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
- name: Run Backend Lint & Unit Tests
run: |
go mod tidy
go vet ./...
go test -v ./...
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Typecheck & Build Frontend
run: |
cd frontend
npm install --no-audit --no-fund
npx tsc --noEmit
npm run build
build-docker:
name: Build & Push Docker Image
needs: test
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.ref != 'refs/heads/main' if: github.ref != 'refs/heads/main'
steps: steps:
@@ -69,10 +37,7 @@ jobs:
echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT" echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
echo "tag=${DOCKER_TAG}" >> "$GITHUB_OUTPUT" echo "tag=${DOCKER_TAG}" >> "$GITHUB_OUTPUT"
echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT" echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT"
echo "Building Docker image: ${IMAGE_NAME}:${DOCKER_TAG} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})" echo "Target Docker image: ${IMAGE_NAME}:${DOCKER_TAG} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry - name: Login to Gitea Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -81,11 +46,10 @@ jobs:
username: ${{ steps.prep.outputs.actor }} username: ${{ steps.prep.outputs.actor }}
password: ${{ secrets.PUSH_TOKEN || secrets.GITEA_TOKEN || github.token }} password: ${{ secrets.PUSH_TOKEN || secrets.GITEA_TOKEN || github.token }}
- name: Build and Push Multi-Arch Image - name: Build & Run All Checks in Container, then Push
uses: docker/build-push-action@v6 run: |
with: FULL_IMAGE="${{ steps.prep.outputs.image }}:${{ steps.prep.outputs.tag }}"
context: . echo "🚀 Building container & executing Frontend/Backend checks inside image: ${FULL_IMAGE}"
platforms: linux/amd64,linux/arm64 DOCKER_BUILDKIT=1 docker build --progress=plain -t "${FULL_IMAGE}" .
push: true echo "✅ All checks & tests passed inside container! Pushing ${FULL_IMAGE}..."
tags: | docker push "${FULL_IMAGE}"
${{ steps.prep.outputs.image }}:${{ steps.prep.outputs.tag }}
+279 -155
View File
@@ -1,183 +1,307 @@
# 🧠 BRAIN.md — MindSift Internal Dev Cheat Sheet # 🧠 BRAIN.md — MindSift Internal Dev Cheat Sheet & Architecture Log
> **Zweck:** Rein internes, ungeschöntes Nachschlagewerk für uns Entwickler. Keine Marketing-Texte, nur knallharte Fakten, Farbwerte, State-Machines, Namenskonventionen und Quick-Commands. > **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.
--- ---
## 🎨 1. Design-Tokens & Gemini-Dark-Theme (Copy & Paste) ## 📋 0. Aktueller Projektplan & Umsetzungs-Status
### Erledigte Meilensteine
- [x] **CI/CD & Multi-Stage Docker Build (`Dockerfile`, `.gitea/workflows/build-and-publish.yaml`):**
- Alle Frontend-Checks (`tsc --noEmit`, `vite build`) und Backend-Tests (`go test -v ./...`) laufen direkt im Multi-Stage Container-Build ohne doppelte Vorab-Steps.
- Push auf `dev` baut und überschreibt `git.dsit.me/sandrodach/mindsift:dev`.
- [x] **Single-File CSS Theming Engine & Design-System (`frontend/src/themes/`, `theme.ts`, `tailwind.config.js`):**
- Jedes Theme ist als **genau eine einzige, eigenständige CSS-Datei** unter `frontend/src/themes/` definiert.
- Alle Farben, Kontraste, Oberflächen, Rahmen, Statusfarben und Rundungen sind in 6 klaren Gruppen organisiert.
- 4 vorkonfigurierte Themes (Dark, Light, High Contrast, Soft) plus saubere `theme-template.css`-Vorlage für eigene Themes.
- Vollständige Trennung von Struktur und Styling: React-Komponenten enthalten keine hardcodierten Farbwerte mehr, sondern ausschließlich semantische Klassen und CSS-Variablen (`text-main`, `text-muted`, `bg-canvas`, `panel-surface`, `checkbox-square`, etc.).
- Schnellauswahl im Profil-Dropdown im Header sowie visueller Theme-Manager in den Benutzereinstellungen.
- [x] **Monochromes, eckiges Desktop-Design & Zentrale CSS-Steuerung:**
- Zentrale CSS-Variablen (`--bg-canvas`, `--bg-panel`, `--accent`, `--border-color`, `--radius-card`, `--radius-btn`, `--radius-input`) in den Theme-Dateien.
- Eckige Tabs, eckige Panels/Karten (`0px` Radius im Dark/Monochrome-Theme), nur die Schnell-Eingabe-Blase (`InstantCaptureBar`) oben im Header nutzt `--radius-pill`.
- Untere doppelte Navigationsleiste entfernt; Schnell-Notiz-Blase sitzt ganz oben im Header über den Tabs.
- Workspace links als eckiger, monochromer Datei-/Ordner-Baum (File Explorer Tree mit Unterordnern).
- [x] **Quick Dump Notes + Triage Swipen + Direkt-Zuordnung (`InstantCaptureBar.tsx`, `TriageDeck.tsx`, `server.go`):**
- **Quick Dump (Standard):** Text oben eingeben + `Enter` wirft den Gedanken sofort als Quick Dump (`status: 'inbox'`) ins Triage-Deck.
- **Direkt-Zuordnung bei der Eingabe:** Über das Auswahl-Menü (`Quick Dump ▾`) in der Eingabeleiste kann ein Eintrag alternativ direkt als **Aufgabe** (`target: 'task'`), direkt auf eine **Geteilte Liste** (`target: 'list_item'`) oder direkt als **Workspace-Notiz** (`target: 'document'`) gespeichert werden.
- **Triage-Entscheidung & Warteschlange:** Im Triage-Deck per Swipe/Klick **Wegwerfen** (`←` / `H`), **Später** (`Leertaste` – ans Ende schieben), **Auf Liste**, **Als Notiz** (`D`), **Mit Termin** (`T`) oder **Als Aufgabe** (`→` / `L`). Inklusive Inline-Textbearbeitung und Warteschlangen-Liste unter der aktiven Karte.
- [x] **Mehrere Aufgabenlisten & Kanban-Board (`TasksView.tsx`, `models.go`, `sqlite.go`):**
- Beliebig viele Aufgabenlisten (z.B. Privat, Arbeit, Homelab) mit eigenem Farb- und Icon-Code.
- Teilen von Aufgabenlisten mit Teams/Spaces.
- Umschalter zwischen kompakter Listenansicht und 3-Spalten-Kanban-Board (`Zu erledigen`, `In Arbeit`, `Erledigt`) mit 1-Klick-Statuswechsel, Fälligkeitsdaten und Notizfeldern.
- [x] **Ersteinrichtung (First-Boot Setup Wizard), Echter Login & Einladungscodes (`SetupAndAuthView.tsx`, `server.go`, `sqlite.go`):**
- Kein hartcodierter Auto-Seed-Benutzer (`admin@mindsift.local` / `"Sandro"`) und kein automatischer `quickStart`-Login mehr beim Seitenaufruf.
- Beim ersten Start (`0` Benutzer in der Datenbank) erkennt `GET /api/v1/auth/status` automatisch `needsSetup: true` und zeigt den **Ersteinrichtungs-Assistenten** (`POST /api/v1/auth/setup`) zum Anlegen des ersten Server-Admin-Kontos und der Instanz-Grundeinstellungen.
- Öffentliche Selbstregistrierung ist standardmäßig deaktiviert (`allowRegistration: false`). Neue Benutzer können nur über einen **Einladungscode** (`MS-XXXXXXXX`, Tabelle `user_invites`) oder direkt durch einen Admin (`admin` / `team_admin`) angelegt werden.
- [x] **Profile-Dropdown & Vollbild-Einstellungsseiten statt kleinem Modal (`App.tsx`, `UserSettingsView.tsx`, `AdminSettingsView.tsx`):**
- Klick auf den Benutzer oben rechts öffnet ein eckiges, monochromes Dropdown-Menü:
- **Kontoeinstellungen** (`UserSettingsView.tsx` — Vollbild-Ansicht für Profil & Passwort, Darstellung & Themes, Multi-Channel-Alarme, Private Webhooks & Daten-Export).
- **Administration** (`AdminSettingsView.tsx` — nur sichtbar für `admin` und `team_admin`; Vollbild-Ansicht für Benutzer, Rollen, Einladungscodes, Organisationen & Teams, Server-Isolation und SMTP-Mailserver).
- **Abmelden** (bereinigt JWT-Token und führt zurück zum Login-Bildschirm).
- 3-stufiges Rollensystem: `admin` (Server-Admin), `team_admin` (Team-Admin mit Berechtigung zum Einladen und Verwalten von Teams) und `member` (Mitglied).
- Layout-Fix im Benutzer-Erstellungsformular (`grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3` mit `min-w-0`), sodass das Rollen-Dropdown niemals über den Container hinausragt.
- [x] **Multi-Channel-Alarme: E-Mail (SMTP), Discord & Eigener Webhook (`UserSettingsView.tsx`, `AdminSettingsView.tsx`, `server.go`):**
- Tab heißt klar **Alarme** mit frei kombinierbaren Zielen (`alarmChannels`: `'email'`, `'discord'`, `'webhook'`).
- SMTP-Konfiguration (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS`) sowohl über `docker-compose.yml` Environment-Variablen als auch direkt in der Admin-Oberfläche (`system_settings` in SQLite) einstellbar, inklusive Live-Testversand.
- [x] **Crash-Fix für „Aufgaben" & „Organisationen & Teams" (`sqlite.go`, `api.ts`, `TasksView.tsx`):**
- Alle Go-Storage-Methoden initialisieren Slices strikt mit `make([]T, 0)` statt `var s []T`, damit `json.Marshal` niemals `null` statt `[]` liefert.
- Zusätzliche `ensureArray()`-Normalisierung in `frontend/src/lib/api.ts` sowie `ViewErrorBoundary` in `App.tsx`.
- [x] **Workspace Markdown-Wiki mit Notion-/Word-Toolbar & Disk-Sync (`WorkspaceView.tsx`, `sqlite.go`):**
- Word-/Notion-artige Formatierungsleiste oben (Fett, Kursiv, Durchgestrichen, Code, Links, Überschriften H1-H3, Checklisten, Aufzählungen, Callouts, Markdown-Tabellen, Fortschrittsbalken).
- Interne Wiki-Links (`[[Dokumenttitel]]`) mit Klick-Navigation und Auto-Erkennung.
- Physische Speicherung auf Festplatte unter `/data/workspace/<user_id>/...` als Standard-`.md`-Dateien zur einfachen Daten-Extraktion, synchronisiert mit SQLite.
- [x] **Workspace Logseq Outliner-Engine, Zoom-Hoisting & Drag & Drop (`WorkspaceView.tsx`, `models.go`, `sqlite.go`):**
- Bullet-Point-basiertes hierarchisches Notizensystem inspiriert von Logseq.
- Jeder Block ist ein Bullet-Point (`•`) mit flexibler Einrückungstiefe (`Level: 0, 1, 2...`).
- Schnelle Tastaturnavigation:
- `Enter`: Neuer Bullet-Point auf gleicher Ebene.
- `Tab`: Block dem vorherigen unterordnen (einrücken).
- `Shift+Tab`: Block ausrücken.
- `Shift+Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks.
- `Backspace`: Auf leerem Block rückt aus oder entfernt den Bullet-Point.
- **Zoom-in Hoisting:** Klick auf den Bullet-Dot zoomt tief in diesen Ast hinein (Fokusmodus) mit Breadcrumbs-Navigation und „Zoom aufheben"-Button.
- **Drag & Drop:** Bullet-Dots dienen als Anfasser zum Verschieben und Umsortieren von Blöcken und ganzen Unterbäumen.
- **Slash-Commands (`/`):** Schnelle Umwandlung in `/todo`, `/h1`, `/h2`, `/h3`, `/code`, `/table`, `/callout`, `/progress`, `/divider`.
- Physische Speicherung auf Festplatte unter `/data/workspace/<user_id>/...` als Standard-`.md`-Dateien mit portabler 2-Leerzeichen-Einrückung (` - [ ] ` bzw. ` - `), vollständig kompatibel mit externen Outlinern und Markdown-Viewern.
- [x] **Globaler Workspace To-Do Rollup Dashboard (`WorkspaceView.tsx`):**
- Aggregierte Übersicht aller To-Dos (`- [ ] ` / `- [x] `) aus sämtlichen Notiz-Dateien über alle Ordner hinweg.
- Übersichtliche Gruppierung nach Ordner ➔ Dokument ➔ Aufgaben mit einklappbaren Akkordeons und Zähler für offene Aufgaben.
- **Ordner-Drilldown:** Klick auf einen Ordner isoliert nur diesen Zweig für fokussiertes Abarbeiten, inklusive Zurücksetzen-Filter.
- **Live 2-Wege-Sync:** Abhaken oder Aktivieren eines To-Dos im Rollup synchronisiert direkt in Echtzeit zurück in die SQLite-Datenbank und die physische `.md`-Datei auf Festplatte.
- [x] **Aufgabenlisten-Teilen & Avatar-Stack (`TasksView.tsx`, `models.go`, `sqlite.go`):**
- Aufgabenlisten können direkt mit Teams/Spaces geteilt werden.
- Avatar-Stack (farbige Initialen-Bubbles mit Hover-Tooltips) im Header der Liste und in den Tabs visualisiert, wem eine Liste gehört und mit welchen Teammitgliedern sie geteilt ist.
- [x] **Admin-Schalter für öffentliche Selbstregistrierung (`AdminSettingsView.tsx`):**
- Unintuitiver Button in den Admin-Einstellungen unter Server & Isolation durch einen flüssig animierten Schiebeschalter (`rounded-pill`) mit eindeutigen Status-Beschriftungen ersetzt.
- [x] **Cross-Browser & Performance Fixes (Chrome & Firefox):**
- SQLite Connection-Pool Deadlock durch striktes Schließen von `sql.Rows` vor Unterabfragen behoben.
- `Cache-Control: no-store` auf allen API-Routen und `index.html`.
- Optimistisches Leeren des Capture-Inputs und 12-Sekunden-Fetch-Timeout.
---
## 🎨 1. Single-File CSS Theming Engine & Design-System
### Philosophie: 1 Datei = 1 komplettes Theme
Anstatt Styling in Dutzenden Tailwind-Klassen über hunderte Komponenten zu verstreuen, steuert **eine einzige CSS-Datei** das komplette Erscheinungsbild der Anwendung.
Jedes Theme liegt unter `frontend/src/themes/` und gruppiert **6 standardisierte Bereiche**:
### Farbpalette (Strict Hex Values)
```css ```css
/* Backgrounds */ :root[data-theme="dein-theme"] {
--bg-app: #0b0f19; /* Tiefstes Obsidian / OLED Base */ /* ==========================================================================
--bg-surface: #131314; /* Leicht erhobene Panels, Sidebar */ 1. AKZENTFARBE (Fokus, Primäraktionen, Highlights)
--bg-card: #1e1f20; /* Swipe-Karten, Dialoge, Dropdowns */ ========================================================================== */
--bg-card-hover: #282a2c; /* Hover-Zustände */ --accent: #2563eb;
--accent-hover: #3b82f6;
--accent-dim: rgba(37, 99, 235, 0.15);
--accent-contrast: #ffffff;
/* Borders & Dividers */ /* ==========================================================================
--border-subtle: rgba(255, 255, 255, 0.08); /* 1px Standard-Kante */ 2. OBERFLÄCHEN & HINTERGRÜNDE
--border-focus: rgba(138, 180, 248, 0.40); /* Gemini Blue Glow */ ========================================================================== */
--bg-canvas: #0e1013;
--bg-panel: #16181d;
--bg-panel-elevated: #1f2229;
--bg-input: #121418;
--bg-code: #0b0c0f;
--bg-overlay: rgba(0, 0, 0, 0.75);
/* Akzente & Status */ /* ==========================================================================
--accent-blue: #8ab4f8; /* Gemini Primärblau (Buttons, Links, Active) */ 3. TEXT & KONTRASTE
--accent-purple: #c58af9; /* Gemini Voice / Magic Glow */ ========================================================================== */
--swipe-keep: #81c995; /* Swipe Rechts (Behalten / ToDo / Success) */ --text-main: #f4f4f5;
--swipe-discard: #f28b82; /* Swipe Links (Verwerfen / Archive / Danger) */ --text-muted: #a1a1aa;
--text-faint: #71717a;
--text-on-accent: #ffffff;
/* Typografie */ /* ==========================================================================
--text-primary: #e3e3e3; /* Haupttext (hoher Kontrast, augenschonend) */ 4. RAHMEN, LINIEN & SCHATTEN
--text-muted: #9aa0a6; /* Sekundärtexte, Metadaten, Placeholders */ ========================================================================== */
--text-subtle: #5f6368; /* Timestamps, Footer, Deaktiviert */ --border-color: rgba(255, 255, 255, 0.08);
``` --border-strong: rgba(255, 255, 255, 0.18);
--shadow-panel: 0 4px 20px -2px rgba(0, 0, 0, 0.45);
### UI-Regeln & Radien /* ==========================================================================
* **Buttons / Pills:** Ausnahmslos `rounded-full` (`border-radius: 9999px`). 5. STATUS- & SIGNALFARBEN
* **Karten (Triage Deck):** `rounded-3xl` (`24px`), Padding `p-6`, Border `1px solid var(--border-subtle)`. ========================================================================== */
* **Input-Feld (Instant Capture):** `rounded-full`, Höhe `h-14` (56px) für mobile Daumenbedienung, Autofokus bei Mount. --status-ok: #22c55e;
* **Gesten-Physik (Framer Motion):** --status-err: #ef4444;
* Swipe-Schwellenwert: `dragConstraints={{ left: -150, right: 150 }}` --status-warn: #eab308;
* Spring-Config: `{ type: "spring", stiffness: 350, damping: 25 }` --status-info: #3b82f6;
* Swipe-Winkel-Rotation: `rotate = x * 0.1`
--- /* ==========================================================================
6. RUNDUNGEN / GEOMETRIE
## ⚙️ 2. State-Machine: Gedanken-Lifecycle ========================================================================== */
--radius-card: 0px;
``` --radius-btn: 2px;
[ INSTANT CAPTURE ] --radius-input: 2px;
│ --radius-checkbox: 2px;
▼ --radius-pill: 9999px;
status: 'inbox'
│
┌───────────┴───────────┐
│ (Swipe Left) │ (Swipe Right)
▼ ▼
status: 'discarded' status: 'kept'
(Soft Delete) │
┌───────┴───────┐
│ (Convert) │ (Direct Task)
▼ ▼
status: 'converted' due_date != null
├── target: 'task'
├── target: 'document'
└── target: 'list_item'
```
### Gültige Status-Enums
* `inbox` – Frisch eingekippt, wartet auf Triage.
* `discarded` – Nach links gewischt (bleibt für 30 Tage im Papierkorb, dann Hard Delete).
* `kept` – Nach rechts gewischt, bleibt als To-Do oder Gedanke archiviert.
* `converted` – In vollwertiges Dokument, Aufgabe oder Listen-Item transformiert.
---
## 📡 3. WebSocket Realtime Protocol (Family Lists)
Verbindungs-URL: `wss://<host>/api/v1/ws/spaces/:spaceId`
### Nachrichten-Format (JSON)
```typescript
interface WSMessage<T = unknown> {
event: 'ITEM_CREATED' | 'ITEM_UPDATED' | 'ITEM_TOGGLED' | 'ITEM_DELETED' | 'USER_PRESENCE';
space_id: string;
list_id: string;
payload: T;
timestamp: string; // ISO 8601
} }
``` ```
### Schnelle Event-Referenz ### Verfügbare Themes
* `ITEM_TOGGLED`: Wenn jemand die Hafermilch abhakt:
`{ "item_id": "itm_123", "is_completed": true, "completed_by": "usr_sandro" }` | Theme-ID | Datei | Charakter | Standard-Geometrie |
* `Optimistic UI`: Frontend schlägt das Item sofort lokal durch und rollt nur bei WebSocket-Fehler zurück. | :--- | :--- | :--- | :--- |
| `dark` | [`theme-dark.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/themes/theme-dark.css) | Standard Monochrom / Dark Slate | Streng eckig (`0px` Cards, `2px` Buttons) |
| `light` | [`theme-light.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalist Light | Modern sauber (`4px` Cards, `4px` Buttons) |
| `high-contrast` | [`theme-high-contrast.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit maximaler Lesbarkeit (AAA Kontrast) | Streng eckig (`0px`, `2px` Rahmen) |
| `soft` | [`theme-soft.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/themes/theme-soft.css) | Soft Rounded Slate mit warmem Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) |
| *Template* | [`theme-template.css`](file:///home/sandro/Dokumente/git/MindSift/frontend/src/themes/theme-template.css) | Dokumentierte Vorlage zur Erstellung eigener Themes | Voll anpassbar |
### Semantische Komponenten-Klassen (`frontend/src/index.css`)
- `.panel-surface` / `.panel-elevated`: Automatisch über `--radius-card`, `--bg-panel` und `--border-color` gesteuert.
- `.btn-primary` / `.btn-action`: Aktionen und Buttons mit `--radius-btn`, `--accent` und `--text-on-accent`.
- `.input-field`: Formularfelder mit `--radius-input`, `--bg-input`, `--border-color` und `--text-main`.
- `.checkbox-square`: Kontrollkästchen mit `--radius-checkbox` und `--accent`.
- `.badge-accent`, `.badge-neutral`, `.badge-ok`, `.badge-err`: Pillen und Zähler.
- `.alert-ok`, `.alert-err`: Hinweiskästen mit Signalfarben.
- `.capture-pill`: Die markante Schnell-Eingabeleiste im Header mit `--radius-pill`.
--- ---
## 🗂️ 4. Ordnerstruktur & Code-Konventionen ## 🗄️ 2. Datenbank-Architektur (SQLite Schema & Tabellen)
``` MindSift speichert alle Daten lokal und hochperformant in SQLite (`/data/mindsift.db`).
mindsift/
├── cmd/
│ └── server/ # Go Main Entrypoint (bindet embed.FS ein)
├── internal/
│ ├── api/ # HTTP Chi Handler, Routing & Middleware
│ ├── domain/ # Entities, Interfaces & Domain Logic
│ ├── storage/ # SQLite / Postgres Adapter (sqlc generiert)
│ ├── ws/ # WebSocket Hub & Raumverwaltung
│ └── webhook/ # Smarthome Ingestion Logic
├── migrations/ # Reine .sql Migrationsdateien
├── frontend/
│ ├── src/
│ │ ├── components/ # UI Primitives (Button, Pill, Input, Modal)
│ │ ├── features/
│ │ │ ├── capture/ # Instant-Input & Voice Recording
│ │ │ ├── triage/ # Framer Motion Swipe Deck
│ │ │ ├── workspace/ # Tiptap Block Editor
│ │ │ └── lists/ # Realtime Shopping / Todo Lists
│ │ ├── lib/ # WS Client, API Client, Sound/Haptics
│ │ └── index.css # Tailwind v4 & Gemini CSS Variablen
├── .gitea/workflows/ # Gitea Actions CI/CD
├── Dockerfile # Multi-Stage Build (< 35 MB)
├── docker-compose.yml
├── README.md # Öffentliche Doku & Vision
└── BRAIN.md # Dieses Dokument (Dev Cheat Sheet)
```
### Namens- & ID-Konventionen ### Tabellen-Übersicht
* **Entity IDs:** Präfix-basierte Strings für Lesbarkeit beim Debuggen:
* `usr_` = User (`usr_9k2m1a`) 1. **`users`**
* `th_` = Thought (`th_4x8z19`) - `id TEXT PRIMARY KEY`, `email TEXT UNIQUE`, `password_hash TEXT`, `display_name TEXT`
* `doc_` = Document (`doc_81na72`) - `role TEXT DEFAULT 'member'` (`admin`, `team_admin`, `member`)
* `lst_` = List (`lst_33fa09`) - `is_active INTEGER DEFAULT 1`, `created_at DATETIME`, `updated_at DATETIME`
* `itm_` = List Item (`itm_77bb41`) 2. **`system_settings`** (Instanzweite globale Konfiguration, 1 Row mit `id = 'singleton'`)
* **Datenbank:** `snake_case` (z. B. `is_completed`, `due_date`) - `sharing_mode TEXT DEFAULT 'teams_only'` (`strict_private`, `teams_only`, `open`)
* **JSON API:** `camelCase` (z. B. `isCompleted`, `dueDate`) - `allow_registration INTEGER DEFAULT 0` (Öffentliche Registrierung erlaubt oder gesperrt)
* **Timestamps:** Immer UTC ISO-8601 (`2026-10-24T14:20:00Z`) - `instance_name TEXT DEFAULT 'MindSift'`
- `smtp_host TEXT`, `smtp_port INTEGER`, `smtp_user TEXT`, `smtp_pass TEXT`, `smtp_from TEXT`, `smtp_tls INTEGER`
3. **`user_invites`** (Einladungscodes für neue Registrierungen)
- `id TEXT PRIMARY KEY`, `code TEXT UNIQUE` (`MS-XXXXXXXX`), `email TEXT` (optional gebunden)
- `role TEXT DEFAULT 'member'`, `team_id TEXT` (optional direkte Team-Zuweisung)
- `created_by TEXT`, `used_by TEXT`, `expires_at DATETIME`, `created_at DATETIME`
4. **`organizations` & `organization_teams` & `team_members`**
- Strukturierung von Benutzern in Abteilungen, Familien oder Projekt-Teams.
- Bildet die Basis für den Sichtbarkeits-Modus `teams_only`.
5. **`spaces` & `space_members`**
- Geteilte Kollaborations-Räume mit Einladungscode (`invite_code`), Echtzeit-WebSocket Sync und Rollen (`owner`, `editor`, `viewer`).
6. **`shared_lists` & `list_items`**
- Geteilte Einkaufs-, Pack- und Inventarlisten mit Kategorien, Mengen und Echtzeit-Abhaken.
7. **`tasks` & `task_lists`**
- Persönliche und geteilte Aufgabenlisten mit Kanban-Spalten (`todo`, `in_progress`, `done`), Fälligkeit, Priorität und Alarm-Optionen.
8. **`quick_dumps` (Triage-Deck)**
- Schnelle Einfälle (`status = 'inbox'`, `'discarded'`, `'converted'`), Fälligkeitsdatum und Text-Notiz.
9. **`documents` (Workspace Markdown-Wiki)**
- Notizen und Dokumentationen mit Folder-Pfad (`folder_path`), Tags, Pinned-Status, JSON-Blocks und physischer `.md`-Spiegelung auf Disk.
10. **`user_alarms` & `user_webhooks`**
- Multi-Channel Alarme (Mail, Discord, Webhook) und ausgehende Event-Webhooks.
--- ---
## ⌨️ 5. Power-User Tastatur-Shortcuts (Desktop) ## ⚡ 3. Triage & Quick-Dump State Machine
```mermaid
stateDiagram-v2
[*] --> InstantCapture: Gedanke eingetippt
InstantCapture --> QuickDump_Inbox: Enter (Standard)
InstantCapture --> TaskCreated: Ziel = 'task'
InstantCapture --> ListItemCreated: Ziel = 'list_item'
InstantCapture --> DocCreated: Ziel = 'document'
QuickDump_Inbox --> TriageDeck: Stapel sichten
TriageDeck --> Discarded: Swipe links / H (Wegwerfen)
TriageDeck --> TaskCreated: Swipe rechts / L (Als Aufgabe)
TriageDeck --> TaskCreated: T (Termin-Aufgabe)
TriageDeck --> DocCreated: D (In Notiz umwandeln)
TriageDeck --> ListItemCreated: Auf Liste schieben
TriageDeck --> QuickDump_Inbox: Leertaste (Später - ans Ende)
Discarded --> [*]
TaskCreated --> [*]
DocCreated --> [*]
ListItemCreated --> [*]
```
---
## 👥 4. Server-Administration & Isolation Modes
Der Server-Administrator (`role = 'admin'`) legt unter `AdminSettingsView.tsx` global fest, wie Benutzer miteinander interagieren dürfen:
1. **`strict_private` (Strikt Privat):**
- Jeder Benutzer arbeitet komplett autark. Keine teamübergreifende Benutzersuche, keine geteilten Spaces.
2. **`teams_only` (Teams isoliert — Empfohlener Standard):**
- Benutzer sehen im Adressbuch und bei geteilten Spaces ausschließlich Personen, mit denen sie mindestens in einem gemeinsamen Team sind.
3. **`open` (Offene Instanz):**
- Alle Benutzer können sich gegenseitig sehen, zu Spaces einladen und gemeinsam an Listen arbeiten.
---
## 📝 5. Workspace: Word/Notion-Toolbar, Wiki-Links & Disk-Sync
1. **Formatierungs-Werkzeuge:**
- Markdown-Syntax wird in Echtzeit formatiert und visuell aufbereitet.
- Buttons für H1, H2, H3, Fett (`**`), Kursiv (`*`), Durchgestrichen (`~~`), Code-Blöcke (```` ``` ````), Aufzählungen (`- `), Todos (`- [ ] `), Callouts (`> `), Markdown-Tabellen (`| Spalte 1 | Spalte 2 |`) und Fortschrittsbalken (`[progress:65%]`).
2. **Wiki-Links (`[[Dokumenttitel]]`):**
- Interne Querverweise werden als interaktive Links gerendert.
- Ein Klick auf `[[Dokumenttitel]]` durchsucht die Workspace-Dokumente und öffnet das verlinkte Dokument direkt im Editor.
3. **Physische Speicherung auf Festplatte:**
- Jede Notiz wird zusätzlich zur SQLite-Tabelle als reine `.md`-Datei unter `/data/workspace/<user_id>/<folder_path>/<slug>.md` gespeichert.
- Ermöglicht jederzeitige manuelle Datensicherung, Git-Versionierung oder Backup-Extraktion ohne Datenbank-Abhängigkeit.
---
## 🛡️ 6. Kritische Backend-Regeln (SQLite & HTTP)
1. **NIEMALS verschachtelte Queries bei offenem `sql.Rows`-Cursor ausführen:**
- In [`internal/storage/sqlite.go`](file:///home/sandro/Dokumente/git/MindSift/internal/storage/sqlite.go) muss jede Schleife `for rows.Next()` zuerst alle Zeilen in ein Slice einlesen und sofort `_ = rows.Close()` aufrufen, **bevor** Folgeabfragen (`GetListItems`, `GetSpaceMembers`, `QueryRow` etc.) ausgeführt werden.
2. **SQLite DSN-Pragmas:**
- Jede Verbindung im `sql.DB`-Pool erhält über den DSN automatisch:
`?_pragma=busy_timeout(10000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)`
- `MaxOpenConns(8)` und `MaxIdleConns(4)` konfiguriert.
3. **Thread-Safe Default User (`EnsureDefaultUser`):**
- Durch `s.initMu.Lock()` abgesichert, damit parallele Browser-Requests beim ersten Laden niemals in einen `UNIQUE constraint`-Konflikt laufen.
4. **Kein Browser-Caching auf API & `index.html`:**
- `writeJSON` und der SPA-Fallback setzen `Cache-Control: no-store, no-cache, must-revalidate`, damit Chrome und Firefox nach jedem Container-Update sofort den aktuellen Stand zeigen.
---
## 📡 7. WebSocket Realtime Protocol (Geteilte Listen)
Verbindungs-URL: `ws(s)://<host>/api/v1/ws/spaces/:spaceId?token=<jwt>`
### Events
- `ITEM_CREATED` (auch ausgelöst, wenn ein Gedanke per Direkt-Zuordnung oder Triage auf eine Liste geschoben wird)
- `ITEM_TOGGLED`
- `ITEM_DELETED`
- `LIST_CREATED` / `LIST_DELETED` / `LIST_CLEARED`
- `USER_PRESENCE`
---
## ⌨️ 8. Tastatur-Shortcuts (Desktop)
| Taste | Kontext | Aktion | | Taste | Kontext | Aktion |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| `C` oder `/` | Überall | Fokus sofort auf das Capture-Eingabefeld | | `C` oder `/` | Überall | Fokus sofort oben in die Quick-Dump-Eingabeleiste |
| `H` oder `←` | Triage | Karte nach links wischen (Verwerfen) | | `Enter` | Capture-Leiste | Sofort speichern (Quick Dump oder gewähltes Direkt-Ziel) |
| `L` oder `→` | Triage | Karte nach rechts wischen (Behalten) | | `Esc` | Capture-Leiste | Eingabe leeren & Dropdown schließen |
| `T` | Triage | Sofort in Aufgabe mit Fälligkeitsdatum verwandeln | | `H` oder `←` | Triage-Deck | Aktiven Quick Dump wegwerfen (`discard`) |
| `D` | Triage | In Workspace-Dokument umwandeln | | `L` oder `→` | Triage-Deck | Aktiven Quick Dump sofort zur Aufgabe machen (`task`) |
| `Esc` | Modal/Capture | Abbrechen / Eingabe leeren | | `T` | Triage-Deck | Datums-Auswahl für Termin-Aufgabe öffnen |
| `D` | Triage-Deck | Aktiven Quick Dump sofort in Workspace-Notiz umwandeln |
| `Leertaste` | Triage-Deck | Karte überspringen („Später" – ans Ende des Stapels schieben) |
| `Enter` | Workspace Outliner | Neuen Bullet-Point auf gleicher Ebene erstellen |
| `Tab` | Workspace Outliner | Bullet-Point einrücken (dem vorherigen unterordnen) |
| `Shift + Tab` | Workspace Outliner | Bullet-Point ausrücken (Ebene verringern) |
| `Shift + Enter` | Workspace Outliner | Weicher Zeilenumbruch innerhalb des aktuellen Blocks |
| `Backspace` | Workspace Outliner | Bei leerem Block: Ebene ausrücken bzw. Bullet löschen |
| `/` | Workspace Outliner | Slash-Command-Menü öffnen (/todo, /h1, /code, etc.) |
--- ---
## ⚡ 6. Cheat Sheet: Lokale Entwickler-Befehle ## 🚀 9. Gitea CI/CD & Docker Workflow
```bash
# 1. Frontend Dev Server (Hot Reload)
cd frontend && npm run dev
# 2. Go Backend starten (Hot Reload via air oder go run)
go run ./cmd/server
# 3. Datenbank-Migrationen lokal ausführen
go run ./cmd/migrate -direction=up
# 4. Lokalen Multi-Stage Docker-Build testen
docker build -t mindsift:dev .
docker run -p 8080:8080 -v $(pwd)/dev-data:/data -e JWT_SECRET="dev-secret-12345" mindsift:dev
# 5. SQLite DB direkt per CLI inspizieren
sqlite3 ./dev-data/mindsift.db ".tables" "SELECT * FROM thoughts LIMIT 5;"
```
---
## 🚀 7. Gitea CI/CD & Docker Tagging-Strategie
* **Registry:** `git.dsit.me/sandrodach/mindsift`
* **Gitea Secret für Registry-Login:** `secrets.PUSH_TOKEN`
* **API-Endpunkt zum Auslesen der Pipeline:** `https://git.dsit.me/api/v1/repos/sandrodach/MindSift/actions/tasks`
* **Branch- & Release-Regeln ([`.gitea/workflows/build-and-publish.yaml`](file:///home/sandro/Dokumente/git/MindSift/.gitea/workflows/build-and-publish.yaml)):**
* **Push auf `main`:** Führt **nur** Linter, TypeScript-Checks (`tsc --noEmit`) und Unit-Tests aus. **Kein Docker-Build/Push** (hält die Registry sauber).
* **Push auf Test-/RC-Branch (z. B. `1.1-rc`, `latest`, `test-feature`):** Führt Tests aus **und** baut/pusht das Image mit dem Branch-Namen (`:1.1-rc`, `:latest`).
* **Git-Tag / Release (z. B. `v1.0`, `1.1`):** Führt Tests aus **und** baut/pusht das Release-Image (`:v1.0`, `:1.1`).
* **Cross-Compile Speedup:** `Dockerfile` nutzt `FROM --platform=$BUILDPLATFORM` für Node & Go und kompiliert `amd64` + `arm64` nativ über `GOOS=${TARGETOS} GOARCH=${TARGETARCH}` ohne langsames QEMU.
- **Arbeits-Branch:** `dev` (`origin/dev`)
- **Ziel-Image:** `git.dsit.me/sandrodach/mindsift:dev`
- **Build-Prinzip:** Ein einziger Multi-Stage-Build ([`Dockerfile`](file:///home/sandro/Dokumente/git/MindSift/Dockerfile)), der Frontend-Typecheck, Vite-Build und Go-Unit-Tests (`go test -v ./...` inkl. [`internal/api/server_test.go`](file:///home/sandro/Dokumente/git/MindSift/internal/api/server_test.go)) direkt beim Image-Bau ausführt und bei Erfolg sofort pusht.
+28 -10
View File
@@ -1,29 +1,47 @@
# ============================================================================== # ==============================================================================
# Stage 1: Frontend Build (Runs natively on $BUILDPLATFORM — zero QEMU overhead) # Stage 1: Frontend Check & Build (Cached npm dependencies layer)
# ============================================================================== # ==============================================================================
FROM --platform=$BUILDPLATFORM node:22-alpine AS frontend-builder FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend/package*.json ./ COPY frontend/package*.json ./
RUN npm install --no-audit --no-fund RUN npm install --no-audit --no-fund
COPY frontend/ ./ COPY frontend/ ./
RUN npm run build RUN npx tsc --noEmit && npm run build
# ============================================================================== # ==============================================================================
# Stage 2: Go Backend Build (Native Cross-Compile via TARGETOS / TARGETARCH) # Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
# ============================================================================== # ==============================================================================
FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS backend-builder FROM golang:1.23-alpine AS backend-builder
ARG TARGETOS=linux
ARG TARGETARCH=amd64
WORKDIR /app WORKDIR /app
RUN apk add --no-cache git ca-certificates RUN apk add --no-cache git ca-certificates
# Cache Go modules & pre-compile heavy dependencies (modernc.org/sqlite) in a dedicated layer
COPY go.mod ./ COPY go.mod ./
RUN go get github.com/go-chi/chi/v5@v5.2.0 \
github.com/go-chi/cors@v1.2.1 \
github.com/golang-jwt/jwt/v5@v5.2.1 \
github.com/gorilla/websocket@v1.5.3 \
golang.org/x/crypto/bcrypt@v0.31.0 \
modernc.org/sqlite@v1.34.4 && \
CGO_ENABLED=0 go build \
modernc.org/sqlite \
github.com/go-chi/chi/v5 \
github.com/go-chi/cors \
github.com/golang-jwt/jwt/v5 \
github.com/gorilla/websocket \
golang.org/x/crypto/bcrypt
# Copy application source & compiled frontend SPA assets for go:embed
COPY cmd/ ./cmd/ COPY cmd/ ./cmd/
COPY internal/ ./internal/ COPY internal/ ./internal/
COPY migrations/ ./migrations/ COPY migrations/ ./migrations/
# Copy compiled frontend SPA assets into cmd/server/dist for go:embed
COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-s -w" -o /app/mindsift ./cmd/server # Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary
RUN go mod tidy && \
CGO_ENABLED=0 go vet ./... && \
CGO_ENABLED=0 go test -v ./... && \
CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/mindsift ./cmd/server
# ============================================================================== # ==============================================================================
# Stage 3: Minimal Production Runtime (< 35 MB) # Stage 3: Minimal Production Runtime (< 35 MB)
+70 -548
View File
@@ -3,495 +3,87 @@
# 🧠 MindSift # 🧠 MindSift
### *Der Kopf frei in drei Sekunden.* ### *Der Kopf frei in drei Sekunden.*
[![Build & Docker](https://img.shields.io/badge/Gitea%20CI-Passing-22c55e?logo=gitea&logoColor=white)](https://gitea.io) [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker Image](https://img.shields.io/badge/Docker-Multi--Arch-2496ed?logo=docker&logoColor=white)](https://hub.docker.com) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-in-60-sekunden-starten)
[![Backend](https://img.shields.io/badge/Backend-Go%201.24-00ADD8?logo=go&logoColor=white)](https://go.dev) [![Markdown](https://img.shields.io/badge/Datenhoheit-Pure%20Markdown%20%26%20JSON-18181b?style=flat-square)](#)
[![Frontend](https://img.shields.io/badge/Frontend-React%2019%20%2F%20Vite-61DAFB?logo=react&logoColor=black)](https://react.dev) [![License](https://img.shields.io/badge/Lizenz-MIT-22c55e?style=flat-square)](LICENSE)
[![Database](https://img.shields.io/badge/Database-SQLite%20(WAL)%20%2F%20Postgres-003B57?logo=sqlite&logoColor=white)](https://sqlite.org)
[![Styling](https://img.shields.io/badge/Theme-Google%20Gemini%20Dark-8ab4f8)](#-designsystem--ästhetik-google-gemini-dna)
[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
**MindSift** verbindet den schnellsten Brain-Dump der Welt mit der Eleganz eines modernen Workspaces, spielerischer Tinder-Style-Triage und kollaborativen Echtzeit-Listen für Familien und Teams — 100 % Self-Hosted & Open Source. **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.
[Manifest](#-das-manifest) • [Features](#-features--workflow) • [Architektur](#-technische-architektur--tech-stack) • [Datenbankschema](#-datenbankschema) • [API & Webhooks](#-api-spezifikation--webhooks) • [CI/CD](#-gitea-cicd-pipeline) • [Deployment](#-deployment--schnellstart) • [Roadmap](#-schrittweiser-phasenplan) [Philosophie](#-die-philosophie) • [Funktionen](#-was-mindsift-auszeichnet) • [Für wen?](#-für-wen-ist-mindsift-gemacht) • [Schnellstart](#-in-60-sekunden-starten)
--- ---
</div> </div>
## 📜 Das Manifest ## 💡 Die Philosophie: *Erst raus aus dem Kopf – später sortieren*
Große Workspace-Tools wie Notion sind genial, wenn man am Schreibtisch sitzt und ein monatelanges Projekt plant. Aber sie scheitern im Alltag genau an einer Stelle: **im echten Moment**. Klassische Produktivitäts- und Notiz-Apps scheitern im Alltag oft an derselben Hürde: **Sie verlangen Struktur im falschen Moment.**
Wenn dir mitten im Gespräch, unterwegs oder kurz vor dem Feierabend ein wichtiger Gedanke kommt, willst du keinen Ordner auswählen, keine Datenbank-Eigenschaften pflegen und kein Formular ausfüllen. Du willst den Gedanken in drei Sekunden loswerden – und sicher sein, dass er nicht verloren geht.
Wenn dir an der Supermarktkasse eine Idee kommt, du im Auto über einen Geistesblitz nachdenkst oder schnell eine Zutat aufschreiben willst, hast du keine Zeit, durch fünf verschachtelte Ordner und relationale Datenbanken zu navigieren. Du brauchst ein Tool, das in einer halben Sekunde bereitsteht, deinen Gedanken friert und dir später hilft, ihn spielerisch auszusortieren. **MindSift trennt das Erfassen radikal vom Organisieren:**
**MindSift löst dieses Problem durch ein dreistufiges Prinzip:** 1. **Sofort abladen (Quick Dump):** Oben in der Eingabeleiste tippen, `Enter` drücken, fertig. Dein Kopf ist wieder frei. Wer möchte, kann einen Eintrag aber auch direkt bei der Eingabe einer Aufgabenliste, einer Einkaufsliste oder einer Notiz zuordnen.
1. **Erfassen:** Sofortiger Brain-Dump ohne jede Reibung. 2. **Spielerisch sichten (Triage):** Wenn du später Ruhe hast, arbeitest du deinen Gedanken-Stapel Karte für Karte durch. Nach links verwerfen, auf „Später“ schieben oder mit einem Klick in eine echte Aufgabe, ein Dokument oder einen Listeneintrag verwandeln.
2. **Sichten (Sift):** Spielerische Triage im Tinder-Style (Inbox Zero für dein Gehirn). 3. **Nahtlos weiterarbeiten:** Aus flüchtigen Ideen werden strukturierte Aufgaben-Boards, verlinkte Wiki-Dokumente oder geteilte Listen für Familie und Team.
3. **Strukturieren & Teilen:** Workspace für reifende Gedanken und geteilte Echtzeit-Listen für den Alltag.
--- ---
## ✨ Features & Workflow ## ✨ Was MindSift auszeichnet
``` ### ⚡ Quick-Dump & Triage-Deck
[ 🚀 Instant Capture ] * **Immer griffbereit:** Die Schnell-Eingabeleiste sitzt prominent ganz oben – jederzeit erreichbar per Klick oder Tastendruck (`C` oder `/`).
Handy raus • PWA • Webhook • Sprache * **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.
│
▼
[ 📥 Thought Inbox ]
│
▼
[ 🃏 Tinder-Style Triage ]
┌──────────┴──────────┐
▼ ▼
[ 👈 Nach Links ] [ 👉 Nach Rechts ]
Verwerfen / Done Behalten / Vertiefen
┌───────┼───────┐
▼ ▼ ▼
Task Doc Shared List
```
* **⚡ Capture First (Instant Puffer):** ### ✅ Aufgabenlisten & Kanban-Board
Handy raus, App antippen, Cursor steht im Feld. Text tippen oder diktieren (Web Speech API), absenden, fertig. Kein Ordner wählen, kein Tagging, kein Nachdenken. PWA-Shortcut für direkten Homescreen-Capture (< 300 ms Latenz). * **Mehrere Aufgabenlisten:** Trenne Berufliches, Privates, Projekte oder gemeinsame Team-Aufgaben sauber in eigenen Listen.
* **🃏 Triage im Tinder-Style:** * **Liste oder Kanban:** Wechsle jederzeit mit einem Klick zwischen einer kompakten Listenansicht und einem übersichtlichen **3-Spalten-Kanban-Board** (*Zu erledigen*, *In Bearbeitung*, *Erledigt*).
Abends oder am Wochenende gehst du deine unstrukturierten Gedanken durch wie ein Kartendeck: * **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.
* **Wisch nach links:** Weg damit (Gedanke erledigt oder irrelevant).
* **Wisch nach rechts:** Behalten, verfeinern, in eine To-Do mit Fälligkeitsdatum verwandeln oder in ein Projekt einsortieren. ### 📝 Workspace: Komfort wie in Word, gespeichert als echtes Markdown
* **📝 Notion-Flair, aber ohne Ballast:** * **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.
Wächst ein Gedanke zum Projekt heran, wechselst du in den Workspace. Reiner, ablenkungsfreier Block-Editor mit Überschriften, Listen, Codeblöcken und Task-Elementen – ohne relationale Datenbank-Überkomplexität. * **Vernetztes Wiki:** Verknüpfe Dokumente untereinander über interne `[[Wiki-Links]]` und organisiere dein Wissen in einer klaren Ordner- und Unterordner-Struktur.
* **🛒 Geteilte Listen & Family Spaces (Echtzeit):** * **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.
Einkaufslisten oder Urlaubsplaner per Link teilen. Hakst du an der Kasse die Hafermilch ab, verschwindet sie bei deinem Partner in Millisekunden dank WebSocket-Synchronisation.
* **🤖 Webhooks & Ingestion API:** ### 🛒 Geteilte Echtzeit-Listen
Smarthome-Alarme (z. B. Home Assistant, Zigbee2MQTT), Cronjobs oder Webhooks speisen dringende Aufgaben oder Messpunkte direkt in deine Inbox ein. * **Gemeinsam abhaken:** Ob Wocheneinkauf, Packliste für den Urlaub oder Checkliste fürs Projekt – teile Spaces per Einladungscode mit Partnern, Familie oder Kollegen.
* **🛡️ 100 % Self-Hosted & Datensouveränität:** * **Live-Synchronisation:** Hakst du im Supermarkt einen Artikel ab, verschwindet er im selben Augenblick auf den Bildschirmen aller anderen Mitglieder.
Keine fremde Cloud liest mit. Ein schlanker Docker-Container, SQLite mit Write-Ahead-Log (WAL) oder PostgreSQL.
### 🏢 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 & 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.
--- ---
## 🎨 Designsystem & Ästhetik: Google Gemini DNA ## 👥 Für wen ist MindSift gemacht?
MindSift setzt konsequent auf die Ästhetik von Google Gemini – organisch, hochmodern und beruhigend: * **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.
* **Dark Mode First (OLED Deep Obsidian):** * **Für Teams, Selbstständige & Homelab-Enthusiasten**, die Aufgaben, Projektdokumentation, Server-Webhooks und Benutzerrechte souverän auf der eigenen Infrastruktur betreiben wollen.
* Hintergrund primär: `#0b0f19` / `#131314`
* Oberflächen/Karten: `#1e1f20` mit feinen `1px` Konturen (`rgba(255, 255, 255, 0.08)`)
* Akzente & Glows: Gemini Blue (`#8ab4f8`) und sanftes Violett (`#c58af9`)
* **Pill-Shapes & Soft Curves:** Stark abgerundete Eingabefelder (`rounded-full`), fließende Kapselbuttons und weiche Kanten.
* **Zurückhaltende Eleganz:** Großzügiger Weißraum (*Spreading*), subtile Ambient-Glows bei Fokus und flüssige 60fps-Gestenphysik für die Karten-Triage.
* **Haptik & Touch-Optimierung:** Große Interaktionsradien (mind. 48px Touch-Targets), Vibrations-Feedback via Web Vibration API bei Karten-Swipes.
--- ---
## 🏗️ Technische Architektur & Tech-Stack ## 🚀 In 60 Sekunden starten
Als Lead-Architektur wurde ein **Single-Binary / Single-Container** Paradigma gewählt. Das Go-Backend kompiliert das React-Frontend direkt in ein einziges Executable ein (`embed.FS`). MindSift läuft als einzelner, ressourcenschonender Container und bringt alles mit, was du brauchst.
``` ### Start mit Docker Compose
┌─────────────────────────────────────────────────────────────────────────┐
│ Client (Mobile PWA / Desktop) │
│ React 19 • Vite • Tailwind CSS v4 • Framer Motion • Tiptap Block-Editor │
└───────────────────────────────┬─────────────────────────────────────────┘
│ HTTP/2 / REST & WebSockets (WSS)
┌───────────────────────────────▼─────────────────────────────────────────┐
│ MindSift Single Container (Go 1.24) │
│ │
│ ┌─────────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Embedded Assets │ │ Chi HTTP Mux │ │ WebSocket Hub │ │
│ │ (React SPA Dist) │ │ REST / Webhook │ │ (Realtime Sync) │ │
│ └─────────────────────┘ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ┌───────▼─────────────────────▼────────┐ │
│ │ Business Service Layer │ │
│ │ (Capture, Triage, Docs, Lists) │ │
│ └──────────────────┬───────────────────┘ │
│ │ │
│ ┌──────────────────▼───────────────────┐ │
│ │ Data Access Layer (sqlc / WAL) │ │
│ └──────────────────┬───────────────────┘ │
└─────────────────────────────────────────────────┼───────────────────────┘
│
┌──────────────────▼───────────────────┐
│ SQLite 3 (WAL Mode, zero-config) │
│ *Optional: PostgreSQL Driver │
└──────────────────────────────────────┘
```
### Stack-Entscheidungen & Rationale Lege eine `docker-compose.yml` an:
| Schicht | Technologie | Begründung & Architekturentscheidung |
| :--- | :--- | :--- |
| **Backend** | **Go 1.24+ (Chi Router)** | Extrem geringer Speicherverbrauch (~15–25 MB RAM idle), Kaltstart in < 5 ms, überlegene Concurrency für WebSockets, erzeugt ein statisches Binary. |
| **Frontend** | **React 19 + TypeScript + Vite** | Bietet mit **Framer Motion** die beste Gesten- und Physik-Engine für flüssige Tinder-Swipes und mit **Tiptap** den industrieweit führenden blockbasierten Rich-Text-Editor. |
| **Styling** | **Tailwind CSS v4** | Höchste Performance, null CSS-Runtime-Overhead, perfekte Abbildung des Gemini Dark-Themas mit Design-Tokens. |
| **Realtime** | **Go WebSockets (`nhooyr/websocket`)** | Schlanke, native WebSocket-Rooms für kollaborative Einkaufslisten mit Sub-10ms-Latenz und automatischem Reconnect. |
| **Datenbank** | **SQLite (WAL-Modus) / sqlc** | Standardmäßig Zero-Config mit Write-Ahead-Logging, ACID-sicher und als einzelne Datei einfachst per Backup sicherbar. Optional umschaltbar auf PostgreSQL per `DATABASE_URL`. |
| **PWA / Offline** | **Workbox / IndexedDB** | App startet auch im Funkloch sofort; Gedanken werden lokal vorgehalten und beim nächsten Verbindungsaufbau transparent synchronisiert. |
| **Container** | **Distroless / Alpine Scratch** | Minimalistisches Multi-Stage Dockerfile. End-Image-Größe < 35 MB. Keine Sicherheitsrisiken durch überflüssige System-Pakete. |
---
## 🗄️ Datenbankschema
Das Schema ist relational strukturiert, normalisiert und auf Lese- sowie Schreibgeschwindigkeit im Sub-Millisekundenbereich optimiert.
```mermaid
erDiagram
USERS ||--o{ THOUGHTS : owns
USERS ||--o{ DOCUMENTS : authors
USERS ||--o{ SPACE_MEMBERS : participates
USERS ||--o{ API_KEYS : possesses
SPACES ||--o{ SPACE_MEMBERS : contains
SPACES ||--o{ LISTS : hosts
LISTS ||--o{ LIST_ITEMS : contains
DOCUMENTS ||--o{ DOCUMENT_BLOCKS : contains
USERS {
uuid id PK
string email UK
string password_hash
string display_name
timestamp created_at
}
THOUGHTS {
uuid id PK
uuid user_id FK
text content
string voice_transcript
string status "inbox | kept | discarded | converted"
string converted_type "task | document | list_item"
uuid converted_ref_id
timestamp due_date
timestamp created_at
timestamp triaged_at
}
DOCUMENTS {
uuid id PK
uuid user_id FK
uuid space_id FK
string title
string icon
boolean is_archived
timestamp created_at
timestamp updated_at
}
DOCUMENT_BLOCKS {
uuid id PK
uuid document_id FK
string block_type "paragraph | heading | task | code"
jsonb content
int sort_order
timestamp updated_at
}
SPACES {
uuid id PK
string name
string icon
boolean is_personal
timestamp created_at
}
SPACE_MEMBERS {
uuid space_id PK,FK
uuid user_id PK,FK
string role "owner | editor | viewer"
timestamp joined_at
}
LISTS {
uuid id PK
uuid space_id FK
string title
string list_type "shopping | checklist"
timestamp updated_at
}
LIST_ITEMS {
uuid id PK
uuid list_id FK
string content
boolean is_completed
uuid completed_by FK
timestamp completed_at
int sort_order
}
API_KEYS {
uuid id PK
uuid user_id FK
string name
string key_hash UK
string scopes
timestamp last_used_at
}
```
### SQL DDL Definitionen (Auszug SQLite / Postgres kompatibel)
```sql
-- Aktivierung von WAL & Foreign Keys für SQLite
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
-- Gedanken & Instant Inbox Puffer
CREATE TABLE IF NOT EXISTS thoughts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
status TEXT CHECK(status IN ('inbox', 'kept', 'discarded', 'converted')) DEFAULT 'inbox',
converted_type TEXT CHECK(converted_type IN ('task', 'document', 'list_item', NULL)),
converted_ref_id TEXT,
due_date DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
triaged_at DATETIME,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC);
-- Kollaborative Listen (z.B. Familie & Einkaufen)
CREATE TABLE IF NOT EXISTS list_items (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL,
content TEXT NOT NULL,
is_completed BOOLEAN NOT NULL DEFAULT 0,
completed_by TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME,
FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE
);
CREATE INDEX idx_list_items_sync ON list_items(list_id, is_completed, sort_order ASC);
```
---
## 🔌 API-Spezifikation & Webhooks
Alle Endpunkte antworten mit `application/json` und nutzen Standard-HTTP-Statuscodes. Die Authentifizierung erfolgt über `Bearer <JWT>` oder dedizierte Ingestion-Keys.
### 1. Instant Capture & Ingestion (Sub-Second Entry)
```http
POST /api/v1/inbox
Authorization: Bearer <token>
Content-Type: application/json
{
"content": "Zündkerzen für den Rasenmäher bestellen #garage",
"due_date": "2026-10-25T18:00:00Z" // Optional
}
```
**Response (201 Created - < 30ms):**
```json
{
"id": "th_9f8a3c21",
"status": "inbox",
"created_at": "2026-10-24T16:25:00Z"
}
```
### 2. Smarthome & Externe Webhook Ingestion
Für Home Assistant, Node-RED, n8n oder Shell-Skripte:
```http
POST /api/v1/webhooks/ingest?token=mindsift_sec_789xyz
Content-Type: application/json
{
"title": "Kellerfeuchtigkeit über 70%",
"priority": "urgent",
"tags": ["smarthome", "keller"]
}
```
### 3. Triage & Tinder-Style Swipe Deck
* **Gedanken-Deck laden:** `GET /api/v1/triage/deck?limit=20`
* **Gedanke wischen (Swipe Action):**
```http
PATCH /api/v1/triage/th_9f8a3c21
Authorization: Bearer <token>
Content-Type: application/json
{
"action": "keep", // oder "discard"
"convert_to": "task", // optional: "task" | "document" | "list_item"
"target_list_id": "lst_shopping_123", // wenn convert_to = "list_item"
"due_date": "2026-10-28T09:00:00Z"
}
```
### 4. Realtime WebSocket Protocol (Kollaborative Listen)
Client verbindet sich zu `wss://mindsift.local/api/v1/ws/spaces/:space_id`.
* **Event: Item hinzugefügt/aktualisiert**
```json
{
"event": "ITEM_UPDATED",
"payload": {
"list_id": "lst_shopping_123",
"item_id": "itm_456",
"content": "Hafermilch (Barista)",
"is_completed": true,
"user_name": "Sandro"
}
}
```
---
## 🚀 Gitea CI/CD Pipeline
Das Projekt ist für den Betrieb mit **Gitea Actions** optimiert. Bei jedem Push auf `main` oder einem Release-Tag baut die Pipeline automatisch ein Multi-Arch-Docker-Image (`linux/amd64`, `linux/arm64`) und schiebt es in die Gitea Container Registry.
### Workflow-Datei: `.gitea/workflows/build-and-publish.yaml`
```yaml
name: MindSift Multi-Arch Docker Build
on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- main
jobs:
test:
name: Lint & Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: true
- name: Run Backend Tests
run: |
go vet ./...
go test -v -race ./...
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Run Frontend Tests
run: |
cd frontend
npm ci
npm run lint
npm run test:run
build-docker:
name: Build & Push Docker Image
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up QEMU (Multi-Arch)
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ gitea.server_url }}
username: ${{ gitea.actor }}
password: ${{ secrets.GITEA_TOKEN }}
- name: Extract Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ gitea.server_url }}/${{ gitea.repository }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=semver,pattern={{version}}
- name: Build and Push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
```
### Multi-Stage Dockerfile (`Dockerfile`)
```dockerfile
# Stage 1: Frontend Build
FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
# Stage 2: Go Backend Build
FROM golang:1.24-alpine AS backend-builder
WORKDIR /app
RUN apk add --no-cache build-base git
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Kopiere gebaute Frontend Assets in das embed.FS-Verzeichnis
COPY --from=frontend-builder /app/frontend/dist ./server/dist
RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w -extldflags '-static'" -o mindsift ./cmd/server
# Stage 3: Minimal Production Image
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=backend-builder /app/mindsift /app/mindsift
ENV PORT=8080
ENV DATA_DIR=/data
VOLUME ["/data"]
EXPOSE 8080
USER 1000:1000
ENTRYPOINT ["/app/mindsift"]
```
---
## 🐳 Deployment & Schnellstart
### 1. Schnellstart mit Docker Compose (Empfohlen)
Erstelle eine `docker-compose.yml`:
```yaml ```yaml
services: services:
mindsift: mindsift:
image: gitea.your-domain.com/user/mindsift:latest image: git.dsit.me/sandrodach/mindsift:dev
container_name: mindsift container_name: mindsift
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -501,109 +93,39 @@ services:
environment: environment:
- PORT=8080 - PORT=8080
- DATA_DIR=/data - DATA_DIR=/data
- JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars - JWT_SECRET=bitte-hier-ein-langes-sicheres-geheimnis-eintragen
- BASE_URL=https://mindsift.local - ALLOW_REGISTRATION=false
- LOG_LEVEL=info # Optional: Eigener SMTP-Server für E-Mail-Alarme
# (kann alternativ auch später direkt in der Admin-Oberfläche eingetragen werden)
- SMTP_HOST=
- SMTP_PORT=587
- SMTP_USER=
- SMTP_PASS=
- SMTP_FROM=
- SMTP_TLS=true
``` ```
Starte den Service: Container starten:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
Die Weboberfläche steht sofort unter `http://localhost:8080` bereit.
### 2. Standalone Docker Run Öffne anschließend **`http://localhost:8080`** im Browser. Beim allerersten Aufruf führt dich MindSift automatisch durch die **Ersteinrichtung** für dein Administrator-Konto.
```bash
docker run -d \
--name mindsift \
--restart unless-stopped \
-p 8080:8080 \
-v $(pwd)/mindsift-data:/data \
-e JWT_SECRET="dein-super-sicheres-geheimnis" \
gitea.your-domain.com/user/mindsift:latest
```
### Umgebungsvariablen
| Variable | Standardwert | Beschreibung |
| :--- | :--- | :--- |
| `PORT` | `8080` | Interner HTTP/WebSocket Port |
| `DATA_DIR` | `/data` | Verzeichnis für SQLite DB, Uploads und Cache |
| `DATABASE_URL` | `sqlite:///data/mindsift.db` | Optional: PostgreSQL DSN (`postgres://user:pass@host:5432/db`) |
| `JWT_SECRET` | *Pflichtfeld* | Secret für Authentifizierungs-Tokens (mind. 32 Zeichen) |
| `BASE_URL` | `http://localhost:8080` | Externe URL für Einladungslinks und Webhooks |
| `ALLOW_REGISTRATION`| `true` | Erstregistrierung erlauben (`false` nach Admin-Erstellung) |
--- ---
## 🗺️ Schrittweiser Phasenplan ## 🔒 Deine Daten gehören dir
``` * **100 % Self-Hosted:** Keine externen Cloud-Dienste, keine versteckte Telemetrie, kein Tracking.
Phase 1: Foundation ────► Phase 2: Triage Deck ────► Phase 3: Workspace * **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.
(Go + PWA + DB) (Framer Physics) (Tiptap Blocks)
│
Phase 6: Hardening ◄──── Phase 5: Webhooks ◄──── Phase 4: Realtime
(CI/CD + Release) (Smarthome Ingest) (Family Lists)
```
### Phase 1: Core Foundation & Instant Capture (Sprint 1)
- [x] Repository-Initialisierung & Architektur-Design
- [x] Go Backend-Grundgerüst (Chi Router, Konfigurations-Management, Healthcheck)
- [x] SQLite Storage-Layer mit WAL-Modus und automatischer Migration
- [x] JWT-Authentifizierung & User-Management
- [x] Mobile-First PWA Setup (Vite, React, Tailwind CSS, Gemini Dark Theme)
- [x] **Instant-Capture-Interface:** Autofokus-Eingabemaske, Sprachaufnahme (Web Speech API), Latenz < 300 ms
### Phase 2: Tinder-Style Triage & Task Engine (Sprint 2)
- [x] Interaktives Swipe-Deck mit **Framer Motion** (Gesten, Rotation, elastische Rückfederung)
- [x] Triage-Status-Engine (`inbox` -> `kept` / `discarded` / `converted`)
- [x] Quick-Convert: Wisch nach rechts transformiert Gedanken direkt in Aufgaben mit Due-Date, Projekt-Notizen oder Listen-Einträge
- [x] Tastatur-Shortcuts für Power-User am Desktop (`H`/`L` oder Pfeiltasten, `C`/`/` für Capture)
- [x] Haptisches Feedback auf Mobilgeräten (Web Vibration API)
### Phase 3: Notion-Style Workspace (Sprint 3)
- [x] Block-Editor (Headings, Task-Checkboxes, Code-Blöcke, Callouts, Listen)
- [x] Dokumenten-Verwaltung für vertiefte Gedanken inkl. Pinning & Icon-Picker
- [x] Tagging & Volltextsuche über Gedanken und Dokumente
- [x] Markdown Export (`.md`)
### Phase 4: Realtime Spaces & Family Sync (Sprint 4)
- [x] Multi-User Space-Architektur (Familien- und Team-Räume mit Invite-Codes)
- [x] Go WebSocket Hub mit Raum-Multiplexing
- [x] Echtzeit-Einkaufslisten: Sofortige Synchronisation von Checked-States über alle Endgeräte
- [x] Optimistische UI-Updates für verzögerungsfreies Offline-/Online-Erlebnis
### Phase 5: Smarthome Webhooks & External Ecosystem (Sprint 5)
- [x] Ingestion-API mit API-Token-Management (`mindsift_sec_...`)
- [x] Webhook-Endpunkte für Home Assistant, Zigbee2MQTT, n8n und Cronjobs
- [x] Interaktiver Live-Webhook-Simulator in der UI
- [x] Backup- und Export-Endpunkt (kompletter JSON-Export)
### Phase 6: Production Hardening, CI/CD & v1.0 (Sprint 6)
- [x] Gitea Actions Multi-Arch Workflow (`amd64`, `arm64`)
- [x] Single-Container Packaging (`embed.FS`) & Multi-Stage Dockerfile
- [x] Automatisierte Backend-Unit-Tests (`internal/storage/sqlite_test.go`)
- [ ] End-to-End Tests (Playwright) auf Ziel-Cluster
- [x] Erstellung der offiziellen Dokumentation & v1.0 Release-Bereitschaft
---
## 🔒 Sicherheit & Datenschutz
* **Zero Tracking:** Keine Analytics, keine externen CDNs, keine Telemetrie.
* **Content Security Policy (CSP):** Strikte Header; alle Assets und Fonts werden lokal ausgeliefert.
* **Sichere Passwörter:** Argon2id Passwort-Hashing nach OWASP-Empfehlung.
* **Isolierte Container:** Der Container läuft standardmäßig unter einer Non-Root-UID (`1000:1000`).
--- ---
## 📄 Lizenz ## 📄 Lizenz
Dieses Projekt ist lizenziert unter der **MIT-Lizenz** – siehe [LICENSE](LICENSE) für Details. Veröffentlicht unter der **MIT-Lizenz** – siehe [LICENSE](LICENSE).
---
<div align="center"> <div align="center">
<sub>Designed with ❤️ for clarity, speed and digital sovereignty.</sub> <sub>Gebaut für maximale Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
</div> </div>
+2 -4
View File
@@ -31,10 +31,8 @@ func main() {
} }
defer store.Close() defer store.Close()
// Ensure initial default workspace exists on fresh self-hosted boot // Seed SMTP defaults from environment variables into DB if not yet configured in DB
if _, err := store.EnsureDefaultUser(); err != nil { store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS)
log.Printf("Warning: could not seed default user: %v", err)
}
wsHub := ws.NewHub() wsHub := ws.NewHub()
+9 -2
View File
@@ -3,7 +3,7 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: git.dsit.me/sandrodach/mindsift:latest image: git.dsit.me/sandrodach/mindsift:dev
container_name: mindsift container_name: mindsift
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -15,5 +15,12 @@ services:
- DATA_DIR=/data - DATA_DIR=/data
- JWT_SECRET=${JWT_SECRET:-change_this_to_a_secure_random_key_min_32_chars} - JWT_SECRET=${JWT_SECRET:-change_this_to_a_secure_random_key_min_32_chars}
- BASE_URL=${BASE_URL:-http://localhost:8080} - BASE_URL=${BASE_URL:-http://localhost:8080}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true} - ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
- LOG_LEVEL=${LOG_LEVEL:-info} - LOG_LEVEL=${LOG_LEVEL:-info}
# 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}
+6 -3
View File
@@ -15,10 +15,13 @@
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
<script> <script>
if ('caches' in window) {
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
}
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { navigator.serviceWorker.getRegistrations().then((regs) => {
navigator.serviceWorker.register('/sw.js').catch(() => {}); regs.forEach((r) => r.unregister());
}); }).catch(() => {});
} }
</script> </script>
</body> </body>
+2744
View File
File diff suppressed because it is too large. Load diff
+8 -23
View File
@@ -1,33 +1,18 @@
const CACHE_NAME = 'mindsift-pwa-v1'; // Network-first / cache-clearing service worker to ensure fresh builds across all browsers
const ASSETS = ['/', '/index.html', '/manifest.json']; self.addEventListener('install', () => {
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))
);
self.skipWaiting(); self.skipWaiting();
}); });
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
event.waitUntil( event.waitUntil(
caches.keys().then((keys) => caches
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))) .keys()
) .then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
.then(() => self.clients.claim())
); );
self.clients.claim();
}); });
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', () => {
if (event.request.method !== 'GET' || event.request.url.includes('/api/')) { // Always use native browser network fetch so API calls and fresh Vite bundles are never intercepted by stale caches
return; return;
}
event.respondWith(
fetch(event.request)
.then((res) => {
const clone = res.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
return res;
})
.catch(() => caches.match(event.request).then((cached) => cached || caches.match('/')))
);
}); });
+380 -251
View File
@@ -1,27 +1,91 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { import {
Layers, Layers,
CheckSquare, CheckSquare,
FileText, FileText,
ShoppingCart, ShoppingCart,
Webhook,
Brain, Brain,
Settings,
Shield,
LogOut, LogOut,
User as UserIcon, ChevronDown,
Sparkles, RotateCcw,
Palette,
} from 'lucide-react'; } from 'lucide-react';
import { DashboardStats, SharedList, Thought, User } from './lib/types'; import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types';
import { api, clearToken, getToken } from './lib/api'; import { api, clearToken, getToken, triggerHaptic } from './lib/api';
import {
AVAILABLE_THEMES,
initThemeOnBoot,
setActiveTheme as applyTheme,
ThemeId,
} from './lib/theme';
import { InstantCaptureBar } from './features/capture/InstantCaptureBar'; import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
import { TriageDeck } from './features/triage/TriageDeck'; import { TriageDeck } from './features/triage/TriageDeck';
import { TasksView } from './features/triage/TasksView'; import { TasksView } from './features/triage/TasksView';
import { WorkspaceView } from './features/workspace/WorkspaceView'; import { WorkspaceView } from './features/workspace/WorkspaceView';
import { SharedListsView } from './features/lists/SharedListsView'; import { SharedListsView } from './features/lists/SharedListsView';
import { EcosystemView } from './features/settings/EcosystemView'; import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { UserSettingsView } from './features/settings/UserSettingsView';
import { AdminSettingsView } from './features/settings/AdminSettingsView';
type ActiveTab = 'triage' | 'tasks' | 'workspace' | 'lists' | 'ecosystem'; type ActiveTab =
| 'triage'
| 'tasks'
| 'workspace'
| 'lists'
| 'user_settings'
| 'admin_settings';
interface ErrorBoundaryState {
hasError: boolean;
errorText: string;
}
class ViewErrorBoundary extends React.Component<
{ children: React.ReactNode; onReset: () => void },
ErrorBoundaryState
> {
constructor(props: { children: React.ReactNode; onReset: () => void }) {
super(props);
this.state = { hasError: false, errorText: '' };
}
static getDerivedStateFromError(err: Error): ErrorBoundaryState {
return { hasError: true, errorText: err?.message || 'Unbekannter Darstellungsfehler' };
}
render() {
if (this.state.hasError) {
return (
<div className="p-6 panel-surface text-xs space-y-3 max-w-lg mx-auto my-8">
<h3 className="text-sm font-bold text-main">Ansicht konnte nicht geladen werden</h3>
<p className="text-muted">{this.state.errorText}</p>
<button
onClick={() => {
this.setState({ hasError: false, errorText: '' });
this.props.onReset();
}}
className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>Ansicht zurücksetzen</span>
</button>
</div>
);
}
return this.props.children;
}
}
export const App: React.FC = () => { export const App: React.FC = () => {
const [activeTheme, setActiveThemeState] = useState<ThemeId>(() => initThemeOnBoot());
const [bootLoading, setBootLoading] = useState(true);
const [authStatus, setAuthStatus] = useState<AuthStatus>({
needsSetup: false,
allowRegistration: false,
instanceName: 'MindSift',
});
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [stats, setStats] = useState<DashboardStats>({ const [stats, setStats] = useState<DashboardStats>({
inboxCount: 0, inboxCount: 0,
@@ -36,14 +100,17 @@ export const App: React.FC = () => {
const [activeTab, setActiveTab] = useState<ActiveTab>('triage'); const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
const [selectedDocId, setSelectedDocId] = useState<string | null>(null); const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [captureFocusCount, setCaptureFocusCount] = useState(0); const [captureFocusCount, setCaptureFocusCount] = useState(0);
const [refreshRevision, setRefreshRevision] = useState(0);
// Auth Modal State for switching or registering additional family members // Profile Dropdown State
const [showAuthModal, setShowAuthModal] = useState(false); const [showProfileMenu, setShowProfileMenu] = useState(false);
const [authMode, setAuthMode] = useState<'login' | 'register'>('login'); const dropdownRef = useRef<HTMLDivElement | null>(null);
const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const handleChangeTheme = (themeId: ThemeId) => {
const [displayName, setDisplayName] = useState(''); triggerHaptic(10);
const [authError, setAuthError] = useState(''); applyTheme(themeId);
setActiveThemeState(themeId);
};
const refreshAll = async () => { const refreshAll = async () => {
try { try {
@@ -54,52 +121,97 @@ export const App: React.FC = () => {
]); ]);
setUser(meRes.user); setUser(meRes.user);
setStats(meRes.stats); setStats(meRes.stats);
setDeck(deckRes); setDeck(Array.isArray(deckRes) ? deckRes : []);
setLists(listsRes); setLists(Array.isArray(listsRes) ? listsRes : []);
} catch { } catch {
// Fallback to quickStart session // If token expired or invalid, api.ts dispatches mindsift:unauthorized
try {
const qs = await api.quickStart();
setUser(qs.user);
const [st, dk, ls] = await Promise.all([
api.getStats(),
api.getTriageDeck(),
api.getAllLists(),
]);
setStats(st);
setDeck(dk);
setLists(ls);
} catch {
// Offline or waiting for server
} }
};
const checkBootAndAuth = async () => {
setBootLoading(true);
try {
const st = await api.getAuthStatus();
setAuthStatus(st);
if (st.needsSetup) {
clearToken();
setUser(null);
return;
}
if (getToken()) {
await api.flushOfflineQueue();
await refreshAll();
} else {
setUser(null);
}
} catch {
// Server temporarily unreachable
} finally {
setBootLoading(false);
} }
}; };
useEffect(() => { useEffect(() => {
const init = async () => { checkBootAndAuth();
if (!getToken()) {
const onUnauthorized = async () => {
setUser(null);
setShowProfileMenu(false);
try { try {
await api.quickStart(); const st = await api.getAuthStatus();
setAuthStatus(st);
} catch { } catch {
// ignore // ignore
} }
}
await api.flushOfflineQueue();
await refreshAll();
}; };
init();
const onOnline = async () => { const onOnline = async () => {
if (getToken()) {
const synced = await api.flushOfflineQueue(); const synced = await api.flushOfflineQueue();
if (synced > 0) refreshAll(); if (synced > 0) refreshAll();
}
}; };
window.addEventListener('mindsift:unauthorized', onUnauthorized);
window.addEventListener('online', onOnline); window.addEventListener('online', onOnline);
return () => window.removeEventListener('online', onOnline); return () => {
window.removeEventListener('mindsift:unauthorized', onUnauthorized);
window.removeEventListener('online', onOnline);
};
}, []); }, []);
const handleCaptured = (thought: Thought) => { // Close profile dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowProfileMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleAuthenticated = async (loggedInUser: User) => {
setUser(loggedInUser);
setAuthStatus((prev) => ({ ...prev, needsSetup: false }));
setActiveTab('triage');
await refreshAll();
};
const handleCaptured = (
thought: Thought,
target: 'inbox' | 'task' | 'list_item' | 'document' = 'inbox'
) => {
if (target === 'inbox' && thought.status === 'inbox') {
setDeck((prev) => [thought, ...prev]); setDeck((prev) => [thought, ...prev]);
setStats((prev) => ({ ...prev, inboxCount: prev.inboxCount + 1 })); setStats((prev) => ({ ...prev, inboxCount: prev.inboxCount + 1 }));
} else if (target === 'document' && thought.convertedRefId) {
setSelectedDocId(thought.convertedRefId);
setActiveTab('workspace');
}
setRefreshRevision((r) => r + 1);
refreshAll(); refreshAll();
}; };
@@ -108,97 +220,214 @@ export const App: React.FC = () => {
setActiveTab('workspace'); setActiveTab('workspace');
}; };
const handleAuthSubmit = async (e: React.FormEvent) => { const handleTriggerAddButton = () => {
e.preventDefault(); triggerHaptic(20);
setAuthError(''); window.scrollTo({ top: 0, behavior: 'smooth' });
setCaptureFocusCount((c) => c + 1);
};
const handleLogout = async () => {
triggerHaptic(20);
clearToken();
setUser(null);
setShowProfileMenu(false);
setActiveTab('triage');
try { try {
if (authMode === 'login') { const st = await api.getAuthStatus();
await api.login(email, password); setAuthStatus(st);
} else { } catch {
await api.register(email, password, displayName); // ignore
}
setShowAuthModal(false);
setEmail('');
setPassword('');
setDisplayName('');
await refreshAll();
} catch (err: any) {
setAuthError(err.message || 'Authentifizierung fehlgeschlagen');
} }
}; };
if (bootLoading) {
return ( return (
<div className="min-h-screen bg-[#0b0f19] text-[#e3e3e3] flex flex-col"> <div className="min-h-screen bg-canvas text-muted flex items-center justify-center text-xs font-sans">
{/* Ambient Top Gemini Glow */} Verbinde mit MindSift…
<div className="pointer-events-none fixed top-0 left-1/2 -translate-x-1/2 w-[560px] h-[180px] bg-gradient-to-r from-[#8ab4f8]/10 via-[#c58af9]/10 to-[#81c995]/10 blur-3xl -z-10" /> </div>
);
}
{/* Header Bar */} // Show Setup Wizard or Login Screen if needsSetup or not logged in
<header className="sticky top-0 z-30 backdrop-blur-xl bg-[#0b0f19]/85 border-b border-white/[0.06]"> if (authStatus.needsSetup || !user) {
<div className="max-w-6xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between gap-4"> return (
{/* Brand Logo */} <SetupAndAuthView
authStatus={authStatus}
onAuthenticated={handleAuthenticated}
/>
);
}
const isStaff = user.role === 'admin' || user.role === 'team_admin';
const roleLabel =
user.role === 'admin'
? 'Server-Admin'
: user.role === 'team_admin'
? 'Team-Admin'
: 'Mitglied';
return (
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30">
{/* Top Header Bar */}
<header className="sticky top-0 z-30 bg-panel border-b border-border shadow-sm">
<div className="max-w-6xl mx-auto px-3 sm:px-4 py-2 flex items-center justify-between gap-3 sm:gap-6">
{/* Logo & Headline */}
<div <div
onClick={() => { onClick={() => {
setActiveTab('triage'); setActiveTab('triage');
setCaptureFocusCount((c) => c + 1); handleTriggerAddButton();
}} }}
className="flex items-center gap-2.5 cursor-pointer group" className="flex items-center gap-2.5 cursor-pointer group select-none flex-shrink-0"
> >
<div className="w-9 h-9 rounded-full bg-[#1e1f20] gemini-border flex items-center justify-center group-hover:border-[#8ab4f8]/50 transition-colors"> <div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
<Brain className="w-5 h-5 text-[#8ab4f8]" /> <Brain className="w-4 h-4" />
</div> </div>
<div> <div className="flex flex-col">
<span className="font-bold text-lg tracking-tight gemini-gradient-text"> <span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
MindSift {authStatus.instanceName || 'MindSift'}
</span> </span>
<span className="hidden md:inline-block ml-2.5 text-xs text-[#9aa0a6]"> <span className="text-[11px] text-muted mt-0.5 hidden sm:inline-block">
Der Kopf frei in drei Sekunden. Der Kopf frei in drei Sekunden
</span> </span>
</div> </div>
</div> </div>
{/* Right User Pill */} {/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
<div className="flex items-center gap-2"> <div className="flex-1 max-w-xl mx-auto">
<InstantCaptureBar
onCaptured={handleCaptured}
lists={lists}
autoFocusTrigger={captureFocusCount}
/>
</div>
{/* User Profile Pill & Dropdown Menu */}
<div className="relative flex items-center gap-2 flex-shrink-0" ref={dropdownRef}>
<button <button
onClick={() => setShowAuthModal(true)} onClick={() => {
className="h-9 px-3.5 rounded-full bg-[#1e1f20] hover:bg-[#282a2c] gemini-border text-xs font-medium text-[#e3e3e3] flex items-center gap-2 transition-colors" triggerHaptic(10);
title="Benutzer wechseln oder Familienmitglied registrieren" setShowProfileMenu((prev) => !prev);
}}
className="h-8 pl-1 pr-2.5 capture-pill bg-panel hover:bg-panel-elevated border border-border text-xs font-medium text-main flex items-center gap-2 transition-colors"
title="Benutzermenü öffnen"
> >
<UserIcon className="w-3.5 h-3.5 text-[#8ab4f8]" /> <div className="w-6 h-6 capture-pill bg-accent text-on-accent text-[10px] font-bold flex items-center justify-center">
<span>{user?.displayName || 'Lokal'}</span> {user.displayName ? user.displayName.slice(0, 1).toUpperCase() : 'U'}
</div>
<span className="max-w-[120px] truncate hidden sm:inline">
{user.displayName}
</span>
<ChevronDown className="w-3.5 h-3.5 text-muted" />
</button>
{showProfileMenu && (
<div className="absolute right-0 top-full mt-1.5 w-64 panel-surface shadow-panel z-50 divide-y divide-border text-xs">
{/* User Info Header */}
<div className="p-3 bg-canvas">
<p className="font-bold text-main truncate">{user.displayName}</p>
<p className="text-[11px] text-muted truncate">{user.email}</p>
<span className="inline-block mt-1.5 px-1.5 py-0.5 bg-panel border border-border rounded-btn text-[10px] text-accent font-semibold">
{roleLabel}
</span>
</div>
{/* Theme Quick Switcher */}
<div className="p-2.5 space-y-1.5">
<span className="text-[10px] font-semibold text-muted uppercase tracking-wider flex items-center gap-1.5 px-1">
<Palette className="w-3 h-3 text-accent" />
<span>Design-Theme</span>
</span>
<div className="grid grid-cols-2 gap-1">
{AVAILABLE_THEMES.map((t) => (
<button
key={t.id}
type="button"
onClick={() => handleChangeTheme(t.id)}
className={`h-7 px-2 text-[11px] font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
activeTheme === t.id
? 'bg-panel-elevated border-accent text-main font-semibold'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span
className="w-2.5 h-2.5 border border-strong flex-shrink-0"
style={{ backgroundColor: t.previewBg }}
/>
<span className="truncate">{t.name.replace('MindSift ', '').split(' ')[0]}</span>
</button>
))}
</div>
</div>
{/* Menu Items */}
<div className="py-1">
<button
onClick={() => {
triggerHaptic(10);
setShowProfileMenu(false);
setActiveTab('user_settings');
}}
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
activeTab === 'user_settings' ? 'text-accent font-semibold' : 'text-main'
}`}
>
<Settings className="w-4 h-4 text-accent" />
<span>Kontoeinstellungen</span>
</button>
{isStaff && (
<button
onClick={() => {
triggerHaptic(10);
setShowProfileMenu(false);
setActiveTab('admin_settings');
}}
className={`w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 hover:bg-panel-elevated transition-colors ${
activeTab === 'admin_settings'
? 'text-accent font-semibold'
: 'text-main'
}`}
>
<Shield className="w-4 h-4 text-accent" />
<span>Administration</span>
</button>
)}
</div>
{/* Logout */}
<div className="py-1">
<button
onClick={handleLogout}
className="w-full px-3.5 py-2.5 text-left flex items-center gap-2.5 text-status-err hover:bg-status-err-bg transition-colors"
>
<LogOut className="w-4 h-4" />
<span>Abmelden</span>
</button> </button>
</div> </div>
</div> </div>
</header> )}
</div>
</div>
{/* Main Content Container */} {/* Navigation tabs attached to header */}
<main className="flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 pt-6 pb-24"> <nav className="max-w-6xl mx-auto px-1 sm:px-2 overflow-x-auto no-scrollbar border-t border-border">
{/* Global Instant Capture Section (Always Ready at Top) */} <div className="flex items-stretch">
<section className="mb-7">
<InstantCaptureBar
onCaptured={handleCaptured}
autoFocusTrigger={captureFocusCount}
/>
</section>
{/* Pill Navigation Tabs */}
<nav className="flex items-center justify-start sm:justify-center gap-2 overflow-x-auto pb-2 mb-8 no-scrollbar">
<button <button
onClick={() => setActiveTab('triage')} onClick={() => {
className={`h-11 px-5 rounded-full text-xs sm:text-sm font-medium flex items-center gap-2 flex-shrink-0 transition-all ${ triggerHaptic(10);
activeTab === 'triage' setActiveTab('triage');
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold shadow-sm' }}
: 'bg-[#131314] hover:bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border' className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`}
}`}
> >
<Layers className="w-4 h-4" /> <Layers className="w-4 h-4" />
<span>Triage-Deck</span> <span>Triage</span>
{stats.inboxCount > 0 && ( {stats.inboxCount > 0 && (
<span <span
className={`px-2 py-0.5 rounded-full text-xs font-bold ${ className={
activeTab === 'triage' activeTab === 'triage'
? 'bg-[#0b0f19] text-[#8ab4f8]' ? 'badge-accent'
: 'bg-[#8ab4f8]/20 text-[#8ab4f8]' : 'badge-neutral'
}`} }
> >
{stats.inboxCount} {stats.inboxCount}
</span> </span>
@@ -206,22 +435,21 @@ export const App: React.FC = () => {
</button> </button>
<button <button
onClick={() => setActiveTab('tasks')} onClick={() => {
className={`h-11 px-5 rounded-full text-xs sm:text-sm font-medium flex items-center gap-2 flex-shrink-0 transition-all ${ triggerHaptic(10);
activeTab === 'tasks' setActiveTab('tasks');
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold shadow-sm' }}
: 'bg-[#131314] hover:bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border' className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
}`}
> >
<CheckSquare className="w-4 h-4" /> <CheckSquare className="w-4 h-4" />
<span>Aufgaben</span> <span>Aufgaben</span>
{stats.tasksOpenCount > 0 && ( {stats.tasksOpenCount > 0 && (
<span <span
className={`px-2 py-0.5 rounded-full text-xs font-bold ${ className={
activeTab === 'tasks' activeTab === 'tasks'
? 'bg-[#0b0f19] text-[#8ab4f8]' ? 'badge-accent'
: 'bg-[#81c995]/20 text-[#81c995]' : 'badge-neutral'
}`} }
> >
{stats.tasksOpenCount} {stats.tasksOpenCount}
</span> </span>
@@ -229,52 +457,42 @@ export const App: React.FC = () => {
</button> </button>
<button <button
onClick={() => setActiveTab('workspace')} onClick={() => {
className={`h-11 px-5 rounded-full text-xs sm:text-sm font-medium flex items-center gap-2 flex-shrink-0 transition-all ${ triggerHaptic(10);
activeTab === 'workspace' setActiveTab('workspace');
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold shadow-sm' }}
: 'bg-[#131314] hover:bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border' className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`}
}`}
> >
<FileText className="w-4 h-4" /> <FileText className="w-4 h-4" />
<span>Workspace</span> <span>Workspace</span>
<span <span
className={`px-2 py-0.5 rounded-full text-xs ${ className={
activeTab === 'workspace' activeTab === 'workspace'
? 'bg-[#0b0f19] text-[#8ab4f8]' ? 'badge-accent'
: 'bg-[#1e1f20] text-[#9aa0a6]' : 'badge-neutral'
}`} }
> >
{stats.docsCount} {stats.docsCount}
</span> </span>
</button> </button>
<button <button
onClick={() => setActiveTab('lists')} onClick={() => {
className={`h-11 px-5 rounded-full text-xs sm:text-sm font-medium flex items-center gap-2 flex-shrink-0 transition-all ${ triggerHaptic(10);
activeTab === 'lists' setActiveTab('lists');
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold shadow-sm' }}
: 'bg-[#131314] hover:bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border' className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`}
}`}
> >
<ShoppingCart className="w-4 h-4" /> <ShoppingCart className="w-4 h-4" />
<span>Geteilte Listen</span> <span>Geteilte Listen</span>
</button> </button>
</div>
<button
onClick={() => setActiveTab('ecosystem')}
className={`h-11 px-5 rounded-full text-xs sm:text-sm font-medium flex items-center gap-2 flex-shrink-0 transition-all ${
activeTab === 'ecosystem'
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold shadow-sm'
: 'bg-[#131314] hover:bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border'
}`}
>
<Webhook className="w-4 h-4" />
<span>Webhooks & Export</span>
</button>
</nav> </nav>
</header>
{/* Active View Content */} {/* Main Container */}
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-6">
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
<section> <section>
{activeTab === 'triage' && ( {activeTab === 'triage' && (
<TriageDeck <TriageDeck
@@ -282,7 +500,7 @@ export const App: React.FC = () => {
lists={lists} lists={lists}
onDeckChange={refreshAll} onDeckChange={refreshAll}
onOpenDocument={handleOpenDocument} onOpenDocument={handleOpenDocument}
onFocusCapture={() => setCaptureFocusCount((c) => c + 1)} onFocusCapture={handleTriggerAddButton}
/> />
)} )}
@@ -290,6 +508,7 @@ export const App: React.FC = () => {
<TasksView <TasksView
onRefreshStats={refreshAll} onRefreshStats={refreshAll}
onOpenDocument={handleOpenDocument} onOpenDocument={handleOpenDocument}
refreshTrigger={refreshRevision}
/> />
)} )}
@@ -304,118 +523,28 @@ export const App: React.FC = () => {
<SharedListsView onRefreshGlobal={refreshAll} /> <SharedListsView onRefreshGlobal={refreshAll} />
)} )}
{activeTab === 'ecosystem' && ( {activeTab === 'user_settings' && (
<EcosystemView onIngested={refreshAll} /> <UserSettingsView
currentUser={user}
activeTheme={activeTheme}
onThemeChange={handleChangeTheme}
onUserUpdated={(u) => {
setUser(u);
refreshAll();
}}
onBack={() => setActiveTab('triage')}
/>
)}
{activeTab === 'admin_settings' && isStaff && (
<AdminSettingsView
currentUser={user}
onBack={() => setActiveTab('triage')}
/>
)} )}
</section> </section>
</ViewErrorBoundary>
</main> </main>
{/* Account Switcher / Registration Modal */}
{showAuthModal && (
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
<div className="w-full max-w-md p-6 sm:p-8 rounded-3xl bg-[#1e1f20] gemini-border shadow-card-depth">
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-[#8ab4f8]" />
<h3 className="text-lg font-bold text-[#e3e3e3]">
{authMode === 'login' ? 'Konto wechseln' : 'Neues Familien-Konto anlegen'}
</h3>
</div>
<button
onClick={() => setShowAuthModal(false)}
className="px-3 py-1 rounded-full bg-[#282a2c] text-xs text-[#9aa0a6] hover:text-[#e3e3e3]"
>
Schließen
</button>
</div>
{user && (
<div className="mb-5 p-3.5 rounded-2xl bg-[#131314] gemini-border flex items-center justify-between">
<div className="text-xs">
<span className="text-[#9aa0a6]">Aktuell aktiv: </span>
<strong className="text-[#e3e3e3]">{user.displayName}</strong>
<span className="text-[#5f6368] ml-1">({user.email})</span>
</div>
<button
type="button"
onClick={async () => {
clearToken();
await api.quickStart();
await refreshAll();
setShowAuthModal(false);
}}
className="text-xs text-[#8ab4f8] hover:underline flex items-center gap-1"
>
<LogOut className="w-3 h-3" /> Standard
</button>
</div>
)}
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={() => setAuthMode('login')}
className={`flex-1 h-9 rounded-full text-xs font-semibold ${
authMode === 'login'
? 'bg-[#8ab4f8] text-[#0b0f19]'
: 'bg-[#131314] text-[#9aa0a6]'
}`}
>
Anmelden
</button>
<button
type="button"
onClick={() => setAuthMode('register')}
className={`flex-1 h-9 rounded-full text-xs font-semibold ${
authMode === 'register'
? 'bg-[#8ab4f8] text-[#0b0f19]'
: 'bg-[#131314] text-[#9aa0a6]'
}`}
>
Registrieren
</button>
</div>
<form onSubmit={handleAuthSubmit} className="space-y-3">
{authMode === 'register' && (
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Anzeigename (z. B. Lisa)"
className="w-full h-11 px-4 rounded-full bg-[#131314] text-sm text-[#e3e3e3] gemini-border focus:outline-none"
required
/>
)}
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="E-Mail-Adresse"
className="w-full h-11 px-4 rounded-full bg-[#131314] text-sm text-[#e3e3e3] gemini-border focus:outline-none"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Passwort (mind. 4 Zeichen)"
className="w-full h-11 px-4 rounded-full bg-[#131314] text-sm text-[#e3e3e3] gemini-border focus:outline-none"
required
/>
{authError && <p className="text-xs text-[#f28b82] px-2">{authError}</p>}
<button
type="submit"
className="w-full h-11 rounded-full bg-[#8ab4f8] text-[#0b0f19] font-semibold text-sm hover:bg-[#a1c2fa] transition-colors"
>
{authMode === 'login' ? 'Einloggen' : 'Konto erstellen & starten'}
</button>
</form>
</div>
</div>
)}
</div> </div>
); );
}; };
@@ -0,0 +1,530 @@
import React, { useState } from 'react';
import {
Brain,
Shield,
Lock,
KeyRound,
UserPlus,
LogIn,
Server,
Share2,
Check,
AlertCircle,
} from 'lucide-react';
import { AuthStatus, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
interface SetupAndAuthViewProps {
authStatus: AuthStatus;
onAuthenticated: (user: User) => void;
}
export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
authStatus,
onAuthenticated,
}) => {
// --- Setup Wizard State ---
const [setupName, setSetupName] = useState('');
const [setupEmail, setSetupEmail] = useState('');
const [setupPassword, setSetupPassword] = useState('');
const [instanceName, setInstanceName] = useState(authStatus.instanceName || 'MindSift');
const [sharingMode, setSharingMode] = useState<'open' | 'teams_only' | 'strict_private'>(
'teams_only'
);
const [allowRegistration, setAllowRegistration] = useState(false);
const [createSampleData, setCreateSampleData] = useState(false);
// --- Login / Invite / Register State ---
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register'>('login');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [inviteCode, setInviteCode] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleCompleteSetup = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
triggerHaptic(15);
try {
const res = await api.completeSetup({
email: setupEmail.trim(),
password: setupPassword,
displayName: setupName.trim(),
instanceName: instanceName.trim() || 'MindSift',
sharingMode,
allowRegistration,
createSampleData,
});
onAuthenticated(res.user);
} catch (err: any) {
setError(err.message || 'Fehler bei der Ersteinrichtung');
} finally {
setSubmitting(false);
}
};
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
triggerHaptic(15);
try {
const res = await api.login(email.trim(), password);
onAuthenticated(res.user);
} catch (err: any) {
setError(err.message || 'Ungültige Anmeldedaten');
} finally {
setSubmitting(false);
}
};
const handleRegisterOrInvite = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setSubmitting(true);
triggerHaptic(15);
try {
const code = authTab === 'invite' ? inviteCode.trim() : '';
const res = await api.register(email.trim(), password, displayName.trim(), code);
onAuthenticated(res.user);
} catch (err: any) {
setError(err.message || 'Registrierung fehlgeschlagen');
} finally {
setSubmitting(false);
}
};
// ============================================================================
// FIRST-BOOT SERVER SETUP WIZARD
// ============================================================================
if (authStatus.needsSetup) {
return (
<div className="min-h-screen bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
<div className="w-full max-w-xl panel-surface shadow-panel overflow-hidden">
{/* Top Brand Bar */}
<div className="p-4 bg-canvas border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 bg-accent text-on-accent rounded-btn flex items-center justify-center">
<Brain className="w-4 h-4" />
</div>
<div>
<h1 className="text-base font-bold text-main tracking-tight leading-none">
MindSift Ersteinrichtung
</h1>
<p className="text-[11px] text-muted mt-0.5">
Initiales Administrator-Konto & Server-Konfiguration
</p>
</div>
</div>
<span className="badge-accent uppercase tracking-wider">First Boot</span>
</div>
<form onSubmit={handleCompleteSetup} className="p-5 space-y-5 text-xs">
<p className="text-muted leading-relaxed">
Willkommen auf deiner neuen MindSift-Instanz. Es existiert noch kein Benutzerkonto in
der Datenbank. Lege jetzt das erste <strong className="text-main">Server-Admin-Konto</strong> an und
bestimme die Sicherheitseinstellungen.
</p>
{error && (
<div className="alert-err flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
<span>{error}</span>
</div>
)}
{/* Section 1: Admin Account */}
<div className="p-3.5 bg-canvas border border-border space-y-3">
<h2 className="text-xs font-bold text-main uppercase tracking-wider flex items-center gap-1.5">
<Shield className="w-3.5 h-3.5 text-accent" />
<span>1. Server-Administrator anlegen</span>
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-muted mb-1">Dein Anzeigename *</label>
<input
type="text"
value={setupName}
onChange={(e) => setSetupName(e.target.value)}
placeholder="z.B. Admin / Dein Name"
required
autoFocus
className="w-full h-8 px-2.5 input-field"
/>
</div>
<div>
<label className="block text-muted mb-1">E-Mail-Adresse (Login) *</label>
<input
type="email"
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
placeholder="admin@deinedomain.de"
required
className="w-full h-8 px-2.5 input-field"
/>
</div>
</div>
<div>
<label className="block text-muted mb-1">Administrator-Passwort *</label>
<input
type="password"
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
minLength={4}
required
className="w-full h-8 px-2.5 input-field"
/>
</div>
</div>
{/* Section 2: Server & Isolation */}
<div className="p-3.5 bg-canvas border border-border space-y-3">
<h2 className="text-xs font-bold text-main uppercase tracking-wider flex items-center gap-1.5">
<Server className="w-3.5 h-3.5 text-accent" />
<span>2. Instanz & Berechtigungen</span>
</h2>
<div>
<label className="block text-muted mb-1">Name der Instanz</label>
<input
type="text"
value={instanceName}
onChange={(e) => setInstanceName(e.target.value)}
placeholder="MindSift"
className="w-full h-8 px-2.5 input-field"
/>
</div>
<div>
<label className="block text-muted mb-1.5 flex items-center gap-1">
<Share2 className="w-3 h-3 text-accent" />
<span>Sichtbarkeit & Sharing-Isolation zwischen Benutzern</span>
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<button
type="button"
onClick={() => setSharingMode('strict_private')}
className={`p-2.5 text-left border transition-colors ${
sharingMode === 'strict_private'
? 'bg-panel border-accent text-main'
: 'border-border text-muted hover:text-main'
}`}
>
<p className="font-bold text-xs">Strikt Privat</p>
<p className="text-[10px] text-muted mt-0.5">
Keiner sieht andere Nutzer. Komplett isoliert.
</p>
</button>
<button
type="button"
onClick={() => setSharingMode('teams_only')}
className={`p-2.5 text-left border transition-colors ${
sharingMode === 'teams_only'
? 'bg-panel border-accent text-main'
: 'border-border text-muted hover:text-main'
}`}
>
<p className="font-bold text-xs">Teams isoliert</p>
<p className="text-[10px] text-muted mt-0.5">
Nutzer sehen nur Mitglieder ihrer eigenen Teams.
</p>
</button>
<button
type="button"
onClick={() => setSharingMode('open')}
className={`p-2.5 text-left border transition-colors ${
sharingMode === 'open'
? 'bg-panel border-accent text-main'
: 'border-border text-muted hover:text-main'
}`}
>
<p className="font-bold text-xs">Alle sehen alle</p>
<p className="text-[10px] text-muted mt-0.5">
Offene Zusammenarbeit auf dem gesamten Server.
</p>
</button>
</div>
</div>
<div className="pt-2 border-t border-border space-y-2">
<label className="flex items-center justify-between cursor-pointer select-none">
<div>
<span className="font-medium text-main block">
Öffentliche Selbstregistrierung erlauben
</span>
<span className="text-[11px] text-muted">
Wenn deaktiviert, können neue Nutzer nur per Einladungscode oder durch einen
Admin angelegt werden.
</span>
</div>
<input
type="checkbox"
checked={allowRegistration}
onChange={(e) => setAllowRegistration(e.target.checked)}
className="w-4 h-4 accent-blue-600"
/>
</label>
<label className="flex items-center justify-between cursor-pointer select-none pt-1">
<div>
<span className="font-medium text-main block">
Start-Beispieldaten anlegen
</span>
<span className="text-[11px] text-muted">
Erstellt wenige Beispiel-Gedanken und ein Einführungsdokument zum Testen.
</span>
</div>
<input
type="checkbox"
checked={createSampleData}
onChange={(e) => setCreateSampleData(e.target.checked)}
className="w-4 h-4 accent-blue-600"
/>
</label>
</div>
</div>
<div className="flex items-center justify-end pt-1">
<button
type="submit"
disabled={submitting}
className="btn-primary h-9 px-5 text-xs font-semibold flex items-center gap-2 disabled:opacity-50"
>
<Check className="w-4 h-4" />
<span>
{submitting
? 'Richte Server ein…'
: 'Ersteinrichtung abschließen & Starten'}
</span>
</button>
</div>
</form>
</div>
</div>
);
}
// ============================================================================
// STANDARD LOGIN / INVITE CODE / REGISTRATION VIEW
// ============================================================================
return (
<div className="min-h-screen bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
<div className="w-full max-w-md panel-surface shadow-panel overflow-hidden">
{/* Top Header */}
<div className="p-4 bg-canvas border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
<Brain className="w-4 h-4" />
</div>
<div>
<h1 className="text-sm font-bold text-main tracking-tight leading-none">
{authStatus.instanceName || 'MindSift'}
</h1>
<p className="text-[11px] text-muted mt-0.5">Der Kopf frei in drei Sekunden</p>
</div>
</div>
<Lock className="w-4 h-4 text-faint" />
</div>
{/* Navigation Tabs */}
<div className="flex border-b border-border bg-canvas text-xs">
<button
type="button"
onClick={() => {
setAuthTab('login');
setError(null);
}}
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
authTab === 'login'
? 'bg-panel text-main border-b-2 border-b-accent'
: 'text-muted hover:text-main'
}`}
>
<LogIn className="w-3.5 h-3.5" />
<span>Anmelden</span>
</button>
<button
type="button"
onClick={() => {
setAuthTab('invite');
setError(null);
}}
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
authTab === 'invite'
? 'bg-panel text-main border-b-2 border-b-accent'
: 'text-muted hover:text-main'
}`}
>
<KeyRound className="w-3.5 h-3.5" />
<span>Einladungscode</span>
</button>
{authStatus.allowRegistration && (
<button
type="button"
onClick={() => {
setAuthTab('register');
setError(null);
}}
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 transition-colors ${
authTab === 'register'
? 'bg-panel text-main border-b-2 border-b-accent'
: 'text-muted hover:text-main'
}`}
>
<UserPlus className="w-3.5 h-3.5" />
<span>Registrieren</span>
</button>
)}
</div>
{/* Form Body */}
<div className="p-5 text-xs">
{error && (
<div className="mb-4 alert-err flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
<span>{error}</span>
</div>
)}
{authTab === 'login' && (
<form onSubmit={handleLogin} className="space-y-3.5">
<div>
<label className="block text-muted mb-1">E-Mail-Adresse</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@beispiel.de"
required
autoFocus
className="w-full h-8 px-2.5 input-field"
/>
</div>
<div>
<label className="block text-muted mb-1">Passwort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
className="w-full h-8 px-2.5 input-field"
/>
</div>
<div className="pt-1">
<button
type="submit"
disabled={submitting}
className="w-full btn-primary h-9 text-xs font-semibold flex items-center justify-center gap-2 disabled:opacity-50"
>
<LogIn className="w-3.5 h-3.5" />
<span>{submitting ? 'Melde an…' : 'Anmelden'}</span>
</button>
</div>
{!authStatus.allowRegistration && (
<p className="text-[11px] text-faint text-center pt-2 border-t border-border">
Öffentliche Registrierung ist deaktiviert. Zugang nur mit Konto oder{' '}
<button
type="button"
onClick={() => setAuthTab('invite')}
className="text-accent hover:underline"
>
Einladungscode
</button>
.
</p>
)}
</form>
)}
{(authTab === 'invite' || authTab === 'register') && (
<form onSubmit={handleRegisterOrInvite} className="space-y-3.5">
{authTab === 'invite' && (
<div>
<label className="block text-muted mb-1">Einladungscode *</label>
<input
type="text"
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value.toUpperCase())}
placeholder="MS-XXXXXXXX"
required
autoFocus
className="w-full h-8 px-2.5 input-field font-mono uppercase"
/>
</div>
)}
<div>
<label className="block text-muted mb-1">Anzeigename *</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Dein Name"
required
className="w-full h-8 px-2.5 input-field"
/>
</div>
<div>
<label className="block text-muted mb-1">E-Mail-Adresse *</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="name@beispiel.de"
required
className="w-full h-8 px-2.5 input-field"
/>
</div>
<div>
<label className="block text-muted mb-1">Passwort *</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
minLength={4}
required
className="w-full h-8 px-2.5 input-field"
/>
</div>
<div className="pt-1">
<button
type="submit"
disabled={submitting}
className="w-full btn-primary h-9 text-xs font-semibold flex items-center justify-center gap-2 disabled:opacity-50"
>
<UserPlus className="w-3.5 h-3.5" />
<span>
{submitting
? 'Konto wird erstellt…'
: authTab === 'invite'
? 'Einladung einlösen & Konto erstellen'
: 'Konto registrieren'}
</span>
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
};
@@ -1,40 +1,76 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Mic, MicOff, Send, Sparkles, Calendar, AlertTriangle, CheckCircle2 } from 'lucide-react'; import {
import { PriorityLevel, Thought } from '../../lib/types'; Mic,
MicOff,
ArrowUp,
Sparkles,
Calendar,
Check,
CheckSquare,
ShoppingCart,
FileText,
Layers,
ChevronDown,
} from 'lucide-react';
import { PriorityLevel, SharedList, Thought } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api'; import { api, triggerHaptic } from '../../lib/api';
export type CaptureTarget = 'inbox' | 'task' | 'list_item' | 'document';
interface InstantCaptureBarProps { interface InstantCaptureBarProps {
onCaptured: (thought: Thought) => void; onCaptured: (thought: Thought, target?: CaptureTarget) => void;
lists?: SharedList[];
autoFocusTrigger?: number; autoFocusTrigger?: number;
} }
export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
onCaptured, onCaptured,
lists = [],
autoFocusTrigger = 0, autoFocusTrigger = 0,
}) => { }) => {
const [content, setContent] = useState(''); const [content, setContent] = useState('');
const [priority, setPriority] = useState<PriorityLevel>('normal'); const [priority, setPriority] = useState<PriorityLevel>('normal');
const [dueDate, setDueDate] = useState(''); const [dueDate, setDueDate] = useState('');
const [target, setTarget] = useState<CaptureTarget>('inbox');
const [targetListId, setTargetListId] = useState<string>('');
const [showOptions, setShowOptions] = useState(false); const [showOptions, setShowOptions] = useState(false);
const [isListening, setIsListening] = useState(false); const [isListening, setIsListening] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [justSaved, setJustSaved] = useState(false); const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLFormElement>(null);
const recognitionRef = useRef<any>(null); const recognitionRef = useRef<any>(null);
useEffect(() => { useEffect(() => {
inputRef.current?.focus(); inputRef.current?.focus();
}, [autoFocusTrigger]); }, [autoFocusTrigger]);
useEffect(() => {
if (!targetListId && lists.length > 0) {
setTargetListId(lists[0].id);
}
}, [lists, targetListId]);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setShowOptions(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Global keyboard shortcut: 'c' or '/' focuses the Instant Capture input immediately // Global keyboard shortcut: 'c' or '/' focuses the Instant Capture input immediately
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement; const el = e.target as HTMLElement;
const isEditing = const isEditing =
target.tagName === 'INPUT' || el.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' || el.tagName === 'TEXTAREA' ||
target.isContentEditable; el.isContentEditable;
if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) { if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) {
e.preventDefault(); e.preventDefault();
@@ -102,129 +138,322 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
} }
}; };
const handleSubmit = async (e?: React.FormEvent) => { const executeCapture = async (
if (e) e.preventDefault(); overrideTarget?: CaptureTarget,
overrideListId?: string
) => {
const trimmed = content.trim(); const trimmed = content.trim();
if (!trimmed || isSubmitting) return; if (!trimmed || isSubmitting) return;
const effectiveTarget = overrideTarget || target;
const effectiveListId =
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
const capturedPriority = priority;
const capturedDueDate = dueDate;
const capturedSource = isListening ? 'voice' : 'manual';
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
setContent('');
setPriority('normal');
setDueDate('');
setTarget('inbox');
setShowOptions(false);
setIsSubmitting(true); setIsSubmitting(true);
triggerHaptic(15); triggerHaptic(15);
const savedMsg =
effectiveTarget === 'task'
? 'Als Aufgabe!'
: effectiveTarget === 'list_item'
? 'Auf Liste!'
: effectiveTarget === 'document'
? 'Als Notiz!'
: 'Im Dump!';
setJustSavedLabel(savedMsg);
setTimeout(() => setJustSavedLabel(null), 1600);
inputRef.current?.focus();
try { try {
const created = await api.captureThought( const created = await api.captureThought(
trimmed, trimmed,
priority, capturedPriority,
isListening ? 'voice' : 'manual', capturedSource,
dueDate capturedDueDate,
{
target: effectiveTarget,
targetListId: effectiveTarget === 'list_item' ? effectiveListId : '',
}
); );
setContent(''); onCaptured(created, effectiveTarget);
setPriority('normal'); } catch {
setDueDate(''); // Restore text if capture unexpectedly failed completely
setShowOptions(false); setContent(trimmed);
setJustSaved(true);
setTimeout(() => setJustSaved(false), 1800);
onCaptured(created);
inputRef.current?.focus();
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
const handleSubmit = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
await executeCapture();
};
const selectedList = lists.find((l) => l.id === targetListId) || lists[0];
const getTargetLabel = () => {
if (target === 'task') return 'Direkt-Aufgabe';
if (target === 'list_item') return selectedList ? `${selectedList.icon} ${selectedList.title}` : 'Auf Liste';
if (target === 'document') return 'Direkt-Notiz';
return 'Quick Dump';
};
return ( return (
<div className="w-full max-w-3xl mx-auto"> <div className="w-full">
<form onSubmit={handleSubmit} className="relative"> <form ref={containerRef} onSubmit={handleSubmit} className="relative">
{/* Quick-Capture Input Bar (Radius controlled by --radius-pill) */}
<div <div
className={`flex items-center h-14 px-4 sm:px-5 rounded-full bg-[#1e1f20] gemini-border gemini-input-glow transition-all duration-200 ${ className={`flex items-center h-9 pl-1.5 pr-1.5 bg-panel border capture-pill transition-colors duration-150 ${
isListening ? 'border-[#c58af9] shadow-purple-glow' : '' isListening
? 'border-accent bg-panel'
: 'border-border focus-within:border-accent'
}`} }`}
> >
<Sparkles <div className="w-6 h-6 capture-pill bg-accent text-on-accent flex items-center justify-center mr-2 flex-shrink-0">
className={`w-5 h-5 mr-3 flex-shrink-0 transition-colors ${ <Sparkles className="w-3 h-3" />
isListening </div>
? 'text-[#c58af9] animate-pulse'
: justSaved
? 'text-[#81c995]'
: 'text-[#8ab4f8]'
}`}
/>
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
executeCapture();
}
}}
placeholder={ placeholder={
isListening isListening
? 'Ich höre zu… sprich deinen Gedanken aus' ? 'Ich höre zu…'
: 'Gedanke, Idee oder Zutat sofort festhalten… (Taste C oder /)' : target === 'task'
? 'Neue Aufgabe direkt anlegen… (Enter)'
: 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)'
} }
className="w-full bg-transparent text-[#e3e3e3] placeholder-[#9aa0a6] text-sm sm:text-base focus:outline-none" className="w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none"
autoComplete="off" autoComplete="off"
/> />
<div className="flex items-center gap-1.5 ml-2 flex-shrink-0"> <div className="flex items-center gap-1 ml-2 flex-shrink-0">
{/* Options Toggle Pill (Due Date / Priority) */} {/* Target & Options Toggle Pill */}
<button <button
type="button" type="button"
onClick={() => setShowOptions((prev) => !prev)} onClick={() => setShowOptions((prev) => !prev)}
title="Priorität & Fälligkeitsdatum" title="Ziel wählen (Quick Dump, direkt als Aufgabe, auf Liste oder als Notiz) & Optionen"
className={`h-9 px-3 rounded-full text-xs font-medium flex items-center gap-1.5 transition-colors ${ className={`h-6 px-2.5 capture-pill text-[11px] flex items-center gap-1 transition-colors ${
showOptions || priority !== 'normal' || dueDate showOptions || target !== 'inbox' || priority !== 'normal' || dueDate
? 'bg-[#8ab4f8]/20 text-[#8ab4f8] border border-[#8ab4f8]/40' ? 'bg-accent-dim text-accent border border-accent-border font-medium'
: 'text-[#9aa0a6] hover:bg-[#282a2c] hover:text-[#e3e3e3]' : 'text-muted hover:text-main hover:bg-panel-elevated border border-transparent'
}`} }`}
> >
<Calendar className="w-3.5 h-3.5" /> {target === 'task' ? (
<span className="hidden sm:inline"> <CheckSquare className="w-3 h-3" />
{dueDate ? dueDate : priority !== 'normal' ? priority.toUpperCase() : 'Optionen'} ) : target === 'list_item' ? (
<ShoppingCart className="w-3 h-3" />
) : target === 'document' ? (
<FileText className="w-3 h-3" />
) : (
<Layers className="w-3 h-3" />
)}
<span className="hidden md:inline max-w-[110px] truncate">
{getTargetLabel()}
</span> </span>
{dueDate && <span className="hidden lg:inline text-[10px] opacity-80">• {dueDate.slice(5)}</span>}
<ChevronDown className="w-3 h-3 opacity-70" />
</button> </button>
{/* Voice Dictation Pill Button */} {/* Voice Dictation Button */}
<button <button
type="button" type="button"
onClick={toggleVoiceDictation} onClick={toggleVoiceDictation}
title="Spracheingabe (Diktieren)" title="Spracheingabe"
className={`w-9 h-9 rounded-full flex items-center justify-center transition-all ${ className={`w-6 h-6 capture-pill flex items-center justify-center transition-colors ${
isListening isListening
? 'bg-[#c58af9] text-[#0b0f19] animate-pulse' ? 'bg-accent-dim text-accent'
: 'bg-[#282a2c] text-[#9aa0a6] hover:text-[#e3e3e3] hover:bg-[#323538]' : 'text-muted hover:text-main hover:bg-panel-elevated'
}`} }`}
> >
{isListening ? <MicOff className="w-4 h-4" /> : <Mic className="w-4 h-4" />} {isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />}
</button> </button>
{/* Instant Send Pill Button */} {/* Instant Send Button */}
<button <button
type="submit" type="submit"
disabled={!content.trim() || isSubmitting} disabled={!content.trim() || isSubmitting}
className={`h-9 px-4 rounded-full font-medium text-xs sm:text-sm flex items-center gap-1.5 transition-all ${ className={`h-6 px-2.5 capture-pill font-medium text-[11px] flex items-center gap-1 transition-all ${
content.trim() content.trim()
? 'bg-[#8ab4f8] text-[#0b0f19] hover:bg-[#a1c2fa] shadow-sm cursor-pointer' ? 'bg-accent hover:bg-accent-hover text-on-accent cursor-pointer shadow-sm'
: 'bg-[#282a2c] text-[#5f6368] cursor-not-allowed' : 'bg-panel-elevated text-faint cursor-not-allowed'
}`} }`}
> >
{justSaved ? ( {justSavedLabel ? (
<> <>
<CheckCircle2 className="w-4 h-4 text-[#0b0f19]" /> <Check className="w-3 h-3" />
<span className="hidden sm:inline">Gespeichert</span> <span className="hidden sm:inline">{justSavedLabel}</span>
</> </>
) : ( ) : (
<> <>
<Send className="w-3.5 h-3.5" /> <ArrowUp className="w-3 h-3" />
<span className="hidden sm:inline">Einfrieren</span> <span className="hidden sm:inline">
{target === 'task'
? 'Als Aufgabe'
: target === 'list_item'
? 'Auf Liste'
: target === 'document'
? 'Als Notiz'
: 'Dump'}
</span>
</> </>
)} )}
</button> </button>
</div> </div>
</div> </div>
{/* Expandable Quick Metadata Pills */} {/* Expandable Target & Metadata Panel (Theme-controlled Dropdown) */}
{showOptions && ( {showOptions && (
<div className="mt-2.5 px-4 py-2.5 rounded-full bg-[#131314] gemini-border flex flex-wrap items-center justify-between gap-2 text-xs animate-fadeIn"> <div className="absolute top-full left-0 right-0 mt-1.5 z-50 p-3 panel-surface shadow-panel space-y-3 text-xs">
{/* Row 1: Where should this entry go? */}
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted">
Wohin damit? {content.trim() ? '(Klick speichert sofort oder wählt Ziel)' : '(Ziel für Eingabe wählen)'}
</span>
<span className="text-[11px] text-faint">
Standard: Quick Dump für späteres Swipen
</span>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
{/* Option 1: Quick Dump (Triage) */}
<button
type="button"
onClick={() => {
setTarget('inbox');
if (content.trim()) {
executeCapture('inbox');
}
}}
className={`h-8 px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors ${
target === 'inbox'
? 'bg-accent-dim border-accent text-main font-medium'
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<Layers className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<span className="truncate">Quick Dump (Triage)</span>
</button>
{/* Option 2: Direct Task */}
<button
type="button"
onClick={() => {
setTarget('task');
if (content.trim()) {
executeCapture('task');
}
}}
className={`h-8 px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors ${
target === 'task'
? 'bg-accent-dim border-accent text-main font-medium'
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<CheckSquare className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<span className="truncate">Direkt als Aufgabe</span>
</button>
{/* Option 3: Direct List Item */}
<button
type="button"
onClick={() => {
setTarget('list_item');
if (!targetListId && lists.length > 0) {
setTargetListId(lists[0].id);
}
}}
className={`h-8 px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors ${
target === 'list_item'
? 'bg-accent-dim border-accent text-main font-medium'
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<span className="truncate">Direkt auf Liste…</span>
</button>
{/* Option 4: Direct Workspace Note */}
<button
type="button"
onClick={() => {
setTarget('document');
if (content.trim()) {
executeCapture('document');
}
}}
className={`h-8 px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors ${
target === 'document'
? 'bg-accent-dim border-accent text-main font-medium'
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<FileText className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<span className="truncate">Direkt als Notiz</span>
</button>
</div>
</div>
{/* Optional Sub-Row: Shared List Picker */}
{lists.length > 0 && (
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
<span className="text-muted mr-1 flex items-center gap-1">
<ShoppingCart className="w-3 h-3 text-accent" />
Liste:
</span>
{lists.map((lst) => {
const isSelected = target === 'list_item' && targetListId === lst.id;
return (
<button
key={lst.id}
type="button"
onClick={() => {
setTarget('list_item');
setTargetListId(lst.id);
if (content.trim()) {
executeCapture('list_item', lst.id);
}
}}
className={`h-7 px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors ${
isSelected
? 'bg-accent text-on-accent border-accent font-medium'
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<span>{lst.icon}</span>
<span>{lst.title}</span>
</button>
);
})}
</div>
)}
{/* Row 2: Priority & Due Date */}
<div className="pt-2 border-t border-border flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 text-[#9aa0a6] mr-1" /> <span className="text-muted mr-1">Priorität:</span>
<span className="text-[#9aa0a6] mr-1">Priorität:</span>
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => { {(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
const labels: Record<PriorityLevel, string> = { const labels: Record<PriorityLevel, string> = {
low: 'Niedrig', low: 'Niedrig',
@@ -238,12 +467,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
key={p} key={p}
type="button" type="button"
onClick={() => setPriority(p)} onClick={() => setPriority(p)}
className={`px-3 py-1 rounded-full transition-colors ${ className={`px-2.5 py-0.5 border rounded-btn transition-colors text-xs ${
active active
? p === 'urgent' ? 'bg-accent text-on-accent border-accent font-medium'
? 'bg-[#f28b82] text-[#0b0f19] font-semibold' : 'bg-canvas text-muted hover:text-main border-border'
: 'bg-[#8ab4f8] text-[#0b0f19] font-semibold'
: 'bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3]'
}`} }`}
> >
{labels[p]} {labels[p]}
@@ -253,15 +480,19 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-[#9aa0a6]">Fällig am:</span> <span className="text-muted flex items-center gap-1">
<Calendar className="w-3 h-3 text-accent" />
Fällig am:
</span>
<input <input
type="date" type="date"
value={dueDate} value={dueDate}
onChange={(e) => setDueDate(e.target.value)} onChange={(e) => setDueDate(e.target.value)}
className="bg-[#1e1f20] text-[#e3e3e3] px-3 py-1 rounded-full gemini-border text-xs focus:outline-none focus:border-[#8ab4f8]" className="input-field px-2 py-0.5 text-xs"
/> />
</div> </div>
</div> </div>
</div>
)} )}
</form> </form>
</div> </div>
+86 -97
View File
@@ -148,54 +148,39 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const text = newItemText.trim(); const text = newItemText.trim();
const qty = newItemQty.trim(); const qty = newItemQty.trim();
const cat = newItemCategory; const cat = newItemCategory || undefined;
setNewItemText(''); setNewItemText('');
setNewItemQty(''); setNewItemQty('');
const created = await api.createListItem(activeList.id, text, cat, qty); try {
const item = await api.createListItem(activeList.id, text, qty, cat);
setLists((prev) => setLists((prev) =>
prev.map((l) => { prev.map((l) => (l.id === activeList.id ? { ...l, items: [item, ...l.items] } : l))
if (l.id !== activeList.id) return l;
if (l.items.some((i) => i.id === created.id)) return l;
return { ...l, items: [created, ...l.items] };
})
); );
onRefreshGlobal();
} catch (err: any) {
alert(err.message || 'Fehler beim Erstellen des Listeneintrags');
}
}; };
const handleToggleItem = async (item: ListItem) => { const handleToggleItem = async (item: ListItem) => {
if (!activeList) return; if (!activeList) return;
triggerHaptic(18); triggerHaptic(10);
const nextCompleted = !item.isCompleted; const nextCompleted = !item.isCompleted;
// Optimistic UI update (BRAIN.md Section 3)
setLists((prev) => setLists((prev) =>
prev.map((l) => { prev.map((l) => {
if (l.id !== activeList.id) return l; if (l.id !== activeList.id) return l;
return { return {
...l, ...l,
items: l.items.map((i) => items: l.items.map((i) => (i.id === item.id ? { ...i, isCompleted: nextCompleted } : i)),
i.id === item.id ? { ...i, isCompleted: nextCompleted } : i
),
}; };
}) })
); );
try { await api.toggleListItem(activeList.id, item.id, nextCompleted);
const updated = await api.toggleListItem(activeList.id, item.id, nextCompleted); onRefreshGlobal();
setLists((prev) =>
prev.map((l) => {
if (l.id !== activeList.id) return l;
return {
...l,
items: l.items.map((i) => (i.id === item.id ? updated : i)),
};
})
);
} catch {
// Rollback on error
loadListsForSpace(activeSpace!.id);
}
}; };
const handleDeleteItem = async (itemId: string) => { const handleDeleteItem = async (itemId: string) => {
@@ -264,18 +249,18 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const completedItems = activeList?.items.filter((i) => i.isCompleted) || []; const completedItems = activeList?.items.filter((i) => i.isCompleted) || [];
return ( return (
<div className="max-w-4xl mx-auto space-y-6"> <div className="w-full space-y-3">
{/* Top Space Bar & Realtime Indicator */} {/* Top Space Bar & Realtime Indicator */}
<div className="p-4 sm:p-5 rounded-3xl bg-[#1e1f20] gemini-border flex flex-wrap items-center justify-between gap-3"> <div className="px-3 py-2 panel-surface flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-1.5">
{spaces.map((sp) => ( {spaces.map((sp) => (
<button <button
key={sp.id} key={sp.id}
onClick={() => setActiveSpace(sp)} onClick={() => setActiveSpace(sp)}
className={`h-10 px-4 rounded-full text-xs sm:text-sm font-medium flex items-center gap-2 transition-all ${ className={`h-8 px-3 text-xs sm:text-sm font-medium flex items-center gap-1.5 transition-colors border rounded-btn ${
activeSpace?.id === sp.id activeSpace?.id === sp.id
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold shadow-sm' ? 'bg-accent text-on-accent border-accent'
: 'bg-[#131314] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border' : 'bg-canvas text-muted hover:text-main border-border'
}`} }`}
> >
<span>{sp.icon}</span> <span>{sp.icon}</span>
@@ -285,16 +270,16 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<button <button
onClick={() => setShowNewSpaceModal((prev) => !prev)} onClick={() => setShowNewSpaceModal((prev) => !prev)}
className="h-10 px-3.5 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#8ab4f8] flex items-center gap-1" className="btn-action h-8"
> >
<Plus className="w-3.5 h-3.5" /> Space <Plus className="w-3.5 h-3.5 text-accent" /> Space
</button> </button>
<button <button
onClick={() => setShowJoinModal((prev) => !prev)} onClick={() => setShowJoinModal((prev) => !prev)}
className="h-10 px-3.5 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#c58af9] flex items-center gap-1" className="btn-action h-8"
> >
<UserPlus className="w-3.5 h-3.5" /> Code einlösen <UserPlus className="w-3.5 h-3.5 text-accent" /> Code einlösen
</button> </button>
</div> </div>
@@ -302,10 +287,10 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{activeSpace && ( {activeSpace && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className={`h-8 px-3 rounded-full text-xs flex items-center gap-1.5 ${ className={`h-8 px-3 text-xs flex items-center gap-1.5 rounded-btn ${
wsConnected wsConnected
? 'bg-[#81c995]/15 text-[#81c995] border border-[#81c995]/30' ? 'badge-ok'
: 'bg-[#282a2c] text-[#9aa0a6]' : 'badge-neutral'
}`} }`}
> >
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} /> <Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
@@ -315,14 +300,14 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<button <button
onClick={copyInviteCode} onClick={copyInviteCode}
title="Einladungscode für Partner oder Familie kopieren" title="Einladungscode für Partner oder Familie kopieren"
className="h-8 px-3 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#e3e3e3] flex items-center gap-1.5" className="btn-action h-8"
> >
<Users className="w-3.5 h-3.5 text-[#8ab4f8]" /> <Users className="w-3.5 h-3.5 text-accent" />
<span>Code: {activeSpace.inviteCode}</span> <span>Code: {activeSpace.inviteCode}</span>
{copiedInvite ? ( {copiedInvite ? (
<Check className="w-3.5 h-3.5 text-[#81c995]" /> <Check className="w-3.5 h-3.5 text-status-ok" />
) : ( ) : (
<Copy className="w-3 h-3 text-[#9aa0a6]" /> <Copy className="w-3 h-3 text-muted" />
)} )}
</button> </button>
</div> </div>
@@ -333,19 +318,19 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{showNewSpaceModal && ( {showNewSpaceModal && (
<form <form
onSubmit={handleCreateSpace} onSubmit={handleCreateSpace}
className="p-4 rounded-3xl bg-[#131314] gemini-border flex items-center gap-3" className="p-2 panel-surface flex items-center gap-2"
> >
<input <input
type="text" type="text"
value={newSpaceName} value={newSpaceName}
onChange={(e) => setNewSpaceName(e.target.value)} onChange={(e) => setNewSpaceName(e.target.value)}
placeholder="Name des neuen Familien-/Team-Spaces (z. B. Sommerurlaub 2027)…" placeholder="Name des neuen Familien-/Team-Spaces (z. B. Urlaub 2026)…"
className="flex-1 h-10 px-4 rounded-full bg-[#1e1f20] text-sm text-[#e3e3e3] gemini-border focus:outline-none" className="flex-1 h-9 px-3 input-field text-sm"
autoFocus autoFocus
/> />
<button <button
type="submit" type="submit"
className="h-10 px-5 rounded-full bg-[#8ab4f8] text-[#0b0f19] font-semibold text-xs sm:text-sm" className="btn-primary h-9 px-4 text-xs sm:text-sm"
> >
Space anlegen Space anlegen
</button> </button>
@@ -355,19 +340,19 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{showJoinModal && ( {showJoinModal && (
<form <form
onSubmit={handleJoinSpace} onSubmit={handleJoinSpace}
className="p-4 rounded-3xl bg-[#131314] gemini-border flex items-center gap-3" className="p-2 panel-surface flex items-center gap-2"
> >
<input <input
type="text" type="text"
value={joinCode} value={joinCode}
onChange={(e) => setJoinCode(e.target.value)} onChange={(e) => setJoinCode(e.target.value)}
placeholder="Invite-Code des Partners eingeben…" placeholder="Invite-Code des Partners eingeben…"
className="flex-1 h-10 px-4 rounded-full bg-[#1e1f20] text-sm text-[#e3e3e3] gemini-border focus:outline-none uppercase" className="flex-1 h-9 px-3 input-field text-sm uppercase"
autoFocus autoFocus
/> />
<button <button
type="submit" type="submit"
className="h-10 px-5 rounded-full bg-[#c58af9] text-[#0b0f19] font-semibold text-xs sm:text-sm" className="btn-primary h-9 px-4 text-xs sm:text-sm"
> >
Beitreten Beitreten
</button> </button>
@@ -375,7 +360,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
)} )}
{/* List Tabs within Active Space */} {/* List Tabs within Active Space */}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-1.5">
{lists.map((lst) => { {lists.map((lst) => {
const openCount = lst.items.filter((i) => !i.isCompleted).length; const openCount = lst.items.filter((i) => !i.isCompleted).length;
const isSelected = activeList?.id === lst.id; const isSelected = activeList?.id === lst.id;
@@ -383,15 +368,17 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<button <button
key={lst.id} key={lst.id}
onClick={() => setActiveListId(lst.id)} onClick={() => setActiveListId(lst.id)}
className={`h-11 px-5 rounded-full text-sm font-medium flex items-center gap-2 transition-all ${ className={`h-8 px-3 text-xs sm:text-sm font-medium flex items-center gap-1.5 transition-all border rounded-btn ${
isSelected isSelected
? 'bg-[#1e1f20] text-[#8ab4f8] border border-[#8ab4f8]/50 shadow-gemini-glow' ? 'bg-accent text-on-accent border-accent'
: 'bg-[#131314] text-[#9aa0a6] hover:text-[#e3e3e3] gemini-border' : 'bg-panel text-muted hover:text-main border-border'
}`} }`}
> >
<span>{lst.icon}</span> <span>{lst.icon}</span>
<span>{lst.title}</span> <span>{lst.title}</span>
<span className="px-2 py-0.5 rounded-full bg-[#0b0f19] text-xs text-[#8ab4f8]"> <span className={`px-1.5 py-0.2 text-[10px] font-semibold rounded-pill ${
isSelected ? 'bg-white/20 text-on-accent' : 'badge-neutral'
}`}>
{openCount} {openCount}
</span> </span>
</button> </button>
@@ -400,9 +387,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<button <button
onClick={() => setShowNewListModal((prev) => !prev)} onClick={() => setShowNewListModal((prev) => !prev)}
className="h-11 px-4 rounded-full bg-[#131314] hover:bg-[#1e1f20] gemini-border text-xs sm:text-sm text-[#e3e3e3] flex items-center gap-1.5" className="btn-action h-8"
> >
<Plus className="w-4 h-4 text-[#8ab4f8]" /> <Plus className="w-3.5 h-3.5 text-accent" />
<span>Neue Liste</span> <span>Neue Liste</span>
</button> </button>
</div> </div>
@@ -410,7 +397,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{showNewListModal && ( {showNewListModal && (
<form <form
onSubmit={handleCreateList} onSubmit={handleCreateList}
className="p-4 rounded-3xl bg-[#131314] gemini-border flex flex-wrap items-center gap-3" className="p-2 panel-surface flex flex-wrap items-center gap-2"
> >
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{['🛒', '🏔️', '🍕', '🎉', '🧰', '🎁'].map((ic) => ( {['🛒', '🏔️', '🍕', '🎉', '🧰', '🎁'].map((ic) => (
@@ -418,8 +405,10 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
key={ic} key={ic}
type="button" type="button"
onClick={() => setNewListIcon(ic)} onClick={() => setNewListIcon(ic)}
className={`w-9 h-9 rounded-full flex items-center justify-center ${ className={`w-9 h-9 flex items-center justify-center rounded-btn transition-colors ${
newListIcon === ic ? 'bg-[#8ab4f8]/25 border border-[#8ab4f8]' : 'bg-[#1e1f20]' newListIcon === ic
? 'bg-accent/20 border border-accent text-accent'
: 'bg-canvas hover:bg-panel-elevated border border-border'
}`} }`}
> >
{ic} {ic}
@@ -431,12 +420,12 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
value={newListTitle} value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)} onChange={(e) => setNewListTitle(e.target.value)}
placeholder="Titel der Liste (z. B. Baumarkt, Drogerie, Camping)…" placeholder="Titel der Liste (z. B. Baumarkt, Drogerie, Camping)…"
className="flex-1 h-10 px-4 rounded-full bg-[#1e1f20] text-sm text-[#e3e3e3] gemini-border focus:outline-none" className="flex-1 h-9 px-3 input-field text-sm"
autoFocus autoFocus
/> />
<button <button
type="submit" type="submit"
className="h-10 px-5 rounded-full bg-[#8ab4f8] text-[#0b0f19] font-semibold text-xs sm:text-sm" className="btn-primary h-9 px-4 text-xs sm:text-sm"
> >
Liste erstellen Liste erstellen
</button> </button>
@@ -445,16 +434,16 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{/* Active Shared List Card */} {/* Active Shared List Card */}
{activeList ? ( {activeList ? (
<div className="p-6 sm:p-8 rounded-3xl bg-[#1e1f20] gemini-border shadow-card-depth"> <div className="p-3 panel-surface">
{/* Quick-Add Pill Form */} {/* Quick-Add Pill Form */}
<form onSubmit={handleAddItem} className="space-y-3 mb-6"> <form onSubmit={handleAddItem} className="space-y-2 mb-3">
<div className="flex flex-col sm:flex-row gap-2.5"> <div className="flex flex-col sm:flex-row gap-2">
<input <input
type="text" type="text"
value={newItemText} value={newItemText}
onChange={(e) => setNewItemText(e.target.value)} onChange={(e) => setNewItemText(e.target.value)}
placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch)…`} placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch)…`}
className="flex-1 h-12 px-5 rounded-full bg-[#131314] text-sm sm:text-base text-[#e3e3e3] placeholder-[#9aa0a6] gemini-border focus:outline-none focus:border-[#8ab4f8]" className="flex-1 h-9 px-3 input-field text-sm"
/> />
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
@@ -462,12 +451,12 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
value={newItemQty} value={newItemQty}
onChange={(e) => setNewItemQty(e.target.value)} onChange={(e) => setNewItemQty(e.target.value)}
placeholder="Menge (z.B. 2x)" placeholder="Menge (z.B. 2x)"
className="w-32 h-12 px-4 rounded-full bg-[#131314] text-sm text-[#e3e3e3] placeholder-[#9aa0a6] gemini-border focus:outline-none focus:border-[#8ab4f8]" className="w-32 h-9 px-3 input-field text-sm"
/> />
<button <button
type="submit" type="submit"
disabled={!newItemText.trim()} disabled={!newItemText.trim()}
className="h-12 px-6 rounded-full bg-[#8ab4f8] disabled:bg-[#282a2c] text-[#0b0f19] disabled:text-[#5f6368] font-semibold text-sm flex items-center gap-1.5 transition-colors" className="btn-primary h-9 px-4 disabled:opacity-40 disabled:cursor-not-allowed text-xs sm:text-sm flex items-center gap-1.5"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
<span>Hinzufügen</span> <span>Hinzufügen</span>
@@ -477,16 +466,16 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{/* Category Quick-Select Pills */} {/* Category Quick-Select Pills */}
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-[#9aa0a6] mr-1">Kategorie:</span> <span className="text-xs text-muted mr-1">Kategorie:</span>
{CATEGORIES.map((cat) => ( {CATEGORIES.map((cat) => (
<button <button
key={cat} key={cat}
type="button" type="button"
onClick={() => setNewItemCategory((prev) => (prev === cat ? '' : cat))} onClick={() => setNewItemCategory((prev) => (prev === cat ? '' : cat))}
className={`px-3 py-1 rounded-full text-xs transition-colors ${ className={`px-2.5 py-0.5 text-xs transition-colors border rounded-pill ${
newItemCategory === cat newItemCategory === cat
? 'bg-[#8ab4f8]/20 text-[#8ab4f8] border border-[#8ab4f8]/40' ? 'bg-accent text-on-accent border-accent font-medium'
: 'bg-[#131314] text-[#9aa0a6] hover:text-[#e3e3e3]' : 'bg-canvas text-muted hover:text-main border-border'
}`} }`}
> >
{cat} {cat}
@@ -496,31 +485,31 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</form> </form>
{/* Open Items */} {/* Open Items */}
<div className="space-y-2"> <div className="border border-border divide-y divide-border rounded-card overflow-hidden">
{openItems.length === 0 ? ( {openItems.length === 0 ? (
<div className="py-8 text-center text-[#9aa0a6] text-sm flex flex-col items-center gap-2"> <div className="py-6 text-center text-muted text-sm flex flex-col items-center gap-1.5">
<Sparkles className="w-6 h-6 text-[#81c995]" /> <Sparkles className="w-4 h-4 text-accent" />
<span>Alles erledigt! Keine offenen Punkte auf dieser Liste.</span> <span>Alles erledigt. Keine offenen Punkte auf dieser Liste.</span>
</div> </div>
) : ( ) : (
openItems.map((item) => ( openItems.map((item) => (
<div <div
key={item.id} key={item.id}
onClick={() => handleToggleItem(item)} onClick={() => handleToggleItem(item)}
className="p-4 rounded-2xl bg-[#131314] hover:bg-[#242628] gemini-border flex items-center justify-between gap-3 cursor-pointer transition-all active:scale-[0.99]" className="px-3 py-2 bg-canvas hover:bg-panel-elevated flex items-center justify-between gap-3 cursor-pointer transition-colors group"
> >
<div className="flex items-center gap-3.5 min-w-0"> <div className="flex items-center gap-2.5 min-w-0">
<Circle className="w-5 h-5 text-[#8ab4f8] flex-shrink-0" /> <Circle className="w-4 h-4 text-faint group-hover:text-accent flex-shrink-0 transition-colors" />
<span className="text-base font-medium text-[#e3e3e3] truncate"> <span className="text-sm font-normal text-main truncate">
{item.content} {item.content}
</span> </span>
{item.quantity && ( {item.quantity && (
<span className="px-2.5 py-0.5 rounded-full bg-[#1e1f20] text-[#8ab4f8] text-xs font-medium"> <span className="badge-accent">
{item.quantity} {item.quantity}
</span> </span>
)} )}
{item.category && ( {item.category && (
<span className="hidden sm:inline px-2.5 py-0.5 rounded-full bg-[#1e1f20] text-[#9aa0a6] text-xs"> <span className="hidden sm:inline px-2 py-0.2 badge-neutral text-xs">
{item.category} {item.category}
</span> </span>
)} )}
@@ -532,9 +521,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
e.stopPropagation(); e.stopPropagation();
handleDeleteItem(item.id); handleDeleteItem(item.id);
}} }}
className="w-8 h-8 rounded-full hover:bg-[#f28b82]/20 text-[#5f6368] hover:text-[#f28b82] flex items-center justify-center" className="btn-icon btn-icon-danger w-6 h-6"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3 h-3" />
</button> </button>
</div> </div>
)) ))
@@ -543,37 +532,37 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
{/* Completed Items Section */} {/* Completed Items Section */}
{completedItems.length > 0 && ( {completedItems.length > 0 && (
<div className="mt-8 pt-6 border-t border-white/[0.06]"> <div className="mt-3 pt-2.5 border-t border-border">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold uppercase tracking-wider text-[#9aa0a6]"> <span className="text-xs font-medium uppercase tracking-wider text-muted">
Bereits im Einkaufswagen / Erledigt ({completedItems.length}) Erledigt ({completedItems.length})
</span> </span>
<button <button
onClick={handleClearCompleted} onClick={handleClearCompleted}
className="px-3.5 py-1 rounded-full bg-[#131314] hover:bg-[#f28b82]/20 text-xs text-[#9aa0a6] hover:text-[#f28b82] transition-colors" className="btn-action text-xs"
> >
Erledigte aufräumen Erledigte aufräumen
</button> </button>
</div> </div>
<div className="space-y-2 opacity-70"> <div className="border border-border divide-y divide-border opacity-75 rounded-card overflow-hidden">
{completedItems.map((item) => ( {completedItems.map((item) => (
<div <div
key={item.id} key={item.id}
onClick={() => handleToggleItem(item)} onClick={() => handleToggleItem(item)}
className="p-3.5 rounded-2xl bg-[#131314]/60 gemini-border flex items-center justify-between gap-3 cursor-pointer" className="px-3 py-1.5 bg-canvas/50 hover:bg-canvas/80 flex items-center justify-between gap-3 cursor-pointer transition-colors"
> >
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-2.5 min-w-0">
<CheckCircle2 className="w-5 h-5 text-[#81c995] flex-shrink-0" /> <CheckCircle2 className="w-4 h-4 text-accent flex-shrink-0" />
<span className="text-sm text-[#9aa0a6] line-through truncate"> <span className="text-sm text-muted line-through truncate">
{item.content} {item.content}
</span> </span>
{item.quantity && ( {item.quantity && (
<span className="text-xs text-[#5f6368]">({item.quantity})</span> <span className="text-xs text-faint">({item.quantity})</span>
)} )}
</div> </div>
{item.completedByName && ( {item.completedByName && (
<span className="text-xs text-[#5f6368]">✓ {item.completedByName}</span> <span className="text-xs text-faint">✓ {item.completedByName}</span>
)} )}
</div> </div>
))} ))}
File diff suppressed because it is too large. Load diff
@@ -92,34 +92,34 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
const baseUrl = window.location.origin; const baseUrl = window.location.origin;
return ( return (
<div className="max-w-4xl mx-auto space-y-6"> <div className="w-full panel-surface divide-y divide-border">
{/* Section 1: API Keys for Smarthome & Automation */} {/* Section 1: API Keys for Smarthome & Automation */}
<div className="p-6 sm:p-8 rounded-3xl bg-[#1e1f20] gemini-border shadow-card-depth"> <div className="p-4">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-[#8ab4f8]/15 flex items-center justify-center"> <div className="w-8 h-8 bg-accent text-white flex items-center justify-center flex-shrink-0">
<Key className="w-5 h-5 text-[#8ab4f8]" /> <Key className="w-4 h-4" />
</div> </div>
<div> <div>
<h3 className="text-lg font-bold text-[#e3e3e3]"> <h3 className="text-base font-bold text-white">
Webhooks & Ingestion API-Schlüssel Webhooks & Ingestion API-Schlüssel
</h3> </h3>
<p className="text-xs sm:text-sm text-[#9aa0a6]"> <p className="text-xs sm:text-sm text-zinc-400">
Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs direkt mit deiner MindSift Inbox. Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs direkt mit deiner MindSift Inbox.
</p> </p>
</div> </div>
</div> </div>
<form onSubmit={handleCreateKey} className="mt-5 flex flex-col sm:flex-row gap-2.5"> <form onSubmit={handleCreateKey} className="mt-3 flex flex-col sm:flex-row gap-2">
<input <input
type="text" type="text"
value={newKeyName} value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)} onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…" placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
className="flex-1 h-11 px-4 rounded-full bg-[#131314] text-sm text-[#e3e3e3] placeholder-[#9aa0a6] gemini-border focus:outline-none focus:border-[#8ab4f8]" className="flex-1 h-9 px-3 input-field text-sm"
/> />
<button <button
type="submit" type="submit"
className="h-11 px-6 rounded-full bg-[#8ab4f8] text-[#0b0f19] font-semibold text-sm flex items-center justify-center gap-1.5 hover:bg-[#a1c2fa] transition-colors" className="btn-primary h-9 px-4 text-xs sm:text-sm"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
<span>API-Key erzeugen</span> <span>API-Key erzeugen</span>
@@ -127,18 +127,18 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
</form> </form>
{newlyCreatedKey && ( {newlyCreatedKey && (
<div className="mt-4 p-4 rounded-2xl bg-[#81c995]/15 border border-[#81c995]/40 flex flex-wrap items-center justify-between gap-3"> <div className="mt-3 p-3 bg-accent/15 border border-accent/30 flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs font-semibold text-[#81c995] mb-1"> <p className="text-xs font-semibold text-accent mb-1">
Neuer API-Schlüssel erstellt (bitte jetzt kopieren): Neuer API-Schlüssel erstellt (bitte jetzt kopieren):
</p> </p>
<code className="text-xs sm:text-sm font-mono text-[#e3e3e3] break-all"> <code className="text-xs sm:text-sm font-mono text-white break-all">
{newlyCreatedKey} {newlyCreatedKey}
</code> </code>
</div> </div>
<button <button
onClick={() => handleCopy(newlyCreatedKey)} onClick={() => handleCopy(newlyCreatedKey)}
className="h-9 px-4 rounded-full bg-[#81c995] text-[#0b0f19] font-semibold text-xs flex items-center gap-1.5" className="btn-primary h-8 px-3 text-xs"
> >
{copiedKey ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />} {copiedKey ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedKey ? 'Kopiert!' : 'Key kopieren'}</span> <span>{copiedKey ? 'Kopiert!' : 'Key kopieren'}</span>
@@ -147,29 +147,29 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
)} )}
{/* Active Keys List */} {/* Active Keys List */}
<div className="mt-5 space-y-2"> <div className="mt-3 border border-border divide-y divide-border">
{keys.length === 0 ? ( {keys.length === 0 ? (
<p className="text-xs text-[#5f6368] py-2"> <p className="text-xs text-zinc-500 px-3 py-2">
Noch keine dedizierten API-Schlüssel hinterlegt. Noch keine dedizierten API-Schlüssel hinterlegt.
</p> </p>
) : ( ) : (
keys.map((k) => ( keys.map((k) => (
<div <div
key={k.id} key={k.id}
className="p-3.5 rounded-2xl bg-[#131314] gemini-border flex items-center justify-between gap-3" className="px-3 py-1.5 bg-canvas flex items-center justify-between gap-3"
> >
<div> <div>
<span className="text-sm font-medium text-[#e3e3e3]">{k.name}</span> <span className="text-sm font-medium text-white">{k.name}</span>
<span className="ml-3 text-xs font-mono text-[#8ab4f8]">{k.keyPrefix}</span> <span className="ml-3 text-xs font-mono text-accent">{k.keyPrefix}</span>
{k.lastUsedAt && ( {k.lastUsedAt && (
<span className="ml-3 text-xs text-[#5f6368]"> <span className="ml-3 text-xs text-zinc-500">
Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')} Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')}
</span> </span>
)} )}
</div> </div>
<button <button
onClick={() => handleDeleteKey(k.id)} onClick={() => handleDeleteKey(k.id)}
className="w-8 h-8 rounded-full hover:bg-[#f28b82]/20 text-[#9aa0a6] hover:text-[#f28b82] flex items-center justify-center" className="btn-icon btn-icon-danger w-7 h-7"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</button> </button>
@@ -180,22 +180,22 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
</div> </div>
{/* Section 2: Interactive Smarthome Webhook Simulator */} {/* Section 2: Interactive Smarthome Webhook Simulator */}
<div className="p-6 sm:p-8 rounded-3xl bg-[#1e1f20] gemini-border shadow-card-depth"> <div className="p-4">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-full bg-[#c58af9]/15 flex items-center justify-center"> <div className="w-7 h-7 bg-accent text-white flex items-center justify-center flex-shrink-0">
<Webhook className="w-5 h-5 text-[#c58af9]" /> <Webhook className="w-4 h-4" />
</div> </div>
<div> <div>
<h3 className="text-lg font-bold text-[#e3e3e3]"> <h3 className="text-base font-bold text-white">
Live Smarthome Webhook-Simulator Live Smarthome Webhook-Simulator
</h3> </h3>
<p className="text-xs sm:text-sm text-[#9aa0a6]"> <p className="text-xs sm:text-sm text-zinc-400">
Teste den Webhook-Endpunkt direkt hier – das Event landet sofort als Karte im Triage-Deck. Teste den Webhook-Endpunkt direkt hier – das Event landet sofort als Karte im Triage-Deck.
</p> </p>
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2 mb-4"> <div className="flex flex-wrap gap-1.5 mb-3">
{[ {[
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel }, { label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel }, { label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
@@ -208,24 +208,24 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
setSimTitle(preset.text); setSimTitle(preset.text);
setSimPriority(preset.prio); setSimPriority(preset.prio);
}} }}
className="px-3.5 py-1.5 rounded-full bg-[#131314] hover:bg-[#282a2c] gemini-border text-xs text-[#e3e3e3]" className="btn-action text-xs"
> >
{preset.label} {preset.label}
</button> </button>
))} ))}
</div> </div>
<div className="flex flex-col sm:flex-row gap-2.5"> <div className="flex flex-col sm:flex-row gap-2">
<input <input
type="text" type="text"
value={simTitle} value={simTitle}
onChange={(e) => setSimTitle(e.target.value)} onChange={(e) => setSimTitle(e.target.value)}
className="flex-1 h-11 px-4 rounded-full bg-[#131314] text-sm text-[#e3e3e3] gemini-border focus:outline-none" className="flex-1 h-9 px-3 input-field text-sm"
/> />
<button <button
type="button" type="button"
onClick={handleSimulateWebhook} onClick={handleSimulateWebhook}
className="h-11 px-6 rounded-full bg-[#c58af9] text-[#0b0f19] font-semibold text-sm flex items-center justify-center gap-1.5 hover:opacity-90 transition-opacity" className="btn-primary h-9 px-4 text-xs sm:text-sm flex items-center gap-1.5"
> >
<Zap className="w-4 h-4" /> <Zap className="w-4 h-4" />
<span>Webhook jetzt abfeuern</span> <span>Webhook jetzt abfeuern</span>
@@ -233,16 +233,16 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
</div> </div>
{simStatus && ( {simStatus && (
<p className="mt-3 text-xs font-medium text-[#81c995]">{simStatus}</p> <p className="mt-3 text-xs font-medium text-accent">{simStatus}</p>
)} )}
{/* Code Example Snippets */} {/* Code Example Snippets */}
<div className="mt-6 space-y-3"> <div className="mt-4 space-y-2">
<div className="flex items-center gap-2 text-xs font-semibold text-[#9aa0a6]"> <div className="flex items-center gap-2 text-xs font-medium text-zinc-400">
<Terminal className="w-3.5 h-3.5 text-[#8ab4f8]" /> <Terminal className="w-3.5 h-3.5 text-accent" />
<span>cURL / Home Assistant REST Command:</span> <span>cURL / Home Assistant REST Command:</span>
</div> </div>
<pre className="p-4 rounded-2xl bg-[#0b0f19] gemini-border font-mono text-xs text-[#8ab4f8] overflow-x-auto"> <pre className="p-3 bg-canvas border border-border font-mono text-xs text-zinc-300 overflow-x-auto">
{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest?token=${exampleToken}" \\ {`curl -X POST "${baseUrl}/api/v1/webhooks/ingest?token=${exampleToken}" \\
-H "Content-Type: application/json" \\ -H "Content-Type: application/json" \\
-d '{ -d '{
@@ -255,16 +255,16 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
</div> </div>
{/* Section 3: Data Sovereignty & Export */} {/* Section 3: Data Sovereignty & Export */}
<div className="p-6 sm:p-8 rounded-3xl bg-[#1e1f20] gemini-border flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4"> <div className="p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-[#81c995]/15 flex items-center justify-center flex-shrink-0"> <div className="w-7 h-7 bg-accent text-white flex items-center justify-center flex-shrink-0">
<ShieldCheck className="w-5 h-5 text-[#81c995]" /> <ShieldCheck className="w-4 h-4" />
</div> </div>
<div> <div>
<h3 className="text-base font-bold text-[#e3e3e3]"> <h3 className="text-base font-bold text-white">
100 % Datensouveränität & Komplett-Export 100 % Datensouveränität & Komplett-Export
</h3> </h3>
<p className="text-xs sm:text-sm text-[#9aa0a6]"> <p className="text-xs sm:text-sm text-zinc-400">
Exportiere jederzeit alle Gedanken, Aufgaben, Dokumente und geteilten Listen als portables JSON-Archiv. Exportiere jederzeit alle Gedanken, Aufgaben, Dokumente und geteilten Listen als portables JSON-Archiv.
</p> </p>
</div> </div>
@@ -272,7 +272,7 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
<button <button
onClick={handleFullExport} onClick={handleFullExport}
className="h-11 px-6 rounded-full bg-[#81c995] text-[#0b0f19] font-semibold text-sm flex items-center gap-2 hover:opacity-90 flex-shrink-0" className="btn-primary h-9 px-4 text-xs sm:text-sm flex items-center gap-2"
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
<span>Komplett-Backup (.json)</span> <span>Komplett-Backup (.json)</span>
@@ -0,0 +1,850 @@
import React, { useEffect, useState } from 'react';
import {
User as UserIcon,
Bell,
Key,
Plus,
Trash2,
Terminal,
Download,
ArrowLeft,
Check,
Mail,
MessageSquare,
Globe,
Send,
AlertCircle,
Palette,
} from 'lucide-react';
import { APIKey, User } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme';
interface UserSettingsViewProps {
currentUser: User;
activeTheme: ThemeId;
onThemeChange: (themeId: ThemeId) => void;
onUserUpdated: (u: User) => void;
onBack: () => void;
}
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks';
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
currentUser,
activeTheme,
onThemeChange,
onUserUpdated,
onBack,
}) => {
const [section, setSection] = useState<UserSettingsSection>('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);
const [savingProfile, setSavingProfile] = useState(false);
// --- Multi-Channel Alarms State ---
const [alarmChannels, setAlarmChannels] = useState<string[]>(
Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []
);
const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || '');
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || '');
const [customWebhookUrl, setCustomWebhookUrl] = useState(currentUser.customWebhookUrl || '');
const [notifyBeforeMinutes, setNotifyBeforeMinutes] = useState<number>(
currentUser.notifyBeforeMinutes ?? 30
);
const [alarmMsg, setAlarmMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [savingAlarms, setSavingAlarms] = useState(false);
const [testingChannel, setTestingChannel] = useState<string | null>(null);
// --- Webhooks & API Keys State ---
const [apiKeys, setApiKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedLabel, setCopiedLabel] = useState<string | null>(null);
useEffect(() => {
setDisplayName(currentUser.displayName || '');
setEmail(currentUser.email || '');
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
setCustomWebhookUrl(currentUser.customWebhookUrl || '');
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
}, [currentUser]);
useEffect(() => {
loadKeys();
}, []);
const loadKeys = async () => {
try {
const keys = await api.getAPIKeys();
setApiKeys(keys);
} catch {
// ignore
}
};
const toggleAlarmChannel = (ch: string) => {
setAlarmChannels((prev) =>
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
);
};
// --- Handlers: Profile ---
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
setProfileMsg(null);
setSavingProfile(true);
try {
const patch: any = {
displayName: displayName.trim(),
email: email.trim(),
};
if (newPassword.trim()) {
patch.password = newPassword.trim();
}
const updated = await api.updateSettings(patch);
onUserUpdated(updated);
setNewPassword('');
setProfileMsg({ type: 'ok', text: 'Profil und Anmeldedaten erfolgreich gespeichert.' });
} catch (err: any) {
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
} finally {
setSavingProfile(false);
}
};
// --- Handlers: Alarms ---
const handleSaveAlarms = async (e: React.FormEvent) => {
e.preventDefault();
setAlarmMsg(null);
setSavingAlarms(true);
try {
const updated = await api.updateSettings({
alarmChannels,
alarmEmail: alarmEmail.trim(),
discordWebhookUrl: discordWebhookUrl.trim(),
customWebhookUrl: customWebhookUrl.trim(),
notifyBeforeMinutes: Number(notifyBeforeMinutes),
});
onUserUpdated(updated);
setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' });
} catch (err: any) {
setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' });
} finally {
setSavingAlarms(false);
}
};
const handleTestChannel = async (channel: 'email' | 'discord' | 'webhook') => {
setAlarmMsg(null);
let target = '';
if (channel === 'email') {
target = (alarmEmail || currentUser.email || '').trim();
if (!target) {
setAlarmMsg({ type: 'err', text: 'Bitte gib zuerst eine Empfänger-E-Mail-Adresse an.' });
return;
}
} else if (channel === 'discord') {
target = discordWebhookUrl.trim();
if (!target) {
setAlarmMsg({ type: 'err', text: 'Bitte gib zuerst eine Discord Webhook URL ein.' });
return;
}
} else if (channel === 'webhook') {
target = customWebhookUrl.trim();
if (!target) {
setAlarmMsg({ type: 'err', text: 'Bitte gib zuerst eine eigene Webhook URL ein.' });
return;
}
}
setTestingChannel(channel);
try {
await api.testAlarmChannel(channel, target);
const label =
channel === 'email' ? 'E-Mail' : channel === 'discord' ? 'Discord' : 'Eigener Webhook';
setAlarmMsg({
type: 'ok',
text: `Test-Alarm über "${label}" wurde erfolgreich vom Server versendet!`,
});
} catch (err: any) {
setAlarmMsg({
type: 'err',
text: `Test fehlgeschlagen: ${err.message || 'Unbekannter Fehler'}`,
});
} finally {
setTestingChannel(null);
}
};
// --- Handlers: Webhooks & Export ---
const handleCreateAPIKey = async (e: React.FormEvent) => {
e.preventDefault();
triggerHaptic(15);
try {
const created = await api.createAPIKey(newKeyName.trim() || 'Eigenes Skript / Webhook');
if (created.rawKey) {
setNewlyCreatedKey(created.rawKey);
}
setNewKeyName('');
loadKeys();
} catch (err: any) {
alert(err.message);
}
};
const handleDeleteAPIKey = async (id: string) => {
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
await api.deleteAPIKey(id);
loadKeys();
};
const handleCopy = (text: string, label: string) => {
navigator.clipboard.writeText(text);
setCopiedLabel(label);
setTimeout(() => setCopiedLabel(null), 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);
};
const baseUrl = window.location.origin;
const activeKeyPlaceholder =
newlyCreatedKey || (apiKeys[0]?.keyPrefix ? apiKeys[0].keyPrefix + '••••••••' : 'DEIN_API_KEY');
const curlTaskExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
-H "X-API-Key: ${activeKeyPlaceholder}" \\
-H "Content-Type: application/json" \\
-d '{"content": "Server-Alarm prüfen #monitoring", "priority": "urgent", "target": "task"}'`;
const roleBadgeLabel =
currentUser.role === 'admin'
? 'Server-Admin'
: currentUser.role === 'team_admin'
? 'Team-Admin'
: 'Mitglied';
return (
<div className="w-full space-y-4">
{/* Top Page Header */}
<div className="flex items-center justify-between border-b border-border pb-3">
<div className="flex items-center gap-3">
<button
onClick={onBack}
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
title="Zurück zur Übersicht"
>
<ArrowLeft className="w-3.5 h-3.5" />
<span>Zurück</span>
</button>
<div>
<h1 className="text-base font-bold text-main tracking-tight">Kontoeinstellungen</h1>
<p className="text-xs text-muted">
Persönliches Profil, Design-Themes, Aufgaben-Alarme, Private Webhooks & Daten-Backup
</p>
</div>
</div>
<span className="px-2.5 py-1 bg-panel border border-border text-xs text-muted font-medium">
Rolle: <strong className="text-accent">{roleBadgeLabel}</strong>
</span>
</div>
{/* Main 2-Column Layout */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 items-start">
{/* Left Navigation Sidebar */}
<aside className="panel-surface divide-y divide-border overflow-hidden">
<button
onClick={() => setSection('profile')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'profile'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<UserIcon className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Profil & Sicherheit</p>
<p className="text-[10px] text-faint">Name, E-Mail & Passwort</p>
</div>
</button>
<button
onClick={() => setSection('appearance')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'appearance'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<Palette className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Darstellung & Themes</p>
<p className="text-[10px] text-faint">Farben, Kontraste & Rundungen</p>
</div>
</button>
<button
onClick={() => setSection('alarms')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'alarms'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<Bell className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Alarme</p>
<p className="text-[10px] text-faint">E-Mail, Discord & Webhooks</p>
</div>
</button>
<button
onClick={() => setSection('webhooks')}
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
section === 'webhooks'
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
: 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<Key className="w-4 h-4 text-accent flex-shrink-0" />
<div>
<p className="font-semibold">Private Webhooks & Export</p>
<p className="text-[10px] text-faint">API-Keys, Curl & JSON-Backup</p>
</div>
</button>
</aside>
{/* Right Content Area */}
<div className="md:col-span-3 panel-surface p-5 text-xs">
{/* ================================================================= */}
{/* SECTION 1: PROFIL & SICHERHEIT */}
{/* ================================================================= */}
{section === 'profile' && (
<div className="space-y-5 max-w-2xl">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">Profil & Anmeldedaten</h2>
<p className="text-muted mt-0.5">
Verwalte deinen Anzeigenamen, deine E-Mail-Adresse und dein persönliches Passwort.
</p>
</div>
{profileMsg && (
<div
className={`flex items-center gap-2 ${
profileMsg.type === 'ok' ? 'alert-ok' : 'alert-err'
}`}
>
{profileMsg.type === 'ok' ? (
<Check className="w-4 h-4 text-status-ok flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
)}
<span>{profileMsg.text}</span>
</div>
)}
<form onSubmit={handleSaveProfile} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-muted font-medium mb-1">Anzeigename</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
required
className="w-full h-9 px-3 input-field"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
E-Mail-Adresse (Login)
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full h-9 px-3 input-field"
/>
</div>
</div>
<div>
<label className="block text-muted font-medium mb-1">
Neues Passwort (optional — leer lassen für keine Änderung)
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Mindestens 4 Zeichen"
className="w-full h-9 px-3 input-field"
/>
</div>
<div className="pt-2 border-t border-border flex justify-end">
<button
type="submit"
disabled={savingProfile}
className="btn-primary h-9 px-5 text-xs font-semibold"
>
{savingProfile ? 'Speichere…' : 'Profil speichern'}
</button>
</div>
</form>
</div>
)}
{/* ================================================================= */}
{/* SECTION 2: DARSTELLUNG & THEMES (SINGLE-FILE CSS THEMING) */}
{/* ================================================================= */}
{section === 'appearance' && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">
Darstellung & Single-File CSS Themes
</h2>
<p className="text-muted mt-0.5">
Jedes Theme ist eine einzige, eigenständige CSS-Datei, in der alle Farben,
Oberflächen, Kontraste und Ecken-Rundungen in 6 festen Gruppen gebündelt sind.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{AVAILABLE_THEMES.map((t) => {
const isSelected = activeTheme === t.id;
return (
<button
key={t.id}
type="button"
onClick={() => onThemeChange(t.id)}
className={`p-4 text-left border transition-all flex flex-col justify-between gap-3 ${
isSelected
? 'bg-canvas border-accent shadow-sm'
: 'bg-canvas border-border hover:border-strong'
}`}
>
<div className="flex items-start justify-between gap-2">
<div>
<p className="font-bold text-sm text-main flex items-center gap-2">
<span>{t.name}</span>
{isSelected && <span className="badge-accent">Aktiv</span>}
</p>
<p className="text-xs text-muted mt-0.5">{t.subtitle}</p>
</div>
</div>
{/* Mini Color & Geometry Swatch */}
<div className="flex items-center justify-between pt-2 border-t border-border">
<div className="flex items-center gap-1.5">
<span
className="w-5 h-5 border border-strong inline-block"
style={{
backgroundColor: t.previewBg,
borderRadius: t.isRounded ? '6px' : '0px',
}}
title="Canvas-Hintergrund"
/>
<span
className="w-5 h-5 border border-strong inline-block"
style={{
backgroundColor: t.previewPanel,
borderRadius: t.isRounded ? '6px' : '0px',
}}
title="Panel-Oberfläche"
/>
<span
className="w-5 h-5 border border-strong inline-block"
style={{
backgroundColor: t.previewAccent,
borderRadius: t.isRounded ? '6px' : '0px',
}}
title="Akzentfarbe"
/>
<span
className="w-5 h-5 border border-strong inline-block"
style={{
backgroundColor: t.previewText,
borderRadius: t.isRounded ? '6px' : '0px',
}}
title="Textfarbe"
/>
</div>
<code className="text-[10px] font-mono text-faint">{t.file}</code>
</div>
</button>
);
})}
</div>
<div className="p-3.5 bg-canvas border border-border space-y-1.5">
<p className="font-semibold text-main">Eigenes Theme anpassen oder hinzufügen:</p>
<p className="text-muted text-[11px] leading-relaxed">
Alle Theme-Dateien liegen unter{' '}
<code className="font-mono text-accent">frontend/src/themes/</code> (inklusive der
Kopiervorlage{' '}
<code className="font-mono text-accent">theme-template.css</code>). Dort sind alle
Eigenschaften in 6 Blöcken gruppiert: <strong>1. Akzentfarbe</strong>,{' '}
<strong>2. Oberflächen</strong>, <strong>3. Textfarben</strong>,{' '}
<strong>4. Rahmen &amp; Schatten</strong>, <strong>5. Statusfarben</strong> und{' '}
<strong>6. Rundungen</strong>.
</p>
</div>
</div>
)}
{/* ================================================================= */}
{/* SECTION 3: ALARME (MULTI-CHANNEL: EMAIL, DISCORD, CUSTOM WEBHOOK) */}
{/* ================================================================= */}
{section === 'alarms' && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">
Aufgaben-Alarme & Benachrichtigungsziele
</h2>
<p className="text-muted mt-0.5">
Wähle aus, über welche Kanäle du benachrichtigt werden möchtest, sobald eine
Aufgabe mit Fälligkeitsdatum ansteht. Mehrere Ziele können gleichzeitig aktiv sein.
</p>
</div>
{alarmMsg && (
<div
className={`flex items-center gap-2 ${
alarmMsg.type === 'ok' ? 'alert-ok' : 'alert-err'
}`}
>
{alarmMsg.type === 'ok' ? (
<Check className="w-4 h-4 text-status-ok flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
)}
<span>{alarmMsg.text}</span>
</div>
)}
<form onSubmit={handleSaveAlarms} className="space-y-4">
{/* Lead Time Selector */}
<div className="p-3.5 bg-canvas border border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<label className="font-semibold text-main block">
Vorwarnzeit vor Fälligkeit
</label>
<p className="text-[11px] text-muted">
Wann soll MindSift den Alarm vor dem Fälligkeitszeitpunkt auslösen?
</p>
</div>
<select
value={notifyBeforeMinutes}
onChange={(e) => setNotifyBeforeMinutes(Number(e.target.value))}
className="h-9 px-3 input-field min-w-[200px]"
>
<option value={0}>Pünktlich bei Fälligkeit (0 Min.)</option>
<option value={15}>15 Minuten vorher</option>
<option value={30}>30 Minuten vorher</option>
<option value={60}>1 Stunde vorher</option>
<option value={120}>2 Stunden vorher</option>
<option value={1440}>1 Tag vorher (24 Std.)</option>
</select>
</div>
{/* Channel 1: E-Mail */}
<div
className={`p-4 border transition-colors space-y-3 bg-canvas ${
alarmChannels.includes('email') ? 'border-accent' : 'border-border'
}`}
>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={alarmChannels.includes('email')}
onChange={() => toggleAlarmChannel('email')}
className="w-4 h-4 accent-blue-600"
/>
<Mail className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-main block">Ziel: E-Mail</span>
<span className="text-[11px] text-muted">
Sendet eine E-Mail über den konfigurierten SMTP-Server der Instanz.
</span>
</div>
</label>
<button
type="button"
onClick={() => handleTestChannel('email')}
disabled={testingChannel === 'email'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'email' ? 'Sende…' : 'E-Mail testen'}</span>
</button>
</div>
<div className="pl-6">
<label className="block text-muted mb-1">
Abweichende Empfänger-E-Mail (optional, Standard: {currentUser.email})
</label>
<input
type="email"
value={alarmEmail}
onChange={(e) => setAlarmEmail(e.target.value)}
placeholder={currentUser.email}
className="w-full h-8 px-2.5 input-field"
/>
</div>
</div>
{/* Channel 2: Discord Webhook */}
<div
className={`p-4 border transition-colors space-y-3 bg-canvas ${
alarmChannels.includes('discord') ? 'border-accent' : 'border-border'
}`}
>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={alarmChannels.includes('discord')}
onChange={() => toggleAlarmChannel('discord')}
className="w-4 h-4 accent-blue-600"
/>
<MessageSquare className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-main block">Ziel: Discord Webhook</span>
<span className="text-[11px] text-muted">
Sendet eine formatierte Embed-Benachrichtigung direkt in deinen
Discord-Kanal.
</span>
</div>
</label>
<button
type="button"
onClick={() => handleTestChannel('discord')}
disabled={testingChannel === 'discord'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'discord' ? 'Sende…' : 'Discord testen'}</span>
</button>
</div>
<div className="pl-6">
<label className="block text-muted mb-1">Discord Webhook URL</label>
<input
type="url"
value={discordWebhookUrl}
onChange={(e) => setDiscordWebhookUrl(e.target.value)}
placeholder="https://discord.com/api/webhooks/123456789/token..."
className="w-full h-8 px-2.5 input-field font-mono"
/>
</div>
</div>
{/* Channel 3: Custom HTTP Webhook */}
<div
className={`p-4 border transition-colors space-y-3 bg-canvas ${
alarmChannels.includes('webhook') ? 'border-accent' : 'border-border'
}`}
>
<div className="flex items-center justify-between gap-2">
<label className="flex items-center gap-2.5 cursor-pointer select-none">
<input
type="checkbox"
checked={alarmChannels.includes('webhook')}
onChange={() => toggleAlarmChannel('webhook')}
className="w-4 h-4 accent-blue-600"
/>
<Globe className="w-4 h-4 text-accent" />
<div>
<span className="font-bold text-main block">
Ziel: Eigener Webhook (HTTP POST JSON)
</span>
<span className="text-[11px] text-muted">
Für eigene Empfänger-Skripte, Ntfy, Gotify, Slack, Mattermost oder
Automatisierungen.
</span>
</div>
</label>
<button
type="button"
onClick={() => handleTestChannel('webhook')}
disabled={testingChannel === 'webhook'}
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
>
<Send className="w-3 h-3 text-accent" />
<span>{testingChannel === 'webhook' ? 'Sende…' : 'Webhook testen'}</span>
</button>
</div>
<div className="pl-6">
<label className="block text-muted mb-1">
Ziel-URL (empfängt JSON-Payload per POST)
</label>
<input
type="url"
value={customWebhookUrl}
onChange={(e) => setCustomWebhookUrl(e.target.value)}
placeholder="https://dein-server.local/webhook/mindsift-alarm"
className="w-full h-8 px-2.5 input-field font-mono"
/>
</div>
</div>
<div className="pt-2 border-t border-border flex justify-end">
<button
type="submit"
disabled={savingAlarms}
className="btn-primary h-9 px-5 text-xs font-semibold"
>
{savingAlarms ? 'Speichere…' : 'Alarm-Einstellungen speichern'}
</button>
</div>
</form>
</div>
)}
{/* ================================================================= */}
{/* SECTION 4: PRIVATE WEBHOOKS & EXPORT */}
{/* ================================================================= */}
{section === 'webhooks' && (
<div className="space-y-5">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">
Private Webhooks (Eingehend) & Daten-Export
</h2>
<p className="text-muted mt-0.5">
Erstelle persönliche API-Schlüssel, um aus externen Skripten, Server-Monitoring
oder Discord-Bots automatisch Aufgaben und Gedanken in dein MindSift einzuspeisen.
</p>
</div>
{/* Create API Key */}
<form onSubmit={handleCreateAPIKey} className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="Bezeichnung des Schlüssels (z.B. Proxmox Monitoring, Discord-Forwarder)…"
className="flex-1 h-9 px-3 input-field"
/>
<button
type="submit"
className="btn-primary h-9 px-4 text-xs font-semibold flex items-center justify-center gap-1.5"
>
<Plus className="w-3.5 h-3.5" />
<span>Neuen API-Key erzeugen</span>
</button>
</form>
{newlyCreatedKey && (
<div className="p-3 bg-accent-dim border border-accent flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-[11px] text-accent font-bold">
Neuer API-Schlüssel erstellt (bitte jetzt kopieren, wird nur einmal im
Klartext angezeigt):
</p>
<code className="text-xs font-mono text-main break-all">{newlyCreatedKey}</code>
</div>
<button
onClick={() => handleCopy(newlyCreatedKey, 'key')}
className="btn-primary h-8 px-3 text-xs flex-shrink-0"
>
{copiedLabel === 'key' ? 'Kopiert!' : 'Key kopieren'}
</button>
</div>
)}
{/* Existing Keys List */}
<div className="border border-border divide-y divide-border bg-canvas">
{apiKeys.length === 0 ? (
<div className="p-4 text-center text-faint">
Noch keine persönlichen API-Schlüssel vorhanden.
</div>
) : (
apiKeys.map((k) => (
<div key={k.id} className="p-3 flex items-center justify-between gap-3">
<div>
<p className="font-semibold text-main">{k.name}</p>
<p className="text-[11px] text-faint font-mono">
Präfix: {k.keyPrefix}•••••••• · Erstellt:{' '}
{new Date(k.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={() => handleDeleteAPIKey(k.id)}
className="btn-icon btn-icon-danger"
title="Schlüssel widerrufen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))
)}
</div>
{/* Curl Example */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="font-semibold text-muted flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>Curl-Beispiel: Direkt als Aufgabe einspeisen</span>
</span>
<button
onClick={() => handleCopy(curlTaskExample, 'curl')}
className="btn-action h-7 px-2.5 text-[11px]"
>
{copiedLabel === 'curl' ? 'Kopiert!' : 'Befehl kopieren'}
</button>
</div>
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
{curlTaskExample}
</pre>
</div>
{/* Full Data Backup */}
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h3 className="font-bold text-main">Vollständiger Daten-Export (JSON Backup)</h3>
<p className="text-muted text-[11px]">
Lädt alle deine Gedanken, Aufgaben, Markdown-Dokumente und Listen als portable
JSON-Datei herunter.
</p>
</div>
<button
onClick={handleExportData}
className="btn-action h-9 px-4 text-xs flex items-center gap-2 flex-shrink-0"
>
<Download className="w-3.5 h-3.5 text-accent" />
<span>Backup herunterladen</span>
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
};
File diff suppressed because it is too large. Load diff
+334 -141
View File
@@ -3,6 +3,7 @@ import { motion, useMotionValue, useTransform, AnimatePresence } from 'framer-mo
import { import {
X, X,
Check, Check,
CheckSquare,
Calendar, Calendar,
FileText, FileText,
ShoppingCart, ShoppingCart,
@@ -13,6 +14,9 @@ import {
Keyboard, Keyboard,
Tag, Tag,
Clock, Clock,
SkipForward,
Edit3,
Layers,
} from 'lucide-react'; } from 'lucide-react';
import { SharedList, Thought } from '../../lib/types'; import { SharedList, Thought } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api'; import { api, triggerHaptic } from '../../lib/api';
@@ -41,6 +45,10 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
}); });
const [showListSelector, setShowListSelector] = useState(false); const [showListSelector, setShowListSelector] = useState(false);
// Inline edit state for cleaning up a hasty Quick Dump note before converting
const [isEditingCard, setIsEditingCard] = useState(false);
const [editDraft, setEditDraft] = useState('');
useEffect(() => { useEffect(() => {
setCards(deck); setCards(deck);
}, [deck]); }, [deck]);
@@ -48,14 +56,19 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
const activeCard = cards[0]; const activeCard = cards[0];
const nextCard = cards[1]; const nextCard = cards[1];
useEffect(() => {
setIsEditingCard(false);
setEditDraft(activeCard?.content || '');
}, [activeCard?.id]);
const x = useMotionValue(0); const x = useMotionValue(0);
const rotate = useTransform(x, [-220, 0, 220], [-14, 0, 14]); const rotate = useTransform(x, [-220, 0, 220], [-8, 0, 8]);
const keepOpacity = useTransform(x, [25, 130], [0, 1]); const keepOpacity = useTransform(x, [25, 110], [0, 1]);
const discardOpacity = useTransform(x, [-130, -25], [1, 0]); const discardOpacity = useTransform(x, [-110, -25], [1, 0]);
const cardBorderColor = useTransform( const cardBorderColor = useTransform(
x, x,
[-140, 0, 140], [-140, 0, 140],
['rgba(242, 139, 130, 0.65)', 'rgba(255, 255, 255, 0.08)', 'rgba(129, 201, 149, 0.65)'] ['rgba(239, 68, 68, 0.7)', 'rgba(255, 255, 255, 0.08)', 'rgba(29, 140, 248, 0.8)']
); );
const handleAction = async ( const handleAction = async (
@@ -64,22 +77,28 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
convertTo?: 'task' | 'document' | 'list_item'; convertTo?: 'task' | 'document' | 'list_item';
targetListId?: string; targetListId?: string;
dueDate?: string; dueDate?: string;
thoughtOverride?: Thought;
} }
) => { ) => {
if (!activeCard) return; const targetCard = options?.thoughtOverride || activeCard;
if (!targetCard) return;
const current = activeCard; triggerHaptic(action === 'keep' ? [10, 20] : 15);
triggerHaptic(action === 'keep' ? [12, 25] : 18);
// Optimistic removal from deck // Optimistic removal from local deck
setCards((prev) => prev.slice(1)); setCards((prev) => prev.filter((c) => c.id !== targetCard.id));
setLastTriaged(current); setLastTriaged(targetCard);
setShowDateModal(false); setShowDateModal(false);
setShowListSelector(false); setShowListSelector(false);
setIsEditingCard(false);
x.set(0); x.set(0);
try { try {
const updated = await api.triageAction(current.id, action, options); const updated = await api.triageAction(targetCard.id, action, {
convertTo: options?.convertTo,
targetListId: options?.targetListId,
dueDate: options?.dueDate,
});
onDeckChange(); onDeckChange();
if (options?.convertTo === 'document' && updated.convertedRefId) { if (options?.convertTo === 'document' && updated.convertedRefId) {
onOpenDocument(updated.convertedRefId); onOpenDocument(updated.convertedRefId);
@@ -89,6 +108,42 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
} }
}; };
const handleSkipCard = () => {
if (cards.length <= 1) return;
triggerHaptic(10);
setShowDateModal(false);
setShowListSelector(false);
setIsEditingCard(false);
x.set(0);
setCards((prev) => [...prev.slice(1), prev[0]]);
};
const handleBringToFront = (thoughtId: string) => {
triggerHaptic(10);
setCards((prev) => {
const chosen = prev.find((c) => c.id === thoughtId);
if (!chosen) return prev;
return [chosen, ...prev.filter((c) => c.id !== thoughtId)];
});
};
const handleSaveInlineEdit = async () => {
if (!activeCard) return;
const trimmed = editDraft.trim();
if (!trimmed || trimmed === activeCard.content) {
setIsEditingCard(false);
return;
}
try {
const updated = await api.updateThought(activeCard.id, { content: trimmed });
setCards((prev) => prev.map((c) => (c.id === activeCard.id ? updated : c)));
setIsEditingCard(false);
onDeckChange();
} catch {
setIsEditingCard(false);
}
};
const handleUndo = async () => { const handleUndo = async () => {
if (!lastTriaged) return; if (!lastTriaged) return;
triggerHaptic(15); triggerHaptic(15);
@@ -98,7 +153,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
onDeckChange(); onDeckChange();
}; };
// Keyboard shortcuts for desktop power users (BRAIN.md Section 5) // Keyboard shortcuts for desktop power users
useEffect(() => { useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
@@ -116,44 +171,42 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
handleAction('discard'); handleAction('discard');
} else if (e.key === 'ArrowRight' || e.key === 'l' || e.key === 'L') { } else if (e.key === 'ArrowRight' || e.key === 'l' || e.key === 'L') {
e.preventDefault(); e.preventDefault();
handleAction('keep'); handleAction('keep', { convertTo: 'task' });
} else if (e.key === 't' || e.key === 'T') { } else if (e.key === 't' || e.key === 'T') {
e.preventDefault(); e.preventDefault();
setShowListSelector(false);
setShowDateModal((prev) => !prev); setShowDateModal((prev) => !prev);
} else if (e.key === 'd' || e.key === 'D') { } else if (e.key === 'd' || e.key === 'D') {
e.preventDefault(); e.preventDefault();
handleAction('keep', { convertTo: 'document' }); handleAction('keep', { convertTo: 'document' });
} else if (e.key === ' ') {
e.preventDefault();
handleSkipCard();
} }
}; };
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown);
}, [activeCard]); }, [activeCard, cards.length]);
const priorityBadge = (priority: string) => { const priorityBadge = (priority: string) => {
switch (priority) { switch (priority) {
case 'urgent': case 'urgent':
return ( return (
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-[#f28b82]/20 text-[#f28b82] border border-[#f28b82]/40"> <span className="px-2 py-0.5 text-[11px] font-semibold border border-accent rounded-btn text-accent">
🚨 Dringend Dringend
</span> </span>
); );
case 'high': case 'high':
return ( return (
<span className="px-3 py-1 rounded-full text-xs font-semibold bg-[#8ab4f8]/20 text-[#8ab4f8] border border-[#8ab4f8]/40"> <span className="px-2 py-0.5 text-[11px] font-medium border border-strong rounded-btn text-main">
⚡ Priorität Hoch Wichtig
</span>
);
case 'low':
return (
<span className="px-3 py-1 rounded-full text-xs font-medium bg-[#282a2c] text-[#9aa0a6]">
Später / Niedrig
</span> </span>
); );
default: default:
return ( return (
<span className="px-3 py-1 rounded-full text-xs font-medium bg-[#282a2c] text-[#9aa0a6]"> <span className="px-2 py-0.5 text-[11px] font-medium bg-panel-elevated text-muted border border-border rounded-btn">
Gedanke Quick Dump
</span> </span>
); );
} }
@@ -162,15 +215,15 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
const sourceBadge = (source: string) => { const sourceBadge = (source: string) => {
if (source === 'voice') { if (source === 'voice') {
return ( return (
<span className="px-3 py-1 rounded-full text-xs bg-[#c58af9]/15 text-[#c58af9] flex items-center gap-1"> <span className="px-2 py-0.5 text-[11px] bg-canvas text-accent border border-border rounded-btn flex items-center gap-1">
<Mic className="w-3 h-3" /> Sprachnotiz <Mic className="w-3 h-3" /> Diktat
</span> </span>
); );
} }
if (source === 'webhook') { if (source === 'webhook') {
return ( return (
<span className="px-3 py-1 rounded-full text-xs bg-[#81c995]/15 text-[#81c995] flex items-center gap-1"> <span className="px-2 py-0.5 text-[11px] bg-canvas text-muted border border-border rounded-btn flex items-center gap-1">
<Webhook className="w-3 h-3" /> Smarthome Webhook <Webhook className="w-3 h-3 text-accent" /> Webhook
</span> </span>
); );
} }
@@ -179,30 +232,32 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
if (!activeCard) { if (!activeCard) {
return ( return (
<div className="max-w-xl mx-auto mt-8 p-8 sm:p-10 rounded-3xl bg-[#1e1f20] gemini-border text-center shadow-card-depth"> <div className="max-w-xl mx-auto mt-2 p-6 panel-surface text-center">
<div className="w-16 h-16 rounded-full bg-[#81c995]/15 border border-[#81c995]/30 flex items-center justify-center mx-auto mb-5"> <div className="w-11 h-11 bg-accent text-on-accent rounded-btn flex items-center justify-center mx-auto mb-3 shadow-sm">
<Sparkles className="w-8 h-8 text-[#81c995]" /> <Sparkles className="w-5 h-5" />
</div> </div>
<h2 className="text-2xl font-bold text-[#e3e3e3] mb-2">Inbox Zero erreicht!</h2> <h2 className="text-lg font-bold text-main mb-1">Triage-Deck ist leer</h2>
<p className="text-[#9aa0a6] text-sm sm:text-base max-w-md mx-auto mb-6"> <p className="text-muted text-xs sm:text-sm max-w-md mx-auto mb-4">
Dein Kopf ist komplett frei. Alle eingefrorenen Gedanken wurden gesichtet, verworfen oder in echte Projekte und Aufgaben verwandelt. Alle Quick Dump Notizen sind einsortiert oder verworfen. Tippe oben in die Leiste (Taste{' '}
<kbd className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-main">C</kbd>
), um neue Gedanken reinzurotzen.
</p> </p>
<div className="flex flex-wrap items-center justify-center gap-3"> <div className="flex flex-wrap items-center justify-center gap-2">
<button <button
onClick={onFocusCapture} onClick={onFocusCapture}
className="h-11 px-6 rounded-full bg-[#8ab4f8] text-[#0b0f19] font-semibold text-sm hover:bg-[#a1c2fa] transition-colors" className="btn-primary h-8 px-4"
> >
+ Neuen Gedanken einfrieren + Quick Dump erfassen
</button> </button>
{lastTriaged && ( {lastTriaged && (
<button <button
onClick={handleUndo} onClick={handleUndo}
className="h-11 px-5 rounded-full bg-[#282a2c] text-[#e3e3e3] hover:bg-[#333639] text-sm flex items-center gap-2 transition-colors" className="btn-action h-8 px-3"
> >
<RotateCcw className="w-4 h-4 text-[#8ab4f8]" /> <RotateCcw className="w-3.5 h-3.5 text-accent" />
Letzten Swipe rückgängig machen Letzte Aktion rückgängig
</button> </button>
)} )}
</div> </div>
@@ -211,40 +266,41 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
} }
return ( return (
<div className="max-w-xl mx-auto select-none"> <div className="max-w-2xl mx-auto select-none space-y-4">
{/* Top Deck Status & Undo Bar */} {/* Top Deck Status & Undo Bar */}
<div className="flex items-center justify-between mb-4 px-2"> <div className="flex items-center justify-between px-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="px-3.5 py-1 rounded-full bg-[#1e1f20] gemini-border text-xs font-medium text-[#8ab4f8]"> <span className="badge-accent">
🃏 Noch {cards.length} {cards.length === 1 ? 'Gedanke' : 'Gedanken'} im Deck {cards.length} {cards.length === 1 ? 'Quick Dump' : 'Quick Dumps'} offen
</span> </span>
<span className="hidden sm:flex items-center gap-1 text-xs text-[#5f6368]"> <span className="hidden sm:inline text-xs text-faint">•</span>
<Keyboard className="w-3.5 h-3.5" /> Pfeiltasten ← / → oder H / L <span className="hidden sm:flex items-center gap-1 text-xs text-muted">
<Keyboard className="w-3 h-3" /> ← Wegwerfen • → Aufgabe • D Notiz • Leertaste Später
</span> </span>
</div> </div>
{lastTriaged && ( {lastTriaged && (
<button <button
onClick={handleUndo} onClick={handleUndo}
className="px-3.5 py-1 rounded-full bg-[#1e1f20] hover:bg-[#282a2c] gemini-border text-xs text-[#9aa0a6] hover:text-[#e3e3e3] flex items-center gap-1.5 transition-colors" className="btn-action h-7 px-2.5 text-xs"
> >
<RotateCcw className="w-3.5 h-3.5 text-[#8ab4f8]" /> <RotateCcw className="w-3 h-3 text-accent" />
Rückgängig Rückgängig
</button> </button>
)} )}
</div> </div>
{/* Card Stack Container */} {/* Card Stack Container */}
<div className="relative h-[340px] sm:h-[360px] w-full flex items-center justify-center"> <div className="relative h-[280px] sm:h-[300px] w-full flex items-center justify-center">
{/* Background Preview Card */} {/* Background Preview Card */}
{nextCard && ( {nextCard && (
<div <div
key={nextCard.id + '-preview'} key={nextCard.id + '-preview'}
className="absolute inset-x-4 top-5 bottom-0 rounded-3xl bg-[#18191b] gemini-border p-6 opacity-50 scale-[0.95] pointer-events-none flex flex-col justify-between" className="absolute inset-x-3 top-3 bottom-0 panel-surface p-4 opacity-40 scale-[0.97] pointer-events-none flex flex-col justify-between"
> >
<div className="flex items-center gap-2">{priorityBadge(nextCard.priority)}</div> <div className="flex items-center gap-2">{priorityBadge(nextCard.priority)}</div>
<p className="text-lg text-[#9aa0a6] line-clamp-3">{nextCard.content}</p> <p className="text-sm text-muted line-clamp-2">{nextCard.content}</p>
<div className="text-xs text-[#5f6368]">Nächste Karte…</div> <div className="text-[11px] text-faint">Nächster Quick Dump…</div>
</div> </div>
)} )}
@@ -253,45 +309,58 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
<motion.div <motion.div
key={activeCard.id} key={activeCard.id}
style={{ x, rotate, borderColor: cardBorderColor }} style={{ x, rotate, borderColor: cardBorderColor }}
drag="x" drag={isEditingCard ? false : 'x'}
dragConstraints={{ left: 0, right: 0 }} dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.85} dragElastic={0.8}
transition={{ type: 'spring', stiffness: 350, damping: 25 }} transition={{ type: 'spring', stiffness: 350, damping: 25 }}
onDragEnd={(_, info) => { onDragEnd={(_, info) => {
if (info.offset.x > 110 || info.velocity.x > 550) { if (info.offset.x > 105 || info.velocity.x > 520) {
handleAction('keep'); handleAction('keep', { convertTo: 'task' });
} else if (info.offset.x < -110 || info.velocity.x < -550) { } else if (info.offset.x < -105 || info.velocity.x < -520) {
handleAction('discard'); handleAction('discard');
} }
}} }}
initial={{ scale: 0.94, opacity: 0, y: 16 }} initial={{ scale: 0.97, opacity: 0, y: 10 }}
animate={{ scale: 1, opacity: 1, y: 0 }} animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.9, opacity: 0 }} exit={{ scale: 0.93, opacity: 0 }}
className="relative w-full h-full rounded-3xl bg-[#1e1f20] border p-6 sm:p-8 shadow-card-depth cursor-grab active:cursor-grabbing flex flex-col justify-between overflow-hidden" className="relative w-full h-full panel-surface p-4 sm:p-5 cursor-grab active:cursor-grabbing flex flex-col justify-between overflow-hidden"
> >
{/* Dynamic Swipe Overlays */} {/* Dynamic Swipe Overlays */}
<motion.div <motion.div
style={{ opacity: keepOpacity }} style={{ opacity: keepOpacity }}
className="pointer-events-none absolute top-6 right-6 px-4 py-1.5 rounded-full bg-[#81c995]/20 border-2 border-[#81c995] text-[#81c995] font-bold text-xs tracking-wider uppercase" className="pointer-events-none absolute top-3.5 right-3.5 px-2.5 py-1 bg-accent-dim border border-accent rounded-btn text-accent font-semibold text-xs tracking-wide uppercase"
> >
Behalten 👉 Als Aufgabe 👉
</motion.div> </motion.div>
<motion.div <motion.div
style={{ opacity: discardOpacity }} style={{ opacity: discardOpacity }}
className="pointer-events-none absolute top-6 left-6 px-4 py-1.5 rounded-full bg-[#f28b82]/20 border-2 border-[#f28b82] text-[#f28b82] font-bold text-xs tracking-wider uppercase" className="pointer-events-none absolute top-3.5 left-3.5 px-2.5 py-1 bg-panel-elevated border border-strong rounded-btn text-main font-semibold text-xs tracking-wide uppercase"
> >
👈 Weg damit 👈 Wegwerfen
</motion.div> </motion.div>
{/* Card Header */} {/* Card Header */}
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
{priorityBadge(activeCard.priority)} {priorityBadge(activeCard.priority)}
{sourceBadge(activeCard.source)} {sourceBadge(activeCard.source)}
</div> </div>
<span className="text-xs text-[#5f6368] flex items-center gap-1"> <div className="flex items-center gap-2">
<Clock className="w-3.5 h-3.5" /> <button
type="button"
onClick={() => {
setEditDraft(activeCard.content);
setIsEditingCard((prev) => !prev);
}}
title="Quick Dump Text vor dem Zuordnen anpassen"
className="h-6 px-2 bg-canvas hover:bg-panel-hover border border-border rounded-btn text-[11px] text-muted hover:text-main flex items-center gap-1"
>
<Edit3 className="w-3 h-3" />
<span>Bearbeiten</span>
</button>
<span className="text-xs text-muted flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(activeCard.createdAt).toLocaleString('de-DE', { {new Date(activeCard.createdAt).toLocaleString('de-DE', {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
@@ -300,33 +369,61 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
})} })}
</span> </span>
</div> </div>
{/* Card Body: The Thought */}
<div className="my-auto py-4">
<p className="text-xl sm:text-2xl font-medium text-[#e3e3e3] leading-relaxed break-words">
{activeCard.content}
</p>
</div> </div>
{/* Card Footer: Tags & Due Date */} {/* Card Body */}
<div className="flex flex-wrap items-center justify-between gap-2 pt-3 border-t border-white/[0.06]"> <div className="my-auto py-2">
{isEditingCard ? (
<div className="space-y-2">
<textarea
value={editDraft}
onChange={(e) => setEditDraft(e.target.value)}
rows={3}
autoFocus
className="w-full input-field p-2 text-sm resize-none"
/>
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
onClick={() => setIsEditingCard(false)}
className="btn-action h-7 px-2.5 text-xs"
>
Abbrechen
</button>
<button
type="button"
onClick={handleSaveInlineEdit}
className="btn-primary h-7 px-3 text-xs"
>
Text speichern
</button>
</div>
</div>
) : (
<p className="text-base sm:text-xl font-medium text-main leading-relaxed break-words">
{activeCard.content}
</p>
)}
</div>
{/* Card Footer: Tags & Hints */}
<div className="flex flex-wrap items-center justify-between gap-2 pt-2.5 border-t border-border">
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
{activeCard.tags && activeCard.tags.length > 0 ? ( {activeCard.tags && activeCard.tags.length > 0 ? (
activeCard.tags.map((tag) => ( activeCard.tags.map((tag) => (
<span <span key={tag} className="badge-accent">
key={tag} <Tag className="w-2.5 h-2.5" />#{tag}
className="px-3 py-0.5 rounded-full bg-[#131314] text-[#8ab4f8] text-xs flex items-center gap-1"
>
<Tag className="w-3 h-3" />#{tag}
</span> </span>
)) ))
) : ( ) : (
<span className="text-xs text-[#5f6368]">Wische links zum Verwerfen, rechts zum Behalten</span> <span className="text-xs text-faint">
Links wischen = Wegwerfen • Rechts wischen = Als Aufgabe sichern
</span>
)} )}
</div> </div>
{activeCard.dueDate && ( {activeCard.dueDate && (
<span className="px-3 py-0.5 rounded-full bg-[#8ab4f8]/15 text-[#8ab4f8] text-xs"> <span className="px-2 py-0.5 bg-canvas text-muted border border-border rounded-btn text-xs">
📅 {activeCard.dueDate} 📅 {activeCard.dueDate}
</span> </span>
)} )}
@@ -335,37 +432,37 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</AnimatePresence> </AnimatePresence>
</div> </div>
{/* Convert to Task Date Picker Popover */} {/* Convert to Task with Due Date Popover */}
{showDateModal && ( {showDateModal && (
<div className="mt-4 p-4 rounded-3xl bg-[#131314] gemini-border flex flex-wrap items-center justify-between gap-3"> <div className="p-3 panel-surface flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm text-[#e3e3e3]"> <div className="flex items-center gap-2 text-xs text-muted">
<Calendar className="w-4 h-4 text-[#8ab4f8]" /> <Calendar className="w-4 h-4 text-accent" />
<span>Als To-Do mit Fälligkeitsdatum anlegen:</span> <span>Aufgabe mit Fälligkeitsdatum anlegen:</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="date" type="date"
value={taskDueDate} value={taskDueDate}
onChange={(e) => setTaskDueDate(e.target.value)} onChange={(e) => setTaskDueDate(e.target.value)}
className="bg-[#1e1f20] text-[#e3e3e3] px-3.5 py-1.5 rounded-full gemini-border text-xs focus:outline-none" className="input-field px-2.5 py-1 text-xs"
/> />
<button <button
onClick={() => handleAction('keep', { convertTo: 'task', dueDate: taskDueDate })} onClick={() => handleAction('keep', { convertTo: 'task', dueDate: taskDueDate })}
className="px-4 py-1.5 rounded-full bg-[#81c995] text-[#0b0f19] font-semibold text-xs hover:opacity-90" className="btn-primary h-7 px-3 text-xs"
> >
Aufgabe speichern Als Termin-Aufgabe speichern
</button> </button>
</div> </div>
</div> </div>
)} )}
{/* Convert to Shopping List Item Popover */} {/* Convert to Shared List Item Popover */}
{showListSelector && ( {showListSelector && (
<div className="mt-4 p-4 rounded-3xl bg-[#131314] gemini-border"> <div className="p-3 panel-surface">
<p className="text-xs text-[#9aa0a6] mb-2.5"> <p className="text-xs text-muted mb-2">
In welche geteilte Liste soll dieser Gedanke verschoben werden? Quick Dump direkt einer Liste zuordnen:
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-1.5">
{lists.length > 0 ? ( {lists.length > 0 ? (
lists.map((lst) => ( lists.map((lst) => (
<button <button
@@ -373,85 +470,181 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
onClick={() => onClick={() =>
handleAction('keep', { convertTo: 'list_item', targetListId: lst.id }) handleAction('keep', { convertTo: 'list_item', targetListId: lst.id })
} }
className="px-4 py-2 rounded-full bg-[#1e1f20] hover:bg-[#282a2c] gemini-border text-xs text-[#e3e3e3] flex items-center gap-1.5 transition-colors" className="btn-action h-7 px-2.5 text-xs"
> >
<span>{lst.icon}</span> <span>{lst.icon}</span>
<span>{lst.title}</span> <span>{lst.title}</span>
</button> </button>
)) ))
) : ( ) : (
<span className="text-xs text-[#5f6368]">Noch keine Listen vorhanden.</span> <span className="text-xs text-muted">Keine Listen vorhanden.</span>
)} )}
</div> </div>
</div> </div>
)} )}
{/* Primary Triage Action Pills */} {/* Primary Triage Decision Bar */}
<div className="mt-6 flex flex-wrap items-center justify-center gap-2.5 sm:gap-3"> <div className="grid grid-cols-2 sm:grid-cols-6 gap-1.5">
{/* Swipe Left: Discard */} {/* 1. Swipe Left: Discard */}
<button <button
onClick={() => handleAction('discard')} onClick={() => handleAction('discard')}
title="Verwerfen (Taste H oder Pfeil links)" title="Wegwerfen (Taste H oder Pfeil links)"
className="h-12 px-5 rounded-full bg-[#1e1f20] hover:bg-[#f28b82]/20 border border-[#f28b82]/30 text-[#f28b82] font-medium text-sm flex items-center gap-2 transition-all active:scale-95" className="btn-action h-8 px-2.5 justify-center"
> >
<X className="w-4 h-4" /> <X className="w-3.5 h-3.5 text-muted" />
<span>Weg damit</span> <span>Wegwerfen</span>
</button> </button>
{/* Convert to Task with Due Date */} {/* 2. Skip / Later */}
<button <button
onClick={() => { onClick={handleSkipCard}
setShowListSelector(false); disabled={cards.length <= 1}
setShowDateModal((prev) => !prev); title="Später entscheiden – ans Ende schieben (Leertaste)"
}} className={`btn-action h-8 px-2.5 justify-center ${
title="Als Aufgabe mit Datum (Taste T)" cards.length <= 1 ? 'opacity-40 cursor-not-allowed' : ''
className={`h-12 px-4 rounded-full gemini-border text-xs sm:text-sm flex items-center gap-1.5 transition-all ${
showDateModal
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold'
: 'bg-[#1e1f20] hover:bg-[#282a2c] text-[#e3e3e3]'
}`} }`}
> >
<Calendar className="w-4 h-4 text-[#8ab4f8]" /> <SkipForward className="w-3.5 h-3.5 text-muted" />
<span>Termin-To-Do</span> <span>Später</span>
</button> </button>
{/* Convert to Workspace Document */} {/* 3. Assign to List */}
<button
onClick={() => handleAction('keep', { convertTo: 'document' })}
title="In ein Workspace-Dokument verwandeln (Taste D)"
className="h-12 px-4 rounded-full bg-[#1e1f20] hover:bg-[#282a2c] gemini-border text-[#e3e3e3] text-xs sm:text-sm flex items-center gap-1.5 transition-all"
>
<FileText className="w-4 h-4 text-[#c58af9]" />
<span>Als Projekt-Notiz</span>
</button>
{/* Convert to Shared Shopping List Item */}
<button <button
onClick={() => { onClick={() => {
setShowDateModal(false); setShowDateModal(false);
setShowListSelector((prev) => !prev); setShowListSelector((prev) => !prev);
}} }}
title="Auf eine geteilte Einkaufsliste setzen" title="Auf eine geteilte Liste setzen"
className={`h-12 px-4 rounded-full gemini-border text-xs sm:text-sm flex items-center gap-1.5 transition-all ${ className={`btn-action h-8 px-2.5 justify-center ${
showListSelector showListSelector ? 'border-accent text-accent bg-accent-dim' : ''
? 'bg-[#8ab4f8] text-[#0b0f19] font-semibold'
: 'bg-[#1e1f20] hover:bg-[#282a2c] text-[#e3e3e3]'
}`} }`}
> >
<ShoppingCart className="w-4 h-4 text-[#81c995]" /> <ShoppingCart className="w-3.5 h-3.5 text-accent" />
<span>Auf Liste</span> <span>Auf Liste</span>
</button> </button>
{/* Swipe Right: Keep */} {/* 4. Convert to Workspace Note */}
<button <button
onClick={() => handleAction('keep')} onClick={() => handleAction('keep', { convertTo: 'document' })}
title="Behalten & als Aufgabe sichern (Taste L oder Pfeil rechts)" title="In eine Workspace-Notiz umwandeln (Taste D)"
className="h-12 px-5 rounded-full bg-[#81c995]/20 hover:bg-[#81c995]/30 border border-[#81c995]/40 text-[#81c995] font-semibold text-sm flex items-center gap-2 transition-all active:scale-95" className="btn-action h-8 px-2.5 justify-center"
> >
<Check className="w-4 h-4" /> <FileText className="w-3.5 h-3.5 text-accent" />
<span>Behalten</span> <span>Als Notiz</span>
</button>
{/* 5. Task with Date */}
<button
onClick={() => {
setShowListSelector(false);
setShowDateModal((prev) => !prev);
}}
title="Als Aufgabe mit Fälligkeitsdatum (Taste T)"
className={`btn-action h-8 px-2.5 justify-center ${
showDateModal ? 'border-accent text-accent bg-accent-dim' : ''
}`}
>
<Calendar className="w-3.5 h-3.5 text-accent" />
<span>Mit Termin</span>
</button>
{/* 6. Swipe Right: Immediate Task */}
<button
onClick={() => handleAction('keep', { convertTo: 'task' })}
title="Sofort als Aufgabe übernehmen (Taste L oder Pfeil rechts)"
className="btn-primary h-8 px-3 justify-center"
>
<CheckSquare className="w-3.5 h-3.5" />
<span>Als Aufgabe</span>
</button>
</div>
{/* Waiting Quick Dump Queue List */}
{cards.length > 1 && (
<div className="pt-2">
<div className="flex items-center justify-between mb-1.5 px-0.5">
<span className="text-xs font-semibold uppercase tracking-wider text-muted flex items-center gap-1.5">
<Layers className="w-3.5 h-3.5 text-accent" />
Warteschlange ({cards.length - 1} weitere Quick Dumps)
</span>
<span className="text-[11px] text-faint">
Klicken zum Vorholen oder direkt zuordnen
</span>
</div>
<div className="panel-surface divide-y divide-border max-h-60 overflow-y-auto">
{cards.slice(1).map((item) => (
<div
key={item.id}
className="px-3 py-2 flex items-center justify-between gap-2 hover:bg-panel-hover transition-colors group"
>
<div
onClick={() => handleBringToFront(item.id)}
className="flex-1 min-w-0 cursor-pointer flex items-center gap-2"
>
<span className="w-1.5 h-1.5 bg-accent flex-shrink-0" />
<span className="text-xs text-muted truncate group-hover:text-main">
{item.content}
</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={() =>
handleAction('keep', { convertTo: 'task', thoughtOverride: item })
}
title="Direkt zur Aufgabe machen"
className="h-6 px-2 bg-canvas hover:bg-accent hover:text-on-accent border border-border rounded-btn text-[11px] text-muted flex items-center gap-1 transition-colors"
>
<Check className="w-3 h-3" />
<span className="hidden sm:inline">Aufgabe</span>
</button>
<button
type="button"
onClick={() =>
handleAction('keep', { convertTo: 'document', thoughtOverride: item })
}
title="Direkt als Workspace-Notiz anlegen"
className="h-6 px-2 bg-canvas hover:bg-panel-elevated border border-border rounded-btn text-[11px] text-muted hover:text-main flex items-center gap-1 transition-colors"
>
<FileText className="w-3 h-3 text-accent" />
<span className="hidden sm:inline">Notiz</span>
</button>
{lists.length > 0 && (
<button
type="button"
onClick={() =>
handleAction('keep', {
convertTo: 'list_item',
targetListId: lists[0].id,
thoughtOverride: item,
})
}
title={`Direkt auf Liste "${lists[0].title}" setzen`}
className="h-6 px-2 bg-canvas hover:bg-panel-elevated border border-border rounded-btn text-[11px] text-muted hover:text-main flex items-center gap-1 transition-colors"
>
<ShoppingCart className="w-3 h-3 text-accent" />
<span className="hidden md:inline">{lists[0].title}</span>
</button>
)}
<button
type="button"
onClick={() => handleAction('discard', { thoughtOverride: item })}
title="Wegwerfen"
className="h-6 w-6 bg-canvas hover:bg-panel-elevated border border-border rounded-btn text-muted hover:text-main flex items-center justify-center transition-colors"
>
<X className="w-3 h-3" />
</button> </button>
</div> </div>
</div> </div>
))}
</div>
</div>
)}
</div>
); );
}; };
File diff suppressed because it is too large. Load diff
+276 -39
View File
@@ -1,27 +1,23 @@
/* ==========================================================================
MINDSIFT THEME ENGINE — ZENTRALE EINBINDUNG
Jedes Theme ist eine eigenständige CSS-Datei unter src/themes/ mit 6 Gruppen:
1. Akzentfarbe
2. Oberflächen & Hintergründe
3. Text & Kontraste
4. Rahmen, Linien & Schatten
5. Status- & Signalfarben
6. Rundungen (Geometrie)
========================================================================== */
@import './themes/theme-dark.css';
@import './themes/theme-light.css';
@import './themes/theme-high-contrast.css';
@import './themes/theme-soft.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root {
/* BRAIN.md Strict Gemini Dark Theme Tokens */
--bg-app: #0b0f19;
--bg-surface: #131314;
--bg-card: #1e1f20;
--bg-card-hover: #282a2c;
--border-subtle: rgba(255, 255, 255, 0.08);
--border-focus: rgba(138, 180, 248, 0.40);
--accent-blue: #8ab4f8;
--accent-purple: #c58af9;
--swipe-keep: #81c995;
--swipe-discard: #f28b82;
--text-primary: #e3e3e3;
--text-muted: #9aa0a6;
--text-subtle: #5f6368;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
@@ -29,15 +25,16 @@
body { body {
background-color: var(--bg-app); background-color: var(--bg-app);
color: var(--text-primary); color: var(--text-main);
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif; font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow-x: hidden; overflow-x: hidden;
transition: background-color 140ms ease, color 140ms ease;
} }
/* Subtle custom dark scrollbar */ /* Scrollbar über Theme-Variablen */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 5px;
height: 6px; height: 5px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
@@ -45,25 +42,265 @@ body {
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.12); background: var(--scrollbar-thumb);
border-radius: 9999px; border-radius: var(--radius-btn);
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(138, 180, 248, 0.35); background: var(--scrollbar-thumb-hover);
} }
.gemini-border { /* ==========================================================================
border: 1px solid var(--border-subtle); SEMANTISCHE KOMPONENTEN-KLASSEN (100% Theme-gesteuert)
} ========================================================================== */
.gemini-input-glow:focus-within { @layer components {
border-color: rgba(138, 180, 248, 0.55); /* Haupt-Karten, Sidebars und Container */
box-shadow: 0 0 32px -4px rgba(138, 180, 248, 0.25), 0 0 12px -2px rgba(197, 138, 249, 0.2); .panel-surface {
} background-color: var(--bg-panel);
border: 1px solid var(--border-color);
border-radius: var(--radius-card);
}
.gemini-gradient-text { .panel-elevated {
background: linear-gradient(135deg, #8ab4f8 0%, #c58af9 55%, #81c995 100%); background-color: var(--bg-panel-elevated);
-webkit-background-clip: text; border: 1px solid var(--border-color);
-webkit-text-fill-color: transparent; border-radius: var(--radius-card);
}
.panel-header {
border-bottom: 1px solid var(--border-color);
}
/* Automatische Übernahme von --radius-card auf gerahmten Panels & Boxen */
.bg-panel.border,
.bg-canvas.border {
border-radius: var(--radius-card);
}
/* Quick-Capture-Blase & Profil-Pill im Header */
.capture-pill {
border-radius: var(--radius-pill);
}
/* Aufgaben-Checkboxen */
.checkbox-square {
border-radius: var(--radius-checkbox);
}
/* Standard-Aktionsbutton */
.btn-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
height: 1.75rem;
padding-left: 0.625rem;
padding-right: 0.625rem;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 500;
transition: all 120ms ease;
cursor: pointer;
}
.btn-action:hover {
background-color: var(--bg-panel-elevated);
color: var(--text-main);
}
/* Primärer Akzent-Button */
.btn-primary {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
height: 1.75rem;
padding-left: 0.75rem;
padding-right: 0.75rem;
background-color: var(--accent-primary);
border: 1px solid var(--accent-border);
border-radius: var(--radius-btn);
color: var(--text-on-accent);
font-size: 0.75rem;
font-weight: 500;
transition: all 120ms ease;
cursor: pointer;
}
.btn-primary:hover {
background-color: var(--accent-hover);
}
/* Kleiner Icon-Button */
.btn-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
color: var(--text-muted);
transition: all 120ms ease;
cursor: pointer;
}
.btn-icon:hover {
background-color: var(--bg-panel-elevated);
color: var(--text-main);
}
.btn-icon-danger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
color: var(--text-muted);
transition: all 120ms ease;
cursor: pointer;
}
.btn-icon-danger:hover {
background-color: var(--bg-danger);
color: var(--color-danger);
border-color: var(--border-danger);
}
/* Zentrales Texteingabefeld */
.input-field {
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-input);
color: var(--text-main);
font-size: 0.75rem;
transition: border-color 120ms ease;
}
.input-field:focus {
outline: none;
border-color: var(--accent-primary);
}
.input-field::placeholder {
color: var(--text-faint);
}
/* Header Tabs */
.tab-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.875rem;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-muted);
border-bottom: 2px solid transparent;
transition: all 120ms ease;
cursor: pointer;
}
.tab-btn:hover {
color: var(--text-main);
background-color: var(--bg-panel-elevated);
}
.tab-btn-active {
color: var(--accent-primary);
border-bottom-color: var(--accent-primary);
background-color: var(--bg-panel-elevated);
}
/* Status Badges */
.badge-accent {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
background-color: var(--accent-dim);
border: 1px solid var(--accent-border);
border-radius: var(--radius-btn);
color: var(--accent-primary);
font-size: 0.75rem;
font-weight: 500;
}
.badge-neutral {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
background-color: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: var(--radius-btn);
color: var(--text-muted);
font-size: 0.65rem;
font-weight: 500;
}
.badge-ok {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
background-color: var(--bg-success);
border: 1px solid var(--border-success);
border-radius: var(--radius-btn);
color: var(--color-success);
font-size: 0.65rem;
font-weight: 500;
}
.badge-err {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
background-color: var(--bg-danger);
border: 1px solid var(--border-danger);
border-radius: var(--radius-btn);
color: var(--color-danger);
font-size: 0.65rem;
font-weight: 500;
}
/* Status Alert Boxen */
.alert-ok {
padding: 0.75rem;
background-color: var(--bg-success);
border: 1px solid var(--border-success);
border-radius: var(--radius-card);
color: var(--text-main);
font-size: 0.75rem;
}
.alert-err {
padding: 0.75rem;
background-color: var(--bg-danger);
border: 1px solid var(--border-danger);
border-radius: var(--radius-card);
color: var(--color-danger);
font-size: 0.75rem;
}
/* Code- & Terminal-Blöcke */
.code-block {
background-color: var(--bg-code);
border: 1px solid var(--border-color);
border-radius: var(--radius-card);
color: var(--text-main);
}
/* Modal-Hintergrund-Overlay */
.modal-overlay {
background-color: var(--bg-overlay);
backdrop-filter: blur(4px);
}
/* File Tree Item */
.tree-node-active {
background-color: var(--bg-panel-elevated);
border-left: 2px solid var(--accent-primary);
color: var(--text-main);
}
} }
+365 -28
View File
@@ -1,14 +1,20 @@
import { import {
APIKey, APIKey,
AuthStatus,
DashboardStats, DashboardStats,
Document, Document,
DocumentBlock, DocumentBlock,
ListItem, ListItem,
Organization,
PriorityLevel, PriorityLevel,
SharedList, SharedList,
Space, Space,
SystemSettings,
Team,
TaskList,
Thought, Thought,
User, User,
UserInvite,
} from './types'; } from './types';
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token'; const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
@@ -36,10 +42,15 @@ export function triggerHaptic(pattern: number | number[] = 15): void {
} }
} }
function ensureArray<T>(val: T[] | null | undefined): T[] {
return Array.isArray(val) ? val : [];
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> { async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken(); const token = getToken();
const headers: Record<string, string> = { const headers: Record<string, string> = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Cache-Control': 'no-cache',
...(options.headers as Record<string, string>), ...(options.headers as Record<string, string>),
}; };
@@ -47,10 +58,31 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
headers['Authorization'] = `Bearer ${token}`; headers['Authorization'] = `Bearer ${token}`;
} }
const res = await fetch(path, { const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 12000);
let res: Response;
try {
res = await fetch(path, {
...options, ...options,
headers, headers,
cache: 'no-store',
signal: controller.signal,
}); });
} finally {
window.clearTimeout(timeoutId);
}
if (
res.status === 401 &&
!path.includes('/auth/login') &&
!path.includes('/auth/register') &&
!path.includes('/auth/setup') &&
!path.includes('/auth/status')
) {
clearToken();
window.dispatchEvent(new CustomEvent('mindsift:unauthorized'));
}
if (!res.ok) { if (!res.ok) {
let errMsg = `HTTP ${res.status}`; let errMsg = `HTTP ${res.status}`;
@@ -67,10 +99,23 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
} }
export const api = { export const api = {
// --- AUTH --- // --- AUTH, FIRST-BOOT SETUP & SETTINGS ---
async quickStart(): Promise<{ token: string; user: User }> { async getAuthStatus(): Promise<AuthStatus> {
const res = await request<{ token: string; user: User }>('/api/v1/auth/quickstart', { return request<AuthStatus>('/api/v1/auth/status');
},
async completeSetup(payload: {
email: string;
password: string;
displayName: string;
instanceName?: string;
sharingMode?: 'open' | 'teams_only' | 'strict_private';
allowRegistration?: boolean;
createSampleData?: boolean;
}): Promise<{ token: string; user: User }> {
const res = await request<{ token: string; user: User }>('/api/v1/auth/setup', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload),
}); });
setToken(res.token); setToken(res.token);
return res; return res;
@@ -85,10 +130,15 @@ export const api = {
return res; return res;
}, },
async register(email: string, password: string, displayName: string): Promise<{ token: string; user: User }> { async register(
email: string,
password: string,
displayName: string,
inviteCode?: string
): Promise<{ token: string; user: User }> {
const res = await request<{ token: string; user: User }>('/api/v1/auth/register', { const res = await request<{ token: string; user: User }>('/api/v1/auth/register', {
method: 'POST', method: 'POST',
body: JSON.stringify({ email, password, displayName }), body: JSON.stringify({ email, password, displayName, inviteCode: inviteCode || '' }),
}); });
setToken(res.token); setToken(res.token);
return res; return res;
@@ -98,24 +148,68 @@ export const api = {
return request<{ user: User; stats: DashboardStats }>('/api/v1/auth/me'); return request<{ user: User; stats: DashboardStats }>('/api/v1/auth/me');
}, },
async updateSettings(patch: {
displayName?: string;
email?: string;
password?: string;
alarmChannels?: string[];
alarmEmail?: string;
discordWebhookUrl?: string;
customWebhookUrl?: string;
notifyBeforeMinutes?: number;
}): Promise<User> {
return request<User>('/api/v1/auth/settings', {
method: 'PATCH',
body: JSON.stringify(patch),
});
},
async testAlarmChannel(
channel: 'email' | 'discord' | 'webhook',
target?: string
): Promise<{ status: string }> {
return request<{ status: string }>('/api/v1/auth/alarms/test', {
method: 'POST',
body: JSON.stringify({ channel, target: target || '' }),
});
},
async getStats(): Promise<DashboardStats> { async getStats(): Promise<DashboardStats> {
return request<DashboardStats>('/api/v1/stats'); return request<DashboardStats>('/api/v1/stats');
}, },
async getUsersDirectory(): Promise<User[]> {
const res = await request<User[]>('/api/v1/users/directory');
return ensureArray(res);
},
// --- INSTANT CAPTURE & OFFLINE SYNC --- // --- INSTANT CAPTURE & OFFLINE SYNC ---
async captureThought( async captureThought(
content: string, content: string,
priority: PriorityLevel = 'normal', priority: PriorityLevel = 'normal',
source: 'manual' | 'voice' = 'manual', source: 'manual' | 'voice' = 'manual',
dueDate = '' dueDate = '',
options?: {
target?: 'inbox' | 'task' | 'list_item' | 'document';
targetListId?: string;
taskListId?: string;
}
): Promise<Thought> { ): Promise<Thought> {
try { try {
const thought = await request<Thought>('/api/v1/inbox', { const thought = await request<Thought>('/api/v1/inbox', {
method: 'POST', method: 'POST',
body: JSON.stringify({ content, priority, source, dueDate }), body: JSON.stringify({
content,
priority,
source,
dueDate,
target: options?.target || 'inbox',
targetListId: options?.targetListId || '',
taskListId: options?.taskListId || '',
}),
}); });
return thought; return thought;
} catch (err) { } catch {
// Queue locally if offline // Queue locally if offline
const queue = getOfflineQueue(); const queue = getOfflineQueue();
const offlineThought: Thought = { const offlineThought: Thought = {
@@ -128,6 +222,7 @@ export const api = {
status: 'inbox', status: 'inbox',
isCompleted: false, isCompleted: false,
dueDate, dueDate,
taskListId: options?.taskListId || '',
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}; };
queue.push(offlineThought); queue.push(offlineThought);
@@ -152,6 +247,7 @@ export const api = {
priority: item.priority, priority: item.priority,
source: item.source, source: item.source,
dueDate: item.dueDate || '', dueDate: item.dueDate || '',
taskListId: item.taskListId || '',
}), }),
}); });
synced++; synced++;
@@ -166,11 +262,13 @@ export const api = {
// --- THOUGHTS, TRIAGE & TASKS --- // --- THOUGHTS, TRIAGE & TASKS ---
async getTriageDeck(): Promise<Thought[]> { async getTriageDeck(): Promise<Thought[]> {
return request<Thought[]>('/api/v1/triage/deck'); const res = await request<Thought[]>('/api/v1/triage/deck');
return ensureArray(res);
}, },
async getThoughts(status = 'all'): Promise<Thought[]> { async getThoughts(status = 'all'): Promise<Thought[]> {
return request<Thought[]>(`/api/v1/thoughts?status=${encodeURIComponent(status)}`); const res = await request<Thought[]>(`/api/v1/thoughts?status=${encodeURIComponent(status)}`);
return ensureArray(res);
}, },
async triageAction( async triageAction(
@@ -179,6 +277,7 @@ export const api = {
options?: { options?: {
convertTo?: 'task' | 'document' | 'list_item'; convertTo?: 'task' | 'document' | 'list_item';
targetListId?: string; targetListId?: string;
taskListId?: string;
dueDate?: string; dueDate?: string;
} }
): Promise<Thought> { ): Promise<Thought> {
@@ -188,18 +287,29 @@ export const api = {
action, action,
convertTo: options?.convertTo || '', convertTo: options?.convertTo || '',
targetListId: options?.targetListId || '', targetListId: options?.targetListId || '',
taskListId: options?.taskListId || '',
dueDate: options?.dueDate || '', dueDate: options?.dueDate || '',
}), }),
}); });
}, },
async getTasks(): Promise<Thought[]> { async getTasks(taskListId?: string): Promise<Thought[]> {
return request<Thought[]>('/api/v1/tasks'); const query = taskListId ? `?taskListId=${encodeURIComponent(taskListId)}` : '';
const res = await request<Thought[]>(`/api/v1/tasks${query}`);
return ensureArray(res);
}, },
async updateThought( async updateThought(
thoughtId: string, thoughtId: string,
patch: { content?: string; isCompleted?: boolean; dueDate?: string; priority?: PriorityLevel } patch: {
content?: string;
isCompleted?: boolean;
dueDate?: string;
priority?: PriorityLevel;
taskListId?: string;
kanbanStatus?: 'todo' | 'in_progress' | 'done';
notes?: string;
}
): Promise<Thought> { ): Promise<Thought> {
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, { return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
method: 'PATCH', method: 'PATCH',
@@ -215,26 +325,79 @@ export const api = {
return request<{ purged: number }>('/api/v1/thoughts/discarded/purge', { method: 'DELETE' }); return request<{ purged: number }>('/api/v1/thoughts/discarded/purge', { method: 'DELETE' });
}, },
// --- TASK LISTS ---
async getTaskLists(): Promise<TaskList[]> {
const res = await request<TaskList[]>('/api/v1/task-lists');
return ensureArray(res);
},
async createTaskList(payload: {
title: string;
icon?: string;
color?: string;
spaceId?: string;
}): Promise<TaskList> {
return request<TaskList>('/api/v1/task-lists', {
method: 'POST',
body: JSON.stringify(payload),
});
},
async updateTaskList(
id: string,
patch: {
title?: string;
icon?: string;
color?: string;
spaceId?: string;
}
): Promise<TaskList> {
return request<TaskList>(`/api/v1/task-lists/${id}`, {
method: 'PUT',
body: JSON.stringify(patch),
});
},
async deleteTaskList(id: string): Promise<void> {
await request(`/api/v1/task-lists/${id}`, { method: 'DELETE' });
},
// --- WORKSPACE DOCUMENTS --- // --- WORKSPACE DOCUMENTS ---
async getDocuments(): Promise<Document[]> { async getDocuments(): Promise<Document[]> {
return request<Document[]>('/api/v1/documents'); const res = await request<Document[]>('/api/v1/documents');
return ensureArray(res).map((d) => ({
...d,
blocks: ensureArray(d.blocks),
tags: ensureArray(d.tags),
}));
}, },
async getDocument(id: string): Promise<Document> { async getDocument(id: string): Promise<Document> {
return request<Document>(`/api/v1/documents/${id}`); const d = await request<Document>(`/api/v1/documents/${id}`);
return {
...d,
blocks: ensureArray(d.blocks),
tags: ensureArray(d.tags),
};
}, },
async createDocument(payload: { async createDocument(payload: {
title: string; title: string;
icon?: string; icon?: string;
folderPath?: string;
blocks?: DocumentBlock[]; blocks?: DocumentBlock[];
tags?: string[]; tags?: string[];
isPinned?: boolean; isPinned?: boolean;
}): Promise<Document> { }): Promise<Document> {
return request<Document>('/api/v1/documents', { const d = await request<Document>('/api/v1/documents', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
return {
...d,
blocks: ensureArray(d.blocks),
tags: ensureArray(d.tags),
};
}, },
async updateDocument( async updateDocument(
@@ -242,15 +405,21 @@ export const api = {
patch: { patch: {
title?: string; title?: string;
icon?: string; icon?: string;
folderPath?: string;
blocks?: DocumentBlock[]; blocks?: DocumentBlock[];
tags?: string[]; tags?: string[];
isPinned?: boolean; isPinned?: boolean;
} }
): Promise<Document> { ): Promise<Document> {
return request<Document>(`/api/v1/documents/${id}`, { const d = await request<Document>(`/api/v1/documents/${id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify(patch), body: JSON.stringify(patch),
}); });
return {
...d,
blocks: ensureArray(d.blocks),
tags: ensureArray(d.tags),
};
}, },
async deleteDocument(id: string): Promise<void> { async deleteDocument(id: string): Promise<void> {
@@ -259,7 +428,11 @@ export const api = {
// --- SPACES & REALTIME LISTS --- // --- SPACES & REALTIME LISTS ---
async getSpaces(): Promise<Space[]> { async getSpaces(): Promise<Space[]> {
return request<Space[]>('/api/v1/spaces'); const res = await request<Space[]>('/api/v1/spaces');
return ensureArray(res).map((s) => ({
...s,
members: ensureArray(s.members),
}));
}, },
async createSpace(name: string, icon: string, description: string): Promise<Space> { async createSpace(name: string, icon: string, description: string): Promise<Space> {
@@ -277,18 +450,30 @@ export const api = {
}, },
async getSpaceLists(spaceId: string): Promise<SharedList[]> { async getSpaceLists(spaceId: string): Promise<SharedList[]> {
return request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`); const res = await request<SharedList[]>(`/api/v1/spaces/${spaceId}/lists`);
return ensureArray(res).map((l) => ({
...l,
items: ensureArray(l.items),
}));
}, },
async getAllLists(): Promise<SharedList[]> { async getAllLists(): Promise<SharedList[]> {
return request<SharedList[]>('/api/v1/lists'); const res = await request<SharedList[]>('/api/v1/lists');
return ensureArray(res).map((l) => ({
...l,
items: ensureArray(l.items),
}));
}, },
async createList(spaceId: string, title: string, icon: string, listType: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> { async createList(spaceId: string, title: string, icon: string, listType: 'shopping' | 'checklist' | 'vacation'): Promise<SharedList> {
return request<SharedList>(`/api/v1/spaces/${spaceId}/lists`, { const l = await request<SharedList>(`/api/v1/spaces/${spaceId}/lists`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ title, icon, listType }), body: JSON.stringify({ title, icon, listType }),
}); });
return {
...l,
items: ensureArray(l.items),
};
}, },
async deleteList(listId: string): Promise<void> { async deleteList(listId: string): Promise<void> {
@@ -302,7 +487,7 @@ export const api = {
}); });
}, },
async toggleListItem(listId: string, itemId: string, isCompleted: boolean): Promise<ListItem> { async toggleListItem(listId: string, itemId: string, isCompleted?: boolean): Promise<ListItem> {
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, { return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify({ isCompleted }), body: JSON.stringify({ isCompleted }),
@@ -319,9 +504,151 @@ export const api = {
}); });
}, },
// --- ORGANIZATIONS & TEAMS ---
async getOrganizations(): Promise<Organization[]> {
const res = await request<Organization[]>('/api/v1/organizations');
return ensureArray(res).map((o) => ({
...o,
teams: ensureArray(o.teams).map((t) => ({
...t,
members: ensureArray(t.members),
})),
}));
},
async createOrganization(name: string): Promise<Organization> {
return request<Organization>('/api/v1/organizations', {
method: 'POST',
body: JSON.stringify({ name }),
});
},
async deleteOrganization(id: string): Promise<void> {
await request(`/api/v1/organizations/${id}`, { method: 'DELETE' });
},
async getOrgTeams(orgId: string): Promise<Team[]> {
const res = await request<Team[]>(`/api/v1/organizations/${orgId}/teams`);
return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) }));
},
async createOrgTeam(orgId: string, name: string, description: string): Promise<Team> {
return request<Team>(`/api/v1/organizations/${orgId}/teams`, {
method: 'POST',
body: JSON.stringify({ name, description }),
});
},
async deleteTeam(teamId: string): Promise<void> {
await request(`/api/v1/teams/${teamId}`, { method: 'DELETE' });
},
async getUserTeams(): Promise<Team[]> {
const res = await request<Team[]>('/api/v1/teams');
return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) }));
},
async addTeamMember(
teamId: string,
payload: { userId?: string; email?: string; role: string }
): Promise<void> {
await request(`/api/v1/teams/${teamId}/members`, {
method: 'POST',
body: JSON.stringify(payload),
});
},
async removeTeamMember(teamId: string, userId: string): Promise<void> {
await request(`/api/v1/teams/${teamId}/members/${userId}`, { method: 'DELETE' });
},
// --- ADMIN & STAFF PANEL ---
async adminGetUsers(): Promise<User[]> {
const res = await request<User[]>('/api/v1/admin/users');
return ensureArray(res);
},
async adminCreateUser(payload: {
email: string;
password: string;
displayName: string;
role: string;
}): Promise<User> {
return request<User>('/api/v1/admin/users', {
method: 'POST',
body: JSON.stringify(payload),
});
},
async adminUpdateUser(
id: string,
patch: {
displayName?: string;
email?: string;
role?: string;
isActive?: boolean;
discordWebhookUrl?: string;
}
): Promise<User> {
return request<User>(`/api/v1/admin/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(patch),
});
},
async adminResetPassword(id: string, password: string): Promise<void> {
await request(`/api/v1/admin/users/${id}/password`, {
method: 'PUT',
body: JSON.stringify({ password }),
});
},
async adminDeleteUser(id: string): Promise<void> {
await request(`/api/v1/admin/users/${id}`, { method: 'DELETE' });
},
async adminGetInvites(): Promise<UserInvite[]> {
const res = await request<UserInvite[]>('/api/v1/admin/invites');
return ensureArray(res);
},
async adminCreateInvite(payload: {
email?: string;
role: string;
teamId?: string;
}): Promise<UserInvite> {
return request<UserInvite>('/api/v1/admin/invites', {
method: 'POST',
body: JSON.stringify(payload),
});
},
async adminDeleteInvite(id: string): Promise<void> {
await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' });
},
async adminGetSettings(): Promise<SystemSettings> {
return request<SystemSettings>('/api/v1/admin/settings');
},
async adminUpdateSettings(settings: SystemSettings): Promise<SystemSettings> {
return request<SystemSettings>('/api/v1/admin/settings', {
method: 'PUT',
body: JSON.stringify(settings),
});
},
async adminTestSMTP(toEmail: string): Promise<{ status: string }> {
return request<{ status: string }>('/api/v1/admin/smtp/test', {
method: 'POST',
body: JSON.stringify({ toEmail }),
});
},
// --- API KEYS, WEBHOOKS & EXPORT --- // --- API KEYS, WEBHOOKS & EXPORT ---
async getAPIKeys(): Promise<APIKey[]> { async getAPIKeys(): Promise<APIKey[]> {
return request<APIKey[]>('/api/v1/keys'); const res = await request<APIKey[]>('/api/v1/keys');
return ensureArray(res);
}, },
async createAPIKey(name: string): Promise<APIKey> { async createAPIKey(name: string): Promise<APIKey> {
@@ -335,15 +662,25 @@ export const api = {
await request(`/api/v1/keys/${id}`, { method: 'DELETE' }); await request(`/api/v1/keys/${id}`, { method: 'DELETE' });
}, },
async testWebhookIngest(token: string, title: string, priority: PriorityLevel, tags: string[]): Promise<{ status: string; thought: Thought }> { async testWebhookIngest(
return request<{ status: string; thought: Thought }>(`/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`, { token: string,
title: string,
priority: PriorityLevel,
tags: string[]
): Promise<{ status: string; thought: Thought }> {
return request<{ status: string; thought: Thought }>(
`/api/v1/webhooks/ingest?token=${encodeURIComponent(token)}`,
{
method: 'POST', method: 'POST',
body: JSON.stringify({ title, priority, tags }), body: JSON.stringify({ title, priority, tags }),
}); }
);
}, },
async searchAll(q: string): Promise<{ thoughts: Thought[]; documents: Document[] }> { async searchAll(q: string): Promise<{ thoughts: Thought[]; documents: Document[] }> {
return request<{ thoughts: Thought[]; documents: Document[] }>(`/api/v1/search?q=${encodeURIComponent(q)}`); return request<{ thoughts: Thought[]; documents: Document[] }>(
`/api/v1/search?q=${encodeURIComponent(q)}`
);
}, },
async exportAll(): Promise<any> { async exportAll(): Promise<any> {
+93
View File
@@ -0,0 +1,93 @@
export type ThemeId = 'dark' | 'light' | 'high-contrast' | 'soft';
export interface ThemeOption {
id: ThemeId;
name: string;
subtitle: string;
file: string;
previewBg: string;
previewPanel: string;
previewAccent: string;
previewText: string;
isRounded: boolean;
}
export const THEME_STORAGE_KEY = 'mindsift_active_theme';
export const AVAILABLE_THEMES: ThemeOption[] = [
{
id: 'dark',
name: 'MindSift Dark (Standard)',
subtitle: 'Monochrom, dunkel & eckig (0px)',
file: 'src/themes/theme-dark.css',
previewBg: '#121316',
previewPanel: '#1a1c20',
previewAccent: '#2563eb',
previewText: '#f4f4f5',
isRounded: false,
},
{
id: 'light',
name: 'MindSift Light (Tagmodus)',
subtitle: 'Helles Papier-Design & klare Kontraste',
file: 'src/themes/theme-light.css',
previewBg: '#f3f4f6',
previewPanel: '#ffffff',
previewAccent: '#2563eb',
previewText: '#111827',
isRounded: false,
},
{
id: 'high-contrast',
name: 'High Contrast (Scharf)',
subtitle: 'Tiefschwarz (#000) & weiße Konturen',
file: 'src/themes/theme-high-contrast.css',
previewBg: '#000000',
previewPanel: '#09090b',
previewAccent: '#38bdf8',
previewText: '#ffffff',
isRounded: false,
},
{
id: 'soft',
name: 'Soft Slate (Abgerundet)',
subtitle: 'Schiefer-Blau mit weichen Radien (10px)',
file: 'src/themes/theme-soft.css',
previewBg: '#0f172a',
previewPanel: '#1e293b',
previewAccent: '#3b82f6',
previewText: '#f8fafc',
isRounded: true,
},
];
export function getActiveTheme(): ThemeId {
try {
const saved = localStorage.getItem(THEME_STORAGE_KEY) as ThemeId | null;
if (saved && AVAILABLE_THEMES.some((t) => t.id === saved)) {
return saved;
}
} catch {
// ignore
}
return 'dark';
}
export function setActiveTheme(themeId: ThemeId): void {
try {
localStorage.setItem(THEME_STORAGE_KEY, themeId);
} catch {
// ignore
}
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', themeId);
}
}
export function initThemeOnBoot(): ThemeId {
const active = getActiveTheme();
if (typeof document !== 'undefined') {
document.documentElement.setAttribute('data-theme', active);
}
return active;
}
+88 -1
View File
@@ -1,13 +1,39 @@
export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted'; export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted';
export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent'; export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent';
export type UserRole = 'admin' | 'team_admin' | 'member';
export interface AuthStatus {
needsSetup: boolean;
allowRegistration: boolean;
instanceName: string;
}
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
displayName: string; displayName: string;
role: UserRole;
isActive: boolean;
alarmChannels?: string[];
alarmEmail?: string;
discordWebhookUrl?: string;
customWebhookUrl?: string;
notifyBeforeMinutes?: number;
createdAt: string; createdAt: string;
} }
export interface UserInvite {
id: string;
code: string;
email?: string;
role: UserRole;
teamId?: string;
createdBy: string;
usedBy?: string;
createdAt: string;
expiresAt: string;
}
export interface Thought { export interface Thought {
id: string; id: string;
userId: string; userId: string;
@@ -20,11 +46,69 @@ export interface Thought {
convertedType?: 'task' | 'document' | 'list_item'; convertedType?: 'task' | 'document' | 'list_item';
convertedRefId?: string; convertedRefId?: string;
dueDate?: string; dueDate?: string;
taskListId?: string;
kanbanStatus?: 'todo' | 'in_progress' | 'done';
notes?: string;
alarmSent?: boolean;
createdAt: string; createdAt: string;
triagedAt?: string; triagedAt?: string;
} }
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider'; export interface TaskList {
id: string;
userId: string;
spaceId?: string;
spaceName?: string;
ownerName?: string;
members?: SpaceMember[];
title: string;
icon: string;
color: string;
tasksCount?: number;
createdAt: string;
updatedAt: string;
}
export interface Organization {
id: string;
name: string;
ownerId: string;
teams?: Team[];
createdAt: string;
}
export interface Team {
id: string;
orgId: string;
name: string;
description: string;
members?: TeamMember[];
createdAt: string;
}
export interface TeamMember {
teamId: string;
userId: string;
displayName: string;
email: string;
role: 'owner' | 'team_admin' | 'editor' | 'member' | 'viewer';
joinedAt: string;
}
export interface SystemSettings {
sharingMode: 'open' | 'teams_only' | 'strict_private';
allowRegistration: boolean;
instanceName: string;
smtpHost?: string;
smtpPort?: number;
smtpUser?: string;
smtpPass?: string;
smtpFrom?: string;
smtpTls?: boolean;
smtpConfiguredViaEnv?: boolean;
}
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
export interface DocumentBlock { export interface DocumentBlock {
id: string; id: string;
@@ -32,6 +116,8 @@ export interface DocumentBlock {
content: string; content: string;
checked?: boolean; checked?: boolean;
lang?: string; lang?: string;
level?: number;
collapsed?: boolean;
} }
export interface Document { export interface Document {
@@ -43,6 +129,7 @@ export interface Document {
content: string; content: string;
blocks: DocumentBlock[]; blocks: DocumentBlock[];
tags: string[]; tags: string[];
folderPath?: string;
isPinned: boolean; isPinned: boolean;
isArchived: boolean; isArchived: boolean;
createdAt: string; createdAt: string;
+56
View File
@@ -0,0 +1,56 @@
/* ==========================================================================
MINDSIFT THEME: DARK MONOCHROME (Standard)
Alle Farben, Kontraste und Rundungen zentral in einer einzigen Datei.
========================================================================== */
:root,
:root[data-theme="dark"] {
/* --- 1. AKZENTFARBE --- */
--accent-primary: #2563eb;
--accent-hover: #3b82f6;
--accent-dim: rgba(37, 99, 235, 0.14);
--accent-border: rgba(59, 130, 246, 0.45);
--text-on-accent: #ffffff;
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #121316;
--bg-panel: #1a1c20;
--bg-panel-elevated: #22252a;
--bg-panel-hover: #2a2e35;
--bg-input: #121316;
--bg-overlay: rgba(0, 0, 0, 0.72);
--bg-code: #0d0e11;
/* --- 3. TEXT & KONTRASTE --- */
--text-main: #f4f4f5;
--text-muted: #a1a1aa;
--text-faint: #71717a;
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
--border-color: rgba(255, 255, 255, 0.09);
--border-subtle: rgba(255, 255, 255, 0.05);
--border-strong: rgba(255, 255, 255, 0.22);
--shadow-panel: 0 10px 30px rgba(0, 0, 0, 0.45);
--scrollbar-thumb: rgba(255, 255, 255, 0.14);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.26);
/* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #22c55e;
--bg-success: rgba(34, 197, 94, 0.12);
--border-success: rgba(34, 197, 94, 0.38);
--color-danger: #ef4444;
--bg-danger: rgba(239, 68, 68, 0.12);
--border-danger: rgba(239, 68, 68, 0.38);
--color-warning: #f59e0b;
--bg-warning: rgba(245, 158, 11, 0.12);
--border-warning: rgba(245, 158, 11, 0.38);
/* --- 6. RUNDUNGEN (Geometrie) --- */
--radius-card: 0px;
--radius-btn: 2px;
--radius-input: 2px;
--radius-checkbox: 0px;
--radius-pill: 9999px;
}
@@ -0,0 +1,55 @@
/* ==========================================================================
MINDSIFT THEME: HIGH CONTRAST (Maximaler Kontrast & Schärfe)
Alle Farben, Kontraste und Rundungen zentral in einer einzigen Datei.
========================================================================== */
:root[data-theme="high-contrast"] {
/* --- 1. AKZENTFARBE --- */
--accent-primary: #38bdf8;
--accent-hover: #7dd3fc;
--accent-dim: rgba(56, 189, 248, 0.22);
--accent-border: #38bdf8;
--text-on-accent: #000000;
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #000000;
--bg-panel: #09090b;
--bg-panel-elevated: #18181b;
--bg-panel-hover: #27272a;
--bg-input: #000000;
--bg-overlay: rgba(0, 0, 0, 0.88);
--bg-code: #000000;
/* --- 3. TEXT & KONTRASTE --- */
--text-main: #ffffff;
--text-muted: #e4e4e7;
--text-faint: #a1a1aa;
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
--border-color: rgba(255, 255, 255, 0.38);
--border-subtle: rgba(255, 255, 255, 0.20);
--border-strong: #ffffff;
--shadow-panel: 0 0 0 1px #ffffff;
--scrollbar-thumb: rgba(255, 255, 255, 0.45);
--scrollbar-thumb-hover: #ffffff;
/* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #4ade80;
--bg-success: rgba(74, 222, 128, 0.20);
--border-success: #4ade80;
--color-danger: #f87171;
--bg-danger: rgba(248, 113, 113, 0.20);
--border-danger: #f87171;
--color-warning: #fbbf24;
--bg-warning: rgba(251, 191, 36, 0.20);
--border-warning: #fbbf24;
/* --- 6. RUNDUNGEN (Geometrie: Komplett scharfkantig) --- */
--radius-card: 0px;
--radius-btn: 0px;
--radius-input: 0px;
--radius-checkbox: 0px;
--radius-pill: 0px;
}
+55
View File
@@ -0,0 +1,55 @@
/* ==========================================================================
MINDSIFT THEME: LIGHT (Tagmodus / Hell & Klar)
Alle Farben, Kontraste und Rundungen zentral in einer einzigen Datei.
========================================================================== */
:root[data-theme="light"] {
/* --- 1. AKZENTFARBE --- */
--accent-primary: #2563eb;
--accent-hover: #1d4ed8;
--accent-dim: rgba(37, 99, 235, 0.10);
--accent-border: rgba(37, 99, 235, 0.38);
--text-on-accent: #ffffff;
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #f3f4f6;
--bg-panel: #ffffff;
--bg-panel-elevated: #ebedf0;
--bg-panel-hover: #e2e5ea;
--bg-input: #ffffff;
--bg-overlay: rgba(15, 23, 42, 0.45);
--bg-code: #f1f5f9;
/* --- 3. TEXT & KONTRASTE --- */
--text-main: #111827;
--text-muted: #4b5563;
--text-faint: #9ca3af;
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
--border-color: rgba(17, 24, 39, 0.13);
--border-subtle: rgba(17, 24, 39, 0.06);
--border-strong: rgba(17, 24, 39, 0.28);
--shadow-panel: 0 8px 24px rgba(15, 23, 42, 0.08);
--scrollbar-thumb: rgba(17, 24, 39, 0.18);
--scrollbar-thumb-hover: rgba(17, 24, 39, 0.32);
/* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #15803d;
--bg-success: rgba(22, 163, 74, 0.10);
--border-success: rgba(22, 163, 74, 0.35);
--color-danger: #b91c1c;
--bg-danger: rgba(220, 38, 38, 0.09);
--border-danger: rgba(220, 38, 38, 0.35);
--color-warning: #b45309;
--bg-warning: rgba(217, 119, 6, 0.10);
--border-warning: rgba(217, 119, 6, 0.35);
/* --- 6. RUNDUNGEN (Geometrie) --- */
--radius-card: 0px;
--radius-btn: 2px;
--radius-input: 2px;
--radius-checkbox: 0px;
--radius-pill: 9999px;
}
+56
View File
@@ -0,0 +1,56 @@
/* ==========================================================================
MINDSIFT THEME: SOFT SLATE (Abgerundete Geometrie & Sanftes Schiefer-Blau)
Demonstriert, wie über die Rundungs-Variablen in einer einzigen Datei
das gesamte App-Layout auf abgerundete Ecken umschaltet.
========================================================================== */
:root[data-theme="soft"] {
/* --- 1. AKZENTFARBE --- */
--accent-primary: #3b82f6;
--accent-hover: #60a5fa;
--accent-dim: rgba(59, 130, 246, 0.15);
--accent-border: rgba(96, 165, 250, 0.40);
--text-on-accent: #ffffff;
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #0f172a;
--bg-panel: #1e293b;
--bg-panel-elevated: #293548;
--bg-panel-hover: #334155;
--bg-input: #0f172a;
--bg-overlay: rgba(2, 6, 23, 0.72);
--bg-code: #090d16;
/* --- 3. TEXT & KONTRASTE --- */
--text-main: #f8fafc;
--text-muted: #94a3b8;
--text-faint: #64748b;
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
--border-color: rgba(148, 163, 184, 0.16);
--border-subtle: rgba(148, 163, 184, 0.08);
--border-strong: rgba(148, 163, 184, 0.32);
--shadow-panel: 0 12px 32px rgba(2, 6, 23, 0.40);
--scrollbar-thumb: rgba(148, 163, 184, 0.22);
--scrollbar-thumb-hover: rgba(148, 163, 184, 0.38);
/* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #10b981;
--bg-success: rgba(16, 185, 129, 0.14);
--border-success: rgba(16, 185, 129, 0.38);
--color-danger: #f43f5e;
--bg-danger: rgba(244, 63, 94, 0.14);
--border-danger: rgba(244, 63, 94, 0.38);
--color-warning: #f59e0b;
--bg-warning: rgba(245, 158, 11, 0.14);
--border-warning: rgba(245, 158, 11, 0.38);
/* --- 6. RUNDUNGEN (Weich abgerundete Ecken überall) --- */
--radius-card: 10px;
--radius-btn: 6px;
--radius-input: 6px;
--radius-checkbox: 4px;
--radius-pill: 9999px;
}
+56
View File
@@ -0,0 +1,56 @@
/* ==========================================================================
MINDSIFT THEME-VORLAGE (TEMPLATE)
Kopiere diese Datei, benenne [data-theme="mein-theme"] um und passe
die 6 Gruppen nach deinen Wünschen an.
========================================================================== */
:root[data-theme="custom-template"] {
/* --- 1. AKZENTFARBE (Buttons, aktive Tabs, Fokus-Ränder, Badges) --- */
--accent-primary: #2563eb;
--accent-hover: #3b82f6;
--accent-dim: rgba(37, 99, 235, 0.14);
--accent-border: rgba(59, 130, 246, 0.45);
--text-on-accent: #ffffff;
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
--bg-app: #121316; /* Seiten-Hintergrund (Canvas) */
--bg-panel: #1a1c20; /* Karten, Header, Sidebar, Dropdowns */
--bg-panel-elevated: #22252a; /* Hervorgehobene Elemente & aktive Zeilen */
--bg-panel-hover: #2a2e35; /* Hover-Effekt auf Oberflächen */
--bg-input: #121316; /* Textfelder, Selects, Textareas */
--bg-overlay: rgba(0, 0, 0, 0.72); /* Hintergrund bei Modals/Dialogen */
--bg-code: #0d0e11; /* Code-Blöcke & Terminal-Boxen */
/* --- 3. TEXT & KONTRASTE --- */
--text-main: #f4f4f5; /* Haupttext & Überschriften */
--text-muted: #a1a1aa; /* Sekundärtext & Beschreibungen */
--text-faint: #71717a; /* Platzhalter & dezente Hinweise */
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
--border-color: rgba(255, 255, 255, 0.09);
--border-subtle: rgba(255, 255, 255, 0.05);
--border-strong: rgba(255, 255, 255, 0.22);
--shadow-panel: 0 10px 30px rgba(0, 0, 0, 0.45);
--scrollbar-thumb: rgba(255, 255, 255, 0.14);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.26);
/* --- 5. STATUS- & SIGNALFARBEN --- */
--color-success: #22c55e;
--bg-success: rgba(34, 197, 94, 0.12);
--border-success: rgba(34, 197, 94, 0.38);
--color-danger: #ef4444;
--bg-danger: rgba(239, 68, 68, 0.12);
--border-danger: rgba(239, 68, 68, 0.38);
--color-warning: #f59e0b;
--bg-warning: rgba(245, 158, 11, 0.12);
--border-warning: rgba(245, 158, 11, 0.38);
/* --- 6. RUNDUNGEN (Geometrie) --- */
--radius-card: 0px; /* Karten, Panels, Container (0px = eckig) */
--radius-btn: 2px; /* Buttons & Badges */
--radius-input: 2px; /* Eingabefelder & Dropdowns */
--radius-checkbox: 0px; /* Aufgaben-Checkboxen */
--radius-pill: 9999px; /* Quick-Capture-Blase & Profil-Pill */
}
+66 -18
View File
@@ -5,28 +5,76 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
gemini: { canvas: 'var(--bg-app)',
bg: '#0b0f19', panel: {
surface: '#131314', DEFAULT: 'var(--bg-panel)',
card: '#1e1f20', elevated: 'var(--bg-panel-elevated)',
hover: '#282a2c', hover: 'var(--bg-panel-hover)',
blue: '#8ab4f8',
purple: '#c58af9',
keep: '#81c995',
discard: '#f28b82',
text: '#e3e3e3',
muted: '#9aa0a6',
subtle: '#5f6368',
}, },
surface: {
DEFAULT: 'var(--bg-panel)',
elevated: 'var(--bg-panel-elevated)',
hover: 'var(--bg-panel-hover)',
border: 'var(--border-color)',
}, },
fontFamily: { input: 'var(--bg-input)',
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'], overlay: 'var(--bg-overlay)',
mono: ['JetBrains Mono', 'Fira Code', 'monospace'], code: 'var(--bg-code)',
main: 'var(--text-main)',
muted: 'var(--text-muted)',
faint: 'var(--text-faint)',
'on-accent': 'var(--text-on-accent)',
accent: {
DEFAULT: 'var(--accent-primary)',
hover: 'var(--accent-hover)',
dim: 'var(--accent-dim)',
border: 'var(--accent-border)',
},
brand: {
DEFAULT: 'var(--accent-primary)',
hover: 'var(--accent-hover)',
dark: 'var(--accent-primary)',
},
status: {
ok: 'var(--color-success)',
'ok-bg': 'var(--bg-success)',
'ok-border': 'var(--border-success)',
err: 'var(--color-danger)',
'err-bg': 'var(--bg-danger)',
'err-border': 'var(--border-danger)',
warn: 'var(--color-warning)',
'warn-bg': 'var(--bg-warning)',
'warn-border': 'var(--border-warning)',
},
border: 'var(--border-color)',
},
borderColor: {
DEFAULT: 'var(--border-color)',
border: 'var(--border-color)',
subtle: 'var(--border-subtle)',
strong: 'var(--border-strong)',
},
borderRadius: {
card: 'var(--radius-card)',
btn: 'var(--radius-btn)',
input: 'var(--radius-input)',
checkbox: 'var(--radius-checkbox)',
pill: 'var(--radius-pill)',
}, },
boxShadow: { boxShadow: {
'gemini-glow': '0 0 28px -4px rgba(138, 180, 248, 0.22)', panel: 'var(--shadow-panel)',
'purple-glow': '0 0 32px -4px rgba(197, 138, 249, 0.35)', },
'card-depth': '0 20px 50px -12px rgba(0, 0, 0, 0.7)', fontFamily: {
sans: [
'Inter',
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'Roboto',
'sans-serif',
],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
}, },
}, },
}, },
+16
View File
@@ -10,3 +10,19 @@ require (
golang.org/x/crypto v0.31.0 golang.org/x/crypto v0.31.0
modernc.org/sqlite v1.34.4 modernc.org/sqlite v1.34.4
) )
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.28.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
+59
View File
@@ -0,0 +1,59 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.0 h1:Aj1EtB0qR2Rdo2dG4O94RIU35w2lvQSj6BRA4+qwFL0=
github.com/go-chi/chi/v5 v5.2.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+1021 -37
View File
File diff suppressed because it is too large. Load diff
+433
View File
@@ -0,0 +1,433 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/sandrodach/mindsift/internal/auth"
"github.com/sandrodach/mindsift/internal/config"
"github.com/sandrodach/mindsift/internal/domain"
"github.com/sandrodach/mindsift/internal/storage"
"github.com/sandrodach/mindsift/internal/ws"
)
func setupTestServer(t *testing.T) (*Server, *storage.Store, string) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test_mindsift.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to create test store: %v", err)
}
t.Cleanup(func() {
_ = store.Close()
})
user, err := store.EnsureDefaultUser()
if err != nil {
t.Fatalf("failed to ensure test user: %v", err)
}
_ = store.SeedStarterContent(user)
cfg := &config.Config{
Port: "8080",
DatabasePath: dbPath,
JWTSecret: "test-jwt-secret-key",
AllowRegistration: false,
}
hub := ws.NewHub()
srv := NewServer(cfg, store, hub, nil)
token, err := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.DisplayName)
if err != nil {
t.Fatalf("failed to generate token: %v", err)
}
return srv, store, token
}
func TestFirstBootSetupAndInviteRegistrationAndNonNullSlices(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "fresh_mindsift.db")
store, err := storage.NewStore(dbPath)
if err != nil {
t.Fatalf("failed to create fresh store: %v", err)
}
defer store.Close()
cfg := &config.Config{
Port: "8080",
DatabasePath: dbPath,
JWTSecret: "test-jwt-secret-key",
AllowRegistration: false,
}
srv := NewServer(cfg, store, ws.NewHub(), nil)
router := srv.Router()
// 1. Fresh DB must report needsSetup = true
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/status", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK from /api/v1/auth/status, got %d", rec.Code)
}
var statusResp struct {
NeedsSetup bool `json:"needsSetup"`
AllowRegistration bool `json:"allowRegistration"`
InstanceName string `json:"instanceName"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &statusResp)
if !statusResp.NeedsSetup {
t.Fatalf("expected needsSetup=true on fresh database, got false")
}
// 2. Complete First-Boot Setup (without sample data)
setupBody := []byte(`{
"email": "owner@example.com",
"password": "secretpassword",
"displayName": "Server Owner",
"instanceName": "My MindSift",
"allowRegistration": false,
"sharingMode": "teams_only",
"seedSampleData": false
}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/setup", bytes.NewReader(setupBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created from /api/v1/auth/setup, got %d: %s", rec.Code, rec.Body.String())
}
var setupRes struct {
Token string `json:"token"`
User domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &setupRes)
if setupRes.User.Role != "admin" || setupRes.Token == "" {
t.Fatalf("expected admin user and token, got %+v", setupRes)
}
// 3. Verify empty endpoints return [] and never null (preventing React crashes)
arrayEndpoints := []string{
"/api/v1/task-lists",
"/api/v1/tasks",
"/api/v1/organizations",
"/api/v1/teams",
"/api/v1/spaces",
"/api/v1/lists",
}
for _, ep := range arrayEndpoints {
req = httptest.NewRequest(http.MethodGet, ep, nil)
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET %s returned %d", ep, rec.Code)
}
trimmed := strings.TrimSpace(rec.Body.String())
if trimmed == "null" || !strings.HasPrefix(trimmed, "[") {
t.Fatalf("GET %s returned %q instead of JSON array []", ep, trimmed)
}
}
// 4. Public registration without invite must be rejected (403)
regBody := []byte(`{"email":"member@example.com","password":"password123","displayName":"Member"}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 Forbidden when registration is disabled, got %d", rec.Code)
}
// 5. Create an Invite as Admin and register with that Invite Code
invBody := []byte(`{"role":"team_admin","validDays":7}`)
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/invites", bytes.NewReader(invBody))
req.Header.Set("Authorization", "Bearer "+setupRes.Token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for invite, got %d: %s", rec.Code, rec.Body.String())
}
var createdInvite domain.UserInvite
_ = json.Unmarshal(rec.Body.Bytes(), &createdInvite)
if createdInvite.Code == "" {
t.Fatalf("expected invite code, got empty")
}
regWithInviteBody, _ := json.Marshal(map[string]string{
"email": "invited@example.com",
"password": "password123",
"displayName": "Invited Team Admin",
"inviteCode": createdInvite.Code,
})
req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regWithInviteBody))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created when registering with valid invite code, got %d: %s", rec.Code, rec.Body.String())
}
var invitedRes struct {
User domain.User `json:"user"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &invitedRes)
if invitedRes.User.Role != "team_admin" {
t.Fatalf("expected invited user to receive role 'team_admin', got %q", invitedRes.User.Role)
}
}
func TestConcurrentInitialLoadAndListsNoDeadlock(t *testing.T) {
srv, _, token := setupTestServer(t)
router := srv.Router()
endpoints := []string{
"/api/v1/auth/me",
"/api/v1/triage/deck",
"/api/v1/lists",
"/api/v1/spaces",
"/api/v1/tasks",
"/api/v1/documents",
}
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
for _, ep := range endpoints {
wg.Add(1)
go func(path string) {
defer wg.Done()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET %s returned %d: %s", path, rec.Code, rec.Body.String())
}
}(ep)
}
}
wg.Wait()
}
func TestQuickDumpToTriageAndDirectTargets(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
// Verify stale token from non-existent user is rejected (401)
staleToken, _ := auth.GenerateToken("test-jwt-secret-key", "usr_non_existent", "old@mindsift.local", "Old")
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
req.Header.Set("Authorization", "Bearer "+staleToken)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 Unauthorized for stale token, got %d", rec.Code)
}
// 1. Capture Quick Dump (default target: inbox)
dumpPayload := map[string]string{
"content": "Schneller Quick Dump Testgedanke #test",
"priority": "high",
"target": "inbox",
}
body, _ := json.Marshal(dumpPayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for Quick Dump, got %d: %s", rec.Code, rec.Body.String())
}
var createdDump domain.Thought
if err := json.Unmarshal(rec.Body.Bytes(), &createdDump); err != nil {
t.Fatalf("failed to decode created dump: %v", err)
}
if createdDump.Status != domain.StatusInbox {
t.Fatalf("expected status 'inbox', got %q", createdDump.Status)
}
// 2. Capture Direct Task (target: task)
taskPayload := map[string]string{
"content": "Direkte Aufgabe ohne Triage",
"priority": "urgent",
"target": "task",
"dueDate": "2026-11-01",
}
body, _ = json.Marshal(taskPayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for Direct Task, got %d: %s", rec.Code, rec.Body.String())
}
var createdTask domain.Thought
_ = json.Unmarshal(rec.Body.Bytes(), &createdTask)
if createdTask.Status != domain.StatusConverted || createdTask.ConvertedType != "task" {
t.Fatalf("expected converted task, got status=%q type=%q", createdTask.Status, createdTask.ConvertedType)
}
// 3. Capture Direct List Item (target: list_item)
defUser, _ := store.EnsureDefaultUser()
lists, err := store.GetAllListsForUser(defUser.ID)
if err != nil || len(lists) == 0 {
t.Fatalf("expected starter lists, got len=%d err=%v", len(lists), err)
}
targetListID := lists[0].ID
listPayload := map[string]string{
"content": "Frische Haferflocken",
"target": "list_item",
"targetListId": targetListID,
}
body, _ = json.Marshal(listPayload)
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for Direct List Item, got %d: %s", rec.Code, rec.Body.String())
}
var createdListItemThought domain.Thought
_ = json.Unmarshal(rec.Body.Bytes(), &createdListItemThought)
if createdListItemThought.ConvertedType != "list_item" || createdListItemThought.ConvertedRefID == "" {
t.Fatalf("expected converted list_item, got %+v", createdListItemThought)
}
// 4. Swipe Triage card to Task
triagePayload := map[string]string{
"action": "keep",
"convertTo": "task",
}
body, _ = json.Marshal(triagePayload)
req = httptest.NewRequest(http.MethodPatch, "/api/v1/triage/"+createdDump.ID, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for triage action, got %d: %s", rec.Code, rec.Body.String())
}
}
func TestNewFullFeatures(t *testing.T) {
srv, _, token := setupTestServer(t)
router := srv.Router()
// 1. Test TaskLists: Create & Get
taskListPayload := map[string]string{
"title": "Homelab & Docker",
"icon": "🐳",
"color": "#3b82f6",
}
body, _ := json.Marshal(taskListPayload)
req := httptest.NewRequest(http.MethodPost, "/api/v1/task-lists", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 Created for TaskList, got %d: %s", rec.Code, rec.Body.String())
}
var createdTaskList domain.TaskList
_ = json.Unmarshal(rec.Body.Bytes(), &createdTaskList)
if createdTaskList.Title != "Homelab & Docker" {
t.Fatalf("expected task list title 'Homelab & Docker', got %q", createdTaskList.Title)
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/task-lists", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for GET /api/v1/task-lists, got %d", rec.Code)
}
// 2. Test User Settings: Update Multi-Channel Alarms & Profile
settingsPayload := map[string]interface{}{
"displayName": "Admin User",
"alarmChannels": []string{"email", "discord", "webhook"},
"alarmEmail": "alarms@example.com",
"discordWebhookUrl": "https://discord.com/api/webhooks/test/123",
"customWebhookUrl": "https://ntfy.sh/my-topic",
"notifyBeforeMinutes": 15,
}
body, _ = json.Marshal(settingsPayload)
req = httptest.NewRequest(http.MethodPatch, "/api/v1/auth/settings", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for PATCH /api/v1/auth/settings, got %d: %s", rec.Code, rec.Body.String())
}
var updatedUser domain.User
_ = json.Unmarshal(rec.Body.Bytes(), &updatedUser)
if len(updatedUser.AlarmChannels) != 3 || updatedUser.AlarmEmail != "alarms@example.com" {
t.Fatalf("expected 3 alarm channels and alarmEmail, got %+v", updatedUser)
}
// 3. Test Admin routes (default user is admin)
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/users", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for GET /api/v1/admin/users, got %d: %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for GET /api/v1/admin/settings, got %d: %s", rec.Code, rec.Body.String())
}
// 4. Test Kanban & Task details update
taskUpdatePayload := map[string]interface{}{
"kanbanStatus": "in_progress",
"notes": "Detailnotizen für die Aufgabe",
"taskListId": createdTaskList.ID,
}
req = httptest.NewRequest(http.MethodPost, "/api/v1/inbox", bytes.NewReader([]byte(`{"content":"Kanban Task Test","target":"task"}`)))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
var taskThought domain.Thought
_ = json.Unmarshal(rec.Body.Bytes(), &taskThought)
body, _ = json.Marshal(taskUpdatePayload)
req = httptest.NewRequest(http.MethodPatch, "/api/v1/thoughts/"+taskThought.ID, bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200 OK for PATCH /api/v1/thoughts/{id}, got %d: %s", rec.Code, rec.Body.String())
}
var updatedThought domain.Thought
_ = json.Unmarshal(rec.Body.Bytes(), &updatedThought)
if updatedThought.KanbanStatus != "in_progress" || updatedThought.Notes != "Detailnotizen für die Aufgabe" {
t.Fatalf("expected updated kanbanStatus and notes, got %+v", updatedThought)
}
}
+22 -1
View File
@@ -14,6 +14,14 @@ type Config struct {
BaseURL string BaseURL string
AllowRegistration bool AllowRegistration bool
LogLevel string LogLevel string
// Optional SMTP environment variables (can also be overridden/configured in DB system_settings)
SMTPHost string
SMTPPort string
SMTPUser string
SMTPPass string
SMTPFrom string
SMTPTLS bool
} }
func Load() *Config { func Load() *Config {
@@ -30,9 +38,16 @@ func Load() *Config {
jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026") jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")
baseURL := getEnv("BASE_URL", "http://localhost:"+port) baseURL := getEnv("BASE_URL", "http://localhost:"+port)
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "true")) != "false" allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
logLevel := getEnv("LOG_LEVEL", "info") logLevel := getEnv("LOG_LEVEL", "info")
smtpHost := getEnv("SMTP_HOST", "")
smtpPort := getEnv("SMTP_PORT", "587")
smtpUser := getEnv("SMTP_USER", "")
smtpPass := getEnv("SMTP_PASS", "")
smtpFrom := getEnv("SMTP_FROM", "")
smtpTLS := strings.ToLower(getEnv("SMTP_TLS", "true")) != "false"
return &Config{ return &Config{
Port: port, Port: port,
DataDir: dataDir, DataDir: dataDir,
@@ -41,6 +56,12 @@ func Load() *Config {
BaseURL: baseURL, BaseURL: baseURL,
AllowRegistration: allowReg, AllowRegistration: allowReg,
LogLevel: logLevel, LogLevel: logLevel,
SMTPHost: smtpHost,
SMTPPort: smtpPort,
SMTPUser: smtpUser,
SMTPPass: smtpPass,
SMTPFrom: smtpFrom,
SMTPTLS: smtpTLS,
} }
} }
+84 -2
View File
@@ -45,6 +45,26 @@ type User struct {
Email string `json:"email"` Email string `json:"email"`
PasswordHash string `json:"-"` PasswordHash string `json:"-"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Role string `json:"role"` // admin | team_admin | member
IsActive bool `json:"isActive"`
AlarmChannels []string `json:"alarmChannels"` // email | discord | webhook
AlarmEmail string `json:"alarmEmail"`
DiscordWebhookURL string `json:"discordWebhookUrl"`
CustomWebhookURL string `json:"customWebhookUrl"`
NotifyBeforeMinutes int `json:"notifyBeforeMinutes"`
CreatedAt string `json:"createdAt"`
}
type UserInvite struct {
ID string `json:"id"`
Code string `json:"code"`
Email string `json:"email,omitempty"`
Role string `json:"role"` // member | team_admin | admin
TeamID string `json:"teamId,omitempty"`
TeamName string `json:"teamName,omitempty"`
CreatedBy string `json:"createdBy"`
ExpiresAt string `json:"expiresAt"`
UsedBy string `json:"usedBy,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
} }
@@ -69,16 +89,76 @@ type Thought struct {
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
DueDate string `json:"dueDate,omitempty"` DueDate string `json:"dueDate,omitempty"`
TaskListID string `json:"taskListId,omitempty"`
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
Notes string `json:"notes,omitempty"`
AlarmSent bool `json:"alarmSent,omitempty"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
TriagedAt string `json:"triagedAt,omitempty"` TriagedAt string `json:"triagedAt,omitempty"`
} }
type TaskList struct {
ID string `json:"id"`
UserID string `json:"userId"`
SpaceID string `json:"spaceId,omitempty"`
SpaceName string `json:"spaceName,omitempty"`
OwnerName string `json:"ownerName,omitempty"`
Members []SpaceMember `json:"members,omitempty"`
Title string `json:"title"`
Icon string `json:"icon"`
Color string `json:"color"`
TasksCount int `json:"tasksCount,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
}
type Organization struct {
ID string `json:"id"`
Name string `json:"name"`
OwnerID string `json:"ownerId"`
Teams []Team `json:"teams,omitempty"`
CreatedAt string `json:"createdAt"`
}
type Team struct {
ID string `json:"id"`
OrgID string `json:"orgId"`
Name string `json:"name"`
Description string `json:"description"`
Members []TeamMember `json:"members"`
CreatedAt string `json:"createdAt"`
}
type TeamMember struct {
TeamID string `json:"teamId"`
UserID string `json:"userId"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
Role string `json:"role"` // team_admin | member | owner
JoinedAt string `json:"joinedAt"`
}
type SystemSettings struct {
SharingMode string `json:"sharingMode"` // open | teams_only | strict_private
AllowRegistration bool `json:"allowRegistration"`
InstanceName string `json:"instanceName"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUser string `json:"smtpUser"`
SMTPPass string `json:"smtpPass"`
SMTPFrom string `json:"smtpFrom"`
SMTPTLS bool `json:"smtpTls"`
SMTPConfiguredViaEnv bool `json:"smtpConfiguredViaEnv,omitempty"`
}
type DocumentBlock struct { type DocumentBlock struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress
Content string `json:"content"` Content string `json:"content"`
Checked bool `json:"checked,omitempty"` Checked bool `json:"checked,omitempty"`
Lang string `json:"lang,omitempty"` Lang string `json:"lang,omitempty"`
Level int `json:"level,omitempty"`
Collapsed bool `json:"collapsed,omitempty"`
} }
type Document struct { type Document struct {
@@ -90,6 +170,7 @@ type Document struct {
Content string `json:"content"` Content string `json:"content"`
Blocks []DocumentBlock `json:"blocks"` Blocks []DocumentBlock `json:"blocks"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
FolderPath string `json:"folderPath,omitempty"`
IsPinned bool `json:"isPinned"` IsPinned bool `json:"isPinned"`
IsArchived bool `json:"isArchived"` IsArchived bool `json:"isArchived"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
@@ -113,7 +194,7 @@ type Space struct {
InviteCode string `json:"inviteCode"` InviteCode string `json:"inviteCode"`
IsPersonal bool `json:"isPersonal"` IsPersonal bool `json:"isPersonal"`
OwnerID string `json:"ownerId"` OwnerID string `json:"ownerId"`
Members []SpaceMember `json:"members,omitempty"` Members []SpaceMember `json:"members"`
ListsCount int `json:"listsCount"` ListsCount int `json:"listsCount"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
} }
@@ -180,4 +261,5 @@ type FullExport struct {
Documents []Document `json:"documents"` Documents []Document `json:"documents"`
Spaces []Space `json:"spaces"` Spaces []Space `json:"spaces"`
Lists []SharedList `json:"lists"` Lists []SharedList `json:"lists"`
TaskLists []TaskList `json:"taskLists,omitempty"`
} }
+1102 -131
View File
File diff suppressed because it is too large. Load diff
+36 -1
View File
@@ -38,7 +38,7 @@ func TestStoreLifecycle(t *testing.T) {
} }
// 2. Test Tinder-Style Triage -> Convert to Document // 2. Test Tinder-Style Triage -> Convert to Document
triaged, err := store.UpdateThoughtTriage(user.ID, thought.ID, "keep", "document", "", "") triaged, err := store.UpdateThoughtTriage(user.ID, thought.ID, "keep", "document", "", "", "")
if err != nil { if err != nil {
t.Fatalf("UpdateThoughtTriage failed: %v", err) t.Fatalf("UpdateThoughtTriage failed: %v", err)
} }
@@ -73,4 +73,39 @@ func TestStoreLifecycle(t *testing.T) {
if err != nil || validatedUser.ID != user.ID { if err != nil || validatedUser.ID != user.ID {
t.Fatalf("ValidateAPIKeyAndGetUser failed: %v", err) t.Fatalf("ValidateAPIKeyAndGetUser failed: %v", err)
} }
// 5. Test TaskLists CRUD
taskList, err := store.CreateTaskList(user.ID, sp.ID, "Haus & Garten", "🏡", "#10b981")
if err != nil || taskList.ID == "" {
t.Fatalf("CreateTaskList failed: %v", err)
}
taskLists, err := store.GetTaskLists(user.ID)
if err != nil || len(taskLists) == 0 {
t.Fatalf("GetTaskLists failed: %v", err)
}
// 6. Test Organizations & Teams
org, err := store.CreateOrganization(user.ID, "Mindsift Inc")
if err != nil || org.ID == "" {
t.Fatalf("CreateOrganization failed: %v", err)
}
team, err := store.CreateTeam(org.ID, "DevOps", "Infrastructure team")
if err != nil || team.ID == "" {
t.Fatalf("CreateTeam failed: %v", err)
}
err = store.AddTeamMember(team.ID, user.ID, "owner")
if err != nil {
t.Fatalf("AddTeamMember failed: %v", err)
}
// 7. Test System Settings
sysSettings, err := store.GetSystemSettings()
if err != nil || sysSettings.InstanceName == "" {
t.Fatalf("GetSystemSettings failed: %v", err)
}
sysSettings.SharingMode = "teams_only"
err = store.UpdateSystemSettings(sysSettings)
if err != nil {
t.Fatalf("UpdateSystemSettings failed: %v", err)
}
} }