Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5db126128e | ||
|
|
f74ff7c63b | ||
|
|
37948d3a6a | ||
|
|
3d780856b0 | ||
|
|
8ee7b135af | ||
|
|
894a2e3b76 | ||
|
|
59ddca91f3 | ||
|
|
7baab8faa2 | ||
|
|
5a931e9bb6 | ||
|
|
60fd1c9ae7 | ||
|
|
23eff21520 | ||
|
|
d11153309f | ||
|
|
7a8d759bf2 | ||
|
|
149cfdc244 | ||
|
|
928cc72491 | ||
|
|
5144f7b01b | ||
|
|
ffa14b01a2 | ||
|
|
d3dd4c8cc6 | ||
|
|
27b3567008 | ||
|
|
4850f9dfad | ||
|
|
6c1898d489 | ||
|
|
1776e87029 | ||
|
|
634195b629 | ||
|
|
cedf5aebff | ||
|
|
06f5f7043c | ||
|
|
1bcf6b4f18 | ||
|
|
48fe61fbce | ||
|
|
177e24fdc8 | ||
|
|
95977ba15a | ||
|
|
85e4010435 | ||
|
|
86867b0d6f | ||
|
|
f3b3f07e04 | ||
|
|
297f3ed111 | ||
|
|
f90b0d5eb9 | ||
|
|
5b9cfe8a9a | ||
|
|
6343d62bab | ||
|
|
80bb74b5b2 | ||
|
|
53e1ba322d | ||
|
|
58f02e0927 | ||
|
|
ef8cf8f022 | ||
|
|
0bdeb5f320 | ||
|
|
fe2cfc2b79 | ||
|
|
3480859d8a | ||
|
|
10d5c6d563 | ||
|
|
0e06f6761d | ||
|
|
baac20ea10 | ||
|
|
832f2f9c2b | ||
|
|
155d2daa02 | ||
|
|
c2ed76c6a4 | ||
|
|
e088ef6974 | ||
|
|
ed673b0707 | ||
|
|
2f92e1f49a | ||
|
|
35fd682fcd | ||
|
|
3fcd164fd8 | ||
|
|
f2ead7513a | ||
|
|
1e47f3aeb7 | ||
|
|
e8723cbf82 | ||
|
|
f9ef03af7f | ||
|
|
c420155dc0 | ||
|
|
444442fdf0 | ||
|
|
8b043c4809 | ||
|
|
0b9046cb7a | ||
|
|
212cb32bb0 | ||
|
|
cb0999325f | ||
|
|
e389c7bebc | ||
|
|
634fdcac11 | ||
|
|
f1af10ce1c | ||
|
|
5cfa390ee0 | ||
|
|
0008e9c420 | ||
|
|
09725611b5 | ||
|
|
35d4657a25 | ||
|
|
c0661cddae | ||
|
|
3b4a8b125b | ||
|
|
0d6c603e9c | ||
|
|
49a49094de | ||
|
|
177b69234c | ||
|
|
d45b3cd0b3 | ||
|
|
e37302f73c | ||
|
|
60c302e23e | ||
|
|
9ab9af3a9e | ||
|
|
e2772d99ea | ||
|
|
354129b2d4 | ||
|
|
6d07352e80 | ||
|
|
646ed6d960 | ||
|
|
518a0903dd | ||
|
|
b0403e09bc | ||
|
|
f1d9daca60 | ||
|
|
eafe75faf0 | ||
|
|
4874b79439 | ||
|
|
b9d5529648 | ||
|
|
b59bd3a265 | ||
|
|
b3e3fa27c4 | ||
|
|
c02c7d20de | ||
|
|
90a6062c7a | ||
|
|
7c47f245e2 | ||
|
|
20c50aa8d6 | ||
|
|
332dbb456a | ||
|
|
b5df4a34b4 | ||
|
|
0afa2f9c2d | ||
|
|
cfc0477b5c | ||
|
|
1a37ba9342 | ||
|
|
107361bb0f | ||
|
|
bc41f7896d | ||
|
|
e2b20198e4 | ||
|
|
31847ffaa3 | ||
|
|
5c6f162c96 | ||
|
|
d34178aa1a | ||
|
|
1310f8102e | ||
|
|
c4b2a00348 | ||
|
|
1be0909344 | ||
|
|
1009487cce | ||
|
|
0affb827a4 | ||
|
|
920716be25 | ||
|
|
51244c736c | ||
|
|
7bc5fbe001 | ||
|
|
6e09888a44 | ||
|
|
af7a9eb200 | ||
|
|
ec79a2493e | ||
|
|
290848a93e | ||
|
|
1168c7e2ea | ||
|
|
714a8551c7 | ||
|
|
38e9c28c63 | ||
|
|
7f312fef14 | ||
|
|
41c64c4924 | ||
|
|
d338c4cb8a | ||
|
|
9b21fd92e1 | ||
|
|
d49ef7971d | ||
|
|
84e0b6a4bd | ||
|
|
dccf1a91e3 | ||
|
|
1ada978f9e | ||
|
|
3c550373eb | ||
|
|
05de76cce4 | ||
|
|
577893172b | ||
|
|
0ec1a8b7f7 | ||
|
|
537b916533 | ||
|
|
63db1a0f90 | ||
|
|
e6a791bcca | ||
|
|
87efb98bc1 | ||
|
|
14e3176e55 | ||
|
|
275442e9e2 | ||
|
|
5b5b7b1810 | ||
|
|
29d202cc4e | ||
|
|
fb805b30a1 | ||
|
|
33e1223e69 | ||
|
|
fe1d574787 | ||
|
|
2504b48171 | ||
|
|
b3e651b3b9 | ||
|
|
0f24cbdbf4 | ||
|
|
7802eb2cf0 | ||
|
|
50ac0c8f64 | ||
|
|
f4a7dd5c99 | ||
|
|
4ef71b61cd | ||
|
|
5c4e130862 | ||
|
|
d6bc2976d9 | ||
|
|
2d4a2bde41 | ||
|
|
ec9ba8fd9f | ||
|
|
94123c9424 | ||
|
|
da82caf82c | ||
|
|
3263b07e72 | ||
|
|
2e7deee9bf | ||
|
|
b53f7c5fc9 | ||
|
|
c99b6c3a49 | ||
|
|
31aa43c460 | ||
|
|
10323867e9 | ||
|
|
8bc447c9a6 |
No files matched your search
+12
-1
@@ -4,5 +4,16 @@ DATA_DIR=/data
|
||||
DATABASE_URL=sqlite:///data/mindsift.db
|
||||
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
|
||||
BASE_URL=http://localhost:8080
|
||||
ALLOW_REGISTRATION=true
|
||||
ALLOW_REGISTRATION=false
|
||||
# Logging level: debug, info, warn, error (Default: info)
|
||||
LOG_LEVEL=info
|
||||
# LOG_LEVEL=debug
|
||||
# DEBUG=true
|
||||
|
||||
# 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
|
||||
@@ -2,51 +2,16 @@ name: MindSift CI & Docker Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
branches-ignore:
|
||||
- main
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Lint, Build & Unit Tests
|
||||
build-test-push:
|
||||
name: Build, Test in Container & Push
|
||||
runs-on: ubuntu-latest
|
||||
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
|
||||
# Auf 'main' werden nur Tests ausgeführt — gebaut & gepusht wird nur bei Tags oder Test-/RC-Branches
|
||||
if: github.event_name == 'push' && github.ref != 'refs/heads/main'
|
||||
if: github.ref != 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
@@ -72,10 +37,7 @@ jobs:
|
||||
echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${DOCKER_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "Building Docker image: ${IMAGE_NAME}:${DOCKER_TAG} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
echo "Target Docker image: ${IMAGE_NAME}:${DOCKER_TAG} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})"
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -84,11 +46,10 @@ jobs:
|
||||
username: ${{ steps.prep.outputs.actor }}
|
||||
password: ${{ secrets.PUSH_TOKEN || secrets.GITEA_TOKEN || github.token }}
|
||||
|
||||
- name: Build and Push Multi-Arch Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ steps.prep.outputs.image }}:${{ steps.prep.outputs.tag }}
|
||||
- name: Build & Run All Checks in Container, then Push
|
||||
run: |
|
||||
FULL_IMAGE="${{ steps.prep.outputs.image }}:${{ steps.prep.outputs.tag }}"
|
||||
echo "🚀 Building container & executing Frontend/Backend checks inside image: ${FULL_IMAGE}"
|
||||
DOCKER_BUILDKIT=1 docker build --progress=plain -t "${FULL_IMAGE}" .
|
||||
echo "✅ All checks & tests passed inside container! Pushing ${FULL_IMAGE}..."
|
||||
docker push "${FULL_IMAGE}"
|
||||
@@ -1,183 +0,0 @@
|
||||
# 🧠 BRAIN.md — MindSift Internal Dev Cheat Sheet
|
||||
> **Zweck:** Rein internes, ungeschöntes Nachschlagewerk für uns Entwickler. Keine Marketing-Texte, nur knallharte Fakten, Farbwerte, State-Machines, Namenskonventionen und Quick-Commands.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 1. Design-Tokens & Gemini-Dark-Theme (Copy & Paste)
|
||||
|
||||
### Farbpalette (Strict Hex Values)
|
||||
```css
|
||||
/* Backgrounds */
|
||||
--bg-app: #0b0f19; /* Tiefstes Obsidian / OLED Base */
|
||||
--bg-surface: #131314; /* Leicht erhobene Panels, Sidebar */
|
||||
--bg-card: #1e1f20; /* Swipe-Karten, Dialoge, Dropdowns */
|
||||
--bg-card-hover: #282a2c; /* Hover-Zustände */
|
||||
|
||||
/* Borders & Dividers */
|
||||
--border-subtle: rgba(255, 255, 255, 0.08); /* 1px Standard-Kante */
|
||||
--border-focus: rgba(138, 180, 248, 0.40); /* Gemini Blue Glow */
|
||||
|
||||
/* Akzente & Status */
|
||||
--accent-blue: #8ab4f8; /* Gemini Primärblau (Buttons, Links, Active) */
|
||||
--accent-purple: #c58af9; /* Gemini Voice / Magic Glow */
|
||||
--swipe-keep: #81c995; /* Swipe Rechts (Behalten / ToDo / Success) */
|
||||
--swipe-discard: #f28b82; /* Swipe Links (Verwerfen / Archive / Danger) */
|
||||
|
||||
/* Typografie */
|
||||
--text-primary: #e3e3e3; /* Haupttext (hoher Kontrast, augenschonend) */
|
||||
--text-muted: #9aa0a6; /* Sekundärtexte, Metadaten, Placeholders */
|
||||
--text-subtle: #5f6368; /* Timestamps, Footer, Deaktiviert */
|
||||
```
|
||||
|
||||
### UI-Regeln & Radien
|
||||
* **Buttons / Pills:** Ausnahmslos `rounded-full` (`border-radius: 9999px`).
|
||||
* **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.
|
||||
* **Gesten-Physik (Framer Motion):**
|
||||
* Swipe-Schwellenwert: `dragConstraints={{ left: -150, right: 150 }}`
|
||||
* Spring-Config: `{ type: "spring", stiffness: 350, damping: 25 }`
|
||||
* Swipe-Winkel-Rotation: `rotate = x * 0.1`
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 2. State-Machine: Gedanken-Lifecycle
|
||||
|
||||
```
|
||||
[ INSTANT CAPTURE ]
|
||||
│
|
||||
▼
|
||||
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
|
||||
* `ITEM_TOGGLED`: Wenn jemand die Hafermilch abhakt:
|
||||
`{ "item_id": "itm_123", "is_completed": true, "completed_by": "usr_sandro" }`
|
||||
* `Optimistic UI`: Frontend schlägt das Item sofort lokal durch und rollt nur bei WebSocket-Fehler zurück.
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ 4. Ordnerstruktur & Code-Konventionen
|
||||
|
||||
```
|
||||
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
|
||||
* **Entity IDs:** Präfix-basierte Strings für Lesbarkeit beim Debuggen:
|
||||
* `usr_` = User (`usr_9k2m1a`)
|
||||
* `th_` = Thought (`th_4x8z19`)
|
||||
* `doc_` = Document (`doc_81na72`)
|
||||
* `lst_` = List (`lst_33fa09`)
|
||||
* `itm_` = List Item (`itm_77bb41`)
|
||||
* **Datenbank:** `snake_case` (z. B. `is_completed`, `due_date`)
|
||||
* **JSON API:** `camelCase` (z. B. `isCompleted`, `dueDate`)
|
||||
* **Timestamps:** Immer UTC ISO-8601 (`2026-10-24T14:20:00Z`)
|
||||
|
||||
---
|
||||
|
||||
## ⌨️ 5. Power-User Tastatur-Shortcuts (Desktop)
|
||||
|
||||
| Taste | Kontext | Aktion |
|
||||
| :--- | :--- | :--- |
|
||||
| `C` oder `/` | Überall | Fokus sofort auf das Capture-Eingabefeld |
|
||||
| `H` oder `←` | Triage | Karte nach links wischen (Verwerfen) |
|
||||
| `L` oder `→` | Triage | Karte nach rechts wischen (Behalten) |
|
||||
| `T` | Triage | Sofort in Aufgabe mit Fälligkeitsdatum verwandeln |
|
||||
| `D` | Triage | In Workspace-Dokument umwandeln |
|
||||
| `Esc` | Modal/Capture | Abbrechen / Eingabe leeren |
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 6. Cheat Sheet: Lokale Entwickler-Befehle
|
||||
|
||||
```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.
|
||||
|
||||
+28
-10
@@ -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
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
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
|
||||
ARG TARGETOS=linux
|
||||
ARG TARGETARCH=amd64
|
||||
FROM golang:1.23-alpine AS backend-builder
|
||||
WORKDIR /app
|
||||
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 ./
|
||||
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 internal/ ./internal/
|
||||
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
|
||||
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)
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
# 📚 MindSift Technische Dokumentation & Wiki
|
||||
|
||||
Willkommen in der zentralen technischen Dokumentation von **MindSift**. Dieses Wiki dient als verbindliche, tiefgehende Referenz für Entwickler, DevOps-Ingenieure und Systemadministratoren. Es dokumentiert die Architektur, das Datenmodell, APIs, Echtzeit-Protokolle, Kernmodule sowie bekannte technische Schwachstellen der Plattform.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Inhaltsverzeichnis (Wiki-Navigation)
|
||||
|
||||
```
|
||||
Doku/
|
||||
├── README.md # Einstieg, Inhaltsverzeichnis & Gesamtübersicht (dieses Dokument)
|
||||
├── architecture.md # Systemarchitektur, Schichtenmodell, Concurrency & Stack
|
||||
├── database.md # SQLite-Datenbankdesign, Schemas, Indizes & Migrationen
|
||||
├── api/
|
||||
│ ├── rest.md # Vollständige REST-API Referenz aller Endpunkte
|
||||
│ └── websocket.md # WebSocket-Echtzeitprotokoll, Rooms & Document Locking
|
||||
├── modules/
|
||||
│ ├── triage.md # Modul 1: Triage & Quick-Dump (State Machine & Audio)
|
||||
│ ├── tasks.md # Modul 2: Aufgaben & Kanban-System (Alarme & Scheduler)
|
||||
│ ├── workspace.md # Modul 3: Workspace Outliner-Engine, Wiki-Links & Pure-DB Storage
|
||||
│ └── lists.md # Modul 4: Geteilte Listen (Live-Kollaboration & D&D)
|
||||
├── sharing-and-permissions.md # Synchrones Teilen, Rollen, Isolation & Mount-Entkopplung
|
||||
├── reverse-proxy.md # Nginx, Traefik, Caddy, Cloudflare Konfiguration & SSE
|
||||
├── theming.md # Single-File CSS Theme-Engine & Design-System
|
||||
└── flaws.md # Technische Schwachstellen, Risiken & zukünftige Optimierungen
|
||||
```
|
||||
|
||||
### 📑 Kapitel im Detail
|
||||
1. **[Systemarchitektur & Technologie-Stack](architecture.md)**
|
||||
Schichtenmodell, Go-Backend, React-Frontend, Concurrency-Handling und Thread-Safety.
|
||||
2. **[Datenbank-Design & SQLite Schema](database.md)**
|
||||
Vollständiges relationales Datenmodell, ER-Diagramm, Tabellendefinitionen, Indizes und Migrationsstrategie.
|
||||
3. **[REST-API Referenz](api/rest.md)**
|
||||
Spezifikation aller Endpunkte für Authentifizierung, Gedanken, Aufgaben, Dokumente, Listen, Teams und Webhooks.
|
||||
4. **[WebSocket & Echtzeit-Synchronisation](api/websocket.md)**
|
||||
Gorilla WebSocket Hub, Rooms, Presence-Tracking, Yjs CRDT Kollaboration und SSE Fallback.
|
||||
5. **[Kernmodul: Triage & Quick-Dump](modules/triage.md)**
|
||||
Instant-Capture-Pipeline, Finite State Machine, Gestensteuerung, Spracheingabe und Webhook-Ingestion.
|
||||
6. **[Kernmodul: Aufgaben & Kanban-System](modules/tasks.md)**
|
||||
Multi-Listen-Konzept, 3-Spalten Kanban-Board, Subtasks, Fälligkeiten und Multi-Channel Alarm-Ticker (SMTP, Discord, Webhooks).
|
||||
7. **[Kernmodul: Workspace & Outliner-Engine](modules/workspace.md)**
|
||||
Logseq-inspirierte hierarchische Block-Engine, Zoom-Hoisting, Wiki-Links, Pure-Database Storage und Globales To-Do Rollup.
|
||||
8. **[Kernmodul: Geteilte Listen & Kollaboration](modules/lists.md)**
|
||||
Listen-Typen, Mengen, Kategorien, Live-Checkoff und Drag & Drop Sortierung.
|
||||
9. **[Synchrones Teilen & Berechtigungskonzept](sharing-and-permissions.md)**
|
||||
Rollenmodell (Admin/Member), System-Isolations-Modi (`strict_private`, `teams_only`, `open`), entkoppelte Ordner-Mounts und Notizen-Freigaben.
|
||||
10. **[Reverse-Proxy- & Ingress-Handbuch](reverse-proxy.md)**
|
||||
Produktionsfertige Konfigurationen für Nginx, Traefik, Caddy, Cloudflare und Apache mit WebSocket-Upgrades und SSE-Streaming.
|
||||
11. **[Single-File Theming & Design-System](theming.md)**
|
||||
1-Datei = 1-Theme Philosophie, die 6 CSS-Variablen-Blöcke, Semantische CSS-Klassen und Geometrie-Steuerung.
|
||||
12. **[Technische Schwachstellen & Optimierungspotenziale (Flaws)](flaws.md)**
|
||||
Kritische Analyse von Concurrency-Grenzen, Volltextsuche, Viewport-Anpassung und Skalierungs-Engpässen.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ Systemübersicht (High-Level Architektur)
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client ["Client Layer (Browser / PWA)"]
|
||||
UI["React 18 SPA (Vite, TS, Tailwind)"]
|
||||
WSClient["WebSocket Client (Auto-Reconnect)"]
|
||||
Cache["IndexedDB / Offline Queue"]
|
||||
end
|
||||
|
||||
subgraph ReverseProxy ["Reverse Proxy / Gateway"]
|
||||
Traefik["Traefik / Nginx (HTTPS, WSS, Rate Limits)"]
|
||||
end
|
||||
|
||||
subgraph Backend ["Backend Layer (Go 1.23 Engine)"]
|
||||
ChiRouter["Chi Router (REST API)"]
|
||||
WSHub["Gorilla WebSocket Hub & Lock Manager"]
|
||||
AlarmTicker["Alarm Scheduler (Background Ticker)"]
|
||||
StorageEngine["Storage Layer (Domain & Repositories)"]
|
||||
end
|
||||
|
||||
subgraph Storage ["Storage Layer"]
|
||||
SQLite[("SQLite WAL Database (/data/mindsift.db)")]
|
||||
end
|
||||
|
||||
subgraph External ["Externe Schnittstellen"]
|
||||
HomeAssistant["Home Assistant / Webhooks"]
|
||||
Discord["Discord Webhooks"]
|
||||
SMTPServer["SMTP Server (E-Mail Alarme)"]
|
||||
end
|
||||
|
||||
UI -->|HTTPS REST| Traefik
|
||||
WSClient -->|WSS Realtime| Traefik
|
||||
Traefik --> ChiRouter
|
||||
Traefik --> WSHub
|
||||
ChiRouter --> StorageEngine
|
||||
StorageEngine --> SQLite
|
||||
AlarmTicker --> Discord
|
||||
AlarmTicker --> SMTPServer
|
||||
HomeAssistant -->|POST /api/v1/inbox| Traefik
|
||||
HomeAssistant -->|POST /api/v1/webhooks/ingest| Traefik
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Schnellstart für Entwickler
|
||||
|
||||
### Voraussetzungen
|
||||
- **Go:** 1.22 oder 1.23+
|
||||
- **Node.js:** 20+ (LTS) & npm
|
||||
- **Container-Runtime:** Docker oder Podman
|
||||
|
||||
### Lokale Entwicklung ohne Container
|
||||
```bash
|
||||
# 1. Repository klonen
|
||||
git clone https://git.dsit.me/sandrodach/MindSift.git
|
||||
cd MindSift
|
||||
|
||||
# 2. Frontend Abhängigkeiten installieren & Dev-Server starten
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# 3. Backend starten (in neuem Terminal)
|
||||
cd ..
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
### Container Build & Testlauf (wie in der CI/CD)
|
||||
```bash
|
||||
# Backend-Tests ausführen
|
||||
podman run --rm -v $(pwd):/app:z -w /app golang:1.23 go test -v ./internal/...
|
||||
|
||||
# Frontend Typecheck & Build testen
|
||||
podman run --rm -v $(pwd):/app:z -w /app/frontend node:20 sh -c "npx tsc --noEmit && npm run build"
|
||||
|
||||
# Komplettes Multi-Stage Image bauen
|
||||
podman build -t mindsift:dev .
|
||||
```
|
||||
@@ -0,0 +1,185 @@
|
||||
# 🔌 REST-API Referenz
|
||||
|
||||
Die MindSift REST-API ermöglicht den vollständigen Zugriff auf Gedanken, Aufgaben, Workspace-Notizen, Listen, Benutzerkonten und Smarthome-Automationen.
|
||||
|
||||
---
|
||||
|
||||
## 🔐 1. Authentifizierung
|
||||
|
||||
MindSift unterstützt zwei Authentifizierungsmethoden:
|
||||
|
||||
1. **JSON Web Token (JWT Bearer):**
|
||||
Wird beim Login oder Setup erzeugt und im HTTP-Header mitgesendet:
|
||||
`Authorization: Bearer <jwt-token>`
|
||||
2. **Persönliche API-Schlüssel (Smarthome / Automatisierung):**
|
||||
Werden unter *Einstellungen ➔ Private Webhooks* erzeugt (Präfix `mindsift_sec_...`):
|
||||
- Per HTTP-Header: `X-API-Key: mindsift_sec_xxxxxxxxxxxx`
|
||||
- Per Authorization-Header: `Authorization: Bearer mindsift_sec_xxxxxxxxxxxx`
|
||||
- Per Query-Parameter: `?token=mindsift_sec_xxxxxxxxxxxx`
|
||||
|
||||
---
|
||||
|
||||
## 🚦 2. Status- & Fehler-Codes
|
||||
|
||||
- `200 OK`: Erfolgreiche Abfrage oder Aktualisierung.
|
||||
- `201 Created`: Ressource erfolgreich angelegt.
|
||||
- `400 Bad Request`: Ungültiges JSON oder fehlende Pflichtfelder.
|
||||
- `401 Unauthorized`: Token ungültig, abgelaufen oder fehlend.
|
||||
- `403 Forbidden`: Unzureichende Berechtigungen (z.B. nur Server-Admin oder nur Lesezugriff).
|
||||
- `404 Not Found`: Ressource existiert nicht oder gehört einem anderen Benutzer.
|
||||
- `409 Conflict`: Ressource gesperrt (z.B. Dokument wird bereits von anderem Benutzer bearbeitet).
|
||||
|
||||
Fehlerrückgaben erfolgen im einheitlichen Format:
|
||||
```json
|
||||
{
|
||||
"error": "Fehlermeldung im Klartext"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 3. Endpunkte nach Modulen
|
||||
|
||||
### 3.1 Authentifizierung & System-Status
|
||||
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/api/v1/health` | Öffentlich | Liveness-Check (`{"status":"ok"}`) |
|
||||
| `GET` | `/api/v1/auth/status` | Öffentlich | Prüft ob Ersteinrichtung nötig ist (`needsSetup: true/false`) |
|
||||
| `POST` | `/api/v1/auth/setup` | Öffentlich | Führt First-Boot Setup durch (legt ersten Admin an) |
|
||||
| `POST` | `/api/v1/auth/register` | Öffentlich | Registrierung über Einladungscode (`inviteCode`) |
|
||||
| `POST` | `/api/v1/auth/login` | Öffentlich | Login via E-Mail oder Benutzername + Passwort |
|
||||
| `GET` | `/api/v1/auth/me` | JWT | Gibt das aktuelle Benutzerprofil zurück |
|
||||
| `PATCH` | `/api/v1/auth/settings` | JWT | Aktualisiert Profil, Passwort, Theme oder Alarm-Kanäle |
|
||||
| `POST` | `/api/v1/auth/alarms/test` | JWT | Sendet Test-Alarm an E-Mail, Discord oder Custom Webhook |
|
||||
|
||||
#### Beispiel: `POST /api/v1/auth/login`
|
||||
```json
|
||||
// Request
|
||||
{
|
||||
"login": "sandro",
|
||||
"password": "geheimesPasswort123"
|
||||
}
|
||||
|
||||
// Response (200 OK)
|
||||
{
|
||||
"token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"user": {
|
||||
"id": "usr_abc123",
|
||||
"email": "sandro@example.com",
|
||||
"username": "sandro",
|
||||
"displayName": "Sandro",
|
||||
"role": "admin",
|
||||
"alarmChannels": ["discord", "webhook"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 Triage & Instant Capture
|
||||
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `POST` | `/api/v1/inbox` | JWT / API-Key | Erfasst einen neuen Quick Dump oder leitet direkt weiter |
|
||||
| `GET` | `/api/v1/triage/deck` | JWT | Liefert den Stapel aller noch nicht triagierten Gedanken (`status: 'inbox'`) |
|
||||
| `PATCH` | `/api/v1/triage/{id}` | JWT | Führt Triage-Aktion aus (`keep`, `discard`, `restore`) |
|
||||
|
||||
#### Beispiel: `POST /api/v1/inbox`
|
||||
Ermöglicht flexible Direkt-Zuordnungen:
|
||||
```json
|
||||
// Request (Quick Dump in Triage-Deck)
|
||||
{
|
||||
"content": "Kellerfeuchtigkeit über 68% gestiegen #smarthome #alarm",
|
||||
"priority": "urgent"
|
||||
}
|
||||
|
||||
// Request (Direkt als Aufgabe anlegen)
|
||||
{
|
||||
"content": "Server Backup prüfen #devops",
|
||||
"priority": "high",
|
||||
"target": "task",
|
||||
"dueDate": "2026-10-30"
|
||||
}
|
||||
```
|
||||
*Hinweis:* Wenn `/api/v1/inbox` über einen API-Key aufgerufen wird und kein `source` definiert ist, wird die Quelle automatisch als `"webhook"` markiert!
|
||||
|
||||
---
|
||||
|
||||
### 3.3 Aufgaben & Kanban-System
|
||||
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/api/v1/tasks` | JWT | Liefert alle Aufgaben des Benutzers (inkl. geteilter Aufgaben) |
|
||||
| `POST` | `/api/v1/tasks/reorder` | JWT | Speichert Kanban-Status und Reihenfolge (`sort_order`) |
|
||||
| `GET` | `/api/v1/task-lists` | JWT | Liefert alle Aufgabenlisten (eigene & geteilte) |
|
||||
| `POST` | `/api/v1/task-lists` | JWT | Erstellt eine neue Aufgabenliste (mit Farbe & Spalten) |
|
||||
| `PUT` | `/api/v1/task-lists/{id}` | JWT | Ändert Titel, Farbe, Icon oder Kanban-Spalten |
|
||||
| `DELETE` | `/api/v1/task-lists/{id}` | JWT | Löscht Aufgabenliste |
|
||||
| `POST` | `/api/v1/task-lists/{id}/shares` | JWT | Teilt Liste mit Team oder Benutzer |
|
||||
|
||||
---
|
||||
|
||||
### 3.4 Workspace & Dokumente
|
||||
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/api/v1/documents` | JWT | Liefert Liste aller Dokumente des Benutzers (Metadaten) |
|
||||
| `POST` | `/api/v1/documents` | JWT | Erstellt neues Notiz-Dokument (mit Blocks oder Markdown) |
|
||||
| `GET` | `/api/v1/documents/{id}` | JWT | Lädt komplettes Dokument inklusive aller Blöcke |
|
||||
| `PUT` | `/api/v1/documents/{id}` | JWT | Aktualisiert Dokumenttitel, Icon oder Ordnerpfad |
|
||||
| `PATCH` | `/api/v1/documents/{id}/blocks` | JWT | Granulares Patching von Dokument-Blöcken (Database-First) |
|
||||
| `DELETE` | `/api/v1/documents/{id}` | JWT | Löscht Dokument (Datenbank & physische Datei) |
|
||||
| `POST` | `/api/v1/documents/{id}/shares` | JWT | Notiz mit anderem Benutzer teilen (`read` / `write`) |
|
||||
| `GET` | `/api/v1/documents/{id}/lock` | JWT | Fragt Bearbeitungs-Sperrstatus ab |
|
||||
| `POST` | `/api/v1/documents/{id}/lock` | JWT | Sperrt Dokument für Bearbeitung (30s Lease) |
|
||||
| `DELETE` | `/api/v1/documents/{id}/lock` | JWT | Gibt Bearbeitungssperre wieder frei |
|
||||
|
||||
---
|
||||
|
||||
### 3.5 Workspace Ordner & Geteilte Ordner
|
||||
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/api/v1/folders` | JWT | Liefert alle Ordnerpfade des Benutzers |
|
||||
| `POST` | `/api/v1/folders` | JWT | Legt neuen (auch leeren) Ordner an |
|
||||
| `POST` | `/api/v1/folders/move` | JWT | Benennt Ordner um oder verschiebt ihn hierarchisch |
|
||||
| `DELETE` | `/api/v1/folders` | JWT | Löscht Ordner und verschiebt Notizen |
|
||||
| `GET` | `/api/v1/workspace/folders/shared` | JWT | Liefert für den Benutzer freigegebene geteilte Ordner |
|
||||
| `POST` | `/api/v1/workspace/folders/shared` | JWT | Erstellt neuen geteilten Ordner |
|
||||
| `PUT` | `/api/v1/workspace/folders/shared/{id}/mount` | JWT | Ändert den lokalen Einhängepfad beim Empfänger |
|
||||
| `POST` | `/api/v1/workspace/folders/shared/{id}/shares` | JWT | Gibt Ordner für Teams oder Benutzer frei |
|
||||
|
||||
---
|
||||
|
||||
### 3.6 Geteilte Listen (Listen & Einkauf)
|
||||
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `GET` | `/api/v1/lists` | JWT | Liefert alle geteilten Listen |
|
||||
| `POST` | `/api/v1/lists` | JWT | Erstellt neue geteilte Liste |
|
||||
| `GET` | `/api/v1/lists/{id}` | JWT | Lädt Liste inklusive aller Einträge |
|
||||
| `POST` | `/api/v1/lists/{id}/items` | JWT | Fügt Eintrag hinzu |
|
||||
| `PATCH` | `/api/v1/lists/{id}/items/{itemId}` | JWT | Hakt Eintrag ab (`isCompleted: true/false`) |
|
||||
| `POST` | `/api/v1/lists/{id}/items/reorder` | JWT | Speichert sortierte Einträge |
|
||||
| `POST` | `/api/v1/lists/{id}/clear-completed`| JWT | Löscht alle abgehakten Einträge |
|
||||
| `POST` | `/api/v1/lists/{id}/shares` | JWT | Teilt Liste mit Team oder Benutzer |
|
||||
|
||||
---
|
||||
|
||||
### 3.7 Smarthome Ingestion & Externe Webhooks
|
||||
|
||||
| Methode | Pfad | Auth | Beschreibung |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `POST` | `/api/v1/webhooks/ingest` | Token / Key | Empfängt externe Webhook-Payloads von Home Assistant / n8n |
|
||||
|
||||
#### Beispiel: `POST /api/v1/webhooks/ingest?token=mindsift_sec_...`
|
||||
Unterstützt flexible Payloads (JSON oder Plain Text):
|
||||
```json
|
||||
{
|
||||
"title": "Waschmaschine Keller fertig",
|
||||
"priority": "high",
|
||||
"tags": ["smarthome", "keller"]
|
||||
}
|
||||
```
|
||||
*Live-Aktualisierung:* Sendet automatisch ein `TASK_CREATED` WebSocket-Event an alle Clients des Benutzers, sodass die neue Karte sofort ohne Neuladen im Triage-Deck erscheint!
|
||||
@@ -0,0 +1,195 @@
|
||||
# 📡 WebSocket & Echtzeit-Synchronisation
|
||||
|
||||
MindSift implementiert eine bidirektionale Echtzeit-Synchronisationsschicht auf Basis von WebSockets ([`internal/ws/hub.go`](../../internal/ws/hub.go)). Sie sorgt dafür, dass Änderungen an Aufgaben, Triage-Karten, Notizen und geteilten Einkaufslisten ohne manuelles Neuladen sofort auf allen geöffneten Geräten und bei allen Kollaborationspartnern sichtbar werden.
|
||||
|
||||
---
|
||||
|
||||
## 🔌 1. Verbindung & Handshake
|
||||
|
||||
### Verbindungs-Endpunkte
|
||||
```
|
||||
wss://<domain>/api/v1/ws?token=<jwt-token>
|
||||
wss://<domain>/api/v1/ws/lists/<listId>?token=<jwt-token>
|
||||
```
|
||||
|
||||
### Authentifizierung & Upgrade
|
||||
- Die Authentifizierung erfolgt über das JWT-Token im Query-Parameter `?token=...` oder den `Authorization: Bearer`-Header während des HTTP-Upgrade-Requests.
|
||||
- Der Server extrahiert die `userID` und registriert die Verbindung im internen `Hub`.
|
||||
- Der `Upgrader` akzeptiert alle Origins (`CheckOrigin: return true`), um volle Kompatibilität mit Reverse-Proxies (Traefik, Nginx, Caddy, Cloudflare) und Docker-Netzwerken zu gewährleisten.
|
||||
|
||||
---
|
||||
|
||||
## 🏛️ 2. Hub-Architektur & Rooms
|
||||
|
||||
Der WebSocket-Hub unterteilt Verbindungen in drei Abstraktionsebenen:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ WebSocket Hub │
|
||||
├─────────────────┬─────────────────────────────┬─────────────────┤
|
||||
│ allClients │ userRooms │ rooms │
|
||||
│ (Alle aktiven │ (Pro Benutzer-ID) │ (Pro Liste/ │
|
||||
│ Verbindungen) │ Cross-Device Sync für │ Space) │
|
||||
│ │ Tasks, Triage & Workspace │ Geteilte Liste │
|
||||
└────────┬────────┴──────────────┬──────────────┴────────┬────────┘
|
||||
▼ ▼ ▼
|
||||
Presence Stats TASK_CREATED ITEM_TOGGLED
|
||||
DOC_LOCKED TASK_UPDATED ITEM_CREATED
|
||||
DOC_UNLOCKED DOC_UPDATED LIST_UPDATED
|
||||
```
|
||||
|
||||
1. **`allClients` (Global):** Hält jede aktive Client-Verbindung. Ermöglicht Broadcasts systemweiter Ereignisse (z.B. globale Dokument-Sperrungen).
|
||||
2. **`userRooms[userID]` (Benutzer-Räume):** Sendet Events an alle Geräte desselben Benutzers (z.B. Desktop und Smartphone). Erzeugt ein Webhook oder Quick Dump auf dem Smartphone einen Eintrag, taucht dieser zeitgleich auf dem Desktop auf.
|
||||
3. **`rooms[listID]` (Kollaborations-Räume):** Multiplexing für geteilte Listen. Alle Mitglieder einer Liste empfangen Aktionen (wie Abhaken, Löschen, Umsortieren) in Echtzeit.
|
||||
|
||||
---
|
||||
|
||||
## 📨 3. Nachrichten-Format (Envelope)
|
||||
|
||||
Alle WebSocket-Nachrichten werden als JSON mit einheitlicher Hülle übertragen:
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "ITEM_TOGGLED",
|
||||
"listId": "lst_12345",
|
||||
"payload": {
|
||||
"itemId": "itm_987",
|
||||
"isCompleted": true,
|
||||
"completedBy": "usr_sandro",
|
||||
"completedByName": "Sandro"
|
||||
},
|
||||
"timestamp": "2026-10-26T13:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 4. Granulare Workspace-Echtzeitsynchronisation (`/api/v1/ws/docs/{id}`)
|
||||
|
||||
MindSift nutzt für die Outliner-Synchronisation eine schlanke, hochgradig reaktive In-Memory-Raumarchitektur (`DocRoom` in [`internal/ws/docroom.go`](../../internal/ws/docroom.go)). Clients verbinden sich mit ihrer eindeutigen `clientId`:
|
||||
|
||||
```
|
||||
wss://<domain>/api/v1/ws/docs/<docId>?token=<jwt>&clientId=<cli_...>&userName=<Name>&userColor=<Hex>
|
||||
```
|
||||
|
||||
### 4.1 JSON-Echtzeit-Eventprotokoll
|
||||
Die Kommunikation erfolgt über leichtgewichtige JSON-Nachrichten ohne binären CRDT-Overhead:
|
||||
|
||||
| Event-Typ | Richtung | Zweck / Payload |
|
||||
|---|---|---|
|
||||
| `doc_state` | Server ➔ Client | Wird direkt beim Verbinden gesendet: Liefert den aktuellen In-Memory-Zustand (`title`, `blocks[]`, `activeCursors[]`). |
|
||||
| `block_updated` | Bidirektional | Feingranulare Inhalts- oder Statusänderung eines Blocks (`blockId`, `content`, `level`, `type`, `checked`, `collapsed`). |
|
||||
| `block_created` | Bidirektional | Erzeugt einen neuen Block (`block`, `afterBlockId`, `newIndex`). |
|
||||
| `block_deleted` | Bidirektional | Löscht einen Block anhand seiner `blockId`. |
|
||||
| `block_moved` | Bidirektional | Verschiebt einen Block / Unterbaum (`blockId`, `newIndex`, `level`). |
|
||||
| `title_updated` | Bidirektional | Aktualisiert den Dokumententitel (`title`). |
|
||||
| `cursor_moved` | Bidirektional | **Ephemere Live-Präsenz & Cursor:** Sendet Benutzer-Avatar, Cursorposition und Textselektion (`clientId`, `userId`, `userName`, `userColor`, `blockId`, `selectionStart`, `selectionEnd`). Wird nicht in SQLite gespeichert! |
|
||||
| `client_left` | Server ➔ Client | Wird automatisch ausgelöst, wenn ein Peer die Verbindung trennt, damit Remote-Cursor sofort verschwinden. |
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor UserA as Admin (Fenster A, cli_1)
|
||||
participant Room as DocRoom (Go RAM)
|
||||
actor UserB as User (Fenster B, cli_2)
|
||||
participant DB as SQLite (blocks, block_tags, documents)
|
||||
|
||||
UserA->>Room: Connect (clientId="cli_1", userName="Admin")
|
||||
Room-->>UserA: doc_state (title, blocks, activeCursors)
|
||||
UserB->>Room: Connect (clientId="cli_2", userName="User")
|
||||
Room-->>UserB: doc_state (title, blocks, activeCursors)
|
||||
|
||||
UserA->>Room: block_updated (clientId="cli_1", blockId="b1", content="Neu")
|
||||
Note over Room: Mutiert RAM-Slice & resetet 1.5s Timer
|
||||
Room-->>UserB: block_updated (clientId="cli_1", blockId="b1", content="Neu")
|
||||
Note over UserA: Echo Filter: cli_1 erhält kein Selbstecho!
|
||||
|
||||
UserB->>Room: cursor_moved (clientId="cli_2", blockId="b1", selStart=3)
|
||||
Room-->>UserA: cursor_moved (clientId="cli_2", blockId="b1")
|
||||
Note over UserA: Farbiger Cursor-Avatar erscheint an b1
|
||||
|
||||
Note over Room: 1.5s Inaktivität ohne neue Tastenanschläge
|
||||
Room->>DB: Debounce-Flush (blocks, Regex #tags -> block_tags, markdown_cache)
|
||||
```
|
||||
|
||||
### 4.2 Strikter Echo-Schutz (`clientId`)
|
||||
- Jeder Client generiert beim Start einen flüchtigen Schlüssel (`clientIdRef`, z. B. `cli_a8f9_l0z3`).
|
||||
- Der Go-Hub filtert beim Broadcasten im Raum alle Clients heraus, deren Verbindung oder `clientId` mit dem Absender übereinstimmt:
|
||||
```go
|
||||
if evt.ClientID != "" && client.clientID == evt.ClientID {
|
||||
continue
|
||||
}
|
||||
```
|
||||
- **Vorteil:** Verhindert Cursor-Resets, Fokus-Verluste und Race Conditions durch Selbstechos während des lokalen Tippens.
|
||||
|
||||
### 4.3 1.5-Sekunden Debounce-Flush in SQLite
|
||||
- Das Tippen im Editor erfolgt latenzfrei direkt im Arbeitsspeicher des `DocRoom`.
|
||||
- Sobald 1,5 Sekunden lang keine neuen Tastenanschläge oder Mutationen eintreffen (oder wenn der letzte Client den Raum verlässt), stößt der Raum einen synchronisierten Flush an:
|
||||
1. Persistiert alle Blöcke in `blocks`.
|
||||
2. Extrahiert alle Hashtags (`#tag`) per Regex und aktualisiert `block_tags`.
|
||||
3. Kompiliert die Blockliste (anhand der `level`-Tiefe) in Standard-Markdown und speichert sie in `documents.markdown_cache`.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 5. Berechtigungen & Bearbeitungsrechte
|
||||
|
||||
- **Kollaboration ohne blockierende Locks:** Alle Nutzer mit Schreibrechten (`permission === 'owner'` oder `'write'`) können zeitgleich im selben Dokument tippen. Pessimistische Locks oder Lease-Heartbeats wurden vollständig entfernt.
|
||||
- **Schreibschutz:** Verfügt ein Nutzer nur über Leserechte (`permission === 'read'`), ist das Eingabefeld gesperrt (`readOnly`). Er kann das Dokument jedoch in Echtzeit mitlesen und sieht die Cursors der Bearbeiter.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 6. Fallback-Strategie & Proxy-Resilienz (Dual-Transport Architektur)
|
||||
|
||||
In restriktiven Firmennetzwerken, hinter Unternehmens-Proxies (z.B. Zscaler, BlueCoat) oder fehlerhaft konfigurierten Reverse-Proxies wird der `Upgrade: websocket`-Header häufig verworfen (`NS_ERROR_CONNECTION_REFUSED`, Code 1006 / 1002 / 1008).
|
||||
|
||||
MindSift löst dieses Problem durch eine **vollwertige Dual-Transport-Architektur** ([FLAW-03](../flaws.md)):
|
||||
|
||||
```
|
||||
┌───────────────────────────────┐
|
||||
│ MindSift Frontend │
|
||||
└───────────────┬───────────────┘
|
||||
│
|
||||
┌─────────────────────┴─────────────────────┐
|
||||
▼ ▼
|
||||
Standard: WebSockets Fallback: SSE + REST
|
||||
(wss://.../api/v1/ws) (https://.../api/v1/events)
|
||||
│ │
|
||||
│ [Proxy Upgrade Block (Code 1006/Timeout)]│
|
||||
│ ────────────────────────────────────────> │
|
||||
▼ ▼
|
||||
┌────────────────────────┐ ┌────────────────────────┐
|
||||
│ Gorilla WS Conn │ │ Server-Sent Events │
|
||||
│ Bidirektionaler │ │ (text/event-stream) │
|
||||
│ TCP-WebSocket │ │ Standard HTTPS Port │
|
||||
└────────────┬───────────┘ └────────────┬───────────┘
|
||||
│ │
|
||||
└─────────────────────┬─────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ Realtime Hub │
|
||||
│ (Multiplexing WS & SSE) │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
### 1. Automatischer SSE-Fallback (`GET /api/v1/events`)
|
||||
- Scheitert der WebSocket-Handshake oder blockiert ein Proxy das Upgrade, wechselt MindSift innerhalb von 3,5 Sekunden automatisch auf einen **Server-Sent Events (SSE) Stream**.
|
||||
- Da SSE ein standardmäßiger HTTP/1.1- bzw. HTTP/2-GET-Request über Standard-Port 443/80 ist (`Content-Type: text/event-stream`), wird er von jedem Proxy und jeder Firewall ungehindert durchgeleitet.
|
||||
- Header `X-Accel-Buffering: no` und `Cache-Control: no-cache` verhindern Pufferung im Proxy; periodische Ping-Kommentare (`: ping\n\n`) alle 15 Sekunden halten Timeouts fern.
|
||||
- Der Client bleibt dadurch **vollständig echtzeitfähig** (`Live (SSE)`).
|
||||
|
||||
### 2. REST-Endpunkte für Upstream-Aktionen im SSE-Fallback-Modus
|
||||
Da SSE unidirektional (Server ➔ Client) ist, stellt MindSift für Schreibvorgänge im SSE-Modus REST-Äquivalente bereit:
|
||||
- `PATCH /api/v1/documents/:id/blocks`: Akzeptiert Block-Mutationen (`upserted`, `deletedIds`, `blockOrder`) und synchronisiert den In-Memory-Raum sowie SQLite.
|
||||
- `PUT /api/v1/documents/:id`: Aktualisiert Dokumenttitel und Metadaten.
|
||||
- `POST /api/v1/events/rooms`: Erlaubt SSE-Clients, Räumen dynamisch beizutreten (`join`) oder sie zu verlassen (`leave`).
|
||||
|
||||
### 3. Verbindungs-Indikator
|
||||
- **Grün pulsierend:** Live verbunden über WebSockets (`Live (WebSocket)`).
|
||||
- **Türkis pulsierend:** Live verbunden über Server-Sent Events (`Live (Server-Sent Events / Proxy-Stream)`).
|
||||
- **Gelb/Amber:** Temporäre Unterbrechung mit automatischem HTTP-Polling-Schutz.
|
||||
|
||||
Ausführliche Konfigurationsbeispiele für Nginx, Traefik, Caddy, Cloudflare und Apache finden sich im separaten Handbuch:
|
||||
👉 **[Reverse-Proxy-Konfigurationshandbuch](../reverse-proxy.md)**
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# 🏛️ Systemarchitektur & Technologie-Stack
|
||||
|
||||
Dieses Dokument beschreibt die interne Architektur von MindSift, die Interaktion zwischen den Systemkomponenten, das Concurrency- und Thread-Safety-Modell sowie die Deployment-Strategie.
|
||||
|
||||
---
|
||||
|
||||
## 🧩 1. Schichten-Architektur
|
||||
|
||||
MindSift ist als monolithische, hochperformante Single-Binary-Lösung konzipiert. Das Go-Backend liefert sowohl die REST- und WebSocket-Schnittstellen aus als auch die vorkompilierte React Single Page Application (SPA) als eingebettetes statisches Dateisystem (`embed.FS`).
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React 18) │
|
||||
│ Vite • TypeScript • Tailwind CSS • Lucide • Motion │
|
||||
└───────────────────────────┬────────────────────────────┘
|
||||
│ HTTPS REST / WSS WebSocket
|
||||
┌───────────────────────────▼────────────────────────────┐
|
||||
│ Go Chi Routing Layer │
|
||||
│ CORS • RealIP • Recoverer • Auth / Role Guard │
|
||||
├───────────────────────────┬────────────────────────────┤
|
||||
│ Business Engine │ Gorilla WebSocket │
|
||||
│ Triage • Tasks • Alarms │ Rooms • Hub • Doc-Locking │
|
||||
├───────────────────────────┴────────────────────────────┤
|
||||
│ Storage Abstraction │
|
||||
│ sqlite.go (modernc.org/sqlite pure Go, WAL-Mode) │
|
||||
└───────────────────────────┬────────────────────────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
▼ ▼
|
||||
/data/mindsift.db /data/workspace/
|
||||
(SQLite Datenbank) (Physische .md Dateien)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 2. Technologie-Stack
|
||||
|
||||
### Backend (Go 1.23)
|
||||
- **HTTP Routing:** [`github.com/go-chi/chi/v5`](https://github.com/go-chi/chi) – Minimalistischer, idiomatisch schneller Router mit Middleware-Ketten.
|
||||
- **WebSocket Hub:** [`github.com/gorilla/websocket`](https://github.com/gorilla/websocket) – Verbindungs-Upgrade, Keep-Alive/Ping-Pong und Room-Multiplexing.
|
||||
- **Datenbank-Treiber:** [`modernc.org/sqlite`](https://gitlab.com/cznic/sqlite) – CGO-freier, rein in Go übersetzter SQLite-Treiber für einfache plattformübergreifende Kompilierung und Containerisierung ohne GCC-Toolchain.
|
||||
- **Authentifizierung:** [`github.com/golang-jwt/jwt/v5`](https://github.com/golang-jwt/jwt) (HMAC-SHA256 Token) und [`golang.org/x/crypto/bcrypt`](https://pkg.go.dev/golang.org/x/crypto/bcrypt) für Passwort-Hashing.
|
||||
|
||||
### Frontend (React 18 SPA)
|
||||
- **Build-Tool:** [Vite 5](https://vitejs.dev/) für schnelles Hot-Module-Replacement (HMR) und optimierte Production-Bundles.
|
||||
- **Sprache:** TypeScript 5 mit strikter Typisierung.
|
||||
- **Design & Layout:** [Tailwind CSS 3](https://tailwindcss.com/) in Verbindung mit dem MindSift Single-File CSS Theming System.
|
||||
- **Icons & Animationen:** [Lucide React](https://lucide.dev/) und [Framer Motion](https://www.framer.com/motion/) für Gesten, Karten-Swipes und Übergänge.
|
||||
|
||||
---
|
||||
|
||||
## 📁 3. Verzeichnisstruktur des Projekts
|
||||
|
||||
```
|
||||
MindSift/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go # Einstiegspunkt: Initialisierung, Config, Store, Start
|
||||
├── internal/
|
||||
│ ├── api/
|
||||
│ │ ├── server.go # HTTP-Routen, Handler-Methoden, Middleware
|
||||
│ │ └── server_test.go # Integrationstests für alle REST- und Ingest-Routen
|
||||
│ ├── auth/
|
||||
│ │ ├── auth.go # JWT Validierung, Token-Erstellung, Context-Helpers
|
||||
│ │ └── apikey.go # API-Key Erzeugung (mindsift_sec_...) & SHA-256 Hashing
|
||||
│ ├── config/
|
||||
│ │ └── config.go # Environment-Variablen (PORT, DATA_DIR, JWT_SECRET, SMTP)
|
||||
│ ├── domain/
|
||||
│ │ └── models.go # Go-Structs für Thought, Document, List, User, Team etc.
|
||||
│ ├── storage/
|
||||
│ │ ├── sqlite.go # DB-Schema, CRUD-Operationen, Queries, Migrationen
|
||||
│ │ └── storage_test.go # Unit-Tests für Speicheroperationen
|
||||
│ └── ws/
|
||||
│ └── hub.go # WebSocket Client, Room-Management, Locking-Mechanismus
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Wiederverwendbare UI-Elemente (Modals, Avatar, Menüs)
|
||||
│ │ ├── context/ # MobileActionContext (Mobile Bottom Bar State)
|
||||
│ │ ├── features/
|
||||
│ │ │ ├── auth/ # SetupWizard & Login
|
||||
│ │ │ ├── capture/ # InstantCaptureBar (Schnelleingabe)
|
||||
│ │ │ ├── help/ # Integrierte Benutzerdokumentation
|
||||
│ │ │ ├── lists/ # Geteilte Listen & Einkauf
|
||||
│ │ │ ├── settings/ # Benutzer-, Admin- & Theme-Einstellungen
|
||||
│ │ │ ├── teams/ # Gruppen & Mitglieder-Management
|
||||
│ │ │ ├── triage/ # Triage-Deck, Aufgabenliste, Kanban & TaskModal
|
||||
│ │ │ └── workspace/ # Outliner-Editor, Datei-Explorer, Wiki & Rollup
|
||||
│ │ ├── lib/ # api.ts (HTTP/WS Client), types.ts, theme.ts
|
||||
│ │ ├── themes/ # Eigenständige Theme CSS-Dateien (dark, light, etc.)
|
||||
│ │ ├── App.tsx # Root-Komponente, Navigation, Top/Bottom Bars
|
||||
│ │ └── main.tsx # React DOM Bootstrap
|
||||
│ ├── package.json
|
||||
│ └── vite.config.ts
|
||||
├── Doku/ # Dieses technische Wiki
|
||||
├── Dockerfile # Multi-Stage Build Definition
|
||||
└── compose.yaml # Docker Compose Vorlage für Produktivbetrieb
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 4. Thread-Safety & Concurrency-Modell
|
||||
|
||||
Da SQLite Datei-basiert arbeitet und Go standardmäßig hochgradig nebenläufig (Multi-Threaded via Goroutines) operiert, sind folgende Schutzmaßnahmen implementiert:
|
||||
|
||||
### 1. SQLite Pragmas & WAL-Modus
|
||||
Im Storage-Layer ([`internal/storage/sqlite.go`](../internal/storage/sqlite.go)) wird die Datenbankverbindung beim Start zwingend mit folgenden DSN-Pragmas konfiguriert:
|
||||
```go
|
||||
dsn := filepath.Join(dataDir, "mindsift.db") +
|
||||
"?_pragma=busy_timeout(10000)&" +
|
||||
"_pragma=journal_mode(WAL)&" +
|
||||
"_pragma=synchronous(NORMAL)&" +
|
||||
"_pragma=foreign_keys(1)"
|
||||
```
|
||||
- **`journal_mode(WAL)`:** Write-Ahead Logging erlaubt parallele Leser (`SELECT`), während ein Schreiber (`INSERT`/`UPDATE`) aktiv ist. Leser blockieren niemals Schreiber und Schreiber blockieren niemals Leser.
|
||||
- **`busy_timeout(10000)`:** Sollte ein Schreibvorgang kurzzeitig blockiert sein, wartet SQLite bis zu 10.000 ms (10 Sekunden), bevor ein `SQLITE_BUSY` Fehler geworfen wird.
|
||||
- **`foreign_keys(1)`:** Erzwingt referenzielle Integrität und automatische Kaskadierungs-Löschungen (`ON DELETE CASCADE`).
|
||||
|
||||
### 2. Connection Pool Begrenzung
|
||||
SQLite unterstützt nur einen einzigen Schreib-Thread zur gleichen Zeit:
|
||||
```go
|
||||
db.SetMaxOpenConns(8)
|
||||
db.SetMaxIdleConns(4)
|
||||
db.SetConnMaxLifetime(time.Hour)
|
||||
```
|
||||
|
||||
### 3. Cursor-Deadlock-Vermeidung
|
||||
> **Kritische Backend-Regel:** Niemals verschachtelte Abfragen bei offenem `*sql.Rows`-Cursor ausführen!
|
||||
In Go hält ein offener Cursor eine Verbindung aus dem Pool besetzt. Führt der Code innerhalb von `for rows.Next()` eine weitere Abfrage auf `s.db` aus, kann der Connection-Pool bei mehreren parallelen Benutzern innerhalb von Millisekunden einfrieren.
|
||||
*Pattern:* Alle Zeilen werden sofort vollständig in ein Go-Slice gelesen, `rows.Close()` wird ausgeführt, und erst danach werden verknüpfte Entitäten geladen.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 5. Deployment & Container-Architektur
|
||||
|
||||
MindSift wird als schlanker, autarker Docker-Container ausgeliefert:
|
||||
1. **Stage 1 (Frontend Build):** `node:20-alpine` kompiliert TypeScript und generiert statische Assets nach `frontend/dist/`.
|
||||
2. **Stage 2 (Backend Build):** `golang:1.23-alpine` bettet `frontend/dist/` via `//go:embed` ein, führt Unit-Tests aus und kompiliert ein statisches Binary (`mindsift-server`).
|
||||
3. **Stage 3 (Production Runtime):** `alpine:3.20` mit `ca-certificates` und `tzdata`. Das finale Image ist ca. 45 MB groß und benötigt keine externen Datenbankserver oder Abhängigkeiten.
|
||||
|
||||
Alle persistenten Daten befinden sich in einem einzigen Verzeichnis `/data`:
|
||||
- `/data/mindsift.db` (SQLite Hauptdatenbank inklusive WAL-Journal und Shared-Memory-Dateien)
|
||||
- Notizen und Workspace-Blöcke werden zu 100% in der SQLite-Datenbank persistiert (keine losen Markdown-Dateien auf der Festplatte).
|
||||
@@ -0,0 +1,359 @@
|
||||
# 🗄️ Datenbank-Design & SQLite Schema
|
||||
|
||||
MindSift verwendet als persistente Datenspeicherung eine lokale SQLite-Datenbank (`/data/mindsift.db`). Dieses Dokument beschreibt das relationale Datenmodell, Tabellenstrukturen, Indizes, Fremdschlüsselbeziehungen und das Migrationskonzept.
|
||||
|
||||
---
|
||||
|
||||
## 📊 1. Entity-Relationship-Diagramm (ERD)
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
users ||--o{ thoughts : "erstellt"
|
||||
users ||--o{ documents : "besitzt"
|
||||
users ||--o{ lists : "besitzt"
|
||||
users ||--o{ task_lists : "besitzt"
|
||||
users ||--o{ api_keys : "besitzt"
|
||||
users ||--o{ team_members : "ist Mitglied in"
|
||||
|
||||
teams ||--o{ team_members : "besteht aus"
|
||||
teams ||--o{ task_list_shares : "erhält Zugriff"
|
||||
teams ||--o{ list_shares : "erhält Zugriff"
|
||||
teams ||--o{ doc_team_shares : "erhält Zugriff"
|
||||
teams ||--o{ workspace_folder_shares : "erhält Zugriff"
|
||||
|
||||
task_lists ||--o{ thoughts : "gruppiert"
|
||||
task_lists ||--o{ task_list_shares : "wird geteilt mit"
|
||||
|
||||
lists ||--o{ list_items : "enthält"
|
||||
lists ||--o{ list_shares : "wird geteilt mit"
|
||||
|
||||
documents ||--o{ blocks : "besteht aus"
|
||||
documents ||--o{ block_tags : "indiziert"
|
||||
blocks ||--o{ block_tags : "verknüpft"
|
||||
documents ||--o{ doc_shares : "geteilt mit Benutzern"
|
||||
documents ||--o{ doc_team_shares : "geteilt mit Teams"
|
||||
documents ||--o{ doc_user_folders : "eigene Platzierung"
|
||||
documents ||--o{ doc_pins : "Favorit von"
|
||||
|
||||
workspace_shared_folders ||--o{ workspace_folder_shares : "wird geteilt mit"
|
||||
workspace_shared_folders ||--o{ workspace_folder_mounts : "eingehängt bei"
|
||||
workspace_shared_folders ||--o{ documents : "enthält"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📑 2. Detaillierte Tabellen-Spezifikation
|
||||
|
||||
### 2.1 Benutzerverwaltung, System & Sicherheit
|
||||
|
||||
#### `users`
|
||||
Verwaltet Konten, Rollen, Multi-Channel Alarmziele und Benutzerpräferenzen.
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY, -- Präfix "usr_..."
|
||||
email TEXT UNIQUE NOT NULL, -- E-Mail-Adresse
|
||||
username TEXT NOT NULL DEFAULT '', -- Eindeutiger Login-Name
|
||||
password_hash TEXT NOT NULL, -- bcrypt Hash
|
||||
display_name TEXT NOT NULL, -- Anzeigename
|
||||
avatar_url TEXT NOT NULL DEFAULT '', -- Profilbild URL oder leer
|
||||
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
|
||||
is_active INTEGER NOT NULL DEFAULT 1, -- 1 = aktiv, 0 = gesperrt
|
||||
alarm_channels TEXT NOT NULL DEFAULT 'discord', -- Kommagetrennt: 'email', 'discord', 'webhook'
|
||||
alarm_email TEXT NOT NULL DEFAULT '', -- Ziel-E-Mail für Alarme
|
||||
discord_webhook_url TEXT NOT NULL DEFAULT '', -- Discord Webhook URL
|
||||
custom_webhook_url TEXT NOT NULL DEFAULT '', -- HTTP POST JSON Webhook URL
|
||||
notify_before_minutes INTEGER NOT NULL DEFAULT 30, -- Vorwarnzeit in Minuten
|
||||
created_at TEXT NOT NULL, -- ISO 8601 String
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
```
|
||||
|
||||
#### `system_settings`
|
||||
Globale Instanz-Konfiguration (Key-Value-Tabelle).
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
key TEXT PRIMARY KEY, -- 'sharing_mode', 'allow_registration', 'instance_name', 'smtp_...'
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
#### `user_invites`
|
||||
Einladungscodes für kontrollierte Benutzer-Registrierungen.
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS user_invites (
|
||||
id TEXT PRIMARY KEY, -- Präfix "inv_..."
|
||||
code TEXT UNIQUE NOT NULL, -- Format "MS-XXXXXXXX"
|
||||
email TEXT DEFAULT '', -- Optional: an bestimmte E-Mail gebunden
|
||||
role TEXT NOT NULL DEFAULT 'member', -- Rolle des neuen Kontos ('member', 'admin')
|
||||
created_by TEXT NOT NULL, -- Ersteller User-ID
|
||||
used_by TEXT DEFAULT '', -- Eingelöste User-ID
|
||||
expires_at TEXT NOT NULL, -- Gültig bis (ISO 8601)
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_invites_code ON user_invites(code);
|
||||
```
|
||||
|
||||
#### `api_keys`
|
||||
Persönliche API-Schlüssel für externe Skripte, Smarthome und Ingestion.
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id TEXT PRIMARY KEY, -- Präfix "key_..."
|
||||
user_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL, -- Bezeichnung (z.B. "Home Assistant")
|
||||
key_prefix TEXT NOT NULL, -- Erste 12 Zeichen im Klartext für Anzeige
|
||||
key_hash TEXT UNIQUE NOT NULL, -- SHA-256 Hash des kompletten Schlüssels
|
||||
scopes TEXT NOT NULL DEFAULT 'ingest,read',
|
||||
last_used_at TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Gruppen & Organisations-Teams
|
||||
|
||||
#### `organizations` & `teams` & `team_members`
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id TEXT PRIMARY KEY, -- Präfix "org_..."
|
||||
name TEXT NOT NULL,
|
||||
owner_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(owner_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id TEXT PRIMARY KEY, -- Präfix "tm_..."
|
||||
org_id TEXT NOT NULL DEFAULT 'default',
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_members (
|
||||
team_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
|
||||
joined_at TEXT NOT NULL,
|
||||
PRIMARY KEY (team_id, user_id),
|
||||
FOREIGN KEY(team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Gedanken, Triage & Aufgaben
|
||||
|
||||
In MindSift nutzen Quick Dumps (Triage) und Aufgaben dieselbe Basistabelle **`thoughts`**. Ein Quick Dump ist ein Gedanke mit `status = 'inbox'`, der durch Triage in `status = 'kept'` oder `status = 'converted'` übergeht.
|
||||
|
||||
#### `thoughts`
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS thoughts (
|
||||
id TEXT PRIMARY KEY, -- Präfix "th_..."
|
||||
user_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL, -- Textinhalt inkl. Hashtags
|
||||
tags TEXT NOT NULL DEFAULT '[]', -- JSON Array: ["smarthome", "keller"]
|
||||
priority TEXT NOT NULL DEFAULT 'normal', -- 'low' | 'normal' | 'high' | 'urgent'
|
||||
source TEXT NOT NULL DEFAULT 'manual', -- 'manual' | 'voice' | 'webhook'
|
||||
status TEXT NOT NULL DEFAULT 'inbox', -- 'inbox' | 'kept' | 'discarded' | 'converted'
|
||||
is_completed INTEGER NOT NULL DEFAULT 0, -- 0 = offen, 1 = erledigt
|
||||
converted_type TEXT DEFAULT '', -- 'task' | 'document' | 'list_item'
|
||||
converted_ref_id TEXT DEFAULT '', -- Verknüpfte ID (z.B. doc_id oder list_item_id)
|
||||
due_date TEXT DEFAULT '', -- Fälligkeitsdatum "YYYY-MM-DD"
|
||||
task_list_id TEXT NOT NULL DEFAULT '', -- Zugeordnete Aufgabenliste ("" = Standard)
|
||||
kanban_status TEXT NOT NULL DEFAULT 'todo',-- 'todo' | 'in_progress' | 'done' | column.id
|
||||
notes TEXT NOT NULL DEFAULT '', -- Zusätzliche Notizen / Beschreibung
|
||||
subtasks TEXT NOT NULL DEFAULT '[]', -- JSON Array von SubTask: [{id, title, isCompleted}]
|
||||
alarm_sent INTEGER NOT NULL DEFAULT 0, -- 1 = Alarm wurde bereits versendet
|
||||
sort_order INTEGER NOT NULL DEFAULT 0, -- Manuelle Reorder-Sortierung
|
||||
created_at TEXT NOT NULL,
|
||||
triaged_at TEXT DEFAULT '',
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);
|
||||
```
|
||||
|
||||
#### `task_lists` & `task_list_shares`
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS task_lists (
|
||||
id TEXT PRIMARY KEY, -- Präfix "tl_..."
|
||||
user_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '📋',
|
||||
color TEXT NOT NULL DEFAULT '#3b82f6',
|
||||
team_id TEXT NOT NULL DEFAULT '', -- Optional: an Team gebunden
|
||||
columns TEXT NOT NULL DEFAULT '[]', -- JSON Array von Kanban-Spalten
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_list_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_list_id TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')),
|
||||
target_id TEXT NOT NULL, -- Team-ID oder User-ID
|
||||
created_at TEXT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
UNIQUE(task_list_id, target_type, target_id),
|
||||
FOREIGN KEY(task_list_id) REFERENCES task_lists(id) ON DELETE CASCADE
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Workspace: Dokumente, Blöcke & Geteilte Ordner
|
||||
|
||||
#### `documents`, `blocks` & `block_tags` (Database-First Outliner Schema)
|
||||
Dokumente werden als Kopfdatensatz in `documents` gespeichert, während der Inhalt als feingranulare Blöcke in `blocks` verwaltet wird. Hashtags (#tags) werden per Regex analysiert und in `block_tags` abgelegt. MindSift speichert alle Notizen zu 100% in SQLite (keine physischen `.md`-Dateien auf Festplatte).
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS documents (
|
||||
id TEXT PRIMARY KEY, -- Präfix "doc_..."
|
||||
user_id TEXT NOT NULL DEFAULT '', -- Eigentümer (usr_...)
|
||||
space_id TEXT DEFAULT '',
|
||||
title TEXT NOT NULL DEFAULT '', -- Dokumenttitel
|
||||
folder_id TEXT NOT NULL DEFAULT '', -- Ordner-Zuordnung
|
||||
markdown_cache TEXT NOT NULL DEFAULT '', -- Kompiliertes Standard-Markdown (automatischer Cache)
|
||||
icon TEXT NOT NULL DEFAULT '📄', -- Emoji-Icon
|
||||
content TEXT NOT NULL DEFAULT '', -- Plaintext / Text-Repräsentation
|
||||
blocks_json TEXT NOT NULL DEFAULT '[]', -- Legacy/Zusatz-Cache
|
||||
tags TEXT NOT NULL DEFAULT '[]', -- JSON Array von Tags
|
||||
folder_path TEXT NOT NULL DEFAULT '', -- Relativer Ordnerpfad des Eigentümers
|
||||
is_pinned INTEGER NOT NULL DEFAULT 0, -- Globaler Pin
|
||||
is_archived INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_user ON documents(user_id, is_archived, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
id TEXT PRIMARY KEY, -- Block-ID (z.B. "blk_..." oder "docId-b0")
|
||||
document_id TEXT NOT NULL, -- Zugehöriges Dokument (doc_...)
|
||||
content TEXT NOT NULL DEFAULT '', -- Textinhalt des Blocks
|
||||
level INT NOT NULL DEFAULT 0, -- Hierarchieebene (0 = Root, 1 = Kind...)
|
||||
position REAL NOT NULL DEFAULT 0, -- Fließkommazahl für konfliktfreies Einfügen/Sortieren
|
||||
type TEXT NOT NULL DEFAULT 'bullet', -- 'bullet', 'todo', 'h1', 'h2', 'h3', 'code', 'quote', 'callout', 'divider'
|
||||
checked INTEGER NOT NULL DEFAULT 0, -- Für Checklisten (0 = offen, 1 = abgehakt)
|
||||
collapsed INTEGER NOT NULL DEFAULT 0, -- Für eingeklappte Unterbäume (1 = zugeklappt)
|
||||
lang TEXT NOT NULL DEFAULT '', -- Programmiersprache für Codeblöcke
|
||||
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_blocks_doc_pos ON blocks(document_id, position ASC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS block_tags (
|
||||
tag TEXT NOT NULL, -- Hashtag (z.B. "meeting", ohne führendes '#')
|
||||
document_id TEXT NOT NULL, -- Zugehöriges Dokument
|
||||
block_id TEXT NOT NULL, -- Zugehöriger Block
|
||||
PRIMARY KEY(tag, block_id),
|
||||
FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY(block_id) REFERENCES blocks(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_block_tags_tag ON block_tags(tag);
|
||||
CREATE INDEX IF NOT EXISTS idx_block_tags_doc ON block_tags(document_id);
|
||||
```
|
||||
|
||||
#### Entkoppelte Ordner-Freigabe (`workspace_shared_folders` & `workspace_folder_mounts`)
|
||||
Ermöglicht das Teilen kompletter Ordnerstrukturen. Verschiebt ein Empfänger den geteilten Ordner in seiner Hierarchie, wird **nur sein Mount-Pfad** aktualisiert – die Quellstruktur beim Eigentümer und anderen Empfängern bleibt unberührt!
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS workspace_shared_folders (
|
||||
id TEXT PRIMARY KEY, -- Präfix "wsf_..."
|
||||
owner_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_folder_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
folder_id TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')),
|
||||
target_id TEXT NOT NULL,
|
||||
permission TEXT NOT NULL CHECK(permission IN ('read', 'write')),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(folder_id, target_type, target_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workspace_folder_mounts (
|
||||
user_id TEXT NOT NULL,
|
||||
folder_id TEXT NOT NULL,
|
||||
mount_path TEXT NOT NULL, -- Eigener lokaler Pfad des Benutzers (z.B. "Arbeit/Projekte/Team-Docs")
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, folder_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS doc_user_folders (
|
||||
user_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL,
|
||||
folder_path TEXT NOT NULL DEFAULT '', -- Individuelle Ordnerplatzierung für geteilte Einzelnotizen
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, doc_id)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.5 Geteilte Listen & Listen-Einträge
|
||||
|
||||
#### `lists`, `list_items` & `list_shares`
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS lists (
|
||||
id TEXT PRIMARY KEY, -- Präfix "lst_..."
|
||||
user_id TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '🛒',
|
||||
list_type TEXT NOT NULL DEFAULT 'shopping',-- 'shopping', 'packing', 'inventory', 'todo'
|
||||
updated_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_lists_user ON lists(user_id, updated_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS list_shares (
|
||||
id TEXT PRIMARY KEY,
|
||||
list_id TEXT NOT NULL,
|
||||
target_type TEXT NOT NULL CHECK(target_type IN ('team', 'user')),
|
||||
target_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'editor' CHECK(role IN ('editor', 'viewer')),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(list_id, target_type, target_id),
|
||||
FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS list_items (
|
||||
id TEXT PRIMARY KEY, -- Präfix "itm_..."
|
||||
list_id TEXT NOT NULL,
|
||||
content TEXT NOT NULL, -- Bezeichnung des Eintrags
|
||||
category TEXT NOT NULL DEFAULT '', -- Regal/Kategorie (z.B. "Kühlregal")
|
||||
quantity TEXT NOT NULL DEFAULT '', -- Menge (z.B. "2x", "500g")
|
||||
is_completed INTEGER NOT NULL DEFAULT 0, -- 1 = abgehakt
|
||||
completed_by TEXT NOT NULL DEFAULT '', -- User-ID des Abhakers
|
||||
completed_by_name TEXT NOT NULL DEFAULT '',-- Name des Abhakers für Live-Badge
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT NOT NULL DEFAULT '',
|
||||
FOREIGN KEY(list_id) REFERENCES lists(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_list_items_sync ON list_items(list_id, is_completed, sort_order ASC);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 3. Migrationsstrategie
|
||||
|
||||
MindSift setzt auf **idempotente automatische In-Process-Migrationen** beim Serverstart ([`internal/storage/sqlite.go`](../internal/storage/sqlite.go)):
|
||||
1. **Schema-Bootstrap:** Ausführung des Basis-Schemas via `CREATE TABLE IF NOT EXISTS`.
|
||||
2. **Column & Index Migration Array:** Ein vordefiniertes Array idempotenter DDL-Befehle (z.B. `ALTER TABLE thoughts ADD COLUMN ...`, `CREATE TABLE IF NOT EXISTS doc_user_folders ...`).
|
||||
3. **Fehlerbehandlung:** Wenn Spalten bereits existieren (z.B. `"duplicate column name"`), wird der Fehler ignoriert und der Start fortgesetzt.
|
||||
4. **Vorteil:** Keine manuellen Migrations-Skripte nötig; Container-Updates können jederzeit ohne Downtime oder Vorbereitung über bestehende Datenbanken ausgerollt werden.
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
# Bekannte Schwachstellen, Risiken & Technische Schulden (Flaws)
|
||||
|
||||
Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse aller bekannten Limitierungen, Architektur-Kompromisse, Skalierungsrisiken und UX-Schwachstellen in **MindSift**. Sie dient als verbindlicher Backlog für zukünftige Refactorings und Architektur-Meilensteine.
|
||||
|
||||
---
|
||||
|
||||
## 📌 Übersicht & Risikomatrix
|
||||
|
||||
| ID | Bereich | Problem / Schwachstelle | Schweregrad | Risiko im Produktivbetrieb |
|
||||
|---|---|---|---|---|
|
||||
| **FLAW-01** | Workspace Sync | Blockierende Locks / Synchronisations-Konflikte | **Gelöst (v2.2)** | Behoben durch In-Memory DocRooms, Echo-Schutz per `clientId` und 1.5s Debounce-Flush |
|
||||
| **FLAW-02** | Storage | Dual-Storage Drift (SQLite vs. Dateisystem) | **Obsolet / Gelöst** | Kein Risiko: MindSift nutzt Pure-DB (keine `.md`-Dateien auf Platte) |
|
||||
| **FLAW-03** | Netzwerk / WS | Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback | **Gelöst (v2.2)** | Behoben durch Dual-Transport SSE-Fallback & globale Singleton-Verbindung |
|
||||
| **FLAW-04** | Datenbank / Suche | Volltextsuche via `LIKE '%query%'` statt SQLite FTS5 | **Mittel** | Performance-Einbrüche bei wachsenden Notizbeständen (>10.000 Blöcke) |
|
||||
| **FLAW-05** | Frontend DOM | Fehlende Virtualisierung im Outliner-Baum | **Mittel** | Frame-Drops und Lags bei Dokumenten mit >500 Blöcken auf Mobilgeräten |
|
||||
| **FLAW-06** | SQLite Concurrency | Single-Writer-Bottleneck bei hohem parallelem Schreibaufkommen | **Mittel** | `SQLITE_BUSY` Fehler bei vielen gleichzeitigen Team-Nutzern |
|
||||
| **FLAW-07** | Benachrichtigungen | Fehlende Dead-Letter-Queue für Webhook- & E-Mail-Alarme | **Niedrig** | Verlorene Benachrichtigungen bei temporärem Ausfall von Drittsystemen |
|
||||
| **FLAW-08** | Sicherheit / Auth | Statische JWT-Tokens ohne rotierende Refresh-Tokens & Blacklisting | **Niedrig** | Keine sofortige Sitzungsinvalidierung bei Rechteentzug |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Detaillierte Analyse & Maßnahmen
|
||||
|
||||
### FLAW-01: [GELÖST / IMPLEMENTIERT] Granulare Outliner-Synchronisation & Multi-User Kollaboration
|
||||
- **Status:** **Gelöst via In-Memory DocRooms, Echo-Schutz & Debounced Persistence**
|
||||
- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`modules/workspace.md`](./modules/workspace.md), `internal/ws/docroom.go`, `internal/storage/sqlite.go`, `frontend/src/lib/useDocumentSync.ts`, `frontend/src/features/workspace/OutlinerBlock.tsx`
|
||||
- **Ursache (Historisch):** MindSift nutzte früher pessimistisches Dokumentensperren (Lease-Locks mit Heartbeats). Bei parallelem Bearbeiten blockierten sich Nutzer gegenseitig; unausgereifte Sync-Versuche führten zu Selbstechos und Cursor-Jumps.
|
||||
- **Implementierte Lösung:**
|
||||
1. **In-Memory DocRooms:** Granularer WebSocket-Raum pro Dokument (`/api/v1/ws/docs/:id`) mit Mutex-geschütztem Block-State im Go-Backend.
|
||||
2. **Strikter Echo-Schutz per `clientId`:** Jede Verbindung besitzt eine eindeutige `clientId`. Der Hub filtert den Sender heraus und leitet Events nur an Peers weiter.
|
||||
3. **Live Presence & Ephemere Cursors:** Cursorposition und Textselektionen (`cursor_moved`) werden flüchtig an Peers gestreamt, ohne SQLite zu belasten.
|
||||
4. **Fokus- & Eingabeschutz (`OutlinerBlock.tsx`):** Remote-Updates werden nur blockiert, wenn `document.hasFocus()` vorliegt. Bei `window.onblur` und `input.onblur` wird sofort (0ms) geflusht; Callbacks werden über `useRef` geführt (keine Stale Closures).
|
||||
5. **1.5s Inaktivitäts-Debounce-Flush:** Nach 1,5s Inaktivität sichert der Raum persistent in SQLite (`blocks`, Regex `#tags` in `block_tags` und kompiliertes Markdown in `documents.markdown_cache`).
|
||||
6. **Vollständige Eliminierung von Locks:** Alle blockierenden Sperren wurden restlos entfernt. Mehrere Nutzer können zeitgleich schreiben.
|
||||
|
||||
---
|
||||
|
||||
### FLAW-02: [OBSOLET / GELÖST] Dual-Storage Drift (SQLite vs. Dateisystem)
|
||||
- **Status:** **Entkräftet & Behoben (Pure Database Architecture)**
|
||||
- **Klarstellung:** In früheren Entwürfen wurde angedacht, Notizen zusätzlich als Markdown-Dateien auf dem Dateisystem zu spiegeln. MindSift speichert Notizen und Blöcke jedoch **zu 100% relational in SQLite** (`documents` und `document_blocks`).
|
||||
- **Verifikation:**
|
||||
- Es existieren **keine** Markdown-Dateien auf der Festplatte.
|
||||
- Das Go-Backend schreibt keine Dateien in ein Notizverzeichnis.
|
||||
- Unnötige Verzeichniserstellungen (`docDir`/`workspace`) wurden aus `internal/storage/sqlite.go` vollständig entfernt.
|
||||
- **Ergebnis:** Es gibt keinen Dual-Storage und somit keinen File-Drift oder externe Dateikonflikte. Notizen verhalten sich vollständig transaktional und konsistent innerhalb der SQLite-Datenbank.
|
||||
|
||||
---
|
||||
|
||||
### FLAW-03: [GELÖST / IMPLEMENTIERT] Proxy-Inkompatibilität bei WebSocket-Upgrades & Polling-Fallback
|
||||
- **Status:** **Gelöst & Vollständig Implementiert (Dual-Transport SSE Fallback & Proxy-Resilienz)**
|
||||
- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`reverse-proxy.md`](./reverse-proxy.md), [`internal/ws/hub.go`](../internal/ws/hub.go), [`internal/api/server.go`](../internal/api/server.go), [`frontend/src/lib/api.ts`](../frontend/src/lib/api.ts)
|
||||
- **Problemursache:** Corporate Proxies (z.B. Zscaler, BlueCoat, strikte Firewalls) oder unvollständig konfigurierte Reverse-Proxies verwerfen den HTTP-Upgrade-Header `Upgrade: websocket` (`NS_ERROR_CONNECTION_REFUSED`, Status 1006 / 1002). Früher fiel der Client dabei auf 2-Sekunden-Polling zurück, was zu Verzögerungen und Last führte.
|
||||
- **Implementierte Lösung:**
|
||||
1. **Unidirektionaler Server-Sent Events (SSE) Fallback:**
|
||||
- Neuer Streaming-Endpunkt `GET /api/v1/events` im Go-Backend mit `Content-Type: text/event-stream`, `X-Accel-Buffering: no` und 15s Heartbeat-Pings (`: ping\n\n`).
|
||||
- Standard HTTPS Ports (443/80), wodurch kein Proxy den Datenstrom blockieren kann.
|
||||
2. **Hub-Multiplexing:**
|
||||
- `ws.Hub` verwaltet WebSocket- und SSE-Clients parallel in gemeinsamen Räumen (`rooms`, `userRooms`, `allClients`).
|
||||
- Sämtliche Events (`BroadcastToAll`, `BroadcastToUser`, `BroadcastRoom`) werden transparent an beide Transportarten verteilt.
|
||||
3. **REST-Parität für Outbound CRDT & Awareness:**
|
||||
- `PUT /api/v1/documents/:id/crdt`: Akzeptiert Delta-Updates (`update`) und broadcastet sie an alle Peers.
|
||||
- `POST /api/v1/documents/:id/awareness` und `DELETE /api/v1/documents/:id/awareness`: Übertragen Blockfokus und Cursor ohne WebSocket.
|
||||
- `POST /api/v1/events/rooms`: Erlaubt SSE-Clients dynamisches Room-Joining.
|
||||
4. **Frontend Realtime Transport Manager (`frontend/src/lib/api.ts`):**
|
||||
- Automatischer 3,5s-Watchdog: Schlägt das WebSocket-Upgrade fehl oder wird Code 1006/1002 gemeldet, wechselt MindSift nahtlos auf `EventSource('/api/v1/events')`.
|
||||
- Grüner/türkiser Statusindikator im Header zeigt `Live (WebSocket)` bzw. `Live (Server-Sent Events / Proxy-Stream)`.
|
||||
5. **Reverse-Proxy-Handbuch:**
|
||||
- Vollständige Produktions-Konfigurationen für Nginx, Traefik, Caddy, Cloudflare und Apache unter [`reverse-proxy.md`](./reverse-proxy.md).
|
||||
|
||||
---
|
||||
|
||||
### FLAW-04: Volltextsuche via `LIKE '%query%'`
|
||||
- **Betroffene Komponenten:** [`database.md`](./database.md), `internal/api/search.go`, `internal/api/thoughts.go`
|
||||
- **Ursache:** Suchabfragen über Gedanken, Notizblöcke und Aufgaben nutzen aktuell einfaches SQL:
|
||||
```sql
|
||||
WHERE content LIKE '%' || ? || '%'
|
||||
```
|
||||
- **Folgen:** `LIKE '%...'` kann B-Tree-Indizes prinzipbedingt nicht nutzen und erzwingt einen sequentiellen Full Table Scan. Bei Datenbanken mit Zehntausenden Blöcken führt dies zu CPU-Spitzen und trägen Suchreaktionen.
|
||||
- **Empfohlene Lösung:**
|
||||
1. Einrichten einer virtuellen SQLite FTS5-Tabelle (`document_blocks_fts`) mit Tokenizer (`unicode61` oder `trigram`).
|
||||
2. Automatische Synchronisierung über SQLite Trigger (`AFTER INSERT`, `AFTER UPDATE`, `AFTER DELETE`).
|
||||
|
||||
---
|
||||
|
||||
### FLAW-05: Fehlende Virtualisierung im Outliner-Baum
|
||||
- **Betroffene Komponenten:** [`modules/workspace.md`](./modules/workspace.md), `frontend/src/features/workspace/WorkspaceView.tsx`
|
||||
- **Ursache:** Jeder Block im Notizbaum wird als eigenes DOM-Element gerendert. Enthält ein Dokument 500 bis 2000 Blöcke (z. B. lange Projektjournale oder Besprechungsprotokolle), befinden sich tausende verschachtelte `div`- und `contenteditable`-Elemente gleichzeitig im DOM.
|
||||
- **Folgen:** Auf älteren Smartphones oder bei schnellem Tippen kommt es zu spürbaren Input-Latenzen und Ruckeln beim Scrollen.
|
||||
- **Empfohlene Lösung:**
|
||||
1. Implementierung einer virtuellen Fensterung (Virtual Windowing) für Blöcke außerhalb des Sichtbereichs.
|
||||
2. Automatisches Einklappen (*Collapsing*) tiefer Hierarchieebenen beim initialen Laden langer Dokumente.
|
||||
|
||||
---
|
||||
|
||||
### FLAW-06: SQLite Concurrency & Single-Writer-Bottleneck
|
||||
- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), [`database.md`](./database.md), `internal/storage/sqlite.go`
|
||||
- **Ursache:** Obwohl SQLite mit `PRAGMA journal_mode = WAL;` parallele Leser erlaubt, kann zu jedem Zeitpunkt immer nur **ein einziger Thread** schreiben. Bei intensiver gleichzeitiger Nutzung (z. B. 20 Teammitglieder tippen gleichzeitig in Listen und Notizen, während im Hintergrund Webhook-Events einlaufen) können Schreib-Transaktionen aufeinander warten.
|
||||
- **Folgen:** Risiko von `database is locked (SQLITE_BUSY)`-Fehlern, wenn der Busy-Timeout überschritten wird.
|
||||
- **Empfohlene Lösung:**
|
||||
1. Setzen von `PRAGMA busy_timeout = 5000;` (5 Sekunden Wartezeit vor Fehlerwurf).
|
||||
2. Kapselung aller Schreibzugriffe über einen dedizierten Go-Channel / Single-Worker-Pool oder Migration auf PostgreSQL bei größeren Installationen.
|
||||
|
||||
---
|
||||
|
||||
### FLAW-07: Fehlende Dead-Letter-Queue für Benachrichtigungen
|
||||
- **Betroffene Komponenten:** [`modules/tasks.md`](./modules/tasks.md), `internal/scheduler/alarm_ticker.go`
|
||||
- **Ursache:** Der Alarm-Ticker überprüft minütlich fällige Aufgaben und sendet Webhooks bzw. E-Mails. Schlägt der HTTP-Post an den externen Webhook fehl (z. B. Zielserver vorübergehend offline), wird der Fehler lediglich ins Log geschrieben.
|
||||
- **Folgen:** Der Alarm wird nicht erneut versucht und die Benachrichtigung geht dauerhaft verloren.
|
||||
- **Empfohlene Lösung:**
|
||||
1. Einführung einer Tabelle `notification_outbox` mit Spalten `retry_count`, `next_retry_at`, `status`.
|
||||
2. Exponentieller Backoff (1m, 5m, 15m, 1h) und Status-Markierung (*failed*, *delivered*).
|
||||
|
||||
---
|
||||
|
||||
### FLAW-08: Statische JWT-Tokens ohne Blacklisting
|
||||
- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), `internal/api/auth.go`
|
||||
- **Ursache:** Nach dem Login wird ein JWT ausgestellt, das clientseitig im `localStorage` gespeichert wird. Der Server prüft lediglich die kryptografische Signatur des Tokens, führt aber keine serverseitige Sitzungsliste.
|
||||
- **Folgen:** Wird einem Benutzer die Berechtigung entzogen oder ändert er sein Passwort, bleibt ein bereits ausgestellter Token bis zu seinem Ablaufdatum (z. B. 7 Tage) gültig.
|
||||
- **Empfohlene Lösung:**
|
||||
1. Einführung einer `revoked_tokens`-Tabelle oder einer `token_version`-Spalte in der `users`-Tabelle.
|
||||
2. Implementierung von kurzlebigen Access-Tokens (15 Min.) in Verbindung mit rotierenden Refresh-Tokens in `httpOnly`-Cookies.
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Verwandte Dokumente
|
||||
- 🏗️ [Gesamtarchitektur](architecture.md)
|
||||
- 💾 [Datenbank-Design & Tabellenschemata](database.md)
|
||||
- 🔌 [WebSocket & Live-Protokoll](api/websocket.md)
|
||||
- 🧠 [Workspace-Modul (Outliner)](modules/workspace.md)
|
||||
- 🛡️ [Rechte & Freigabesystem](sharing-and-permissions.md)
|
||||
@@ -0,0 +1,62 @@
|
||||
# 🛒 Kernmodul 4: Geteilte Listen & Kollaboration
|
||||
|
||||
Das Modul für geteilte Listen ([`SharedListsView.tsx`](../../frontend/src/features/lists/SharedListsView.tsx)) ist auf **Echtzeit-Kollaboration im Alltag** ausgelegt (z.B. gemeinsamer Wocheneinkauf im Supermarkt, Packlisten für Reisen, Inventarlisten oder schnelle Checklisten).
|
||||
|
||||
---
|
||||
|
||||
## 🛍️ 1. Listen-Typen & Kategorien
|
||||
|
||||
Jede Liste besitzt einen Typ, ein Icon und eine Farb-Codierung:
|
||||
|
||||
| Listen-Typ | Icon | Einsatzzweck | Typische Kategorien |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `shopping` | 🛒 | Supermarkt- & Lebensmitteleinkauf | Obst & Gemüse, Kühlregal, Vorrat, Drogerie |
|
||||
| `packing` | 🎒 | Urlaubs- & Reise-Packlisten | Kleidung, Elektronik, Dokumente, Kosmetik |
|
||||
| `inventory` | 📦 | Vorratskammer, Keller, Homelab-Hardware | Regal 1, Gefrierschrank, Server-Rack |
|
||||
| `todo` | 📝 | Einfache Gruppen-Checklisten | Heute, Später, Ideen |
|
||||
|
||||
### Automatische Kategorisierung & Mengenangaben
|
||||
- Einträge können mit einer Mengenangabe (z.B. `3x`, `500g`, `1 Kasten`) versehen werden.
|
||||
- Die intelligente Eingabe erkennt Einheiten automatisch oder trennt Mengen über Eingabefelder.
|
||||
- Einträge werden übersichtlich nach Kategorien gruppiert. Jede Kategorie kann zur besseren Übersicht eingeklappt werden.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 2. Echtzeit-Abhaken (Multi-User Live Sync)
|
||||
|
||||
Wenn mehrere Familienmitglieder oder Teammitglieder gleichzeitig im Supermarkt unterwegs sind, synchronisiert MindSift jeden Schritt in Echtzeit über WebSockets:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor Alice as Alice (im Markt)
|
||||
participant WS as WebSocket Hub
|
||||
actor Bob as Bob (zu Hause)
|
||||
|
||||
Alice->>WS: PATCH /items/123 (isCompleted: true)
|
||||
WS->>Bob: Event: ITEM_TOGGLED (itemId: "123", completedBy: "Alice")
|
||||
Note over Bob: Eintrag wird sofort durchgestrichen<br/>Grünes Badge "von Alice" erscheint
|
||||
```
|
||||
|
||||
- **Transparenz:** Beim Abhaken speichert MindSift `completed_by_name`. Ein dezentes grünes Pillen-Badge zeigt an, wer den Artikel eingepackt hat.
|
||||
- **Erledigte Einträge:** Abgehakte Artikel wandern nach unten in einen einklappbaren Bereich „Erledigt".
|
||||
- **Liste leeren:** Ein Klick auf „Erledigte entfernen" löscht alle abgehakten Einträge (`LIST_CLEARED`) für alle Teilnehmer.
|
||||
|
||||
---
|
||||
|
||||
## 🖐️ 3. Drag & Drop Umsortierung
|
||||
|
||||
Innerhalb einer Liste können Einträge frei per Drag & Drop sortiert werden:
|
||||
1. **Innerhalb einer Kategorie:** Ändert die Reihenfolge (`sort_order`).
|
||||
2. **Kategorie-Übergreifend:** Zieht der Benutzer einen Eintrag in eine andere Kategorie (z.B. von „Kühlregal" nach „Obst & Gemüse"), aktualisiert das Frontend optimistisch die Kategorie und sendet das Update ans Backend.
|
||||
|
||||
---
|
||||
|
||||
## 👥 4. Freigaben & Berechtigungen
|
||||
|
||||
Listen können über den einheitlichen Freigabe-Dialog ([`UnifiedShareModal.tsx`](../../frontend/src/components/UnifiedShareModal.tsx)) geteilt werden:
|
||||
- **Freigabe für Teams:** Alle aktuellen und zukünftigen Mitglieder der gewählten Gruppe erhalten automatisch Zugriff.
|
||||
- **Freigabe für Einzelbenutzer:** Gezielter Zugriff für einzelne Personen.
|
||||
- **Rollen:**
|
||||
- `editor`: Darf Einträge hinzufügen, bearbeiten, abhaken und löschen.
|
||||
- `viewer`: Nur Lesezugriff (Einträge ansehen, aber nicht verändern).
|
||||
@@ -0,0 +1,86 @@
|
||||
# 📋 Kernmodul 2: Aufgaben & Kanban-System
|
||||
|
||||
Das Aufgaben-Modul ([`TasksView.tsx`](../../frontend/src/features/triage/TasksView.tsx)) bietet eine flexible Aufgabenverwaltung mit frei konfigurierbaren Listen, Kanban-Boards, Subtask-Checklisten und einem automatisierten Multi-Channel Alarm-System.
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ 1. Datenmodell & Listen-Hierarchie
|
||||
|
||||
Aufgaben basieren technisch auf der Tabelle **`thoughts`** in SQLite. Ein Eintrag gilt als Aufgabe, wenn `status = 'kept'` oder `(status = 'converted' AND converted_type = 'task')` zutrifft.
|
||||
|
||||
```
|
||||
Benutzer (users)
|
||||
└── Aufgabenlisten (task_lists)
|
||||
├── Eigene Listen (z.B. "Privat", "Homelab")
|
||||
└── Mit Teams geteilte Listen (task_list_shares)
|
||||
└── Aufgaben (thoughts)
|
||||
├── Kanban-Status (todo, in_progress, done)
|
||||
├── Priorität (low, normal, high, urgent)
|
||||
├── Fälligkeitsdatum (dueDate)
|
||||
├── Notizen (notes)
|
||||
└── Subtasks (JSON Array von Teilaufgaben)
|
||||
```
|
||||
|
||||
### Listen-Sharing & Avatar-Stack
|
||||
Aufgabenlisten können für Gruppen/Teams freigegeben werden. Ein Avatar-Stack im Listen-Header und in der Tab-Leiste visualisiert alle Mitglieder, die Zugriff auf die jeweilige Liste haben.
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ 2. Ansichten: Liste vs. Kanban-Board
|
||||
|
||||
Der Benutzer kann jederzeit nahtlos zwischen zwei Ansichten umschalten:
|
||||
|
||||
### 1. Kompakte Listenansicht
|
||||
- Optimiert für schnelles Abarbeiten und Tastatursteuerung.
|
||||
- Zeigt Checkbox, Titel, Prioritäts-Badge, Herkunfts-Pille (`Webhook`, `Diktat`), Fälligkeitsdatum und Subtask-Zähler (`2/5`).
|
||||
- 1-Klick-Prioritäts-Wechsel: Klick auf ein Badge rotiert durch die Prioritäten.
|
||||
|
||||
### 2. Kanban-Board (Drag & Drop)
|
||||
- Standardmäßig 3 Spalten: **Zu erledigen** (`todo`), **In Arbeit** (`in_progress`) und **Erledigt** (`done`).
|
||||
- **Freie Spalten-Konfiguration:** Über das Spalten-Menü können Spalten umbenannt, farblich markiert oder neue Spalten hinzugefügt werden.
|
||||
- **Drag & Drop:** Aufgaben können per HTML5 Drag & Drop zwischen Spalten verschoben werden. Das Backend speichert die relative Position (`sort_order`) und den neuen `kanban_status` automatisch.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 3. Prioritäten & Subtasks
|
||||
|
||||
### 4-Stufen-Prioritätensystem
|
||||
Aufgaben unterstützen vier einheitliche Prioritätsstufen:
|
||||
- **Niedrig (`low`):** Dezent grau hinterlegt.
|
||||
- **Normal (`normal`):** Akzentfarbe.
|
||||
- **Wichtig (`high`):** Gelb hervorgehoben mit Warndreieck (`⚠️ Wichtig`).
|
||||
- **Dringend (`urgent`):** Rot pulsierend mit Flammen-Icon (`🔥 Dringend`).
|
||||
|
||||
### Subtasks (Teilaufgaben)
|
||||
Jede Aufgabe kann eine hierarchische Checkliste von Subtasks enthalten:
|
||||
- Gespeichert als JSON in der Spalte `subtasks`.
|
||||
- Fortschrittsanzeige im Kartenkopf (z.B. `3/5 erledigt`).
|
||||
- Einzelnes Abhaken synchronisiert direkt in die Datenbank.
|
||||
|
||||
---
|
||||
|
||||
## ⏰ 4. Multi-Channel Alarm-Scheduler
|
||||
|
||||
MindSift enthält einen serverseitigen Hintergrund-Ticker in Go ([`internal/api/server.go`](../../internal/api/server.go)), der fällige Aufgaben proaktiv an externe Kanäle meldet.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Ticker["Go Background Ticker (alle 30s)"] --> Query["SELECT thoughts WHERE due_date <= now + Vorwarnzeit AND alarm_sent = 0"]
|
||||
Query --> CheckChannels{"Welche Kanäle sind beim User aktiv?"}
|
||||
|
||||
CheckChannels -->|email| SendSMTP["E-Mail via SMTP versenden"]
|
||||
CheckChannels -->|discord| SendDiscord["Discord Webhook POST"]
|
||||
CheckChannels -->|webhook| SendCustom["Custom HTTP POST Webhook"]
|
||||
|
||||
SendSMTP --> MarkSent["UPDATE thoughts SET alarm_sent = 1"]
|
||||
SendDiscord --> MarkSent
|
||||
SendCustom --> MarkSent
|
||||
```
|
||||
|
||||
### Alarm-Konfiguration
|
||||
1. **Vorwarnzeit:** Einstellbar in Minuten (z.B. 15, 30, 60 Minuten vor Fälligkeit).
|
||||
2. **Kanal-Multi-Select:** Benutzer können beliebig viele Kanäle parallel aktivieren:
|
||||
- **E-Mail:** Versand über den in den Admin-Einstellungen hinterlegten SMTP-Server.
|
||||
- **Discord:** Benachrichtigung mit formatierter Embed-Karte in einen Discord-Kanal.
|
||||
- **Eigener Webhook:** Beliebige HTTP POST JSON Endpunkte (z.B. Gotify, Ntfy, Home Assistant).
|
||||
3. **Schutz vor Doppel-Alarmen:** Sobald ein Alarm abgesetzt wurde, wird `alarm_sent = 1` gesetzt. Wird das Datum der Aufgabe später geändert, setzt der Server `alarm_sent = 0` automatisch zurück.
|
||||
@@ -0,0 +1,88 @@
|
||||
# 📥 Kernmodul 1: Triage & Quick-Dump Engine
|
||||
|
||||
Das Triage-Modul von MindSift basiert auf dem *Getting Things Done (GTD)*-Prinzip. Es trennt die **Erfassung von Gedanken** strikt von der **Entscheidung, was damit geschehen soll**.
|
||||
|
||||
---
|
||||
|
||||
## 💡 1. Das 2-Phasen-Konzept
|
||||
|
||||
1. **Phase 1: Schnelles Erfassen (Instant Dump)**
|
||||
Ein Gedanke, ein Anruf, eine Idee oder ein Server-Alarm wird ohne Reibung erfasst. Der Benutzer muss in diesem Moment weder ein Projekt auswählen, noch Fälligkeitsdaten eingeben oder Ordner durchsuchen.
|
||||
2. **Phase 2: Fokussierte Triage (Inbox Zero)**
|
||||
Zu einem späteren Zeitpunkt (z.B. morgens oder beim Feierabend) sichtet der Benutzer den Stapel im Triage-Deck Karte für Karte und trifft für jeden Eintrag eine eindeutige Entscheidung.
|
||||
|
||||
---
|
||||
|
||||
## 🔄 2. Triage State Machine
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Capture: Gedanke erfasst (Desktop / Mobile / Webhook)
|
||||
|
||||
Capture --> Inbox: Standard (status: 'inbox')
|
||||
Capture --> Task: Direkt als Aufgabe (target: 'task')
|
||||
Capture --> Document: Direkt als Notiz (target: 'document')
|
||||
Capture --> ListItem: Direkt auf Liste (target: 'list_item')
|
||||
|
||||
state Inbox {
|
||||
[*] --> ActiveCard: Oben auf Stapel
|
||||
ActiveCard --> Skipped: Leertaste / Später (Ans Ende)
|
||||
Skipped --> ActiveCard
|
||||
}
|
||||
|
||||
ActiveCard --> Discarded: Swipe links / H (Wegwerfen)
|
||||
ActiveCard --> Task: Swipe rechts / L (Als Aufgabe)
|
||||
ActiveCard --> TaskDated: T (Mit Fälligkeitsdatum)
|
||||
ActiveCard --> Document: D (In Notiz umwandeln)
|
||||
ActiveCard --> ListItem: Auf Liste schieben
|
||||
|
||||
Discarded --> Trash: status: 'discarded'
|
||||
Task --> TasksModule: status: 'kept', converted: 'task'
|
||||
TaskDated --> TasksModule: status: 'kept', converted: 'task', dueDate
|
||||
Document --> WorkspaceModule: status: 'converted', converted: 'document'
|
||||
ListItem --> ListsModule: status: 'converted', converted: 'list_item'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🖐️ 3. Steuerung & Gesten (Mobile vs. Desktop)
|
||||
|
||||
### Desktop-Bedienung (Tastatur-First)
|
||||
- `C` oder `/`: Fokussiert sofort die Eingabeleiste oben im Header.
|
||||
- `Enter`: Speichert den Gedanken sofort im Deck.
|
||||
- `←` oder `H`: Aktive Karte wegwerfen (`discard`).
|
||||
- `→` oder `L`: Aktive Karte sofort zur Aufgabe machen (`task`).
|
||||
- `T`: Datumsauswahl für Termin-Aufgabe öffnen.
|
||||
- `D`: Aktive Karte in eine vollwertige Workspace-Notiz umwandeln.
|
||||
- `Leertaste`: Karte überspringen und ans Ende des Stapels schieben („Später").
|
||||
|
||||
### Mobile Bedienung (Touch & Swipe)
|
||||
- **Mittlerer Plus-Button (`+`):** Öffnet das design-angeglichene Bottom-Sheet mit Spracheingabe, 4 Prioritäts-Buttons (`Niedrig`, `Normal`, `Wichtig`, `Dringend`) und Fälligkeitsdatum.
|
||||
- **Wischgeste nach links (Swipe Left):** Roter Indikator erscheint, Karte wird verworfen.
|
||||
- **Wischgeste nach rechts (Swipe Right):** Grüner Indikator erscheint, Karte wird sofort zur Aufgabe.
|
||||
- **Long-Press (Langes Drücken auf Karte):** Öffnet das Schnell-Aktions-Menü (Auf Liste, Mit Termin, Als Notiz, Später).
|
||||
|
||||
---
|
||||
|
||||
## 🎙️ 4. Spracheingabe (Speech-to-Text)
|
||||
|
||||
In der Desktop-Schnelleingabeleiste ([`InstantCaptureBar.tsx`](../../frontend/src/features/capture/InstantCaptureBar.tsx)) sowie im mobilen Triage-Modal ([`App.tsx`](../../frontend/src/App.tsx)) ist ein nativer Browser-Sprachassistent integriert:
|
||||
- Verwendet die standardisierte `SpeechRecognition` bzw. `webkitSpeechRecognition` API.
|
||||
- Erkennt Sprache auf Deutsch (`lang: 'de-DE'`).
|
||||
- Live-Transkription: Gesprochener Text wird in Echtzeit in das Textfeld eingefügt.
|
||||
- Haptisches Feedback: Vibrieren beim Starten und Stoppen der Aufnahme.
|
||||
- Markiert erfasste Gedanken automatisch mit dem Herkunfts-Attribut `source: 'voice'` (Diktat-Badge).
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 5. Webhook-Ingestion & Smarthome-Pille
|
||||
|
||||
MindSift kann externe Alarme (Home Assistant, Zigbee2MQTT, n8n, Proxmox, Monitoring) automatisch ins Triage-Deck einspeisen:
|
||||
|
||||
### Herkunftserkennung
|
||||
1. Wird ein Gedanke über `/api/v1/inbox` mit `X-API-Key` oder über `/api/v1/webhooks/ingest` eingeliefert, setzt das Backend automatisch `source = "webhook"`.
|
||||
2. Im Frontend visualisiert die Triage-Karte eine dezente Webhook-Pille (`<Webhook className="w-3 h-3 text-accent" /> Webhook`).
|
||||
3. Diese Kennzeichnung bleibt auch erhalten, wenn die Karte später zu einer Aufgabe umgewandelt wird.
|
||||
|
||||
### Live-WebSocket Update
|
||||
Nach erfolgreichem Ingest sendet der Server sofort ein `TASK_CREATED` Event über den WebSocket-Hub. Das geöffnete Triage-Deck fügt die neue Alarm-Karte sofort oben in den Stapel ein – ohne dass der Benutzer die Seite neu laden muss.
|
||||
@@ -0,0 +1,104 @@
|
||||
# 📝 Kernmodul 3: Workspace Outliner-Engine, Wiki-Links & Pure-DB Storage
|
||||
|
||||
Der MindSift Workspace ([`WorkspaceView.tsx`](../../frontend/src/features/workspace/WorkspaceView.tsx)) kombiniert ein **hierarchisches Bullet-Point Notizsystem (inspiriert von Logseq)** mit einem **persönlichen Wiki**, **Live-Kollaboration** und einem **globalen To-Do Rollup Dashboard**.
|
||||
|
||||
---
|
||||
|
||||
## 🌳 1. Die Logseq-Outliner-Engine
|
||||
|
||||
Im Gegensatz zu klassischen Fließtext-Editoren basiert der MindSift Workspace auf einer **flachen, relationalen Liste von Blöcken** (`blocks`-Tabelle in SQLite). Jeder Block ist ein gedanklicher Knotenpunkt mit einer flexiblen Einrückungsebene (`level: 0, 1, 2, ...`) und einer fortlaufenden Fließkommaposition (`position`):
|
||||
|
||||
```
|
||||
• Projekt MindSift v2.0 (level 0)
|
||||
• Architektur-Entscheidung (level 1)
|
||||
- [x] Database-First Blocks implementieren (level 2)
|
||||
- [ ] Performance Benchmark mit 10.000 Blöcken (level 2)
|
||||
• Notizen aus dem Dev-Meeting (level 1)
|
||||
> Wichtig: Keine verschachtelten SQLite Queries ausführen! (level 2)
|
||||
```
|
||||
|
||||
### Tastatur-Navigation & Shortcuts
|
||||
- `Enter`: Erstellt einen neuen Block auf derselben Hierarchieebene.
|
||||
- `Tab`: Rückt den aktuellen Block ein (wird Kindknoten des vorherigen Blocks).
|
||||
- `Shift + Tab`: Rückt den Block um eine Ebene aus (verringert die Tiefe).
|
||||
- `Shift + Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks (z.B. für mehrzeilige Notizen oder Code).
|
||||
- `Backspace` (auf leerem Block): Löscht den Block sofort. Das Ausrücken erfolgt strikt und kontrolliert über `Shift + Tab`.
|
||||
- `/` (Slash-Commands): Öffnet das Umwandlungsmenü (`/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/quote`, `/divider`).
|
||||
- **Markdown-Tastatur-Kürzel:** Tippen von `[] ` oder `[ ] ` wandelt den Block sofort in ein Todo um; `# `, `## `, `### ` erzeugt Überschriften.
|
||||
|
||||
### Eingabestabilität & Zombie-Fokus-Schutz (`OutlinerBlock.tsx`)
|
||||
1. **Echter Fokus-Check:** Remote-Updates (`block_updated`) werden nur dann abgewehrt, wenn der Block tatsächlich aktiv im Betriebssystem editiert wird:
|
||||
`isFocusedRef.current === true && document.hasFocus()`.
|
||||
2. **Sofort-Flush bei Fensterwechsel:** Sobald das Browser-Fenster den Fokus verliert (`window.addEventListener('blur', ...)`), wird ein offener 150ms-Debounce sofort gecancelt und der Text augenblicklich per WebSocket übertragen.
|
||||
3. **Stale Closures eliminiert:** Alle Callbacks werden in `useRef` gehalten, sodass asynchrone Debounce-Timer garantiert die aktuellste Komponenten-Referenz verwenden.
|
||||
|
||||
### Zoom-In Hoisting (Fokus-Modus)
|
||||
Klickt der Benutzer auf den Bullet-Dot (`•`) eines Blocks, blendet der Editor alle übergeordneten und benachbarten Blöcke aus und zoomt **ausschließlich in diesen Ast** hinein:
|
||||
- Der gewählte Punkt wird zur prominenten Überschrift im Fokus-Modus.
|
||||
- Eine klickbare Breadcrumb-Leiste oben zeigt den Pfad an (z.B. `Workspace / Projekte / MindSift / Architektur`).
|
||||
- Ein Klick auf ein beliebiges Breadcrumb-Element zoomt sofort auf die jeweilige Ebene zurück.
|
||||
|
||||
### Drag & Drop von Blöcken
|
||||
Der Bullet-Dot dient gleichzeitig als Drag-Anfasser. Wird ein Block verschoben, erkennt die Engine automatisch alle untergeordneten Kind-Blöcke (`isDescendantBlock`) und verschiebt den **kompletten Unterbaum** an die neue Zielposition.
|
||||
|
||||
---
|
||||
|
||||
## 🔗 2. Wiki-Links (`[[Link]]`), Web-Links (`/link`) & Hashtags
|
||||
|
||||
1. **Interne Wiki-Links:**
|
||||
- Text in doppelten eckigen Klammern (z.B. `[[Reiseplanung 2026]]`) wird automatisch als klickbare Notiz-Pille gerendert.
|
||||
- Klick darauf öffnet die verknüpfte Notiz direkt im Editor.
|
||||
2. **Slash-Befehl `/link` & Link-Modal:**
|
||||
- `/link` öffnet ein Modal mit zwei Modi:
|
||||
- **Notiz verknüpfen:** Durchsucht bestehende Notizen und fügt `[[Titel]]` ein.
|
||||
- **Web-Link:** Eingabe von Titel und URL, fügt Standard-Markdown `[Titel](url)` ein.
|
||||
3. **Hashtags (`#tag`):**
|
||||
- Hashtags im Fließtext werden automatisch indiziert (`block_tags`-Tabelle).
|
||||
- In der linken Leiste listet der Tag-Explorer alle verwendeten Schlagworte auf. Klick auf ein Tag filtert alle Dokumente und Blöcke, die dieses Tag enthalten.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ 3. Echtzeit-Kollaboration & Automatische Markdown-Kompilierung
|
||||
|
||||
MindSift setzt auf eine latenzfreie In-Memory Kollaborations-Architektur ([`internal/ws/docroom.go`](../../internal/ws/docroom.go)):
|
||||
|
||||
### Funktionsweise
|
||||
1. **In-Memory DocRoom pro geöffnetem Dokument:**
|
||||
- Hält den synchronen Zustand von `title` und `blocks[]` im RAM.
|
||||
- Granulare Mutations-Events (`block_updated`, `block_created`, `block_deleted`, `block_moved`, `title_updated`).
|
||||
- Ephemeres Presence-Tracking (`cursor_moved`, `client_left`) zeigt farbige Benutzer-Avatare im Header und Cursors am aktiven Block an.
|
||||
2. **Echo-Schutz:**
|
||||
- Jeder Client identifiziert sich mit einer eindeutigen `clientId`.
|
||||
- Der Server sendet Mutations-Events ausschließlich an andere Clients im selben Raum (`clientId != senderClientId`), um Cursor-Springen und Selbstechos zu verhindern.
|
||||
|
||||
### Automatische Markdown-Kompilierung & SQLite Debounce-Flush
|
||||
```
|
||||
Eingabe im OutlinerBlock
|
||||
│
|
||||
├──▶ WebSocket (block_updated Event)
|
||||
│ │
|
||||
│ ▼
|
||||
│ Go DocRoom: In-Memory Mutation & Broadcast an Peers
|
||||
│
|
||||
└──▶ 1.5s Inaktivitäts-Debounce (oder Raum-Exit)
|
||||
│
|
||||
▼
|
||||
SQLite Transaktion:
|
||||
1. Persistiert Blöcke in `blocks`
|
||||
2. Regex-Extraktion von #tags in `block_tags`
|
||||
3. Kompiliert Block-Hierarchie zu Standard-Markdown
|
||||
und speichert Cache in `documents.markdown_cache`
|
||||
```
|
||||
|
||||
1. **Kompiliertes Markdown:** Jeder Block wird gemäß seiner `level`-Tiefe mit 2 Leerzeichen pro Einrückungsebene als sauberes Markdown formatiert (`documents.markdown_cache`).
|
||||
2. **Pure Database Architecture:** Alle Daten liegen transaktional und sicher in SQLite. Es werden keine unkontrollierten Dateien auf der Festplatte abgelegt; Notizen können jederzeit per Klick als `.md`-Datei exportiert werden.
|
||||
|
||||
---
|
||||
|
||||
## 📊 4. Globaler To-Do Rollup Dashboard
|
||||
|
||||
Über den Tab **„Globale To-Dos"** bietet der Workspace eine aggregierte Übersicht aller im gesamten Wiki verteilten Aufgaben:
|
||||
- Scannt in Echtzeit alle unvollendeten `- [ ] ` Checkboxen über alle Ordner und Notizen hinweg.
|
||||
- Übersichtliche Gruppierung nach **Ordner ➔ Dokument ➔ Aufgabe**.
|
||||
- **Ordner-Filter:** Klick auf einen Ordner isoliert nur dessen Aufgaben für fokussiertes Arbeiten.
|
||||
- **2-Wege-Live-Sync:** Wird eine Checkbox im Rollup abgehakt, wird der Block in SQLite sofort auf `checked = 1` aktualisiert und via WebSocket an alle aktiven Clients gebroadcastet.
|
||||
@@ -0,0 +1,229 @@
|
||||
# 🛡️ Reverse-Proxy & WebServer-Konfigurationshandbuch
|
||||
|
||||
Dieses Dokument beschreibt die empfohlene Konfiguration von Reverse-Proxies und Ingress-Controllern für MindSift. MindSift nutzt eine **hybride Realtime-Architektur** bestehend aus bidirektionalen **WebSockets** und einem automatischen **Server-Sent Events (SSE) Fallback**, um selbst in restriktivsten Firmennetzwerken, hinter strikten Firewalls oder bei Deep-Packet-Inspection unterbrechungsfreie Live-Synchronisation zu gewährleisten.
|
||||
|
||||
---
|
||||
|
||||
## ⚡ Wichtige Anforderungen auf einen Blick
|
||||
|
||||
| Protokoll | Standard-Port | Benötigte Header & Einstellungen | Zweck |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`<br>`Connection "Upgrade"`<br>`proxy_read_timeout 3600s` | Bidirektionale Yjs-CRDT-Synchronisation & Awareness |
|
||||
| **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`<br>`proxy_buffering off`<br>`proxy_cache off` | Fallback bei blockiertem WebSocket-Upgrade |
|
||||
| **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Anhänge & CRDT-Zustandsblöcke |
|
||||
|
||||
---
|
||||
|
||||
## 1. Nginx
|
||||
|
||||
Eine typische `nginx.conf` bzw. Site-Konfiguration unter `/etc/nginx/sites-available/mindsift`:
|
||||
|
||||
```nginx
|
||||
# Map für sauberes Connection-Handling bei WebSocket-Upgrades
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name mindsift.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name mindsift.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/mindsift.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/mindsift.example.com/privkey.pem;
|
||||
|
||||
# Maximale Payload-Größe für Backups und große CRDT-Zustände
|
||||
client_max_body_size 64M;
|
||||
|
||||
# MindSift Frontend & REST API
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 1. WebSocket Endpunkte (/api/v1/ws)
|
||||
location /api/v1/ws {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Upgrade-Header zwingend erforderlich
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Verhindert vorzeitigen Verbindungsabbruch durch Proxy-Inaktivitäts-Timeout
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# 2. Server-Sent Events Fallback (/api/v1/events & /api/v1/sync/)
|
||||
location ~ ^/api/v1/(events|sync/) {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# Pufferung zwingend deaktivieren für sofortiges Event-Streaming
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_set_header X-Accel-Buffering no;
|
||||
|
||||
proxy_set_header Connection '';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_read_timeout 86400s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Traefik (Docker Compose / Ingress)
|
||||
|
||||
Bei Verwendung von Traefik Version 2/3 müssen keine expliziten WebSocket-Upgrades deklariert werden, da Traefik diese transparent durchleitet. Für Server-Sent Events und Timeouts empfehlen sich Middleware-Definitionen:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mindsift:
|
||||
image: ghcr.io/sandrodach/mindsift:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- PORT=8080
|
||||
- DB_PATH=/app/data/mindsift.db
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.mindsift.rule=Host(`mindsift.example.com`)"
|
||||
- "traefik.http.routers.mindsift.entrypoints=websecure"
|
||||
- "traefik.http.routers.mindsift.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.mindsift.loadbalancer.server.port=8080"
|
||||
|
||||
# Verhindert Pufferung und hält persistente SSE-Streams am Leben
|
||||
- "traefik.http.middlewares.mindsift-buffering.buffering.maxRequestBodyBytes=67108864"
|
||||
- "traefik.http.routers.mindsift.middlewares=mindsift-buffering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Caddy Server (Caddyfile)
|
||||
|
||||
Caddy unterstützt WebSockets und HTTP/2 Server-Sent Events von Haus aus vollständig ("out-of-the-box"):
|
||||
|
||||
```caddyfile
|
||||
mindsift.example.com {
|
||||
encode gzip zstd
|
||||
|
||||
# Notizen, CRDT & Event-Stream ohne Pufferung weiterleiten
|
||||
reverse_proxy 127.0.0.1:8080 {
|
||||
# Streaming für SSE optimieren
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Cloudflare Proxy (Orange Cloud)
|
||||
|
||||
Wird MindSift hinter dem Cloudflare CDN betrieben:
|
||||
1. **WebSockets aktivieren:**
|
||||
- Navigiere in das Cloudflare Dashboard: **Network** > **WebSockets** > Schalter auf **ON** stellen.
|
||||
2. **Cloudflare Rocket Loader / Auto Minify:**
|
||||
- Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe die Yjs-CRDT-Initialisierung stören können.
|
||||
3. **Cloudflare WAF / Bot Fight Mode:**
|
||||
- Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden.
|
||||
4. **SSE-Unterstützung:**
|
||||
- Cloudflare unterstützt Server-Sent Events nativ über HTTP/2 und HTTP/3. Dank der vom MindSift-Backend gesetzten Header `X-Accel-Buffering: no` und `Cache-Control: no-cache` streamt Cloudflare Echtzeit-Events verzögerungsfrei an die Clients.
|
||||
|
||||
---
|
||||
|
||||
## 5. Apache 2.4 (`mod_proxy_wstunnel`)
|
||||
|
||||
Unter Debian/Ubuntu:
|
||||
```bash
|
||||
sudo a2enmod proxy proxy_http proxy_wstunnel headers
|
||||
sudo systemctl restart apache2
|
||||
```
|
||||
|
||||
VirtualHost-Konfiguration (`/etc/apache2/sites-available/mindsift.conf`):
|
||||
|
||||
```apache
|
||||
<VirtualHost *:443>
|
||||
ServerName mindsift.example.com
|
||||
SSLEngine on
|
||||
SSLCertificateFile /etc/letsencrypt/live/mindsift.example.com/fullchain.pem
|
||||
SSLCertificateKeyFile /etc/letsencrypt/live/mindsift.example.com/privkey.pem
|
||||
|
||||
# 1. WebSocket Tunneling
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTP:Upgrade} =websocket [NC]
|
||||
RewriteRule /(.*) ws://127.0.0.1:8080/$1 [P,L]
|
||||
RewriteCond %{HTTP:Upgrade} !=websocket [NC]
|
||||
RewriteRule /(.*) http://127.0.0.1:8080/$1 [P,L]
|
||||
|
||||
# 2. SSE Response Flushing
|
||||
<Location "/api/v1/events">
|
||||
ProxyPass "http://127.0.0.1:8080/api/v1/events" flushpackets=on
|
||||
ProxyPassReverse "http://127.0.0.1:8080/api/v1/events"
|
||||
</Location>
|
||||
|
||||
ProxyPass / http://127.0.0.1:8080/
|
||||
ProxyPassReverse / http://127.0.0.1:8080/
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Diagnose & Verifikation
|
||||
|
||||
Mit den folgenden Befehlen lässt sich die Erreichbarkeit und Streaming-Funktion prüfen:
|
||||
|
||||
### 1. Server-Sent Events Test (Standard-HTTPS)
|
||||
```bash
|
||||
curl -N -H "Accept: text/event-stream" -H "Authorization: Bearer <DEIN_TOKEN>" https://mindsift.example.com/api/v1/events
|
||||
```
|
||||
*Erwartete Ausgabe:*
|
||||
```text
|
||||
: connected (transport: sse, id: sse_...)
|
||||
data: {"event":"SSE_CONNECTED","payload":{"sseId":"sse_...","transport":"sse"},"timestamp":"..."}
|
||||
: ping
|
||||
```
|
||||
|
||||
### 2. WebSocket Handshake Test
|
||||
```bash
|
||||
curl -i -N \
|
||||
-H "Connection: Upgrade" \
|
||||
-H "Upgrade: websocket" \
|
||||
-H "Host: mindsift.example.com" \
|
||||
-H "Origin: https://mindsift.example.com" \
|
||||
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
|
||||
-H "Sec-WebSocket-Version: 13" \
|
||||
https://mindsift.example.com/api/v1/ws?token=<DEIN_TOKEN>
|
||||
```
|
||||
*Erwartete Antwort:* `101 Switching Protocols`
|
||||
|
||||
---
|
||||
|
||||
## 🧭 Verwandte Dokumente
|
||||
- 🔌 [WebSocket & Live-Protokoll](api/websocket.md)
|
||||
- 🏗️ [Gesamtarchitektur](architecture.md)
|
||||
- 🚨 [Bekannte Schwachstellen & Flaws](flaws.md)
|
||||
@@ -0,0 +1,93 @@
|
||||
# 👥 Synchrones Teilen & Berechtigungskonzept
|
||||
|
||||
MindSift bietet ein mehrstufiges Kollaborations- und Freigabesystem. Es gewährleistet strikte Datensouveränität und Privatsphäre, ermöglicht aber gleichzeitig reibungslose Zusammenarbeit in Teams, Familien oder Unternehmen.
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 1. Globale Rollen & System-Isolation
|
||||
|
||||
### Globale Rollen
|
||||
- **Server-Admin (`role = 'admin'`):** Vollzugriff auf System-Einstellungen, E-Mail-Konfiguration (SMTP), Benutzerverwaltung, Einladungscodes und System-Statistiken.
|
||||
- **Mitglied (`role = 'member'`):** Regulärer Benutzer; arbeitet in seinen eigenen Notizen, Aufgaben und freigegebenen Teams.
|
||||
|
||||
### System-Isolations-Modi (`sharing_mode`)
|
||||
Der Server-Admin legt global fest, wie Benutzer miteinander interagieren dürfen:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ System-Isolations-Modi │
|
||||
├──────────────────────┬─────────────────────────┬───────────────────────┤
|
||||
│ strict_private │ teams_only │ open │
|
||||
│ (Strikt Isoliert) │ (Teams & Gruppen) │ (Offene Instanz) │
|
||||
│ │ [EMPFOHLENER STANDARD]│ │
|
||||
├──────────────────────┼─────────────────────────┼───────────────────────┤
|
||||
│ Kein Adressbuch │ Adressbuch zeigt nur │ Alle Benutzer sehen │
|
||||
│ Keine Freigaben │ gemeinsame Teammit- │ alle anderen Konten │
|
||||
│ Autarke Einzel-Instanz│ glieder │ im System │
|
||||
└──────────────────────┴─────────────────────────┴───────────────────────┘
|
||||
```
|
||||
|
||||
1. **`strict_private`:** Vollständige Abschottung. Benutzer wissen nicht voneinander. Keine Freigaben möglich.
|
||||
2. **`teams_only` (Standard):** Kollaboration ist strikt an Gruppen gebunden. Ein Benutzer kann Objekte nur mit Personen teilen, die mindestens in einer gemeinsamen Gruppe/Team mit ihm sind.
|
||||
3. **`open`:** Geeignet für Familien oder kleine Firmen. Jeder Benutzer kann für jeden anderen Benutzer Objekte freigeben.
|
||||
|
||||
---
|
||||
|
||||
## 🏢 2. Gruppen & Teams (`teams` & `team_members`)
|
||||
|
||||
Gruppen strukturieren Benutzer (z.B. „Familie", „Entwicklung", „Marketing"):
|
||||
- **Gruppen-Admin:** Darf Mitglieder einladen, Rollen ändern oder die Gruppe löschen.
|
||||
- **Gruppen-Mitglied:** Hat Zugriff auf alle mit der Gruppe geteilten Ressourcen (Ordner, Aufgabenlisten, Einkaufslisten, Notizen).
|
||||
|
||||
Wird eine Ressource für eine Gruppe freigegeben, erhalten **alle aktuellen und zukünftigen Mitglieder** der Gruppe automatisch Zugriff, ohne dass Einzelberechtigungen gepflegt werden müssen.
|
||||
|
||||
---
|
||||
|
||||
## 📁 3. Entkoppelte Ordner-Freigabe (Decoupled Folder Mounts)
|
||||
|
||||
### Das traditionelle Problem
|
||||
In herkömmlichen Cloud-Systemen (wie Google Drive oder Nextcloud) führt das Verschieben eines geteilten Ordners oft dazu, dass er auch bei allen anderen Teilnehmern den Platz wechselt. Verschiebt ein Kollege den Ordner in sein Unterverzeichnis „Archiv", verschwindet er beim Ersteller aus dem Sichtfeld.
|
||||
|
||||
### Die MindSift-Lösung: Unabhängige Einhängepunkte (Mounts)
|
||||
MindSift entkoppelt die physische Ordner-Identität von der persönlichen Ordner-Hierarchie des Benutzers:
|
||||
|
||||
```
|
||||
Eigentümer (Alice):
|
||||
Ordner: "Kundenprojekte"
|
||||
└── Geteilter Ordner: "Projekt Phoenix" (wsf_123)
|
||||
├── spec.md
|
||||
└── roadmap.md
|
||||
|
||||
Empfänger (Bob):
|
||||
Ordner: "Meine Aufgaben"
|
||||
└── "Wichtiges"
|
||||
└── Einhängepunkt: "Projekt Phoenix" (wsf_123 eingehängt via mount_path)
|
||||
├── spec.md
|
||||
└── roadmap.md
|
||||
```
|
||||
|
||||
#### Wie es technisch funktioniert:
|
||||
1. Der geteilte Ordner existiert als Entität in `workspace_shared_folders`.
|
||||
2. Beim Eigentümer liegt der Ordner unter seinem Pfad.
|
||||
3. Jeder Empfänger erhält in `workspace_folder_mounts` seinen **eigenen lokalen `mount_path`**.
|
||||
4. **Unabhängiges Verschieben:** Schiebt Bob den Ordner an eine andere Stelle in seinem Dateibaum, wird **ausschließlich sein persönlicher `mount_path`** aktualisiert. Alice und andere Teammitglieder bemerken davon nichts.
|
||||
5. **Inhaltliche Synchronisation:** Verschiebt, erstellt oder bearbeitet jemand eine Notiz *innerhalb* des geteilten Ordners, wird der relative Unterpfad (`sub_folder_path`) synchronisiert, sodass alle dieselbe interne Dokumentstruktur sehen.
|
||||
|
||||
---
|
||||
|
||||
## 📄 4. Entkoppelte Notizen-Freigabe (`doc_user_folders`)
|
||||
|
||||
Dasselbe Prinzip gilt für einzeln geteilte Dokumente:
|
||||
- Erhält Benutzer Bob eine einzelne Notiz von Alice freigegeben, kann Bob diese Notiz in jeden beliebigen seiner eigenen Ordner einsortieren.
|
||||
- Seine persönliche Zuordnung wird in der Tabelle `doc_user_folders` (`user_id`, `doc_id`, `folder_path`) gespeichert.
|
||||
- Die Original-Notiz von Alice verbleibt an ihrem ursprünglichen Platz.
|
||||
|
||||
---
|
||||
|
||||
## 📋 5. Geteilte Aufgaben- & Einkaufslisten
|
||||
|
||||
- **Aufgabenlisten (`task_list_shares`):** Freigabe für Teams oder Einzelbenutzer mit Lese- oder Schreibrechten.
|
||||
- **Geteilte Listen (`list_shares`):**
|
||||
- Rolle `editor`: Einträge hinzufügen, ändern, abhaken und löschen.
|
||||
- Rolle `viewer`: Nur Lesezugriff (Echtzeit-Ansicht).
|
||||
- **Einheitlicher Freigabe-Dialog:** [`UnifiedShareModal.tsx`](../../frontend/src/components/UnifiedShareModal.tsx) bündelt alle Freigabeoptionen (Teams, Benutzer, Berechtigungsstufen) in einer konsistenten Benutzeroberfläche.
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
# 🎨 Single-File CSS Theming Engine & Design-System
|
||||
|
||||
MindSift implementiert ein radikal einfaches, modulares Theming-System. Anstatt Farb- und Geometriewerte in Dutzenden Tailwind-Klassen über hunderte Komponenten zu verstreuen, wird das komplette Erscheinungsbild der Plattform über **eine einzige CSS-Datei pro Theme** gesteuert.
|
||||
|
||||
---
|
||||
|
||||
## 💡 1. Philosophie: 1 Datei = 1 komplettes Theme
|
||||
|
||||
Jedes Theme liegt im Verzeichnis [`frontend/src/themes/`](../../frontend/src/themes/) und definiert alle visuellen Eigenschaften über standardisierte CSS-Variablen.
|
||||
|
||||
### Trennung von Struktur und Design
|
||||
- **React-Komponenten** definieren ausschließlich semantische Klassen (wie `.panel-surface`, `.input-field`, `.btn-primary`, `text-main`, `bg-canvas`).
|
||||
- **Die aktive Theme-CSS-Datei** bestimmt, welche konkreten Farben, Kontraste, Rahmenbreiten und Ecken-Rundungen diesen Klassen zugewiesen werden.
|
||||
|
||||
---
|
||||
|
||||
## 🧱 2. Die 6 standardisierten Variablen-Blöcke
|
||||
|
||||
Jede Theme-Datei gliedert sich in sechs exakt definierte Bereiche:
|
||||
|
||||
```css
|
||||
:root[data-theme="dein-theme"] {
|
||||
/* ==========================================================================
|
||||
1. AKZENTFARBE (Fokus, Primäraktionen, Highlights)
|
||||
========================================================================== */
|
||||
--accent: #2563eb; /* Primäre Schaltflächen & aktive Tabs */
|
||||
--accent-hover: #3b82f6; /* Hover-Zustand */
|
||||
--accent-dim: rgba(37, 99, 235, 0.15);/* Transparenter Hintergrund für Badges */
|
||||
--accent-contrast: #ffffff; /* Textfarbe auf Akzentflächen */
|
||||
|
||||
/* ==========================================================================
|
||||
2. OBERFLÄCHEN & HINTERGRÜNDE
|
||||
========================================================================== */
|
||||
--bg-canvas: #0e1013; /* Grundhintergrund der gesamten Seite */
|
||||
--bg-panel: #16181d; /* Panels, Karten, Modals & Seitenleisten */
|
||||
--bg-panel-elevated: #1f2229; /* Hervorgehobene Elemente & Hover-Panels */
|
||||
--bg-input: #121418; /* Hintergrund von Textfeldern */
|
||||
--bg-code: #0b0c0f; /* Codeblöcke & Tastatur-Badges */
|
||||
--bg-overlay: rgba(0, 0, 0, 0.75); /* Modal-Hintergrund Backdrop */
|
||||
|
||||
/* ==========================================================================
|
||||
3. TEXT & KONTRASTE
|
||||
========================================================================== */
|
||||
--text-main: #f4f4f5; /* Haupttext, Überschriften, Labels */
|
||||
--text-muted: #a1a1aa; /* Sekundärtext, Beschreibungen */
|
||||
--text-faint: #71717a; /* Dezentester Text, Tastaturhinweise */
|
||||
--text-on-accent: #ffffff; /* Lesbarer Text auf --accent */
|
||||
|
||||
/* ==========================================================================
|
||||
4. RAHMEN, LINIEN & SCHATTEN
|
||||
========================================================================== */
|
||||
--border-color: rgba(255, 255, 255, 0.08); /* Standard-Trennlinien */
|
||||
--border-strong: rgba(255, 255, 255, 0.18);/* Fokussierte Rahmen, Tabs */
|
||||
--shadow-panel: 0 4px 20px -2px rgba(0, 0, 0, 0.45); /* Box-Shadows */
|
||||
|
||||
/* ==========================================================================
|
||||
5. STATUS- & SIGNALFARBEN
|
||||
========================================================================== */
|
||||
--status-ok: #22c55e; /* Erledigt, Online, Aktiv (Grün) */
|
||||
--status-err: #ef4444; /* Fehler, Löschen, Dringend (Rot) */
|
||||
--status-warn: #eab308; /* Wichtig, Warnung, Gesperrt (Gelb) */
|
||||
--status-info: #3b82f6; /* Hinweise, Information (Blau) */
|
||||
|
||||
/* ==========================================================================
|
||||
6. RUNDUNGEN / GEOMETRIE
|
||||
========================================================================== */
|
||||
--radius-card: 0px; /* Eckenrundung von Panels & Karten */
|
||||
--radius-btn: 2px; /* Eckenrundung von Schaltflächen */
|
||||
--radius-input: 2px; /* Eckenrundung von Eingabefeldern */
|
||||
--radius-checkbox: 2px; /* Checkboxen */
|
||||
--radius-pill: 9999px; /* Pillen & Status-Badges */
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎭 3. Verfügbare Standard-Themes
|
||||
|
||||
| Theme-ID | Datei | Stil & Charakter | Standard-Geometrie |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `dark` | [`theme-dark.css`](../../frontend/src/themes/theme-dark.css) | Standard Dark Slate / Monochrom | Streng eckig (`0px` Cards, `2px` Buttons) |
|
||||
| `light` | [`theme-light.css`](../../frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalistisch | Modern dezent (`4px` Cards, `4px` Buttons) |
|
||||
| `high-contrast` | [`theme-high-contrast.css`](../../frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit AAA-Kontrast | Streng eckig (`0px`, kräftige Rahmen) |
|
||||
| `soft` | [`theme-soft.css`](../../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) |
|
||||
| *Template* | [`theme-template.css`](../../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar |
|
||||
|
||||
---
|
||||
|
||||
## 🔌 4. Tailwind-Bridge & Semantische Klassen
|
||||
|
||||
In [`frontend/src/index.css`](../../frontend/src/index.css) und `tailwind.config.js` sind die semantischen Utility-Klassen definiert, die sich automatisch an das gewählte Theme anpassen:
|
||||
|
||||
- `.panel-surface`: Wendet `--bg-panel`, `--border-color` und `--radius-card` an.
|
||||
- `.input-field`: Wendet `--bg-input`, `--border-color`, `--radius-input` und `--text-main` an.
|
||||
- `.btn-primary`: Akzent-Schaltfläche mit `--accent`, `--accent-hover`, `--text-on-accent` und `--radius-btn`.
|
||||
- `.btn-action`: Sekundär-Aktion mit dezentem Rahmen und Hover-Zustand.
|
||||
- `.checkbox-square`: Kontrollkästchen mit `--radius-checkbox`.
|
||||
- `.capture-pill`: Spezial-Pille für die Desktop-Schnelleingabeleiste mit `--radius-pill`.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 5. Ein eigenes Theme erstellen
|
||||
|
||||
Um ein neues Theme (z.B. „Nordic Blue" oder „Cyberpunk") hinzuzufügen:
|
||||
1. Kopiere `frontend/src/themes/theme-template.css` nach `frontend/src/themes/theme-meintheme.css`.
|
||||
2. Ändere den Selektor `:root[data-theme="template"]` in `:root[data-theme="meintheme"]`.
|
||||
3. Passe Farben und Rundungen nach Belieben an.
|
||||
4. Importiere die CSS-Datei in [`frontend/src/lib/theme.ts`](../../frontend/src/lib/theme.ts) und trage das Theme im `AVAILABLE_THEMES`-Array ein.
|
||||
5. Das neue Theme steht sofort in den Kontoeinstellungen und im Profil-Dropdown zur Verfügung!
|
||||
@@ -3,495 +3,183 @@
|
||||
# 🧠 MindSift
|
||||
### *Der Kopf frei in drei Sekunden.*
|
||||
|
||||
[](https://gitea.io)
|
||||
[](https://hub.docker.com)
|
||||
[](https://go.dev)
|
||||
[](https://react.dev)
|
||||
[%20%2F%20Postgres-003B57?logo=sqlite&logoColor=white)](https://sqlite.org)
|
||||
[](#-designsystem--ästhetik-google-gemini-dna)
|
||||
[](LICENSE)
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#)
|
||||
[](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, kompromisslos schnelle Schaltstelle für spontane Gedanken, strukturierte Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen mit Zoom-Fokus und gemeinsame Echtzeit-Listen — ablenkungsfrei gestaltet, lokal als Standard-Markdown gespeichert 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)
|
||||
<br />
|
||||
|
||||
---
|
||||
<p align="center">
|
||||
<img src="screenshots/Triage_desktop.png" alt="MindSift Triage Desktop" width="85%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" />
|
||||
</p>
|
||||
|
||||
[Philosophie](#-die-philosophie) • [Funktionen](#-was-mindsift-auszeichnet) • [Schnellstart](#-schnellstart) • [Für wen?](#-für-wen-ist-mindsift-gemacht)
|
||||
|
||||
</div>
|
||||
|
||||
## 📜 Das Manifest
|
||||
---
|
||||
|
||||
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**.
|
||||
## 💡 Die Philosophie: *Erst raus aus dem Kopf – später sortieren*
|
||||
|
||||
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.
|
||||
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 auf dem Smartphone oder kurz vor Feierabend ein wichtiger Gedanke kommt, willst du keinen Ordner suchen, keine Datenbankfelder ausfüllen und keine starren Formulare bedienen. Du willst den Gedanken in drei Sekunden festhalten – und sicher sein, dass er verlässlich da ist, wenn du Zeit hast.
|
||||
|
||||
**MindSift löst dieses Problem durch ein dreistufiges Prinzip:**
|
||||
1. **Erfassen:** Sofortiger Brain-Dump ohne jede Reibung.
|
||||
2. **Sichten (Sift):** Spielerische Triage im Tinder-Style (Inbox Zero für dein Gehirn).
|
||||
3. **Strukturieren & Teilen:** Workspace für reifende Gedanken und geteilte Echtzeit-Listen für den Alltag.
|
||||
**MindSift trennt das Erfassen radikal vom Organisieren:**
|
||||
|
||||
1. **Sofort abladen (Quick Dump):** In die globale Eingabeleiste tippen, `Enter` drücken, Kopf wieder frei. Du kannst Gedanken aber auch direkt beim Eintippen gezielt einer Aufgabenliste, Einkaufsliste oder Notiz zuweisen.
|
||||
2. **Spielerisch sichten (Triage):** In ruhigen Momenten sichtest du deinen Stapel Karte für Karte – am Desktop per Tastaturkürzel oder auf dem Smartphone per Wischgeste. Mit einer Taste nach links verwerfen, auf „Später“ vertagen oder direkt in eine Aufgabe, Notiz oder Liste umwandeln.
|
||||
3. **Strukturiert vertiefen:** Aus losen Ideen werden vernetzte Outliner-Dokumente, hierarchische Unteraufgaben im Kanban-Board oder geteilte Listen für Familie, WG und Team.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features & Workflow
|
||||
## ✨ Was MindSift auszeichnet
|
||||
|
||||
```
|
||||
[ 🚀 Instant Capture ]
|
||||
Handy raus • PWA • Webhook • Sprache
|
||||
│
|
||||
▼
|
||||
[ 📥 Thought Inbox ]
|
||||
│
|
||||
▼
|
||||
[ 🃏 Tinder-Style Triage ]
|
||||
┌──────────┴──────────┐
|
||||
▼ ▼
|
||||
[ 👈 Nach Links ] [ 👉 Nach Rechts ]
|
||||
Verwerfen / Done Behalten / Vertiefen
|
||||
┌───────┼───────┐
|
||||
▼ ▼ ▼
|
||||
Task Doc Shared List
|
||||
```
|
||||
### ⚡ Quick-Dump & Triage-Deck (Das 3-Sekunden-Prinzip)
|
||||
|
||||
* **⚡ Capture First (Instant Puffer):**
|
||||
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).
|
||||
* **🃏 Triage im Tinder-Style:**
|
||||
Abends oder am Wochenende gehst du deine unstrukturierten Gedanken durch wie ein Kartendeck:
|
||||
* **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.
|
||||
* **📝 Notion-Flair, aber ohne Ballast:**
|
||||
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.
|
||||
* **🛒 Geteilte Listen & Family Spaces (Echtzeit):**
|
||||
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:**
|
||||
Smarthome-Alarme (z. B. Home Assistant, Zigbee2MQTT), Cronjobs oder Webhooks speisen dringende Aufgaben oder Messpunkte direkt in deine Inbox ein.
|
||||
* **🛡️ 100 % Self-Hosted & Datensouveränität:**
|
||||
Keine fremde Cloud liest mit. Ein schlanker Docker-Container, SQLite mit Write-Ahead-Log (WAL) oder PostgreSQL.
|
||||
* **Blitz-Erfassung:** Die Schnelleingabeleiste sitzt prominent im Header – jederzeit blitzschnell per Mausklick oder Tastendruck (`C` oder `/`) fokussierbar.
|
||||
* **Tastatur-Triage am Desktop:**
|
||||
* `H` oder `←`: Wegwerfen / Erledigt
|
||||
* `L` oder `→`: Sofort zur Aufgabe machen
|
||||
* `T`: Termin-Aufgabe mit Fälligkeitsdatum
|
||||
* `D`: In Workspace-Notiz umwandeln
|
||||
* `Leertaste`: Später (ans Ende des Stapels schieben)
|
||||
* **Mobile-First & Touch-optimiert:** Unterwegs kannst du Gedanken per Wischgeste (Swipe nach links zum Verwerfen, Swipe nach rechts zum Behalten) sortieren.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/Mobile_Triage.png" alt="MindSift Mobile Triage" width="280px" style="border-radius: 12px; box-shadow: 0 4px 16px rgba(0,0,0,0.25);" />
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Designsystem & Ästhetik: Google Gemini DNA
|
||||
### 📝 Workspace: Der hierarchische Outliner mit Zoom-Fokus
|
||||
|
||||
MindSift setzt konsequent auf die Ästhetik von Google Gemini – organisch, hochmodern und beruhigend:
|
||||
Der MindSift-Workspace vereint die Übersicht eines klassischen Dokumenten-Explorers mit der Leichtigkeit eines hierarchischen Outliners (inspiriert von Logseq und Roam).
|
||||
|
||||
* **Dark Mode First (OLED Deep Obsidian):**
|
||||
* 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.
|
||||
<p align="center">
|
||||
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace Outliner" width="90%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" />
|
||||
</p>
|
||||
|
||||
* **Jeder Gedanke ein Bullet-Point:**
|
||||
* `Enter`: Neuer Bullet-Point auf derselben Ebene
|
||||
* `Tab` / `Shift + Tab`: Unterordnen (einrücken) und ausrücken
|
||||
* `Shift + Enter`: Weicher Zeilenumbruch innerhalb desselben Blocks
|
||||
* **🔍 Fokus-Modus & Zoom-in (Hoisting):**
|
||||
* Klicke auf einen Bullet-Punkt (`•`), um **isoliert in diesen Ast hineinzuzoomen**.
|
||||
* Der gewählte Punkt wird zur prominenten Überschrift, sodass du dich ohne jede Ablenkung auf genau dieses Thema konzentrieren kannst.
|
||||
* Eine klickbare Breadcrumb-Leiste oben zeigt deinen aktuellen Pfad und führt dich mit einem Klick zurück in übergeordnete Ebenen oder ins Hauptdokument.
|
||||
* **Intuitives Drag & Drop für Bullet-Points:**
|
||||
* Verschiebe Absätze und ganze Gedankenbäume mühelos mit der Maus.
|
||||
* **Unterpunkte per Drop erstellen:** Ziehst du einen Bullet-Point direkt auf einen anderen Aufzählungspunkt, wird er automatisch als Unter-Bullet eingerückt. Eingeklappte Eltern-Punkte klappen beim Hineinlegen automatisch auf.
|
||||
* Untergeordnete Unterpunkte werden beim Ziehen automatisch als zusammenhängender Ast mitbewegt.
|
||||
* **Live-Kollaboration & Presence-Tracking:**
|
||||
* Gleichzeitiges, latenzfreies Bearbeiten von Dokumenten im Team ohne blockierende Sperren.
|
||||
* Farbige Avatare im Header und Live-Cursors am aktiven Block visualisieren genau, wer gerade wo tippt.
|
||||
* Strikter Echo-Schutz verhindert Cursor-Springen und Flackern beim Tippen.
|
||||
* **Slash-Befehle (`/`):** Tippe einfach `/` für `/todo`, `/link` (Notizen & Web-Links verknüpfen), `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/quote` oder `/divider`.
|
||||
* **Vernetzung & Hashtags:** Verlinke Notizen untereinander mit Wiki-Links (`[[Notiztitel]]`) und tagge Gedanken mit `#Hashtags`.
|
||||
* **Explorer mit Ordnern & Favoriten:** Organisiere Dokumente hierarchisch in Ordnern, verschiebe Notizen per Drag & Drop und hefte deine wichtigsten Dokumente als Favoriten ganz oben an.
|
||||
* **100 % Pure-DB & Markdown-Export:** Alle Notizen und Blöcke werden transaktional und sicher in SQLite gespeichert. Beim Speichern wird automatisch ein sauberer Markdown-Cache generiert, und Dokumente können jederzeit mit einem Klick als `.md`-Datei exportiert werden.
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Technische Architektur & Tech-Stack
|
||||
### ✅ Aufgabenlisten & Kanban-Board
|
||||
|
||||
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`).
|
||||
Behalte offene Aufgaben, Projekte und Termine im Blick – wahlweise als strukturierte Liste oder als visuelles Kanban-Board.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 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 │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
<p align="center">
|
||||
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" />
|
||||
</p>
|
||||
|
||||
### Stack-Entscheidungen & Rationale
|
||||
|
||||
| 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. |
|
||||
* **Flexible Ansichten (Liste & Kanban):** Wechsle mit einem Klick zwischen kompakter Listenansicht und dem interaktiven Kanban-Board (*Zu erledigen*, *In Arbeit*, *Wartend*, *Erledigt*).
|
||||
* **Unteraufgaben (Subtasks):**
|
||||
* Gliedere komplexe Aufgaben in beliebig viele Teilaufgaben.
|
||||
* Ein visueller Fortschrittsbalken und eine Prozentanzeige zeigen dir auf einen Blick, wie weit die Aufgabe gediehen ist.
|
||||
* **Freies Drag & Drop:**
|
||||
* Sortiere Aufgaben vertikal in deiner bevorzugten Reihenfolge.
|
||||
* Im Kanban-Board verschiebst du Karten per Drag & Drop direkt zwischen Status-Spalten oder ordnest sie innerhalb einer Spalte um.
|
||||
* Kein mühsames Suchen nach Greifern: Klicke und ziehe eine Aufgabe einfach direkt mit der Maus – ein kurzer Klick ohne Ziehen öffnet das Detail-Modal zum Bearbeiten.
|
||||
* **Prioritäten & Termine:** Hebe dringende oder wichtige Aufgaben mit Farbcodes und Icons hervor (🔥 Dringend, ⚠️ Wichtig). Setze Fälligkeitsdaten und filtere nach Hashtags.
|
||||
* **Geteilte Aufgabenlisten:** Erstelle separate Listen für Beruf, Homelab oder Familie und teile sie gezielt mit Gruppen, Teams oder einzelnen Nutzern.
|
||||
|
||||
---
|
||||
|
||||
## 🗄️ Datenbankschema
|
||||
### 🛒 Geteilte Listen (Einkauf & Checklisten)
|
||||
|
||||
Das Schema ist relational strukturiert, normalisiert und auf Lese- sowie Schreibgeschwindigkeit im Sub-Millisekundenbereich optimiert.
|
||||
Ob Wochenmarkt, Baumarkt oder Urlaubs-Packliste – erstelle geteilte Listen, die für gemeinsames Arbeiten in Echtzeit optimiert sind.
|
||||
|
||||
```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
|
||||
<p align="center">
|
||||
<img src="screenshots/Listen_desktop.png" alt="MindSift Geteilte Listen" width="90%" style="border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.3);" />
|
||||
</p>
|
||||
|
||||
USERS {
|
||||
uuid id PK
|
||||
string email UK
|
||||
string password_hash
|
||||
string display_name
|
||||
timestamp created_at
|
||||
}
|
||||
* **Automatische Hashtag-Kategorien:**
|
||||
* Tagge Artikel einfach mit einem Hashtag (z. B. `Hafermilch #Kühlregal` oder `Bananen #Obst`).
|
||||
* MindSift sortiert die Kategorien automatisch alphabetisch und zeigt sie in übersichtlichen Sektionen mit Kacheln an.
|
||||
* **Hashtag-Autovervollständigung:** Bereits beim Tippen eines `#` schlägt dir die Eingabe existierende Hashtags vor (case-insensitive) – mit Tastaturnavigation und Sofort-Übernahme per `Enter`.
|
||||
* **Mengenangaben & Schnell-Eingabe:** Erfasse Mengen (`2x`, `500g`) direkt im Eingabefeld.
|
||||
* **Drag & Drop zwischen Kategorien:** Ziehe Einträge mit der Maus nach oben oder unten oder schiebe sie direkt in einen anderen Hashtag-Bereich – der Hashtag im Text wird automatisch aktualisiert.
|
||||
* **Gemeinsam teilen mit Gruppen & Teams:**
|
||||
* Über das einheitliche Teilen-Modal kannst du jede Liste mit deinen Teams, Arbeitsgruppen oder Einzelpersonen freigeben.
|
||||
* Avatare und Freigabe-Badges im Header zeigen dir transparent, wer Zugriff hat.
|
||||
* **Echtzeit-WebSocket-Synchronisation:** Wird ein Artikel im Supermarkt abgehakt, hakt er sich bei allen Beteiligten im selben Augenblick live ab.
|
||||
* **Eckiges Abhaken & Aufräumen:** Erledigte Punkte wandern in einen separaten Bereich und können mit einem Klick aufgeräumt werden.
|
||||
|
||||
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
|
||||
}
|
||||
### 📋 Globale To-Dos: Das automatische Notizen-Rollup
|
||||
|
||||
DOCUMENT_BLOCKS {
|
||||
uuid id PK
|
||||
uuid document_id FK
|
||||
string block_type "paragraph | heading | task | code"
|
||||
jsonb content
|
||||
int sort_order
|
||||
timestamp updated_at
|
||||
}
|
||||
* **Automatische Erfassung:** MindSift durchsucht alle Workspace-Notizen und sammelt sämtliche Checkboxen (`- [ ]`) in einem zentralen Rollup-Dashboard.
|
||||
* **Ordner-Filter:** Isoliere Aufgaben bestimmter Projekte mit einem Klick.
|
||||
* **2-Wege-Echtzeit-Sync:** Hakst du ein To-Do im Rollup ab, wird die physische Markdown-Datei auf der Festplatte augenblicklich als `- [x]` aktualisiert.
|
||||
|
||||
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
|
||||
}
|
||||
### 🏢 Benutzer, Gruppen & Administration
|
||||
|
||||
LISTS {
|
||||
uuid id PK
|
||||
uuid space_id FK
|
||||
string title
|
||||
string list_type "shopping | checklist"
|
||||
timestamp updated_at
|
||||
}
|
||||
* **Geführte Ersteinrichtung:** Beim ersten Start führt dich ein Assistent durch die Einrichtung deines Administrator-Kontos.
|
||||
* **Eigenständiger Gruppen-Bereich:** Verwalte eigene Gruppen und Teams direkt über das Benutzer-Menü oben rechts.
|
||||
* **Gruppen-Administration:** Wer eine Gruppe anlegt oder zum Gruppen-Admin ernannt wird, hat die volle Kontrolle über die Mitglieder dieser Gruppe.
|
||||
* **Server-Sicherheit:** Schalte die öffentliche Registrierung ab und lade neue Mitglieder kontrolliert per Einladungscode ein.
|
||||
|
||||
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
|
||||
}
|
||||
```
|
||||
## 🚀 Schnellstart
|
||||
|
||||
### SQL DDL Definitionen (Auszug SQLite / Postgres kompatibel)
|
||||
MindSift läuft als leichtgewichtiger, einzelner Container und bringt Webserver, API, SQLite-Datenbank und Frontend direkt mit.
|
||||
|
||||
```sql
|
||||
-- Aktivierung von WAL & Foreign Keys für SQLite
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA busy_timeout = 5000;
|
||||
### Option 1: Der 1-Zeiler (Empfohlen)
|
||||
|
||||
-- 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
|
||||
);
|
||||
Führe diesen Befehl im Terminal deines Servers aus. Er erstellt das Verzeichnis, lädt die Konfiguration, generiert ein sicheres JWT-Geheimnis und startet den Container:
|
||||
|
||||
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);
|
||||
```bash
|
||||
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/install.sh | bash
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 API-Spezifikation & Webhooks
|
||||
### Option 2: Schneller Start mit zwei `curl`-Befehlen
|
||||
|
||||
Alle Endpunkte antworten mit `application/json` und nutzen Standard-HTTP-Statuscodes. Die Authentifizierung erfolgt über `Bearer <JWT>` oder dedizierte Ingestion-Keys.
|
||||
Falls du die Dateien direkt ohne Skript herunterladen möchtest:
|
||||
|
||||
### 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"
|
||||
}
|
||||
}
|
||||
```bash
|
||||
mkdir -p mindsift && cd mindsift
|
||||
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/docker-compose.yml -o docker-compose.yml
|
||||
curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/.env.example -o .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Gitea CI/CD Pipeline
|
||||
### Option 3: Manuelle `docker-compose.yml`
|
||||
|
||||
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`:
|
||||
Erstelle eine Datei namens `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mindsift:
|
||||
image: gitea.your-domain.com/user/mindsift:latest
|
||||
image: git.dsit.me/sandrodach/mindsift:dev
|
||||
container_name: mindsift
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -501,109 +189,52 @@ services:
|
||||
environment:
|
||||
- PORT=8080
|
||||
- DATA_DIR=/data
|
||||
- JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
|
||||
- BASE_URL=https://mindsift.local
|
||||
- LOG_LEVEL=info
|
||||
- JWT_SECRET=hier-ein-langes-sicheres-zufalls-geheimnis-eintragen
|
||||
- ALLOW_REGISTRATION=false
|
||||
# Optional: Mailserver für E-Mail-Alarme (auch im Admin-UI einstellbar)
|
||||
- SMTP_HOST=
|
||||
- SMTP_PORT=587
|
||||
- SMTP_USER=
|
||||
- SMTP_PASS=
|
||||
- SMTP_FROM=
|
||||
- SMTP_TLS=true
|
||||
```
|
||||
|
||||
Starte den Service:
|
||||
Starte den Container:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
Die Weboberfläche steht sofort unter `http://localhost:8080` bereit.
|
||||
|
||||
### 2. Standalone Docker Run
|
||||
|
||||
```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
|
||||
### 🌐 Ersteinrichtung aufrufen
|
||||
|
||||
```
|
||||
Phase 1: Foundation ────► Phase 2: Triage Deck ────► Phase 3: Workspace
|
||||
(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
|
||||
Öffne deinen Browser unter **`http://localhost:8080`** (bzw. die IP deines Servers).
|
||||
MindSift erkennt den Erststart automatisch und führt dich durch den Einrichtungs-Assistenten zum Erstellen des Administrator-Kontos.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Sicherheit & Datenschutz
|
||||
## 👥 Für wen ist MindSift gemacht?
|
||||
|
||||
* **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`).
|
||||
* **Für Macher & Wissensarbeiter**, die tagsüber dutzende Gedanken, Links und Aufgaben festhalten müssen, ohne aus dem Arbeitsfluss gerissen zu werden.
|
||||
* **Für Outliner-Fans**, die die Flexibilität von Logseq schätzen, aber ihre Notizen in einer klaren Ordnerstruktur auf dem eigenen Server haben wollen.
|
||||
* **Für Familien & WGs**, die gemeinsame Einkaufs- und Packlisten ohne Werbe-Apps und fremde Clouds synchronisieren möchten.
|
||||
* **Für Homelab- & Self-Hosting-Begeisterte**, die volle Datenhoheit, saubere Markdown-Dateien und automatisierte Webhooks auf eigener Hardware schätzen.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Deine Daten gehören dir
|
||||
|
||||
* **100 % Self-Hosted:** Keine fremden Cloud-Dienste, keine Telemetrie, kein Tracking.
|
||||
* **Kein Vendor-Lock-in:** Alle Notizen liegen als relationale Daten in SQLite und werden automatisch als Standard-Markdown kompiliert. Dokumente lassen sich jederzeit mit einem Klick als `.md`-Datei exportieren oder als SQLite-Dump sichern.
|
||||
|
||||
---
|
||||
|
||||
## 📄 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">
|
||||
<sub>Designed with ❤️ for clarity, speed and digital sovereignty.</sub>
|
||||
<sub>Gebaut für maximale Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
|
||||
</div>
|
||||
+24
-10
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"embed"
|
||||
"io/fs"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@@ -23,24 +23,37 @@ var embeddedDist embed.FS
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
log.Printf("🧠 Starting MindSift Server on port %s (DB: %s)", cfg.Port, cfg.DatabasePath)
|
||||
level := cfg.SlogLevel()
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
slog.Info("MindSift Server starting", "port", cfg.Port, "log_level", level.String())
|
||||
|
||||
store, err := storage.NewStore(cfg.DatabasePath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize storage: %v", err)
|
||||
slog.Error("Failed to initialize storage", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Ensure initial default workspace exists on fresh self-hosted boot
|
||||
if _, err := store.EnsureDefaultUser(); err != nil {
|
||||
log.Printf("Warning: could not seed default user: %v", err)
|
||||
// Seed SMTP defaults from environment variables into DB if not yet configured in DB
|
||||
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS)
|
||||
|
||||
if cfg.DemoMode {
|
||||
slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
|
||||
if err := store.EnsureDemoAccounts(); err != nil {
|
||||
slog.Warn("⚠️ Fehler beim Anlegen der Demo-Accounts", "error", err)
|
||||
} else {
|
||||
slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
|
||||
}
|
||||
}
|
||||
|
||||
wsHub := ws.NewHub()
|
||||
|
||||
staticFS, err := fs.Sub(embeddedDist, "dist")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to mount embedded dist filesystem: %v", err)
|
||||
slog.Error("Failed to mount embedded dist filesystem", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
srv := api.NewServer(cfg, store, wsHub, staticFS)
|
||||
@@ -54,9 +67,10 @@ func main() {
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("✨ MindSift ready at http://0.0.0.0:%s", cfg.Port)
|
||||
slog.Info("✨ MindSift ready", "url", "http://0.0.0.0:"+cfg.Port)
|
||||
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("HTTP server error: %v", err)
|
||||
slog.Error("HTTP server error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -64,7 +78,7 @@ func main() {
|
||||
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
||||
<-stop
|
||||
|
||||
log.Println("Shutting down MindSift server gracefully...")
|
||||
slog.Info("Shutting down MindSift server gracefully...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = httpServer.Shutdown(ctx)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
mindsift:
|
||||
image: git.dsit.me/sandrodach/mindsift:dev
|
||||
container_name: mindsift
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-8080}:8080"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
environment:
|
||||
- PORT=8080
|
||||
- DATA_DIR=/data
|
||||
- JWT_SECRET=${JWT_SECRET:-change_this_to_a_secure_random_key_min_32_chars}
|
||||
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
# Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging
|
||||
# - LOG_LEVEL=debug
|
||||
# - DEBUG=true
|
||||
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
|
||||
- MINDA_DEMO=${MINDA_DEMO:-false}
|
||||
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
|
||||
- SMTP_HOST=${SMTP_HOST:-}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-}
|
||||
- SMTP_PASS=${SMTP_PASS:-}
|
||||
- SMTP_FROM=${SMTP_FROM:-}
|
||||
- SMTP_TLS=${SMTP_TLS:-true}
|
||||
+14
-5
@@ -1,9 +1,6 @@
|
||||
services:
|
||||
mindsift:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: git.dsit.me/sandrodach/mindsift:latest
|
||||
image: git.dsit.me/sandrodach/mindsift:dev
|
||||
container_name: mindsift
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
@@ -15,5 +12,17 @@ services:
|
||||
- DATA_DIR=/data
|
||||
- JWT_SECRET=${JWT_SECRET:-change_this_to_a_secure_random_key_min_32_chars}
|
||||
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-true}
|
||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
# Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging
|
||||
# - LOG_LEVEL=debug
|
||||
# - DEBUG=true
|
||||
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
|
||||
- MINDA_DEMO=${MINDA_DEMO:-false}
|
||||
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
|
||||
- SMTP_HOST=${SMTP_HOST:-}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-}
|
||||
- SMTP_PASS=${SMTP_PASS:-}
|
||||
- SMTP_FROM=${SMTP_FROM:-}
|
||||
- SMTP_TLS=${SMTP_TLS:-true}
|
||||
+16
-2
@@ -4,17 +4,31 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="theme-color" content="#0b0f19" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="MindSift" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="MindSift — Der Kopf frei in drei Sekunden. Instant Capture, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='26' fill='%23131314'/><circle cx='50' cy='50' r='32' fill='none' stroke='%238ab4f8' stroke-width='7'/><path d='M36 52 L46 62 L66 38' fill='none' stroke='%23c58af9' stroke-width='8' stroke-linecap='round' stroke-linejoin='round'/></svg>" />
|
||||
<title>MindSift — Der Kopf frei in drei Sekunden</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
||||
<title>MindSift</title>
|
||||
<script>
|
||||
window.__deferredPwaPrompt = null;
|
||||
window.addEventListener('beforeinstallprompt', function (e) {
|
||||
e.preventDefault();
|
||||
window.__deferredPwaPrompt = e;
|
||||
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script>
|
||||
if ('caches' in window) {
|
||||
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
|
||||
}
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
|
||||
Generated
+2831
File diff suppressed because it is too large.
Load diff
@@ -11,8 +11,10 @@
|
||||
"test:run": "echo 'Frontend checks passed'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<rect width="512" height="512" rx="128" fill="#0b0f19"/>
|
||||
<circle cx="256" cy="256" r="164" fill="none" stroke="#8ab4f8" stroke-width="36"/>
|
||||
<path d="M184 266 L236 318 L338 194" fill="none" stroke="#c58af9" stroke-width="40" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 374 B |
@@ -1,18 +1,26 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "MindSift — Der Kopf frei in drei Sekunden",
|
||||
"short_name": "MindSift",
|
||||
"description": "Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
|
||||
"start_url": "/?mode=capture",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0b0f19",
|
||||
"theme_color": "#0b0f19",
|
||||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='26' fill='%23131314'/><circle cx='50' cy='50' r='32' fill='none' stroke='%238ab4f8' stroke-width='7'/><path d='M36 52 L46 62 L66 38' fill='none' stroke='%23c58af9' stroke-width='8' stroke-linecap='round' stroke-linejoin='round'/></svg>",
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "192x192 512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any maskable"
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
|
||||
+9
-24
@@ -1,33 +1,18 @@
|
||||
const CACHE_NAME = 'mindsift-pwa-v1';
|
||||
const ASSETS = ['/', '/index.html', '/manifest.json'];
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))
|
||||
);
|
||||
// Network-first / cache-clearing service worker to ensure fresh builds across all browsers
|
||||
self.addEventListener('install', () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys().then((keys) =>
|
||||
Promise.all(keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)))
|
||||
)
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
if (event.request.method !== 'GET' || event.request.url.includes('/api/')) {
|
||||
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('/')))
|
||||
);
|
||||
self.addEventListener('fetch', () => {
|
||||
// Always use native browser network fetch so API calls and fresh Vite bundles are never intercepted by stale caches
|
||||
return;
|
||||
});
|
||||
+1384
-304
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { useMobileActions } from '../context/MobileActionContext';
|
||||
import { triggerHaptic } from '../lib/api';
|
||||
|
||||
interface MobileHeaderMenuProps {
|
||||
activeTabLabel: string;
|
||||
}
|
||||
|
||||
export const MobileHeaderMenu: React.FC<MobileHeaderMenuProps> = ({ activeTabLabel }) => {
|
||||
const { groups, isMenuOpen, closeMenu } = useMobileActions();
|
||||
|
||||
if (!isMenuOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="sm:hidden fixed inset-0 !m-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 !m-0 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
||||
onClick={() => {
|
||||
triggerHaptic(8);
|
||||
closeMenu();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Menu Sheet below Header */}
|
||||
<div className="fixed top-14 left-2.5 right-2.5 max-h-[80vh] overflow-y-auto bg-panel border border-border rounded-2xl shadow-2xl p-3 space-y-3.5 z-50 animate-in fade-in slide-in-from-top-2 duration-150">
|
||||
{/* Menu Header */}
|
||||
<div className="flex items-center justify-between px-2 pb-2 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-accent"></span>
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-muted">
|
||||
{activeTabLabel} — Optionen & Aktionen
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(8);
|
||||
closeMenu();
|
||||
}}
|
||||
className="w-9 h-9 rounded-xl flex items-center justify-center text-muted hover:text-main hover:bg-canvas active:scale-95 transition-all"
|
||||
title="Menü schließen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<div className="py-6 text-center text-xs text-muted">
|
||||
Keine spezifischen Optionen für diese Ansicht verfügbar.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groups.map((group) => (
|
||||
<div key={group.id} className="space-y-1.5">
|
||||
{group.title && (
|
||||
<div className="px-2 text-[11px] font-bold uppercase tracking-wider text-faint">
|
||||
{group.title}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{group.items.map((item) => {
|
||||
const isAccent = item.variant === 'accent';
|
||||
const isDanger = item.variant === 'danger';
|
||||
const isActive = item.active;
|
||||
|
||||
let btnClass =
|
||||
'bg-canvas/70 hover:bg-canvas text-main border border-border/60';
|
||||
if (isAccent) {
|
||||
btnClass =
|
||||
'bg-accent text-on-accent border border-accent font-semibold shadow-sm';
|
||||
} else if (isDanger) {
|
||||
btnClass =
|
||||
'bg-status-err/10 text-status-err border border-status-err/30 font-medium';
|
||||
} else if (isActive) {
|
||||
btnClass =
|
||||
'bg-accent/15 text-accent border border-accent/40 font-semibold';
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(12);
|
||||
closeMenu();
|
||||
item.onClick();
|
||||
}}
|
||||
className={`w-full min-h-[46px] px-3.5 py-2.5 rounded-xl flex items-center justify-between gap-3 text-sm transition-all active:scale-[0.98] cursor-pointer ${btnClass}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1 text-left">
|
||||
{item.icon && (
|
||||
<span
|
||||
className={`flex-shrink-0 flex items-center justify-center w-5 h-5 ${
|
||||
isAccent
|
||||
? 'text-on-accent'
|
||||
: isDanger
|
||||
? 'text-status-err'
|
||||
: isActive
|
||||
? 'text-accent'
|
||||
: 'text-muted'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{item.label}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{item.badge !== undefined && item.badge !== '' && (
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${
|
||||
isAccent
|
||||
? 'bg-black/20 text-on-accent'
|
||||
: isActive
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'bg-panel text-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
{isActive && !isAccent && (
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,359 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Users,
|
||||
User as UserIcon,
|
||||
X,
|
||||
Plus,
|
||||
Trash2,
|
||||
Lock,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { User, Team, UnifiedShare } from '../lib/types';
|
||||
import { UserAutocomplete } from '../features/UserAutocomplete';
|
||||
import { UserAvatar } from './UserAvatar';
|
||||
|
||||
export interface UnifiedShareModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
resourceName: string;
|
||||
resourceIcon?: string | React.ReactNode;
|
||||
isOwner: boolean;
|
||||
currentUserId?: string;
|
||||
shares: UnifiedShare[];
|
||||
teams: Team[];
|
||||
collaborators: User[];
|
||||
allowRoleSelection?: boolean; // Default true (editor vs viewer)
|
||||
onAddShare: (targetType: 'team' | 'user', targetId: string, permission: 'editor' | 'viewer') => Promise<void>;
|
||||
onRemoveShare: (shareId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title = 'Freigeben & Berechtigungen',
|
||||
resourceName,
|
||||
resourceIcon,
|
||||
isOwner,
|
||||
currentUserId,
|
||||
shares,
|
||||
teams,
|
||||
collaborators,
|
||||
allowRoleSelection = true,
|
||||
onAddShare,
|
||||
onRemoveShare,
|
||||
}) => {
|
||||
const [shareType, setShareType] = useState<'team' | 'user'>('team');
|
||||
const [selectedTeamId, setSelectedTeamId] = useState('');
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>('editor');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const targetId = shareType === 'team' ? selectedTeamId : selectedUser?.id;
|
||||
if (!targetId) {
|
||||
setError(shareType === 'team' ? 'Bitte eine Gruppe auswählen' : 'Bitte einen Benutzer auswählen');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await onAddShare(shareType, targetId, selectedPermission);
|
||||
setSelectedTeamId('');
|
||||
setSelectedUser(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Fehler beim Hinzufügen der Freigabe');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (shareId: string) => {
|
||||
if (!window.confirm('Möchtest du diese Freigabe wirklich aufheben?')) return;
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await onRemoveShare(shareId);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Fehler beim Entfernen der Freigabe');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const excludedUserIds = [
|
||||
...(currentUserId ? [currentUserId] : []),
|
||||
...shares.filter((s) => s.targetType === 'user').map((s) => s.targetId),
|
||||
];
|
||||
|
||||
const availableTeams = teams.filter(
|
||||
(t) => !shares.some((s) => s.targetType === 'team' && s.targetId === t.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4 animate-in fade-in zoom-in-95 duration-150">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-border">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-btn bg-accent/15 text-accent flex items-center justify-center shrink-0">
|
||||
{resourceIcon ? (
|
||||
typeof resourceIcon === 'string' ? (
|
||||
<span className="text-base">{resourceIcon}</span>
|
||||
) : (
|
||||
resourceIcon
|
||||
)
|
||||
) : (
|
||||
<Users className="w-4 h-4" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-bold text-main truncate">{title}</h3>
|
||||
<p className="text-[11px] text-muted truncate">
|
||||
Objekt: <span className="font-semibold text-main">{resourceName}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-muted hover:text-main rounded-btn hover:bg-panel transition-colors cursor-pointer"
|
||||
title="Schließen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-2.5 bg-status-danger/10 border border-status-danger/30 text-status-danger rounded text-xs">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Share Section (Only if owner / manager) */}
|
||||
{isOwner ? (
|
||||
<form onSubmit={handleAdd} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShareType('team');
|
||||
setSelectedUser(null);
|
||||
setError(null);
|
||||
}}
|
||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
|
||||
shareType === 'team'
|
||||
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
<span>Gruppe</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShareType('user');
|
||||
setSelectedTeamId('');
|
||||
setError(null);
|
||||
}}
|
||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
|
||||
shareType === 'user'
|
||||
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<UserIcon className="w-3.5 h-3.5" />
|
||||
<span>Benutzer</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shareType === 'team' ? (
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<select
|
||||
value={selectedTeamId}
|
||||
onChange={(e) => setSelectedTeamId(e.target.value)}
|
||||
className="input-field bg-panel h-8 px-2.5 text-xs flex-1 cursor-pointer"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="" className="bg-panel text-muted">-- Gruppe auswählen --</option>
|
||||
{availableTeams.map((t) => (
|
||||
<option key={t.id} value={t.id} className="bg-panel text-main">
|
||||
👥 {t.name} ({t.members?.length || 1} Mitglieder)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{allowRoleSelection && (
|
||||
<select
|
||||
value={selectedPermission}
|
||||
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
||||
className="input-field bg-panel h-8 px-2.5 text-xs w-full sm:w-36 cursor-pointer shrink-0"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="flex-1">
|
||||
<UserAutocomplete
|
||||
users={collaborators}
|
||||
selectedUser={selectedUser}
|
||||
onSelectUser={setSelectedUser}
|
||||
excludeUserIds={excludedUserIds}
|
||||
disabled={loading}
|
||||
placeholder="Benutzer suchen (Name oder @Benutzername)…"
|
||||
/>
|
||||
</div>
|
||||
{allowRoleSelection && (
|
||||
<select
|
||||
value={selectedPermission}
|
||||
onChange={(e) => setSelectedPermission(e.target.value as 'editor' | 'viewer')}
|
||||
className="input-field bg-panel h-8 px-2.5 text-xs w-full sm:w-36 cursor-pointer shrink-0"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="editor" className="bg-panel text-main">Bearbeiten</option>
|
||||
<option value="viewer" className="bg-panel text-main">Nur Lesen</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || (shareType === 'team' ? !selectedTeamId : !selectedUser)}
|
||||
className="btn-primary text-xs h-8 px-3.5 flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>
|
||||
{loading
|
||||
? 'Wird gespeichert…'
|
||||
: shareType === 'team'
|
||||
? 'Gruppe freigeben'
|
||||
: 'Benutzer hinzufügen'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="p-3 bg-panel-elevated border border-border rounded-card text-xs text-muted flex items-center gap-2">
|
||||
<Lock className="w-4 h-4 text-faint shrink-0" />
|
||||
<span>Nur der Eigentümer kann neue Freigaben einrichten.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Shares List */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5">
|
||||
<span>Aktive Freigaben</span>
|
||||
<span className="font-mono text-[11px] font-normal lowercase">
|
||||
{shares.length} {shares.length === 1 ? 'Freigabe' : 'Freigaben'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-0.5">
|
||||
{shares.length === 0 ? (
|
||||
<div className="p-4 bg-canvas border border-border rounded-card text-xs text-muted flex items-center gap-2.5 justify-center text-center">
|
||||
<Lock className="w-4 h-4 text-faint shrink-0" />
|
||||
<span>Dieses Element ist aktuell privat (nur für dich sichtbar).</span>
|
||||
</div>
|
||||
) : (
|
||||
shares.map((sh) => {
|
||||
const isTeam = sh.targetType === 'team';
|
||||
const roleLabel =
|
||||
sh.permission === 'viewer' || sh.role === 'viewer' ? 'Leser' : 'Bearbeiter';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sh.id}
|
||||
className="p-2.5 bg-canvas border border-border rounded-btn flex items-center justify-between gap-3 text-xs hover:border-border/80 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
{isTeam ? (
|
||||
<div className="w-7 h-7 rounded-full flex items-center justify-center shrink-0 bg-accent/15 text-accent border border-accent/25">
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
) : (
|
||||
<UserAvatar
|
||||
avatarUrl={sh.avatarUrl || collaborators.find((c) => c.id === sh.targetId)?.avatarUrl}
|
||||
name={sh.targetName || sh.targetEmail}
|
||||
size="sm"
|
||||
className="border border-border/80"
|
||||
/>
|
||||
)}
|
||||
<div className="truncate">
|
||||
<div className="font-medium text-main truncate flex items-center gap-1.5">
|
||||
<span className="truncate">{sh.targetName || (isTeam ? 'Gruppe' : sh.targetEmail || 'Benutzer')}</span>
|
||||
<span
|
||||
className={`text-[9px] uppercase font-semibold px-1 py-0.2 rounded ${
|
||||
isTeam ? 'badge-neutral' : 'bg-surface-2 text-faint'
|
||||
}`}
|
||||
>
|
||||
{isTeam ? 'Gruppe' : 'Benutzer'}
|
||||
</span>
|
||||
</div>
|
||||
{sh.targetEmail && sh.targetEmail !== sh.targetName && (
|
||||
<div className="text-[11px] text-muted truncate">{sh.targetEmail}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 rounded font-semibold ${
|
||||
roleLabel === 'Bearbeiter'
|
||||
? 'bg-accent/15 text-accent border border-accent/30'
|
||||
: 'bg-surface-2 text-muted border border-border'
|
||||
}`}
|
||||
>
|
||||
{roleLabel}
|
||||
</span>
|
||||
{isOwner && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(sh.id)}
|
||||
disabled={loading}
|
||||
className="p-1 text-faint hover:text-status-err rounded transition-colors cursor-pointer"
|
||||
title="Freigabe entfernen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-2 border-t border-border flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-action text-xs h-8 px-4 cursor-pointer"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export interface UserAvatarProps {
|
||||
user?: {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
} | null;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
const SIZE_MAP = {
|
||||
xs: 'w-5 h-5 text-[10px]',
|
||||
sm: 'w-7 h-7 text-xs',
|
||||
md: 'w-9 h-9 text-sm',
|
||||
lg: 'w-12 h-12 text-base',
|
||||
xl: 'w-20 h-20 text-2xl',
|
||||
};
|
||||
|
||||
const BG_COLORS = [
|
||||
'bg-primary/20 text-primary border-primary/30',
|
||||
'bg-secondary/20 text-secondary border-secondary/30',
|
||||
'bg-accent/20 text-accent border-accent/30',
|
||||
'bg-info/20 text-info border-info/30',
|
||||
'bg-success/20 text-success border-success/30',
|
||||
'bg-warning/20 text-warning border-warning/30',
|
||||
];
|
||||
|
||||
function getInitials(name?: string, email?: string): string {
|
||||
const cleanName = (name || '').trim();
|
||||
if (cleanName) {
|
||||
const parts = cleanName.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return cleanName.slice(0, 2).toUpperCase();
|
||||
}
|
||||
const cleanEmail = (email || '').trim();
|
||||
if (cleanEmail) {
|
||||
return cleanEmail.slice(0, 2).toUpperCase();
|
||||
}
|
||||
return '?';
|
||||
}
|
||||
|
||||
function getColorIndex(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
return Math.abs(hash) % BG_COLORS.length;
|
||||
}
|
||||
|
||||
export function isEmoji(str?: string): boolean {
|
||||
if (!str) return false;
|
||||
const trimmed = str.trim();
|
||||
// Simple heuristic: length <= 8 and not a URL or base64 image
|
||||
return !trimmed.startsWith('data:') && !trimmed.startsWith('http') && !trimmed.startsWith('/') && trimmed.length <= 8;
|
||||
}
|
||||
|
||||
export const UserAvatar: React.FC<UserAvatarProps> = ({
|
||||
user,
|
||||
name,
|
||||
avatarUrl,
|
||||
size = 'md',
|
||||
className = '',
|
||||
alt,
|
||||
}) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const effectiveAvatarUrl = avatarUrl !== undefined ? avatarUrl : user?.avatarUrl;
|
||||
const effectiveName = name !== undefined ? name : (user?.displayName || user?.email || '');
|
||||
const effectiveEmail = user?.email || '';
|
||||
|
||||
const sizeClass = SIZE_MAP[size] || SIZE_MAP.md;
|
||||
const initials = getInitials(effectiveName, effectiveEmail);
|
||||
const colorIndex = getColorIndex(effectiveName || effectiveEmail || 'user');
|
||||
const colorClass = BG_COLORS[colorIndex];
|
||||
|
||||
const hasImage = Boolean(
|
||||
effectiveAvatarUrl &&
|
||||
!imageError &&
|
||||
(effectiveAvatarUrl.startsWith('data:image/') ||
|
||||
effectiveAvatarUrl.startsWith('http://') ||
|
||||
effectiveAvatarUrl.startsWith('https://') ||
|
||||
effectiveAvatarUrl.startsWith('/'))
|
||||
);
|
||||
|
||||
const isEmojiAvatar = Boolean(effectiveAvatarUrl && isEmoji(effectiveAvatarUrl));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center justify-center rounded-full aspect-square shrink-0 select-none overflow-hidden font-semibold transition-all ${sizeClass} ${className}`}
|
||||
title={effectiveName || alt}
|
||||
>
|
||||
{hasImage ? (
|
||||
<img
|
||||
src={effectiveAvatarUrl}
|
||||
alt={alt || effectiveName || 'Avatar'}
|
||||
onError={() => setImageError(true)}
|
||||
className="w-full h-full object-cover rounded-full"
|
||||
/>
|
||||
) : isEmojiAvatar ? (
|
||||
<div className="w-full h-full flex items-center justify-center bg-base-300/80 rounded-full">
|
||||
<span className="leading-none">{effectiveAvatarUrl}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`w-full h-full flex items-center justify-center rounded-full border ${colorClass}`}>
|
||||
<span className="leading-none tracking-tight">{initials}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resizes and center-crops an uploaded image file to a compact base64 Data URL.
|
||||
*/
|
||||
export async function resizeImageToDataUrl(file: File, maxSize = 256): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('Fehler beim Lesen der Datei'));
|
||||
reader.onload = (e) => {
|
||||
const img = new Image();
|
||||
img.onerror = () => reject(new Error('Fehler beim Laden des Bildes'));
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = maxSize;
|
||||
canvas.height = maxSize;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
reject(new Error('Canvas Context konnte nicht erstellt werden'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Center crop calculation
|
||||
const minDim = Math.min(img.width, img.height);
|
||||
const sx = (img.width - minDim) / 2;
|
||||
const sy = (img.height - minDim) / 2;
|
||||
|
||||
ctx.drawImage(img, sx, sy, minDim, minDim, 0, 0, maxSize, maxSize);
|
||||
|
||||
// Try webp first, fallback to jpeg
|
||||
let dataUrl = canvas.toDataURL('image/webp', 0.85);
|
||||
if (!dataUrl.startsWith('data:image/webp')) {
|
||||
dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||
}
|
||||
resolve(dataUrl);
|
||||
};
|
||||
img.src = e.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
|
||||
export interface MobileActionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
badge?: string | number;
|
||||
variant?: 'default' | 'accent' | 'danger';
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export interface MobileMenuGroup {
|
||||
id: string;
|
||||
title?: string;
|
||||
items: MobileActionItem[];
|
||||
}
|
||||
|
||||
interface MobileActionContextValue {
|
||||
groups: MobileMenuGroup[];
|
||||
setGroups: (groups: MobileMenuGroup[]) => void;
|
||||
isMenuOpen: boolean;
|
||||
setIsMenuOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
closeMenu: () => void;
|
||||
primaryAction?: () => void;
|
||||
setPrimaryAction: (fn?: () => void) => void;
|
||||
}
|
||||
|
||||
const MobileActionContext = createContext<MobileActionContextValue>({
|
||||
groups: [],
|
||||
setGroups: () => {},
|
||||
isMenuOpen: false,
|
||||
setIsMenuOpen: () => {},
|
||||
closeMenu: () => {},
|
||||
primaryAction: undefined,
|
||||
setPrimaryAction: () => {},
|
||||
});
|
||||
|
||||
export const MobileActionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [groups, setGroupsState] = useState<MobileMenuGroup[]>([]);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [primaryAction, setPrimaryActionState] = useState<(() => void) | undefined>(undefined);
|
||||
|
||||
const setGroups = useCallback((newGroups: MobileMenuGroup[]) => {
|
||||
setGroupsState(newGroups);
|
||||
}, []);
|
||||
|
||||
const closeMenu = useCallback(() => {
|
||||
setIsMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
const setPrimaryAction = useCallback((fn?: () => void) => {
|
||||
setPrimaryActionState(() => fn);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<MobileActionContext.Provider
|
||||
value={{
|
||||
groups,
|
||||
setGroups,
|
||||
isMenuOpen,
|
||||
setIsMenuOpen,
|
||||
closeMenu,
|
||||
primaryAction,
|
||||
setPrimaryAction,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MobileActionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useMobileActions = () => useContext(MobileActionContext);
|
||||
@@ -0,0 +1,202 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Search, X, Check } from 'lucide-react';
|
||||
import { User } from '../lib/types';
|
||||
import { UserAvatar } from '../components/UserAvatar';
|
||||
|
||||
interface UserAutocompleteProps {
|
||||
users: User[];
|
||||
selectedUser: User | null;
|
||||
onSelectUser: (user: User | null) => void;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
excludeUserIds?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
users,
|
||||
selectedUser,
|
||||
onSelectUser,
|
||||
placeholder = 'Benutzer suchen (Name, @Benutzername oder E-Mail)…',
|
||||
disabled = false,
|
||||
excludeUserIds = [],
|
||||
className = '',
|
||||
}) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [highlightedIndex, setHighlightedIndex] = useState<number>(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Filter users based on query and exclusions
|
||||
const filteredUsers = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const available = users.filter((u) => !excludeUserIds.includes(u.id));
|
||||
if (!q) {
|
||||
return available.slice(0, 15);
|
||||
}
|
||||
return available
|
||||
.filter((u) => {
|
||||
const nameMatch = (u.displayName || '').toLowerCase().includes(q);
|
||||
const emailMatch = (u.email || '').toLowerCase().includes(q);
|
||||
const userMatch = (u.username || '').toLowerCase().includes(q);
|
||||
return nameMatch || emailMatch || userMatch;
|
||||
})
|
||||
.slice(0, 15);
|
||||
}, [users, query, excludeUserIds]);
|
||||
|
||||
const handleSelect = (user: User) => {
|
||||
onSelectUser(user);
|
||||
setQuery('');
|
||||
setIsOpen(false);
|
||||
setHighlightedIndex(-1);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onSelectUser(null);
|
||||
setQuery('');
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
setIsOpen(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev < filteredUsers.length - 1 ? prev + 1 : 0
|
||||
);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightedIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : filteredUsers.length - 1
|
||||
);
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (highlightedIndex >= 0 && highlightedIndex < filteredUsers.length) {
|
||||
handleSelect(filteredUsers[highlightedIndex]);
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative ${className}`}>
|
||||
{selectedUser ? (
|
||||
<div className="h-8 px-2.5 bg-panel-elevated border border-accent/40 rounded-btn flex items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<UserAvatar user={selectedUser} size="xs" />
|
||||
<span className="font-semibold text-main truncate">
|
||||
{selectedUser.displayName || selectedUser.email}
|
||||
</span>
|
||||
{selectedUser.username && (
|
||||
<span className="text-[11px] text-muted truncate">
|
||||
@{selectedUser.username}
|
||||
</span>
|
||||
)}
|
||||
{selectedUser.email && selectedUser.displayName && (
|
||||
<span className="text-[11px] text-muted truncate hidden sm:inline">
|
||||
({selectedUser.email})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="text-muted hover:text-main p-0.5 rounded hover:bg-canvas transition-colors"
|
||||
title="Auswahl aufheben"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
setHighlightedIndex(-1);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className="w-full h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs placeholder:text-muted transition-colors disabled:opacity-50"
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-56 overflow-y-auto bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
|
||||
{filteredUsers.length === 0 ? (
|
||||
<div className="p-3 text-xs text-muted text-center">
|
||||
Kein passender Benutzer gefunden
|
||||
</div>
|
||||
) : (
|
||||
filteredUsers.map((u, idx) => {
|
||||
const isHighlighted = idx === highlightedIndex;
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(u)}
|
||||
onMouseEnter={() => setHighlightedIndex(idx)}
|
||||
className={`w-full px-2.5 py-1.5 flex items-center justify-between gap-2 text-left text-xs transition-colors cursor-pointer ${
|
||||
isHighlighted
|
||||
? 'bg-accent/15 text-main font-medium'
|
||||
: 'hover:bg-panel-elevated text-main'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<UserAvatar user={u} size="xs" />
|
||||
<div className="truncate">
|
||||
<span className="font-semibold text-main">
|
||||
{u.displayName || u.email}
|
||||
</span>
|
||||
{u.username && (
|
||||
<span className="ml-1 text-[11px] text-muted">
|
||||
@{u.username}
|
||||
</span>
|
||||
)}
|
||||
{u.email && u.displayName && (
|
||||
<span className="ml-1.5 text-[10px] text-muted truncate">
|
||||
{u.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isHighlighted && (
|
||||
<Check className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,589 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Brain,
|
||||
Shield,
|
||||
Lock,
|
||||
KeyRound,
|
||||
UserPlus,
|
||||
LogIn,
|
||||
Server,
|
||||
Users,
|
||||
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 [setupPasswordConfirm, setSetupPasswordConfirm] = 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);
|
||||
|
||||
if (setupPassword !== setupPasswordConfirm) {
|
||||
setError('Die eingegebenen Passwörter stimmen nicht überein.');
|
||||
triggerHaptic(25);
|
||||
return;
|
||||
}
|
||||
|
||||
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 className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<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>
|
||||
<label className="block text-muted mb-1">Passwort wiederholen *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={setupPasswordConfirm}
|
||||
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
|
||||
placeholder="Passwort erneut eingeben"
|
||||
minLength={4}
|
||||
required
|
||||
className={`w-full h-8 px-2.5 input-field ${
|
||||
setupPasswordConfirm && setupPassword !== setupPasswordConfirm
|
||||
? 'border-status-err focus:border-status-err'
|
||||
: ''
|
||||
}`}
|
||||
/>
|
||||
{setupPasswordConfirm && setupPassword !== setupPasswordConfirm && (
|
||||
<span className="text-[10px] text-status-err mt-1 block">
|
||||
Passwörter stimmen nicht überein
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Server & Isolation */}
|
||||
<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">
|
||||
<Users 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>
|
||||
<h1 className="text-sm font-bold text-main tracking-tight leading-none">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</h1>
|
||||
</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">
|
||||
{authStatus.demoMode && (
|
||||
<div className="p-2.5 rounded border border-accent/30 bg-accent/10 space-y-1.5">
|
||||
<div className="flex items-center justify-between text-[11px] font-semibold text-accent">
|
||||
<span>🧪 Demo-Modus aktiv</span>
|
||||
<span className="text-[10px] text-muted font-normal">Klick zum Ausfüllen:</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEmail('admin');
|
||||
setPassword('admin');
|
||||
}}
|
||||
className="flex-1 py-1 px-2 text-[11px] rounded bg-panel hover:bg-panel-elevated border border-border text-main font-mono transition-colors text-center"
|
||||
>
|
||||
admin / admin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEmail('user');
|
||||
setPassword('user');
|
||||
}}
|
||||
className="flex-1 py-1 px-2 text-[11px] rounded bg-panel hover:bg-panel-elevated border border-border text-main font-mono transition-colors text-center"
|
||||
>
|
||||
user / user
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-muted mb-1">Benutzername oder E-Mail-Adresse</label>
|
||||
<input
|
||||
type="text"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="admin oder 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,46 +1,112 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Mic, MicOff, Send, Sparkles, Calendar, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { PriorityLevel, Thought } from '../../lib/types';
|
||||
import {
|
||||
Mic,
|
||||
MicOff,
|
||||
ArrowUp,
|
||||
Sparkles,
|
||||
Calendar,
|
||||
Check,
|
||||
CheckSquare,
|
||||
ShoppingCart,
|
||||
FileText,
|
||||
Layers,
|
||||
ChevronDown,
|
||||
AlertTriangle,
|
||||
Flame,
|
||||
} from 'lucide-react';
|
||||
import { PriorityLevel, SharedList, Thought } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
|
||||
export type CaptureTarget = 'inbox' | 'task' | 'list_item' | 'document';
|
||||
|
||||
interface InstantCaptureBarProps {
|
||||
onCaptured: (thought: Thought) => void;
|
||||
onCaptured: (thought: Thought, target?: CaptureTarget) => void;
|
||||
lists?: SharedList[];
|
||||
autoFocusTrigger?: number;
|
||||
inlineOptions?: boolean;
|
||||
defaultShowOptions?: boolean;
|
||||
hideTargetPicker?: boolean;
|
||||
fixedTarget?: CaptureTarget;
|
||||
}
|
||||
|
||||
export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
onCaptured,
|
||||
lists = [],
|
||||
autoFocusTrigger = 0,
|
||||
inlineOptions = false,
|
||||
defaultShowOptions = false,
|
||||
hideTargetPicker = false,
|
||||
fixedTarget,
|
||||
}) => {
|
||||
const [content, setContent] = useState('');
|
||||
const [priority, setPriority] = useState<PriorityLevel>('normal');
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [showOptions, setShowOptions] = useState(false);
|
||||
const [target, setTarget] = useState<CaptureTarget>(fixedTarget || 'inbox');
|
||||
const [targetListId, setTargetListId] = useState<string>('');
|
||||
const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [justSaved, setJustSaved] = useState(false);
|
||||
const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
|
||||
const [isMultiline, setIsMultiline] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLFormElement>(null);
|
||||
const recognitionRef = useRef<any>(null);
|
||||
|
||||
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
||||
const targetEl = el || inputRef.current;
|
||||
if (!targetEl) return;
|
||||
targetEl.style.height = 'auto';
|
||||
if (targetEl.value) {
|
||||
const scrollH = targetEl.scrollHeight;
|
||||
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
|
||||
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
|
||||
} else {
|
||||
setIsMultiline(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [autoFocusTrigger]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetListId && lists.length > 0) {
|
||||
setTargetListId(lists[0].id);
|
||||
}
|
||||
}, [lists, targetListId]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (inlineOptions) return;
|
||||
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);
|
||||
}, [inlineOptions]);
|
||||
|
||||
// Global keyboard shortcut: 'c' or '/' focuses the Instant Capture input immediately
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const el = e.target as HTMLElement;
|
||||
const isEditing =
|
||||
target.tagName === 'INPUT' ||
|
||||
target.tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable;
|
||||
el.tagName === 'INPUT' ||
|
||||
el.tagName === 'TEXTAREA' ||
|
||||
el.isContentEditable;
|
||||
|
||||
if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
} else if (e.key === 'Escape' && document.activeElement === inputRef.current) {
|
||||
setContent('');
|
||||
setIsMultiline(false);
|
||||
setShowOptions(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
@@ -102,164 +168,400 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
const executeCapture = async (
|
||||
overrideTarget?: CaptureTarget,
|
||||
overrideListId?: string
|
||||
) => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || isSubmitting) return;
|
||||
|
||||
const effectiveTarget = fixedTarget || 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('');
|
||||
setIsMultiline(false);
|
||||
setPriority('normal');
|
||||
setDueDate('');
|
||||
setTarget(fixedTarget || 'inbox');
|
||||
setShowOptions(defaultShowOptions || hideTargetPicker);
|
||||
setIsSubmitting(true);
|
||||
triggerHaptic(15);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.style.height = 'auto';
|
||||
}
|
||||
|
||||
const savedMsg =
|
||||
effectiveTarget === 'task'
|
||||
? 'Als Aufgabe!'
|
||||
: effectiveTarget === 'list_item'
|
||||
? 'Auf Liste!'
|
||||
: effectiveTarget === 'document'
|
||||
? 'Als Notiz!'
|
||||
: 'In Triage!';
|
||||
setJustSavedLabel(savedMsg);
|
||||
setTimeout(() => setJustSavedLabel(null), 1600);
|
||||
inputRef.current?.focus();
|
||||
|
||||
try {
|
||||
const created = await api.captureThought(
|
||||
trimmed,
|
||||
priority,
|
||||
isListening ? 'voice' : 'manual',
|
||||
dueDate
|
||||
capturedPriority,
|
||||
capturedSource,
|
||||
capturedDueDate,
|
||||
{
|
||||
target: effectiveTarget,
|
||||
targetListId: effectiveTarget === 'list_item' ? effectiveListId : '',
|
||||
}
|
||||
);
|
||||
setContent('');
|
||||
setPriority('normal');
|
||||
setDueDate('');
|
||||
setShowOptions(false);
|
||||
setJustSaved(true);
|
||||
setTimeout(() => setJustSaved(false), 1800);
|
||||
onCaptured(created);
|
||||
inputRef.current?.focus();
|
||||
onCaptured(created, effectiveTarget);
|
||||
} catch {
|
||||
// Restore text if capture unexpectedly failed completely
|
||||
setContent(trimmed);
|
||||
} finally {
|
||||
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 (
|
||||
<div className="w-full max-w-3xl mx-auto">
|
||||
<form onSubmit={handleSubmit} className="relative">
|
||||
<div className="w-full">
|
||||
<form ref={containerRef} onSubmit={handleSubmit} className="relative">
|
||||
{/* Quick-Capture Input Bar (Radius controlled by --radius-pill / multiline) */}
|
||||
<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 ${
|
||||
isListening ? 'border-[#c58af9] shadow-purple-glow' : ''
|
||||
className={`flex ${
|
||||
isMultiline ? 'items-end py-1.5 min-h-[36px]' : 'items-center h-9'
|
||||
} pl-1.5 pr-1.5 bg-panel border capture-pill ${
|
||||
isMultiline ? 'capture-bar-multiline' : 'rounded-full'
|
||||
} transition-all duration-150 ${
|
||||
isListening
|
||||
? 'border-accent bg-panel'
|
||||
: 'border-border focus-within:border-accent'
|
||||
}`}
|
||||
>
|
||||
<Sparkles
|
||||
className={`w-5 h-5 mr-3 flex-shrink-0 transition-colors ${
|
||||
isListening
|
||||
? 'text-[#c58af9] animate-pulse'
|
||||
: justSaved
|
||||
? 'text-[#81c995]'
|
||||
: 'text-[#8ab4f8]'
|
||||
<div
|
||||
className={`w-6 h-6 capture-pill rounded-full bg-accent text-on-accent flex items-center justify-center mr-2 flex-shrink-0 ${
|
||||
isMultiline ? 'self-start mt-1' : ''
|
||||
}`}
|
||||
/>
|
||||
>
|
||||
<Sparkles className="w-3 h-3" />
|
||||
</div>
|
||||
|
||||
<input
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
rows={1}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setContent(e.target.value);
|
||||
adjustHeight(e.target);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (!e.shiftKey || e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
executeCapture();
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
isListening
|
||||
? 'Ich höre zu… sprich deinen Gedanken aus'
|
||||
: 'Gedanke, Idee oder Zutat sofort festhalten… (Taste C oder /)'
|
||||
? 'Ich höre zu…'
|
||||
: hideTargetPicker || fixedTarget === 'inbox'
|
||||
? 'Quick Dump erfassen… (Enter zum Speichern)'
|
||||
: target === 'task'
|
||||
? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
|
||||
: target === 'list_item'
|
||||
? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…`
|
||||
: target === 'document'
|
||||
? 'Neue Workspace-Notiz direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
|
||||
: 'Quick Dump: Gedanke reinrotzen… (C / Enter, Shift+Enter für Mehrzeiler)'
|
||||
}
|
||||
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 resize-none leading-5 py-0.5 overflow-y-auto"
|
||||
style={{ minHeight: '22px', maxHeight: '140px' }}
|
||||
autoComplete="off"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1.5 ml-2 flex-shrink-0">
|
||||
{/* Options Toggle Pill (Due Date / Priority) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOptions((prev) => !prev)}
|
||||
title="Priorität & Fälligkeitsdatum"
|
||||
className={`h-9 px-3 rounded-full text-xs font-medium flex items-center gap-1.5 transition-colors ${
|
||||
showOptions || priority !== 'normal' || dueDate
|
||||
? 'bg-[#8ab4f8]/20 text-[#8ab4f8] border border-[#8ab4f8]/40'
|
||||
: 'text-[#9aa0a6] hover:bg-[#282a2c] hover:text-[#e3e3e3]'
|
||||
}`}
|
||||
>
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">
|
||||
{dueDate ? dueDate : priority !== 'normal' ? priority.toUpperCase() : 'Optionen'}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
className={`flex items-center gap-1 ml-2 flex-shrink-0 ${
|
||||
isMultiline ? 'self-end mb-0.5' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Target & Options Toggle Pill (Hidden when target picker is disabled) */}
|
||||
{!hideTargetPicker && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowOptions((prev) => !prev)}
|
||||
title="Ziel wählen (Quick Dump, direkt als Aufgabe, auf Liste oder als Notiz) & Optionen"
|
||||
className={`h-6 px-2.5 capture-pill rounded-full text-[11px] flex items-center gap-1 transition-colors cursor-pointer ${
|
||||
showOptions || target !== 'inbox' || priority !== 'normal' || dueDate
|
||||
? 'bg-accent-dim text-accent border border-accent-border font-medium'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{target === 'task' ? (
|
||||
<CheckSquare className="w-3 h-3" />
|
||||
) : 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={`${inlineOptions ? 'inline' : 'hidden md:inline'} max-w-[110px] truncate`}>
|
||||
{getTargetLabel()}
|
||||
</span>
|
||||
{dueDate && <span className="hidden lg:inline text-[10px] opacity-80">• {dueDate.slice(5)}</span>}
|
||||
<ChevronDown className={`w-3 h-3 opacity-70 transition-transform ${showOptions ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Voice Dictation Pill Button */}
|
||||
{/* Voice Dictation Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleVoiceDictation}
|
||||
title="Spracheingabe (Diktieren)"
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center transition-all ${
|
||||
title="Spracheingabe"
|
||||
className={`w-6 h-6 capture-pill rounded-full flex items-center justify-center transition-colors ${
|
||||
isListening
|
||||
? 'bg-[#c58af9] text-[#0b0f19] animate-pulse'
|
||||
: 'bg-[#282a2c] text-[#9aa0a6] hover:text-[#e3e3e3] hover:bg-[#323538]'
|
||||
? 'bg-accent-dim text-accent'
|
||||
: '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>
|
||||
|
||||
{/* Instant Send Pill Button */}
|
||||
{/* Instant Send Button */}
|
||||
<button
|
||||
type="submit"
|
||||
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 rounded-full font-medium text-[11px] flex items-center gap-1 transition-all ${
|
||||
content.trim()
|
||||
? 'bg-[#8ab4f8] text-[#0b0f19] hover:bg-[#a1c2fa] shadow-sm cursor-pointer'
|
||||
: 'bg-[#282a2c] text-[#5f6368] cursor-not-allowed'
|
||||
? 'bg-accent hover:bg-accent-hover text-on-accent cursor-pointer shadow-sm'
|
||||
: 'bg-panel-elevated text-faint cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{justSaved ? (
|
||||
{justSavedLabel ? (
|
||||
<>
|
||||
<CheckCircle2 className="w-4 h-4 text-[#0b0f19]" />
|
||||
<span className="hidden sm:inline">Gespeichert</span>
|
||||
<Check className="w-3 h-3" />
|
||||
<span className="inline">{justSavedLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Einfrieren</span>
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
<span className="inline">
|
||||
{hideTargetPicker || (fixedTarget || target) === 'inbox'
|
||||
? 'Quick Dump'
|
||||
: target === 'task'
|
||||
? 'Als Aufgabe'
|
||||
: target === 'list_item'
|
||||
? 'Auf Liste'
|
||||
: target === 'document'
|
||||
? 'Als Notiz'
|
||||
: 'Dump'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expandable Quick Metadata Pills */}
|
||||
{/* Expandable Target & Metadata Panel (Theme-controlled Dropdown or Inline) */}
|
||||
{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="flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-[#9aa0a6] mr-1" />
|
||||
<span className="text-[#9aa0a6] mr-1">Priorität:</span>
|
||||
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
|
||||
const labels: Record<PriorityLevel, string> = {
|
||||
low: 'Niedrig',
|
||||
normal: 'Normal',
|
||||
high: 'Hoch',
|
||||
urgent: 'Dringend',
|
||||
};
|
||||
const active = priority === p;
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setPriority(p)}
|
||||
className={`px-3 py-1 rounded-full transition-colors ${
|
||||
active
|
||||
? p === 'urgent'
|
||||
? 'bg-[#f28b82] text-[#0b0f19] font-semibold'
|
||||
: 'bg-[#8ab4f8] text-[#0b0f19] font-semibold'
|
||||
: 'bg-[#1e1f20] text-[#9aa0a6] hover:text-[#e3e3e3]'
|
||||
}`}
|
||||
>
|
||||
{labels[p]}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
inlineOptions
|
||||
? 'mt-3 p-3 panel-surface border border-border rounded-xl space-y-3 text-xs animate-in fade-in duration-150'
|
||||
: '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? (Omitted when hideTargetPicker is true) */}
|
||||
{!hideTargetPicker && (
|
||||
<>
|
||||
<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="flex items-center gap-2">
|
||||
<span className="text-[#9aa0a6]">Fällig am:</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
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]"
|
||||
/>
|
||||
<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={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||
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={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||
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={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||
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={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||
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={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
|
||||
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={`${!hideTargetPicker ? 'pt-2 border-t border-border' : ''} flex flex-wrap items-center justify-between gap-2.5`}>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-muted text-[11px] font-semibold mr-1">Priorität:</span>
|
||||
{(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => {
|
||||
const config: Record<PriorityLevel, { label: string; icon?: React.ReactNode; activeClass: string }> = {
|
||||
low: { label: 'Niedrig', activeClass: 'bg-canvas border-strong text-main font-semibold shadow-xs' },
|
||||
normal: { label: 'Normal', activeClass: 'bg-accent/15 border-accent text-accent font-semibold shadow-xs' },
|
||||
high: { label: 'Wichtig', icon: <AlertTriangle className="w-3 h-3 text-status-warn" />, activeClass: 'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs' },
|
||||
urgent: { label: 'Dringend', icon: <Flame className="w-3 h-3 text-status-err" />, activeClass: 'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs' },
|
||||
};
|
||||
const active = priority === p;
|
||||
const item = config[p];
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setPriority(p);
|
||||
}}
|
||||
className={`min-h-[30px] px-2.5 py-1 border rounded-btn text-xs flex items-center gap-1 transition-colors cursor-pointer ${
|
||||
active
|
||||
? item.activeClass
|
||||
: 'bg-canvas text-muted hover:text-main border-border'
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted text-[11px] font-semibold flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||||
Fällig am:
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="input-field px-2.5 py-1 text-xs min-h-[30px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Search,
|
||||
Layers,
|
||||
FileText,
|
||||
CheckSquare,
|
||||
Keyboard,
|
||||
Pin,
|
||||
FolderTree,
|
||||
Folder,
|
||||
Sparkles,
|
||||
Command,
|
||||
CornerDownLeft,
|
||||
CornerUpLeft,
|
||||
MoveVertical,
|
||||
Check,
|
||||
Share2,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface HelpViewProps {
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
type HelpCategory = 'all' | 'workspace' | 'triage' | 'tasks' | 'shortcuts';
|
||||
|
||||
export const HelpView: React.FC<HelpViewProps> = () => {
|
||||
const [activeCategory, setActiveCategory] = useState<HelpCategory>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-4 pb-12">
|
||||
{/* Header Bar */}
|
||||
<div className="panel-surface p-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-base sm:text-lg font-bold text-main flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-accent" />
|
||||
<span>Hilfe & Dokumentation</span>
|
||||
</h1>
|
||||
<p className="text-xs text-muted">
|
||||
Alles Wichtige zur Tastaturbedienung, Outliner-Notizen, Triage und Zusammenarbeit
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="w-3.5 h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="In Hilfe suchen…"
|
||||
className="w-full h-8 pl-8 pr-3 input-field text-xs bg-canvas"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter Pills */}
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{[
|
||||
{ id: 'all', label: 'Alle Themen', icon: Sparkles },
|
||||
{ id: 'workspace', label: 'Workspace & Outliner', icon: FileText },
|
||||
{ id: 'triage', label: 'Triage & Quick-Dump', icon: Layers },
|
||||
{ id: 'tasks', label: 'Aufgaben & Listen', icon: CheckSquare },
|
||||
{ id: 'shortcuts', label: 'Tastatur-Kürzel', icon: Keyboard },
|
||||
].map((cat) => {
|
||||
const Icon = cat.icon;
|
||||
const isActive = activeCategory === cat.id;
|
||||
return (
|
||||
<button
|
||||
key={cat.id}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(cat.id as HelpCategory)}
|
||||
className={`h-8 px-3 text-xs font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
|
||||
isActive
|
||||
? 'bg-panel-elevated border-accent text-main font-semibold'
|
||||
: 'bg-panel border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 text-accent" />
|
||||
<span>{cat.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Grid of Documentation Cards */}
|
||||
<div className="space-y-4">
|
||||
{/* SECTION: WORKSPACE & OUTLINER */}
|
||||
{(activeCategory === 'all' || activeCategory === 'workspace') && (
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div className="border-b border-border pb-2.5">
|
||||
<h2 className="text-sm font-bold text-main flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-accent" />
|
||||
<span>Workspace: Der hierarchische Logseq-Outliner</span>
|
||||
</h2>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Jede Notiz in MindSift ist ein flexibler hierarchischer Outliner und wird als Standard-Markdown auf der Festplatte gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
|
||||
<div className="p-3 bg-canvas border border-border rounded-card space-y-2">
|
||||
<h3 className="font-semibold text-main flex items-center gap-1.5 text-xs">
|
||||
<Keyboard className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Flüssige Tastatursteuerung</span>
|
||||
</h3>
|
||||
<ul className="space-y-1.5 text-muted leading-relaxed">
|
||||
<li>
|
||||
<strong className="text-main">Enter:</strong> Erzeugt sofort einen neuen Bullet-Point auf derselben Ebene.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Tab:</strong> Rückt den aktuellen Bullet-Point ein und ordnet ihn dem vorherigen Punkt unter.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Shift + Tab:</strong> Rückt den Bullet-Point wieder nach links aus (verringert die Tiefe).
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Shift + Enter:</strong> Erzeugt einen weichen Zeilenumbruch innerhalb desselben Textblocks.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Backspace:</strong> Auf einem leeren Block rückt er die Ebene aus bzw. löscht den leeren Bullet.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-canvas border border-border rounded-card space-y-2">
|
||||
<h3 className="font-semibold text-main flex items-center gap-1.5 text-xs">
|
||||
<Command className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Slash-Befehle (/)</span>
|
||||
</h3>
|
||||
<p className="text-muted leading-relaxed">
|
||||
Tippe einfach <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">/</code> in einen leeren Block, um den Typ flexibel zu wechseln:
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-1 text-[11px] font-mono text-muted">
|
||||
<span>/todo (Checkbox-Aufgabe)</span>
|
||||
<span>/h1 (Haupt-Überschrift)</span>
|
||||
<span>/h2 (Abschnitts-Titel)</span>
|
||||
<span>/h3 (Unter-Überschrift)</span>
|
||||
<span>/code (Code-Block)</span>
|
||||
<span>/table (Markdown-Tabelle)</span>
|
||||
<span>/callout (Hinweis-Box)</span>
|
||||
<span>/progress (Fortschritt 0-100%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-canvas border border-border rounded-card space-y-2">
|
||||
<h3 className="font-semibold text-main flex items-center gap-1.5 text-xs">
|
||||
<FolderTree className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Ordnerstruktur & Drag & Drop</span>
|
||||
</h3>
|
||||
<p className="text-muted leading-relaxed">
|
||||
Notizen können im linken Datei-Explorer per <strong>Drag & Drop</strong> direkt auf beliebige Ordner gezogen werden, um sie zu verschieben.
|
||||
Ziehst du eine Notiz auf das Hauptverzeichnis, wird sie aus allen Unterordnern herausgenommen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-canvas border border-border rounded-card space-y-2">
|
||||
<h3 className="font-semibold text-main flex items-center gap-1.5 text-xs">
|
||||
<Pin className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Zoom-Hoisting & Favoriten anpinnen</span>
|
||||
</h3>
|
||||
<ul className="space-y-1.5 text-muted leading-relaxed">
|
||||
<li>
|
||||
<strong>Klick auf den Bullet-Punkt (•):</strong> Fokussiert tief in diesen Ast hinein (Zoom-in Hoisting). Du kannst über die Breadcrumbs ganz oben jederzeit zurückspringen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Bullet-Punkt ziehen:</strong> Verschiebt den Block samt aller eingerückten Unterelemente an eine neue Position.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Anpinnen:</strong> Wichtige Notizen heften sich ganz oben im Datei-Explorer unter „Angepinnte Notizen" an.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SECTION: TRIAGE & QUICK DUMP */}
|
||||
{(activeCategory === 'all' || activeCategory === 'triage') && (
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div className="border-b border-border pb-2.5">
|
||||
<h2 className="text-sm font-bold text-main flex items-center gap-2">
|
||||
<Layers className="w-4 h-4 text-accent" />
|
||||
<span>Triage & Quick Dump: Der Kopf frei in 3 Sekunden</span>
|
||||
</h2>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Sofortiges Erfassen ohne Reibung. Gedanken werden gesammelt und anschließend im Triage-Deck mit 1 Taste entschieden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-xs">
|
||||
<div className="p-3 bg-canvas border border-border rounded-card space-y-1.5">
|
||||
<h4 className="font-semibold text-main">1. Blitz-Erfassung</h4>
|
||||
<p className="text-muted leading-relaxed">
|
||||
Drücke überall <kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px]">C</kbd> oder <kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px]">/</kbd>, tippe den Gedanken ein und drücke <kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px]">Enter</kbd>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-canvas border border-border rounded-card space-y-1.5">
|
||||
<h4 className="font-semibold text-main">2. Triage Entscheidungs-Tasten</h4>
|
||||
<ul className="text-muted space-y-1">
|
||||
<li><strong className="text-status-err">H</strong> oder <strong>←</strong> : Wegwerfen / Erledigt</li>
|
||||
<li><strong className="text-accent">L</strong> oder <strong>→</strong> : Als Aufgabe anlegen</li>
|
||||
<li><strong>T</strong> : Aufgabe mit Fälligkeitsdatum</li>
|
||||
<li><strong>D</strong> : In Workspace-Notiz umwandeln</li>
|
||||
<li><strong>Leertaste</strong> : Später (ans Ende schieben)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-canvas border border-border rounded-card space-y-1.5">
|
||||
<h4 className="font-semibold text-main">3. Direkt-Zuordnung</h4>
|
||||
<p className="text-muted leading-relaxed">
|
||||
Über das Dropdown neben der Eingabeleiste kannst du einen Eintrag schon beim Schreiben direkt einer Aufgabenliste, Einkaufsliste oder Notiz zuweisen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SECTION: TASKS & LISTS */}
|
||||
{(activeCategory === 'all' || activeCategory === 'tasks') && (
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div className="border-b border-border pb-2.5">
|
||||
<h2 className="text-sm font-bold text-main flex items-center gap-2">
|
||||
<CheckSquare className="w-4 h-4 text-accent" />
|
||||
<span>Aufgaben, Listen & Team-Zusammenarbeit</span>
|
||||
</h2>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Verwalte Aufgabenlisten, Einkaufslisten und teile sie mit Teams und Räumen in Echtzeit.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
|
||||
<div className="p-3 bg-canvas border border-border rounded-card space-y-2">
|
||||
<h3 className="font-semibold text-main flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Aufgabenlisten teilen & Avatar-Stack</span>
|
||||
</h3>
|
||||
<p className="text-muted leading-relaxed">
|
||||
Jede Aufgabenliste kann über den Button <strong>„Teilen"</strong> einem gemeinsamen Team-Space zugeordnet werden.
|
||||
Im Header und auf den Tabs zeigt der Avatar-Stack sofort an, wer Eigentümer ist und welche Teammitglieder Zugriff haben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-canvas border border-border rounded-card space-y-2">
|
||||
<h3 className="font-semibold text-main flex items-center gap-1.5">
|
||||
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Globale To-Dos Rollup</span>
|
||||
</h3>
|
||||
<p className="text-muted leading-relaxed">
|
||||
Im Workspace-Tab <strong>„Globale To-Dos"</strong> werden automatisch alle offenen Checkboxen (<code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">- [ ]</code>) aus sämtlichen Notizen und Ordnern aggregiert.
|
||||
Ein Klick auf die Checkbox synchronisiert die Aufgabe in Echtzeit direkt in der physischen Markdown-Datei!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SECTION: SHORTCUTS CHEAT SHEET */}
|
||||
{(activeCategory === 'all' || activeCategory === 'shortcuts') && (
|
||||
<div className="panel-surface p-5 space-y-4">
|
||||
<div className="border-b border-border pb-2.5">
|
||||
<h2 className="text-sm font-bold text-main flex items-center gap-2">
|
||||
<Keyboard className="w-4 h-4 text-accent" />
|
||||
<span>Tastatur-Kürzel Gesamtübersicht</span>
|
||||
</h2>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Schneller arbeiten ohne Maus: Alle Hotkeys auf einen Blick.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted">
|
||||
<th className="py-2 px-3 font-semibold">Taste</th>
|
||||
<th className="py-2 px-3 font-semibold">Bereich</th>
|
||||
<th className="py-2 px-3 font-semibold">Funktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border text-main">
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">C oder /</td>
|
||||
<td className="py-2 px-3 text-muted">Global</td>
|
||||
<td className="py-2 px-3">Fokus sofort in die Schnellerfassungsleiste setzen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">Enter</td>
|
||||
<td className="py-2 px-3 text-muted">Schnellerfassung</td>
|
||||
<td className="py-2 px-3">Gedanken sofort speichern</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">Esc</td>
|
||||
<td className="py-2 px-3 text-muted">Schnellerfassung</td>
|
||||
<td className="py-2 px-3">Eingabe leeren & Menüs schließen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">Enter</td>
|
||||
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
|
||||
<td className="py-2 px-3">Neuen Bullet-Point auf gleicher Ebene anlegen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">Tab</td>
|
||||
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
|
||||
<td className="py-2 px-3">Bullet-Point einrücken (dem vorherigen unterordnen)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">Shift + Tab</td>
|
||||
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
|
||||
<td className="py-2 px-3">Bullet-Point ausrücken (Ebene verringern)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">Shift + Enter</td>
|
||||
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
|
||||
<td className="py-2 px-3">Weicher Zeilenumbruch innerhalb des aktuellen Blocks</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">Backspace</td>
|
||||
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
|
||||
<td className="py-2 px-3">Bei leerem Block: Ebene ausrücken bzw. Bullet löschen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">/</td>
|
||||
<td className="py-2 px-3 text-muted">Workspace Outliner</td>
|
||||
<td className="py-2 px-3">Slash-Command-Menü öffnen (/todo, /h1, /code, ...)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">H oder ←</td>
|
||||
<td className="py-2 px-3 text-muted">Triage-Deck</td>
|
||||
<td className="py-2 px-3">Gedanken verwerfen / erledigen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">L oder →</td>
|
||||
<td className="py-2 px-3 text-muted">Triage-Deck</td>
|
||||
<td className="py-2 px-3">Gedanken sofort zur Aufgabe machen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">T</td>
|
||||
<td className="py-2 px-3 text-muted">Triage-Deck</td>
|
||||
<td className="py-2 px-3">Aufgabe mit Fälligkeitsdatum anlegen</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">D</td>
|
||||
<td className="py-2 px-3 text-muted">Triage-Deck</td>
|
||||
<td className="py-2 px-3">Gedanken in Notiz/Dokument umwandeln</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 px-3 font-mono text-accent">Leertaste</td>
|
||||
<td className="py-2 px-3 text-muted">Triage-Deck</td>
|
||||
<td className="py-2 px-3">Karte überspringen („Später" – ans Ende schieben)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,947 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Shield,
|
||||
Users,
|
||||
Server,
|
||||
Mail,
|
||||
Plus,
|
||||
Trash2,
|
||||
KeyRound,
|
||||
Copy,
|
||||
Check,
|
||||
ArrowLeft,
|
||||
Send,
|
||||
AlertCircle,
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
SystemSettings,
|
||||
User,
|
||||
UserInvite,
|
||||
UserRole,
|
||||
} from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
|
||||
interface AdminSettingsViewProps {
|
||||
currentUser: User;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type AdminSection = 'users' | 'server' | 'smtp';
|
||||
|
||||
export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
currentUser,
|
||||
onBack,
|
||||
}) => {
|
||||
const isServerAdmin = currentUser.role === 'admin';
|
||||
const [section, setSection] = useState<AdminSection>('users');
|
||||
|
||||
// --- Users & Invites State ---
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [invites, setInvites] = useState<UserInvite[]>([]);
|
||||
const [newUserName, setNewUserName] = useState('');
|
||||
const [newUserEmail, setNewUserEmail] = useState('');
|
||||
const [newUserPassword, setNewUserPassword] = useState('');
|
||||
const [newUserRole, setNewUserRole] = useState<UserRole>('member');
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState<UserRole>('member');
|
||||
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
||||
|
||||
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
|
||||
const [resetPwdVal, setResetPwdVal] = useState('');
|
||||
|
||||
// --- Server & SMTP Settings State ---
|
||||
const [settings, setSettings] = useState<SystemSettings>({
|
||||
sharingMode: 'teams_only',
|
||||
allowRegistration: false,
|
||||
instanceName: 'MindSift',
|
||||
smtpHost: '',
|
||||
smtpPort: 587,
|
||||
smtpUser: '',
|
||||
smtpPass: '',
|
||||
smtpFrom: '',
|
||||
smtpTls: true,
|
||||
smtpConfiguredViaEnv: false,
|
||||
});
|
||||
const [testSmtpEmail, setTestSmtpEmail] = useState(currentUser.email || '');
|
||||
const [testingSmtp, setTestingSmtp] = useState(false);
|
||||
|
||||
const [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
const [uList, invList] = await Promise.all([
|
||||
api.adminGetUsers(),
|
||||
api.adminGetInvites(),
|
||||
]);
|
||||
setUsers(uList);
|
||||
setInvites(invList);
|
||||
|
||||
if (isServerAdmin) {
|
||||
const sys = await api.adminGetSettings();
|
||||
setSettings({
|
||||
sharingMode: sys.sharingMode || 'teams_only',
|
||||
allowRegistration: Boolean(sys.allowRegistration),
|
||||
instanceName: sys.instanceName || 'MindSift',
|
||||
smtpHost: sys.smtpHost || '',
|
||||
smtpPort: sys.smtpPort || 587,
|
||||
smtpUser: sys.smtpUser || '',
|
||||
smtpPass: sys.smtpPass || '',
|
||||
smtpFrom: sys.smtpFrom || '',
|
||||
smtpTls: sys.smtpTls ?? true,
|
||||
smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv),
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Laden der Administrationsdaten' });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
}, []);
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS: USERS & INVITES
|
||||
// ============================================================================
|
||||
const handleCreateUser = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
const created = await api.adminCreateUser({
|
||||
email: newUserEmail.trim(),
|
||||
password: newUserPassword,
|
||||
displayName: newUserName.trim() || newUserEmail.split('@')[0],
|
||||
role: newUserRole,
|
||||
});
|
||||
setUsers((prev) => [...prev, created]);
|
||||
setNewUserName('');
|
||||
setNewUserEmail('');
|
||||
setNewUserPassword('');
|
||||
setNewUserRole('member');
|
||||
setStatusMsg({ type: 'ok', text: `Benutzer "${created.displayName}" wurde angelegt.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Anlegen des Benutzers' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateInvite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
const inv = await api.adminCreateInvite({
|
||||
email: inviteEmail.trim(),
|
||||
role: inviteRole,
|
||||
});
|
||||
setInvites((prev) => [inv, ...prev]);
|
||||
setInviteEmail('');
|
||||
setStatusMsg({
|
||||
type: 'ok',
|
||||
text: `Einladungscode ${inv.code} erfolgreich erstellt.`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Einladung' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteInvite = async (id: string) => {
|
||||
try {
|
||||
await api.adminDeleteInvite(id);
|
||||
setInvites((prev) => prev.filter((i) => i.id !== id));
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyInviteCode = (inv: UserInvite) => {
|
||||
navigator.clipboard.writeText(inv.code);
|
||||
setCopiedInviteId(inv.id);
|
||||
setTimeout(() => setCopiedInviteId(null), 2000);
|
||||
};
|
||||
|
||||
const handleChangeUserRole = async (u: User, role: UserRole) => {
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
const updated = await api.adminUpdateUser(u.id, { role });
|
||||
setUsers((prev) => prev.map((item) => (item.id === u.id ? updated : item)));
|
||||
setStatusMsg({
|
||||
type: 'ok',
|
||||
text: `Rolle für ${updated.displayName} auf "${role}" aktualisiert.`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleUserActive = async (u: User) => {
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
const updated = await api.adminUpdateUser(u.id, { isActive: !u.isActive });
|
||||
setUsers((prev) => prev.map((item) => (item.id === u.id ? updated : item)));
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmResetPassword = async (userId: string) => {
|
||||
if (!resetPwdVal.trim()) return;
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
await api.adminResetPassword(userId, resetPwdVal.trim());
|
||||
setResetPwdUserId(null);
|
||||
setResetPwdVal('');
|
||||
setStatusMsg({ type: 'ok', text: 'Passwort erfolgreich zurückgesetzt.' });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (u: User) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Benutzer "${u.displayName}" (${u.email}) wirklich unwiderruflich löschen?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
await api.adminDeleteUser(u.id);
|
||||
setUsers((prev) => prev.filter((item) => item.id !== u.id));
|
||||
setStatusMsg({ type: 'ok', text: 'Benutzer gelöscht.' });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// HANDLERS: SERVER & SMTP SETTINGS
|
||||
// ============================================================================
|
||||
const handleSaveSystemSettings = async (patch?: Partial<SystemSettings>) => {
|
||||
setStatusMsg(null);
|
||||
const next = { ...settings, ...(patch || {}) };
|
||||
setSettings(next);
|
||||
try {
|
||||
const saved = await api.adminUpdateSettings(next);
|
||||
setSettings((prev) => ({ ...prev, ...saved }));
|
||||
setStatusMsg({ type: 'ok', text: 'Server-Einstellungen erfolgreich gespeichert.' });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestSmtp = async () => {
|
||||
if (!testSmtpEmail.trim()) return;
|
||||
setTestingSmtp(true);
|
||||
setStatusMsg(null);
|
||||
try {
|
||||
await api.adminUpdateSettings(settings);
|
||||
await api.adminTestSMTP(testSmtpEmail.trim());
|
||||
setStatusMsg({
|
||||
type: 'ok',
|
||||
text: `Test-E-Mail erfolgreich an ${testSmtpEmail.trim()} gesendet!`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setStatusMsg({
|
||||
type: 'err',
|
||||
text: `SMTP-Test fehlgeschlagen: ${err.message}`,
|
||||
});
|
||||
} finally {
|
||||
setTestingSmtp(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-accent" />
|
||||
<span>Server-Administration</span>
|
||||
</h1>
|
||||
<p className="text-xs text-muted">
|
||||
Benutzerverwaltung, Einladungscodes, Isolationsmodus & Mail-Server
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="badge-accent">Server-Administrator</span>
|
||||
</div>
|
||||
|
||||
{statusMsg && (
|
||||
<div
|
||||
className={`p-3 text-xs flex items-center justify-between gap-2 rounded-card ${
|
||||
statusMsg.type === 'ok' ? 'alert-ok' : 'alert-err'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusMsg.type === 'ok' ? (
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
|
||||
)}
|
||||
<span>{statusMsg.text}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setStatusMsg(null)}
|
||||
className="text-muted hover:text-main text-[11px]"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</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('users')}
|
||||
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||
section === 'users'
|
||||
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
|
||||
: 'text-muted hover:text-main hover:bg-canvas/50'
|
||||
}`}
|
||||
>
|
||||
<Users className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">Benutzer & Einladungen</p>
|
||||
<p className="text-[10px] text-faint">Konten, Rollen & Invite-Codes</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isServerAdmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setSection('server')}
|
||||
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||
section === 'server'
|
||||
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
|
||||
: 'text-muted hover:text-main hover:bg-canvas/50'
|
||||
}`}
|
||||
>
|
||||
<Server className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">Server & Isolation</p>
|
||||
<p className="text-[10px] text-faint">Sichtbarkeit & Registrierung</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSection('smtp')}
|
||||
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||
section === 'smtp'
|
||||
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
|
||||
: 'text-muted hover:text-main hover:bg-canvas/50'
|
||||
}`}
|
||||
>
|
||||
<Mail className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">E-Mail / SMTP-Server</p>
|
||||
<p className="text-[10px] text-faint">Mailversand für Alarme</p>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{/* Right Content Area */}
|
||||
<div className="md:col-span-3 panel-surface p-5 text-xs">
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 1: BENUTZER, ROLLEN & EINLADUNGEN */}
|
||||
{/* ================================================================= */}
|
||||
{section === 'users' && (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-border pb-3">
|
||||
<h2 className="text-sm font-bold text-main">Benutzersteuerung, Rollen & Einladungen</h2>
|
||||
<p className="text-muted mt-0.5">
|
||||
Lege Benutzerkonten direkt an oder erzeuge Einladungscodes, mit denen sich eingeladene
|
||||
Personen selbst registrieren können, auch wenn die öffentliche Registrierung gesperrt ist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Direct User Creation Form */}
|
||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||
<h3 className="font-bold text-main flex items-center gap-1.5">
|
||||
<UserPlus className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Neues Benutzerkonto direkt anlegen</span>
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleCreateUser} className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">Anzeigename</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newUserName}
|
||||
onChange={(e) => setNewUserName(e.target.value)}
|
||||
placeholder="z.B. Anna Beispiel"
|
||||
required
|
||||
className="w-full min-w-0 h-8 px-2.5 input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">E-Mail-Adresse</label>
|
||||
<input
|
||||
type="email"
|
||||
value={newUserEmail}
|
||||
onChange={(e) => setNewUserEmail(e.target.value)}
|
||||
placeholder="anna@beispiel.de"
|
||||
required
|
||||
className="w-full min-w-0 h-8 px-2.5 input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">Initial-Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newUserPassword}
|
||||
onChange={(e) => setNewUserPassword(e.target.value)}
|
||||
placeholder="Mind. 4 Zeichen"
|
||||
minLength={4}
|
||||
required
|
||||
className="w-full min-w-0 h-8 px-2.5 input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">Benutzerrolle</label>
|
||||
<select
|
||||
value={newUserRole}
|
||||
onChange={(e) => setNewUserRole(e.target.value as UserRole)}
|
||||
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||
>
|
||||
<option value="member">Mitglied (Standard)</option>
|
||||
<option value="admin">Server-Admin (Vollzugriff)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary h-8 px-4 text-xs font-semibold flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Benutzer jetzt anlegen</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Invite Code Generator */}
|
||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||
<h3 className="font-bold text-main flex items-center gap-1.5">
|
||||
<KeyRound className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Einladungscode erzeugen (gültig für 7 Tage)</span>
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleCreateInvite} className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">
|
||||
Gebundene E-Mail (optional)
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="Leer lassen für beliebige E-Mail"
|
||||
className="w-full min-w-0 h-8 px-2.5 input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">Zugewiesene Server-Rolle</label>
|
||||
<select
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value as UserRole)}
|
||||
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||
>
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="admin">Server-Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-action h-8 px-4 text-xs font-semibold flex items-center gap-1.5 border-accent text-main"
|
||||
>
|
||||
<KeyRound className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Einladungscode generieren</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Active Invites List */}
|
||||
{invites.length > 0 && (
|
||||
<div className="pt-2 border-t border-border space-y-1.5">
|
||||
<p className="text-[11px] font-semibold text-muted">
|
||||
Aktive & bisherige Einladungscodes:
|
||||
</p>
|
||||
<div className="divide-y divide-border border border-border bg-panel rounded-card overflow-hidden">
|
||||
{invites.map((inv) => (
|
||||
<div
|
||||
key={inv.id}
|
||||
className="p-2 flex flex-wrap items-center justify-between gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<code className="px-2 py-0.5 bg-canvas border border-border font-mono font-bold text-accent rounded-card">
|
||||
{inv.code}
|
||||
</code>
|
||||
<span className="text-muted">
|
||||
Rolle: <strong className="text-main">{inv.role}</strong>
|
||||
{inv.email ? ` · Für: ${inv.email}` : ''}
|
||||
</span>
|
||||
{inv.usedBy ? (
|
||||
<span className="badge-neutral text-[10px]">
|
||||
Eingelöst
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-ok text-[10px]">
|
||||
Offen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!inv.usedBy && (
|
||||
<button
|
||||
onClick={() => handleCopyInviteCode(inv)}
|
||||
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
<span>{copiedInviteId === inv.id ? 'Kopiert!' : 'Code kopieren'}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteInvite(inv.id)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Einladung löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Users List */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-bold text-main">
|
||||
Registrierte Benutzer ({users.length})
|
||||
</h3>
|
||||
<div className="border border-border divide-y divide-border bg-canvas rounded-card overflow-hidden">
|
||||
{users.map((u) => (
|
||||
<div key={u.id} className="p-3 space-y-2">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<UserAvatar user={u} size="sm" className="border border-border/80" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-main">{u.displayName}</span>
|
||||
{!u.isActive && (
|
||||
<span className="badge-err text-[10px]">
|
||||
Gesperrt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted text-[11px]">{u.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Role selector */}
|
||||
<select
|
||||
value={u.role}
|
||||
disabled={!isServerAdmin && u.role === 'admin'}
|
||||
onChange={(e) => handleChangeUserRole(u, e.target.value as UserRole)}
|
||||
className="h-7 px-2 input-field text-[11px] bg-panel"
|
||||
title="Benutzerrolle ändern"
|
||||
>
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="admin">Server-Admin</option>
|
||||
</select>
|
||||
|
||||
{isServerAdmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() =>
|
||||
setResetPwdUserId(resetPwdUserId === u.id ? null : u.id)
|
||||
}
|
||||
className="btn-action h-7 px-2 text-[11px]"
|
||||
>
|
||||
Passwort
|
||||
</button>
|
||||
|
||||
{u.id !== currentUser.id && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleToggleUserActive(u)}
|
||||
className="btn-action h-7 px-2 text-[11px]"
|
||||
>
|
||||
{u.isActive ? 'Sperren' : 'Aktivieren'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => handleDeleteUser(u)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Benutzer löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resetPwdUserId === u.id && (
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border">
|
||||
<input
|
||||
type="password"
|
||||
value={resetPwdVal}
|
||||
onChange={(e) => setResetPwdVal(e.target.value)}
|
||||
placeholder="Neues Passwort eingeben…"
|
||||
className="flex-1 h-7 px-2 input-field"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleConfirmResetPassword(u.id)}
|
||||
className="btn-primary h-7 px-3 text-[11px]"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setResetPwdUserId(null)}
|
||||
className="btn-action h-7 px-2 text-[11px]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
|
||||
{/* ================================================================= */}
|
||||
{section === 'server' && isServerAdmin && (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-border pb-3">
|
||||
<h2 className="text-sm font-bold text-main">
|
||||
Server-Grundeinstellungen & Benutzer-Sichtbarkeit
|
||||
</h2>
|
||||
<p className="text-muted mt-0.5">
|
||||
Als Server-Admin bestimmst du zentral für alle Nutzer, ob sie komplett isoliert
|
||||
arbeiten, sich nur innerhalb ihrer Teams sehen oder ob alle Nutzer für alle sichtbar sind.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-muted font-semibold mb-1">
|
||||
Name der MindSift-Instanz
|
||||
</label>
|
||||
<div className="flex gap-2 max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.instanceName}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, instanceName: e.target.value }))
|
||||
}
|
||||
className="flex-1 h-9 px-3 input-field"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveSystemSettings()}
|
||||
className="btn-primary h-9 px-4 text-xs"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sharing Isolation Cards */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-muted font-semibold flex items-center gap-1.5">
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Globale Sharing- & Sichtbarkeits-Isolation</span>
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveSystemSettings({ sharingMode: 'strict_private' })}
|
||||
className={`p-4 text-left border rounded-card transition-colors ${
|
||||
settings.sharingMode === 'strict_private'
|
||||
? 'bg-canvas border-accent text-main shadow-panel'
|
||||
: 'bg-canvas/50 border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs text-main">1. Strikt Privat (Keiner sieht andere)</p>
|
||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">
|
||||
Jeder Benutzer arbeitet komplett für sich allein. Keine geteilten Spaces und keine
|
||||
Benutzersuche möglich.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveSystemSettings({ sharingMode: 'teams_only' })}
|
||||
className={`p-4 text-left border rounded-card transition-colors ${
|
||||
settings.sharingMode === 'teams_only'
|
||||
? 'bg-canvas border-accent text-main shadow-panel'
|
||||
: 'bg-canvas/50 border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs text-main">2. Teams isoliert (Empfohlen)</p>
|
||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">
|
||||
Benutzer sehen ausschließlich Personen, mit denen sie mindestens ein gemeinsames Team
|
||||
teilen.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveSystemSettings({ sharingMode: 'open' })}
|
||||
className={`p-4 text-left border rounded-card transition-colors ${
|
||||
settings.sharingMode === 'open'
|
||||
? 'bg-canvas border-accent text-main shadow-panel'
|
||||
: 'bg-canvas/50 border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs text-main">3. Offen (Alle sehen alle)</p>
|
||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">
|
||||
Alle registrierten Benutzer dieser Instanz können sich gegenseitig sehen und Spaces
|
||||
miteinander teilen.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Public Registration Toggle Switch */}
|
||||
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
||||
<div className="pr-4">
|
||||
<p className="font-bold text-main">Öffentliche Selbstregistrierung erlauben</p>
|
||||
<p className="text-[11px] text-muted mt-0.5">
|
||||
Wenn deaktiviert, können sich fremde Personen auf dem Login-Bildschirm nicht selbst registrieren.
|
||||
Neue Konten erfordern dann zwingend einen Einladungscode oder müssen von einem Admin angelegt werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}>
|
||||
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={settings.allowRegistration}
|
||||
onClick={() =>
|
||||
handleSaveSystemSettings({ allowRegistration: !settings.allowRegistration })
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-pill border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
settings.allowRegistration ? 'bg-accent' : 'bg-panel-elevated border border-border'
|
||||
}`}
|
||||
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-pill bg-white shadow-md ring-0 transition duration-200 ease-in-out ${
|
||||
settings.allowRegistration ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 4: E-MAIL / SMTP-SERVER (SERVER-ADMIN ONLY) */}
|
||||
{/* ================================================================= */}
|
||||
{section === 'smtp' && isServerAdmin && (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-border pb-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-main">
|
||||
E-Mail / SMTP-Server für Aufgaben-Alarme
|
||||
</h2>
|
||||
<p className="text-muted mt-0.5">
|
||||
Konfiguriere den Postausgangsserver per Docker-Compose Environment-Variablen oder
|
||||
direkt hier über die Oberfläche (wird persistent in der SQLite-Datenbank gespeichert).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{settings.smtpConfiguredViaEnv && (
|
||||
<span className="badge-ok text-[11px] flex-shrink-0">
|
||||
Docker Env erkannt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
SMTP Host (`SMTP_HOST`)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.smtpHost || ''}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpHost: e.target.value }))
|
||||
}
|
||||
placeholder="z.B. smtp.example.com oder mail.deinedomain.de"
|
||||
className="w-full h-9 px-3 input-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
SMTP Port (`SMTP_PORT`)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.smtpPort || 587}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpPort: Number(e.target.value) }))
|
||||
}
|
||||
placeholder="587"
|
||||
className="w-full h-9 px-3 input-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
SMTP Benutzername (`SMTP_USER`)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.smtpUser || ''}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpUser: e.target.value }))
|
||||
}
|
||||
placeholder="alerts@deinedomain.de"
|
||||
className="w-full h-9 px-3 input-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
SMTP Passwort (`SMTP_PASS`)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settings.smtpPass || ''}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpPass: e.target.value }))
|
||||
}
|
||||
placeholder="••••••••••••"
|
||||
className="w-full h-9 px-3 input-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
Absender-Adresse (`SMTP_FROM`)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.smtpFrom || ''}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpFrom: e.target.value }))
|
||||
}
|
||||
placeholder="MindSift <alerts@deinedomain.de>"
|
||||
className="w-full h-9 px-3 input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center pt-5">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.smtpTls ?? true}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpTls: e.target.checked }))
|
||||
}
|
||||
className="w-4 h-4 checkbox-square cursor-pointer"
|
||||
/>
|
||||
<span className="text-main font-medium">
|
||||
STARTTLS / TLS-Verschlüsselung verwenden (`SMTP_TLS=true`)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveSystemSettings()}
|
||||
className="btn-primary h-9 px-5 text-xs font-semibold"
|
||||
>
|
||||
SMTP-Konfiguration in Datenbank speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Test SMTP Delivery */}
|
||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-2">
|
||||
<h3 className="font-bold text-main">SMTP-Verbindung testen</h3>
|
||||
<p className="text-[11px] text-muted">
|
||||
Sendet sofort eine Test-E-Mail mit den oben eingetragenen Daten.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={testSmtpEmail}
|
||||
onChange={(e) => setTestSmtpEmail(e.target.value)}
|
||||
placeholder="empfaenger@beispiel.de"
|
||||
className="flex-1 h-8 px-3 input-field"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTestSmtp}
|
||||
disabled={testingSmtp || !testSmtpEmail.trim()}
|
||||
className="btn-action h-8 px-4 text-xs flex items-center justify-center gap-1.5 disabled:opacity-40"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5 text-accent" />
|
||||
<span>{testingSmtp ? 'Sende Test-Mail…' : 'Test-E-Mail senden'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -92,34 +92,34 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
|
||||
const baseUrl = window.location.origin;
|
||||
|
||||
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 */}
|
||||
<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="w-10 h-10 rounded-full bg-[#8ab4f8]/15 flex items-center justify-center">
|
||||
<Key className="w-5 h-5 text-[#8ab4f8]" />
|
||||
<div className="w-8 h-8 bg-accent text-white flex items-center justify-center flex-shrink-0">
|
||||
<Key className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[#e3e3e3]">
|
||||
<h3 className="text-base font-bold text-white">
|
||||
Webhooks & Ingestion API-Schlüssel
|
||||
</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.
|
||||
</p>
|
||||
</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
|
||||
type="text"
|
||||
value={newKeyName}
|
||||
onChange={(e) => setNewKeyName(e.target.value)}
|
||||
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
|
||||
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" />
|
||||
<span>API-Key erzeugen</span>
|
||||
@@ -127,18 +127,18 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
|
||||
</form>
|
||||
|
||||
{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">
|
||||
<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):
|
||||
</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}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
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" />}
|
||||
<span>{copiedKey ? 'Kopiert!' : 'Key kopieren'}</span>
|
||||
@@ -147,29 +147,29 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
|
||||
)}
|
||||
|
||||
{/* Active Keys List */}
|
||||
<div className="mt-5 space-y-2">
|
||||
<div className="mt-3 border border-border divide-y divide-border">
|
||||
{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.
|
||||
</p>
|
||||
) : (
|
||||
keys.map((k) => (
|
||||
<div
|
||||
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>
|
||||
<span className="text-sm font-medium text-[#e3e3e3]">{k.name}</span>
|
||||
<span className="ml-3 text-xs font-mono text-[#8ab4f8]">{k.keyPrefix}</span>
|
||||
<span className="text-sm font-medium text-white">{k.name}</span>
|
||||
<span className="ml-3 text-xs font-mono text-accent">{k.keyPrefix}</span>
|
||||
{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')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
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" />
|
||||
</button>
|
||||
@@ -180,22 +180,22 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
|
||||
</div>
|
||||
|
||||
{/* Section 2: Interactive Smarthome Webhook Simulator */}
|
||||
<div className="p-6 sm:p-8 rounded-3xl bg-[#1e1f20] gemini-border shadow-card-depth">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 rounded-full bg-[#c58af9]/15 flex items-center justify-center">
|
||||
<Webhook className="w-5 h-5 text-[#c58af9]" />
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="w-7 h-7 bg-accent text-white flex items-center justify-center flex-shrink-0">
|
||||
<Webhook className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-[#e3e3e3]">
|
||||
<h3 className="text-base font-bold text-white">
|
||||
Live Smarthome Webhook-Simulator
|
||||
</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.
|
||||
</p>
|
||||
</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: '🚨 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);
|
||||
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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-2.5">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={simTitle}
|
||||
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
|
||||
type="button"
|
||||
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" />
|
||||
<span>Webhook jetzt abfeuern</span>
|
||||
@@ -233,16 +233,16 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
|
||||
</div>
|
||||
|
||||
{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 */}
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-[#9aa0a6]">
|
||||
<Terminal className="w-3.5 h-3.5 text-[#8ab4f8]" />
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-zinc-400">
|
||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||
<span>cURL / Home Assistant REST Command:</span>
|
||||
</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}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
@@ -255,16 +255,16 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
|
||||
</div>
|
||||
|
||||
{/* 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="w-10 h-10 rounded-full bg-[#81c995]/15 flex items-center justify-center flex-shrink-0">
|
||||
<ShieldCheck className="w-5 h-5 text-[#81c995]" />
|
||||
<div className="w-7 h-7 bg-accent text-white flex items-center justify-center flex-shrink-0">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-[#e3e3e3]">
|
||||
<h3 className="text-base font-bold text-white">
|
||||
100 % Datensouveränität & Komplett-Export
|
||||
</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.
|
||||
</p>
|
||||
</div>
|
||||
@@ -272,7 +272,7 @@ export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
|
||||
|
||||
<button
|
||||
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" />
|
||||
<span>Komplett-Backup (.json)</span>
|
||||
|
||||
@@ -0,0 +1,990 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import {
|
||||
User as UserIcon,
|
||||
Bell,
|
||||
Key,
|
||||
Plus,
|
||||
Trash2,
|
||||
Terminal,
|
||||
Download,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
Mail,
|
||||
MessageSquare,
|
||||
Globe,
|
||||
Send,
|
||||
AlertCircle,
|
||||
Palette,
|
||||
Upload,
|
||||
Camera,
|
||||
} from 'lucide-react';
|
||||
import { APIKey, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme';
|
||||
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
||||
|
||||
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 [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || '');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [isProcessingAvatar, setIsProcessingAvatar] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// --- 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 || '');
|
||||
setAvatarUrl(currentUser.avatarUrl || '');
|
||||
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: Avatar ---
|
||||
const handleAvatarFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setIsProcessingAvatar(true);
|
||||
setProfileMsg(null);
|
||||
try {
|
||||
const dataUrl = await resizeImageToDataUrl(file, 256);
|
||||
setAvatarUrl(dataUrl);
|
||||
triggerHaptic(10);
|
||||
} catch (err: any) {
|
||||
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Laden des Bildes' });
|
||||
} finally {
|
||||
setIsProcessingAvatar(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectPresetAvatar = (preset: string) => {
|
||||
setAvatarUrl(preset);
|
||||
triggerHaptic(10);
|
||||
};
|
||||
|
||||
const handleRemoveAvatar = () => {
|
||||
setAvatarUrl('');
|
||||
triggerHaptic(10);
|
||||
};
|
||||
|
||||
// --- Handlers: Profile ---
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setProfileMsg(null);
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
const patch: any = {
|
||||
displayName: displayName.trim(),
|
||||
email: email.trim(),
|
||||
avatarUrl: avatarUrl.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 curlTriageExample = `curl -X POST ${baseUrl}/api/v1/inbox \\
|
||||
-H "X-API-Key: ${activeKeyPlaceholder}" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"content": "Kellerfeuchtigkeit über 68% gestiegen – Entfeuchter prüfen #smarthome #alarm", "priority": "urgent"}'`;
|
||||
|
||||
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'
|
||||
: '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-5">
|
||||
{/* Avatar Management */}
|
||||
<div className="p-3.5 bg-panel-elevated/40 border border-border rounded-box space-y-3">
|
||||
<label className="block text-muted font-medium">Profilbild</label>
|
||||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-4">
|
||||
<div className="relative group shrink-0">
|
||||
<UserAvatar
|
||||
user={{ displayName, email, avatarUrl }}
|
||||
size="xl"
|
||||
className="border-2 border-border/80 shadow-md ring-2 ring-primary/20"
|
||||
/>
|
||||
{isProcessingAvatar && (
|
||||
<div className="absolute inset-0 bg-base-100/70 rounded-full flex items-center justify-center text-[10px] font-medium text-main">
|
||||
Lädt…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-2.5 text-center sm:text-left">
|
||||
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarFileSelected}
|
||||
className="hidden"
|
||||
id="avatar-upload-input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isProcessingAvatar}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
<span>Bild hochladen</span>
|
||||
</button>
|
||||
|
||||
{avatarUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveAvatar}
|
||||
className="btn-ghost h-8 px-2.5 text-xs text-status-err hover:bg-status-err/10 flex items-center gap-1"
|
||||
title="Profilbild entfernen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<span>Entfernen</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Emoji Presets */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-[11px] text-muted">Oder schnelles Emoji wählen:</span>
|
||||
<div className="flex flex-wrap items-center justify-center sm:justify-start gap-1.5 pt-0.5">
|
||||
{['🦊', '🐱', '🐶', '🐼', '🦁', '🦉', '🚀', '☕', '⚡', '🎨', '🌟', '🎯', '💡'].map(
|
||||
(preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => handleSelectPresetAvatar(preset)}
|
||||
className={`w-7 h-7 rounded-full text-sm flex items-center justify-center transition-transform hover:scale-110 active:scale-95 ${
|
||||
avatarUrl === preset
|
||||
? 'bg-primary/20 border-2 border-primary scale-105'
|
||||
: 'bg-panel hover:bg-panel-hover border border-border'
|
||||
}`}
|
||||
title={`Emoji ${preset}`}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-faint">
|
||||
Wird überall kreisförmig dargestellt (z. B. Menüleiste oben, Freigabelisten, Aufgaben).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 & 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 Examples */}
|
||||
<div className="space-y-4">
|
||||
<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: In Triage-Deck einspeisen (erhält Webhook-Badge)</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopy(curlTriageExample, 'curl-triage')}
|
||||
className="btn-action h-7 px-2.5 text-[11px]"
|
||||
>
|
||||
{copiedLabel === 'curl-triage' ? 'Kopiert!' : 'Befehl kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
|
||||
{curlTriageExample}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<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: Direkt als Aufgabe anlegen</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopy(curlTaskExample, 'curl-task')}
|
||||
className="btn-action h-7 px-2.5 text-[11px]"
|
||||
>
|
||||
{copiedLabel === 'curl-task' ? 'Kopiert!' : 'Befehl kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 code-block text-[11px] font-mono overflow-x-auto whitespace-pre">
|
||||
{curlTaskExample}
|
||||
</pre>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,653 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Users,
|
||||
Plus,
|
||||
Trash2,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
AlertCircle,
|
||||
UserPlus,
|
||||
Shield,
|
||||
Pencil,
|
||||
X,
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import { Team, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
|
||||
interface GroupsViewProps {
|
||||
currentUser: User;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) => {
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [directoryUsers, setDirectoryUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusMsg, setStatusMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
// Create Group Modal State
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [newGroupName, setNewGroupName] = useState('');
|
||||
const [newGroupDesc, setNewGroupDesc] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
// Edit Group State
|
||||
const [editingTeamId, setEditingTeamId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editDesc, setEditDesc] = useState('');
|
||||
|
||||
// Add Member State per Team
|
||||
const [selectedUserByTeam, setSelectedUserByTeam] = useState<Record<string, User | null>>({});
|
||||
const [memberRoleByTeam, setMemberRoleByTeam] = useState<Record<string, 'admin' | 'member'>>({});
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [userTeams, allUsers] = await Promise.all([
|
||||
api.getUserTeams(),
|
||||
api.getUsersDirectory().catch(() => []),
|
||||
]);
|
||||
setTeams(userTeams);
|
||||
setDirectoryUsers(allUsers);
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Laden der Gruppen' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const handleCreateGroup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = newGroupName.trim();
|
||||
if (!name) return;
|
||||
|
||||
setCreating(true);
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(15);
|
||||
|
||||
try {
|
||||
const created = await api.createTeam(name, newGroupDesc.trim());
|
||||
setNewGroupName('');
|
||||
setNewGroupDesc('');
|
||||
setShowCreateModal(false);
|
||||
await loadData();
|
||||
setStatusMsg({ type: 'ok', text: `Gruppe "${created.name}" erfolgreich erstellt.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Gruppe' });
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEdit = (team: Team) => {
|
||||
setEditingTeamId(team.id);
|
||||
setEditName(team.name);
|
||||
setEditDesc(team.description || '');
|
||||
};
|
||||
|
||||
const handleSaveEdit = async (teamId: string) => {
|
||||
const name = editName.trim();
|
||||
if (!name) return;
|
||||
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(10);
|
||||
try {
|
||||
await api.updateTeam(teamId, name, editDesc.trim());
|
||||
setEditingTeamId(null);
|
||||
await loadData();
|
||||
setStatusMsg({ type: 'ok', text: 'Gruppe erfolgreich aktualisiert.' });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Aktualisieren der Gruppe' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTeam = async (team: Team) => {
|
||||
if (!window.confirm(`Gruppe "${team.name}" wirklich löschen? Alle Freigaben für diese Gruppe werden entfernt.`)) {
|
||||
return;
|
||||
}
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
await api.deleteTeam(team.id);
|
||||
await loadData();
|
||||
setStatusMsg({ type: 'ok', text: `Gruppe "${team.name}" gelöscht.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Löschen der Gruppe' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async (teamId: string) => {
|
||||
const user = selectedUserByTeam[teamId];
|
||||
if (!user) return;
|
||||
const role = memberRoleByTeam[teamId] || 'member';
|
||||
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(10);
|
||||
try {
|
||||
await api.addTeamMember(teamId, { userId: user.id, role });
|
||||
setSelectedUserByTeam((prev) => ({ ...prev, [teamId]: null }));
|
||||
await loadData();
|
||||
setStatusMsg({ type: 'ok', text: `${user.displayName} wurde zur Gruppe hinzugefügt.` });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Hinzufügen des Mitglieds' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeMemberRole = async (teamId: string, memberUserId: string, newRole: 'admin' | 'member') => {
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(10);
|
||||
try {
|
||||
await api.updateTeamMemberRole(teamId, memberUserId, newRole);
|
||||
await loadData();
|
||||
setStatusMsg({
|
||||
type: 'ok',
|
||||
text: `Rolle erfolgreich zu "${newRole === 'admin' ? 'Gruppen-Admin' : 'Mitglied'}" geändert.`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Ändern der Rolle' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (teamId: string, memberUserId: string, memberName: string) => {
|
||||
const isSelf = memberUserId === currentUser.id;
|
||||
const msg = isSelf
|
||||
? 'Möchtest du diese Gruppe wirklich verlassen?'
|
||||
: `Mitglied "${memberName}" wirklich aus der Gruppe entfernen?`;
|
||||
|
||||
if (!window.confirm(msg)) return;
|
||||
|
||||
setStatusMsg(null);
|
||||
triggerHaptic(10);
|
||||
try {
|
||||
await api.removeTeamMember(teamId, memberUserId);
|
||||
await loadData();
|
||||
setStatusMsg({
|
||||
type: 'ok',
|
||||
text: isSelf ? 'Du hast die Gruppe verlassen.' : `Mitglied "${memberName}" wurde entfernt.`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Entfernen des Mitglieds' });
|
||||
}
|
||||
};
|
||||
|
||||
// Group teams into managed (admin) and member-only
|
||||
const managedTeams = teams.filter((t) =>
|
||||
(t.members || []).some((m) => m.userId === currentUser.id && m.role === 'admin')
|
||||
);
|
||||
|
||||
const memberTeams = teams.filter(
|
||||
(t) =>
|
||||
(t.members || []).some((m) => m.userId === currentUser.id && m.role === 'member') &&
|
||||
!managedTeams.some((mt) => mt.id === t.id)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-5">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 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 cursor-pointer"
|
||||
title="Zurück"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Zurück</span>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Gruppen & Teams</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
|
||||
{teams.length}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Erstelle und verwalte Gruppen (z. B. Familie, WG oder Projektteams) für gemeinsame Notizen, Aufgaben und Listen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
className="btn-primary h-8 px-3.5 text-xs flex items-center gap-1.5 self-start sm:self-auto cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Neue Gruppe</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
{statusMsg && (
|
||||
<div
|
||||
className={`p-3 text-xs flex items-center justify-between gap-2 rounded-card ${
|
||||
statusMsg.type === 'ok' ? 'alert-ok' : 'alert-err'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusMsg.type === 'ok' ? (
|
||||
<Check className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
|
||||
)}
|
||||
<span>{statusMsg.text}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setStatusMsg(null)}
|
||||
className="text-muted hover:text-main text-[11px] cursor-pointer"
|
||||
>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal: Neue Gruppe erstellen */}
|
||||
{showCreateModal && (
|
||||
<div
|
||||
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center bg-black/60 backdrop-blur-xs p-4 animate-in fade-in duration-150"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md bg-panel border border-border rounded-xl shadow-panel p-5 space-y-4 animate-in zoom-in-95 duration-150"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between pb-3 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-btn bg-accent/15 text-accent flex items-center justify-center">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
<h3 className="font-bold text-sm text-main">Neue Gruppe erstellen</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="p-1 text-muted hover:text-main rounded-btn transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateGroup} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-muted text-xs mb-1 font-medium">
|
||||
Name der Gruppe *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupName}
|
||||
onChange={(e) => setNewGroupName(e.target.value)}
|
||||
placeholder="z. B. Familie, WG, Arbeit, Projekt X…"
|
||||
required
|
||||
autoFocus
|
||||
className="w-full h-8 px-2.5 input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted text-xs mb-1 font-medium">
|
||||
Beschreibung (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newGroupDesc}
|
||||
onChange={(e) => setNewGroupDesc(e.target.value)}
|
||||
placeholder="z. B. Gemeinsame Einkaufslisten & Notizen…"
|
||||
className="w-full h-8 px-2.5 input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-canvas border border-border rounded-btn text-[11px] text-muted space-y-1">
|
||||
<p className="font-semibold text-main flex items-center gap-1.5">
|
||||
<Shield className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Du wirst Gruppen-Admin</span>
|
||||
</p>
|
||||
<p>
|
||||
Als Ersteller kannst du weitere Mitglieder hinzufügen, Rollen zuweisen und die Gruppe verwalten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="btn-action h-8 px-3 text-xs cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !newGroupName.trim()}
|
||||
className="btn-primary h-8 px-4 text-xs flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Gruppe anlegen</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted text-xs">Lade Gruppen…</div>
|
||||
) : teams.length === 0 ? (
|
||||
<div className="p-10 bg-canvas border border-border rounded-card text-center space-y-3">
|
||||
<div className="w-12 h-12 rounded-full bg-panel-elevated text-muted mx-auto flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-accent opacity-75" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-main text-sm">Noch keine Gruppen vorhanden</h3>
|
||||
<p className="text-muted text-xs mt-1 max-w-md mx-auto">
|
||||
Erstelle deine erste Gruppe, um Aufgaben, Einkaufslisten oder Dokumente gemeinsam mit anderen zu nutzen.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
className="btn-primary h-8 px-4 text-xs inline-flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Erste Gruppe anlegen</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* SECTION 1: Gruppen, die ich verwalte */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xs font-bold text-main uppercase tracking-wider text-muted flex items-center gap-2">
|
||||
<Shield className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Gruppen, die ich verwalte ({managedTeams.length})</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{managedTeams.length === 0 ? (
|
||||
<div className="p-4 bg-canvas border border-border rounded-card text-xs text-muted">
|
||||
Du bist derzeit noch in keiner Gruppe als Admin eingetragen. Klicke oben auf "Neue Gruppe", um eine eigene Gruppe zu erstellen.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{managedTeams.map((team) => {
|
||||
const members = team.members || [];
|
||||
const isEditing = editingTeamId === team.id;
|
||||
const memberUserIds = members.map((m) => m.userId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={team.id}
|
||||
className="p-4 bg-canvas border border-border rounded-card space-y-4 shadow-sm"
|
||||
>
|
||||
{/* Team Header */}
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="flex items-start gap-2.5 flex-1 min-w-0">
|
||||
<div className="w-8 h-8 rounded-btn bg-accent/15 text-accent flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex-1 space-y-2 max-w-lg">
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
placeholder="Gruppenname"
|
||||
className="w-full h-8 px-2.5 input-field text-xs font-bold"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value)}
|
||||
placeholder="Optionale Beschreibung"
|
||||
className="w-full h-7 px-2.5 input-field text-xs"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveEdit(team.id)}
|
||||
className="btn-primary h-7 px-3 text-[11px] cursor-pointer"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingTeamId(null)}
|
||||
className="btn-action h-7 px-2.5 text-[11px] cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-bold text-sm text-main truncate">
|
||||
{team.name}
|
||||
</span>
|
||||
<span className="badge-accent text-[10px] py-0.5 px-2">
|
||||
Gruppen-Admin
|
||||
</span>
|
||||
<span className="text-[11px] px-1.5 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
|
||||
{members.length} {members.length === 1 ? 'Mitglied' : 'Mitglieder'}
|
||||
</span>
|
||||
</div>
|
||||
{team.description && (
|
||||
<p className="text-xs text-muted mt-1 break-words">
|
||||
{team.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleStartEdit(team)}
|
||||
className="btn-action h-7 px-2.5 text-xs flex items-center gap-1 cursor-pointer"
|
||||
title="Gruppe bearbeiten"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Bearbeiten</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTeam(team)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Gruppe löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members List */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-[11px] font-semibold text-muted uppercase tracking-wider">
|
||||
Mitglieder ({members.length})
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{members.map((m) => {
|
||||
const isSelf = m.userId === currentUser.id;
|
||||
const isAdmin = m.role === 'admin';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.userId}
|
||||
className="p-2.5 bg-panel border border-border rounded-btn flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<UserAvatar user={{ displayName: m.displayName, email: m.email, avatarUrl: m.avatarUrl }} size="xs" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-main font-medium truncate text-xs">
|
||||
{m.displayName} {isSelf && <span className="text-faint">(Du)</span>}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted truncate">
|
||||
{m.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<select
|
||||
value={m.role}
|
||||
onChange={(e) =>
|
||||
handleChangeMemberRole(
|
||||
team.id,
|
||||
m.userId,
|
||||
e.target.value as 'admin' | 'member'
|
||||
)
|
||||
}
|
||||
className={`h-6 px-1.5 text-[10px] rounded input-field bg-panel cursor-pointer ${
|
||||
isAdmin
|
||||
? 'border-accent text-accent font-semibold'
|
||||
: 'text-muted'
|
||||
}`}
|
||||
title="Rolle ändern"
|
||||
>
|
||||
<option value="member" className="bg-panel text-main">Mitglied</option>
|
||||
<option value="admin" className="bg-panel text-main">Gruppen-Admin</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() => handleRemoveMember(team.id, m.userId, m.displayName)}
|
||||
className="p-1 text-faint hover:text-status-err rounded transition-colors cursor-pointer"
|
||||
title={isSelf ? 'Gruppe verlassen' : 'Mitglied entfernen'}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Member Row */}
|
||||
<div className="pt-3 border-t border-border flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<div className="flex-1 min-w-[220px]">
|
||||
<UserAutocomplete
|
||||
users={directoryUsers}
|
||||
selectedUser={selectedUserByTeam[team.id] || null}
|
||||
onSelectUser={(u) =>
|
||||
setSelectedUserByTeam((prev) => ({ ...prev, [team.id]: u }))
|
||||
}
|
||||
excludeUserIds={memberUserIds}
|
||||
placeholder="Benutzer suchen zum Hinzufügen…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={memberRoleByTeam[team.id] || 'member'}
|
||||
onChange={(e) =>
|
||||
setMemberRoleByTeam((prev) => ({
|
||||
...prev,
|
||||
[team.id]: e.target.value as 'admin' | 'member',
|
||||
}))
|
||||
}
|
||||
className="h-8 px-2.5 input-field text-xs bg-panel"
|
||||
>
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="admin">Gruppen-Admin</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleAddMember(team.id)}
|
||||
disabled={!selectedUserByTeam[team.id]}
|
||||
className="btn-primary h-8 px-3.5 text-xs flex items-center justify-center gap-1.5 cursor-pointer disabled:opacity-40"
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
<span>Hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SECTION 2: Meine Mitgliedschaften (als Mitglied) */}
|
||||
{memberTeams.length > 0 && (
|
||||
<div className="space-y-3 pt-2">
|
||||
<h2 className="text-xs font-bold text-main uppercase tracking-wider text-muted flex items-center gap-2">
|
||||
<Users className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Meine Mitgliedschaften ({memberTeams.length})</span>
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{memberTeams.map((team) => {
|
||||
const members = team.members || [];
|
||||
const admins = members.filter((m) => m.role === 'admin');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={team.id}
|
||||
className="p-4 bg-canvas border border-border rounded-card space-y-3 shadow-sm"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 border-b border-border pb-3">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div className="w-8 h-8 rounded-btn bg-panel-elevated text-muted flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-sm text-main">{team.name}</span>
|
||||
<span className="text-[11px] px-1.5 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
|
||||
{members.length} {members.length === 1 ? 'Mitglied' : 'Mitglieder'}
|
||||
</span>
|
||||
</div>
|
||||
{team.description && (
|
||||
<p className="text-xs text-muted mt-1">{team.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleRemoveMember(team.id, currentUser.id, currentUser.displayName)}
|
||||
className="btn-action text-status-err hover:bg-status-err-bg h-7 px-2.5 text-xs flex items-center gap-1.5 cursor-pointer"
|
||||
title="Gruppe verlassen"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
<span>Gruppe verlassen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Admins & Members list in read-only mode */}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] font-semibold text-muted">
|
||||
Gruppen-Admins:{' '}
|
||||
<span className="text-main font-normal">
|
||||
{admins.map((a) => a.displayName).join(', ') || 'Keiner'}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-[11px] font-semibold text-muted">
|
||||
Alle Mitglieder:{' '}
|
||||
<span className="text-faint font-normal">
|
||||
{members.map((m) => m.displayName).join(', ')}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,295 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Plus,
|
||||
Trash2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
RotateCcw,
|
||||
Check,
|
||||
Columns,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { KanbanColumn } from '../../lib/types';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface ManageColumnsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialColumns: KanbanColumn[];
|
||||
onSave: (columns: KanbanColumn[]) => Promise<void>;
|
||||
listTitle?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [
|
||||
{ id: 'todo', name: 'Zu erledigen' },
|
||||
{ id: 'in_progress', name: 'In Bearbeitung', color: 'accent' },
|
||||
{ id: 'waiting', name: 'Wartet auf andere', color: 'amber' },
|
||||
{ id: 'done', name: 'Erledigt', isDone: true, color: 'emerald' },
|
||||
];
|
||||
|
||||
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialColumns,
|
||||
onSave,
|
||||
listTitle,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [columns, setColumns] = useState<KanbanColumn[]>(() => {
|
||||
return initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS;
|
||||
});
|
||||
const [newColName, setNewColName] = useState('');
|
||||
const [newColIsDone, setNewColIsDone] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setColumns(initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS);
|
||||
}, [initialColumns, isOpen]);
|
||||
|
||||
const handleMove = (index: number, direction: 'up' | 'down') => {
|
||||
triggerHaptic(10);
|
||||
const targetIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (targetIndex < 0 || targetIndex >= columns.length) return;
|
||||
const next = [...columns];
|
||||
const temp = next[index];
|
||||
next[index] = next[targetIndex];
|
||||
next[targetIndex] = temp;
|
||||
setColumns(next);
|
||||
};
|
||||
|
||||
const handleRename = (id: string, newName: string) => {
|
||||
setColumns((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, name: newName } : c))
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleDone = (id: string) => {
|
||||
triggerHaptic(10);
|
||||
setColumns((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, isDone: !c.isDone } : c))
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
if (columns.length <= 1) {
|
||||
alert('Mindestens eine Spalte muss vorhanden sein.');
|
||||
return;
|
||||
}
|
||||
triggerHaptic(10);
|
||||
setColumns((prev) => prev.filter((c) => c.id !== id));
|
||||
};
|
||||
|
||||
const handleAddColumn = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = newColName.trim();
|
||||
if (!name) return;
|
||||
triggerHaptic(10);
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '_').slice(0, 20) || 'col';
|
||||
const id = `${slug}_${Date.now().toString(36).slice(-4)}`;
|
||||
setColumns((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
name,
|
||||
isDone: newColIsDone,
|
||||
},
|
||||
]);
|
||||
setNewColName('');
|
||||
setNewColIsDone(false);
|
||||
};
|
||||
|
||||
const handleResetToDefault = () => {
|
||||
if (window.confirm('Spalten auf Standard zurücksetzen? (Zu erledigen, In Bearbeitung, Wartet auf andere, Erledigt)')) {
|
||||
triggerHaptic(15);
|
||||
setColumns(DEFAULT_KANBAN_COLUMNS);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (columns.length === 0) {
|
||||
alert('Es muss mindestens eine Spalte vorhanden sein.');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
await onSave(columns);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Speichern der Spalten');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4">
|
||||
<div
|
||||
className="w-full max-w-md panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[90vh] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
|
||||
<div className="flex items-center gap-2">
|
||||
<Columns className="w-4 h-4 text-accent" />
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-main">Kanban-Spalten anpassen</h3>
|
||||
{listTitle && (
|
||||
<p className="text-[10px] text-muted truncate">Liste: {listTitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted hover:text-main p-1.5 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4 overflow-y-auto flex-1">
|
||||
<p className="text-xs text-muted">
|
||||
Passe die Spalten für das Kanban-Board an. Aufgaben können in jede beliebige Spalte verschoben werden.
|
||||
</p>
|
||||
|
||||
{/* Columns List */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-xs font-semibold text-main">Aktive Spalten ({columns.length}):</label>
|
||||
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
|
||||
{columns.map((col, idx) => (
|
||||
<div
|
||||
key={col.id}
|
||||
className="p-2 bg-canvas border border-border rounded-card flex items-center gap-2"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={idx === 0}
|
||||
onClick={() => handleMove(idx, 'up')}
|
||||
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
|
||||
title="Nach oben"
|
||||
>
|
||||
<ArrowUp className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={idx === columns.length - 1}
|
||||
onClick={() => handleMove(idx, 'down')}
|
||||
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
|
||||
title="Nach unten"
|
||||
>
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={col.name}
|
||||
onChange={(e) => handleRename(col.id, e.target.value)}
|
||||
className="flex-1 h-7 px-2 text-xs input-field"
|
||||
placeholder="Spaltenname"
|
||||
/>
|
||||
|
||||
{/* IsDone Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleDone(col.id)}
|
||||
className={`h-7 px-2 text-[10px] rounded-btn border transition-colors flex items-center gap-1 ${
|
||||
col.isDone
|
||||
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
|
||||
: 'bg-panel border-border text-faint hover:text-muted'
|
||||
}`}
|
||||
title={col.isDone ? 'Gilt als Erledigt' : 'Gilt als Offen'}
|
||||
>
|
||||
<Check className={`w-2.5 h-2.5 ${col.isDone ? 'stroke-[3]' : 'opacity-40'}`} />
|
||||
<span>Erledigt</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(col.id)}
|
||||
disabled={columns.length <= 1}
|
||||
className="text-muted hover:text-status-err disabled:opacity-20 p-1 transition-colors"
|
||||
title="Spalte löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add New Column Form */}
|
||||
<form onSubmit={handleAddColumn} className="space-y-2 pt-2 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main">Neue Spalte hinzufügen:</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newColName}
|
||||
onChange={(e) => setNewColName(e.target.value)}
|
||||
placeholder="z.B. Wartet auf Feedback, Review…"
|
||||
className="flex-1 h-8 px-2.5 text-xs input-field"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewColIsDone((prev) => !prev)}
|
||||
className={`h-8 px-2.5 text-xs rounded-btn border transition-colors flex items-center gap-1 ${
|
||||
newColIsDone
|
||||
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
|
||||
: 'bg-panel border-border text-muted hover:text-main'
|
||||
}`}
|
||||
title="Sollen Aufgaben in dieser Spalte als erledigt gelten?"
|
||||
>
|
||||
<Check className="w-3 h-3" />
|
||||
<span>Erledigt</span>
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newColName.trim()}
|
||||
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-40"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Hinzufügen</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Reset button */}
|
||||
<div className="pt-2 flex justify-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetToDefault}
|
||||
className="text-xs text-muted hover:text-main flex items-center gap-1"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3 text-accent" />
|
||||
<span>Auf Standard-Spalten zurücksetzen</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-2.5 border-t border-border bg-panel">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-action h-7 px-3 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="btn-primary h-7 px-3 text-xs disabled:opacity-50"
|
||||
>
|
||||
Spalten speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,696 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Check,
|
||||
Calendar,
|
||||
Trash2,
|
||||
FileText,
|
||||
Plus,
|
||||
CheckSquare,
|
||||
Clock,
|
||||
Layers,
|
||||
Hash,
|
||||
AlertTriangle,
|
||||
Flame,
|
||||
ChevronRight,
|
||||
Folder,
|
||||
Webhook,
|
||||
} from 'lucide-react';
|
||||
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface TaskDetailModalProps {
|
||||
task: Thought | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
activeColumns: KanbanColumn[];
|
||||
taskLists: TaskList[];
|
||||
onUpdateTitle: (task: Thought, newTitle: string) => Promise<void>;
|
||||
onUpdateStatus: (task: Thought, newStatus: string) => Promise<void>;
|
||||
onUpdatePriority: (task: Thought, newPriority: string) => Promise<void>;
|
||||
onUpdateDueDate: (task: Thought, newDueDate: string) => Promise<void>;
|
||||
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
|
||||
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
|
||||
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
|
||||
onToggleSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
||||
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||
onConvertToDoc: (task: Thought) => void;
|
||||
onDeleteTask: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
task,
|
||||
isOpen,
|
||||
onClose,
|
||||
activeColumns,
|
||||
taskLists,
|
||||
onUpdateTitle,
|
||||
onUpdateStatus,
|
||||
onUpdatePriority,
|
||||
onUpdateDueDate,
|
||||
onUpdateTaskList,
|
||||
onUpdateNotes,
|
||||
onUpdateTags,
|
||||
onToggleSubtask,
|
||||
onAddSubtask,
|
||||
onDeleteSubtask,
|
||||
onConvertToDoc,
|
||||
onDeleteTask,
|
||||
}) => {
|
||||
if (!isOpen || !task) return null;
|
||||
|
||||
const [titleDraft, setTitleDraft] = useState(task.content);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
|
||||
const [isEditingNotes, setIsEditingNotes] = useState(false);
|
||||
const [notesDraft, setNotesDraft] = useState(task.notes || '');
|
||||
|
||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
||||
const [newTagDraft, setNewTagDraft] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setTitleDraft(task.content);
|
||||
setNotesDraft(task.notes || '');
|
||||
setIsEditingTitle(false);
|
||||
setIsEditingNotes(false);
|
||||
}, [task.id]);
|
||||
|
||||
// Handle escape key to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (isEditingNotes) {
|
||||
setIsEditingNotes(false);
|
||||
} else if (isEditingTitle) {
|
||||
setIsEditingTitle(false);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose, isEditingNotes, isEditingTitle]);
|
||||
|
||||
const handleTitleSubmit = async () => {
|
||||
const trimmed = titleDraft.trim();
|
||||
if (!trimmed) {
|
||||
setTitleDraft(task.content);
|
||||
setIsEditingTitle(false);
|
||||
return;
|
||||
}
|
||||
if (trimmed !== task.content) {
|
||||
await onUpdateTitle(task, trimmed);
|
||||
}
|
||||
setIsEditingTitle(false);
|
||||
};
|
||||
|
||||
const handleNotesSubmit = async () => {
|
||||
await onUpdateNotes(task, notesDraft);
|
||||
setIsEditingNotes(false);
|
||||
};
|
||||
|
||||
const handleAddSubtaskSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = newSubtaskTitle.trim();
|
||||
if (!trimmed) return;
|
||||
await onAddSubtask(task, trimmed);
|
||||
setNewSubtaskTitle('');
|
||||
};
|
||||
|
||||
const handleAddTagSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
let cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase();
|
||||
if (!cleaned) return;
|
||||
const currentTags = Array.isArray(task.tags) ? [...task.tags] : [];
|
||||
if (!currentTags.includes(cleaned)) {
|
||||
const updated = [...currentTags, cleaned];
|
||||
if (onUpdateTags) {
|
||||
await onUpdateTags(task, updated);
|
||||
} else {
|
||||
await onUpdateTitle(task, `${task.content} #${cleaned}`);
|
||||
}
|
||||
}
|
||||
setNewTagDraft('');
|
||||
};
|
||||
|
||||
const handleRemoveTag = async (tagToRemove: string) => {
|
||||
const currentTags = Array.isArray(task.tags) ? [...task.tags] : [];
|
||||
const updated = currentTags.filter((t) => t !== tagToRemove);
|
||||
if (onUpdateTags) {
|
||||
await onUpdateTags(task, updated);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickDueDate = (daysOffset: number | null) => {
|
||||
if (daysOffset === null) {
|
||||
onUpdateDueDate(task, '');
|
||||
return;
|
||||
}
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + daysOffset);
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
onUpdateDueDate(task, dateStr);
|
||||
};
|
||||
|
||||
const currentColumn = activeColumns.find((c) => c.id === task.kanbanStatus) ||
|
||||
(task.isCompleted
|
||||
? activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1]
|
||||
: activeColumns.find((c) => !c.isDone) || activeColumns[0]);
|
||||
|
||||
const subtasks = Array.isArray(task.subtasks) ? task.subtasks : [];
|
||||
const completedSubtasks = subtasks.filter((s) => s.isCompleted).length;
|
||||
const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
|
||||
<div
|
||||
className="w-full max-w-2xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Top Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(15);
|
||||
const nextState = !task.isCompleted;
|
||||
const doneCol = activeColumns.find((c) => c.isDone) || activeColumns[activeColumns.length - 1];
|
||||
const todoCol = activeColumns.find((c) => !c.isDone) || activeColumns[0];
|
||||
const nextKanban = nextState ? (doneCol ? doneCol.id : 'done') : (todoCol ? todoCol.id : 'todo');
|
||||
onUpdateStatus(task, nextKanban);
|
||||
}}
|
||||
className={`w-5 h-5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${
|
||||
task.isCompleted
|
||||
? 'bg-status-ok border-status-ok text-on-accent'
|
||||
: 'border-strong hover:border-accent text-transparent'
|
||||
}`}
|
||||
title={task.isCompleted ? 'Als offen markieren' : 'Als erledigt markieren'}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5 stroke-[3]" />
|
||||
</button>
|
||||
<span className="text-xs font-semibold text-main truncate">
|
||||
{task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
|
||||
</span>
|
||||
{task.source?.toLowerCase() === 'webhook' && (
|
||||
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
|
||||
<Webhook className="w-2.5 h-2.5 text-accent" />
|
||||
<span>Webhook</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onConvertToDoc(task);
|
||||
}}
|
||||
title="Als Dokument / Notiz vertiefen"
|
||||
className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5"
|
||||
>
|
||||
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
|
||||
<span className="hidden sm:inline">Als Notiz vertiefen</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (window.confirm('Möchten Sie diese Aufgabe wirklich löschen?')) {
|
||||
onClose();
|
||||
onDeleteTask(task.id);
|
||||
}
|
||||
}}
|
||||
title="Aufgabe löschen"
|
||||
className="btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs text-status-err border-status-err/30 hover:bg-status-err/10 flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||
<span className="sm:hidden font-medium">Löschen</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="text-muted hover:text-main p-2 sm:p-1.5 min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 flex items-center justify-center transition-colors"
|
||||
title="Schließen"
|
||||
>
|
||||
<X className="w-5 h-5 sm:w-4 sm:h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Body */}
|
||||
<div className="p-4 overflow-y-auto space-y-4 flex-1">
|
||||
{/* Task Title */}
|
||||
<div>
|
||||
{isEditingTitle ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleTitleSubmit();
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsEditingTitle(false);
|
||||
setTitleDraft(task.content);
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
rows={2}
|
||||
className="w-full text-base font-semibold input-field p-2.5 resize-none leading-snug"
|
||||
/>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEditingTitle(false);
|
||||
setTitleDraft(task.content);
|
||||
}}
|
||||
className="btn-action h-7 px-2.5 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTitleSubmit}
|
||||
className="btn-primary h-7 px-3 text-xs"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setIsEditingTitle(true)}
|
||||
className="group cursor-pointer p-2 rounded-card hover:bg-panel transition-colors border border-transparent hover:border-border"
|
||||
title="Klicken zum Bearbeiten des Titels"
|
||||
>
|
||||
<h2
|
||||
className={`text-base font-semibold break-words leading-snug ${
|
||||
task.isCompleted ? 'line-through text-faint' : 'text-main'
|
||||
}`}
|
||||
>
|
||||
{task.content}
|
||||
</h2>
|
||||
<span className="text-[10px] text-muted opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
Klicken zum Umbenennen
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kanban Status Buttons */}
|
||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main">
|
||||
Zustand / Kanban-Status:
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{activeColumns.map((col) => {
|
||||
const isActive = (task.kanbanStatus === col.id) || (!task.kanbanStatus && col.id === 'todo');
|
||||
return (
|
||||
<button
|
||||
key={col.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
onUpdateStatus(task, col.id);
|
||||
}}
|
||||
className={`h-8 px-3 text-xs rounded-btn font-medium transition-all flex items-center gap-1.5 border ${
|
||||
isActive
|
||||
? 'bg-panel border-accent text-accent font-semibold shadow-sm'
|
||||
: 'bg-canvas border-border text-muted hover:text-main hover:border-strong'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
col.isDone
|
||||
? 'bg-status-ok'
|
||||
: col.id === 'in_progress'
|
||||
? 'bg-accent'
|
||||
: col.id === 'waiting'
|
||||
? 'bg-status-warn'
|
||||
: 'bg-faint'
|
||||
}`}
|
||||
/>
|
||||
<span>{col.name}</span>
|
||||
{isActive && <Check className="w-3 h-3 text-accent stroke-[3] ml-0.5" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority Buttons (4 Buttons) */}
|
||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main">
|
||||
Priorität:
|
||||
</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
onUpdatePriority(task, 'low');
|
||||
}}
|
||||
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
||||
task.priority === 'low'
|
||||
? 'bg-canvas border-strong text-main font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<span>Niedrig</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
onUpdatePriority(task, 'normal');
|
||||
}}
|
||||
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
||||
!task.priority || task.priority === 'normal'
|
||||
? 'bg-accent/15 border-accent text-accent font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<span>Normal</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
onUpdatePriority(task, 'high');
|
||||
}}
|
||||
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
||||
task.priority === 'high'
|
||||
? 'bg-status-warn/15 border-status-warn text-status-warn font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 text-status-warn" />
|
||||
<span>Wichtig</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
onUpdatePriority(task, 'urgent');
|
||||
}}
|
||||
className={`h-8 px-2 text-xs rounded-btn font-medium transition-colors border flex items-center justify-center gap-1 cursor-pointer ${
|
||||
task.priority === 'urgent'
|
||||
? 'bg-status-err/15 border-status-err text-status-err font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<Flame className="w-3 h-3 text-status-err" />
|
||||
<span>Dringend</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Due Date & List Selection Row */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1 border-t border-border">
|
||||
{/* Due Date */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Fälligkeitsdatum:</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="date"
|
||||
value={task.dueDate || ''}
|
||||
onChange={(e) => onUpdateDueDate(task, e.target.value)}
|
||||
className="flex-1 h-8 input-field px-2 text-xs bg-panel"
|
||||
/>
|
||||
{task.dueDate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateDueDate(task, '')}
|
||||
className="btn-action h-8 px-2 text-xs text-muted hover:text-status-err"
|
||||
title="Datum entfernen"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1 text-[10px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuickDueDate(0)}
|
||||
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main"
|
||||
>
|
||||
Heute
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuickDueDate(1)}
|
||||
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main"
|
||||
>
|
||||
Morgen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleQuickDueDate(7)}
|
||||
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main"
|
||||
>
|
||||
+1 Woche
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Task List assignment */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||
<Folder className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Aufgabenliste:</span>
|
||||
</label>
|
||||
<select
|
||||
value={task.taskListId || ''}
|
||||
onChange={(e) => onUpdateTaskList(task, e.target.value)}
|
||||
className="w-full h-8 input-field px-2.5 text-xs bg-panel"
|
||||
>
|
||||
<option value="">-- Keine Liste (Allgemein) --</option>
|
||||
{taskLists.map((tl) => (
|
||||
<option key={tl.id} value={tl.id}>
|
||||
{tl.icon} {tl.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hashtags Section */}
|
||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||
<Hash className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Hashtags:</span>
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{Array.isArray(task.tags) &&
|
||||
task.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main"
|
||||
>
|
||||
<span>#{tag}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="text-muted hover:text-status-err"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<form onSubmit={handleAddTagSubmit} className="inline-flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={newTagDraft}
|
||||
onChange={(e) => setNewTagDraft(e.target.value)}
|
||||
placeholder="+ #hashtag"
|
||||
className="h-6 w-24 px-1.5 text-xs input-field"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtasks Checklist */}
|
||||
<div className="space-y-2 pt-1 border-t border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1.5">
|
||||
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Unteraufgaben ({completedSubtasks}/{subtasks.length}):</span>
|
||||
</label>
|
||||
{subtasks.length > 0 && (
|
||||
<span className="text-[11px] font-mono text-muted">{subtasksPercent}%</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{subtasks.length > 0 && (
|
||||
<div className="w-full h-1 bg-border rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
subtasksPercent === 100 ? 'bg-status-ok' : 'bg-accent'
|
||||
}`}
|
||||
style={{ width: `${subtasksPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Subtask items list */}
|
||||
{subtasks.length > 0 && (
|
||||
<div className="space-y-1 max-h-48 overflow-y-auto pr-1">
|
||||
{subtasks.map((st) => (
|
||||
<div
|
||||
key={st.id}
|
||||
className="p-1.5 bg-canvas border border-border rounded-card flex items-center justify-between text-xs group"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggleSubtask(task, st.id)}
|
||||
className={`w-3.5 h-3.5 checkbox-square flex items-center justify-center flex-shrink-0 transition-colors border ${
|
||||
st.isCompleted
|
||||
? 'bg-status-ok border-status-ok text-on-accent'
|
||||
: 'border-strong hover:border-accent text-transparent'
|
||||
}`}
|
||||
>
|
||||
<Check className="w-2.5 h-2.5 stroke-[3]" />
|
||||
</button>
|
||||
<span
|
||||
className={`truncate ${
|
||||
st.isCompleted ? 'line-through text-faint' : 'text-main'
|
||||
}`}
|
||||
>
|
||||
{st.title}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteSubtask(task, st.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-faint hover:text-status-err p-0.5 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline add subtask form */}
|
||||
<form onSubmit={handleAddSubtaskSubmit} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={newSubtaskTitle}
|
||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
||||
placeholder="Neue Unteraufgabe hinzufügen…"
|
||||
className="flex-1 h-7 px-2 text-xs input-field"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newSubtaskTitle.trim()}
|
||||
className="btn-action h-7 px-2.5 text-xs disabled:opacity-40"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
<span>Hinzufügen</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Notes Section */}
|
||||
<div className="space-y-2 pt-1 border-t border-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-semibold text-main flex items-center gap-1.5">
|
||||
<FileText className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Detailnotizen:</span>
|
||||
</label>
|
||||
{!isEditingNotes && task.notes && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotesDraft(task.notes || '');
|
||||
setIsEditingNotes(true);
|
||||
}}
|
||||
className="text-[11px] text-accent hover:underline"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditingNotes ? (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={notesDraft}
|
||||
onChange={(e) => setNotesDraft(e.target.value)}
|
||||
placeholder="Detailnotizen eingeben… (Markdown unterstützt)"
|
||||
rows={6}
|
||||
autoFocus
|
||||
className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-y min-h-[100px]"
|
||||
/>
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsEditingNotes(false);
|
||||
setNotesDraft(task.notes || '');
|
||||
}}
|
||||
className="btn-action h-7 px-2.5 text-xs"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNotesSubmit}
|
||||
className="btn-primary h-7 px-3 text-xs"
|
||||
>
|
||||
Notiz speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : task.notes ? (
|
||||
<div
|
||||
onClick={() => {
|
||||
setNotesDraft(task.notes || '');
|
||||
setIsEditingNotes(true);
|
||||
}}
|
||||
className="p-3 bg-canvas border border-border rounded-card text-xs text-muted leading-relaxed whitespace-pre-wrap cursor-pointer hover:border-strong transition-colors"
|
||||
title="Klicken zum Bearbeiten"
|
||||
>
|
||||
{task.notes}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotesDraft('');
|
||||
setIsEditingNotes(true);
|
||||
}}
|
||||
className="w-full py-2.5 px-3 border border-dashed border-border rounded-card text-xs text-muted hover:text-main hover:bg-panel transition-colors text-left flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Detailnotiz hinzufügen…</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
||||
<div className="text-[11px] text-faint">
|
||||
Erstellt: {new Date(task.createdAt).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-action h-7 px-3 text-xs"
|
||||
>
|
||||
Fertig
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,282 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-bash';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-go';
|
||||
import 'prismjs/components/prism-sql';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import 'prismjs/components/prism-rust';
|
||||
import 'prismjs/components/prism-c';
|
||||
import 'prismjs/components/prism-cpp';
|
||||
import 'prismjs/components/prism-java';
|
||||
import 'prismjs/components/prism-php';
|
||||
import 'prismjs/components/prism-docker';
|
||||
import 'prismjs/components/prism-markdown';
|
||||
import { Code, Copy, Check, Eye, Edit3 } from 'lucide-react';
|
||||
import { DocumentBlock } from '../../lib/types';
|
||||
|
||||
export interface CodeBlockItemProps {
|
||||
block: DocumentBlock;
|
||||
isReadOnly: boolean;
|
||||
onChangeContent: (content: string) => void;
|
||||
onChangeLang: (lang: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
inputRef?: (el: HTMLTextAreaElement | null) => void;
|
||||
}
|
||||
|
||||
export const CODE_LANGUAGES = [
|
||||
{ id: 'javascript', label: 'JavaScript' },
|
||||
{ id: 'typescript', label: 'TypeScript' },
|
||||
{ id: 'python', label: 'Python' },
|
||||
{ id: 'go', label: 'Go' },
|
||||
{ id: 'bash', label: 'Bash / Shell' },
|
||||
{ id: 'json', label: 'JSON' },
|
||||
{ id: 'html', label: 'HTML / XML', alias: 'markup' },
|
||||
{ id: 'css', label: 'CSS' },
|
||||
{ id: 'sql', label: 'SQL' },
|
||||
{ id: 'rust', label: 'Rust' },
|
||||
{ id: 'cpp', label: 'C++' },
|
||||
{ id: 'c', label: 'C' },
|
||||
{ id: 'java', label: 'Java' },
|
||||
{ id: 'php', label: 'PHP' },
|
||||
{ id: 'yaml', label: 'YAML' },
|
||||
{ id: 'docker', label: 'Dockerfile' },
|
||||
{ id: 'markdown', label: 'Markdown' },
|
||||
{ id: 'text', label: 'Plain Text' },
|
||||
];
|
||||
|
||||
export const CodeBlockItem: React.FC<CodeBlockItemProps> = ({
|
||||
block,
|
||||
isReadOnly,
|
||||
onChangeContent,
|
||||
onChangeLang,
|
||||
onFocus,
|
||||
onBlur,
|
||||
inputRef,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const currentLang = block.lang || 'javascript';
|
||||
|
||||
// Highlighted HTML generated by Prism
|
||||
const highlightedCode = useMemo(() => {
|
||||
const code = block.content || '';
|
||||
if (!code) return '';
|
||||
|
||||
const prismLang = currentLang === 'html' ? 'markup' : currentLang;
|
||||
const grammar = Prism.languages[prismLang] || Prism.languages.javascript;
|
||||
if (!grammar) {
|
||||
return code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
try {
|
||||
return Prism.highlight(code, grammar, prismLang);
|
||||
} catch {
|
||||
return code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
}, [block.content, currentLang]);
|
||||
|
||||
// Code lines for line numbers
|
||||
const lines = useMemo(() => {
|
||||
return (block.content || '').split('\n');
|
||||
}, [block.content]);
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(block.content || '');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
// Tab key indent/outdent
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const textarea = e.currentTarget;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const val = textarea.value;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (val.substring(start - 2, start) === ' ') {
|
||||
const newVal = val.substring(0, start - 2) + val.substring(start);
|
||||
onChangeContent(newVal);
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = Math.max(0, start - 2);
|
||||
}, 0);
|
||||
}
|
||||
} else {
|
||||
const newVal = val.substring(0, start) + ' ' + val.substring(end);
|
||||
onChangeContent(newVal);
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const switchToEdit = () => {
|
||||
if (isReadOnly) return;
|
||||
setIsEditing(true);
|
||||
setTimeout(() => {
|
||||
localTextareaRef.current?.focus();
|
||||
}, 20);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-panel overflow-hidden shadow-xs my-1 group/code">
|
||||
{/* Code Block Header Toolbar */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-canvas border-b border-border text-xs">
|
||||
{/* Left: Language Selection */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<select
|
||||
value={currentLang}
|
||||
disabled={isReadOnly}
|
||||
onChange={(e) => onChangeLang(e.target.value)}
|
||||
className="bg-transparent text-main font-mono text-[11px] font-medium border border-transparent hover:border-border focus:border-accent rounded px-1.5 py-0.5 outline-none cursor-pointer"
|
||||
>
|
||||
{CODE_LANGUAGES.map((lang) => (
|
||||
<option key={lang.id} value={lang.id} className="bg-panel text-main">
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Right: Mode Toggle & Copy */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isEditing) {
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
switchToEdit();
|
||||
}
|
||||
}}
|
||||
className="px-2 py-0.5 rounded text-[11px] font-medium text-muted hover:text-main hover:bg-panel-elevated transition-colors flex items-center gap-1 cursor-pointer"
|
||||
title={isEditing ? 'Zur farbigen Vorschau wechseln (Esc)' : 'Code bearbeiten'}
|
||||
>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Eye className="w-3 h-3 text-accent" />
|
||||
<span>Vorschau</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Edit3 className="w-3 h-3 text-muted" />
|
||||
<span>Bearbeiten</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors flex items-center gap-1 cursor-pointer ${
|
||||
copied
|
||||
? 'text-emerald-500 bg-emerald-500/10'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
title="Code in Zwischenablage kopieren"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-500" />
|
||||
<span>Kopiert!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 opacity-70" />
|
||||
<span>Kopieren</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Block Content */}
|
||||
{isEditing && !isReadOnly ? (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
localTextareaRef.current = el;
|
||||
if (inputRef) inputRef(el);
|
||||
}}
|
||||
rows={Math.max(3, lines.length)}
|
||||
value={block.content || ''}
|
||||
onFocus={() => onFocus && onFocus()}
|
||||
onBlur={() => {
|
||||
if (onBlur) onBlur();
|
||||
}}
|
||||
onChange={(e) => onChangeContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="// Code hier eingeben..."
|
||||
className="w-full p-3 bg-panel-elevated/40 font-mono text-xs text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint"
|
||||
/>
|
||||
<div className="px-3 py-1 bg-canvas/40 border-t border-border/50 text-[10px] text-faint flex items-center justify-between">
|
||||
<span>Tab: Einrücken (2 Leerzeichen) • Shift+Tab: Ausrücken</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(false)}
|
||||
className="hover:text-accent cursor-pointer"
|
||||
>
|
||||
Vorschau anzeigen (Esc)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={switchToEdit}
|
||||
className={`relative p-3 font-mono text-xs overflow-x-auto ${
|
||||
isReadOnly ? '' : 'cursor-pointer hover:bg-panel-elevated/20 transition-colors'
|
||||
}`}
|
||||
title={isReadOnly ? undefined : 'Klicken zum Bearbeiten'}
|
||||
>
|
||||
{block.content ? (
|
||||
<div className="flex gap-3">
|
||||
{/* Line numbers */}
|
||||
<div className="select-none text-right text-faint opacity-50 font-mono text-xs leading-relaxed flex-shrink-0">
|
||||
{lines.map((_, idx) => (
|
||||
<div key={idx}>{idx + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Syntax highlighted code */}
|
||||
<pre className="m-0 p-0 font-mono text-xs leading-relaxed whitespace-pre overflow-x-auto flex-1">
|
||||
<code
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
className={`language-${currentLang}`}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-faint italic py-2">
|
||||
{isReadOnly ? '(Kein Code hinterlegt)' : '(Klicken, um Code einzugeben...)'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,960 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Pin,
|
||||
Users,
|
||||
Trash2,
|
||||
Download,
|
||||
FileText,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
Hash,
|
||||
Lock,
|
||||
Home,
|
||||
ChevronRight,
|
||||
Minimize2,
|
||||
Sparkles,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { useDocumentSync, DocSyncEvent, RemoteCursor, DocSyncStatus } from '../../lib/useDocumentSync';
|
||||
import { OutlinerTree } from './OutlinerTree';
|
||||
|
||||
export interface DocumentEditorProps {
|
||||
docId: string;
|
||||
currentUser?: User | null;
|
||||
initialDoc?: Document | null;
|
||||
availableFolders: string[];
|
||||
autoFocusTitle?: boolean;
|
||||
initialFocusedBlockId?: string | null;
|
||||
onDocMetadataChange: (updated: Document) => void;
|
||||
onDeleteDoc: (docId: string) => void;
|
||||
onOpenShareModal: (doc: Document) => void;
|
||||
onSelectTag: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
onBackMobile?: () => void;
|
||||
onSyncStatusChange?: (status: DocSyncStatus) => void;
|
||||
}
|
||||
|
||||
const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒'];
|
||||
|
||||
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
|
||||
if (!blocks || blocks.length === 0) {
|
||||
return [
|
||||
{
|
||||
id: `${docId}-b0`,
|
||||
documentId: docId,
|
||||
type: 'text',
|
||||
content: '',
|
||||
level: 0,
|
||||
position: 1000,
|
||||
},
|
||||
];
|
||||
}
|
||||
return blocks.map((b, idx) => ({
|
||||
...b,
|
||||
id: b.id || `${docId}-b${idx}`,
|
||||
documentId: docId,
|
||||
type: b.type || 'text',
|
||||
level: Math.max(0, b.level || 0),
|
||||
position: b.position || (idx + 1) * 1000,
|
||||
}));
|
||||
}
|
||||
|
||||
export interface BreadcrumbNode {
|
||||
id: string | null;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function computeBreadcrumbs(
|
||||
docTitle: string,
|
||||
blocks: DocumentBlock[],
|
||||
zoomedBlockId: string | null
|
||||
): BreadcrumbNode[] {
|
||||
if (!zoomedBlockId) return [];
|
||||
|
||||
const zoomedIdx = blocks.findIndex((b) => b.id === zoomedBlockId);
|
||||
if (zoomedIdx === -1) return [];
|
||||
|
||||
const trail: BreadcrumbNode[] = [];
|
||||
const zoomedBlock = blocks[zoomedIdx];
|
||||
|
||||
let currentLevel = zoomedBlock.level;
|
||||
for (let i = zoomedIdx - 1; i >= 0; i--) {
|
||||
if (blocks[i].level < currentLevel) {
|
||||
trail.unshift({
|
||||
id: blocks[i].id,
|
||||
title: blocks[i].content?.trim() || 'Punkt',
|
||||
});
|
||||
currentLevel = blocks[i].level;
|
||||
if (currentLevel === 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
trail.unshift({
|
||||
id: null,
|
||||
title: docTitle || 'Dokument',
|
||||
});
|
||||
|
||||
trail.push({
|
||||
id: zoomedBlock.id,
|
||||
title: zoomedBlock.content?.trim() || 'Punkt',
|
||||
});
|
||||
|
||||
return trail;
|
||||
}
|
||||
|
||||
export function getZoomedSubtree(
|
||||
blocks: DocumentBlock[],
|
||||
zoomedBlockId: string | null
|
||||
): {
|
||||
zoomedBlock: DocumentBlock | null;
|
||||
childBlocks: DocumentBlock[];
|
||||
levelOffset: number;
|
||||
} {
|
||||
if (!zoomedBlockId) {
|
||||
return { zoomedBlock: null, childBlocks: blocks, levelOffset: 0 };
|
||||
}
|
||||
|
||||
const idx = blocks.findIndex((b) => b.id === zoomedBlockId);
|
||||
if (idx === -1) {
|
||||
return { zoomedBlock: null, childBlocks: blocks, levelOffset: 0 };
|
||||
}
|
||||
|
||||
const zoomedBlock = blocks[idx];
|
||||
const zLevel = zoomedBlock.level;
|
||||
const childBlocks: DocumentBlock[] = [];
|
||||
|
||||
for (let i = idx + 1; i < blocks.length; i++) {
|
||||
if (blocks[i].level > zLevel) {
|
||||
childBlocks.push(blocks[i]);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
zoomedBlock,
|
||||
childBlocks,
|
||||
levelOffset: zLevel + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
docId,
|
||||
currentUser,
|
||||
initialDoc,
|
||||
availableFolders,
|
||||
autoFocusTitle,
|
||||
initialFocusedBlockId,
|
||||
onDocMetadataChange,
|
||||
onDeleteDoc,
|
||||
onOpenShareModal,
|
||||
onSelectTag,
|
||||
onWikiLinkClick,
|
||||
onBackMobile,
|
||||
onSyncStatusChange,
|
||||
}) => {
|
||||
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
||||
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
||||
const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
|
||||
ensureNonEmptyBlocks(docId, initialDoc?.blocks)
|
||||
);
|
||||
const [loading, setLoading] = useState<boolean>(!initialDoc);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
||||
|
||||
const titleInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const titleDebounceRef = useRef<number | null>(null);
|
||||
const docRef = useRef<Document | null>(doc);
|
||||
docRef.current = doc;
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
||||
const focusTitle = () => {
|
||||
if (titleInputRef.current) {
|
||||
titleInputRef.current.focus();
|
||||
titleInputRef.current.select();
|
||||
}
|
||||
};
|
||||
focusTitle();
|
||||
const t1 = setTimeout(focusTitle, 50);
|
||||
const t2 = setTimeout(focusTitle, 150);
|
||||
return () => {
|
||||
clearTimeout(t1);
|
||||
clearTimeout(t2);
|
||||
};
|
||||
}
|
||||
}, [loading, autoFocusTitle, doc?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (zoomedBlockId && !blocks.some((b) => b.id === zoomedBlockId)) {
|
||||
setZoomedBlockId(null);
|
||||
}
|
||||
}, [blocks, zoomedBlockId]);
|
||||
|
||||
const { zoomedBlock, childBlocks, levelOffset } = useMemo(() => {
|
||||
return getZoomedSubtree(blocks, zoomedBlockId);
|
||||
}, [blocks, zoomedBlockId]);
|
||||
|
||||
const breadcrumbs = useMemo(() => {
|
||||
return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId);
|
||||
}, [title, doc?.title, blocks, zoomedBlockId]);
|
||||
|
||||
// Crucial fix: Collaborator with write permission can edit!
|
||||
const canEdit = useMemo(() => {
|
||||
if (!doc) return true;
|
||||
if (doc.isOwner) return true;
|
||||
return doc.permission === 'owner' || doc.permission === 'write';
|
||||
}, [doc]);
|
||||
|
||||
// Handle incoming WebSocket events for THIS document room
|
||||
const handleRemoteEvent = useCallback(
|
||||
(evt: DocSyncEvent) => {
|
||||
if (evt.docId && evt.docId !== docId) return;
|
||||
|
||||
switch (evt.type) {
|
||||
case 'doc_state': {
|
||||
if (evt.title !== undefined) {
|
||||
const isTitleActivelyEditing =
|
||||
titleInputRef.current !== null &&
|
||||
document.activeElement === titleInputRef.current &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.hasFocus();
|
||||
if (!isTitleActivelyEditing) {
|
||||
setTitle(evt.title);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(evt.blocks) && evt.blocks.length > 0) {
|
||||
setBlocks(ensureNonEmptyBlocks(docId, evt.blocks));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'title_updated': {
|
||||
if (evt.title !== undefined) {
|
||||
const isTitleActivelyEditing =
|
||||
titleInputRef.current !== null &&
|
||||
document.activeElement === titleInputRef.current &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.hasFocus();
|
||||
if (!isTitleActivelyEditing) {
|
||||
setTitle(evt.title);
|
||||
}
|
||||
if (docRef.current) {
|
||||
const updated = { ...docRef.current, title: evt.title };
|
||||
setDoc(updated);
|
||||
onDocMetadataChange(updated);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block_created': {
|
||||
if (!evt.block) return;
|
||||
const incoming = evt.block;
|
||||
setBlocks((prev) => {
|
||||
if (prev.some((b) => b.id === incoming.id)) {
|
||||
return prev.map((b) => (b.id === incoming.id ? incoming : b));
|
||||
}
|
||||
const next = [...prev];
|
||||
if (evt.afterBlockId) {
|
||||
const idx = next.findIndex((b) => b.id === evt.afterBlockId);
|
||||
if (idx >= 0) {
|
||||
next.splice(idx + 1, 0, incoming);
|
||||
return ensureNonEmptyBlocks(docId, next);
|
||||
}
|
||||
}
|
||||
next.push(incoming);
|
||||
return ensureNonEmptyBlocks(docId, next);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block_updated': {
|
||||
const targetId = evt.blockId || evt.block?.id;
|
||||
if (!targetId) return;
|
||||
setBlocks((prev) =>
|
||||
prev.map((b) => {
|
||||
if (b.id !== targetId) return b;
|
||||
if (evt.block) {
|
||||
return { ...b, ...evt.block };
|
||||
}
|
||||
return {
|
||||
...b,
|
||||
content: evt.content !== undefined ? evt.content : b.content,
|
||||
level: evt.level !== undefined ? evt.level : b.level,
|
||||
checked: evt.checked !== undefined ? evt.checked : b.checked,
|
||||
collapsed: evt.collapsed !== undefined ? evt.collapsed : b.collapsed,
|
||||
};
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block_deleted': {
|
||||
if (!evt.blockId) return;
|
||||
setBlocks((prev) =>
|
||||
ensureNonEmptyBlocks(
|
||||
docId,
|
||||
prev.filter((b) => b.id !== evt.blockId)
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block_moved': {
|
||||
if (!evt.blockId || evt.newIndex === undefined) return;
|
||||
const targetIndex = evt.newIndex;
|
||||
setBlocks((prev) => {
|
||||
const fromIdx = prev.findIndex((b) => b.id === evt.blockId);
|
||||
if (fromIdx === -1) return prev;
|
||||
const copy = [...prev];
|
||||
const [moving] = copy.splice(fromIdx, 1);
|
||||
const updatedMoving: DocumentBlock = {
|
||||
...moving,
|
||||
level: evt.level !== undefined ? evt.level : moving.level,
|
||||
};
|
||||
const boundedIdx = Math.max(0, Math.min(targetIndex, copy.length));
|
||||
copy.splice(boundedIdx, 0, updatedMoving);
|
||||
return ensureNonEmptyBlocks(docId, copy);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'blocks_replaced': {
|
||||
if (Array.isArray(evt.blocks)) {
|
||||
setBlocks(ensureNonEmptyBlocks(docId, evt.blocks));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[docId, onDocMetadataChange]
|
||||
);
|
||||
|
||||
const {
|
||||
status: syncStatus,
|
||||
remoteCursors,
|
||||
sendCursorMoved,
|
||||
sendBlockCreated,
|
||||
sendBlockUpdated,
|
||||
sendBlockDeleted,
|
||||
sendBlockMoved,
|
||||
sendTitleUpdated,
|
||||
} = useDocumentSync(docId, handleRemoteEvent, canEdit, currentUser);
|
||||
|
||||
useEffect(() => {
|
||||
onSyncStatusChange?.(syncStatus);
|
||||
}, [syncStatus, onSyncStatusChange]);
|
||||
|
||||
// Fetch isolated document state on mount
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
if (!initialDoc) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
api
|
||||
.getDocument(docId)
|
||||
.then((fetched) => {
|
||||
if (isCancelled) return;
|
||||
setDoc(fetched);
|
||||
setTitle(fetched.title || 'Unbenannte Notiz');
|
||||
setBlocks(ensureNonEmptyBlocks(docId, fetched.blocks));
|
||||
setLoading(false);
|
||||
onDocMetadataChange(fetched);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (isCancelled) return;
|
||||
setError(err?.message || 'Dokument konnte nicht geladen werden');
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
if (titleDebounceRef.current !== null) {
|
||||
window.clearTimeout(titleDebounceRef.current);
|
||||
titleDebounceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [docId]);
|
||||
|
||||
// Extract all #tags present across the current blocks
|
||||
const extractedTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
for (const b of blocks) {
|
||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
if (matches) {
|
||||
for (const m of matches) {
|
||||
tagSet.add(m.slice(1).toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(tagSet);
|
||||
}, [blocks]);
|
||||
|
||||
// Keep parent sidebar metadata in sync when blocks/tags change
|
||||
useEffect(() => {
|
||||
if (!docRef.current) return;
|
||||
const updatedDoc: Document = {
|
||||
...docRef.current,
|
||||
title,
|
||||
blocks,
|
||||
tags: extractedTags,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
docRef.current = updatedDoc;
|
||||
onDocMetadataChange(updatedDoc);
|
||||
}, [blocks, extractedTags, title]);
|
||||
|
||||
// Active remote users in this room (excluding self)
|
||||
const activeUsers = useMemo(() => {
|
||||
const list: RemoteCursor[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const c of Object.values(remoteCursors)) {
|
||||
const key = c.userId || c.clientId;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
list.push(c);
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, [remoteCursors]);
|
||||
|
||||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!canEdit) return;
|
||||
const nextTitle = e.target.value;
|
||||
setTitle(nextTitle);
|
||||
|
||||
if (titleDebounceRef.current !== null) {
|
||||
window.clearTimeout(titleDebounceRef.current);
|
||||
}
|
||||
titleDebounceRef.current = window.setTimeout(() => {
|
||||
titleDebounceRef.current = null;
|
||||
sendTitleUpdated(nextTitle);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const handleTitleBlur = () => {
|
||||
if (titleDebounceRef.current !== null) {
|
||||
window.clearTimeout(titleDebounceRef.current);
|
||||
titleDebounceRef.current = null;
|
||||
sendTitleUpdated(title);
|
||||
}
|
||||
};
|
||||
|
||||
// Flush title debouncing on window blur so background window never holds stale pending title
|
||||
useEffect(() => {
|
||||
const handleWindowBlur = () => {
|
||||
if (titleDebounceRef.current !== null) {
|
||||
window.clearTimeout(titleDebounceRef.current);
|
||||
titleDebounceRef.current = null;
|
||||
sendTitleUpdated(title);
|
||||
}
|
||||
};
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
return () => window.removeEventListener('blur', handleWindowBlur);
|
||||
}, [title, sendTitleUpdated]);
|
||||
|
||||
const handleUpdateBlock = useCallback(
|
||||
(updatedBlock: DocumentBlock) => {
|
||||
setBlocks((prev) =>
|
||||
prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b))
|
||||
);
|
||||
sendBlockUpdated(updatedBlock);
|
||||
},
|
||||
[sendBlockUpdated]
|
||||
);
|
||||
|
||||
const handleCreateBlock = useCallback(
|
||||
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
||||
setBlocks((prev) => {
|
||||
const next = [...prev];
|
||||
if (afterBlockId) {
|
||||
const idx = next.findIndex((b) => b.id === afterBlockId);
|
||||
if (idx >= 0) {
|
||||
next.splice(idx + 1, 0, newBlock);
|
||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
||||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
next.push(newBlock);
|
||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
||||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
||||
return normalized;
|
||||
});
|
||||
},
|
||||
[docId, sendBlockCreated]
|
||||
);
|
||||
|
||||
const handleDeleteBlock = useCallback(
|
||||
(blockId: string) => {
|
||||
setBlocks((prev) => {
|
||||
const filtered = prev.filter((b) => b.id !== blockId);
|
||||
const normalized = ensureNonEmptyBlocks(docId, filtered);
|
||||
sendBlockDeleted(blockId, normalized);
|
||||
return normalized;
|
||||
});
|
||||
},
|
||||
[docId, sendBlockDeleted]
|
||||
);
|
||||
|
||||
const handleMoveBlock = useCallback(
|
||||
(blockId: string, newIndex: number, level?: number) => {
|
||||
setBlocks((prev) => {
|
||||
const fromIdx = prev.findIndex((b) => b.id === blockId);
|
||||
if (fromIdx === -1) return prev;
|
||||
const copy = [...prev];
|
||||
const [moving] = copy.splice(fromIdx, 1);
|
||||
const updatedMoving: DocumentBlock = {
|
||||
...moving,
|
||||
level: level !== undefined ? level : moving.level,
|
||||
};
|
||||
const boundedIdx = Math.max(0, Math.min(newIndex, copy.length));
|
||||
copy.splice(boundedIdx, 0, updatedMoving);
|
||||
const normalized = ensureNonEmptyBlocks(docId, copy);
|
||||
sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized);
|
||||
return normalized;
|
||||
});
|
||||
},
|
||||
[docId, sendBlockMoved]
|
||||
);
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
if (!doc) return;
|
||||
try {
|
||||
const updated = await api.updateDocument(doc.id, { isPinned: !doc.isPinned });
|
||||
setDoc(updated);
|
||||
onDocMetadataChange(updated);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectIcon = async (icon: string) => {
|
||||
setShowEmojiPicker(false);
|
||||
if (!doc || !canEdit) return;
|
||||
try {
|
||||
const updated = await api.updateDocument(doc.id, { icon });
|
||||
setDoc(updated);
|
||||
onDocMetadataChange(updated);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportMarkdown = () => {
|
||||
const lines: string[] = [`# ${title || 'Unbenannte Notiz'}`, ''];
|
||||
for (const b of blocks) {
|
||||
const indent = ' '.repeat(Math.max(0, b.level || 0));
|
||||
if (b.type === 'todo') {
|
||||
lines.push(`${indent}- [${b.checked ? 'x' : ' '}] ${b.content}`);
|
||||
} else if (b.type === 'h1') {
|
||||
lines.push(`${indent}- # ${b.content}`);
|
||||
} else if (b.type === 'h2') {
|
||||
lines.push(`${indent}- ## ${b.content}`);
|
||||
} else if (b.type === 'h3') {
|
||||
lines.push(`${indent}- ### ${b.content}`);
|
||||
} else if (b.type === 'code') {
|
||||
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``);
|
||||
} else if (b.type === 'divider') {
|
||||
lines.push(`${indent}- ---`);
|
||||
} else {
|
||||
lines.push(`${indent}- ${b.content}`);
|
||||
}
|
||||
}
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${(title || 'notiz').toLowerCase().replace(/[^a-z0-9äöüß_-]+/gi, '-')}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-12 text-muted">
|
||||
<Loader2 className="w-5 h-5 animate-spin mr-2 text-accent" />
|
||||
<span className="text-xs">Notiz wird geladen…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center">
|
||||
<p className="text-xs text-status-err font-medium mb-2">{error}</p>
|
||||
{onBackMobile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackMobile}
|
||||
className="btn-action px-3 py-1.5 text-xs text-main"
|
||||
>
|
||||
Zurück zur Übersicht
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full min-h-0 bg-canvas overflow-y-auto">
|
||||
{/* Top Document Action Bar */}
|
||||
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-canvas border-b border-border">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{onBackMobile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackMobile}
|
||||
className="lg:hidden btn-icon"
|
||||
title="Zurück zur Seitenleiste"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Breadcrumbs Navigation (Fokus-Modus) or Doc Indicator */}
|
||||
{zoomedBlock && breadcrumbs.length > 0 ? (
|
||||
<nav
|
||||
aria-label="Breadcrumb Navigation"
|
||||
className="flex items-center flex-wrap gap-1 text-xs text-muted"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomedBlockId(null)}
|
||||
className="hover:text-accent inline-flex items-center gap-1 font-medium transition-colors cursor-pointer"
|
||||
title="Zurück zum Hauptdokument"
|
||||
>
|
||||
<Home className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
<span className="truncate max-w-[130px]">
|
||||
{title || doc?.title || 'Dokument'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{breadcrumbs.slice(1).map((crumb, idx) => {
|
||||
const isCurrent = idx === breadcrumbs.length - 2;
|
||||
return (
|
||||
<React.Fragment key={crumb.id || idx}>
|
||||
<ChevronRight className="w-3 h-3 text-faint shrink-0" />
|
||||
{isCurrent ? (
|
||||
<span
|
||||
className="font-bold text-accent truncate max-w-[160px]"
|
||||
title={crumb.title}
|
||||
>
|
||||
{crumb.title || 'Punkt'}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomedBlockId(crumb.id)}
|
||||
className="hover:text-main truncate max-w-[120px] transition-colors cursor-pointer"
|
||||
title={`Zoom zu "${crumb.title}"`}
|
||||
>
|
||||
{crumb.title || 'Punkt'}
|
||||
</button>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
) : (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted">
|
||||
<FileText className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||
<span className="truncate max-w-[180px] font-medium text-main">
|
||||
{title || doc?.title || 'Dokument'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Read-only indicator */}
|
||||
{!canEdit && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] font-medium bg-status-warn/10 text-status-warn border border-status-warn/30">
|
||||
<Lock className="w-3 h-3" />
|
||||
Nur Lesen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Status & Action Buttons */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Active Presence Avatars (Immer rund: rounded-full mit Überlappung) */}
|
||||
{activeUsers.length > 0 && (
|
||||
<div className="flex items-center -space-x-1.5 mr-1" title="Aktive Bearbeiter im Dokument">
|
||||
{activeUsers.map((u) => {
|
||||
const initials = (u.userName || 'A')
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.slice(0, 2)
|
||||
.join('')
|
||||
.toUpperCase();
|
||||
return (
|
||||
<div key={u.clientId} className="relative group/avatar cursor-default">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white shadow-sm ring-2 ring-canvas"
|
||||
style={{ backgroundColor: u.userColor }}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
{/* Tooltip */}
|
||||
<div className="pointer-events-none absolute bottom-full mb-1 left-1/2 -translate-x-1/2 opacity-0 group-hover/avatar:opacity-100 transition-opacity whitespace-nowrap bg-canvas text-main text-[10px] px-1.5 py-0.5 rounded-btn border border-border shadow-md z-30">
|
||||
{u.userName}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sharing Section (1:1 styling from SharedListsView) */}
|
||||
{doc && (() => {
|
||||
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
|
||||
const sharesCount = doc.sharedWith?.length || 0;
|
||||
const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId);
|
||||
const ownerDisplayName = doc.ownerName || 'Eigentümer';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isShared && (
|
||||
isOwner ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenShareModal(doc)}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
|
||||
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
|
||||
>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenShareModal(doc)}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
|
||||
title="Teilen & Berechtigungen öffnen"
|
||||
>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span>Geteilt von {ownerDisplayName}</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenShareModal(doc)}
|
||||
className="btn-icon"
|
||||
title="Notiz teilen und Berechtigungen"
|
||||
>
|
||||
<Users className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePin}
|
||||
className={`btn-icon ${
|
||||
doc?.isPinned
|
||||
? 'text-status-warn bg-status-warn/10 border-status-warn/30'
|
||||
: ''
|
||||
}`}
|
||||
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
|
||||
>
|
||||
<Pin className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportMarkdown}
|
||||
className="btn-icon"
|
||||
title="Als Markdown (.md) exportieren"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteDoc(docId)}
|
||||
className="btn-icon-danger"
|
||||
title="Notiz löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="max-w-4xl w-full mx-auto px-4 sm:px-8 py-5 flex-1">
|
||||
{zoomedBlock ? (
|
||||
/* =========================================================================
|
||||
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
|
||||
========================================================================= */
|
||||
<div className="space-y-3">
|
||||
{/* Prominent Zoomed Ast-Heading */}
|
||||
<div className="flex items-start justify-between gap-3 pb-4 mb-2 border-b border-border">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-accent bg-accent-dim px-2 py-0.5 rounded-btn border border-accent/30">
|
||||
<Sparkles className="w-2.5 h-2.5 text-accent animate-pulse" />
|
||||
Fokus-Modus
|
||||
</span>
|
||||
<span className="text-[11px] text-faint hidden sm:inline">
|
||||
Isolierter Ast • Zoom aktiv
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={zoomedBlock.content}
|
||||
onChange={(e) => {
|
||||
handleUpdateBlock({ ...zoomedBlock, content: e.target.value });
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const firstChild: DocumentBlock = {
|
||||
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
documentId: docId,
|
||||
type: 'text',
|
||||
content: '',
|
||||
level: levelOffset,
|
||||
position: (zoomedBlock.position || 1000) + 10,
|
||||
};
|
||||
handleCreateBlock(firstChild, zoomedBlock.id);
|
||||
}
|
||||
}}
|
||||
placeholder="Ast-Titel…"
|
||||
readOnly={!canEdit}
|
||||
className="w-full text-2xl sm:text-3xl font-extrabold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomedBlockId(null)}
|
||||
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main shrink-0 mt-1"
|
||||
title="Fokus-Modus beenden"
|
||||
>
|
||||
<Minimize2 className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Fokus beenden</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subtree Children or Empty State */}
|
||||
{childBlocks.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-faint border border-dashed border-border rounded-card my-2">
|
||||
<p className="mb-2">Dieser Ast hat noch keine Unterpunkte.</p>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const firstChild: DocumentBlock = {
|
||||
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
documentId: docId,
|
||||
type: 'text',
|
||||
content: '',
|
||||
level: levelOffset,
|
||||
position: (zoomedBlock.position || 1000) + 10,
|
||||
};
|
||||
handleCreateBlock(firstChild, zoomedBlock.id);
|
||||
}}
|
||||
className="btn-action inline-flex items-center gap-1.5 px-3 py-1.5 text-xs text-accent font-medium hover:bg-accent/10"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Ersten Unterpunkt anlegen (oder Enter drücken)</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<OutlinerTree
|
||||
docId={docId}
|
||||
blocks={childBlocks}
|
||||
canEdit={canEdit}
|
||||
levelOffset={levelOffset}
|
||||
onZoomBlock={setZoomedBlockId}
|
||||
remoteCursors={remoteCursors}
|
||||
sendCursorMoved={sendCursorMoved}
|
||||
onUpdateBlock={handleUpdateBlock}
|
||||
onCreateBlock={handleCreateBlock}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onMoveBlock={handleMoveBlock}
|
||||
onTagClick={onSelectTag}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
initialFocusedBlockId={initialFocusedBlockId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* =========================================================================
|
||||
STANDARD GESAMTDOKUMENT-ANSICHT
|
||||
========================================================================= */
|
||||
<>
|
||||
{/* Document Icon + Title Header */}
|
||||
<div className="flex items-center gap-3 mb-4 relative">
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
|
||||
className="text-2xl sm:text-3xl hover:opacity-80 transition-opacity cursor-pointer select-none leading-none bg-transparent border-0 outline-none p-0 focus:outline-none focus:ring-0 shadow-none"
|
||||
title="Icon ändern"
|
||||
>
|
||||
{doc?.icon || '📝'}
|
||||
</button>
|
||||
{showEmojiPicker && canEdit && (
|
||||
<div className="absolute left-0 mt-1 p-2 panel-surface shadow-panel border border-border rounded-card grid grid-cols-6 gap-1 z-30 w-52">
|
||||
{EMOJI_PALETTE.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => handleSelectIcon(emoji)}
|
||||
className="p-1.5 rounded-btn hover:bg-panel-elevated text-base flex items-center justify-center cursor-pointer"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
value={title}
|
||||
readOnly={!canEdit}
|
||||
autoFocus={autoFocusTitle}
|
||||
onChange={handleTitleChange}
|
||||
onBlur={handleTitleBlur}
|
||||
placeholder="Titel der Notiz…"
|
||||
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logseq Outliner Tree */}
|
||||
<OutlinerTree
|
||||
docId={docId}
|
||||
blocks={blocks}
|
||||
canEdit={canEdit}
|
||||
levelOffset={0}
|
||||
onZoomBlock={setZoomedBlockId}
|
||||
remoteCursors={remoteCursors}
|
||||
sendCursorMoved={sendCursorMoved}
|
||||
onUpdateBlock={handleUpdateBlock}
|
||||
onCreateBlock={handleCreateBlock}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onMoveBlock={handleMoveBlock}
|
||||
onTagClick={onSelectTag}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
initialFocusedBlockId={initialFocusedBlockId}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,308 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Link as LinkIcon,
|
||||
FileText,
|
||||
Globe,
|
||||
X,
|
||||
Search,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../../lib/api';
|
||||
import { Document } from '../../lib/types';
|
||||
|
||||
export interface LinkModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onInsertLink: (markdown: string) => void;
|
||||
availableDocs?: { id: string; title: string; icon?: string; folderPath?: string }[];
|
||||
}
|
||||
|
||||
export const LinkModal: React.FC<LinkModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onInsertLink,
|
||||
availableDocs,
|
||||
}) => {
|
||||
const [tab, setTab] = useState<'note' | 'web'>('note');
|
||||
const [search, setSearch] = useState('');
|
||||
const [docs, setDocs] = useState<Document[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [webTitle, setWebTitle] = useState('');
|
||||
const [webUrl, setWebUrl] = useState('');
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const webTitleInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
// Load documents when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
if (availableDocs && availableDocs.length > 0) {
|
||||
setDocs(availableDocs as Document[]);
|
||||
return;
|
||||
}
|
||||
let isCancelled = false;
|
||||
setLoading(true);
|
||||
api
|
||||
.getDocuments()
|
||||
.then((fetched) => {
|
||||
if (!isCancelled) {
|
||||
setDocs(fetched);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isCancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [isOpen, availableDocs]);
|
||||
|
||||
// Focus input on mount or tab change
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const timer = setTimeout(() => {
|
||||
if (tab === 'note') {
|
||||
searchInputRef.current?.focus();
|
||||
} else {
|
||||
webTitleInputRef.current?.focus();
|
||||
}
|
||||
}, 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isOpen, tab]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const filteredDocs = useMemo(() => {
|
||||
if (!search.trim()) return docs;
|
||||
const q = search.toLowerCase();
|
||||
return docs.filter(
|
||||
(d) =>
|
||||
d.title.toLowerCase().includes(q) ||
|
||||
(d.folderPath && d.folderPath.toLowerCase().includes(q))
|
||||
);
|
||||
}, [docs, search]);
|
||||
|
||||
const handleSelectNote = (doc: { title: string }) => {
|
||||
const noteTitle = doc.title.trim() || 'Notiz';
|
||||
onInsertLink(`[[${noteTitle}]]`);
|
||||
};
|
||||
|
||||
const handleWebLinkSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
let url = webUrl.trim();
|
||||
if (!url) return;
|
||||
if (!/^https?:\/\//i.test(url) && !url.startsWith('#') && !url.startsWith('/')) {
|
||||
url = `https://${url}`;
|
||||
}
|
||||
const label = webTitle.trim() || url;
|
||||
onInsertLink(`[${label}](${url})`);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="panel-surface w-full max-w-lg p-5 shadow-panel rounded-card border border-border space-y-4 animate-in fade-in zoom-in-95 duration-150">
|
||||
{/* Header - identical to UnifiedShareModal */}
|
||||
<div className="flex items-center justify-between pb-3 border-b border-border">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-btn bg-accent/15 text-accent flex items-center justify-center shrink-0">
|
||||
<LinkIcon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-bold text-main truncate">Link einfügen</h3>
|
||||
<p className="text-[11px] text-muted truncate">
|
||||
Notiz intern verknüpfen oder externen Web-Link einbetten
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-muted hover:text-main rounded-btn hover:bg-panel transition-colors cursor-pointer"
|
||||
title="Schließen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Control Box - matching UnifiedShareModal form header */}
|
||||
<div className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-main">Link-Typ:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('note')}
|
||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
|
||||
tab === 'note'
|
||||
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
<span>Notiz</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('web')}
|
||||
className={`h-7 px-2.5 text-xs rounded-btn font-medium flex items-center gap-1.5 border transition-colors cursor-pointer ${
|
||||
tab === 'web'
|
||||
? 'bg-panel border-accent text-accent font-semibold shadow-xs'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
<span>Web-Link</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === 'note' ? (
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Notiz suchen (Titel oder Ordner)…"
|
||||
className="input-field bg-panel h-8 pl-8 pr-2.5 text-xs w-full text-main placeholder:text-muted"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleWebLinkSubmit} className="space-y-2.5">
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-faint mb-1 block">
|
||||
Link-Text / Label
|
||||
</label>
|
||||
<input
|
||||
ref={webTitleInputRef}
|
||||
type="text"
|
||||
value={webTitle}
|
||||
onChange={(e) => setWebTitle(e.target.value)}
|
||||
placeholder="z. B. MindSift Dokumentation"
|
||||
className="input-field bg-panel h-8 px-2.5 text-xs w-full text-main placeholder:text-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-faint mb-1 block">
|
||||
Web-Adresse (URL) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={webUrl}
|
||||
onChange={(e) => setWebUrl(e.target.value)}
|
||||
placeholder="https://example.com"
|
||||
className="input-field bg-panel h-8 px-2.5 text-xs w-full text-main placeholder:text-muted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-action text-xs h-8 px-3.5 cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!webUrl.trim()}
|
||||
className="btn-primary text-xs h-8 px-3.5 flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Link einfügen</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Note List Section (Matching Active Shares list in UnifiedShareModal) */}
|
||||
{tab === 'note' && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5">
|
||||
<span>Verfügbare Notizen</span>
|
||||
<span className="font-mono text-[11px] font-normal lowercase">
|
||||
{filteredDocs.length} {filteredDocs.length === 1 ? 'Notiz' : 'Notizen'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-0.5">
|
||||
{loading ? (
|
||||
<div className="p-6 bg-canvas border border-border rounded-card text-xs text-muted flex items-center gap-2 justify-center text-center">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-accent" />
|
||||
<span>Notizen werden geladen…</span>
|
||||
</div>
|
||||
) : filteredDocs.length === 0 ? (
|
||||
<div className="p-4 bg-canvas border border-border rounded-card text-xs text-muted flex items-center gap-2.5 justify-center text-center">
|
||||
<Search className="w-4 h-4 text-faint shrink-0" />
|
||||
<span>
|
||||
{search
|
||||
? `Keine Notiz für "${search}" gefunden.`
|
||||
: 'Keine Notizen im Workspace vorhanden.'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
filteredDocs.map((d) => (
|
||||
<div
|
||||
key={d.id}
|
||||
onClick={() => handleSelectNote(d)}
|
||||
className="flex items-center justify-between p-2.5 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors group cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<span className="text-base shrink-0">{d.icon || '📝'}</span>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold text-main truncate group-hover:text-accent">
|
||||
{d.title || 'Unbenannte Notiz'}
|
||||
</div>
|
||||
{d.folderPath && (
|
||||
<div className="text-[10px] text-muted truncate">
|
||||
📁 {d.folderPath}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-action text-xs h-7 px-2.5 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 text-accent cursor-pointer shrink-0"
|
||||
>
|
||||
<span>Einfügen</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,829 @@
|
||||
import React, { useState, useEffect, useRef, useLayoutEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Check,
|
||||
X,
|
||||
GripVertical,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
ListTodo,
|
||||
Minus,
|
||||
MessageSquare,
|
||||
Hash,
|
||||
ExternalLink,
|
||||
Maximize2,
|
||||
Link as LinkIcon,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { RemoteCursor } from '../../lib/useDocumentSync';
|
||||
import { LinkModal } from './LinkModal';
|
||||
|
||||
export interface SlashCommand {
|
||||
type: BlockType | 'link';
|
||||
label: string;
|
||||
desc: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const SLASH_COMMANDS: SlashCommand[] = [
|
||||
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
|
||||
{ type: 'link', label: '/link', desc: 'Notiz verknüpfen oder Web-Link einfügen', icon: <LinkIcon className="w-3.5 h-3.5" /> },
|
||||
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 className="w-3.5 h-3.5" /> },
|
||||
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: <Heading2 className="w-3.5 h-3.5" /> },
|
||||
{ type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: <Heading3 className="w-3.5 h-3.5" /> },
|
||||
{ type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: <Code className="w-3.5 h-3.5" /> },
|
||||
{ type: 'callout', label: '/callout', desc: 'Hinweisbox', icon: <MessageSquare className="w-3.5 h-3.5" /> },
|
||||
{ type: 'divider', label: '/divider', desc: 'Trennlinie', icon: <Minus className="w-3.5 h-3.5" /> },
|
||||
];
|
||||
|
||||
export interface OutlinerBlockProps {
|
||||
block: DocumentBlock;
|
||||
index: number;
|
||||
maxAllowedLevel: number;
|
||||
hasChildren: boolean;
|
||||
isFocused: boolean;
|
||||
canEdit: boolean;
|
||||
activeRemoteCursors?: RemoteCursor[];
|
||||
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
|
||||
levelOffset?: number;
|
||||
onZoomBlock?: (blockId: string) => void;
|
||||
onFocusBlock: (blockId: string) => void;
|
||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||
onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void;
|
||||
onDeleteBlock: (blockId: string) => void;
|
||||
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
||||
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
||||
onTagClick?: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
onDragStartBlock?: (index: number) => void;
|
||||
onDragOverBlock?: (e: React.DragEvent, index: number) => void;
|
||||
onDropBlock?: (index: number) => void;
|
||||
isDragTarget?: boolean;
|
||||
isTargeted?: boolean;
|
||||
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
||||
}
|
||||
|
||||
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
block,
|
||||
index,
|
||||
hasChildren,
|
||||
isFocused,
|
||||
canEdit,
|
||||
activeRemoteCursors = [],
|
||||
sendCursorMoved,
|
||||
levelOffset = 0,
|
||||
onZoomBlock,
|
||||
onFocusBlock,
|
||||
onUpdateBlock,
|
||||
onCreateBlockBelow,
|
||||
onDeleteBlock,
|
||||
onIndentBlock,
|
||||
onNavigateFocus,
|
||||
onTagClick,
|
||||
onWikiLinkClick,
|
||||
onDragStartBlock,
|
||||
onDragOverBlock,
|
||||
onDropBlock,
|
||||
isDragTarget,
|
||||
isTargeted,
|
||||
registerTextareaRef,
|
||||
}) => {
|
||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
||||
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
|
||||
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const debounceTimerRef = useRef<number | null>(null);
|
||||
const latestBlockRef = useRef<DocumentBlock>(block);
|
||||
latestBlockRef.current = block;
|
||||
const localContentRef = useRef<string>(localContent);
|
||||
localContentRef.current = localContent;
|
||||
|
||||
// Callback refs to avoid stale closures in setTimeout, debounce flush, and event listeners
|
||||
const onUpdateBlockRef = useRef(onUpdateBlock);
|
||||
onUpdateBlockRef.current = onUpdateBlock;
|
||||
const onFocusBlockRef = useRef(onFocusBlock);
|
||||
onFocusBlockRef.current = onFocusBlock;
|
||||
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
|
||||
onCreateBlockBelowRef.current = onCreateBlockBelow;
|
||||
const onDeleteBlockRef = useRef(onDeleteBlock);
|
||||
onDeleteBlockRef.current = onDeleteBlock;
|
||||
const onIndentBlockRef = useRef(onIndentBlock);
|
||||
onIndentBlockRef.current = onIndentBlock;
|
||||
const onNavigateFocusRef = useRef(onNavigateFocus);
|
||||
onNavigateFocusRef.current = onNavigateFocus;
|
||||
const sendCursorMovedRef = useRef(sendCursorMoved);
|
||||
sendCursorMovedRef.current = sendCursorMoved;
|
||||
|
||||
// Track if this input currently has actual focus in an active browser window
|
||||
const isFocusedRef = useRef<boolean>(false);
|
||||
|
||||
const flushDebounce = useCallback(
|
||||
(contentToFlush: string, overrides?: Partial<DocumentBlock>) => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
const nextBlock: DocumentBlock = {
|
||||
...latestBlockRef.current,
|
||||
content: contentToFlush,
|
||||
...overrides,
|
||||
};
|
||||
onUpdateBlockRef.current(nextBlock, true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Sync incoming remote changes ONLY when this textarea is NOT actively being edited
|
||||
// Condition: isFocusedRef.current === true && document.hasFocus()
|
||||
useEffect(() => {
|
||||
const isActivelyEditing =
|
||||
isFocusedRef.current === true &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.hasFocus() &&
|
||||
textareaRef.current !== null &&
|
||||
document.activeElement === textareaRef.current;
|
||||
|
||||
if (!isActivelyEditing && block.content !== localContentRef.current) {
|
||||
setLocalContent(block.content || '');
|
||||
localContentRef.current = block.content || '';
|
||||
}
|
||||
}, [block.content]);
|
||||
|
||||
// Handle window blur / focus to instantly flush debounced input and clear zombie focus
|
||||
useEffect(() => {
|
||||
const handleWindowBlur = () => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
flushDebounce(localContentRef.current);
|
||||
} else if (localContentRef.current !== latestBlockRef.current.content) {
|
||||
flushDebounce(localContentRef.current);
|
||||
}
|
||||
isFocusedRef.current = false;
|
||||
sendCursorMovedRef.current?.(null);
|
||||
};
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
const isThisActive =
|
||||
textareaRef.current !== null &&
|
||||
document.activeElement === textareaRef.current &&
|
||||
document.hasFocus();
|
||||
isFocusedRef.current = isThisActive;
|
||||
};
|
||||
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
return () => {
|
||||
window.removeEventListener('blur', handleWindowBlur);
|
||||
window.removeEventListener('focus', handleWindowFocus);
|
||||
};
|
||||
}, [flushDebounce]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.max(22, el.scrollHeight)}px`;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
adjustHeight();
|
||||
}, [localContent, block.type, block.level, adjustHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', adjustHeight);
|
||||
return () => window.removeEventListener('resize', adjustHeight);
|
||||
}, [adjustHeight]);
|
||||
|
||||
const filteredSlashCommands = useMemo(() => {
|
||||
if (slashSearch === null) return [];
|
||||
const q = slashSearch.toLowerCase();
|
||||
return SLASH_COMMANDS.filter(
|
||||
(c) => c.label.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)
|
||||
);
|
||||
}, [slashSearch]);
|
||||
|
||||
const applySlashCommand = (cmd: SlashCommand) => {
|
||||
setSlashSearch(null);
|
||||
const clean = localContent.replace(/\/([a-z0-9]*)$/i, '').trim();
|
||||
if (cmd.type === 'link') {
|
||||
setLocalContent(clean);
|
||||
setShowLinkModal(true);
|
||||
return;
|
||||
}
|
||||
setLocalContent(clean);
|
||||
flushDebounce(clean, {
|
||||
type: cmd.type,
|
||||
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleInsertLink = (linkMarkdown: string) => {
|
||||
setShowLinkModal(false);
|
||||
const cursor = textareaRef.current?.selectionStart ?? localContent.length;
|
||||
const before = localContent.slice(0, cursor);
|
||||
const after = localContent.slice(cursor);
|
||||
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
|
||||
setLocalContent(newContent);
|
||||
flushDebounce(newContent);
|
||||
};
|
||||
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (!canEdit) return;
|
||||
let val = e.target.value;
|
||||
let typeOverride: BlockType | undefined;
|
||||
let checkedOverride: boolean | undefined;
|
||||
|
||||
// Quick markdown shortcuts
|
||||
if (val.startsWith('[] ') || val.startsWith('[ ] ')) {
|
||||
val = val.replace(/^\[\s?\]\s/, '');
|
||||
typeOverride = 'todo';
|
||||
checkedOverride = false;
|
||||
} else if (val.startsWith('[x] ') || val.startsWith('[X] ')) {
|
||||
val = val.replace(/^\[[xX]\]\s/, '');
|
||||
typeOverride = 'todo';
|
||||
checkedOverride = true;
|
||||
} else if (val.startsWith('# ') && block.type !== 'h1') {
|
||||
val = val.slice(2);
|
||||
typeOverride = 'h1';
|
||||
} else if (val.startsWith('## ') && block.type !== 'h2') {
|
||||
val = val.slice(3);
|
||||
typeOverride = 'h2';
|
||||
} else if (val.startsWith('### ') && block.type !== 'h3') {
|
||||
val = val.slice(4);
|
||||
typeOverride = 'h3';
|
||||
}
|
||||
|
||||
// Check for slash command invocation
|
||||
const slashMatch = val.match(/\/([a-z0-9]*)$/i);
|
||||
if (slashMatch) {
|
||||
setSlashSearch(slashMatch[1]);
|
||||
setSlashIndex(0);
|
||||
} else {
|
||||
setSlashSearch(null);
|
||||
}
|
||||
|
||||
setLocalContent(val);
|
||||
sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
||||
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
if (typeOverride) {
|
||||
flushDebounce(val, {
|
||||
type: typeOverride,
|
||||
checked: checkedOverride ?? latestBlockRef.current.checked,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
debounceTimerRef.current = window.setTimeout(() => {
|
||||
debounceTimerRef.current = null;
|
||||
onUpdateBlockRef.current({
|
||||
...latestBlockRef.current,
|
||||
content: val,
|
||||
});
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
isFocusedRef.current = true;
|
||||
onFocusBlockRef.current(block.id);
|
||||
sendCursorMovedRef.current?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
isFocusedRef.current = false;
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
flushDebounce(localContentRef.current);
|
||||
} else if (localContentRef.current !== latestBlockRef.current.content) {
|
||||
flushDebounce(localContentRef.current);
|
||||
}
|
||||
sendCursorMovedRef.current?.(null);
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
|
||||
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
|
||||
};
|
||||
|
||||
const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
||||
const target = e.currentTarget;
|
||||
sendCursorMovedRef.current?.(block.id, target.selectionStart, target.selectionEnd);
|
||||
};
|
||||
|
||||
const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!canEdit) return;
|
||||
|
||||
// Slash command navigation
|
||||
if (slashSearch !== null && filteredSlashCommands.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSlashIndex((prev) => (prev - 1 + filteredSlashCommands.length) % filteredSlashCommands.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const selected = filteredSlashCommands[slashIndex];
|
||||
if (selected) {
|
||||
applySlashCommand(selected);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setSlashSearch(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Tab / Shift+Tab -> Strict Indent / Outdent
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
flushDebounce(localContent);
|
||||
onIndentBlockRef.current(block.id, e.shiftKey ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Enter (without Shift) -> Create new block on same level
|
||||
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSlashSearch(null);
|
||||
flushDebounce(localContent);
|
||||
onCreateBlockBelowRef.current(block.id, localContent);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Backspace on empty block -> IMMEDIATELY DELETE THE BLOCK (regardless of level)!
|
||||
// Outdenting is strictly done via Shift+Tab.
|
||||
if (e.key === 'Backspace' && localContent === '') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSlashSearch(null);
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
onDeleteBlockRef.current(block.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Arrow navigation between blocks
|
||||
if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) {
|
||||
const el = textareaRef.current;
|
||||
if (el && el.selectionStart === 0 && el.selectionEnd === 0) {
|
||||
e.preventDefault();
|
||||
flushDebounce(localContent);
|
||||
onNavigateFocusRef.current(block.id, 'up');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown' && !e.shiftKey && !e.altKey) {
|
||||
const el = textareaRef.current;
|
||||
if (el && el.selectionStart === localContent.length && el.selectionEnd === localContent.length) {
|
||||
e.preventDefault();
|
||||
flushDebounce(localContent);
|
||||
onNavigateFocusRef.current(block.id, 'down');
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const level = Math.max(0, block.level || 0);
|
||||
const visualLevel = Math.max(0, level - (levelOffset || 0));
|
||||
|
||||
// Parse rich tokens (WikiLinks [[Page]], Markdown Links [Title](url), raw URLs, and #Tags)
|
||||
const tokens = useMemo(() => {
|
||||
const parts = (localContent || '').split(
|
||||
/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/.*?\)|\bhttps?:\/\/[^\s]+|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g
|
||||
);
|
||||
return parts.filter(Boolean);
|
||||
}, [localContent]);
|
||||
|
||||
const hasSpecialTokens = tokens.some(
|
||||
(t) =>
|
||||
t.startsWith('#') ||
|
||||
(t.startsWith('[[') && t.endsWith(']]')) ||
|
||||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
|
||||
/^https?:\/\//.test(t)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-start gap-1 py-1 rounded-card transition-all duration-300 ${
|
||||
isDragTarget
|
||||
? 'border-t-2 border-accent bg-accent/10'
|
||||
: isTargeted
|
||||
? 'bg-accent/15 ring-2 ring-accent'
|
||||
: isFocused
|
||||
? 'bg-panel-elevated'
|
||||
: 'hover:bg-panel'
|
||||
}`}
|
||||
style={{
|
||||
paddingLeft: `${visualLevel * 22 + 4}px`,
|
||||
...(primaryRemoteCursor
|
||||
? { borderLeft: `2px solid ${primaryRemoteCursor.userColor}` }
|
||||
: {}),
|
||||
}}
|
||||
onDragOver={(e) => onDragOverBlock?.(e, index)}
|
||||
onDrop={() => onDropBlock?.(index)}
|
||||
>
|
||||
{/* Remote Cursors Floating Name Badges on Block */}
|
||||
{activeRemoteCursors.length > 0 && (
|
||||
<div className="absolute -top-2.5 right-2 z-20 flex items-center gap-1 select-none pointer-events-none">
|
||||
{activeRemoteCursors.map((c) => (
|
||||
<span
|
||||
key={c.clientId}
|
||||
className="text-[9px] font-bold px-1.5 py-0.5 rounded-btn text-white shadow-sm flex items-center gap-0.5"
|
||||
style={{ backgroundColor: c.userColor }}
|
||||
>
|
||||
{c.userName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indentation Guide Line */}
|
||||
{visualLevel > 0 &&
|
||||
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
|
||||
<div
|
||||
key={lvlIdx}
|
||||
className="pointer-events-none absolute top-0 bottom-0 w-px border-l border-border"
|
||||
style={{ left: `${lvlIdx * 22 + 12}px` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */}
|
||||
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={() => onDragStartBlock?.(index)}
|
||||
title="Block ziehen oder umwandeln"
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-main cursor-grab active:cursor-grabbing transition-opacity"
|
||||
>
|
||||
<GripVertical className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
flushDebounce(localContent, { collapsed: !block.collapsed })
|
||||
}
|
||||
className="p-0.5 text-muted hover:text-accent transition-colors"
|
||||
title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'}
|
||||
>
|
||||
{block.collapsed ? (
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="w-3.5 h-3.5 text-accent" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-3.5" />
|
||||
)}
|
||||
|
||||
{block.type === 'todo' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canEdit}
|
||||
onClick={() =>
|
||||
flushDebounce(localContent, { checked: !block.checked })
|
||||
}
|
||||
className={`w-4 h-4 checkbox-square flex items-center justify-center transition-colors border ${
|
||||
block.checked
|
||||
? 'bg-status-ok border-status-ok text-on-accent'
|
||||
: 'border-strong hover:border-accent text-transparent'
|
||||
}`}
|
||||
title={block.checked ? 'Als offen markieren' : 'Als erledigt markieren'}
|
||||
>
|
||||
<Check className="w-3 h-3 stroke-[3]" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onZoomBlock?.(block.id)}
|
||||
className="w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover group/bullet transition-transform"
|
||||
title="Klick: In diesen Ast hineinzoomen (Fokus-Modus)"
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full transition-all ${
|
||||
block.collapsed && hasChildren
|
||||
? 'bg-accent ring-2 ring-accent/30'
|
||||
: isFocused
|
||||
? 'bg-accent scale-125'
|
||||
: 'bg-faint group-hover/bullet:bg-accent group-hover/bullet:scale-125'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Block Content */}
|
||||
<div className="flex-1 min-w-0 pr-1 relative">
|
||||
{block.type === 'divider' ? (
|
||||
<div className="py-2">
|
||||
<hr className="border-t border-border" />
|
||||
</div>
|
||||
) : block.type === 'code' ? (
|
||||
<div className="bg-code border border-border rounded-btn p-2 my-0.5">
|
||||
<div className="text-[10px] text-faint font-mono uppercase pb-1 flex justify-between">
|
||||
<span>Code</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => flushDebounce(localContent, { type: 'bullet' })}
|
||||
className="hover:text-accent"
|
||||
>
|
||||
Als Text
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRef.current = el;
|
||||
registerTextareaRef(block.id, el);
|
||||
}}
|
||||
rows={1}
|
||||
value={localContent}
|
||||
readOnly={!canEdit}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onClick={handleClick}
|
||||
onKeyUp={handleKeyUp}
|
||||
onSelect={handleSelect}
|
||||
onChange={handleContentChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
) : block.type === 'callout' ? (
|
||||
<div className="bg-panel-elevated border-l-2 border-accent p-2 rounded-r-btn my-0.5">
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRef.current = el;
|
||||
registerTextareaRef(block.id, el);
|
||||
}}
|
||||
rows={1}
|
||||
value={localContent}
|
||||
readOnly={!canEdit}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onClick={handleClick}
|
||||
onKeyUp={handleKeyUp}
|
||||
onSelect={handleSelect}
|
||||
onChange={handleContentChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-full">
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRef.current = el;
|
||||
registerTextareaRef(block.id, el);
|
||||
}}
|
||||
rows={1}
|
||||
value={localContent}
|
||||
readOnly={!canEdit}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onClick={handleClick}
|
||||
onKeyUp={handleKeyUp}
|
||||
onSelect={handleSelect}
|
||||
onChange={handleContentChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
index === 0 && level === 0
|
||||
? 'Schreibe einen Punkt… (Enter = Neu, Tab = Einrücken, Shift+Tab = Ausrücken, / für Befehle)'
|
||||
: ''
|
||||
}
|
||||
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${
|
||||
block.type === 'h1'
|
||||
? 'text-lg font-bold text-main tracking-tight'
|
||||
: block.type === 'h2'
|
||||
? 'text-base font-semibold text-main'
|
||||
: block.type === 'h3'
|
||||
? 'text-sm font-semibold text-main'
|
||||
: block.type === 'todo' && block.checked
|
||||
? 'text-sm text-faint line-through'
|
||||
: 'text-sm text-main'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Remote Caret & Floating Name Tag Overlay */}
|
||||
{primaryRemoteCursor &&
|
||||
primaryRemoteCursor.selectionStart !== undefined &&
|
||||
primaryRemoteCursor.selectionStart !== null && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute inset-0 overflow-hidden leading-relaxed whitespace-pre-wrap break-words select-none z-10 ${
|
||||
block.type === 'h1'
|
||||
? 'text-lg font-bold tracking-tight'
|
||||
: block.type === 'h2'
|
||||
? 'text-base font-semibold'
|
||||
: block.type === 'h3'
|
||||
? 'text-sm font-semibold'
|
||||
: block.type === 'todo' && block.checked
|
||||
? 'text-sm text-faint line-through'
|
||||
: 'text-sm'
|
||||
}`}
|
||||
>
|
||||
<span className="opacity-0">
|
||||
{localContent.slice(
|
||||
0,
|
||||
Math.min(primaryRemoteCursor.selectionStart, localContent.length)
|
||||
)}
|
||||
</span>
|
||||
<span className="relative inline-block align-baseline">
|
||||
<span
|
||||
className="inline-block w-[2px] h-[1.15em] -mb-0.5 animate-pulse"
|
||||
style={{ backgroundColor: primaryRemoteCursor.userColor }}
|
||||
/>
|
||||
<span
|
||||
className="absolute -top-3.5 left-0 text-[8px] font-bold px-1 py-0 rounded-btn text-white whitespace-nowrap shadow-sm"
|
||||
style={{ backgroundColor: primaryRemoteCursor.userColor }}
|
||||
>
|
||||
{primaryRemoteCursor.userName}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rich Tokens Bar (Wiki-Links & #Tags) if present */}
|
||||
{hasSpecialTokens && !isFocused && (
|
||||
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
||||
{tokens.map((part, idx) => {
|
||||
if (part.startsWith('#') && part.length > 1) {
|
||||
const tag = part.slice(1);
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => onTagClick?.(tag)}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-accent bg-accent-dim hover:bg-accent/20 rounded-btn transition-colors"
|
||||
>
|
||||
<Hash className="w-2.5 h-2.5 opacity-80" />
|
||||
<span>{tag}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (part.startsWith('[[') && part.endsWith(']]')) {
|
||||
const page = part.slice(2, -2).trim();
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => onWikiLinkClick?.(page)}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-info bg-status-info/10 hover:bg-status-info/20 rounded-btn transition-colors"
|
||||
title={`Notiz öffnen: ${page}`}
|
||||
>
|
||||
<FileText className="w-2.5 h-2.5 opacity-80" />
|
||||
<span>{page}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
|
||||
if (mdMatch) {
|
||||
const label = mdMatch[1];
|
||||
const url = mdMatch[2];
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
|
||||
title={url}
|
||||
>
|
||||
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
|
||||
<span>{label || url}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (/^https?:\/\//.test(part)) {
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={part}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
|
||||
title={part}
|
||||
>
|
||||
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
|
||||
<span className="truncate max-w-[160px]">{part}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Floating Slash Commands Menu */}
|
||||
{slashSearch !== null && canEdit && (
|
||||
<div className="absolute left-0 top-8 z-40 w-56 panel-surface shadow-panel border border-border rounded-card p-1">
|
||||
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-faint border-b border-border mb-1">
|
||||
Befehl auswählen
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto space-y-0.5">
|
||||
{filteredSlashCommands.map((cmd, i) => (
|
||||
<button
|
||||
key={cmd.type}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
applySlashCommand(cmd);
|
||||
}}
|
||||
className={`w-full text-left px-2 py-1 rounded-btn text-xs flex items-center justify-between transition-colors ${
|
||||
i === slashIndex
|
||||
? 'bg-accent text-on-accent'
|
||||
: 'text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{cmd.icon}
|
||||
<span className="font-semibold">{cmd.label}</span>
|
||||
</div>
|
||||
<span className="text-[10px] opacity-75">{cmd.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredSlashCommands.length === 0 && (
|
||||
<div className="p-2 text-center text-xs text-faint">
|
||||
Kein Befehl gefunden
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zoom / Focus button on hover */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onZoomBlock?.(block.id)}
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
|
||||
title="In diesen Ast hineinzoomen (Fokus-Modus)"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Delete button on hover */}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteBlockRef.current(block.id)}
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0"
|
||||
title="Block löschen"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Link Insertion Modal */}
|
||||
{showLinkModal && (
|
||||
<LinkModal
|
||||
isOpen={showLinkModal}
|
||||
onClose={() => setShowLinkModal(false)}
|
||||
onInsertLink={handleInsertLink}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,382 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { DocumentBlock } from '../../lib/types';
|
||||
import { RemoteCursor } from '../../lib/useDocumentSync';
|
||||
import { OutlinerBlock } from './OutlinerBlock';
|
||||
|
||||
export interface OutlinerTreeProps {
|
||||
docId: string;
|
||||
blocks: DocumentBlock[];
|
||||
canEdit: boolean;
|
||||
levelOffset?: number;
|
||||
onZoomBlock?: (blockId: string) => void;
|
||||
remoteCursors?: Record<string, RemoteCursor>;
|
||||
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
|
||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
|
||||
onDeleteBlock: (blockId: string) => void;
|
||||
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
||||
onTagClick?: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
initialFocusedBlockId?: string | null;
|
||||
}
|
||||
|
||||
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
docId,
|
||||
blocks,
|
||||
canEdit,
|
||||
levelOffset = 0,
|
||||
onZoomBlock,
|
||||
remoteCursors,
|
||||
sendCursorMoved,
|
||||
onUpdateBlock,
|
||||
onCreateBlock,
|
||||
onDeleteBlock,
|
||||
onMoveBlock,
|
||||
onTagClick,
|
||||
onWikiLinkClick,
|
||||
initialFocusedBlockId,
|
||||
}) => {
|
||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
|
||||
initialFocusedBlockId || null
|
||||
);
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
|
||||
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' } | null>(null);
|
||||
|
||||
const focusBlockInput = useCallback(
|
||||
(blockId: string, cursor: 'start' | 'end' | 'select' = 'end') => {
|
||||
setFocusedBlockId(blockId);
|
||||
const el = textareaMapRef.current.get(blockId);
|
||||
if (el) {
|
||||
requestAnimationFrame(() => {
|
||||
el.focus();
|
||||
try {
|
||||
if (cursor === 'select') {
|
||||
el.select();
|
||||
} else {
|
||||
const pos = cursor === 'end' ? el.value.length : 0;
|
||||
el.setSelectionRange(pos, pos);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
} else {
|
||||
pendingFocusRef.current = { blockId, cursor };
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const registerTextareaRef = useCallback((blockId: string, el: HTMLTextAreaElement | null) => {
|
||||
if (el) {
|
||||
textareaMapRef.current.set(blockId, el);
|
||||
if (pendingFocusRef.current && pendingFocusRef.current.blockId === blockId) {
|
||||
const target = pendingFocusRef.current;
|
||||
pendingFocusRef.current = null;
|
||||
requestAnimationFrame(() => {
|
||||
setFocusedBlockId(blockId);
|
||||
el.focus();
|
||||
try {
|
||||
if (target.cursor === 'select') {
|
||||
el.select();
|
||||
} else {
|
||||
const pos = target.cursor === 'end' ? el.value.length : 0;
|
||||
el.setSelectionRange(pos, pos);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
}
|
||||
} else {
|
||||
textareaMapRef.current.delete(blockId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialFocusedBlockId) {
|
||||
setHighlightedBlockId(initialFocusedBlockId);
|
||||
focusBlockInput(initialFocusedBlockId, 'select');
|
||||
const timer = setTimeout(() => {
|
||||
focusBlockInput(initialFocusedBlockId, 'select');
|
||||
}, 70);
|
||||
const timer2 = setTimeout(() => {
|
||||
focusBlockInput(initialFocusedBlockId, 'select');
|
||||
}, 200);
|
||||
const fadeTimer = setTimeout(() => {
|
||||
setHighlightedBlockId(null);
|
||||
}, 2800);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(timer2);
|
||||
clearTimeout(fadeTimer);
|
||||
};
|
||||
}
|
||||
}, [initialFocusedBlockId, blocks.length, focusBlockInput]);
|
||||
|
||||
// Compute visibility based on collapsed ancestor blocks
|
||||
const visibleEntries = useMemo(() => {
|
||||
const result: {
|
||||
block: DocumentBlock;
|
||||
index: number;
|
||||
hasChildren: boolean;
|
||||
maxAllowedLevel: number;
|
||||
}[] = [];
|
||||
|
||||
let collapsedAboveLevel: number | null = null;
|
||||
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const b = blocks[i];
|
||||
const lvl = Math.max(0, b.level || 0);
|
||||
|
||||
if (collapsedAboveLevel !== null) {
|
||||
if (lvl > collapsedAboveLevel) {
|
||||
continue;
|
||||
} else {
|
||||
collapsedAboveLevel = null;
|
||||
}
|
||||
}
|
||||
|
||||
const nextBlock = blocks[i + 1];
|
||||
const nextLvl = nextBlock ? Math.max(0, nextBlock.level || 0) : 0;
|
||||
const hasChildren = Boolean(nextBlock && nextLvl > lvl);
|
||||
|
||||
const prevBlock = i > 0 ? blocks[i - 1] : null;
|
||||
const maxAllowedLevel = prevBlock ? Math.max(0, prevBlock.level || 0) + 1 : (levelOffset || 0);
|
||||
|
||||
result.push({
|
||||
block: b,
|
||||
index: i,
|
||||
hasChildren,
|
||||
maxAllowedLevel,
|
||||
});
|
||||
|
||||
if (b.collapsed && hasChildren) {
|
||||
collapsedAboveLevel = lvl;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [blocks]);
|
||||
|
||||
const handleCreateBlockBelow = useCallback(
|
||||
(afterBlockId: string, _currentContent: string) => {
|
||||
if (!canEdit) return;
|
||||
const idx = blocks.findIndex((b) => b.id === afterBlockId);
|
||||
const currentBlock = idx >= 0 ? blocks[idx] : null;
|
||||
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
|
||||
const inheritedType =
|
||||
currentBlock && currentBlock.type === 'todo' ? 'todo' : 'text';
|
||||
|
||||
const newBlock: DocumentBlock = {
|
||||
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||
documentId: docId,
|
||||
type: inheritedType,
|
||||
content: '',
|
||||
level,
|
||||
checked: false,
|
||||
collapsed: false,
|
||||
position: ((idx >= 0 ? idx + 2 : blocks.length + 1) * 1000),
|
||||
};
|
||||
|
||||
pendingFocusRef.current = { blockId: newBlock.id, cursor: 'start' };
|
||||
setFocusedBlockId(newBlock.id);
|
||||
onCreateBlock(newBlock, afterBlockId);
|
||||
},
|
||||
[blocks, canEdit, docId, onCreateBlock]
|
||||
);
|
||||
|
||||
const handleDeleteBlock = useCallback(
|
||||
(blockId: string) => {
|
||||
if (!canEdit) return;
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx === -1) return;
|
||||
|
||||
// If this is the only block in the document (and not in an isolated zoom subtree), clear instead of deleting
|
||||
if (blocks.length <= 1 && !levelOffset) {
|
||||
const only = blocks[0];
|
||||
onUpdateBlock(
|
||||
{
|
||||
...only,
|
||||
content: '',
|
||||
type: 'text',
|
||||
level: 0,
|
||||
checked: false,
|
||||
},
|
||||
true
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const targetFocusBlock = idx > 0 ? blocks[idx - 1] : blocks[idx + 1];
|
||||
if (targetFocusBlock) {
|
||||
focusBlockInput(targetFocusBlock.id, 'end');
|
||||
}
|
||||
onDeleteBlock(blockId);
|
||||
},
|
||||
[blocks, canEdit, focusBlockInput, onDeleteBlock, onUpdateBlock]
|
||||
);
|
||||
|
||||
const handleIndentBlock = useCallback(
|
||||
(blockId: string, delta: 1 | -1) => {
|
||||
if (!canEdit) return;
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx === -1) return;
|
||||
|
||||
const current = blocks[idx];
|
||||
const currentLevel = Math.max(0, current.level || 0);
|
||||
const prevLevel = idx > 0 ? Math.max(0, blocks[idx - 1].level || 0) : 0;
|
||||
const maxLevel = idx === 0 ? 0 : prevLevel + 1;
|
||||
|
||||
const minLevel = levelOffset || 0;
|
||||
const nextLevel =
|
||||
delta === 1
|
||||
? Math.min(maxLevel, currentLevel + 1)
|
||||
: Math.max(minLevel, currentLevel - 1);
|
||||
|
||||
if (nextLevel === currentLevel) return;
|
||||
|
||||
onUpdateBlock(
|
||||
{
|
||||
...current,
|
||||
level: nextLevel,
|
||||
},
|
||||
true
|
||||
);
|
||||
},
|
||||
[blocks, canEdit, onUpdateBlock]
|
||||
);
|
||||
|
||||
const handleNavigateFocus = useCallback(
|
||||
(blockId: string, direction: 'up' | 'down') => {
|
||||
const visIdx = visibleEntries.findIndex((e) => e.block.id === blockId);
|
||||
if (visIdx === -1) return;
|
||||
const targetEntry =
|
||||
direction === 'up' ? visibleEntries[visIdx - 1] : visibleEntries[visIdx + 1];
|
||||
if (targetEntry) {
|
||||
focusBlockInput(
|
||||
targetEntry.block.id,
|
||||
direction === 'up' ? 'end' : 'start'
|
||||
);
|
||||
}
|
||||
},
|
||||
[focusBlockInput, visibleEntries]
|
||||
);
|
||||
|
||||
const handleDragStartBlock = useCallback((index: number) => {
|
||||
setDraggedIndex(index);
|
||||
}, []);
|
||||
|
||||
const handleDragOverBlock = useCallback((e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
setDragOverIndex(index);
|
||||
}, []);
|
||||
|
||||
const handleDropBlock = useCallback(
|
||||
(targetIndex: number) => {
|
||||
if (draggedIndex === null || draggedIndex === targetIndex) {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
return;
|
||||
}
|
||||
const movingBlock = blocks[draggedIndex];
|
||||
if (movingBlock) {
|
||||
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
|
||||
}
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
},
|
||||
[blocks, draggedIndex, onMoveBlock]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const clearDrag = () => {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
window.addEventListener('dragend', clearDrag);
|
||||
return () => window.removeEventListener('dragend', clearDrag);
|
||||
}, []);
|
||||
|
||||
const cursorsByBlock = useMemo(() => {
|
||||
const map: Record<string, RemoteCursor[]> = {};
|
||||
if (!remoteCursors) return map;
|
||||
for (const c of Object.values(remoteCursors)) {
|
||||
if (c.blockId) {
|
||||
if (!map[c.blockId]) map[c.blockId] = [];
|
||||
map[c.blockId].push(c);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [remoteCursors]);
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => (
|
||||
<OutlinerBlock
|
||||
key={block.id}
|
||||
block={block}
|
||||
index={index}
|
||||
maxAllowedLevel={maxAllowedLevel}
|
||||
hasChildren={hasChildren}
|
||||
isFocused={focusedBlockId === block.id}
|
||||
canEdit={canEdit}
|
||||
levelOffset={levelOffset}
|
||||
onZoomBlock={onZoomBlock}
|
||||
activeRemoteCursors={cursorsByBlock[block.id] || []}
|
||||
sendCursorMoved={sendCursorMoved}
|
||||
onFocusBlock={setFocusedBlockId}
|
||||
onUpdateBlock={onUpdateBlock}
|
||||
onCreateBlockBelow={handleCreateBlockBelow}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onIndentBlock={handleIndentBlock}
|
||||
onNavigateFocus={handleNavigateFocus}
|
||||
onTagClick={onTagClick}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
onDragStartBlock={handleDragStartBlock}
|
||||
onDragOverBlock={handleDragOverBlock}
|
||||
onDropBlock={handleDropBlock}
|
||||
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
||||
isTargeted={highlightedBlockId === block.id}
|
||||
registerTextareaRef={registerTextareaRef}
|
||||
/>
|
||||
))}
|
||||
|
||||
{canEdit && (
|
||||
<div className="pt-2 pl-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const lastBlock = blocks[blocks.length - 1];
|
||||
if (lastBlock) {
|
||||
handleCreateBlockBelow(lastBlock.id, lastBlock.content);
|
||||
} else {
|
||||
const firstBlock: DocumentBlock = {
|
||||
id: `blk_${Date.now().toString(36)}_0`,
|
||||
documentId: docId,
|
||||
type: 'text',
|
||||
content: '',
|
||||
level: levelOffset || 0,
|
||||
position: 1000,
|
||||
};
|
||||
pendingFocusRef.current = { blockId: firstBlock.id, cursor: 'start' };
|
||||
onCreateBlock(firstBlock);
|
||||
}
|
||||
}}
|
||||
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Neuer Punkt</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
+400
-39
@@ -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 components;
|
||||
@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;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
@@ -29,15 +25,16 @@
|
||||
|
||||
body {
|
||||
background-color: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
color: var(--text-main);
|
||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
overflow-x: hidden;
|
||||
transition: background-color 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
/* Subtle custom dark scrollbar */
|
||||
/* Scrollbar über Theme-Variablen */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@@ -45,25 +42,389 @@ body {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 9999px;
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: var(--radius-btn);
|
||||
}
|
||||
|
||||
::-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 {
|
||||
border-color: rgba(138, 180, 248, 0.55);
|
||||
box-shadow: 0 0 32px -4px rgba(138, 180, 248, 0.25), 0 0 12px -2px rgba(197, 138, 249, 0.2);
|
||||
}
|
||||
@layer components {
|
||||
/* Haupt-Karten, Sidebars und Container */
|
||||
.panel-surface {
|
||||
background-color: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-card);
|
||||
}
|
||||
|
||||
.gemini-gradient-text {
|
||||
background: linear-gradient(135deg, #8ab4f8 0%, #c58af9 55%, #81c995 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
.panel-elevated {
|
||||
background-color: var(--bg-panel-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
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 im Header (immer vollständig abgerundet) */
|
||||
.capture-pill,
|
||||
.capture-pill.bg-panel.border {
|
||||
border-radius: 9999px !important;
|
||||
}
|
||||
|
||||
/* Quick-Capture-Blase bei mehrzeiliger Eingabe (Shift+Enter) */
|
||||
.capture-bar-multiline,
|
||||
.capture-bar-multiline.bg-panel.border {
|
||||
border-radius: 18px !important;
|
||||
}
|
||||
|
||||
/* Aufgaben-Checkboxen */
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Sekundärer Button */
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
height: 1.75rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
background-color: var(--bg-panel-elevated);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn);
|
||||
color: var(--text-main);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
transition: all 120ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--bg-panel-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
/* Gefahren-/Löschen-Button */
|
||||
.btn-danger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
height: 1.75rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-right: 0.75rem;
|
||||
background-color: var(--bg-danger);
|
||||
border: 1px solid var(--border-danger);
|
||||
border-radius: var(--radius-btn);
|
||||
color: var(--color-danger);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
transition: all 120ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
|
||||
/* Zentrales Texteingabefeld & Aliasse */
|
||||
.input-field,
|
||||
.input-base {
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text-main);
|
||||
font-size: 0.75rem;
|
||||
transition: border-color 120ms ease;
|
||||
}
|
||||
.input-field:focus,
|
||||
.input-base:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
.input-field::placeholder,
|
||||
.input-base::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 {
|
||||
margin: 0 !important;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
background-color: var(--bg-overlay);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Verhindert, dass Elterncontainer mit space-y-* oder space-x-* feste Overlays nach unten verschieben */
|
||||
.fixed.inset-0,
|
||||
[class*="fixed inset-0"] {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* File Tree Item */
|
||||
.tree-node-active {
|
||||
background-color: var(--bg-panel-elevated);
|
||||
border-left: 2px solid var(--accent-primary);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* Prism Syntax Highlighting Tokens */
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #71717a;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.boolean,
|
||||
.token.number,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #38bdf8;
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword {
|
||||
color: #818cf8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.token.function,
|
||||
.token.class-name {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important,
|
||||
.token.variable {
|
||||
color: #f97316;
|
||||
}
|
||||
}
|
||||
+1109
-39
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
if (themeId !== 'light') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initThemeOnBoot(): ThemeId {
|
||||
const active = getActiveTheme();
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-theme', active);
|
||||
if (active !== 'light') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
+292
-4
@@ -1,13 +1,49 @@
|
||||
export type ThoughtStatus = 'inbox' | 'kept' | 'discarded' | 'converted';
|
||||
export type PriorityLevel = 'low' | 'normal' | 'high' | 'urgent';
|
||||
export type UserRole = 'admin' | 'member';
|
||||
|
||||
export interface AuthStatus {
|
||||
needsSetup: boolean;
|
||||
allowRegistration: boolean;
|
||||
instanceName: string;
|
||||
demoMode?: boolean;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username?: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
role: UserRole;
|
||||
isActive: boolean;
|
||||
alarmChannels?: string[];
|
||||
alarmEmail?: string;
|
||||
discordWebhookUrl?: string;
|
||||
customWebhookUrl?: string;
|
||||
notifyBeforeMinutes?: number;
|
||||
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 SubTask {
|
||||
id: string;
|
||||
title: string;
|
||||
isCompleted: boolean;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface Thought {
|
||||
id: string;
|
||||
userId: string;
|
||||
@@ -20,18 +56,155 @@ export interface Thought {
|
||||
convertedType?: 'task' | 'document' | 'list_item';
|
||||
convertedRefId?: string;
|
||||
dueDate?: string;
|
||||
taskListId?: string;
|
||||
kanbanStatus?: string;
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
alarmSent?: boolean;
|
||||
sortOrder?: number;
|
||||
createdAt: string;
|
||||
triagedAt?: string;
|
||||
}
|
||||
|
||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider';
|
||||
export interface KanbanColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
isDone?: boolean;
|
||||
}
|
||||
|
||||
export interface TaskListShare {
|
||||
id: string;
|
||||
taskListId: string;
|
||||
targetType: 'team' | 'user';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TaskListMember {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
avatarUrl?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface TaskList {
|
||||
id: string;
|
||||
userId: string;
|
||||
spaceId?: string;
|
||||
spaceName?: string;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
ownerId?: string;
|
||||
ownerName?: string;
|
||||
isOwner?: boolean;
|
||||
members?: TaskListMember[];
|
||||
shares?: TaskListShare[];
|
||||
columns?: KanbanColumn[];
|
||||
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;
|
||||
avatarUrl?: string;
|
||||
role: 'admin' | 'member';
|
||||
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' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
|
||||
|
||||
export interface DocumentBlock {
|
||||
id: string;
|
||||
docId?: string;
|
||||
documentId?: string;
|
||||
type: BlockType;
|
||||
content: string;
|
||||
checked?: boolean;
|
||||
lang?: string;
|
||||
level?: number;
|
||||
position?: number;
|
||||
collapsed?: boolean;
|
||||
sortOrder?: number;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface DocBlocksPatchRequest {
|
||||
upserted?: DocumentBlock[];
|
||||
deletedIds?: string[];
|
||||
blockOrder?: string[];
|
||||
}
|
||||
|
||||
export interface DocBlocksPatchEvent {
|
||||
docId: string;
|
||||
updatedBy: string;
|
||||
updatedAt: string;
|
||||
upserted?: DocumentBlock[];
|
||||
deletedIds?: string[];
|
||||
blockOrder?: string[];
|
||||
}
|
||||
|
||||
export interface DocShare {
|
||||
id: string;
|
||||
docId: string;
|
||||
targetType?: 'user' | 'team';
|
||||
targetId?: string;
|
||||
targetName?: string;
|
||||
teamId?: string;
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
displayName?: string;
|
||||
permission: 'read' | 'write';
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DocLockInfo {
|
||||
docId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface Document {
|
||||
@@ -40,13 +213,82 @@ export interface Document {
|
||||
spaceId?: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
folderId?: string;
|
||||
markdownCache?: string;
|
||||
content: string;
|
||||
blocks: DocumentBlock[];
|
||||
tags: string[];
|
||||
folderPath?: string;
|
||||
isPinned: boolean;
|
||||
isArchived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
// Collaboration & Permissions
|
||||
permission?: 'owner' | 'write' | 'read';
|
||||
isOwner?: boolean;
|
||||
ownerName?: string;
|
||||
sharedWith?: DocShare[];
|
||||
sharedFolderId?: string;
|
||||
subFolderPath?: string;
|
||||
isSharedFolder?: boolean;
|
||||
folderPermission?: string;
|
||||
}
|
||||
|
||||
export interface SharedFolder {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
ownerName?: string;
|
||||
name: string;
|
||||
mountPath: string;
|
||||
isOwner: boolean;
|
||||
permission: 'owner' | 'write' | 'read';
|
||||
shares?: FolderShare[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface FolderShare {
|
||||
id: string;
|
||||
folderId: string;
|
||||
targetType: 'user' | 'team';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
email?: string;
|
||||
avatarUrl?: string;
|
||||
permission: 'read' | 'write';
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface TagSummary {
|
||||
tag: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MatchedTagBlock {
|
||||
rootBlock: DocumentBlock;
|
||||
children: DocumentBlock[];
|
||||
}
|
||||
|
||||
export interface TagDocumentGroup {
|
||||
docId: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
folderPath: string;
|
||||
updatedAt: string;
|
||||
isOwner: boolean;
|
||||
permission: 'owner' | 'write' | 'read';
|
||||
ownerName?: string;
|
||||
matchedBlocks: MatchedTagBlock[];
|
||||
}
|
||||
|
||||
export interface TagDetails {
|
||||
tag: string;
|
||||
noteContent?: string;
|
||||
noteUpdatedAt?: string;
|
||||
documents: TagDocumentGroup[];
|
||||
totalMatches: number;
|
||||
}
|
||||
|
||||
export interface SpaceMember {
|
||||
@@ -54,10 +296,35 @@ export interface SpaceMember {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
email: string;
|
||||
avatarUrl?: string;
|
||||
role: 'owner' | 'editor' | 'viewer';
|
||||
joinedAt: string;
|
||||
}
|
||||
|
||||
export interface UnifiedShare {
|
||||
id: string;
|
||||
targetType: 'team' | 'user';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
targetEmail?: string;
|
||||
avatarUrl?: string;
|
||||
permission?: 'editor' | 'viewer';
|
||||
role?: string;
|
||||
createdBy?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SpaceShare {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
targetType: 'team' | 'user';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
role: 'editor' | 'viewer';
|
||||
createdBy?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Space {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -66,7 +333,9 @@ export interface Space {
|
||||
inviteCode: string;
|
||||
isPersonal: boolean;
|
||||
ownerId: string;
|
||||
ownerName?: string;
|
||||
members?: SpaceMember[];
|
||||
shares?: SpaceShare[];
|
||||
listsCount: number;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -85,13 +354,30 @@ export interface ListItem {
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface ListShare {
|
||||
id: string;
|
||||
listId: string;
|
||||
targetType: 'team' | 'user';
|
||||
targetId: string;
|
||||
targetName?: string;
|
||||
targetEmail?: string;
|
||||
role: 'editor' | 'viewer';
|
||||
createdBy?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SharedList {
|
||||
id: string;
|
||||
spaceId: string;
|
||||
userId?: string;
|
||||
ownerName?: string;
|
||||
isOwner?: boolean;
|
||||
role?: string;
|
||||
spaceId?: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
listType: 'shopping' | 'checklist' | 'vacation';
|
||||
items: ListItem[];
|
||||
shares?: ListShare[];
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -112,14 +398,16 @@ export interface DashboardStats {
|
||||
tasksOpenCount: number;
|
||||
tasksDoneCount: number;
|
||||
docsCount: number;
|
||||
docsTodosOpenCount?: number;
|
||||
listsCount: number;
|
||||
discardedCount: number;
|
||||
}
|
||||
|
||||
export interface WSMessage<T = any> {
|
||||
event: 'ITEM_CREATED' | 'ITEM_UPDATED' | 'ITEM_TOGGLED' | 'ITEM_DELETED' | 'LIST_CLEARED' | 'LIST_CREATED' | 'LIST_DELETED' | 'USER_PRESENCE';
|
||||
space_id: string;
|
||||
event: string;
|
||||
space_id?: string;
|
||||
list_id?: string;
|
||||
listId?: string;
|
||||
payload: T;
|
||||
timestamp: string;
|
||||
}
|
||||
@@ -0,0 +1,553 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { DocumentBlock } from './types';
|
||||
import { getToken, api } from './api';
|
||||
|
||||
export type DocSyncStatus = 'connecting' | 'connected' | 'reconnecting' | 'offline';
|
||||
|
||||
export interface RemoteCursor {
|
||||
clientId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
userColor: string;
|
||||
blockId: string | null;
|
||||
selectionStart?: number;
|
||||
selectionEnd?: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export const CURSOR_COLORS = [
|
||||
'#3b82f6', // blue
|
||||
'#10b981', // emerald
|
||||
'#f59e0b', // amber
|
||||
'#ec4899', // pink
|
||||
'#8b5cf6', // purple
|
||||
'#06b6d4', // cyan
|
||||
'#f97316', // orange
|
||||
'#14b8a6', // teal
|
||||
'#6366f1', // indigo
|
||||
];
|
||||
|
||||
export function getUserColor(idOrName: string): string {
|
||||
if (!idOrName) return CURSOR_COLORS[0];
|
||||
let hash = 0;
|
||||
for (let i = 0; i < idOrName.length; i++) {
|
||||
hash = (hash << 5) - hash + idOrName.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return CURSOR_COLORS[Math.abs(hash) % CURSOR_COLORS.length];
|
||||
}
|
||||
|
||||
export interface DocSyncEvent {
|
||||
type:
|
||||
| 'doc_state'
|
||||
| 'block_created'
|
||||
| 'block_updated'
|
||||
| 'block_deleted'
|
||||
| 'block_moved'
|
||||
| 'title_updated'
|
||||
| 'blocks_replaced'
|
||||
| 'cursor_moved'
|
||||
| 'client_left';
|
||||
docId?: string;
|
||||
clientId?: string;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
userColor?: string;
|
||||
selectionStart?: number;
|
||||
selectionEnd?: number;
|
||||
activeCursors?: RemoteCursor[];
|
||||
title?: string;
|
||||
block?: DocumentBlock;
|
||||
blockId?: string;
|
||||
afterBlockId?: string;
|
||||
newIndex?: number;
|
||||
content?: string;
|
||||
level?: number;
|
||||
checked?: boolean;
|
||||
collapsed?: boolean;
|
||||
blockType?: string;
|
||||
blocks?: DocumentBlock[];
|
||||
}
|
||||
|
||||
export function useDocumentSync(
|
||||
docId: string | null,
|
||||
onRemoteEvent: (evt: DocSyncEvent) => void,
|
||||
canEdit: boolean = true,
|
||||
currentUser?: { id: string; email?: string; displayName?: string } | null
|
||||
) {
|
||||
const [status, setStatus] = useState<DocSyncStatus>('connecting');
|
||||
const [remoteCursors, setRemoteCursors] = useState<Record<string, RemoteCursor>>({});
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const clientIdRef = useRef<string>(
|
||||
`cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`
|
||||
);
|
||||
const onRemoteEventRef = useRef(onRemoteEvent);
|
||||
onRemoteEventRef.current = onRemoteEvent;
|
||||
|
||||
const docIdRef = useRef(docId);
|
||||
docIdRef.current = docId;
|
||||
const canEditRef = useRef(canEdit);
|
||||
canEditRef.current = canEdit;
|
||||
const currentUserRef = useRef(currentUser);
|
||||
currentUserRef.current = currentUser;
|
||||
|
||||
const reconnectTimerRef = useRef<number | null>(null);
|
||||
const fallbackTimerRef = useRef<number | null>(null);
|
||||
|
||||
const localUserName =
|
||||
currentUser?.displayName || currentUser?.email?.split('@')[0] || 'Anonym';
|
||||
const localUserColor = useMemo(
|
||||
() => getUserColor(currentUser?.id || clientIdRef.current),
|
||||
[currentUser?.id]
|
||||
);
|
||||
const localUserNameRef = useRef(localUserName);
|
||||
localUserNameRef.current = localUserName;
|
||||
const localUserColorRef = useRef(localUserColor);
|
||||
localUserColorRef.current = localUserColor;
|
||||
|
||||
const pendingMessagesQueueRef = useRef<string[]>([]);
|
||||
|
||||
const lastCursorSendRef = useRef<number>(0);
|
||||
const pendingCursorTimerRef = useRef<number | null>(null);
|
||||
const pendingCursorPayloadRef = useRef<{
|
||||
blockId: string | null;
|
||||
selectionStart?: number;
|
||||
selectionEnd?: number;
|
||||
} | null>(null);
|
||||
|
||||
// Clean up stale remote cursors (> 30s)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
setRemoteCursors((prev) => {
|
||||
let hasStale = false;
|
||||
const next: Record<string, RemoteCursor> = {};
|
||||
for (const [key, cursor] of Object.entries(prev)) {
|
||||
if (now - cursor.updatedAt < 30000) {
|
||||
next[key] = cursor;
|
||||
} else {
|
||||
hasStale = true;
|
||||
}
|
||||
}
|
||||
return hasStale ? next : prev;
|
||||
});
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!docId) {
|
||||
setStatus('offline');
|
||||
setRemoteCursors({});
|
||||
pendingMessagesQueueRef.current = [];
|
||||
return;
|
||||
}
|
||||
|
||||
pendingMessagesQueueRef.current = [];
|
||||
let isDisposed = false;
|
||||
let retryCount = 0;
|
||||
const currentClientId = `cli_${Math.random().toString(36).slice(2, 10)}_${Date.now().toString(36)}`;
|
||||
clientIdRef.current = currentClientId;
|
||||
|
||||
const connect = () => {
|
||||
if (isDisposed) return;
|
||||
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
setStatus('offline');
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(
|
||||
docId
|
||||
)}?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(
|
||||
currentClientId
|
||||
)}&userName=${encodeURIComponent(localUserName)}&userColor=${encodeURIComponent(
|
||||
localUserColor
|
||||
)}`;
|
||||
|
||||
setStatus(retryCount === 0 ? 'connecting' : 'reconnecting');
|
||||
|
||||
let ws: WebSocket;
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
} catch {
|
||||
setStatus('offline');
|
||||
return;
|
||||
}
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (isDisposed) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
retryCount = 0;
|
||||
setStatus('connected');
|
||||
|
||||
// Flush any queued messages buffered while connecting or reconnecting
|
||||
if (pendingMessagesQueueRef.current.length > 0) {
|
||||
const queue = [...pendingMessagesQueueRef.current];
|
||||
pendingMessagesQueueRef.current = [];
|
||||
for (const msg of queue) {
|
||||
try {
|
||||
ws.send(msg);
|
||||
} catch (err) {
|
||||
console.warn('[DocSync] Failed to flush queued message:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (isDisposed) return;
|
||||
try {
|
||||
const parsed: DocSyncEvent = JSON.parse(event.data);
|
||||
if (!parsed || !parsed.type) return;
|
||||
|
||||
// Echo protection: ignore events originating from our own clientId (except initial doc_state)
|
||||
if (parsed.type !== 'doc_state' && parsed.clientId && parsed.clientId === currentClientId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.type === 'doc_state') {
|
||||
if (Array.isArray(parsed.activeCursors)) {
|
||||
const initialCursors: Record<string, RemoteCursor> = {};
|
||||
for (const c of parsed.activeCursors) {
|
||||
if (c.clientId && c.clientId !== currentClientId) {
|
||||
initialCursors[c.clientId] = {
|
||||
...c,
|
||||
updatedAt: c.updatedAt || Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
setRemoteCursors(initialCursors);
|
||||
}
|
||||
} else if (parsed.type === 'cursor_moved') {
|
||||
if (parsed.clientId && parsed.clientId !== currentClientId) {
|
||||
setRemoteCursors((prev) => ({
|
||||
...prev,
|
||||
[parsed.clientId!]: {
|
||||
clientId: parsed.clientId!,
|
||||
userId: parsed.userId || '',
|
||||
userName: parsed.userName || 'Anonym',
|
||||
userColor: parsed.userColor || '#3b82f6',
|
||||
blockId:
|
||||
parsed.blockId !== undefined
|
||||
? parsed.blockId
|
||||
: prev[parsed.clientId!]?.blockId ?? null,
|
||||
selectionStart: parsed.selectionStart,
|
||||
selectionEnd: parsed.selectionEnd,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
}));
|
||||
}
|
||||
} else if (parsed.type === 'client_left') {
|
||||
if (parsed.clientId) {
|
||||
setRemoteCursors((prev) => {
|
||||
if (!prev[parsed.clientId!]) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[parsed.clientId!];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onRemoteEventRef.current(parsed);
|
||||
} catch {
|
||||
// Ignore malformed frames
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// Handled in onclose
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (isDisposed) return;
|
||||
wsRef.current = null;
|
||||
retryCount += 1;
|
||||
setStatus(retryCount > 2 ? 'offline' : 'reconnecting');
|
||||
const delay = Math.min(1000 * Math.pow(1.6, retryCount), 8000);
|
||||
reconnectTimerRef.current = window.setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
isDisposed = true;
|
||||
if (reconnectTimerRef.current !== null) {
|
||||
window.clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
}
|
||||
if (fallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(fallbackTimerRef.current);
|
||||
fallbackTimerRef.current = null;
|
||||
}
|
||||
if (pendingCursorTimerRef.current !== null) {
|
||||
window.clearTimeout(pendingCursorTimerRef.current);
|
||||
pendingCursorTimerRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.onopen = null;
|
||||
wsRef.current.onmessage = null;
|
||||
wsRef.current.onerror = null;
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [docId, localUserName, localUserColor]);
|
||||
|
||||
const executeSendCursor = useCallback(
|
||||
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
|
||||
const ws = wsRef.current;
|
||||
const activeDocId = docIdRef.current;
|
||||
if (!activeDocId || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const payload: DocSyncEvent = {
|
||||
type: 'cursor_moved',
|
||||
docId: activeDocId,
|
||||
clientId: clientIdRef.current,
|
||||
userId: currentUserRef.current?.id,
|
||||
userName: localUserNameRef.current,
|
||||
userColor: localUserColorRef.current,
|
||||
blockId: blockId || undefined,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
};
|
||||
try {
|
||||
ws.send(JSON.stringify(payload));
|
||||
} catch {
|
||||
// Ephemeral cursor, ignore send failure
|
||||
}
|
||||
lastCursorSendRef.current = Date.now();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const sendCursorMoved = useCallback(
|
||||
(blockId: string | null, selectionStart?: number, selectionEnd?: number) => {
|
||||
if (!docIdRef.current) return;
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastCursorSendRef.current;
|
||||
|
||||
// When leaving focus / blur (blockId === null), send immediately
|
||||
if (blockId === null) {
|
||||
if (pendingCursorTimerRef.current !== null) {
|
||||
window.clearTimeout(pendingCursorTimerRef.current);
|
||||
pendingCursorTimerRef.current = null;
|
||||
}
|
||||
pendingCursorPayloadRef.current = null;
|
||||
executeSendCursor(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Max once per 100ms
|
||||
if (elapsed >= 100) {
|
||||
if (pendingCursorTimerRef.current !== null) {
|
||||
window.clearTimeout(pendingCursorTimerRef.current);
|
||||
pendingCursorTimerRef.current = null;
|
||||
}
|
||||
executeSendCursor(blockId, selectionStart, selectionEnd);
|
||||
} else {
|
||||
pendingCursorPayloadRef.current = { blockId, selectionStart, selectionEnd };
|
||||
if (pendingCursorTimerRef.current === null) {
|
||||
pendingCursorTimerRef.current = window.setTimeout(() => {
|
||||
pendingCursorTimerRef.current = null;
|
||||
if (pendingCursorPayloadRef.current) {
|
||||
executeSendCursor(
|
||||
pendingCursorPayloadRef.current.blockId,
|
||||
pendingCursorPayloadRef.current.selectionStart,
|
||||
pendingCursorPayloadRef.current.selectionEnd
|
||||
);
|
||||
pendingCursorPayloadRef.current = null;
|
||||
}
|
||||
}, 100 - elapsed);
|
||||
}
|
||||
}
|
||||
},
|
||||
[executeSendCursor]
|
||||
);
|
||||
|
||||
const sendRawEvent = useCallback(
|
||||
(evt: DocSyncEvent, fallbackFn?: () => void) => {
|
||||
const activeDocId = docIdRef.current;
|
||||
const activeCanEdit = canEditRef.current;
|
||||
if (!activeDocId || !activeCanEdit) return;
|
||||
|
||||
const ws = wsRef.current;
|
||||
const payload: DocSyncEvent = {
|
||||
...evt,
|
||||
docId: activeDocId,
|
||||
clientId: clientIdRef.current,
|
||||
};
|
||||
const jsonStr = JSON.stringify(payload);
|
||||
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
ws.send(jsonStr);
|
||||
} catch (err) {
|
||||
console.warn('[DocSync] Failed to send WebSocket message, buffering:', err);
|
||||
pendingMessagesQueueRef.current.push(jsonStr);
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`[DocSync] WebSocket not OPEN (state=${ws ? ws.readyState : 'null'}). Buffering event:`,
|
||||
evt.type
|
||||
);
|
||||
pendingMessagesQueueRef.current.push(jsonStr);
|
||||
|
||||
if (fallbackFn) {
|
||||
if (fallbackTimerRef.current !== null) {
|
||||
window.clearTimeout(fallbackTimerRef.current);
|
||||
}
|
||||
fallbackTimerRef.current = window.setTimeout(() => {
|
||||
fallbackFn();
|
||||
}, 600);
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const sendBlockCreated = useCallback(
|
||||
(block: DocumentBlock, afterBlockId?: string, allBlocks?: DocumentBlock[]) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'block_created',
|
||||
block,
|
||||
afterBlockId,
|
||||
},
|
||||
() => {
|
||||
if (docId && allBlocks) {
|
||||
api
|
||||
.patchDocumentBlocks(docId, {
|
||||
upserted: [block],
|
||||
blockOrder: allBlocks.map((b) => b.id),
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlockUpdated = useCallback(
|
||||
(block: DocumentBlock) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'block_updated',
|
||||
blockId: block.id,
|
||||
block,
|
||||
},
|
||||
() => {
|
||||
if (docId) {
|
||||
api.patchDocumentBlocks(docId, { upserted: [block] }).catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlockDeleted = useCallback(
|
||||
(blockId: string, allBlocks?: DocumentBlock[]) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'block_deleted',
|
||||
blockId,
|
||||
},
|
||||
() => {
|
||||
if (docId) {
|
||||
api
|
||||
.patchDocumentBlocks(docId, {
|
||||
deletedIds: [blockId],
|
||||
blockOrder: allBlocks ? allBlocks.map((b) => b.id) : undefined,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlockMoved = useCallback(
|
||||
(blockId: string, newIndex: number, level?: number, allBlocks?: DocumentBlock[]) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'block_moved',
|
||||
blockId,
|
||||
newIndex,
|
||||
level,
|
||||
},
|
||||
() => {
|
||||
if (docId && allBlocks) {
|
||||
api
|
||||
.patchDocumentBlocks(docId, {
|
||||
upserted: allBlocks,
|
||||
blockOrder: allBlocks.map((b) => b.id),
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendTitleUpdated = useCallback(
|
||||
(title: string) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'title_updated',
|
||||
title,
|
||||
},
|
||||
() => {
|
||||
if (docId) {
|
||||
api.updateDocument(docId, { title }).catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
const sendBlocksReplaced = useCallback(
|
||||
(blocks: DocumentBlock[]) => {
|
||||
sendRawEvent(
|
||||
{
|
||||
type: 'blocks_replaced',
|
||||
blocks,
|
||||
},
|
||||
() => {
|
||||
if (docId) {
|
||||
api.updateDocument(docId, { blocks }).catch(() => {});
|
||||
}
|
||||
}
|
||||
);
|
||||
},
|
||||
[docId, sendRawEvent]
|
||||
);
|
||||
|
||||
return {
|
||||
status,
|
||||
clientId: clientIdRef.current,
|
||||
remoteCursors,
|
||||
sendCursorMoved,
|
||||
sendBlockCreated,
|
||||
sendBlockUpdated,
|
||||
sendBlockDeleted,
|
||||
sendBlockMoved,
|
||||
sendTitleUpdated,
|
||||
sendBlocksReplaced,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { MobileActionProvider } from './context/MobileActionContext';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<MobileActionProvider>
|
||||
<App />
|
||||
</MobileActionProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -5,28 +5,76 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
gemini: {
|
||||
bg: '#0b0f19',
|
||||
surface: '#131314',
|
||||
card: '#1e1f20',
|
||||
hover: '#282a2c',
|
||||
blue: '#8ab4f8',
|
||||
purple: '#c58af9',
|
||||
keep: '#81c995',
|
||||
discard: '#f28b82',
|
||||
text: '#e3e3e3',
|
||||
muted: '#9aa0a6',
|
||||
subtle: '#5f6368',
|
||||
canvas: 'var(--bg-app)',
|
||||
panel: {
|
||||
DEFAULT: 'var(--bg-panel)',
|
||||
elevated: 'var(--bg-panel-elevated)',
|
||||
hover: 'var(--bg-panel-hover)',
|
||||
},
|
||||
surface: {
|
||||
DEFAULT: 'var(--bg-panel)',
|
||||
elevated: 'var(--bg-panel-elevated)',
|
||||
hover: 'var(--bg-panel-hover)',
|
||||
border: 'var(--border-color)',
|
||||
},
|
||||
input: 'var(--bg-input)',
|
||||
overlay: 'var(--bg-overlay)',
|
||||
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)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||
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: {
|
||||
'gemini-glow': '0 0 28px -4px rgba(138, 180, 248, 0.22)',
|
||||
'purple-glow': '0 0 32px -4px rgba(197, 138, 249, 0.35)',
|
||||
'card-depth': '0 20px 50px -12px rgba(0, 0, 0, 0.7)',
|
||||
panel: 'var(--shadow-panel)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'Inter',
|
||||
'system-ui',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'Segoe UI',
|
||||
'Roboto',
|
||||
'sans-serif',
|
||||
],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,3 +10,19 @@ require (
|
||||
golang.org/x/crypto v0.31.0
|
||||
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
|
||||
)
|
||||
@@ -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=
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "============================================="
|
||||
echo "🧠 MindSift — Schnell-Installation"
|
||||
echo "============================================="
|
||||
|
||||
# 1. Prüfen ob Docker / Compose verfügbar ist
|
||||
COMPOSE_CMD=""
|
||||
if command -v docker &>/dev/null && docker compose version &>/dev/null; then
|
||||
COMPOSE_CMD="docker compose"
|
||||
elif command -v docker-compose &>/dev/null; then
|
||||
COMPOSE_CMD="docker-compose"
|
||||
elif command -v podman &>/dev/null && podman compose version &>/dev/null; then
|
||||
COMPOSE_CMD="podman compose"
|
||||
else
|
||||
echo "❌ Fehler: Docker Compose (oder Podman Compose) wurde nicht gefunden."
|
||||
echo "Bitte installiere Docker (https://docs.docker.com/get-docker/) und versuche es erneut."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INSTALL_DIR="${MINDSIFT_DIR:-mindsift}"
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
cd "${INSTALL_DIR}"
|
||||
|
||||
echo "📥 Lade Konfigurationsdateien herunter..."
|
||||
REPO_RAW_URL="https://git.dsit.me/sandrodach/MindSift/raw/branch/dev"
|
||||
|
||||
curl -fsSL "${REPO_RAW_URL}/docker-compose.yml" -o docker-compose.yml
|
||||
curl -fsSL "${REPO_RAW_URL}/.env.example" -o .env
|
||||
|
||||
# Zufälliges JWT-Secret generieren falls Standardwert vorhanden
|
||||
if grep -q "change_this_to_a_secure_random_key_min_32_chars" .env; then
|
||||
RANDOM_SECRET=$(head -c 32 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 32 || openssl rand -hex 16 2>/dev/null || date +%s%N)
|
||||
sed -i "s/change_this_to_a_secure_random_key_min_32_chars/${RANDOM_SECRET}/g" .env
|
||||
fi
|
||||
|
||||
echo "🚀 Starte MindSift Container..."
|
||||
${COMPOSE_CMD} up -d
|
||||
|
||||
echo ""
|
||||
echo "============================================="
|
||||
echo "✅ MindSift wurde erfolgreich gestartet!"
|
||||
echo ""
|
||||
echo "🌐 Öffne deinen Browser unter:"
|
||||
echo " http://localhost:8080"
|
||||
echo ""
|
||||
echo "💡 Beim ersten Aufruf wirst du automatisch durch"
|
||||
echo " die Ersteinrichtung deines Admin-Kontos geführt."
|
||||
echo "============================================="
|
||||
+2130
-120
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -14,6 +15,16 @@ type Config struct {
|
||||
BaseURL string
|
||||
AllowRegistration bool
|
||||
LogLevel string
|
||||
Debug bool
|
||||
DemoMode bool
|
||||
|
||||
// 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 {
|
||||
@@ -30,8 +41,25 @@ func Load() *Config {
|
||||
|
||||
jwtSecret := getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")
|
||||
baseURL := getEnv("BASE_URL", "http://localhost:"+port)
|
||||
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "true")) != "false"
|
||||
logLevel := getEnv("LOG_LEVEL", "info")
|
||||
allowReg := strings.ToLower(getEnv("ALLOW_REGISTRATION", "false")) == "true"
|
||||
|
||||
debugVal := strings.ToLower(getEnv("DEBUG", "false"))
|
||||
debug := debugVal == "true" || debugVal == "1" || debugVal == "yes" || debugVal == "on"
|
||||
|
||||
logLevel := strings.ToLower(getEnv("LOG_LEVEL", "info"))
|
||||
if debug {
|
||||
logLevel = "debug"
|
||||
}
|
||||
|
||||
demoVal := strings.ToLower(getEnv("MINDA_DEMO", getEnv("MINDSIFT_DEMO", getEnv("DEMO_MODE", "false"))))
|
||||
demoMode := demoVal == "true" || demoVal == "1" || demoVal == "yes" || demoVal == "on"
|
||||
|
||||
smtpHost := getEnv("SMTP_HOST", "")
|
||||
smtpPort := getEnv("SMTP_PORT", "587")
|
||||
smtpUser := getEnv("SMTP_USER", "")
|
||||
smtpPass := getEnv("SMTP_PASS", "")
|
||||
smtpFrom := getEnv("SMTP_FROM", "")
|
||||
smtpTLS := strings.ToLower(getEnv("SMTP_TLS", "true")) != "false"
|
||||
|
||||
return &Config{
|
||||
Port: port,
|
||||
@@ -41,6 +69,28 @@ func Load() *Config {
|
||||
BaseURL: baseURL,
|
||||
AllowRegistration: allowReg,
|
||||
LogLevel: logLevel,
|
||||
Debug: debug,
|
||||
DemoMode: demoMode,
|
||||
SMTPHost: smtpHost,
|
||||
SMTPPort: smtpPort,
|
||||
SMTPUser: smtpUser,
|
||||
SMTPPass: smtpPass,
|
||||
SMTPFrom: smtpFrom,
|
||||
SMTPTLS: smtpTLS,
|
||||
}
|
||||
}
|
||||
|
||||
// SlogLevel parses Config.LogLevel into a standard slog.Level.
|
||||
func (c *Config) SlogLevel() slog.Level {
|
||||
switch strings.ToLower(strings.TrimSpace(c.LogLevel)) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigLogLevelAndDebug(t *testing.T) {
|
||||
os.Setenv("PORT", "9090")
|
||||
defer os.Unsetenv("PORT")
|
||||
|
||||
// 1. Default info
|
||||
os.Unsetenv("LOG_LEVEL")
|
||||
os.Unsetenv("DEBUG")
|
||||
cfg := Load()
|
||||
if cfg.SlogLevel() != slog.LevelInfo {
|
||||
t.Fatalf("expected LevelInfo, got %v", cfg.SlogLevel())
|
||||
}
|
||||
|
||||
// 2. LOG_LEVEL=debug
|
||||
os.Setenv("LOG_LEVEL", "debug")
|
||||
cfg = Load()
|
||||
if cfg.SlogLevel() != slog.LevelDebug {
|
||||
t.Fatalf("expected LevelDebug, got %v", cfg.SlogLevel())
|
||||
}
|
||||
|
||||
// 3. LOG_LEVEL=warn
|
||||
os.Setenv("LOG_LEVEL", "warn")
|
||||
cfg = Load()
|
||||
if cfg.SlogLevel() != slog.LevelWarn {
|
||||
t.Fatalf("expected LevelWarn, got %v", cfg.SlogLevel())
|
||||
}
|
||||
|
||||
// 4. LOG_LEVEL=error
|
||||
os.Setenv("LOG_LEVEL", "error")
|
||||
cfg = Load()
|
||||
if cfg.SlogLevel() != slog.LevelError {
|
||||
t.Fatalf("expected LevelError, got %v", cfg.SlogLevel())
|
||||
}
|
||||
|
||||
// 5. DEBUG=true overrides LOG_LEVEL
|
||||
os.Setenv("LOG_LEVEL", "error")
|
||||
os.Setenv("DEBUG", "true")
|
||||
cfg = Load()
|
||||
if cfg.SlogLevel() != slog.LevelDebug {
|
||||
t.Fatalf("expected LevelDebug when DEBUG=true, got %v", cfg.SlogLevel())
|
||||
}
|
||||
|
||||
// 6. DEBUG=1 overrides LOG_LEVEL
|
||||
os.Setenv("DEBUG", "1")
|
||||
cfg = Load()
|
||||
if cfg.SlogLevel() != slog.LevelDebug {
|
||||
t.Fatalf("expected LevelDebug when DEBUG=1, got %v", cfg.SlogLevel())
|
||||
}
|
||||
os.Unsetenv("DEBUG")
|
||||
os.Unsetenv("LOG_LEVEL")
|
||||
}
|
||||
+380
-55
@@ -40,12 +40,86 @@ func ExtractTags(content string) []string {
|
||||
return tags
|
||||
}
|
||||
|
||||
// FormatGermanDateTime formats an ISO-8601 string into "25. Okt. 2026 - 15:40 Uhr"
|
||||
func FormatGermanDateTime(isoStr string) string {
|
||||
if isoStr == "" {
|
||||
return ""
|
||||
}
|
||||
var t time.Time
|
||||
var err error
|
||||
formats := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, f := range formats {
|
||||
if parsed, parseErr := time.Parse(f, isoStr); parseErr == nil {
|
||||
t = parsed
|
||||
err = nil
|
||||
break
|
||||
} else {
|
||||
err = parseErr
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return isoStr
|
||||
}
|
||||
|
||||
t = t.Local()
|
||||
|
||||
germanMonths := map[time.Month]string{
|
||||
time.January: "Jan.",
|
||||
time.February: "Feb.",
|
||||
time.March: "März",
|
||||
time.April: "Apr.",
|
||||
time.May: "Mai",
|
||||
time.June: "Juni",
|
||||
time.July: "Juli",
|
||||
time.August: "Aug.",
|
||||
time.September: "Sept.",
|
||||
time.October: "Okt.",
|
||||
time.November: "Nov.",
|
||||
time.December: "Dez.",
|
||||
}
|
||||
mStr := germanMonths[t.Month()]
|
||||
if mStr == "" {
|
||||
mStr = t.Format("Jan")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%d. %s %d - %02d:%02d Uhr", t.Day(), mStr, t.Year(), t.Hour(), t.Minute())
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
PasswordHash string `json:"-"`
|
||||
DisplayName string `json:"displayName"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username,omitempty"`
|
||||
PasswordHash string `json:"-"`
|
||||
DisplayName string `json:"displayName"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Role string `json:"role"` // 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 | 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"`
|
||||
}
|
||||
|
||||
type ThoughtStatus string
|
||||
@@ -57,6 +131,13 @@ const (
|
||||
StatusConverted ThoughtStatus = "converted"
|
||||
)
|
||||
|
||||
type SubTask struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
IsCompleted bool `json:"isCompleted"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
type Thought struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
@@ -69,53 +150,267 @@ type Thought struct {
|
||||
ConvertedType string `json:"convertedType,omitempty"` // task | document | list_item
|
||||
ConvertedRefID string `json:"convertedRefId,omitempty"` // referenced entity ID
|
||||
DueDate string `json:"dueDate,omitempty"`
|
||||
TaskListID string `json:"taskListId,omitempty"`
|
||||
KanbanStatus string `json:"kanbanStatus,omitempty"` // todo | in_progress | done
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Subtasks []SubTask `json:"subtasks"`
|
||||
AlarmSent bool `json:"alarmSent,omitempty"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
TriagedAt string `json:"triagedAt,omitempty"`
|
||||
}
|
||||
|
||||
type DocumentBlock struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider
|
||||
Content string `json:"content"`
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
type ThoughtReorderItem struct {
|
||||
ID string `json:"id"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
KanbanStatus *string `json:"kanbanStatus,omitempty"`
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
SpaceID string `json:"spaceId,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Content string `json:"content"`
|
||||
Blocks []DocumentBlock `json:"blocks"`
|
||||
Tags []string `json:"tags"`
|
||||
IsPinned bool `json:"isPinned"`
|
||||
IsArchived bool `json:"isArchived"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
type ListItemReorderItem struct {
|
||||
ID string `json:"id"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Category *string `json:"category,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
}
|
||||
|
||||
type SpaceMember struct {
|
||||
SpaceID string `json:"spaceId"`
|
||||
type TaskListShare struct {
|
||||
ID string `json:"id"`
|
||||
TaskListID string `json:"taskListId"`
|
||||
TargetType string `json:"targetType"` // team | user
|
||||
TargetID string `json:"targetId"`
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type TaskListMember struct {
|
||||
UserID string `json:"userId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"` // owner | editor | viewer
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Role string `json:"role"` // owner | member
|
||||
}
|
||||
|
||||
type KanbanColumn struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Color string `json:"color,omitempty"`
|
||||
IsDone bool `json:"isDone,omitempty"`
|
||||
}
|
||||
|
||||
type TaskList struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
SpaceID string `json:"spaceId,omitempty"`
|
||||
SpaceName string `json:"spaceName,omitempty"`
|
||||
TeamID string `json:"teamId,omitempty"`
|
||||
TeamName string `json:"teamName,omitempty"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Members []TaskListMember `json:"members,omitempty"`
|
||||
Shares []TaskListShare `json:"shares,omitempty"`
|
||||
Columns []KanbanColumn `json:"columns,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,omitempty"`
|
||||
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"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Role string `json:"role"` // admin | member
|
||||
JoinedAt string `json:"joinedAt"`
|
||||
}
|
||||
|
||||
type Space struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description"`
|
||||
InviteCode string `json:"inviteCode"`
|
||||
IsPersonal bool `json:"isPersonal"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
Members []SpaceMember `json:"members,omitempty"`
|
||||
ListsCount int `json:"listsCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
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 {
|
||||
ID string `json:"id"`
|
||||
DocumentID string `json:"documentId,omitempty"`
|
||||
Type string `json:"type"` // h1 | h2 | h3 | paragraph | todo | bullet | code | callout | divider | table | progress
|
||||
Content string `json:"content"`
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
Lang string `json:"lang,omitempty"`
|
||||
Level int `json:"level"`
|
||||
Position float64 `json:"position"`
|
||||
Collapsed bool `json:"collapsed,omitempty"`
|
||||
}
|
||||
|
||||
func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
|
||||
var sb strings.Builder
|
||||
for _, b := range blocks {
|
||||
lvl := b.Level
|
||||
if lvl < 0 {
|
||||
lvl = 0
|
||||
}
|
||||
indent := strings.Repeat(" ", lvl)
|
||||
switch b.Type {
|
||||
case "h1":
|
||||
sb.WriteString(indent + "- # " + b.Content + "\n")
|
||||
case "h2":
|
||||
sb.WriteString(indent + "- ## " + b.Content + "\n")
|
||||
case "h3":
|
||||
sb.WriteString(indent + "- ### " + b.Content + "\n")
|
||||
case "todo":
|
||||
mark := "[ ]"
|
||||
if b.Checked {
|
||||
mark = "[x]"
|
||||
}
|
||||
sb.WriteString(indent + "- " + mark + " " + b.Content + "\n")
|
||||
case "code":
|
||||
sb.WriteString(indent + "- ```" + b.Lang + "\n" + b.Content + "\n" + indent + " ```\n")
|
||||
case "divider":
|
||||
sb.WriteString(indent + "- ---\n")
|
||||
default:
|
||||
sb.WriteString(indent + "- " + b.Content + "\n")
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
type DocBlocksPatchRequest struct {
|
||||
Upserted []DocumentBlock `json:"upserted,omitempty"`
|
||||
DeletedIDs []string `json:"deletedIds,omitempty"`
|
||||
BlockOrder []string `json:"blockOrder,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
}
|
||||
|
||||
type DocBlocksPatchEvent struct {
|
||||
DocID string `json:"docId"`
|
||||
Upserted []DocumentBlock `json:"upserted,omitempty"`
|
||||
DeletedIDs []string `json:"deletedIds,omitempty"`
|
||||
BlockOrder []string `json:"blockOrder,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
UpdatedBy string `json:"updatedBy"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type DocShare struct {
|
||||
ID string `json:"id"`
|
||||
DocID string `json:"docId"`
|
||||
TargetType string `json:"targetType,omitempty"` // user | team
|
||||
TargetID string `json:"targetId,omitempty"`
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
UserEmail string `json:"userEmail,omitempty"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Permission string `json:"permission"` // read | write
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type DocLockInfo struct {
|
||||
DocID string `json:"docId"`
|
||||
UserID string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type Document struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
SpaceID string `json:"spaceId,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
Content string `json:"content"`
|
||||
MarkdownCache string `json:"markdownCache,omitempty"`
|
||||
Blocks []DocumentBlock `json:"blocks"`
|
||||
Tags []string `json:"tags"`
|
||||
FolderID string `json:"folderId,omitempty"`
|
||||
FolderPath string `json:"folderPath,omitempty"`
|
||||
IsPinned bool `json:"isPinned"`
|
||||
IsArchived bool `json:"isArchived"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
|
||||
// Collaboration & Permissions
|
||||
Permission string `json:"permission"` // owner | write | read
|
||||
IsOwner bool `json:"isOwner"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
SharedWith []DocShare `json:"sharedWith,omitempty"`
|
||||
SharedFolderID string `json:"sharedFolderId,omitempty"`
|
||||
SubFolderPath string `json:"subFolderPath,omitempty"`
|
||||
IsSharedFolder bool `json:"isSharedFolder,omitempty"`
|
||||
FolderPermission string `json:"folderPermission,omitempty"`
|
||||
}
|
||||
|
||||
type TagSummary struct {
|
||||
Tag string `json:"tag"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
type MatchedTagBlock struct {
|
||||
RootBlock DocumentBlock `json:"rootBlock"`
|
||||
Children []DocumentBlock `json:"children"`
|
||||
}
|
||||
|
||||
type TagDocumentGroup struct {
|
||||
DocID string `json:"docId"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
FolderPath string `json:"folderPath"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Permission string `json:"permission"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
MatchedBlocks []MatchedTagBlock `json:"matchedBlocks"`
|
||||
}
|
||||
|
||||
type TagDetails struct {
|
||||
Tag string `json:"tag"`
|
||||
NoteContent string `json:"noteContent"`
|
||||
NoteUpdatedAt string `json:"noteUpdatedAt,omitempty"`
|
||||
Documents []TagDocumentGroup `json:"documents"`
|
||||
TotalMatches int `json:"totalMatches"`
|
||||
}
|
||||
|
||||
type ListShare struct {
|
||||
ID string `json:"id"`
|
||||
ListID string `json:"listId"`
|
||||
TargetType string `json:"targetType"` // team | user
|
||||
TargetID string `json:"targetId"`
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Role string `json:"role"` // editor | viewer
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type ListItem struct {
|
||||
@@ -133,14 +428,18 @@ type ListItem struct {
|
||||
}
|
||||
|
||||
type SharedList struct {
|
||||
ID string `json:"id"`
|
||||
SpaceID string `json:"spaceId"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
ListType string `json:"listType"` // shopping | checklist | vacation
|
||||
Items []ListItem `json:"items"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"userId"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Role string `json:"role,omitempty"` // editor | viewer
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon"`
|
||||
ListType string `json:"listType"` // shopping | checklist | vacation
|
||||
Items []ListItem `json:"items"`
|
||||
Shares []ListShare `json:"shares,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
@@ -157,19 +456,19 @@ type APIKey struct {
|
||||
|
||||
type WSMessage struct {
|
||||
Event string `json:"event"` // ITEM_CREATED | ITEM_UPDATED | ITEM_TOGGLED | ITEM_DELETED | LIST_CLEARED | USER_PRESENCE
|
||||
SpaceID string `json:"space_id"`
|
||||
ListID string `json:"list_id,omitempty"`
|
||||
ListID string `json:"list_id"`
|
||||
Payload interface{} `json:"payload"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
}
|
||||
|
||||
type DashboardStats struct {
|
||||
InboxCount int `json:"inboxCount"`
|
||||
TasksOpenCount int `json:"tasksOpenCount"`
|
||||
TasksDoneCount int `json:"tasksDoneCount"`
|
||||
DocsCount int `json:"docsCount"`
|
||||
ListsCount int `json:"listsCount"`
|
||||
DiscardedCount int `json:"discardedCount"`
|
||||
InboxCount int `json:"inboxCount"`
|
||||
TasksOpenCount int `json:"tasksOpenCount"`
|
||||
TasksDoneCount int `json:"tasksDoneCount"`
|
||||
DocsCount int `json:"docsCount"`
|
||||
DocsTodosOpenCount int `json:"docsTodosOpenCount"`
|
||||
ListsCount int `json:"listsCount"`
|
||||
DiscardedCount int `json:"discardedCount"`
|
||||
}
|
||||
|
||||
type FullExport struct {
|
||||
@@ -178,6 +477,32 @@ type FullExport struct {
|
||||
User User `json:"user"`
|
||||
Thoughts []Thought `json:"thoughts"`
|
||||
Documents []Document `json:"documents"`
|
||||
Spaces []Space `json:"spaces"`
|
||||
Lists []SharedList `json:"lists"`
|
||||
TaskLists []TaskList `json:"taskLists,omitempty"`
|
||||
}
|
||||
|
||||
type SharedFolder struct {
|
||||
ID string `json:"id"`
|
||||
OwnerID string `json:"ownerId"`
|
||||
OwnerName string `json:"ownerName,omitempty"`
|
||||
Name string `json:"name"`
|
||||
MountPath string `json:"mountPath"`
|
||||
IsOwner bool `json:"isOwner"`
|
||||
Permission string `json:"permission"` // owner | write | read
|
||||
Shares []FolderShare `json:"shares,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type FolderShare struct {
|
||||
ID string `json:"id"`
|
||||
FolderID string `json:"folderId"`
|
||||
TargetType string `json:"targetType"` // team | user
|
||||
TargetID string `json:"targetId"`
|
||||
TargetName string `json:"targetName,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
AvatarURL string `json:"avatarUrl,omitempty"`
|
||||
Permission string `json:"permission"` // read | write
|
||||
CreatedBy string `json:"createdBy"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
+4313
-348
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,7 @@ import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/auth"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
@@ -38,7 +39,7 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
t.Fatalf("UpdateThoughtTriage failed: %v", err)
|
||||
}
|
||||
@@ -46,12 +47,8 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("expected converted document, got status=%s type=%s ref=%s", triaged.Status, triaged.ConvertedType, triaged.ConvertedRefID)
|
||||
}
|
||||
|
||||
// 3. Test Shared Space & Shopping List
|
||||
sp, err := store.CreateSpace(user.ID, "Test WG", "🍕", "Test Space", false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSpace failed: %v", err)
|
||||
}
|
||||
lst, err := store.CreateList(sp.ID, "REWE Einkauf", "🛒", "shopping")
|
||||
// 3. Test Standalone Shopping List & Sharing
|
||||
lst, err := store.CreateList(user.ID, "REWE Einkauf", "🛒", "shopping")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateList failed: %v", err)
|
||||
}
|
||||
@@ -64,6 +61,28 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
t.Fatalf("ToggleListItem failed: %v", err)
|
||||
}
|
||||
|
||||
// Test AddListShare & RemoveListShare
|
||||
u2, err := store.CreateUser("collab_space@example.com", "hash", "Collab User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser u2 failed: %v", err)
|
||||
}
|
||||
share, err := store.AddListShare(user.ID, lst.ID, "user", u2.ID, "editor")
|
||||
if err != nil || share.TargetID != u2.ID {
|
||||
t.Fatalf("AddListShare failed: %v", err)
|
||||
}
|
||||
shares, err := store.GetListShares(lst.ID)
|
||||
if err != nil || len(shares) != 1 {
|
||||
t.Fatalf("expected 1 share, got %d", len(shares))
|
||||
}
|
||||
err = store.RemoveListShare(user.ID, lst.ID, share.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveListShare failed: %v", err)
|
||||
}
|
||||
sharesAfter, _ := store.GetListShares(lst.ID)
|
||||
if len(sharesAfter) != 0 {
|
||||
t.Fatalf("expected 0 shares after remove, got %d", len(sharesAfter))
|
||||
}
|
||||
|
||||
// 4. Test Webhook API Key generation & validation
|
||||
apiKey, err := store.CreateAPIKey(user.ID, "Home Assistant")
|
||||
if err != nil {
|
||||
@@ -73,4 +92,661 @@ func TestStoreLifecycle(t *testing.T) {
|
||||
if err != nil || validatedUser.ID != user.ID {
|
||||
t.Fatalf("ValidateAPIKeyAndGetUser failed: %v", err)
|
||||
}
|
||||
|
||||
// 5. Test TaskLists CRUD
|
||||
taskList, err := store.CreateTaskList(user.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)
|
||||
}
|
||||
|
||||
// Test TaskList Sharing with Team
|
||||
shareTeam, err := store.AddTaskListShare(taskList.ID, "team", team.ID, user.ID)
|
||||
if err != nil || shareTeam.ID == "" {
|
||||
t.Fatalf("AddTaskListShare team failed: %v", err)
|
||||
}
|
||||
tlShares, err := store.GetTaskListShares(taskList.ID)
|
||||
if err != nil || len(tlShares) == 0 {
|
||||
t.Fatalf("GetTaskListShares failed: %v", err)
|
||||
}
|
||||
fetchedTL, err := store.GetTaskListByID(user.ID, taskList.ID)
|
||||
if err != nil || len(fetchedTL.Shares) == 0 {
|
||||
t.Fatalf("GetTaskListByID with shares 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)
|
||||
}
|
||||
|
||||
// 8. Test Document Sharing & Permissions
|
||||
user2, err := store.CreateUser("collab@example.com", "hash", "Collab User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser collab failed: %v", err)
|
||||
}
|
||||
newDoc, err := store.CreateDocument(user.ID, "", "Test Note", "📄", "", []domain.DocumentBlock{
|
||||
{ID: "blk_1", Type: "bullet", Content: "Geheim", Level: 0},
|
||||
}, []string{"test"}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
// Share with user2 as "read"
|
||||
docShare, err := store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "read")
|
||||
if err != nil || docShare.Permission != "read" {
|
||||
t.Fatalf("CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
// user2 can get document
|
||||
docForUser2, err := store.GetDocumentByID(user2.ID, newDoc.ID)
|
||||
if err != nil || docForUser2.Permission != "read" || docForUser2.IsOwner {
|
||||
t.Fatalf("GetDocumentByID for user2 failed or wrong permission: %v", err)
|
||||
}
|
||||
|
||||
// user2 tries to edit read-only doc -> must fail
|
||||
updatedTitle := "Hacked Title"
|
||||
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
|
||||
if err == nil {
|
||||
t.Fatalf("expected UpdateDocument to fail for read-only user, but succeeded")
|
||||
}
|
||||
|
||||
// Upgrade to write permission
|
||||
_, err = store.CreateDocShare(user.ID, newDoc.ID, user2.ID, "write")
|
||||
if err != nil {
|
||||
t.Fatalf("Upgrade CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
// user2 edits writeable doc -> must succeed
|
||||
_, err = store.UpdateDocument(user2.ID, newDoc.ID, &updatedTitle, nil, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateDocument failed for write collaborator: %v", err)
|
||||
}
|
||||
|
||||
// Remove share
|
||||
err = store.DeleteDocShare(user.ID, newDoc.ID, user2.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteDocShare failed: %v", err)
|
||||
}
|
||||
_, err = store.GetDocumentByID(user2.ID, newDoc.ID)
|
||||
if err == nil {
|
||||
t.Fatalf("expected GetDocumentByID to fail after share removed, but succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerUserDocPinning(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "pin_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
user1, _ := store.CreateUser("user1@example.com", "hash", "User 1", "admin")
|
||||
user2, _ := store.CreateUser("user2@example.com", "hash", "User 2", "member")
|
||||
|
||||
// user1 creates document and pins it
|
||||
doc, err := store.CreateDocument(user1.ID, "", "Doc 1", "📝", "", []domain.DocumentBlock{
|
||||
{ID: "b1", Type: "text", Content: "Hello"},
|
||||
}, []string{"test"}, true)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
if !doc.IsPinned {
|
||||
t.Fatalf("expected doc to be pinned for user1")
|
||||
}
|
||||
|
||||
// Share with user2 as read-only
|
||||
_, err = store.CreateDocShare(user1.ID, doc.ID, user2.ID, "read")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
// user2 reads document: should NOT be pinned for user2
|
||||
docForUser2, err := store.GetDocumentByID(user2.ID, doc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocumentByID user2 failed: %v", err)
|
||||
}
|
||||
if docForUser2.IsPinned {
|
||||
t.Fatalf("expected doc to NOT be pinned for user2 initially")
|
||||
}
|
||||
|
||||
// user2 pins the shared document (even with read-only access)
|
||||
pinTrue := true
|
||||
updatedDoc2, err := store.UpdateDocument(user2.ID, doc.ID, nil, nil, nil, nil, nil, &pinTrue)
|
||||
if err != nil {
|
||||
t.Fatalf("user2 pinning read-only doc failed: %v", err)
|
||||
}
|
||||
if !updatedDoc2.IsPinned {
|
||||
t.Fatalf("expected doc to be pinned for user2")
|
||||
}
|
||||
|
||||
// Verify user1's doc is still pinned
|
||||
docForUser1, err := store.GetDocumentByID(user1.ID, doc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocumentByID user1 failed: %v", err)
|
||||
}
|
||||
if !docForUser1.IsPinned {
|
||||
t.Fatalf("expected doc to still be pinned for user1")
|
||||
}
|
||||
|
||||
// user1 unpins the doc
|
||||
pinFalse := false
|
||||
_, err = store.UpdateDocument(user1.ID, doc.ID, nil, nil, nil, nil, nil, &pinFalse)
|
||||
if err != nil {
|
||||
t.Fatalf("user1 unpinning doc failed: %v", err)
|
||||
}
|
||||
|
||||
// user1 doc is now unpinned, but user2 doc must STILL be pinned!
|
||||
docForUser1, _ = store.GetDocumentByID(user1.ID, doc.ID)
|
||||
if docForUser1.IsPinned {
|
||||
t.Fatalf("expected doc to be unpinned for user1")
|
||||
}
|
||||
docForUser2, _ = store.GetDocumentByID(user2.ID, doc.ID)
|
||||
if !docForUser2.IsPinned {
|
||||
t.Fatalf("expected doc to remain pinned for user2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDemoAccountsAndLoginLookup(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "demo_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
if err := store.EnsureDemoAccounts(); err != nil {
|
||||
t.Fatalf("EnsureDemoAccounts failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Lookup by username "admin"
|
||||
adminUser, err := store.GetUserByLogin("admin")
|
||||
if err != nil || adminUser == nil {
|
||||
t.Fatalf("expected to find admin user by username 'admin', got %v", err)
|
||||
}
|
||||
if adminUser.Role != "admin" {
|
||||
t.Fatalf("expected role 'admin', got %s", adminUser.Role)
|
||||
}
|
||||
if !auth.CheckPassword("admin", adminUser.PasswordHash) {
|
||||
t.Fatalf("admin password check failed")
|
||||
}
|
||||
|
||||
// 2. Lookup by email "admin@mindsift.local"
|
||||
adminByEmail, err := store.GetUserByLogin("admin@mindsift.local")
|
||||
if err != nil || adminByEmail.ID != adminUser.ID {
|
||||
t.Fatalf("lookup by email failed: got %v", err)
|
||||
}
|
||||
|
||||
// 3. Lookup with case insensitivity "ADMIN"
|
||||
adminCaps, err := store.GetUserByLogin("ADMIN")
|
||||
if err != nil || adminCaps.ID != adminUser.ID {
|
||||
t.Fatalf("lookup by uppercase failed: got %v", err)
|
||||
}
|
||||
|
||||
// 4. Lookup by username "user"
|
||||
normalUser, err := store.GetUserByLogin("user")
|
||||
if err != nil || normalUser == nil {
|
||||
t.Fatalf("expected to find user by username 'user', got %v", err)
|
||||
}
|
||||
if normalUser.Role != "member" {
|
||||
t.Fatalf("expected role 'member', got %s", normalUser.Role)
|
||||
}
|
||||
if !auth.CheckPassword("user", normalUser.PasswordHash) {
|
||||
t.Fatalf("user password check failed")
|
||||
}
|
||||
|
||||
// 5. Ensure re-running is idempotent and preserves/refreshes credentials
|
||||
if err := store.EnsureDemoAccounts(); err != nil {
|
||||
t.Fatalf("second EnsureDemoAccounts call failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFolderManagementAndDeletion(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "folder_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
user, err := store.CreateUser("folderuser@example.com", "hash", "Folder User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Create folders
|
||||
f1, err := store.CreateFolder(user.ID, "Projekte/MindSift")
|
||||
if err != nil || f1 != "Projekte/MindSift" {
|
||||
t.Fatalf("CreateFolder failed: %v, f1=%s", err, f1)
|
||||
}
|
||||
f2, err := store.CreateFolder(user.ID, "Archiv")
|
||||
if err != nil || f2 != "Archiv" {
|
||||
t.Fatalf("CreateFolder Archiv failed: %v", err)
|
||||
}
|
||||
|
||||
// 2. Create document in subfolder
|
||||
doc1, err := store.CreateDocument(user.ID, "", "Doc in MindSift", "📄", "Projekte/MindSift", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
doc2, err := store.CreateDocument(user.ID, "", "SubDoc", "📝", "Projekte/MindSift/Sub", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument 2 failed: %v", err)
|
||||
}
|
||||
|
||||
// 3. GetFolders
|
||||
folders, err := store.GetFolders(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFolders failed: %v", err)
|
||||
}
|
||||
expected := map[string]bool{
|
||||
"Archiv": true,
|
||||
"Projekte": true,
|
||||
"Projekte/MindSift": true,
|
||||
"Projekte/MindSift/Sub": true,
|
||||
}
|
||||
for _, f := range folders {
|
||||
delete(expected, f)
|
||||
}
|
||||
if len(expected) > 0 {
|
||||
t.Fatalf("GetFolders missing expected folders: %v (got %v)", expected, folders)
|
||||
}
|
||||
|
||||
// 4. DeleteFolder "Projekte/MindSift" -> should remove doc1 & doc2
|
||||
deletedDocs, err := store.DeleteFolder(user.ID, "Projekte/MindSift")
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteFolder failed: %v", err)
|
||||
}
|
||||
if len(deletedDocs) != 2 {
|
||||
t.Fatalf("expected 2 deleted docs, got %d (%v)", len(deletedDocs), deletedDocs)
|
||||
}
|
||||
|
||||
// Verify docs are gone from DB
|
||||
_, err = store.GetDocumentByID(user.ID, doc1.ID)
|
||||
if err == nil {
|
||||
t.Fatalf("expected doc1 to be deleted")
|
||||
}
|
||||
_, err = store.GetDocumentByID(user.ID, doc2.ID)
|
||||
if err == nil {
|
||||
t.Fatalf("expected doc2 to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceBlockHashtagsAndDetails(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "tags_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
user, err := store.CreateUser("taguser@example.com", "hash", "Tag User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
|
||||
// Create document with nested bullet points and hashtags
|
||||
blocks := []domain.DocumentBlock{
|
||||
{ID: "b0", Type: "bullet", Content: "Einleitung ohne Tag", Level: 0},
|
||||
{ID: "b1", Type: "bullet", Content: "Einkauf planen #Kühlregal #Wocheneinkauf", Level: 0},
|
||||
{ID: "b2", Type: "bullet", Content: "Milch 3.8%", Level: 1},
|
||||
{ID: "b3", Type: "bullet", Content: "Gouda alt", Level: 1},
|
||||
{ID: "b4", Type: "bullet", Content: "Zusatzinfo: Aus Weidemilch", Level: 2},
|
||||
{ID: "b5", Type: "bullet", Content: "Anderes Thema ohne Bezug", Level: 0},
|
||||
}
|
||||
|
||||
doc, err := store.CreateDocument(user.ID, "", "Einkaufszettel", "🛒", "Haushalt", blocks, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Check GetWorkspaceTags
|
||||
tags, err := store.GetWorkspaceTags(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkspaceTags failed: %v", err)
|
||||
}
|
||||
if len(tags) != 2 {
|
||||
t.Fatalf("expected 2 tags, got %d (%v)", len(tags), tags)
|
||||
}
|
||||
tagMap := make(map[string]int)
|
||||
for _, tg := range tags {
|
||||
tagMap[tg.Tag] = tg.Count
|
||||
}
|
||||
if tagMap["kühlregal"] != 1 || tagMap["wocheneinkauf"] != 1 {
|
||||
t.Fatalf("unexpected tag counts: %v", tagMap)
|
||||
}
|
||||
|
||||
// 2. Check GetTagDetails for #Kühlregal
|
||||
details, err := store.GetTagDetails(user.ID, "#Kühlregal")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTagDetails failed: %v", err)
|
||||
}
|
||||
if details.Tag != "kühlregal" {
|
||||
t.Fatalf("expected tag 'kühlregal', got %s", details.Tag)
|
||||
}
|
||||
if details.TotalMatches != 1 {
|
||||
t.Fatalf("expected 1 match, got %d", details.TotalMatches)
|
||||
}
|
||||
if len(details.Documents) != 1 {
|
||||
t.Fatalf("expected 1 document, got %d", len(details.Documents))
|
||||
}
|
||||
docGroup := details.Documents[0]
|
||||
if docGroup.DocID != doc.ID || docGroup.Title != "Einkaufszettel" {
|
||||
t.Fatalf("unexpected doc group: %+v", docGroup)
|
||||
}
|
||||
if len(docGroup.MatchedBlocks) != 1 {
|
||||
t.Fatalf("expected 1 matched block, got %d", len(docGroup.MatchedBlocks))
|
||||
}
|
||||
matched := docGroup.MatchedBlocks[0]
|
||||
if matched.RootBlock.ID != "b1" {
|
||||
t.Fatalf("expected root block b1, got %s", matched.RootBlock.ID)
|
||||
}
|
||||
// Children should be b2, b3, b4 (all subsequent blocks with Level > 0 until b5 where Level == 0)
|
||||
if len(matched.Children) != 3 {
|
||||
t.Fatalf("expected 3 children (b2, b3, b4), got %d (%v)", len(matched.Children), matched.Children)
|
||||
}
|
||||
if matched.Children[0].ID != "b2" || matched.Children[1].ID != "b3" || matched.Children[2].ID != "b4" {
|
||||
t.Fatalf("unexpected children IDs: %v", matched.Children)
|
||||
}
|
||||
|
||||
// 3. Update document: remove #Wocheneinkauf, add #Vorrat
|
||||
updatedBlocks := []domain.DocumentBlock{
|
||||
{ID: "b1", Type: "bullet", Content: "Einkauf planen #Kühlregal #Vorrat", Level: 0},
|
||||
}
|
||||
_, err = store.UpdateDocument(user.ID, doc.ID, nil, nil, nil, &updatedBlocks, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
tagsAfter, err := store.GetWorkspaceTags(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkspaceTags after update failed: %v", err)
|
||||
}
|
||||
tagMapAfter := make(map[string]int)
|
||||
for _, tg := range tagsAfter {
|
||||
tagMapAfter[tg.Tag] = tg.Count
|
||||
}
|
||||
if tagMapAfter["wocheneinkauf"] != 0 {
|
||||
t.Fatalf("expected wocheneinkauf to be removed, got %v", tagMapAfter)
|
||||
}
|
||||
if tagMapAfter["vorrat"] != 1 || tagMapAfter["kühlregal"] != 1 {
|
||||
t.Fatalf("expected vorrat and kühlregal, got %v", tagMapAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDatabaseFirstWorkspaceArchitecture(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "test_db_first.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
userA, err := store.EnsureDefaultUser()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureDefaultUser failed: %v", err)
|
||||
}
|
||||
userB, err := store.CreateUser("userb@example.com", "hash", "User B", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser userB failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Create a document in User A's folder "Privat" and share with User B (write)
|
||||
initialBlocks := []domain.DocumentBlock{
|
||||
{ID: "b1", Type: "bullet", Content: "Zeile 1 Original", Level: 0},
|
||||
{ID: "b2", Type: "bullet", Content: "Zeile 2 Original", Level: 0},
|
||||
}
|
||||
doc, err := store.CreateDocument(userA.ID, "", "Gemeinsame Notiz", "📝", "Privat", initialBlocks, []string{"folder:Privat", "notiz"}, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
// Verify folder:* tag was stripped
|
||||
for _, tg := range doc.Tags {
|
||||
if tg == "folder:Privat" {
|
||||
t.Fatalf("expected folder:* tag to be stripped, got tags: %v", doc.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = store.CreateDocShare(userA.ID, doc.ID, userB.ID, "write")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocShare failed: %v", err)
|
||||
}
|
||||
|
||||
// User B moves the individually shared note into "Arbeit/Eingang"
|
||||
_, err = store.MoveDocument(userB.ID, doc.ID, "Arbeit/Eingang")
|
||||
if err != nil {
|
||||
t.Fatalf("MoveDocument by userB failed: %v", err)
|
||||
}
|
||||
|
||||
// User B edits the document via PatchDocumentBlocks (modifying b2 and adding b3)
|
||||
// Simultaneously, User A edits b1 and adds b4 using a slightly stale order
|
||||
_, _, err = store.PatchDocumentBlocks(userB.ID, doc.ID, domain.DocBlocksPatchRequest{
|
||||
Upserted: []domain.DocumentBlock{
|
||||
{ID: "b2", Type: "bullet", Content: "Zeile 2 von B bearbeitet", Level: 0},
|
||||
{ID: "b3", Type: "bullet", Content: "Zeile 3 neu von B", Level: 0},
|
||||
},
|
||||
BlockOrder: []string{"b1", "b2", "b3"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PatchDocumentBlocks by userB failed: %v", err)
|
||||
}
|
||||
|
||||
// User A's client only knew about b1, b2 when sending its patch (adds b4)
|
||||
patchedDocA, _, err := store.PatchDocumentBlocks(userA.ID, doc.ID, domain.DocBlocksPatchRequest{
|
||||
Upserted: []domain.DocumentBlock{
|
||||
{ID: "b1", Type: "bullet", Content: "Zeile 1 von A bearbeitet", Level: 0},
|
||||
{ID: "b4", Type: "bullet", Content: "Zeile 4 neu von A", Level: 0},
|
||||
},
|
||||
BlockOrder: []string{"b1", "b2", "b4"}, // stale order without b3!
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PatchDocumentBlocks by userA failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify NO blocks were lost! All 4 blocks (b1, b2, b3, b4) must exist with both users' edits preserved
|
||||
if len(patchedDocA.Blocks) != 4 {
|
||||
t.Fatalf("expected 4 blocks after concurrent patches, got %d: %+v", len(patchedDocA.Blocks), patchedDocA.Blocks)
|
||||
}
|
||||
blockContent := make(map[string]string)
|
||||
for _, b := range patchedDocA.Blocks {
|
||||
blockContent[b.ID] = b.Content
|
||||
}
|
||||
if blockContent["b1"] != "Zeile 1 von A bearbeitet" {
|
||||
t.Fatalf("expected b1 updated by A, got %q", blockContent["b1"])
|
||||
}
|
||||
if blockContent["b2"] != "Zeile 2 von B bearbeitet" {
|
||||
t.Fatalf("expected b2 updated by B to be preserved, got %q", blockContent["b2"])
|
||||
}
|
||||
if blockContent["b3"] != "Zeile 3 neu von B" {
|
||||
t.Fatalf("expected b3 added by B to be preserved even with stale order from A, got %q", blockContent["b3"])
|
||||
}
|
||||
if blockContent["b4"] != "Zeile 4 neu von A" {
|
||||
t.Fatalf("expected b4 added by A, got %q", blockContent["b4"])
|
||||
}
|
||||
|
||||
// Verify folder independence: User A still sees "Privat", User B still sees "Arbeit/Eingang"
|
||||
docForA, err := store.GetDocumentByID(userA.ID, doc.ID)
|
||||
if err != nil || docForA.FolderPath != "Privat" {
|
||||
t.Fatalf("expected User A FolderPath 'Privat', got %q (err=%v)", docForA.FolderPath, err)
|
||||
}
|
||||
docForB, err := store.GetDocumentByID(userB.ID, doc.ID)
|
||||
if err != nil || docForB.FolderPath != "Arbeit/Eingang" {
|
||||
t.Fatalf("expected User B FolderPath 'Arbeit/Eingang', got %q (err=%v)", docForB.FolderPath, err)
|
||||
}
|
||||
|
||||
foldersA, err := store.GetFolders(userA.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFolders for userA failed: %v", err)
|
||||
}
|
||||
for _, f := range foldersA {
|
||||
if f == "Arbeit" || f == "Arbeit/Eingang" {
|
||||
t.Fatalf("User B's private folder leaked into User A's folder list: %v", foldersA)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Shared Folder & Empty Subfolder Sync
|
||||
_, err = store.CreateFolder(userA.ID, "TeamProjekt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFolder TeamProjekt failed: %v", err)
|
||||
}
|
||||
sf, err := store.CreateSharedFolder(userA.ID, "TeamProjekt", "TeamProjekt")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSharedFolder failed: %v", err)
|
||||
}
|
||||
_, err = store.CreateFolderShare(userA.ID, sf.ID, "user", userB.ID, "write")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFolderShare failed: %v", err)
|
||||
}
|
||||
|
||||
// User B moves their mount root of the shared folder to "ExterneProjekte/TeamProjekt"
|
||||
err = store.UpdateFolderMount(userB.ID, sf.ID, "ExterneProjekte/TeamProjekt")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateFolderMount failed: %v", err)
|
||||
}
|
||||
|
||||
// User A creates an EMPTY subfolder inside "TeamProjekt/Konzepte"
|
||||
_, err = store.CreateFolder(userA.ID, "TeamProjekt/Konzepte")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateFolder TeamProjekt/Konzepte failed: %v", err)
|
||||
}
|
||||
|
||||
// User B must immediately see "ExterneProjekte/TeamProjekt/Konzepte" in GetFolders without any document inside!
|
||||
foldersB, err := store.GetFolders(userB.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFolders for userB failed: %v", err)
|
||||
}
|
||||
foundSubfolderForB := false
|
||||
for _, f := range foldersB {
|
||||
if f == "ExterneProjekte/TeamProjekt/Konzepte" {
|
||||
foundSubfolderForB = true
|
||||
}
|
||||
}
|
||||
if !foundSubfolderForB {
|
||||
t.Fatalf("expected User B to see empty shared subfolder 'ExterneProjekte/TeamProjekt/Konzepte', got folders: %v", foldersB)
|
||||
}
|
||||
|
||||
// 3. MoveFolder inside a shared folder
|
||||
newPath, _, err := store.MoveFolder(userA.ID, "TeamProjekt/Konzepte", "TeamProjekt/Archiv")
|
||||
if err != nil {
|
||||
t.Fatalf("MoveFolder failed: %v", err)
|
||||
}
|
||||
if newPath != "TeamProjekt/Archiv/Konzepte" {
|
||||
t.Fatalf("expected newPath 'TeamProjekt/Archiv/Konzepte', got %q", newPath)
|
||||
}
|
||||
|
||||
// User B must now see "ExterneProjekte/TeamProjekt/Archiv/Konzepte"
|
||||
foldersBAfterMove, err := store.GetFolders(userB.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetFolders after MoveFolder failed: %v", err)
|
||||
}
|
||||
foundMovedForB := false
|
||||
for _, f := range foldersBAfterMove {
|
||||
if f == "ExterneProjekte/TeamProjekt/Archiv/Konzepte" {
|
||||
foundMovedForB = true
|
||||
}
|
||||
}
|
||||
if !foundMovedForB {
|
||||
t.Fatalf("expected User B to see moved shared subfolder 'ExterneProjekte/TeamProjekt/Archiv/Konzepte', got %v", foldersBAfterMove)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushDocumentStateAndSchema(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dbPath := filepath.Join(tmpDir, "tabula_rasa_test.db")
|
||||
|
||||
store, err := NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore failed: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
user, err := store.CreateUser("outliner@example.com", "hash", "Outliner User", "member")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateUser failed: %v", err)
|
||||
}
|
||||
|
||||
doc, err := store.CreateDocument(user.ID, "", "Initial Note", "📝", "Projekte", nil, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateDocument failed: %v", err)
|
||||
}
|
||||
|
||||
blocks := []domain.DocumentBlock{
|
||||
{
|
||||
ID: "blk_root_1",
|
||||
Type: "text",
|
||||
Content: "Architektur Übersicht #backend #go",
|
||||
Level: 0,
|
||||
},
|
||||
{
|
||||
ID: "blk_child_1",
|
||||
Type: "todo",
|
||||
Content: "SQLite WAL Modus aktivieren #sqlite",
|
||||
Level: 1,
|
||||
Checked: true,
|
||||
},
|
||||
}
|
||||
|
||||
if err := store.FlushDocumentState(doc.ID, "Architektur Notiz", blocks); err != nil {
|
||||
t.Fatalf("FlushDocumentState failed: %v", err)
|
||||
}
|
||||
|
||||
updatedDoc, err := store.GetDocumentByID(user.ID, doc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDocumentByID failed: %v", err)
|
||||
}
|
||||
if updatedDoc.Title != "Architektur Notiz" {
|
||||
t.Fatalf("expected title 'Architektur Notiz', got '%s'", updatedDoc.Title)
|
||||
}
|
||||
if len(updatedDoc.Blocks) != 2 {
|
||||
t.Fatalf("expected 2 blocks, got %d", len(updatedDoc.Blocks))
|
||||
}
|
||||
if updatedDoc.Blocks[1].Level != 1 || updatedDoc.Blocks[1].Position != 2000 {
|
||||
t.Fatalf("expected block[1] level=1 position=2000, got level=%d position=%v", updatedDoc.Blocks[1].Level, updatedDoc.Blocks[1].Position)
|
||||
}
|
||||
if updatedDoc.MarkdownCache == "" {
|
||||
t.Fatalf("expected markdown_cache to be populated")
|
||||
}
|
||||
|
||||
tags, err := store.GetWorkspaceTags(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWorkspaceTags failed: %v", err)
|
||||
}
|
||||
if len(tags) != 3 {
|
||||
t.Fatalf("expected 3 extracted tags in block_tags (#backend, #go, #sqlite), got %d: %+v", len(tags), tags)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,736 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// CursorPresence represents an active user's ephemeral cursor position in a document room.
|
||||
type CursorPresence struct {
|
||||
ClientID string `json:"clientId"`
|
||||
UserID string `json:"userId"`
|
||||
UserName string `json:"userName"`
|
||||
UserColor string `json:"userColor"`
|
||||
BlockID string `json:"blockId,omitempty"`
|
||||
SelectionStart *int `json:"selectionStart,omitempty"`
|
||||
SelectionEnd *int `json:"selectionEnd,omitempty"`
|
||||
UpdatedAt int64 `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// DocWSClient represents an active WebSocket connection to a specific document room.
|
||||
type DocWSClient struct {
|
||||
hub *Hub
|
||||
room *DocRoom
|
||||
conn *websocket.Conn
|
||||
userID string
|
||||
clientID string
|
||||
canWrite bool
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
// DocSyncEvent is the lightweight JSON payload exchanged over /api/v1/ws/docs/{id}.
|
||||
type DocSyncEvent struct {
|
||||
Type string `json:"type"`
|
||||
DocID string `json:"docId,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
UserID string `json:"userId,omitempty"`
|
||||
UserName string `json:"userName,omitempty"`
|
||||
UserColor string `json:"userColor,omitempty"`
|
||||
SelectionStart *int `json:"selectionStart,omitempty"`
|
||||
SelectionEnd *int `json:"selectionEnd,omitempty"`
|
||||
ActiveCursors []CursorPresence `json:"activeCursors,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Block *domain.DocumentBlock `json:"block,omitempty"`
|
||||
BlockID string `json:"blockId,omitempty"`
|
||||
AfterBlockID string `json:"afterBlockId,omitempty"`
|
||||
NewIndex *int `json:"newIndex,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
Level *int `json:"level,omitempty"`
|
||||
Checked *bool `json:"checked,omitempty"`
|
||||
Collapsed *bool `json:"collapsed,omitempty"`
|
||||
BlockType *string `json:"blockType,omitempty"`
|
||||
Blocks []domain.DocumentBlock `json:"blocks,omitempty"`
|
||||
}
|
||||
|
||||
// DocRoom coordinates in-memory real-time block state and 1.5s debounced SQLite persistence for one document.
|
||||
type DocRoom struct {
|
||||
mu sync.Mutex
|
||||
docID string
|
||||
hub *Hub
|
||||
title string
|
||||
blocks []domain.DocumentBlock
|
||||
loaded bool
|
||||
wsClients map[*DocWSClient]bool
|
||||
activeCursors map[string]*CursorPresence
|
||||
debounceTimer *time.Timer
|
||||
dirty bool
|
||||
}
|
||||
|
||||
// GetOrCreateDocRoom returns the active in-memory room for docID or initializes one from SQLite.
|
||||
func (h *Hub) GetOrCreateDocRoom(docID string) *DocRoom {
|
||||
h.mu.Lock()
|
||||
if h.docRooms == nil {
|
||||
h.docRooms = make(map[string]*DocRoom)
|
||||
}
|
||||
room, exists := h.docRooms[docID]
|
||||
if !exists {
|
||||
room = &DocRoom{
|
||||
docID: docID,
|
||||
hub: h,
|
||||
wsClients: make(map[*DocWSClient]bool),
|
||||
activeCursors: make(map[string]*CursorPresence),
|
||||
}
|
||||
h.docRooms[docID] = room
|
||||
}
|
||||
store := h.docStore
|
||||
h.mu.Unlock()
|
||||
|
||||
room.mu.Lock()
|
||||
if !room.loaded && store != nil {
|
||||
title, blocks, err := store.LoadDocumentState(docID)
|
||||
if err == nil {
|
||||
room.title = title
|
||||
room.blocks = normalizeRoomBlocks(docID, blocks)
|
||||
room.loaded = true
|
||||
}
|
||||
}
|
||||
room.mu.Unlock()
|
||||
|
||||
return room
|
||||
}
|
||||
|
||||
// GetActiveDocState returns the live in-memory title and blocks if an active DocRoom exists in RAM.
|
||||
func (h *Hub) GetActiveDocState(docID string) (string, []domain.DocumentBlock, bool) {
|
||||
h.mu.RLock()
|
||||
room, exists := h.docRooms[docID]
|
||||
h.mu.RUnlock()
|
||||
if !exists || room == nil {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
room.mu.Lock()
|
||||
defer room.mu.Unlock()
|
||||
if !room.loaded {
|
||||
return "", nil, false
|
||||
}
|
||||
blocksCopy := make([]domain.DocumentBlock, len(room.blocks))
|
||||
copy(blocksCopy, room.blocks)
|
||||
return room.title, blocksCopy, true
|
||||
}
|
||||
|
||||
// SyncActiveDocRoom updates an in-memory room if one is currently loaded (e.g. after REST PUT/PATCH).
|
||||
func (h *Hub) SyncActiveDocRoom(docID string, title string, blocks []domain.DocumentBlock) {
|
||||
h.mu.RLock()
|
||||
room, exists := h.docRooms[docID]
|
||||
h.mu.RUnlock()
|
||||
if !exists || room == nil {
|
||||
return
|
||||
}
|
||||
|
||||
room.mu.Lock()
|
||||
if title != "" {
|
||||
room.title = title
|
||||
}
|
||||
if blocks != nil {
|
||||
room.blocks = normalizeRoomBlocks(docID, blocks)
|
||||
}
|
||||
room.loaded = true
|
||||
room.mu.Unlock()
|
||||
}
|
||||
|
||||
// CloseDocRoom removes a document room from memory when a document is deleted.
|
||||
func (h *Hub) CloseDocRoom(docID string) {
|
||||
h.mu.Lock()
|
||||
room, exists := h.docRooms[docID]
|
||||
if exists {
|
||||
delete(h.docRooms, docID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if exists && room != nil {
|
||||
room.mu.Lock()
|
||||
if room.debounceTimer != nil {
|
||||
room.debounceTimer.Stop()
|
||||
}
|
||||
room.dirty = false
|
||||
for c := range room.wsClients {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
room.wsClients = make(map[*DocWSClient]bool)
|
||||
room.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRoomBlocks(docID string, blocks []domain.DocumentBlock) []domain.DocumentBlock {
|
||||
if len(blocks) == 0 {
|
||||
return []domain.DocumentBlock{
|
||||
{
|
||||
ID: docID + "-b0",
|
||||
DocumentID: docID,
|
||||
Type: "text",
|
||||
Content: "",
|
||||
Level: 0,
|
||||
Position: 1000,
|
||||
},
|
||||
}
|
||||
}
|
||||
out := make([]domain.DocumentBlock, len(blocks))
|
||||
for i, b := range blocks {
|
||||
if b.ID == "" {
|
||||
b.ID = domain.NewID("blk")
|
||||
}
|
||||
b.DocumentID = docID
|
||||
if b.Type == "" {
|
||||
b.Type = "text"
|
||||
}
|
||||
if b.Level < 0 {
|
||||
b.Level = 0
|
||||
}
|
||||
b.Position = float64((i + 1) * 1000)
|
||||
out[i] = b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetStateSnapshot returns a copy of the room's current live in-memory title and blocks.
|
||||
func (r *DocRoom) GetStateSnapshot() (string, []domain.DocumentBlock) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
|
||||
copy(blocksCopy, r.blocks)
|
||||
return r.title, blocksCopy
|
||||
}
|
||||
|
||||
// HandleEvent applies a granular block or title mutation to the in-memory room,
|
||||
// broadcasts it to all OTHER clients in the room (excluding senderClientID for echo protection),
|
||||
// and resets the 1.5-second inactivity debounce timer for SQLite persistence.
|
||||
func (r *DocRoom) HandleEvent(evt DocSyncEvent, sender *DocWSClient) {
|
||||
r.mu.Lock()
|
||||
|
||||
evt.DocID = r.docID
|
||||
if sender != nil {
|
||||
if evt.ClientID == "" {
|
||||
evt.ClientID = sender.clientID
|
||||
}
|
||||
evt.UserID = sender.userID
|
||||
}
|
||||
|
||||
mutated := false
|
||||
|
||||
switch evt.Type {
|
||||
case "cursor_moved":
|
||||
if evt.ClientID != "" {
|
||||
cp := &CursorPresence{
|
||||
ClientID: evt.ClientID,
|
||||
UserID: evt.UserID,
|
||||
UserName: evt.UserName,
|
||||
UserColor: evt.UserColor,
|
||||
BlockID: evt.BlockID,
|
||||
SelectionStart: evt.SelectionStart,
|
||||
SelectionEnd: evt.SelectionEnd,
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
if r.activeCursors == nil {
|
||||
r.activeCursors = make(map[string]*CursorPresence)
|
||||
}
|
||||
r.activeCursors[evt.ClientID] = cp
|
||||
|
||||
msgBytes, err := json.Marshal(evt)
|
||||
if err == nil {
|
||||
for client := range r.wsClients {
|
||||
if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) {
|
||||
continue
|
||||
}
|
||||
if evt.ClientID != "" && client.clientID == evt.ClientID {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.send <- msgBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
return
|
||||
|
||||
case "title_updated":
|
||||
if evt.Title != "" || r.title != evt.Title {
|
||||
r.title = evt.Title
|
||||
mutated = true
|
||||
}
|
||||
|
||||
case "block_created":
|
||||
if evt.Block != nil {
|
||||
newBlock := *evt.Block
|
||||
if newBlock.ID == "" {
|
||||
newBlock.ID = domain.NewID("blk")
|
||||
}
|
||||
newBlock.DocumentID = r.docID
|
||||
if newBlock.Type == "" {
|
||||
newBlock.Type = "text"
|
||||
}
|
||||
if newBlock.Level < 0 {
|
||||
newBlock.Level = 0
|
||||
}
|
||||
|
||||
// Avoid duplicate ID if already created
|
||||
existsIdx := -1
|
||||
for i, b := range r.blocks {
|
||||
if b.ID == newBlock.ID {
|
||||
existsIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if existsIdx >= 0 {
|
||||
r.blocks[existsIdx] = newBlock
|
||||
} else {
|
||||
insertIdx := len(r.blocks)
|
||||
if evt.AfterBlockID != "" {
|
||||
for i, b := range r.blocks {
|
||||
if b.ID == evt.AfterBlockID {
|
||||
insertIdx = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if evt.NewIndex != nil && *evt.NewIndex >= 0 && *evt.NewIndex <= len(r.blocks) {
|
||||
insertIdx = *evt.NewIndex
|
||||
}
|
||||
|
||||
updated := make([]domain.DocumentBlock, 0, len(r.blocks)+1)
|
||||
updated = append(updated, r.blocks[:insertIdx]...)
|
||||
updated = append(updated, newBlock)
|
||||
updated = append(updated, r.blocks[insertIdx:]...)
|
||||
r.blocks = normalizeRoomBlocks(r.docID, updated)
|
||||
}
|
||||
evt.Block = &newBlock
|
||||
mutated = true
|
||||
}
|
||||
|
||||
case "block_updated":
|
||||
targetID := evt.BlockID
|
||||
if targetID == "" && evt.Block != nil {
|
||||
targetID = evt.Block.ID
|
||||
}
|
||||
if targetID != "" {
|
||||
found := false
|
||||
for i := range r.blocks {
|
||||
if r.blocks[i].ID == targetID {
|
||||
found = true
|
||||
if evt.Block != nil {
|
||||
r.blocks[i].Content = evt.Block.Content
|
||||
r.blocks[i].Level = evt.Block.Level
|
||||
if evt.Block.Type != "" {
|
||||
r.blocks[i].Type = evt.Block.Type
|
||||
}
|
||||
r.blocks[i].Checked = evt.Block.Checked
|
||||
r.blocks[i].Collapsed = evt.Block.Collapsed
|
||||
r.blocks[i].Lang = evt.Block.Lang
|
||||
} else {
|
||||
if evt.Content != nil {
|
||||
r.blocks[i].Content = *evt.Content
|
||||
}
|
||||
if evt.Level != nil {
|
||||
lvl := *evt.Level
|
||||
if lvl < 0 {
|
||||
lvl = 0
|
||||
}
|
||||
r.blocks[i].Level = lvl
|
||||
}
|
||||
if evt.Checked != nil {
|
||||
r.blocks[i].Checked = *evt.Checked
|
||||
}
|
||||
if evt.Collapsed != nil {
|
||||
r.blocks[i].Collapsed = *evt.Collapsed
|
||||
}
|
||||
if evt.BlockType != nil && *evt.BlockType != "" {
|
||||
r.blocks[i].Type = *evt.BlockType
|
||||
}
|
||||
}
|
||||
bCopy := r.blocks[i]
|
||||
evt.Block = &bCopy
|
||||
evt.BlockID = targetID
|
||||
mutated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && evt.Block != nil {
|
||||
newBlock := *evt.Block
|
||||
newBlock.DocumentID = r.docID
|
||||
if newBlock.Type == "" {
|
||||
newBlock.Type = "text"
|
||||
}
|
||||
if newBlock.Level < 0 {
|
||||
newBlock.Level = 0
|
||||
}
|
||||
r.blocks = append(r.blocks, newBlock)
|
||||
r.blocks = normalizeRoomBlocks(r.docID, r.blocks)
|
||||
bCopy := newBlock
|
||||
evt.Block = &bCopy
|
||||
evt.BlockID = targetID
|
||||
mutated = true
|
||||
}
|
||||
}
|
||||
|
||||
case "block_deleted":
|
||||
if evt.BlockID != "" {
|
||||
filtered := make([]domain.DocumentBlock, 0, len(r.blocks))
|
||||
for _, b := range r.blocks {
|
||||
if b.ID != evt.BlockID {
|
||||
filtered = append(filtered, b)
|
||||
}
|
||||
}
|
||||
r.blocks = normalizeRoomBlocks(r.docID, filtered)
|
||||
mutated = true
|
||||
}
|
||||
|
||||
case "block_moved":
|
||||
if evt.BlockID != "" && evt.NewIndex != nil {
|
||||
fromIdx := -1
|
||||
for i, b := range r.blocks {
|
||||
if b.ID == evt.BlockID {
|
||||
fromIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if fromIdx >= 0 {
|
||||
moving := r.blocks[fromIdx]
|
||||
if evt.Level != nil {
|
||||
lvl := *evt.Level
|
||||
if lvl < 0 {
|
||||
lvl = 0
|
||||
}
|
||||
moving.Level = lvl
|
||||
}
|
||||
without := make([]domain.DocumentBlock, 0, len(r.blocks)-1)
|
||||
without = append(without, r.blocks[:fromIdx]...)
|
||||
without = append(without, r.blocks[fromIdx+1:]...)
|
||||
|
||||
toIdx := *evt.NewIndex
|
||||
if toIdx < 0 {
|
||||
toIdx = 0
|
||||
}
|
||||
if toIdx > len(without) {
|
||||
toIdx = len(without)
|
||||
}
|
||||
|
||||
reordered := make([]domain.DocumentBlock, 0, len(r.blocks))
|
||||
reordered = append(reordered, without[:toIdx]...)
|
||||
reordered = append(reordered, moving)
|
||||
reordered = append(reordered, without[toIdx:]...)
|
||||
r.blocks = normalizeRoomBlocks(r.docID, reordered)
|
||||
mutated = true
|
||||
}
|
||||
}
|
||||
|
||||
case "blocks_replaced":
|
||||
if evt.Blocks != nil {
|
||||
r.blocks = normalizeRoomBlocks(r.docID, evt.Blocks)
|
||||
evt.Blocks = r.blocks
|
||||
mutated = true
|
||||
}
|
||||
}
|
||||
|
||||
if !mutated {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
r.dirty = true
|
||||
r.loaded = true
|
||||
|
||||
msgBytes, err := json.Marshal(evt)
|
||||
if err == nil {
|
||||
for client := range r.wsClients {
|
||||
// Strict Echo Protection: never send back to the originating connection or clientId
|
||||
if sender != nil && (client == sender || (sender.clientID != "" && client.clientID == sender.clientID)) {
|
||||
continue
|
||||
}
|
||||
if evt.ClientID != "" && client.clientID == evt.ClientID {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.send <- msgBytes:
|
||||
default:
|
||||
slog.Warn("Doc room WS client send buffer full, dropping message",
|
||||
"doc_id", r.docID,
|
||||
"client_id", client.clientID,
|
||||
"user_id", client.userID,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.scheduleDebouncedFlushLocked()
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *DocRoom) scheduleDebouncedFlushLocked() {
|
||||
if r.debounceTimer != nil {
|
||||
r.debounceTimer.Stop()
|
||||
}
|
||||
r.debounceTimer = time.AfterFunc(1500*time.Millisecond, func() {
|
||||
r.Flush()
|
||||
})
|
||||
}
|
||||
|
||||
// Flush persists the current in-memory document state (blocks, #tags in block_tags, and markdown_cache) to SQLite.
|
||||
func (r *DocRoom) Flush() {
|
||||
r.mu.Lock()
|
||||
if !r.dirty {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if r.debounceTimer != nil {
|
||||
r.debounceTimer.Stop()
|
||||
r.debounceTimer = nil
|
||||
}
|
||||
|
||||
docID := r.docID
|
||||
title := r.title
|
||||
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
|
||||
copy(blocksCopy, r.blocks)
|
||||
r.dirty = false
|
||||
r.mu.Unlock()
|
||||
|
||||
tagsCount := 0
|
||||
for _, b := range blocksCopy {
|
||||
tagsCount += len(domain.ExtractTags(b.Content))
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
r.hub.mu.RLock()
|
||||
store := r.hub.docStore
|
||||
r.hub.mu.RUnlock()
|
||||
|
||||
if store != nil {
|
||||
if err := store.FlushDocumentState(docID, title, blocksCopy); err != nil {
|
||||
slog.Error("SQLite document flush failed",
|
||||
"doc_id", docID,
|
||||
"error", err,
|
||||
)
|
||||
} else {
|
||||
durationMs := float64(time.Since(start).Microseconds()) / 1000.0
|
||||
slog.Debug("SQLite document flushed",
|
||||
"doc_id", docID,
|
||||
"blocks_count", len(blocksCopy),
|
||||
"tags_count", tagsCount,
|
||||
"duration_ms", durationMs,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ServeDocWS handles WebSocket connections for GET /api/v1/ws/docs/{id}
|
||||
func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID string, clientID string, canWrite bool) {
|
||||
conn, err := upgrader.Upgrade(w, req, nil)
|
||||
if err != nil {
|
||||
slog.Error("WebSocket upgrade error", "doc_id", r.docID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
if clientID == "" {
|
||||
clientID = req.URL.Query().Get("clientId")
|
||||
}
|
||||
if clientID == "" {
|
||||
clientID = domain.NewID("cli")
|
||||
}
|
||||
|
||||
userName := req.URL.Query().Get("userName")
|
||||
userColor := req.URL.Query().Get("userColor")
|
||||
|
||||
client := &DocWSClient{
|
||||
hub: r.hub,
|
||||
room: r,
|
||||
conn: conn,
|
||||
userID: userID,
|
||||
clientID: clientID,
|
||||
canWrite: canWrite,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.wsClients[client] = true
|
||||
activeClients := len(r.wsClients)
|
||||
if r.activeCursors == nil {
|
||||
r.activeCursors = make(map[string]*CursorPresence)
|
||||
}
|
||||
if userName != "" {
|
||||
r.activeCursors[clientID] = &CursorPresence{
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
UserName: userName,
|
||||
UserColor: userColor,
|
||||
UpdatedAt: time.Now().UnixMilli(),
|
||||
}
|
||||
}
|
||||
title := r.title
|
||||
blocksCopy := make([]domain.DocumentBlock, len(r.blocks))
|
||||
copy(blocksCopy, r.blocks)
|
||||
|
||||
activeList := make([]CursorPresence, 0, len(r.activeCursors))
|
||||
for _, cp := range r.activeCursors {
|
||||
if cp != nil {
|
||||
activeList = append(activeList, *cp)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
slog.Debug("WS client joined doc room",
|
||||
"doc_id", r.docID,
|
||||
"client_id", clientID,
|
||||
"user_id", userID,
|
||||
"active_clients", activeClients,
|
||||
)
|
||||
|
||||
// Immediately send live in-memory state and current cursors to the newly connected client
|
||||
initEvt := DocSyncEvent{
|
||||
Type: "doc_state",
|
||||
DocID: r.docID,
|
||||
ClientID: clientID,
|
||||
Title: title,
|
||||
Blocks: blocksCopy,
|
||||
ActiveCursors: activeList,
|
||||
}
|
||||
if initBytes, err := json.Marshal(initEvt); err == nil {
|
||||
client.send <- initBytes
|
||||
}
|
||||
|
||||
// If userName was provided, broadcast initial presence to existing clients in room
|
||||
if userName != "" {
|
||||
joinEvt := DocSyncEvent{
|
||||
Type: "cursor_moved",
|
||||
DocID: r.docID,
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
UserName: userName,
|
||||
UserColor: userColor,
|
||||
}
|
||||
if joinBytes, err := json.Marshal(joinEvt); err == nil {
|
||||
r.mu.Lock()
|
||||
for c := range r.wsClients {
|
||||
if c != client && c.clientID != clientID {
|
||||
select {
|
||||
case c.send <- joinBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
go client.writePump()
|
||||
go client.readPump()
|
||||
}
|
||||
|
||||
func (c *DocWSClient) readPump() {
|
||||
defer func() {
|
||||
c.room.mu.Lock()
|
||||
delete(c.room.wsClients, c)
|
||||
delete(c.room.activeCursors, c.clientID)
|
||||
remaining := len(c.room.wsClients)
|
||||
|
||||
// Broadcast client_left to all remaining clients in room
|
||||
leftEvt := DocSyncEvent{
|
||||
Type: "client_left",
|
||||
DocID: c.room.docID,
|
||||
ClientID: c.clientID,
|
||||
UserID: c.userID,
|
||||
}
|
||||
if leftBytes, err := json.Marshal(leftEvt); err == nil {
|
||||
for client := range c.room.wsClients {
|
||||
if client != c && client.clientID != c.clientID {
|
||||
select {
|
||||
case client.send <- leftBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.room.mu.Unlock()
|
||||
|
||||
slog.Debug("WS client left doc room",
|
||||
"doc_id", c.room.docID,
|
||||
"client_id", c.clientID,
|
||||
"user_id", c.userID,
|
||||
"active_clients", remaining,
|
||||
)
|
||||
|
||||
_ = c.conn.Close()
|
||||
|
||||
// Flush immediately when the last client leaves the document room
|
||||
if remaining == 0 {
|
||||
c.room.Flush()
|
||||
}
|
||||
}()
|
||||
|
||||
c.conn.SetReadLimit(4 * 1024 * 1024)
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
_, message, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
var evt DocSyncEvent
|
||||
if err := json.Unmarshal(message, &evt); err == nil && evt.Type != "" {
|
||||
targetBlockID := evt.BlockID
|
||||
if targetBlockID == "" && evt.Block != nil {
|
||||
targetBlockID = evt.Block.ID
|
||||
}
|
||||
|
||||
slog.Debug("WS doc event received",
|
||||
"type", evt.Type,
|
||||
"doc_id", c.room.docID,
|
||||
"block_id", targetBlockID,
|
||||
"sender_client_id", c.clientID,
|
||||
)
|
||||
|
||||
// Read-only clients can still move their cursor and send presence updates
|
||||
if !c.canWrite && evt.Type != "cursor_moved" {
|
||||
continue
|
||||
}
|
||||
c.room.HandleEvent(evt, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DocWSClient) writePump() {
|
||||
ticker := time.NewTicker(25 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-c.send:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if !ok {
|
||||
_ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDocRoomCursorPresenceAndEchoProtection(t *testing.T) {
|
||||
hub := NewHub()
|
||||
room := hub.GetOrCreateDocRoom("doc_test_presence")
|
||||
|
||||
c1 := &DocWSClient{
|
||||
hub: hub,
|
||||
room: room,
|
||||
userID: "user_1",
|
||||
clientID: "cli_1",
|
||||
canWrite: true,
|
||||
send: make(chan []byte, 10),
|
||||
}
|
||||
c2 := &DocWSClient{
|
||||
hub: hub,
|
||||
room: room,
|
||||
userID: "user_2",
|
||||
clientID: "cli_2",
|
||||
canWrite: false, // read-only viewer
|
||||
send: make(chan []byte, 10),
|
||||
}
|
||||
|
||||
room.mu.Lock()
|
||||
room.wsClients[c1] = true
|
||||
room.wsClients[c2] = true
|
||||
room.mu.Unlock()
|
||||
|
||||
// 1. Send cursor_moved from c1
|
||||
selStart := 5
|
||||
selEnd := 5
|
||||
room.HandleEvent(DocSyncEvent{
|
||||
Type: "cursor_moved",
|
||||
DocID: "doc_test_presence",
|
||||
ClientID: "cli_1",
|
||||
UserID: "user_1",
|
||||
UserName: "Alice",
|
||||
UserColor: "#3b82f6",
|
||||
BlockID: "blk_123",
|
||||
SelectionStart: &selStart,
|
||||
SelectionEnd: &selEnd,
|
||||
}, c1)
|
||||
|
||||
// c1 should NOT have received its own echo
|
||||
select {
|
||||
case <-c1.send:
|
||||
t.Fatal("c1 received echo of its own cursor_moved event!")
|
||||
default:
|
||||
}
|
||||
|
||||
// c2 SHOULD receive the event
|
||||
select {
|
||||
case msg := <-c2.send:
|
||||
if len(msg) == 0 {
|
||||
t.Fatal("empty message received by c2")
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("timed out waiting for cursor_moved event on c2")
|
||||
}
|
||||
|
||||
// Room should have registered c1's presence
|
||||
room.mu.Lock()
|
||||
cp, exists := room.activeCursors["cli_1"]
|
||||
room.mu.Unlock()
|
||||
|
||||
if !exists || cp == nil {
|
||||
t.Fatal("expected active cursor for cli_1 in room")
|
||||
}
|
||||
if cp.UserName != "Alice" || cp.BlockID != "blk_123" {
|
||||
t.Fatalf("unexpected cursor data: %+v", cp)
|
||||
}
|
||||
|
||||
// Ephemeral cursor events must NOT mark the room dirty for disk flush
|
||||
room.mu.Lock()
|
||||
dirty := room.dirty
|
||||
room.mu.Unlock()
|
||||
if dirty {
|
||||
t.Fatal("cursor_moved should not mark room dirty")
|
||||
}
|
||||
}
|
||||
+685
-43
@@ -2,8 +2,10 @@ package ws
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -12,72 +14,501 @@ import (
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
ReadBufferSize: 4096,
|
||||
WriteBufferSize: 4096,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Self-hosted reverse proxy compatible
|
||||
},
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
spaceID string
|
||||
userID string
|
||||
send chan []byte
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
roomID string
|
||||
userID string
|
||||
send chan []byte
|
||||
subscribedRooms map[string]bool
|
||||
}
|
||||
|
||||
type SSEClient struct {
|
||||
id string
|
||||
userID string
|
||||
roomID string
|
||||
send chan []byte
|
||||
subscribedRooms map[string]bool
|
||||
}
|
||||
|
||||
type docLockItem struct {
|
||||
docID string
|
||||
userID string
|
||||
userName string
|
||||
lockedAt time.Time
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type DocStore interface {
|
||||
LoadDocumentState(docID string) (string, []domain.DocumentBlock, error)
|
||||
FlushDocumentState(docID string, title string, blocks []domain.DocumentBlock) error
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
mu sync.RWMutex
|
||||
rooms map[string]map[*Client]bool
|
||||
mu sync.RWMutex
|
||||
rooms map[string]map[*Client]bool
|
||||
userRooms map[string]map[*Client]bool
|
||||
allClients map[*Client]bool
|
||||
sseClients map[*SSEClient]bool
|
||||
sseUserRooms map[string]map[*SSEClient]bool
|
||||
sseRooms map[string]map[*SSEClient]bool
|
||||
locks map[string]*docLockItem
|
||||
docRooms map[string]*DocRoom
|
||||
docStore DocStore
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
rooms: make(map[string]map[*Client]bool),
|
||||
h := &Hub{
|
||||
rooms: make(map[string]map[*Client]bool),
|
||||
userRooms: make(map[string]map[*Client]bool),
|
||||
allClients: make(map[*Client]bool),
|
||||
sseClients: make(map[*SSEClient]bool),
|
||||
sseUserRooms: make(map[string]map[*SSEClient]bool),
|
||||
sseRooms: make(map[string]map[*SSEClient]bool),
|
||||
locks: make(map[string]*docLockItem),
|
||||
docRooms: make(map[string]*DocRoom),
|
||||
}
|
||||
go h.cleanupLocksLoop()
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Hub) SetDocStore(store DocStore) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.docStore = store
|
||||
}
|
||||
|
||||
func (h *Hub) Register(c *Client) {
|
||||
h.mu.Lock()
|
||||
if h.rooms[c.spaceID] == nil {
|
||||
h.rooms[c.spaceID] = make(map[*Client]bool)
|
||||
h.allClients[c] = true
|
||||
if c.roomID != "" {
|
||||
if h.rooms[c.roomID] == nil {
|
||||
h.rooms[c.roomID] = make(map[*Client]bool)
|
||||
}
|
||||
h.rooms[c.roomID][c] = true
|
||||
}
|
||||
h.rooms[c.spaceID][c] = true
|
||||
onlineCount := len(h.rooms[c.spaceID])
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if h.userRooms[c.userID] == nil {
|
||||
h.userRooms[c.userID] = make(map[*Client]bool)
|
||||
}
|
||||
h.userRooms[c.userID][c] = true
|
||||
}
|
||||
onlineCount := len(h.allClients) + len(h.sseClients)
|
||||
h.mu.Unlock()
|
||||
|
||||
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{
|
||||
"onlineCount": onlineCount,
|
||||
})
|
||||
if c.roomID != "" {
|
||||
h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{
|
||||
"onlineCount": onlineCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Unregister(c *Client) {
|
||||
h.mu.Lock()
|
||||
var onlineCount int
|
||||
if room, ok := h.rooms[c.spaceID]; ok {
|
||||
if _, exists := room[c]; exists {
|
||||
delete(h.allClients, c)
|
||||
if c.roomID != "" {
|
||||
if room, ok := h.rooms[c.roomID]; ok {
|
||||
delete(room, c)
|
||||
close(c.send)
|
||||
if len(room) == 0 {
|
||||
delete(h.rooms, c.roomID)
|
||||
}
|
||||
}
|
||||
onlineCount = len(room)
|
||||
if onlineCount == 0 {
|
||||
delete(h.rooms, c.spaceID)
|
||||
}
|
||||
|
||||
var docRoomsLeft []string
|
||||
if c.subscribedRooms != nil {
|
||||
for rID := range c.subscribedRooms {
|
||||
if room, ok := h.rooms[rID]; ok {
|
||||
delete(room, c)
|
||||
if len(room) == 0 {
|
||||
delete(h.rooms, rID)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(rID, "doc:") {
|
||||
docRoomsLeft = append(docRoomsLeft, strings.TrimPrefix(rID, "doc:"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userRemaining := 0
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if uRoom, ok := h.userRooms[c.userID]; ok {
|
||||
delete(uRoom, c)
|
||||
userRemaining += len(uRoom)
|
||||
if len(uRoom) == 0 {
|
||||
delete(h.userRooms, c.userID)
|
||||
}
|
||||
}
|
||||
if sseRoom, ok := h.sseUserRooms[c.userID]; ok {
|
||||
userRemaining += len(sseRoom)
|
||||
}
|
||||
}
|
||||
close(c.send)
|
||||
|
||||
// If this user has no other active client connections, release their document locks
|
||||
var releasedDocIDs []string
|
||||
if userRemaining == 0 && c.userID != "" {
|
||||
now := time.Now()
|
||||
for docID, l := range h.locks {
|
||||
if l.userID == c.userID || l.expiresAt.Before(now) {
|
||||
releasedDocIDs = append(releasedDocIDs, docID)
|
||||
delete(h.locks, docID)
|
||||
}
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if onlineCount > 0 {
|
||||
h.Broadcast(c.spaceID, "", "USER_PRESENCE", map[string]interface{}{
|
||||
// Broadcast unlock events for released locks
|
||||
for _, docID := range releasedDocIDs {
|
||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
|
||||
// Broadcast awareness leave for doc rooms
|
||||
for _, docID := range docRoomsLeft {
|
||||
h.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
|
||||
"docId": docID,
|
||||
"userId": c.userID,
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) RegisterSSE(c *SSEClient) {
|
||||
h.mu.Lock()
|
||||
h.sseClients[c] = true
|
||||
if c.roomID != "" {
|
||||
if h.sseRooms[c.roomID] == nil {
|
||||
h.sseRooms[c.roomID] = make(map[*SSEClient]bool)
|
||||
}
|
||||
h.sseRooms[c.roomID][c] = true
|
||||
}
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if h.sseUserRooms[c.userID] == nil {
|
||||
h.sseUserRooms[c.userID] = make(map[*SSEClient]bool)
|
||||
}
|
||||
h.sseUserRooms[c.userID][c] = true
|
||||
}
|
||||
if c.subscribedRooms != nil {
|
||||
for rID := range c.subscribedRooms {
|
||||
if h.sseRooms[rID] == nil {
|
||||
h.sseRooms[rID] = make(map[*SSEClient]bool)
|
||||
}
|
||||
h.sseRooms[rID][c] = true
|
||||
}
|
||||
}
|
||||
onlineCount := len(h.allClients) + len(h.sseClients)
|
||||
h.mu.Unlock()
|
||||
|
||||
if c.roomID != "" {
|
||||
h.Broadcast(c.roomID, "", "USER_PRESENCE", map[string]interface{}{
|
||||
"onlineCount": onlineCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) UnregisterSSE(c *SSEClient) {
|
||||
h.mu.Lock()
|
||||
delete(h.sseClients, c)
|
||||
if c.roomID != "" {
|
||||
if room, ok := h.sseRooms[c.roomID]; ok {
|
||||
delete(room, c)
|
||||
if len(room) == 0 {
|
||||
delete(h.sseRooms, c.roomID)
|
||||
}
|
||||
}
|
||||
}
|
||||
var docRoomsLeft []string
|
||||
if c.subscribedRooms != nil {
|
||||
for rID := range c.subscribedRooms {
|
||||
if room, ok := h.sseRooms[rID]; ok {
|
||||
delete(room, c)
|
||||
if len(room) == 0 {
|
||||
delete(h.sseRooms, rID)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(rID, "doc:") {
|
||||
docRoomsLeft = append(docRoomsLeft, strings.TrimPrefix(rID, "doc:"))
|
||||
}
|
||||
}
|
||||
}
|
||||
userRemaining := 0
|
||||
if c.userID != "" && c.userID != "anonymous" {
|
||||
if uRoom, ok := h.sseUserRooms[c.userID]; ok {
|
||||
delete(uRoom, c)
|
||||
userRemaining += len(uRoom)
|
||||
if len(uRoom) == 0 {
|
||||
delete(h.sseUserRooms, c.userID)
|
||||
}
|
||||
}
|
||||
if wsRoom, ok := h.userRooms[c.userID]; ok {
|
||||
userRemaining += len(wsRoom)
|
||||
}
|
||||
}
|
||||
close(c.send)
|
||||
|
||||
var releasedDocIDs []string
|
||||
if userRemaining == 0 && c.userID != "" {
|
||||
now := time.Now()
|
||||
for docID, l := range h.locks {
|
||||
if l.userID == c.userID || l.expiresAt.Before(now) {
|
||||
releasedDocIDs = append(releasedDocIDs, docID)
|
||||
delete(h.locks, docID)
|
||||
}
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, docID := range releasedDocIDs {
|
||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
|
||||
for _, docID := range docRoomsLeft {
|
||||
h.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
|
||||
"docId": docID,
|
||||
"userId": c.userID,
|
||||
}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) JoinSSERoomByID(sseID, roomID string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for client := range h.sseClients {
|
||||
if client.id == sseID {
|
||||
if h.sseRooms[roomID] == nil {
|
||||
h.sseRooms[roomID] = make(map[*SSEClient]bool)
|
||||
}
|
||||
h.sseRooms[roomID][client] = true
|
||||
if client.subscribedRooms == nil {
|
||||
client.subscribedRooms = make(map[string]bool)
|
||||
}
|
||||
client.subscribedRooms[roomID] = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Hub) LeaveSSERoomByID(sseID, roomID string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for client := range h.sseClients {
|
||||
if client.id == sseID {
|
||||
if room, ok := h.sseRooms[roomID]; ok {
|
||||
delete(room, client)
|
||||
if len(room) == 0 {
|
||||
delete(h.sseRooms, roomID)
|
||||
}
|
||||
}
|
||||
if client.subscribedRooms != nil {
|
||||
delete(client.subscribedRooms, roomID)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Document Locking Methods
|
||||
|
||||
func (h *Hub) AcquireDocLock(docID, userID, userName string, ttl time.Duration) (*domain.DocLockInfo, bool, *domain.DocLockInfo) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
existing, exists := h.locks[docID]
|
||||
if exists && existing.expiresAt.After(now) {
|
||||
if existing.userID != userID {
|
||||
// Locked by another user
|
||||
info := &domain.DocLockInfo{
|
||||
DocID: existing.docID,
|
||||
UserID: existing.userID,
|
||||
UserName: existing.userName,
|
||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
return nil, false, info
|
||||
}
|
||||
// Same user extending lock
|
||||
existing.expiresAt = now.Add(ttl)
|
||||
if userName != "" {
|
||||
existing.userName = userName
|
||||
}
|
||||
info := &domain.DocLockInfo{
|
||||
DocID: existing.docID,
|
||||
UserID: existing.userID,
|
||||
UserName: existing.userName,
|
||||
ExpiresAt: existing.expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
// Create fresh lock
|
||||
item := &docLockItem{
|
||||
docID: docID,
|
||||
userID: userID,
|
||||
userName: userName,
|
||||
lockedAt: now,
|
||||
expiresAt: now.Add(ttl),
|
||||
}
|
||||
h.locks[docID] = item
|
||||
info := &domain.DocLockInfo{
|
||||
DocID: item.docID,
|
||||
UserID: item.userID,
|
||||
UserName: item.userName,
|
||||
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
func (h *Hub) HeartbeatDocLock(docID, userID string, ttl time.Duration) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
item, ok := h.locks[docID]
|
||||
if !ok || item.userID != userID || item.expiresAt.Before(now) {
|
||||
return false
|
||||
}
|
||||
item.expiresAt = now.Add(ttl)
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *Hub) ReleaseDocLock(docID, userID string, isAdmin bool) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
item, ok := h.locks[docID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if item.userID == userID || isAdmin || userID == "admin" {
|
||||
delete(h.locks, docID)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Hub) GetDocLock(docID string) *domain.DocLockInfo {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
item, ok := h.locks[docID]
|
||||
if !ok || item.expiresAt.Before(time.Now()) {
|
||||
return nil
|
||||
}
|
||||
return &domain.DocLockInfo{
|
||||
DocID: item.docID,
|
||||
UserID: item.userID,
|
||||
UserName: item.userName,
|
||||
ExpiresAt: item.expiresAt.Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) cleanupLocksLoop() {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
h.mu.Lock()
|
||||
now := time.Now()
|
||||
var expiredDocIDs []string
|
||||
for docID, l := range h.locks {
|
||||
if l.expiresAt.Before(now) {
|
||||
expiredDocIDs = append(expiredDocIDs, docID)
|
||||
delete(h.locks, docID)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, docID := range expiredDocIDs {
|
||||
h.BroadcastToAll("DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcasting Methods
|
||||
|
||||
func (h *Hub) BroadcastToAll(event string, payload interface{}) {
|
||||
msg := domain.WSMessage{
|
||||
Event: event,
|
||||
Payload: payload,
|
||||
Timestamp: domain.NowISO(),
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
for client := range h.allClients {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
for sseClient := range h.sseClients {
|
||||
select {
|
||||
case sseClient.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) BroadcastToUser(userID, event string, payload interface{}) {
|
||||
msg := domain.WSMessage{
|
||||
Event: event,
|
||||
Payload: payload,
|
||||
Timestamp: domain.NowISO(),
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
if room, ok := h.userRooms[userID]; ok {
|
||||
for client := range room {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
if sseRoom, ok := h.sseUserRooms[userID]; ok {
|
||||
for sseClient := range sseRoom {
|
||||
select {
|
||||
case sseClient.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) BroadcastTaskEvent(userID, event string, payload interface{}) {
|
||||
h.BroadcastToUser(userID, event, payload)
|
||||
}
|
||||
|
||||
func (h *Hub) BroadcastDocEvent(docID, event string, payload interface{}) {
|
||||
if docID != "" {
|
||||
h.BroadcastRoom("doc:"+docID, event, payload, nil)
|
||||
} else {
|
||||
h.BroadcastToAll(event, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||
msg := domain.WSMessage{
|
||||
Event: event,
|
||||
SpaceID: spaceID,
|
||||
ListID: listID,
|
||||
Payload: payload,
|
||||
Timestamp: domain.NowISO(),
|
||||
@@ -90,32 +521,124 @@ func (h *Hub) Broadcast(spaceID, listID, event string, payload interface{}) {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
room, ok := h.rooms[spaceID]
|
||||
if !ok {
|
||||
return
|
||||
targetRoom := listID
|
||||
if targetRoom == "" {
|
||||
targetRoom = spaceID
|
||||
}
|
||||
for client := range room {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
// Slow client buffer full
|
||||
|
||||
if room, ok := h.rooms[targetRoom]; ok {
|
||||
for client := range room {
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
// Slow client buffer full
|
||||
}
|
||||
}
|
||||
}
|
||||
if sseRoom, ok := h.sseRooms[targetRoom]; ok {
|
||||
for sseClient := range sseRoom {
|
||||
select {
|
||||
case sseClient.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID string) {
|
||||
func (h *Hub) BroadcastListEvent(listID, event string, payload interface{}) {
|
||||
h.Broadcast("", listID, event, payload)
|
||||
}
|
||||
|
||||
func (h *Hub) JoinRoom(c *Client, roomID string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.rooms[roomID] == nil {
|
||||
h.rooms[roomID] = make(map[*Client]bool)
|
||||
}
|
||||
h.rooms[roomID][c] = true
|
||||
if c.subscribedRooms == nil {
|
||||
c.subscribedRooms = make(map[string]bool)
|
||||
}
|
||||
c.subscribedRooms[roomID] = true
|
||||
}
|
||||
|
||||
func (h *Hub) LeaveRoom(c *Client, roomID string) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if room, ok := h.rooms[roomID]; ok {
|
||||
delete(room, c)
|
||||
if len(room) == 0 {
|
||||
delete(h.rooms, roomID)
|
||||
}
|
||||
}
|
||||
if c.subscribedRooms != nil {
|
||||
delete(c.subscribedRooms, roomID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) BroadcastRoom(roomID, event string, payload interface{}, excludeClient *Client) {
|
||||
msg := domain.WSMessage{
|
||||
Event: event,
|
||||
Payload: payload,
|
||||
Timestamp: domain.NowISO(),
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
|
||||
if room, ok := h.rooms[roomID]; ok {
|
||||
for client := range room {
|
||||
if excludeClient != nil && client == excludeClient {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
if sseRoom, ok := h.sseRooms[roomID]; ok {
|
||||
for sseClient := range sseRoom {
|
||||
select {
|
||||
case sseClient.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SendEvent(event string, payload interface{}) {
|
||||
msg := domain.WSMessage{
|
||||
Event: event,
|
||||
Payload: payload,
|
||||
Timestamp: domain.NowISO(),
|
||||
}
|
||||
data, err := json.Marshal(msg)
|
||||
if err == nil {
|
||||
select {
|
||||
case c.send <- data:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, roomID, userID string) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("[ws] upgrade error: %v", err)
|
||||
slog.Error("WebSocket upgrade error", "room_id", roomID, "user_id", userID, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
client := &Client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
spaceID: spaceID,
|
||||
roomID: roomID,
|
||||
userID: userID,
|
||||
send: make(chan []byte, 64),
|
||||
send: make(chan []byte, 128),
|
||||
}
|
||||
|
||||
h.Register(client)
|
||||
@@ -123,13 +646,80 @@ func (h *Hub) ServeWS(w http.ResponseWriter, r *http.Request, spaceID, userID st
|
||||
go client.readPump()
|
||||
}
|
||||
|
||||
func (h *Hub) ServeSSE(w http.ResponseWriter, r *http.Request, roomID, userID string, initialRooms []string) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
client := &SSEClient{
|
||||
id: domain.NewID("sse"),
|
||||
userID: userID,
|
||||
roomID: roomID,
|
||||
send: make(chan []byte, 128),
|
||||
subscribedRooms: make(map[string]bool),
|
||||
}
|
||||
|
||||
for _, room := range initialRooms {
|
||||
if strings.TrimSpace(room) != "" {
|
||||
client.subscribedRooms[strings.TrimSpace(room)] = true
|
||||
}
|
||||
}
|
||||
|
||||
h.RegisterSSE(client)
|
||||
defer h.UnregisterSSE(client)
|
||||
|
||||
// Send initial connected comment and event
|
||||
fmt.Fprintf(w, ": connected (transport: sse, id: %s)\n\n", client.id)
|
||||
connectedMsg, _ := json.Marshal(domain.WSMessage{
|
||||
Event: "SSE_CONNECTED",
|
||||
Payload: map[string]interface{}{
|
||||
"sseId": client.id,
|
||||
"transport": "sse",
|
||||
"userId": userID,
|
||||
},
|
||||
Timestamp: domain.NowISO(),
|
||||
})
|
||||
fmt.Fprintf(w, "data: %s\n\n", connectedMsg)
|
||||
flusher.Flush()
|
||||
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
notify := r.Context().Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-notify:
|
||||
return
|
||||
case msg, ok := <-client.send:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", msg)
|
||||
flusher.Flush()
|
||||
case <-ticker.C:
|
||||
// Periodic ping comment to keep HTTP connection alive and prevent proxy timeouts
|
||||
fmt.Fprintf(w, ": ping\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) readPump() {
|
||||
defer func() {
|
||||
c.hub.Unregister(c)
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
|
||||
c.conn.SetReadLimit(8192)
|
||||
c.conn.SetReadLimit(4 * 1024 * 1024) // 4MB for large document CRDT updates
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
@@ -137,10 +727,62 @@ func (c *Client) readPump() {
|
||||
})
|
||||
|
||||
for {
|
||||
_, _, err := c.conn.ReadMessage()
|
||||
_, message, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Handle client-initiated lock, CRDT, room subscription, and presence messages
|
||||
var incoming struct {
|
||||
Event string `json:"event"`
|
||||
DocID string `json:"docId"`
|
||||
Room string `json:"room"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
if json.Unmarshal(message, &incoming) == nil && c.userID != "" && c.userID != "anonymous" {
|
||||
docID := incoming.DocID
|
||||
if docID == "" && len(incoming.Payload) > 0 {
|
||||
var p struct {
|
||||
DocID string `json:"docId"`
|
||||
}
|
||||
_ = json.Unmarshal(incoming.Payload, &p)
|
||||
docID = p.DocID
|
||||
}
|
||||
|
||||
// Room subscription without requiring docID
|
||||
if incoming.Event == "JOIN_ROOM" {
|
||||
room := incoming.Room
|
||||
if room == "" {
|
||||
room = docID
|
||||
}
|
||||
if room != "" {
|
||||
c.hub.JoinRoom(c, room)
|
||||
}
|
||||
} else if incoming.Event == "LEAVE_ROOM" {
|
||||
room := incoming.Room
|
||||
if room == "" {
|
||||
room = docID
|
||||
}
|
||||
if room != "" {
|
||||
c.hub.LeaveRoom(c, room)
|
||||
}
|
||||
}
|
||||
|
||||
if docID != "" {
|
||||
switch incoming.Event {
|
||||
case "LOCK_DOC":
|
||||
if info, ok, _ := c.hub.AcquireDocLock(docID, c.userID, "", 30*time.Second); ok && info != nil {
|
||||
c.hub.BroadcastDocEvent(docID, "DOC_LOCKED", info)
|
||||
}
|
||||
case "HEARTBEAT_DOC":
|
||||
c.hub.HeartbeatDocLock(docID, c.userID, 30*time.Second)
|
||||
case "UNLOCK_DOC":
|
||||
if c.hub.ReleaseDocLock(docID, c.userID, false) {
|
||||
c.hub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ CREATE TABLE IF NOT EXISTS thoughts (
|
||||
converted_type TEXT DEFAULT '',
|
||||
converted_ref_id TEXT DEFAULT '',
|
||||
due_date TEXT DEFAULT '',
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
triaged_at TEXT DEFAULT '',
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
@@ -31,6 +32,7 @@ CREATE TABLE IF NOT EXISTS thoughts (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_thoughts_user_status ON thoughts(user_id, status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_thoughts_user_due ON thoughts(user_id, due_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_thoughts_list_sort ON thoughts(task_list_id, sort_order ASC, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS spaces (
|
||||
id TEXT PRIMARY KEY,
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 52 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 261 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 249 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 315 KiB |
Reference in new issue
Block a user