Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ab138606 | ||
|
|
75a7b67d09 | ||
|
|
acefa3160c | ||
|
|
1fac1a80b7 | ||
|
|
5f51637c78 | ||
|
|
a62b1893b4 | ||
|
|
a044620cec | ||
|
|
b07a7acf42 | ||
|
|
e509f685c1 | ||
|
|
d6c70d7a8e | ||
|
|
0e9bfab943 | ||
|
|
9c6598cb85 | ||
|
|
71fa53ead3 | ||
|
|
9eeb32f085 | ||
|
|
a62b204e14 | ||
|
|
e74661e0c7 | ||
|
|
633bd6f1ef | ||
|
|
a354f3d008 | ||
|
|
ce321ce579 | ||
|
|
2eff9319b5 | ||
|
|
bf0d68e7e1 | ||
|
|
84395f3322 | ||
|
|
73fad1c5db | ||
|
|
1bd38f4d50 | ||
|
|
3540b4b2ae | ||
|
|
f82ab2fd32 | ||
|
|
da12b3946a | ||
|
|
41b02b9564 | ||
|
|
be85ea3093 | ||
|
|
1220919257 | ||
|
|
4e39b367bd | ||
|
|
f26f286fb8 | ||
|
|
aea4324c20 | ||
|
|
8b7a53daab | ||
|
|
aa397c18cd | ||
|
|
08d9b2dc77 | ||
|
|
51f90eb7e4 | ||
|
|
1ea0c78b41 | ||
|
|
bcc06da3ee | ||
|
|
74797e3f44 | ||
|
|
af578d8237 | ||
|
|
8a4b4820df | ||
|
|
106a34d260 | ||
|
|
2b5291dc7b | ||
|
|
b4c8577a61 | ||
|
|
c7e05b385c |
No files matched your search
@@ -7,3 +7,4 @@ frontend/dist
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.env
|
||||
e2e
|
||||
@@ -17,3 +17,11 @@ SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=noreply@mindsift.local
|
||||
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=
|
||||
@@ -1,17 +1,21 @@
|
||||
name: MindSift CI & Docker Release
|
||||
|
||||
# Image tags:
|
||||
# push to dev -> :dev
|
||||
# push/merge main -> :latest
|
||||
# git tag v1.2.0 -> :v1.2.0 and :stable
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
branches:
|
||||
- dev
|
||||
- main
|
||||
tags:
|
||||
- '*'
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build-test-push:
|
||||
name: Build, Test in Container & Push
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref != 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
@@ -33,11 +37,25 @@ jobs:
|
||||
# Ersetze evtl. Slashes in Branch-Namen (z.B. rc/1.1 -> rc-1.1) für valide Docker-Tags
|
||||
DOCKER_TAG=$(echo "${REF_NAME}" | sed 's#/#-#g' | tr '[:upper:]' '[:lower:]')
|
||||
|
||||
# All tags of this run (space separated)
|
||||
case "${GITHUB_REF:-}" in
|
||||
refs/tags/*) TAGS="${DOCKER_TAG} stable" ;;
|
||||
refs/heads/main) TAGS="latest" ;;
|
||||
*) TAGS="${DOCKER_TAG}" ;;
|
||||
esac
|
||||
|
||||
echo "registry=${REGISTRY_HOST}" >> "$GITHUB_OUTPUT"
|
||||
echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${DOCKER_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${TAGS%% *}" >> "$GITHUB_OUTPUT"
|
||||
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
||||
echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT"
|
||||
echo "Target Docker image: ${IMAGE_NAME}:${DOCKER_TAG} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})"
|
||||
# Releases (tags) show the tag as version in the app header
|
||||
APP_VERSION=""
|
||||
case "${GITHUB_REF:-}" in
|
||||
refs/tags/*) APP_VERSION="${REF_NAME}" ;;
|
||||
esac
|
||||
echo "app_version=${APP_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "Target Docker image: ${IMAGE_NAME} tags: ${TAGS} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})"
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -48,8 +66,10 @@ jobs:
|
||||
|
||||
- name: Build & Run All Checks in Container, then Push
|
||||
run: |
|
||||
FULL_IMAGE="${{ steps.prep.outputs.image }}:${{ steps.prep.outputs.tag }}"
|
||||
echo "🚀 Building container & executing Frontend/Backend checks inside image: ${FULL_IMAGE}"
|
||||
DOCKER_BUILDKIT=1 docker build --progress=plain -t "${FULL_IMAGE}" .
|
||||
echo "✅ All checks & tests passed inside container! Pushing ${FULL_IMAGE}..."
|
||||
docker push "${FULL_IMAGE}"
|
||||
IMAGE="${{ steps.prep.outputs.image }}"
|
||||
TAG_ARGS=""
|
||||
for t in ${{ steps.prep.outputs.tags }}; do TAG_ARGS="${TAG_ARGS} -t ${IMAGE}:${t}"; done
|
||||
echo "🚀 Building container & executing Frontend/Backend checks inside image: ${IMAGE} (tags: ${{ steps.prep.outputs.tags }})"
|
||||
DOCKER_BUILDKIT=1 docker build --progress=plain --build-arg APP_VERSION="${{ steps.prep.outputs.app_version }}" ${TAG_ARGS} .
|
||||
echo "✅ All checks & tests passed inside container! Pushing..."
|
||||
for t in ${{ steps.prep.outputs.tags }}; do docker push "${IMAGE}:${t}"; done
|
||||
@@ -17,3 +17,4 @@ dev-data/
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
e2e/node_modules/
|
||||
@@ -6,7 +6,9 @@ WORKDIR /app/frontend
|
||||
COPY frontend/package*.json ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
COPY frontend/ ./
|
||||
RUN npx tsc --noEmit && npm run build
|
||||
# Version shown in the header: the git tag for releases (set by CI), otherwise "<package version>-dev"
|
||||
ARG APP_VERSION=""
|
||||
RUN npx tsc --noEmit && 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)
|
||||
|
||||
@@ -13,19 +13,22 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code
|
||||
| [modules/triage.md](modules/triage.md) | Schnellerfassung und Triage |
|
||||
| [modules/tasks.md](modules/tasks.md) | Aufgaben, Kanban, Alarme |
|
||||
| [modules/workspace.md](modules/workspace.md) | Outliner-Notizen, Ordner, Tags |
|
||||
| [modules/collaboration.md](modules/collaboration.md) | Gemeinsames Bearbeiten von Notizen: Log, Sperren, Konflikte, Offline, Transport-Stufen |
|
||||
| [modules/lists.md](modules/lists.md) | Geteilte Listen |
|
||||
| [sharing-and-permissions.md](sharing-and-permissions.md) | Rollen, Gruppen, Freigaben |
|
||||
| [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare |
|
||||
| [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 |
|
||||
|
||||
## Überblick in 60 Sekunden
|
||||
|
||||
- **Ein Go-Binary** liefert REST-API, WebSocket/SSE und das eingebettete React-Frontend (`go:embed`). Es gibt genau einen Container und eine SQLite-Datei (`/data/mindsift.db`).
|
||||
- **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. Verteilt werden Ereignisse nach REST-Änderungen. Es gibt keine CRDTs, Sperren oder Cursor-Presence.
|
||||
- **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)).
|
||||
- **Auth:** JWT (HS256, 30 Tage) mit `token_version` und Sperrliste, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
|
||||
- **Freigaben:** Aufgabenlisten und geteilte Listen mit Rolle Bearbeiter oder Leser, Notizen und Ordner nur lesend.
|
||||
- **Freigaben:** Aufgabenlisten, geteilte Listen, Notizen und Ordner jeweils mit Rolle Bearbeiter oder Leser.
|
||||
|
||||
## Entwickeln
|
||||
|
||||
@@ -34,7 +37,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code
|
||||
go vet ./... && go test ./...
|
||||
|
||||
# Frontend (Node 22)
|
||||
cd frontend && npm ci && npx tsc --noEmit && npm run build
|
||||
cd frontend && npm ci && npx tsc --noEmit && npm run test:run && npm run build
|
||||
```
|
||||
|
||||
Das Frontend wird für das Binary nach `cmd/server/dist` gebaut (siehe `Dockerfile`). Die CI läuft unter `.gitea/workflows/build-and-publish.yaml`.
|
||||
@@ -7,38 +7,67 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| 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/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
|
||||
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
||||
| 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 |
|
||||
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md) |
|
||||
| GET | `/events` | SSE-Fallback |
|
||||
| POST | `/events/rooms` | SSE-Räume beitreten/verlassen ([FLAW-02](../flaws.md)) |
|
||||
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig |
|
||||
| GET | `/events` | SSE-Fallback; Token als `?token=` nötig |
|
||||
|
||||
## Konto und Allgemeines (angemeldet)
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| 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 | `/auth/logout` | Token sperren |
|
||||
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
|
||||
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` |
|
||||
| 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 | `/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/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 |
|
||||
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
|
||||
|
||||
### 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
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| 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/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`); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
|
||||
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
||||
| GET | `/triage/deck` | Posteingang (max. 100) |
|
||||
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
|
||||
@@ -67,17 +96,50 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
|
||||
| GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) |
|
||||
| GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen |
|
||||
| PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) |
|
||||
| GET/POST/DELETE | `/workspace/folders/shared/{id}/shares[/{shareId}]` | Freigaben (nur Lesen wirksam) |
|
||||
| GET/POST/DELETE | `/workspace/folders/shared/{id}/shares[/{shareId}]` | Freigaben (`permission`: `read`/`write`) |
|
||||
| GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
|
||||
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
|
||||
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
|
||||
| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`); Löschen |
|
||||
| PATCH | `/documents/{id}/blocks` | Teil-Update der Blöcke (`upserted`, `deletedIds`, Reihenfolge); löst `DOC_BLOCKS_PATCHED` aus |
|
||||
| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen |
|
||||
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
|
||||
| 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`; wirksam nur Lesen) |
|
||||
| GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
|
||||
|
||||
Nur der Eigentümer schreibt in eine Notiz; Empfänger erhalten `403`/„nur Lesezugriff“ ([FLAW-17](../flaws.md)).
|
||||
Der Inhalt einer Notiz (Blöcke) wird ausschließlich über `POST /docs/{id}/sync` geändert. `POST /documents` nimmt beim Anlegen weiterhin eine Start-Blockliste an; sie wird intern in Operationen umgerechnet.
|
||||
|
||||
## Gemeinsames Bearbeiten von Notizen
|
||||
|
||||
Konzept und Regeln: [modules/collaboration.md](../modules/collaboration.md). Zugriff über die Rolle `owner`/`editor`/`viewer` (`Store.DocRole`); ohne Rolle `404`.
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET | `/docs/{id}/snapshot` | Kompletter Blockbaum inkl. Grabsteinen, `title`, `titleVersion`, `headSeq`, `role`, `shared` |
|
||||
| POST | `/docs/{id}/sync` | Ein Rundlauf: eigene Operationen senden, Leases halten/abgeben, Log ab `lastSeq` abholen |
|
||||
| GET | `/docs/{id}/changes?since=N&wait=S` | Long-Polling: antwortet sofort, wenn `headSeq > N`, sonst bei der nächsten Änderung (auch Leases/Präsenz) oder nach höchstens 25 s. Antwort `{headSeq, changed}`; Daten holt der Client danach per `/sync` |
|
||||
| POST | `/docs/{id}/leave` | Sitzung beenden, alle Leases des Clients freigeben (`{clientId}`) |
|
||||
|
||||
`/sync`-Anfrage:
|
||||
|
||||
```json
|
||||
{
|
||||
"clientId": "tab-7f3…",
|
||||
"lastSeq": 412,
|
||||
"ops": [
|
||||
{ "opId": "c1a…", "kind": "set_content", "blockId": "b9…", "baseVersion": 7, "content": "Milch und Brot" },
|
||||
{ "opId": "c1b…", "kind": "insert", "blockId": "bA…", "parentId": "b2…", "orderKey": "a0V", "type": "bullet", "content": "" }
|
||||
],
|
||||
"leases": { "hold": ["b9…", "bA…"], "release": [] }
|
||||
}
|
||||
```
|
||||
|
||||
- Operationsarten: `insert`, `set_content`, `set_props` (`patch`: `type`, `checked`, `collapsed`, `lang`, `clearConflict`), `move`, `delete`, `restore` (`ids`), `set_title` (`baseVersion`, `title`).
|
||||
- `leases.hold` ist die vollständige Liste der Blöcke, die der Client halten will; alle anderen Leases des Clients werden freigegeben. Neu eingefügte Blöcke und Konfliktkopien bekommt der Autor automatisch.
|
||||
- Höchstens 500 Operationen pro Anfrage. Leser dürfen keine Operationen senden (`403`), aber abfragen.
|
||||
- `skipLog: true` liefert kein Log zurück; gedacht für einzelne Änderungen ohne geöffneten Editor (z. B. Abhaken in der To-Do-Übersicht).
|
||||
|
||||
Antwort: `results` (je Operation `status` `applied`/`conflict`/`rejected`, ggf. `conflictBlockId`, `version`, `seq`), `ops` (Log-Einträge `{seq, opId, userId, clientId, effect}` ab `lastSeq`, höchstens 1000; `hasMore` bei mehr), `headSeq`, `resync` (Snapshot neu laden: der Client ist dem Server voraus, oder sein `lastSeq` liegt vor der verdichteten Grenze `log_floor`; die gesendeten Operationen werden trotzdem angewendet), `role`, `shared`, `sessions`, `presence`, `leases` (`granted`, `denied`, `others`).
|
||||
|
||||
Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Antwort enthält das gespeicherte Ergebnis.
|
||||
|
||||
## Geteilte Listen
|
||||
|
||||
@@ -97,21 +159,24 @@ Nur der Eigentümer schreibt in eine Notiz; Empfänger erhalten `403`/„nur Les
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
| GET/POST/DELETE | `/organizations`, `/organizations/{id}[/teams]` | Veraltet, ungeschützt ([FLAW-24](../flaws.md)) |
|
||||
|
||||
## Administration (`role = admin`)
|
||||
|
||||
| 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 |
|
||||
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
|
||||
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `teamId`, `validDays`) |
|
||||
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode`, SMTP-Felder |
|
||||
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `organizationId`, `validDays`) |
|
||||
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben |
|
||||
| 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`) |
|
||||
|
||||
## Beispiele
|
||||
|
||||
@@ -11,9 +11,18 @@ Der Server verteilt Änderungen als JSON-Ereignisse an verbundene Clients. Die E
|
||||
| WebSocket | `GET /api/v1/ws` (auch `/ws/lists/{id}`, `/ws/spaces/{id}`) | Ping alle 30 s, Lesefrist 60 s, Nachrichten bis 4 MB |
|
||||
| SSE | `GET /api/v1/events` | Kommentar-Ping alle 15 s, Header `X-Accel-Buffering: no` |
|
||||
|
||||
Der Client versucht zuerst WebSocket. Gelingt der Aufbau nicht innerhalb von 2,5 s oder bricht die Verbindung unsauber ab, wechselt er auf SSE und bleibt dabei (Firmen-Proxys). Dort liefert der Server `SSE_CONNECTED` mit einer `sseId`; Räume werden über `POST /api/v1/events/rooms` (`sseId`, `room`, `action`: `join`/`leave`) abonniert.
|
||||
Der Client versucht zuerst WebSocket und wechselt in folgenden Fällen für die Sitzung des Tabs auf SSE:
|
||||
|
||||
**Authentifizierung:** Token als `?token=<JWT|API-Key>` oder `Authorization`-Header. Ohne gültiges Token wird die Verbindung als `anonymous` akzeptiert ([FLAW-01](../flaws.md), [FLAW-02](../flaws.md)). Das Token wird nur beim Verbinden geprüft.
|
||||
- Aufbau gelingt nicht innerhalb von 2,5 s, oder die Verbindung bricht unsauber ab (z. B. Code 1006/1008).
|
||||
- **Herzschlag:** Alle 15 s schickt der Client `{"event":"PING"}`, der Server antwortet `PONG`. Kommt 35 s lang keine Nachricht, gilt der WebSocket als stumm (Proxy nimmt das Upgrade an, leitet aber nichts weiter).
|
||||
|
||||
Über SSE liefert der Server zuerst `SSE_CONNECTED` mit einer `sseId`; erst dann gilt SSE als verbunden. Bleibt dieses erste Ereignis 5 s nach dem Öffnen aus, puffert ein Proxy den Stream: SSE wird für den Tab abgeschaltet. Räume werden über `POST /api/v1/events/rooms` (`sseId`, `room`, `action`: `join`/`leave`) abonniert.
|
||||
|
||||
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)).
|
||||
|
||||
**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.
|
||||
|
||||
## Nachrichtenformat
|
||||
|
||||
@@ -21,7 +30,7 @@ Der Client versucht zuerst WebSocket. Gelingt der Aufbau nicht innerhalb von 2,5
|
||||
{ "event": "TASK_UPDATED", "listId": "", "payload": { … }, "timestamp": "2026-10-27T12:00:00Z" }
|
||||
```
|
||||
|
||||
Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":"<id>"}` und `{"event":"LEAVE_ROOM","room":"<id>"}`. Räume heißen `doc:<docId>` für Notizen oder `<listId>` für geteilte Listen.
|
||||
Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":"<id>"}`, `{"event":"LEAVE_ROOM","room":"<id>"}` und `{"event":"PING"}` (Antwort `PONG`). Räume heißen `doc:<docId>` für Notizen oder `<listId>` für geteilte Listen.
|
||||
|
||||
## Zustellung
|
||||
|
||||
@@ -29,20 +38,20 @@ Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":"<id>"}`
|
||||
|---|---|
|
||||
| Benutzer-Kanal (`BroadcastToUser`) | alle Verbindungen eines Benutzers |
|
||||
| Raum (`BroadcastRoom`, `Broadcast`) | Verbindungen im Raum |
|
||||
| Alle (`BroadcastToAll`) | **jede** Verbindung, auch unangemeldete |
|
||||
| Mehrere Benutzer (`BroadcastToUsers`) | alle Verbindungen einer berechneten Benutzergruppe, z. B. Eigentümer und Mitglieder einer Aufgabenliste |
|
||||
|
||||
## Ereignisse
|
||||
|
||||
| Ereignis | Auslöser | Kanal |
|
||||
|---|---|---|
|
||||
| `TASK_CREATED` | `POST /inbox` | Benutzer |
|
||||
| `TASK_UPDATED` | `PATCH /thoughts/{id}` (Triage: Benutzer) | alle |
|
||||
| `TASK_DELETED` | `DELETE /thoughts/{id}` | alle |
|
||||
| `TASK_REORDERED` | Aufgaben umsortieren | alle |
|
||||
| `TASK_LIST_UPDATED` / `TASK_LIST_DELETED` | Liste ändern, (un)teilen, löschen | alle |
|
||||
| `TASK_CREATED` | `POST /inbox`, Webhook | Eigentümer und Benutzer der Liste |
|
||||
| `TASK_UPDATED` | `PATCH /thoughts/{id}`, Triage | Eigentümer und Benutzer der alten und neuen Liste |
|
||||
| `TASK_DELETED` | `DELETE /thoughts/{id}` | Eigentümer und Benutzer der Liste |
|
||||
| `TASK_REORDERED` | Aufgaben umsortieren | Benutzer aller betroffenen Listen |
|
||||
| `TASK_LIST_UPDATED` / `TASK_LIST_DELETED` | Liste ändern, (un)teilen, löschen; Payload nur `{id}`, Clients laden ihre Listen neu | Benutzer vor und nach der Änderung |
|
||||
| `DOC_CREATED`, `DOC_UPDATED`, `DOC_DELETED` | Notiz anlegen/ändern/löschen, Freigaben | berechtigte Benutzer |
|
||||
| `DOC_BLOCKS_PATCHED` | `PATCH /documents/{id}/blocks` (`docId`, `updatedBy`, `upserted`, `deletedIds`, `blockOrder`) | Raum `doc:<id>` und berechtigte Benutzer |
|
||||
| `WORKSPACE_FOLDERS_UPDATED` | Ordner/Mounts ändern | Benutzer |
|
||||
| `DOC_SEQ` | Änderung an Notiz, Leases oder Präsenz (`docId`, `headSeq`); Clients holen die Daten per `/docs/{id}/sync` | Raum `doc:<id>` |
|
||||
| `WORKSPACE_FOLDERS_UPDATED` | Ordner/Mounts ändern | Benutzer selbst; bei geteilten Ordnern Eigentümer und alle Beteiligten |
|
||||
| `LIST_CREATED`, `LIST_UPDATED`, `LIST_DELETED`, `LIST_SHARED`, `LIST_UNSHARED`, `LIST_CLEARED` | Listenverwaltung | Benutzer bzw. Raum der Liste |
|
||||
| `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste |
|
||||
| `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum |
|
||||
@@ -52,4 +61,4 @@ Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":"<id>"}`
|
||||
|
||||
- Die globale Verbindung (ein Singleton) leitet jede Nachricht als Browser-Ereignis `mindsift:ws` weiter; Ansichten (`TasksView`, `WorkspaceView`, `SharedListsView`) hören darauf und laden bzw. patchen ihren Zustand.
|
||||
- Nach einem Abbruch verbindet sich der Client mit Verzögerung neu.
|
||||
- Weil Notizen nur vom Eigentümer geschrieben werden, dient `DOC_BLOCKS_PATCHED` vor allem dazu, Lesenden und weiteren Geräten des Eigentümers Änderungen zu zeigen. Es gibt keine Konfliktauflösung (letzter Schreiber gewinnt).
|
||||
- Notiz-Inhalte kommen nie per Push, sondern immer über `POST /docs/{id}/sync`; `DOC_SEQ` ist nur der Weckruf. Konflikte entscheidet der Server (Versionen und Sperren, siehe [modules/collaboration.md](../modules/collaboration.md)).
|
||||
@@ -34,10 +34,13 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
||||
| `internal/storage/` | `sqlite.go` (Schema, Migrationen, gesamte Datenlogik) |
|
||||
| `internal/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion |
|
||||
| `internal/ws/` | `hub.go` |
|
||||
| `internal/collab/` | Kern der Notiz-Synchronisation ohne Datenbank: Blockbaum, Operationen und Konfliktregeln, Sortierschlüssel, Leases, Long-Poll-Benachrichtigung |
|
||||
| `internal/auth/` | Tokens und Passwörter |
|
||||
| `internal/config/` | Umgebungsvariablen |
|
||||
| `frontend/src/features/` | `triage`, `workspace`, `lists`, `teams`, `settings`, `capture`, `auth`, `help` |
|
||||
| `frontend/src/lib/` | `api.ts` (REST-Client, Transport-Umschaltung WS/SSE), `types.ts`, `markdown.tsx`, `theme.ts` |
|
||||
| `frontend/src/lib/docSync/` | Notiz-Synchronisation im Browser: Portierung von `internal/collab` (`model.ts`, `orderKey.ts`), Vergleich Editor-Liste → Operationen (`diff.ts`), `engine.ts`, IndexedDB (`persist.ts`), HTTP (`transport.ts`) |
|
||||
| `e2e/` | Browser-Tests mit Playwright gegen einen echten Server (`e2e/run.sh`) |
|
||||
| `frontend/src/themes/` | CSS pro Theme |
|
||||
|
||||
## Start und Laufzeit
|
||||
@@ -46,7 +49,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
||||
2. `storage.NewStore()` öffnet SQLite (`busy_timeout`, WAL, max. 8 Verbindungen) und führt `migrate()` aus.
|
||||
3. SMTP-Werte aus der Umgebung werden in `system_settings` übernommen, sofern dort noch nichts steht.
|
||||
4. Mit Demo-Flag legt `EnsureDemoAccounts` die Konten `admin/admin` und `user/user` an.
|
||||
5. `api.NewServer` startet den **Alarm-Ticker** (jede Minute) und baut den Router. Server-Timeouts: Lesen 15 s, Schreiben 30 s, Leerlauf 60 s. Beim Beenden wird sauber heruntergefahren.
|
||||
5. `api.NewServer` startet den **Alarm-Ticker** (jede Minute), die **Verdichtung des Notiz-Logs** (beim Start, dann alle 6 Stunden) und baut den Router. Server-Timeouts: Lesen 15 s, Schreiben 30 s, Leerlauf 60 s. Beim Beenden wird sauber heruntergefahren.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
@@ -61,6 +64,9 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
|
||||
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
|
||||
| `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.
|
||||
|
||||
@@ -76,12 +82,40 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
|
||||
|
||||
`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).
|
||||
|
||||
## Hintergrundjob: Verdichtung der Notiz-Synchronisation
|
||||
|
||||
`startCompaction` räumt beim Start und dann alle 6 Stunden auf: abgelaufene Leases und Sitzungen im Speicher, Log-Einträge älter als 30 Tage (private Notizen: 7 Tage), Grabsteine älter als 30 Tage und Idempotenz-Ergebnisse älter als 90 Tage. Details: [database.md](database.md#verdichtung-des-notiz-logs).
|
||||
|
||||
## Build
|
||||
|
||||
```
|
||||
Stage 1 node:22-alpine npm ci && npm run build → frontend/dist
|
||||
Stage 1 node:22-alpine tsc, vitest, vite build → frontend/dist
|
||||
Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (dist wird eingebettet)
|
||||
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
|
||||
```
|
||||
|
||||
Die Tests laufen beim Image-Build (`go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests existieren nicht ([FLAW-19](flaws.md)).
|
||||
**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.
|
||||
|
||||
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`.
|
||||
|
||||
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`).
|
||||
|
||||
## 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`.
|
||||
@@ -9,9 +9,10 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
|
||||
### Benutzer und Zugang
|
||||
| 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` |
|
||||
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `team_id`, `expires_at`, `used_by` |
|
||||
| `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`, `organization_id`, `expires_at`, `used_by` |
|
||||
| `revoked_tokens` | Sperrliste ausgeloggter JWTs | `token_hash`, `expires_at` |
|
||||
| `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` |
|
||||
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
|
||||
|
||||
@@ -19,7 +20,8 @@ Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist vera
|
||||
| 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` …) |
|
||||
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `team_id` (Altfeld), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
|
||||
| `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`) |
|
||||
|
||||
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,12 +29,14 @@ Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND
|
||||
### Workspace
|
||||
| Tabelle | Zweck |
|
||||
|---|---|
|
||||
| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived`, `markdown_cache`, `shared_folder_id`, `sub_folder_path`, Altspalten `content`, `blocks_json` |
|
||||
| `blocks` | Outliner-Blöcke: `document_id`, `type`, `content`, `level`, `position` (REAL), `checked`, `collapsed`, `lang` |
|
||||
| `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` |
|
||||
| `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_op_results` | Ergebnis je Client-Operation (`op_id` eindeutig) für idempotente Wiederholungen; wird nach 90 Tagen gelöscht |
|
||||
| `block_tags` | `#Hashtags` je Block (`tag`, `document_id`, `block_id`) |
|
||||
| `tag_pages` | Freitext-Notiz je Benutzer und Tag |
|
||||
| `doc_pins` | Favoriten je Benutzer |
|
||||
| `doc_shares`, `doc_team_shares` | Notiz-Freigaben an Benutzer bzw. Gruppen (`permission` effektiv immer `read`) |
|
||||
| `doc_shares`, `doc_team_shares` | Notiz-Freigaben an Benutzer bzw. Gruppen (`permission`: `read` = Leser, `write` = Bearbeiter) |
|
||||
| `workspace_folders` | Explizit angelegte Ordnerpfade |
|
||||
| `workspace_shared_folders` | Geteilte Ordner (Entität mit Eigentümer) |
|
||||
| `workspace_folder_shares` | Freigaben geteilter Ordner |
|
||||
@@ -46,14 +50,14 @@ Blocktypen: `h1`, `h2`, `h3`, `paragraph`, `text`, `todo`, `bullet`, `code`, `ca
|
||||
|---|---|
|
||||
| `lists` | Geteilte Listen: `title`, `icon`, `list_type` (`shopping`/`checklist`/`vacation`) |
|
||||
| `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
|
||||
| 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`) |
|
||||
| `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
|
||||
|
||||
@@ -74,10 +78,23 @@ teams 1─n team_members n─1 users
|
||||
- Spalten/Tabellen/Indizes werden nachgezogen (`ALTER TABLE … ADD COLUMN`, Fehler werden ignoriert).
|
||||
- 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.
|
||||
- Alle Dokument-, Team- und Ordnerfreigaben werden auf `read` gesetzt (bei **jedem** Start, siehe [FLAW-17](flaws.md)).
|
||||
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=teams_only`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
||||
- Die Tabelle `teams` wird einmalig ohne Fremdschlüssel auf `organizations` neu aufgebaut.
|
||||
- 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=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
||||
- **Schema-Version:** `PRAGMA user_version` hält `schemaVersion` (derzeit 2). Ist die Datenbank älter, werden vor allem anderen **alle Tabellen gelöscht** und neu angelegt. Bewusst so, solange MindSift nicht produktiv läuft.
|
||||
|
||||
## Sicherung
|
||||
|
||||
Die gesamte Instanz besteht aus der Datei `mindsift.db` (plus `-wal`/`-shm` im laufenden Betrieb). Konsistent sichern per `sqlite3 mindsift.db ".backup 'backup.db'"` oder durch Stoppen des Containers und Kopieren des `data/`-Verzeichnisses. Benutzerexport: `GET /api/v1/export` (JSON mit Gedanken, Notizen, Listen, Aufgabenlisten).
|
||||
|
||||
## Verdichtung des Notiz-Logs
|
||||
|
||||
`Store.CompactDocs` läuft beim Start und danach alle 6 Stunden (`startCompaction` in `internal/api/docsync.go`):
|
||||
|
||||
| Was | Aufbewahrung | Folge |
|
||||
|---|---|---|
|
||||
| `doc_ops` geteilter Notizen | 30 Tage | ältere Einträge werden gelöscht, `documents.log_floor` merkt sich die Grenze |
|
||||
| `doc_ops` privater Notizen | 7 Tage | wie oben |
|
||||
| Grabsteine in `blocks` | 30 Tage nach `deleted_at` | endgültig gelöscht; das ist selbst ein Log-Eintrag `purge`, geöffnete Editoren übernehmen ihn ohne Neuladen. Ein Grabstein bleibt, solange ein jüngerer gelöschter Unterblock auf ihn zeigt. |
|
||||
| `doc_op_results` | 90 Tage | gelöscht |
|
||||
|
||||
Ein Client, dessen `lastSeq` unter `log_floor` liegt (z. B. Gerät wochenlang offline), bekommt von `/sync` `resync: true` und lädt den Snapshot neu; seine noch nicht gesendeten Änderungen bleiben in der Warteschlange und werden danach angewendet. Text für einen inzwischen endgültig gelöschten Block wird als neuer Block am Ende der Notiz angelegt, ein neuer Block unter einem endgültig gelöschten Elternblock landet auf der obersten Ebene.
|
||||
@@ -10,46 +10,51 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
||||
|
||||
| ID | Bereich | Kurzbeschreibung | Schwere |
|
||||
|---|---|---|---|
|
||||
| FLAW-01 | Realtime | `BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen, auch unauthentifizierte | **Hoch** |
|
||||
| FLAW-02 | Realtime | Räume (WS `JOIN_ROOM`, SSE `/events/rooms`, `?listId=`) ohne Berechtigungsprüfung | **Hoch** |
|
||||
| FLAW-03 | Aufgaben | `ReorderTasks` ändert beliebige Aufgaben per ID, ohne Zugriffsprüfung (IDOR) | **Hoch** |
|
||||
| FLAW-01 | Realtime | ~~`BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen~~ | **Behoben** |
|
||||
| FLAW-02 | Realtime | ~~Räume ohne Berechtigungsprüfung~~ | **Behoben** |
|
||||
| FLAW-03 | Aufgaben | ~~`ReorderTasks` ändert beliebige Aufgaben per ID (IDOR)~~ | **Behoben** |
|
||||
| FLAW-04 | Auth | Fest eincodiertes Fallback-`JWT_SECRET` | **Hoch** |
|
||||
| FLAW-05 | Benachrichtigungen | Discord-/Webhook-URLs ungeprüft (SSRF) | Mittel |
|
||||
| FLAW-06 | Aufgaben | Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben | Mittel |
|
||||
| FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** |
|
||||
| FLAW-07 | Auth | API-Key-`scopes` werden nie ausgewertet; ein Key ist ein Vollzugriff | Mittel |
|
||||
| FLAW-08 | Auth | Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active` | Mittel |
|
||||
| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel |
|
||||
| FLAW-10 | Freigaben | `sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten | Mittel |
|
||||
| FLAW-11 | Workspace | Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht | Mittel |
|
||||
| FLAW-10 | Freigaben | ~~`sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten~~ | **Behoben** |
|
||||
| FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** |
|
||||
| FLAW-12 | Benachrichtigungen | `notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder | Mittel |
|
||||
| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel |
|
||||
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
|
||||
| FLAW-15 | Realtime | `WriteTimeout` von 30 s trifft vermutlich auch SSE-Streams *(nicht live verifiziert)* | Mittel |
|
||||
| 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 | Dokument- und Ordnerfreigaben sind per Migration erzwungen schreibgeschützt; tote Schreibpfade | Niedrig |
|
||||
| FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig |
|
||||
| FLAW-19 | Qualität | Keine Frontend-Tests, `lint` und `test:run` sind Platzhalter | Niedrig |
|
||||
| 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~~ | **Behoben** |
|
||||
| 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-24 | Gruppen | ~~Organisations-Endpunkte ohne Berechtigungsprüfung~~ | **Behoben** |
|
||||
| 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
|
||||
|
||||
### FLAW-01: Broadcast an alle Verbindungen (Datenabfluss)
|
||||
- **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`.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **Maßnahme:** Ereignisse an die berechtigten Nutzer adressieren (wie bei `DOC_*` über `GetAuthorizedDocUsers`), unauthentifizierte Verbindungen ablehnen.
|
||||
|
||||
### FLAW-02: Räume ohne Berechtigungsprüfung
|
||||
- **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`.
|
||||
- **Fundstellen:** `Hub.readPump` (`JOIN_ROOM`), `handleSSERooms` (Route `/api/v1/events/rooms` liegt **außerhalb** der Auth-Gruppe), `Hub.Register` (Raum aus `?listId=`/`?spaceId=`).
|
||||
- **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.
|
||||
- **Maßnahme:** Beitritt nur nach Zugriffsprüfung (`GetDocumentByID`, `GetListByID`); `/events/rooms` hinter `authMiddleware`.
|
||||
|
||||
### FLAW-03: `ReorderTasks` ohne Zugriffsprüfung (IDOR)
|
||||
- **Status: Behoben.** `handleReorderTasks` lehnt die ganze Anfrage ab, sobald eine Aufgabe nicht zugreifbar (`404`) oder schreibgeschützt (`403`) ist. Test: `TestReorderRejectsForeignTasks`.
|
||||
- **Fundstelle:** `Store.ReorderTasks` (`UPDATE thoughts SET sort_order … WHERE id = ?`) und `handleReorderTasks`.
|
||||
- **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:** Jede ID gegen `accessibleTaskListIDsQuery` bzw. `user_id` prüfen und bei Verstoß ablehnen.
|
||||
@@ -65,6 +70,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
||||
- **Maßnahme:** Nur `https`, Discord-Host prüfen, private/Loopback-Adressen beim Verbinden sperren.
|
||||
|
||||
### FLAW-06: Editor kann Aufgabenliste weiterteilen
|
||||
- **Status: Behoben.** Das Feld `task_lists.team_id` ist entfernt; Freigaben gibt es nur noch über `task_list_shares`, und die legt nur der Eigentümer an.
|
||||
- **Fundstelle:** `Store.UpdateTaskList` (`teamID`-Zweig ruft `AddTaskListShare`); `handleUpdateTaskList` prüft nur, dass der Nutzer **kein** Leser ist.
|
||||
- **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.
|
||||
- **Maßnahme:** `teamId`-Änderungen nur für den Eigentümer; das Legacy-Feld `task_lists.team_id` zugunsten von `task_list_shares` abschaffen.
|
||||
@@ -85,6 +91,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
||||
- **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort.
|
||||
|
||||
### FLAW-10: `sharing_mode` wird nicht durchgesetzt
|
||||
- **Status: Behoben.** Modi `private`/`organizations`/`open`, zentral durchgesetzt über `Store.CanSee`/`CanShareWith` in Verzeichnis, allen Freigaben und Gruppen; Rechte werden bei Sichtbarkeitsverlust bereinigt ([sharing-and-permissions.md](sharing-and-permissions.md)). Test: `internal/storage/visibility_test.go`.
|
||||
- **Fundstellen:** `Store.GetDiscoverableUsers`, `Store.GetCollaborators`; Share-Handler in `server.go`.
|
||||
- **Problem:**
|
||||
1. Der Modus beeinflusst nur `GetDiscoverableUsers`. In `teams_only` liefert die Funktion trotzdem **alle** Benutzer (`if … mode == "open" || mode == "teams_only" { return GetAllUsers() }`).
|
||||
@@ -93,6 +100,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
||||
- **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
|
||||
- **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:** `Store.PatchDocumentBlocks`, `INSERT INTO blocks … ON CONFLICT(id) DO UPDATE`.
|
||||
- **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:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen.
|
||||
@@ -107,6 +115,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
||||
- **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
|
||||
|
||||
### FLAW-13: Migrationen
|
||||
- **Teilweise behoben:** Das Schema hat eine Version (`PRAGMA user_version`, `schemaVersion` in `sqlite.go`). Ist eine Datenbank älter, wird sie beim Start **komplett gelöscht** und neu angelegt (vor dem produktiven Einsatz bewusst so gewählt). Die übrigen Punkte unten gelten weiter.
|
||||
- **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql).
|
||||
- **Probleme:**
|
||||
- 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.
|
||||
@@ -117,9 +126,11 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
||||
### FLAW-14: Verschluckte Fehler
|
||||
- **Fundstellen:** u. a. `PatchDocumentBlocks` (Upserts/Deletes), `AddTaskListShare`, `RemoveTaskListShare`, viele `_, _ = s.db.Exec`.
|
||||
- **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.
|
||||
- **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:** Mehrstufige Änderungen in Transaktionen bündeln und Fehler zurückgeben.
|
||||
|
||||
### FLAW-15: WriteTimeout und SSE *(nicht live verifiziert)*
|
||||
### 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.
|
||||
@@ -130,19 +141,19 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
||||
- **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.
|
||||
- **Status: Behoben.** SQLite FTS5-Volltexttabellen mit BM25-Ranking (`thoughts_fts`, `documents_fts`, `list_items_fts`), Trigger zur automatischen Synchronisation und initialem Backfill beim Start. Sicheres Query-Escaping, Präfixsuche. Strikte Berechtigungsprüfung über `accessibleTaskListIDsQuery`, `DocRole` (eigene, geteilte Notizen, über Ordner gemountete Notizen) und zugängliche Listen. Rückwärtskompatible API-Antwort mit Snippets (`<mark>`) und Frontend-Integration über `CommandPalette` (`Cmd+K` / `Ctrl+K` / `/`).
|
||||
- **Fundstellen:** `internal/storage/search.go`, `internal/api/server.go`, `frontend/src/components/CommandPalette.tsx`.
|
||||
|
||||
### FLAW-19: Keine automatisierten Frontend-Prüfungen
|
||||
- **Fundstelle:** [`frontend/package.json`](../frontend/package.json): `"lint": "echo 'Linting passed'"`, `"test:run": "echo 'Frontend checks passed'"`.
|
||||
- **Problem:** Die Skripte melden immer Erfolg. Die CI (`.gitea/workflows/build-and-publish.yaml`) prüft daher nur Go-Tests und den Vite-Build. Regressionen im UI (z. B. Berechtigungsanzeigen) fallen nicht auf.
|
||||
- **Maßnahme:** `tsc --noEmit` und ESLint als echte Skripte, Komponententests für kritische Pfade.
|
||||
### 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).
|
||||
@@ -162,10 +173,28 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
||||
- 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
|
||||
- **Status: Behoben.** Die alten `/organizations`-Endpunkte sind entfernt; Organisationen verwaltet nur der Server-Admin über `/admin/organizations`.
|
||||
- **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
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# Modul: Gemeinsames Bearbeiten von Notizen
|
||||
|
||||
Code: [internal/collab/](../../internal/collab/) (Regeln, ohne Datenbank), [internal/storage/docsync.go](../../internal/storage/docsync.go) (Log, Snapshot, Rollen, Verdichtung), [internal/api/docsync.go](../../internal/api/docsync.go) (Endpunkte, Hintergrundjob), [frontend/src/lib/docSync/](../../frontend/src/lib/docSync/) (Engine im Browser), Anzeige in [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx) und [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx).
|
||||
|
||||
Mehrere Personen und Geräte bearbeiten dieselbe Notiz gleichzeitig. Jede Notiz, auch eine private, speichert über diesen Weg; es gibt keinen zweiten Speicherweg.
|
||||
|
||||
## Anforderungen
|
||||
|
||||
1. **Kein Datenverlust.** Was jemand getippt hat, steht danach in der Notiz, auch nach Verbindungsabbruch, Neuladen oder gleichzeitigem Bearbeiten desselben Bullets.
|
||||
2. **Funktioniert ohne WebSocket.** Hinter Proxys, die WebSockets blocken oder Streams puffern, funktioniert die Bearbeitung voll, nur mit etwas mehr Verzögerung.
|
||||
3. **Funktioniert bei schlechtem Netz.** Eingaben werden lokal gesichert und später zuverlässig übertragen, auch nach Tagen offline.
|
||||
4. **Klare Regel bei Konflikten.** Bearbeiten zwei Personen denselben Bullet, bekommt eine das Recht. Der Text der anderen bleibt als Konfliktkopie erhalten.
|
||||
5. **Berechtigungen.** Notizen und Ordner lassen sich als *Bearbeiter* oder *Leser* teilen; der Server prüft das bei jeder Änderung.
|
||||
|
||||
## Ansatz
|
||||
|
||||
**Server-autoritatives Operations-Log mit Versionen pro Block und Block-Sperren (Leases).** Daten fließen immer über gewöhnliche HTTP-Anfragen; WebSocket, SSE und Long-Polling sind nur der Weckruf.
|
||||
|
||||
| Baustein | Aufgabe |
|
||||
|---|---|
|
||||
| **Operations-Log** | Jede Änderung ist eine kleine Operation (Text ändern, Block einfügen, verschieben, löschen …). Der Server prüft und wendet sie an und schreibt die Auswirkungen (Effekte) mit fortlaufender Nummer `seq` pro Notiz ins Log. Wer seine `seq` kennt, holt alles danach. |
|
||||
| **Version pro Block** | Jede Textänderung nennt die Blockversion, die der Nutzer beim Tippen vor sich hatte. Passt sie nicht mehr, liegt ein Konflikt vor. |
|
||||
| **Lease pro Block** | Wer in einen Bullet klickt, hält ihn für 20 s und verlängert das, solange er dort arbeitet. Andere sehen „Anna bearbeitet …“ und können genau diesen Bullet so lange nicht ändern, alle anderen schon, auch im selben Ast. |
|
||||
| **Konfliktkopie** | Kommt es trotzdem zum Konflikt (Lease abgelaufen, jemand war offline), behält der Block die zuerst gespeicherte Fassung; der andere Text wird als markierter Block direkt darunter eingefügt. |
|
||||
| **Lokale Warteschlange** | Jede Änderung landet zuerst in IndexedDB und wird von dort gesendet. Jede Operation hat eine eindeutige ID; doppeltes Senden ist harmlos. |
|
||||
|
||||
### Warum nicht CRDT (Yjs/Automerge) oder OT
|
||||
|
||||
- **CRDT** führt Zeichen für Zeichen zusammen. Dafür bräuchte das Go-Backend eine vollständige Yjs-Implementierung, um Rechte, Hashtags und Markdown serverseitig auszuwerten; die gibt es in Go nicht ausgereift. Ohne sie wäre der Server nur ein Speicher für Binärdaten, den er nicht prüfen kann. Ein früherer Versuch ist daran gescheitert.
|
||||
- **OT** braucht einen fehlerfreien Transformations-Kern für jede Operationsart; schwer korrekt zu bekommen und schwer zu testen.
|
||||
- Gewollt ist, dass bei einem Bullet **einer das Recht bekommt**. Sperren plus Versionsprüfung sind einfach zu verstehen, vollständig serverseitig prüfbar und gegen Netzfehler testbar.
|
||||
- Bewusster Verzicht: Zwei Personen können nicht gleichzeitig im **selben** Bullet tippen. In **verschiedenen** Bullets, auch im selben Ast, geht es.
|
||||
|
||||
## Datenmodell
|
||||
|
||||
Details in [database.md](../database.md).
|
||||
|
||||
- **Baum statt flacher Liste.** `blocks.parent_id` (leer = oberste Ebene) und `blocks.order_key` sortieren Geschwister. Einrücken oder Verschieben eines Astes ist *eine* Operation auf *einem* Block. Ebene und Position für die Oberfläche berechnet der Server beim Lesen.
|
||||
- **Sortierschlüssel (Fractional Index).** Ein neuer Block zwischen `a0` und `a1` bekommt einen Schlüssel dazwischen; umnummeriert wird nie. Gleiche Schlüssel (zwei gleichzeitige Einfügungen) sortiert die Block-ID. Go (`orderkey.go`) und TypeScript (`orderKey.ts`) erzeugen identische Schlüssel, geprüft mit gemeinsamen Testvektoren.
|
||||
- **Grabsteine.** Gelöschte Blöcke bleiben mit `deleted = 1` und `deleted_at` erhalten. Eine verspätete Textänderung stellt den Block wieder her, statt verloren zu gehen. Nach 30 Tagen werden sie endgültig entfernt (siehe [Verdichtung](#verdichtung)).
|
||||
- **Log** `doc_ops` (Effekte je `seq`) und **Idempotenz** `doc_op_results` (Ergebnis je Operations-ID). Das Abspielen des Logs ab einem Snapshot ergibt exakt den Serverstand.
|
||||
- **Leases und Sitzungen liegen im Arbeitsspeicher** (`collab.LeaseManager`). Sie sind kurzlebig, nach einem Neustart fordern Clients sie automatisch neu an. MindSift läuft als eine Instanz, das passt.
|
||||
- **Abgeleitete Daten** (`markdown_cache`, Suchtext, `tags`, `block_tags`) entstehen in derselben Transaktion wie die Änderung.
|
||||
|
||||
## Protokoll
|
||||
|
||||
Endpunkte und Felder: [api/rest.md](../api/rest.md#gemeinsames-bearbeiten-von-notizen).
|
||||
|
||||
| Methode | Pfad | Zweck |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/docs/{id}/snapshot` | Alle Blöcke inkl. Grabsteine, Titel, `headSeq`, eigene Rolle, `shared` |
|
||||
| POST | `/api/v1/docs/{id}/sync` | **Ein Aufruf für alles:** eigene Operationen senden, Leases halten, neue Log-Einträge abholen |
|
||||
| GET | `/api/v1/docs/{id}/changes?since=N&wait=25` | Long-Polling: antwortet, sobald sich die Notiz (oder Sperren/Präsenz) ändert, spätestens nach `wait` Sekunden |
|
||||
| POST | `/api/v1/docs/{id}/leave` | Sitzung beenden, Leases freigeben |
|
||||
|
||||
Operationen: `insert`, `set_content`, `set_props` (Typ, abgehakt, eingeklappt, Sprache, Konflikt erledigt), `move`, `delete`, `restore`, `set_title`. Im Log steht zusätzlich `purge` (Verdichtung).
|
||||
|
||||
### Regeln auf dem Server
|
||||
|
||||
Alle Operationen einer Notiz laufen nacheinander: ein Mutex pro Notiz und eine SQLite-Transaktion pro `/sync`. „Wer zuerst kommt“ ist dadurch immer eindeutig.
|
||||
|
||||
1. **Idempotenz:** Ist die Operations-ID bekannt, kommt das gespeicherte Ergebnis zurück, ohne erneut anzuwenden. Verlorene Antworten sind harmlos.
|
||||
2. **Berechtigung:** Operationen nur mit Rolle `owner` oder `editor`, sonst `403`. Block-IDs anderer Notizen sind tabu.
|
||||
3. **`set_content`:** Hält ein *anderer* Client eine Lease oder passt die Basisversion nicht → **Konflikt**. Gleicher Text → angewendet ohne neue Version. Gelöschter Block → wiederherstellen (bei gelöschtem Elternblock unter dem nächsten lebenden Vorfahren) und anwenden. Endgültig entfernter Block → Text wird als neuer Block am Ende angelegt.
|
||||
4. **Konflikt:** Der Block behält seinen Inhalt. Direkt danach entsteht ein Block mit dem abgelehnten Text, `conflictOf` und `conflictAuthor` gesetzt, mit deterministischer ID (`blk_cf_` + Hash der Operations-ID), damit Wiederholungen keine zweite Kopie erzeugen. Für den Titel gilt dasselbe mit eigener Titelversion.
|
||||
5. **`move`:** abgelehnt bei Zyklus, bei gelöschtem Ziel-Elternblock oder wenn jemand anders den Block selbst hält. Unterpunkte wandern mit; wer darin tippt, tippt einfach an der neuen Stelle weiter.
|
||||
6. **`delete`:** abgelehnt, wenn jemand anders einen Block im Ast hält; sonst Grabstein für den ganzen Ast.
|
||||
7. **`insert`:** Ist der Elternblock gelöscht, wird unter dem nächsten lebenden Vorfahren eingefügt, ist er endgültig entfernt, auf oberster Ebene.
|
||||
8. **Leases:** Vergabe nur, wenn frei, abgelaufen oder schon im Besitz desselben Clients; gültig 20 s ab letzter Bestätigung. `leases.hold` ist immer die vollständige Liste. Neu eingefügte Blöcke und Konfliktkopien bekommt ihr Autor automatisch.
|
||||
9. **Keine Lease auf Elternblöcke:** Einfügen, Ein- und Ausrücken unter einem Elternblock brauchen keine Sperre auf diesen.
|
||||
|
||||
### Ablauf beim Tippen
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Client Anna
|
||||
participant S as Server
|
||||
participant B as Client Ben
|
||||
A->>S: /sync leases.hold=[b9]
|
||||
S-->>A: granted b9
|
||||
S--)B: DOC_SEQ (Weckruf)
|
||||
B->>S: /sync lastSeq=412
|
||||
S-->>B: leases.others: b9 = Anna (Bullet gesperrt)
|
||||
A->>S: /sync op set_content b9 base=7
|
||||
S-->>A: applied, seq 414
|
||||
S--)B: DOC_SEQ headSeq=414
|
||||
B->>S: /sync lastSeq=413
|
||||
S-->>B: ops [set_content b9 v8]
|
||||
```
|
||||
|
||||
## Client (`DocSyncEngine`)
|
||||
|
||||
- **Zustände:** *bestätigter Stand* (vom Server, mit `lastSeq`) und *Warteschlange* (eigene, unbestätigte Operationen). Angezeigt wird der bestätigte Stand mit der Warteschlange darübergelegt; das macht `model.ts` mit denselben Regeln wie der Server.
|
||||
- **Editor-Anbindung:** Die Editor-Komponenten arbeiten mit einer flachen Liste (Ebene + Reihenfolge). `diff.ts` übersetzt jede Änderung per Vergleich in Operationen.
|
||||
- **Persistenz:** Warteschlange und bestätigter Stand liegen in IndexedDB. Beim Öffnen erscheint sofort der gespeicherte Stand, auch offline.
|
||||
- **Senden:** gesammelt nach 300 ms, immer nur ein `/sync` gleichzeitig; Wiederholung mit Backoff 1 s, 2 s, 4 s … höchstens 30 s. Mehrere Textänderungen eines Blocks werden zusammengefasst; die Basisversion bleibt die der ersten (die der Nutzer beim Tippen sah), damit nie eine fremde Änderung überschrieben wird.
|
||||
- **Leases:**
|
||||
- Tippen wartet nie auf eine Sperre. Beim Fokussieren wird sie sofort angemeldet.
|
||||
- Neue Bullets (Enter, Einfügen) gehören sofort dem Ersteller.
|
||||
- Verlassene Bullets bleiben 20 s gehalten; nach 60 s ohne Eingabe wird auch der fokussierte Bullet freigegeben.
|
||||
- Fremd gesperrte Bullets zeigen ein Schloss mit Namen und sind schreibgeschützt; Enter darin legt einen neuen Bullet darunter an. Löschen, Zusammenführen, Ein-/Ausrücken und Ziehen, die einen gesperrten Bullet berühren, werden gar nicht erst ausgeführt (Hinweis unten im Editor).
|
||||
- **Konflikte:** Konfliktkopien sind farbig markiert, mit „Übernehmen“ (ersetzt den Originaltext) und „Verwerfen“. Beide prüfen vorher die Sperren von Original und Kopie.
|
||||
- **Hintergrund und Netz:** Beim Verbergen der Seite sendet die Engine Ausstehendes mit `keepalive` und gibt ihre Sperren frei; bei `online` und Rückkehr in den Tab gleicht sie sofort ab.
|
||||
- **Rechteentzug:** Der nächste `/sync` liefert `403` bzw. `404`. Die Engine verwirft die Warteschlange nicht, hört aber auf zu senden und zeigt den Fehler.
|
||||
- **Neu laden:** Meldet der Server `resync` (Client voraus oder hinter der verdichteten Log-Grenze) oder fehlt eine `seq`, lädt die Engine den Snapshot; die Warteschlange bleibt erhalten und wird danach angewendet.
|
||||
|
||||
## Transport: robust auch hinter Proxys
|
||||
|
||||
Daten fließen nur über `/sync` und `/snapshot`. Push-Kanäle melden nur „Notiz X hat jetzt `headSeq` N“ (`DOC_SEQ` im Raum `doc:<id>`, siehe [api/websocket.md](../api/websocket.md)).
|
||||
|
||||
| Stufe | Ausfall erkannt durch | Wechsel zu |
|
||||
|---|---|---|
|
||||
| 1. WebSocket | Verbindungsfehler oder 35 s ohne `PONG` auf das `PING` alle 15 s (WebSocket angenommen, aber nichts kommt durch) | SSE |
|
||||
| 2. SSE | Erstes Ereignis kommt nicht innerhalb von 5 s (Proxy puffert); gilt dann für den ganzen Tab | Long-Polling |
|
||||
| 3. Long-Polling (`/changes`, 25 s) | 3 Fehlschläge hintereinander | Intervall |
|
||||
| 4. Intervall | – | Abfrage alle 3 s; bei `online` wird Long-Polling erneut versucht |
|
||||
|
||||
Zusätzlich, solange Stufe 1–3 läuft: Abgleich alle 7 s, wenn die Engine Sperren hält (Verlängerung), sonst alle 30 s zur Sicherheit. Der Editor zeigt den Weg an („Live“, „Live (Long-Polling)“, „Abgleich alle 3 s“). Gemessen im Browser-Test: 0,2–0,3 s über WebSocket, SSE und Long-Polling, rund 3 s im Intervall.
|
||||
|
||||
Long-Polling bleibt mit 25 s unter üblichen Proxy-Grenzen (Nginx 60 s, Cloudflare 100 s). Den Schreib-Timeout hebt der Server für Streams und Long-Polling auf. Räume betritt nur, wer die Notiz lesen darf. Proxy-Beispiele: [reverse-proxy.md](../reverse-proxy.md).
|
||||
|
||||
## Berechtigungen
|
||||
|
||||
- Rollen je Notiz: `owner`, `editor`, `viewer`. Die effektive Rolle ist die höchste aus Eigentum, direkter Freigabe, Gruppenfreigabe und Freigabe des geteilten Ordners. Eine zentrale Funktion `Store.DocRole` entscheidet für Snapshot, Sync, Long-Polling, Raumbeitritt und Aufgaben-Verknüpfungen.
|
||||
- Nur der Eigentümer verwaltet Freigaben und löscht die Notiz. Bearbeiter dürfen sonst alles: Inhalt, Struktur, Titel und Symbol.
|
||||
- Freigaben liegen in `doc_shares`, `doc_team_shares` und `workspace_folder_shares` (`read`/`write`). Eine Zusammenlegung zu einer Tabelle wurde bewusst nicht umgesetzt: `DocRole` entscheidet bereits zentral, das Umschreiben der rund 20 Sichtbarkeitsabfragen brächte nur Risiko. Mehr: [sharing-and-permissions.md](../sharing-and-permissions.md).
|
||||
|
||||
## Private Notizen
|
||||
|
||||
Gleiches Modell, aber sparsam: Solange eine Notiz nicht geteilt ist (`shared = false`) und nur in einem Tab offen ist (`sessions = 1`), gibt es keine Leases, keinen Herzschlag und nur die 30-s-Abfrage. Öffnet ein zweites Gerät die Notiz oder wird sie geteilt, schalten alle offenen Clients mit der nächsten Antwort auf den vollen Ablauf um. Gegenüber dem früheren Speichern des ganzen Dokuments alle 800 ms überträgt und schreibt das Modell nur geänderte Blöcke.
|
||||
|
||||
## Rückgängig und Wiederholen
|
||||
|
||||
- `Strg+Z`/`Cmd+Z` nimmt die letzte **eigene** Änderung zurück, `Strg+Umschalt+Z` bzw. `Strg+Y` wiederholt. Fremde Änderungen bleiben unberührt.
|
||||
- Zu jeder eigenen Änderung merkt sich die Engine den Gegenschritt (`undo.ts`) und sendet ihn als normale Operation; Server und Log brauchen dafür nichts Besonderes.
|
||||
- Tippen im selben Bullet ist ein Schritt bis zu einer Pause von 1 s; Einfügen, Löschen, Einrücken, Verschieben, Abhaken und mehrzeiliges Einfügen sind jeweils ein Schritt. Bis zu 200 Schritte pro Tab und Notiz.
|
||||
- Hat inzwischen jemand anders den Bullet geändert oder hält ihn gerade, wird der Schritt mit Hinweis übersprungen, nie blind überschrieben.
|
||||
- Das Undo des Textfelds selbst wird abgefangen (`keydown` und `beforeinput` mit `historyUndo`), damit Browser-Undo und eigenes Undo nicht gegeneinander arbeiten.
|
||||
|
||||
## Verdichtung
|
||||
|
||||
Ein Hintergrundjob (`startCompaction`) läuft beim Start und danach alle 6 Stunden:
|
||||
|
||||
| Was | Aufbewahrung |
|
||||
|---|---|
|
||||
| Log geteilter Notizen | 30 Tage; die Grenze steht in `documents.log_floor` |
|
||||
| Log privater Notizen | 7 Tage |
|
||||
| Grabsteine | 30 Tage nach `deleted_at`, dann endgültig entfernt per Log-Eintrag `purge` (offene Editoren übernehmen das ohne Neuladen) |
|
||||
| Idempotenz-Ergebnisse | 90 Tage |
|
||||
| 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).
|
||||
|
||||
## Tests
|
||||
|
||||
| Ebene | Was | Wo |
|
||||
|---|---|---|
|
||||
| Regeln | Konfliktmatrix aus Lease, Version, Grabstein, Verschieben, Löschen, Purge | `internal/collab/ops_test.go` |
|
||||
| Simulation | 4 Clients, 25 Seeds × 600 Schritte über ein Netz, das Anfragen und Antworten verliert und verdoppelt; Clients gehen offline; zwischendurch Verdichtung. Geprüft: Konvergenz, gültiger Baum, jede Operation genau einmal angewendet, kein Text verloren. Per Gegenprobe (absichtlich eingebaute Fehler) abgesichert. | `internal/storage/docsync_sim_test.go` |
|
||||
| Speicher und API | Log ↔ Snapshot, Rollen, Long-Polling, Verdichtung | `internal/storage/docsync_test.go`, `internal/api/docsync_test.go` |
|
||||
| Browser-Engine | Vitest mit gefälschtem Server und Netz, `fake-indexeddb`: offline, Neuladen, verlorene Antworten, Push-Wechsel, Verdichtung | `frontend/src/lib/docSync/__tests__/` |
|
||||
| Ende-zu-Ende | Playwright mit zwei Benutzern: gemeinsames Tippen, Freigabe, Sperren, Konflikte, Undo, alle Transport-Stufen mit blockiertem WebSocket/SSE | `e2e/` (`e2e/run.sh`) |
|
||||
@@ -5,7 +5,7 @@ Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsVi
|
||||
## Datenmodell
|
||||
|
||||
- `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`.
|
||||
|
||||
## 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`).
|
||||
- **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.
|
||||
- **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.
|
||||
@@ -4,7 +4,7 @@ Code: [TasksView.tsx](../../frontend/src/features/triage/TasksView.tsx), [TaskDe
|
||||
|
||||
## 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` (nur Datum), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId`, `sortOrder` und `assignees` (Benutzer-IDs). Aufgaben ohne Liste sind persönlich.
|
||||
|
||||
## Ansichten
|
||||
|
||||
@@ -21,7 +21,11 @@ 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“.
|
||||
- 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.
|
||||
- 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: [FLAW-03](../flaws.md) (Umsortieren per ID).
|
||||
|
||||
## Alarme
|
||||
|
||||
@@ -29,10 +33,18 @@ Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offe
|
||||
|
||||
| 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 |
|
||||
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
|
||||
|
||||
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war.
|
||||
|
||||
**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, `AUTH PLAIN`, `MAIL FROM`, `RCPT TO`, Daten.
|
||||
- `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).
|
||||
|
||||
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)).
|
||||
@@ -1,11 +1,12 @@
|
||||
# Modul: Workspace (Outliner-Notizen)
|
||||
|
||||
Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [CodeBlockItem.tsx](../../frontend/src/features/workspace/CodeBlockItem.tsx), [LinkModal.tsx](../../frontend/src/features/workspace/LinkModal.tsx); Backend: Dokument-, Ordner- und Tag-Handler, `Store.PatchDocumentBlocks`.
|
||||
Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [CodeBlockItem.tsx](../../frontend/src/features/workspace/CodeBlockItem.tsx), [LinkModal.tsx](../../frontend/src/features/workspace/LinkModal.tsx); Backend: Dokument-, Ordner- und Tag-Handler, Sync-Handler in `internal/api/docsync.go`, `internal/collab`, `internal/storage/docsync.go`.
|
||||
|
||||
## Datenhaltung
|
||||
|
||||
- Eine Notiz ist ein Datensatz in `documents` plus geordnete Zeilen in `blocks` (`position` als Kommazahl, `level` für die Einrückung). 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.
|
||||
- **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).
|
||||
- Blocktypen: Überschriften `h1`–`h3`, `paragraph`/`text`, `bullet`, `todo`, `code`, `callout`, `divider`, `table`, `progress`.
|
||||
|
||||
@@ -17,7 +18,21 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
||||
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`).
|
||||
- **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.
|
||||
- **Speichern:** Der Editor speichert 800 ms nach der letzten Eingabe das gesamte Dokument (`PUT /documents/{id}`). Der schlanke Block-Endpunkt `PATCH /documents/{id}/blocks` wird derzeit vom To-Do-Rollup beim Abhaken genutzt.
|
||||
- **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.
|
||||
- **Abgleich bei geteilten oder mehrfach geöffneten Notizen**, nach Verfügbarkeit:
|
||||
1. **Push** über WebSocket oder SSE: Der Server meldet `DOC_SEQ`, die Engine gleicht sofort ab (im Test 0,2–0,3 s).
|
||||
2. **Long-Polling** (`/docs/{id}/changes`), wenn ein Proxy WebSocket und SSE blockiert oder SSE puffert: ebenso schnell.
|
||||
3. **Abfrage alle 3 s**, wenn auch Long-Polling dreimal scheitert (bei `online` wird Long-Polling erneut versucht).
|
||||
|
||||
Solange 1. oder 2. läuft, gleicht die Engine zusätzlich alle 7 s ab, wenn sie Sperren hält (Verlängerung), sonst alle 30 s zur Sicherheit. Ein Fokuswechsel meldet die Sperre sofort an. Der Editor zeigt den Weg an („Live“, „Live (Long-Polling)“, „Abgleich alle 3 s“).
|
||||
- **Private Notizen**, die nur einmal geöffnet sind, gleichen alle 30 s ab, außerdem sofort bei Rückkehr ins Netz oder in den Tab. Beim Verlassen sendet die Engine Ausstehendes mit `keepalive` und gibt ihre Sperren frei.
|
||||
- **Textkonflikte:** Jede Textänderung trägt die Version, die der Nutzer beim Tippen vor sich hatte. Hat jemand anderes den Bullet inzwischen geändert oder gesperrt, legt der Server eine Konfliktkopie an; es wird nie still überschrieben.
|
||||
- **Sperren:** Der Bullet mit dem Cursor, verlassene Bullets für 20 s und neu angelegte Bullets werden gehalten. Bullets, die jemand anderes hält, zeigen ein Schloss mit Namen und sind schreibgeschützt; Enter darin legt einen neuen Bullet darunter an. Löschen, Zusammenführen, Ein-/Ausrücken und Ziehen werden bei gesperrten Bullets nicht ausgeführt (Hinweis unten im Editor).
|
||||
- **Präsenz:** Im Kopf der Notiz steht, wer sie gerade sonst noch geöffnet hat.
|
||||
- **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.
|
||||
- `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
|
||||
|
||||
@@ -30,10 +45,21 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
||||
|
||||
- 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)).
|
||||
- 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 `DOC_BLOCKS_PATCHED`/`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)).
|
||||
|
||||
## Grenzen
|
||||
|
||||
- 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)*.
|
||||
- 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 ([FLAW-18](../flaws.md)).
|
||||
|
||||
## 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.
|
||||
- Ein Import aus ZIP gibt es noch nicht.
|
||||
|
||||
@@ -193,6 +193,10 @@ VirtualHost-Konfiguration (`/etc/apache2/sites-available/mindsift.conf`):
|
||||
|
||||
---
|
||||
|
||||
## Wenn der Proxy nicht angepasst werden kann
|
||||
|
||||
MindSift funktioniert auch hinter Proxys, die WebSocket blocken, Streams puffern oder nur kurze Anfragen erlauben. Die Notiz-Synchronisation wählt automatisch den besten verfügbaren Weg (WebSocket → SSE → Long-Polling → Abfrage alle 3 s); der Editor zeigt ihn an. Long-Polling-Anfragen dauern höchstens 25 s und bleiben damit unter üblichen Proxy-Grenzen. Details: [api/websocket.md](api/websocket.md).
|
||||
|
||||
## 🔍 Diagnose & Verifikation
|
||||
|
||||
Mit den folgenden Befehlen lässt sich die Erreichbarkeit und Streaming-Funktion prüfen:
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
| 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. |
|
||||
| Ressource | siehe unten | Eigentümer, Bearbeiter, Leser. |
|
||||
|
||||
@@ -14,13 +15,13 @@
|
||||
|---|---|---|---|---|
|
||||
| **Aufgabenliste** (`task_list_shares.permission`) | alles, löschen, (un)teilen | Aufgaben anlegen/ändern/abhaken/verschieben/löschen, Liste umbenennen, Spalten ändern | nur ansehen | Benutzer, Gruppen |
|
||||
| **Geteilte Liste** (`list_shares.role`) | alles, löschen, (un)teilen | Einträge anlegen/ändern/abhaken/löschen | nur ansehen | Benutzer, Gruppen |
|
||||
| **Notiz** (`doc_shares`, `doc_team_shares`) | schreiben | – | nur lesen | Benutzer, Gruppen |
|
||||
| **Geteilter Ordner** (`workspace_folder_shares`) | schreiben | – | nur lesen | Benutzer, Gruppen |
|
||||
| **Notiz** (`doc_shares`, `doc_team_shares`) | alles, Freigaben, löschen | Inhalt, Struktur, Titel | nur lesen | Benutzer, Gruppen |
|
||||
| **Geteilter Ordner** (`workspace_folder_shares`) | alles | Notizen darin bearbeiten | nur lesen | Benutzer, Gruppen |
|
||||
|
||||
- **Notizen und Ordner sind absichtlich schreibgeschützt für Empfänger.** Das erzwingt eine Migration beim Start (`permission = 'read'`); Schreibpfade im Code sind tot ([FLAW-17](flaws.md)).
|
||||
- **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.
|
||||
- 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)
|
||||
|
||||
@@ -31,17 +32,34 @@
|
||||
|
||||
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 |
|
||||
| `teams_only` | Freigaben nur zwischen Mitgliedern gemeinsamer Gruppen |
|
||||
| `open` | Jeder kann für jeden freigeben |
|
||||
| `private` | Niemand. Benutzersuche, Teilen und Gruppen sind aus (Server antwortet mit Fehler, UI blendet sie aus). |
|
||||
| `organizations` (Standard) | Nur Mitglieder derselben Organisation. Wer keiner angehört, sieht niemanden. |
|
||||
| `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
|
||||
|
||||
@@ -52,4 +70,4 @@ Werte: `strict_private`, `teams_only` (Standard), `open`. Gedacht ist:
|
||||
|
||||
- Tokens werden bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ ungültig (`token_version`); ausgeloggte Tokens landen in `revoked_tokens`.
|
||||
- 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: [FLAW-01](flaws.md), [FLAW-02](flaws.md), [FLAW-03](flaws.md), [FLAW-07](flaws.md), [FLAW-08](flaws.md).
|
||||
@@ -97,11 +97,31 @@ In [`frontend/src/index.css`](../frontend/src/index.css) und `tailwind.config.js
|
||||
- `.checkbox-square`: Kontrollkästchen mit `--radius-checkbox`.
|
||||
- `.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
|
||||
|
||||
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`.
|
||||
2. Ändere den Selektor `:root[data-theme="template"]` in `:root[data-theme="meintheme"]`.
|
||||
3. Passe Farben und Rundungen nach Belieben an.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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.
|
||||
|
||||
## 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, Workspace, Aufgaben).
|
||||
- `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-2`, solange ein Zähler da ist), der Kreis bekommt `-top-1.5 -right-1`.
|
||||
- `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.
|
||||
@@ -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,8 +6,9 @@
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#-deine-daten-gehören-dir)
|
||||
[](#-schnellstart)
|
||||
|
||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen 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.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/Triage_desktop.png" alt="MindSift Triage Desktop" width="85%" />
|
||||
@@ -61,8 +62,17 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
||||
* Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`.
|
||||
* Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
|
||||
* Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`.
|
||||
* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt.
|
||||
* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen **zum Lesen** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein.
|
||||
* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt; `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her.
|
||||
* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen als **Bearbeiter** oder **Leser** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein.
|
||||
|
||||
#### 🤝 Gemeinsam bearbeiten
|
||||
|
||||
* **Live:** Mehrere Personen schreiben gleichzeitig in derselben Notiz; Änderungen erscheinen bei den anderen in Bruchteilen einer Sekunde. Der Kopf der Notiz zeigt, wer gerade mitliest.
|
||||
* **Kein Überschreiben:** Wer in einem Bullet schreibt, hält ihn kurz für sich; andere sehen ein Schloss mit Namen und arbeiten in den übrigen Bullets weiter. Kommt es doch zu einem Konflikt (z. B. nach Offline-Arbeit), bleibt der zweite Text als markierte Kopie mit „Übernehmen“/„Verwerfen“ erhalten.
|
||||
* **Offline-fest:** Jede Eingabe wird zuerst im Browser gesichert und später übertragen, auch nach Neuladen oder Tagen ohne Netz.
|
||||
* **Hinter strengen Proxys:** WebSocket → SSE → Long-Polling → Abfrage alle 3 s; MindSift wählt automatisch, was durchkommt. Der aktive Weg steht im Editor („Live“, „Abgleich alle 3 s“).
|
||||
|
||||
Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md).
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace Outliner" width="90%" />
|
||||
@@ -118,7 +128,7 @@ Minimal:
|
||||
```yaml
|
||||
services:
|
||||
mindsift:
|
||||
image: git.dsit.me/sandrodach/mindsift:dev
|
||||
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.3); :latest = main, :dev = Entwicklungsstand
|
||||
restart: unless-stopped
|
||||
ports: ["8080:8080"]
|
||||
volumes: ["./data:/data"]
|
||||
@@ -146,11 +156,15 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|
||||
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
||||
| `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) |
|
||||
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin`, `user/user` an. **Nie auf öffentlichen Servern!** |
|
||||
| `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!** |
|
||||
|
||||
* **Reverse-Proxy:** WebSocket-Upgrade und ungepufferte SSE-Streams müssen durchgereicht werden. 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.
|
||||
* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst.
|
||||
* **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -167,10 +181,14 @@ cd frontend
|
||||
npm ci
|
||||
npm run dev # Vite-Entwicklungsserver
|
||||
npx tsc --noEmit # Typprüfung
|
||||
npm run test:run # Komponententests (Vitest, jsdom)
|
||||
npm run build # Produktions-Build
|
||||
cd ..
|
||||
e2e/run.sh # Browser-Tests (Playwright, zwei Benutzer) gegen ein frisch gebautes Image
|
||||
APP_VERSION=v0.3 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 Vet und 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.
|
||||
|
||||
**Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite) · React 18, TypeScript, Vite, Tailwind.
|
||||
|
||||
@@ -178,20 +196,20 @@ Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:em
|
||||
|
||||
## 📚 Dokumentation
|
||||
|
||||
Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank, REST- und Echtzeit-API, Module, Freigaben und Berechtigungen, Themes. Bekannte Schwachstellen und offene Punkte stehen in [Doku/flaws.md](Doku/flaws.md).
|
||||
Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank, REST- und Echtzeit-API, Module (inkl. gemeinsamem Bearbeiten), Freigaben und Berechtigungen, Reverse-Proxy, Themes. Bekannte Schwachstellen und offene Punkte stehen in [Doku/flaws.md](Doku/flaws.md).
|
||||
|
||||
---
|
||||
|
||||
## 🔒 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).
|
||||
* 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
|
||||
|
||||
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">
|
||||
<sub>Gebaut für Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
|
||||
|
||||
@@ -37,18 +37,18 @@ func main() {
|
||||
}
|
||||
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 {
|
||||
slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
|
||||
if err := store.EnsureDemoAccounts(); err != nil {
|
||||
slog.Warn("⚠️ Fehler beim Anlegen der Demo-Accounts", "error", err)
|
||||
slog.Info("🧪 Demo-Modus aktiv: Setze Datenbank zurück und richte Demo-Accounts ein...")
|
||||
if err := store.ResetAndSeedDemo(); err != nil {
|
||||
slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err)
|
||||
} else {
|
||||
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()
|
||||
|
||||
staticFS, err := fs.Sub(embeddedDist, "dist")
|
||||
|
||||
@@ -26,3 +26,9 @@ services:
|
||||
- SMTP_PASS=${SMTP_PASS:-}
|
||||
- SMTP_FROM=${SMTP_FROM:-}
|
||||
- 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_FROM=${SMTP_FROM:-}
|
||||
- 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:-}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Ende-zu-Ende-Tests
|
||||
|
||||
Browser-Tests mit Playwright gegen einen echten MindSift-Server.
|
||||
|
||||
```bash
|
||||
e2e/run.sh # baut das Image, startet es im Demo-Modus und führt die Tests aus
|
||||
ENGINE=docker e2e/run.sh
|
||||
```
|
||||
|
||||
Gegen einen bereits laufenden Server (Demo-Modus mit `admin/admin` und `user/user` nötig):
|
||||
|
||||
```bash
|
||||
cd e2e && npm install && MINDSIFT_URL=http://localhost:8080 node collab.e2e.mjs
|
||||
```
|
||||
|
||||
## collab.e2e.mjs
|
||||
|
||||
Zwei Browser (Eigentümer und Bearbeiter) arbeiten in derselben Notiz. Geprüft wird:
|
||||
|
||||
- Tippen wird gespeichert und erscheint beim anderen ohne Neuladen
|
||||
- neue Bullets per Enter, in beide Richtungen
|
||||
- Offline-Phase: Text bleibt lokal und wird nach Rückkehr übertragen
|
||||
- beide Browser und der Server zeigen denselben Stand, auch nach Neuladen
|
||||
- Sperren eines geschlossenen Browsers laufen nach spätestens 20 s ab
|
||||
- Einrücken (Tab) und Zusammenführen (Backspace) bleiben nach Neuladen erhalten
|
||||
|
||||
## share.e2e.mjs
|
||||
|
||||
Der Eigentümer teilt eine Notiz über den Freigabe-Dialog mit Rolle „Bearbeiten“; geprüft wird, dass die Freigabe als `write` gespeichert, im Dialog als „Bearbeiter“ angezeigt wird und der Empfänger die Notiz bearbeiten kann. Zum Schluss hakt der Eigentümer ein To-Do in der To-Do-Übersicht ab.
|
||||
|
||||
## lock.e2e.mjs
|
||||
|
||||
Sperren, Präsenz, Rückgängig und Konfliktkopien mit zwei Browsern: Schloss und Schreibschutz am fremd bearbeiteten Bullet, Hinweis statt halb ausgeführter Aktion, `Strg+Z`/`Strg+Umschalt+Z`, Tippen direkt nach Enter, Markierung von Konfliktkopien sowie „Übernehmen“ (verweigert bei fremder Sperre, erfolgreich für den Sperrinhaber).
|
||||
|
||||
## channels.e2e.mjs
|
||||
|
||||
Der Eigentümer tippt mit normaler Verbindung, der Bearbeiter sitzt hinter einem simulierten Proxy. Für jede Situation wird der gewählte Weg und die Zeit bis zur Anzeige gemessen: nichts blockiert (WebSocket), WebSocket abgelehnt (SSE), WebSocket angenommen aber stumm (SSE nach Herzschlag-Ausfall), WebSocket und SSE blockiert sowie SSE hängend (Long-Polling), nur normale Anfragen (Abfrage alle 3 s).
|
||||
|
||||
`collab.e2e.mjs`, `lock.e2e.mjs` und `channels.e2e.mjs` dauern je etwa eine Minute. Die Tests laufen nicht in der CI, weil sie Browser-Images brauchen.
|
||||
|
||||
## Screenshots
|
||||
|
||||
`e2e/screenshots.sh` baut das Image, startet es im Demo-Modus mit frischer Datenbank und erzeugt die Bilder in `screenshots/` neu (`screenshots.mjs`). Für die Versionsangabe im Kopf: `APP_VERSION=v0.2 e2e/screenshots.sh`. In der Workspace-Aufnahme bearbeitet der Demo-Benutzer `user` gleichzeitig einen Bullet, damit Sperre und Präsenz zu sehen sind.
|
||||
@@ -0,0 +1,119 @@
|
||||
// How fast changes arrive behind different (simulated) proxies. The admin edits with a normal connection;
|
||||
// the editor's browser blocks WebSocket, SSE and/or long polling. Needs a server in demo mode.
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let failures = 0;
|
||||
const check = (cond, msg) => {
|
||||
console.log(`${cond ? 'OK ' : 'FAIL'} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
async function api(token, method, path, body) {
|
||||
const res = await fetch(BASE + path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${text}`);
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
async function waitFor(fn, ms) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
try {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
} catch {}
|
||||
await sleep(100);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const editorValues = (page) =>
|
||||
page.$$eval('textarea', (els) => els.filter((e) => !e.placeholder?.startsWith('Titel')).map((e) => e.value));
|
||||
const channelOf = (page) => page.getAttribute('[data-sync-channel]', 'data-sync-channel');
|
||||
|
||||
async function openDoc(browser, token, title, block = {}) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
// A blocking proxy refuses the connection (policy violation); a clean close would just mean "reconnect"
|
||||
if (block.ws === 'reject') await ctx.routeWebSocket(/\/api\/v1\/ws/, (ws) => ws.close({ code: 1008, reason: 'blocked by proxy' }));
|
||||
if (block.ws === 'silent') await ctx.routeWebSocket(/\/api\/v1\/ws/, () => {}); // accepted, nothing forwarded
|
||||
if (block.sse === 'abort') await ctx.route('**/api/v1/events**', (r) => r.abort());
|
||||
if (block.sse === 'hang') await ctx.route('**/api/v1/events**', () => {}); // never answered
|
||||
if (block.longpoll) await ctx.route('**/changes**', (r) => r.abort());
|
||||
await ctx.addInitScript((t) => localStorage.setItem('mindsift_jwt_token', t), token);
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(BASE);
|
||||
await page.getByRole('button', { name: /Workspace/ }).first().click();
|
||||
await page.getByText(title, { exact: true }).first().click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('textarea').length > 1, null, { timeout: 10000 });
|
||||
return { ctx, page };
|
||||
}
|
||||
|
||||
async function typeAtEnd(page, text) {
|
||||
await page.bringToFront();
|
||||
// Place the cursor via script: Playwright's actionability checks wait for animation frames, which
|
||||
// pages in the background do not get while the other browser window is in front
|
||||
const ok = await page.evaluate(() => {
|
||||
const el = Array.from(document.querySelectorAll('textarea')).find((e) => e.value.startsWith('Live-Test'));
|
||||
if (!el) return false;
|
||||
el.focus();
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
return true;
|
||||
});
|
||||
if (!ok) throw new Error('Live-Test field not found');
|
||||
await page.keyboard.type(text);
|
||||
}
|
||||
|
||||
const TITLE = `Kanal-Test ${Date.now()}`;
|
||||
|
||||
const MODES = [
|
||||
{ name: 'nichts blockiert', block: {}, channel: 'ws', maxMs: 2500 },
|
||||
{ name: 'WebSocket-Handshake abgelehnt', block: { ws: 'reject' }, channel: 'sse', maxMs: 2500 },
|
||||
{ name: 'WebSocket angenommen, leitet nichts weiter', block: { ws: 'silent' }, channel: 'sse', maxMs: 2500, switchMs: 60000 },
|
||||
{ name: 'WebSocket und SSE blockiert', block: { ws: 'reject', sse: 'abort' }, channel: 'longpoll', maxMs: 2500 },
|
||||
{ name: 'SSE-Verbindung hängt', block: { ws: 'reject', sse: 'hang' }, channel: 'longpoll', maxMs: 2500 },
|
||||
{ name: 'nur normale Anfragen möglich', block: { ws: 'reject', sse: 'abort', longpoll: true }, channel: 'interval', maxMs: 6000, switchMs: 15000 },
|
||||
];
|
||||
|
||||
const main = async () => {
|
||||
const admin = (await api(null, 'POST', '/api/v1/auth/login', { login: 'admin', password: 'admin' })).token;
|
||||
const user = (await api(null, 'POST', '/api/v1/auth/login', { login: 'user', password: 'user' })).token;
|
||||
const dir = await api(admin, 'GET', '/api/v1/users/directory');
|
||||
const userId = dir.find((u) => u.username === 'user' || u.email?.startsWith('user')).id;
|
||||
const doc = await api(admin, 'POST', '/api/v1/documents', {
|
||||
title: TITLE,
|
||||
blocks: [{ id: `ch_${Date.now()}`, type: 'bullet', content: 'Live-Test', level: 0 }],
|
||||
});
|
||||
await api(admin, 'POST', `/api/v1/documents/${doc.id}/shares`, { userId, permission: 'write' });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const A = await openDoc(browser, admin, TITLE);
|
||||
|
||||
for (const [i, mode] of MODES.entries()) {
|
||||
const B = await openDoc(browser, user, TITLE, mode.block);
|
||||
const channel = await waitFor(async () => ((await channelOf(B.page)) === mode.channel ? mode.channel : null), mode.switchMs || 10000);
|
||||
check(channel === mode.channel, `${mode.name}: Weg ${(await channelOf(B.page)) || '?'} (erwartet ${mode.channel})`);
|
||||
|
||||
const marker = ` r${i}`;
|
||||
const start = Date.now();
|
||||
await typeAtEnd(A.page, marker);
|
||||
const arrived = await waitFor(async () => (await editorValues(B.page)).some((v) => v.endsWith(marker)), mode.maxMs + 4000);
|
||||
const ms = Date.now() - start;
|
||||
check(!!arrived && ms <= mode.maxMs, `${mode.name}: Änderung kam nach ${arrived ? ms : '–'} ms an (Ziel ≤ ${mode.maxMs} ms)`);
|
||||
await B.ctx.close();
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(failures ? `${failures} FEHLGESCHLAGEN` : 'ALLE PRÜFUNGEN BESTANDEN');
|
||||
process.exit(failures ? 1 : 0);
|
||||
};
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
// End-to-end test of collaborative note editing with two real browsers (owner and editor).
|
||||
// Needs a server in demo mode (accounts admin/admin and user/user); see e2e/README.md.
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let failures = 0;
|
||||
const check = (cond, msg) => {
|
||||
console.log(`${cond ? 'OK ' : 'FAIL'} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
async function api(token, method, path, body) {
|
||||
const res = await fetch(BASE + path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${text}`);
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
async function waitFor(fn, ms, label) {
|
||||
const end = Date.now() + ms;
|
||||
let last;
|
||||
while (Date.now() < end) {
|
||||
try {
|
||||
last = await fn();
|
||||
if (last) return last;
|
||||
} catch {}
|
||||
await sleep(250);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverBlocks = async (token, docId) => {
|
||||
const snap = await api(token, 'GET', `/api/v1/docs/${docId}/snapshot`);
|
||||
return snap.blocks.filter((b) => !b.deleted).map((b) => b.content);
|
||||
};
|
||||
|
||||
// Outliner text fields of a page (without the title field)
|
||||
const editorValues = (page) =>
|
||||
page.$$eval('textarea', (els) => els.filter((e) => !e.placeholder?.startsWith('Titel')).map((e) => e.value));
|
||||
|
||||
async function openDoc(browser, token, title) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
await ctx.addInitScript((t) => localStorage.setItem('mindsift_jwt_token', t), token);
|
||||
const page = await ctx.newPage();
|
||||
page.on('pageerror', (e) => console.log('PAGEERROR', e.message));
|
||||
await page.goto(BASE);
|
||||
await page.getByRole('button', { name: /Workspace/ }).first().click();
|
||||
await page.getByText(title, { exact: true }).first().click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('textarea').length > 1, null, { timeout: 10000 });
|
||||
return { ctx, page };
|
||||
}
|
||||
|
||||
const TITLE = `E2E Notiz ${Date.now()}`;
|
||||
const main = async () => {
|
||||
const admin = (await api(null, 'POST', '/api/v1/auth/login', { login: 'admin', password: 'admin' })).token;
|
||||
const user = (await api(null, 'POST', '/api/v1/auth/login', { login: 'user', password: 'user' })).token;
|
||||
const dir = await api(admin, 'GET', '/api/v1/users/directory');
|
||||
const userId = dir.find((u) => u.username === 'user' || u.email?.startsWith('user')).id;
|
||||
const doc = await api(admin, 'POST', '/api/v1/documents', {
|
||||
title: TITLE,
|
||||
blocks: [{ id: 'e2e_1', type: 'bullet', content: 'Erster Punkt', level: 0 }],
|
||||
});
|
||||
await api(admin, 'POST', `/api/v1/documents/${doc.id}/shares`, { userId, permission: 'write' });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const A = await openDoc(browser, admin, TITLE);
|
||||
const B = await openDoc(browser, user, TITLE);
|
||||
check((await editorValues(A.page)).includes('Erster Punkt'), 'Admin sieht die Notiz');
|
||||
check((await editorValues(B.page)).includes('Erster Punkt'), 'Bearbeiter sieht die Notiz');
|
||||
|
||||
// 1. Admin types at the end of the first bullet
|
||||
await A.page.getByText('Erster Punkt', { exact: true }).first().click();
|
||||
await A.page.keyboard.press('End');
|
||||
await A.page.keyboard.type(' + A');
|
||||
const saved = await waitFor(async () => (await serverBlocks(admin, doc.id)).includes('Erster Punkt + A'), 5000);
|
||||
check(!!saved, 'Getippter Text liegt auf dem Server');
|
||||
const seenByB = await waitFor(async () => (await editorValues(B.page)).includes('Erster Punkt + A'), 8000);
|
||||
check(!!seenByB, 'Bearbeiter sieht die Änderung ohne Neuladen');
|
||||
|
||||
// 2. Admin creates a new bullet with Enter
|
||||
await A.page.keyboard.press('Enter');
|
||||
await sleep(150); // a human does not type within the same frame as Enter
|
||||
await A.page.keyboard.type('Zweiter von A');
|
||||
const second = await waitFor(async () => (await editorValues(B.page)).includes('Zweiter von A'), 8000);
|
||||
check(!!second, 'Neuer Bullet erscheint beim Bearbeiter');
|
||||
|
||||
// 3. Bearbeiter adds his own bullet at the end
|
||||
await B.page.getByText('Zweiter von A', { exact: true }).first().click();
|
||||
await B.page.keyboard.press('End');
|
||||
await B.page.keyboard.press('Enter');
|
||||
await sleep(150);
|
||||
await B.page.keyboard.type('Von B');
|
||||
const fromB = await waitFor(async () => (await editorValues(A.page)).includes('Von B'), 8000);
|
||||
check(!!fromB, 'Bullet des Bearbeiters erscheint beim Admin');
|
||||
|
||||
// 4. Offline phase on the admin side
|
||||
await A.ctx.setOffline(true);
|
||||
await A.page.getByText('Erster Punkt + A', { exact: true }).first().click();
|
||||
await A.page.keyboard.press('End');
|
||||
await A.page.keyboard.type(' offline');
|
||||
await sleep(1500);
|
||||
check(!(await serverBlocks(admin, doc.id)).includes('Erster Punkt + A offline'), 'Offline getippt: noch nicht auf dem Server');
|
||||
await A.ctx.setOffline(false);
|
||||
await A.page.evaluate(() => window.dispatchEvent(new Event('online')));
|
||||
const afterOnline = await waitFor(async () => (await serverBlocks(admin, doc.id)).includes('Erster Punkt + A offline'), 8000);
|
||||
check(!!afterOnline, 'Nach Rückkehr online wird der Offline-Text übertragen');
|
||||
|
||||
// 5. Both converge and a reload shows the same state
|
||||
await sleep(4000);
|
||||
const final = await serverBlocks(admin, doc.id);
|
||||
const a = await editorValues(A.page);
|
||||
const b = await editorValues(B.page);
|
||||
check(JSON.stringify(a) === JSON.stringify(b), `Beide Browser zeigen dasselbe: ${JSON.stringify(a)}`);
|
||||
check(final.every((c) => a.includes(c)), `Server-Stand ist sichtbar: ${JSON.stringify(final)}`);
|
||||
await A.page.reload();
|
||||
await A.page.getByRole('button', { name: /Workspace/ }).first().click();
|
||||
await A.page.getByText(TITLE, { exact: true }).first().click();
|
||||
const reloaded = await waitFor(async () => {
|
||||
const v = await editorValues(A.page);
|
||||
return JSON.stringify(v) === JSON.stringify(a) ? v : null;
|
||||
}, 8000);
|
||||
check(!!reloaded, 'Nach Neuladen ist alles noch da');
|
||||
|
||||
// 6. Structure: indent with Tab, merge with Backspace, both survive a reload. B leaves first, so none of
|
||||
// its block leases are active (with B's cursor in a block the server rightly rejects moving it).
|
||||
await B.ctx.close();
|
||||
// Closing a context abruptly may drop B's "leave" request; its leases then expire after 20 s
|
||||
const freed = await waitFor(async () => {
|
||||
const probe = await api(admin, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'probe', lastSeq: 0 });
|
||||
return probe.leases.others.length === 0;
|
||||
}, 25000);
|
||||
check(!!freed, 'Sperren eines geschlossenen Browsers laufen nach spätestens 20 s ab');
|
||||
await sleep(3500); // the admin's browser learns about it with its next sync (every 3 s in shared notes)
|
||||
await A.page.getByText('Von B', { exact: true }).first().click();
|
||||
await A.page.keyboard.press('Home');
|
||||
await A.page.keyboard.press('Tab');
|
||||
await sleep(1500);
|
||||
const legacy = await api(admin, 'GET', `/api/v1/documents/${doc.id}`);
|
||||
const vonB = legacy.blocks.find((b) => b.content === 'Von B');
|
||||
check(vonB && vonB.level === 1, `Tab rückt ein (Ebene ${vonB?.level})`);
|
||||
await A.page.getByText('Zweiter von A', { exact: true }).first().click();
|
||||
await A.page.keyboard.press('Home');
|
||||
await A.page.keyboard.press('Backspace');
|
||||
const merged = await waitFor(async () => (await serverBlocks(admin, doc.id)).includes('Erster Punkt + A offlineZweiter von A'), 5000);
|
||||
check(!!merged, 'Backspace am Zeilenanfang führt mit dem Bullet darüber zusammen');
|
||||
await A.page.reload();
|
||||
await A.page.getByRole('button', { name: /Workspace/ }).first().click();
|
||||
await A.page.getByText(TITLE, { exact: true }).first().click();
|
||||
const afterStructure = await waitFor(async () => {
|
||||
const v = await editorValues(A.page);
|
||||
return v.includes('Erster Punkt + A offlineZweiter von A') && v.includes('Von B') && !v.includes('Zweiter von A') ? v : null;
|
||||
}, 8000);
|
||||
check(!!afterStructure, `Struktur nach Neuladen erhalten: ${JSON.stringify(afterStructure)}`);
|
||||
const B2 = await openDoc(browser, user, TITLE);
|
||||
const bSees = await waitFor(async () => {
|
||||
const v = await editorValues(B2.page);
|
||||
return v.includes('Erster Punkt + A offlineZweiter von A') && !v.includes('Zweiter von A') ? v : null;
|
||||
}, 8000);
|
||||
check(!!bSees, `Bearbeiter sieht nach erneutem Öffnen denselben Stand: ${JSON.stringify(bSees)}`);
|
||||
|
||||
const log = await api(admin, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'e2e-check', lastSeq: 0 });
|
||||
const conflicts = log.ops.filter((e) => e.effect.kind === 'insert' && e.effect.props?.conflictOf).length;
|
||||
console.log(`Log: ${log.headSeq} Einträge, ${conflicts} Konfliktkopien`);
|
||||
|
||||
await browser.close();
|
||||
console.log(failures ? `${failures} FEHLGESCHLAGEN` : 'ALLE PRÜFUNGEN BESTANDEN');
|
||||
process.exit(failures ? 1 : 0);
|
||||
};
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
// Locks, presence, undo and conflict copies with two real browsers. Needs a server in demo mode
|
||||
// (accounts admin/admin and user/user); see e2e/README.md.
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let failures = 0;
|
||||
const check = (cond, msg) => {
|
||||
console.log(`${cond ? 'OK ' : 'FAIL'} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
async function api(token, method, path, body) {
|
||||
const res = await fetch(BASE + path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${text}`);
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
async function waitFor(fn, ms) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
try {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
} catch {}
|
||||
await sleep(250);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const serverBlocks = async (token, docId) =>
|
||||
(await api(token, 'GET', `/api/v1/docs/${docId}/snapshot`)).blocks.filter((b) => !b.deleted);
|
||||
const editorValues = (page) =>
|
||||
page.$$eval('textarea', (els) => els.filter((e) => !e.placeholder?.startsWith('Titel')).map((e) => e.value));
|
||||
|
||||
// Puts the cursor at the end of the text field showing `text` (works whether or not it is focused already)
|
||||
async function focusField(page, text) {
|
||||
const handle = await page.waitForFunction(
|
||||
(t) => Array.from(document.querySelectorAll('textarea')).find((e) => e.value === t) || null,
|
||||
text,
|
||||
{ timeout: 5000, polling: 100 } // rAF polling stalls in background pages
|
||||
);
|
||||
const el = handle.asElement();
|
||||
await el.click();
|
||||
await page.keyboard.press('End');
|
||||
}
|
||||
|
||||
async function openDoc(browser, token, title) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
await ctx.addInitScript((t) => localStorage.setItem('mindsift_jwt_token', t), token);
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(BASE);
|
||||
await page.getByRole('button', { name: /Workspace/ }).first().click();
|
||||
await page.getByText(title, { exact: true }).first().click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('textarea').length > 1, null, { timeout: 10000 });
|
||||
return { ctx, page };
|
||||
}
|
||||
|
||||
const TITLE = `Sperr-Test ${Date.now()}`;
|
||||
const id = (n) => `lk_${n}_${Date.now()}`;
|
||||
|
||||
const main = async () => {
|
||||
const admin = (await api(null, 'POST', '/api/v1/auth/login', { login: 'admin', password: 'admin' })).token;
|
||||
const user = (await api(null, 'POST', '/api/v1/auth/login', { login: 'user', password: 'user' })).token;
|
||||
const dir = await api(admin, 'GET', '/api/v1/users/directory');
|
||||
const userId = dir.find((u) => u.username === 'user' || u.email?.startsWith('user')).id;
|
||||
const ids = { a: id('a'), b: id('b') };
|
||||
const doc = await api(admin, 'POST', '/api/v1/documents', {
|
||||
title: TITLE,
|
||||
blocks: [
|
||||
{ id: ids.a, type: 'bullet', content: 'Admin schreibt hier', level: 0 },
|
||||
{ id: ids.b, type: 'bullet', content: 'Darunter', level: 0 },
|
||||
],
|
||||
});
|
||||
await api(admin, 'POST', `/api/v1/documents/${doc.id}/shares`, { userId, permission: 'write' });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const A = await openDoc(browser, admin, TITLE);
|
||||
const B = await openDoc(browser, user, TITLE);
|
||||
|
||||
// Presence
|
||||
// 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}`);
|
||||
|
||||
// Admin clicks into the first bullet -> user sees the lock
|
||||
await A.page.getByText('Admin schreibt hier', { exact: true }).first().click();
|
||||
const lockBadge = await waitFor(async () => (await B.page.getByTitle(/bearbeitet diesen Punkt gerade/).count()) > 0, 10000);
|
||||
check(!!lockBadge, 'Bearbeiter sieht das Schloss am gesperrten Bullet');
|
||||
const readOnly = await B.page.$$eval('textarea', (els) => els.find((e) => e.value === 'Admin schreibt hier')?.readOnly);
|
||||
check(readOnly === true, 'Gesperrter Bullet ist beim Bearbeiter schreibgeschützt');
|
||||
|
||||
// User tries to merge "Darunter" into the locked bullet
|
||||
await B.page.getByText('Darunter', { exact: true }).first().click();
|
||||
await B.page.keyboard.press('Home');
|
||||
await B.page.keyboard.press('Backspace');
|
||||
const notice = await waitFor(async () => B.page.getByRole('status').innerText(), 3000);
|
||||
check(!!notice && /bearbeitet diesen Punkt gerade/.test(notice), `Hinweis statt halber Aktion: ${notice}`);
|
||||
await sleep(1500);
|
||||
const afterMerge = (await serverBlocks(admin, doc.id)).map((b) => b.content).sort();
|
||||
check(JSON.stringify(afterMerge) === JSON.stringify(['Admin schreibt hier', 'Darunter']), `Nichts wurde teilweise ausgeführt: ${JSON.stringify(afterMerge)}`);
|
||||
|
||||
// Undo on the admin side
|
||||
await focusField(A.page, 'Admin schreibt hier');
|
||||
await A.page.keyboard.type(' und noch mehr');
|
||||
await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Admin schreibt hier und noch mehr'), 5000);
|
||||
await A.page.keyboard.press('Control+z');
|
||||
const undone = await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Admin schreibt hier'), 5000);
|
||||
check(!!undone, 'Strg+Z nimmt den getippten Text zurück (auch auf dem Server)');
|
||||
const fieldUndone = await waitFor(async () => (await editorValues(A.page)).includes('Admin schreibt hier'), 3000);
|
||||
check(!!fieldUndone, 'Das fokussierte Feld zeigt den zurückgenommenen Text');
|
||||
await A.page.keyboard.press('Control+Shift+z');
|
||||
const redone = await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Admin schreibt hier und noch mehr'), 5000);
|
||||
check(!!redone, 'Strg+Umschalt+Z stellt ihn wieder her');
|
||||
|
||||
// Typing right after Enter lands in the new bullet (no pause)
|
||||
await A.page.keyboard.press('End');
|
||||
await A.page.keyboard.press('Enter');
|
||||
await A.page.keyboard.type('Direkt nach Enter');
|
||||
const newBullet = await waitFor(async () => (await serverBlocks(admin, doc.id)).some((b) => b.content === 'Direkt nach Enter'), 5000);
|
||||
check(!!newBullet, 'Sofort nach Enter Getipptes landet im neuen Bullet');
|
||||
|
||||
// Conflict copy: two writes on the same base version via the API
|
||||
const before = (await serverBlocks(admin, doc.id)).find((b) => b.id === ids.b);
|
||||
await api(admin, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'api-1', lastSeq: 0, ops: [{ opId: `${ids.b}-x`, kind: 'set_content', blockId: ids.b, baseVersion: before.version, content: 'Gewinner-Text' }] });
|
||||
await api(user, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'api-2', lastSeq: 0, ops: [{ opId: `${ids.b}-y`, kind: 'set_content', blockId: ids.b, baseVersion: before.version, content: 'Verlierer-Text' }] });
|
||||
const conflictShown = await waitFor(async () => (await A.page.getByText(/Version von/).count()) > 0, 8000);
|
||||
check(!!conflictShown, 'Konfliktkopie wird markiert angezeigt');
|
||||
// The user's cursor is still in the original bullet (it holds the lock), so the admin may not overwrite it
|
||||
await A.page.getByRole('button', { name: 'Übernehmen' }).first().click();
|
||||
const refused = await waitFor(async () => A.page.getByRole('status').innerText(), 3000);
|
||||
check(!!refused && /bearbeitet/.test(refused), `Übernehmen in einen fremd gesperrten Punkt wird verweigert: ${refused}`);
|
||||
// The user holding the original can accept once the copy's author (here a separate API client that never
|
||||
// renews its lease) no longer holds the copy, i.e. after at most 20 s
|
||||
await waitFor(async () => (await B.page.getByText(/Version von/).count()) > 0, 8000);
|
||||
const copyId = (await serverBlocks(admin, doc.id)).find((b) => b.props?.conflictOf)?.id;
|
||||
const copyFree = await waitFor(async () => {
|
||||
const probe = await api(admin, 'POST', `/api/v1/docs/${doc.id}/sync`, { clientId: 'probe', lastSeq: 0 });
|
||||
return !probe.leases.others.some((l) => l.blockId === copyId);
|
||||
}, 25000);
|
||||
check(!!copyFree, 'Sperre der Konfliktkopie läuft ab');
|
||||
await sleep(3500); // the user's browser learns about it with its next sync
|
||||
// Both API writes became conflict copies (the user's cursor locked the original); accept the first one
|
||||
const copiesBefore = (await serverBlocks(admin, doc.id)).filter((b) => b.props?.conflictOf === ids.b);
|
||||
const firstCopyText = await B.page.$$eval(
|
||||
'textarea',
|
||||
(els, texts) => els.map((e) => e.value).find((v) => texts.includes(v)),
|
||||
copiesBefore.map((c) => c.content)
|
||||
);
|
||||
await B.page.getByRole('button', { name: 'Übernehmen' }).first().click();
|
||||
const accepted = await waitFor(async () => {
|
||||
const blocks = await serverBlocks(admin, doc.id);
|
||||
const copiesNow = blocks.filter((b) => b.props?.conflictOf === ids.b);
|
||||
return blocks.find((b) => b.id === ids.b)?.content === firstCopyText && copiesNow.length === copiesBefore.length - 1;
|
||||
}, 6000);
|
||||
check(!!accepted, `„Übernehmen“ ersetzt den ursprünglichen Text durch „${firstCopyText}“ und entfernt diese Kopie`);
|
||||
|
||||
await browser.close();
|
||||
console.log(failures ? `${failures} FEHLGESCHLAGEN` : 'ALLE PRÜFUNGEN BESTANDEN');
|
||||
process.exit(failures ? 1 : 0);
|
||||
};
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "mindsift-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mindsift-e2e",
|
||||
"dependencies": {
|
||||
"playwright": "1.48.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.48.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.48.2.tgz",
|
||||
"integrity": "sha512-NjYvYgp4BPmiwfe31j4gHLa3J7bD2WiBz8Lk2RoSsmX38SVIARZ18VYjxLjAcDsAhA+F4iSEXTSGgjua0rrlgQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.48.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.48.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.2.tgz",
|
||||
"integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "mindsift-e2e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"playwright": "1.48.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds MindSift, starts it in demo mode on port 18080 with a fresh database and runs the browser tests.
|
||||
# Requires podman (or docker via ENGINE=docker). Usage: e2e/run.sh
|
||||
set -euo pipefail
|
||||
ENGINE="${ENGINE:-podman}"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
trap '"$ENGINE" rm -f mindsift-e2e >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT
|
||||
|
||||
echo "Baue Image …"
|
||||
"$ENGINE" build -q -t mindsift-e2e-image "$ROOT" >/dev/null
|
||||
"$ENGINE" run -d --name mindsift-e2e --network host -e PORT=18080 -e MINDA_DEMO=true \
|
||||
-e JWT_SECRET=e2e-only-secret -v "$WORK":/data:Z mindsift-e2e-image >/dev/null
|
||||
for _ in $(seq 1 60); do curl -s -m 1 http://localhost:18080/api/v1/health >/dev/null && break; sleep 0.5; done
|
||||
|
||||
echo "Starte Browser-Tests …"
|
||||
"$ENGINE" run --rm --network host -v "$ROOT/e2e":/e2e:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
|
||||
sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node collab.e2e.mjs && node share.e2e.mjs && node lock.e2e.mjs && node channels.e2e.mjs'
|
||||
@@ -0,0 +1,84 @@
|
||||
// Takes the README screenshots from a fresh server in demo mode (sample data of admin/admin).
|
||||
// Usage: e2e/screenshots.sh (builds the image) or MINDSIFT_URL=… SHOTS_DIR=… node screenshots.mjs
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080';
|
||||
const OUT = process.env.SHOTS_DIR || '../screenshots';
|
||||
const DESKTOP = { viewport: { width: 1518, height: 886 }, deviceScaleFactor: 2 };
|
||||
const MOBILE = { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
async function login(user) {
|
||||
const res = await fetch(`${BASE}/api/v1/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ login: user, password: user }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`login ${user}: ${res.status}`);
|
||||
return (await res.json()).token;
|
||||
}
|
||||
|
||||
async function open(browser, token, options) {
|
||||
const ctx = await browser.newContext({ ...options, colorScheme: 'dark', locale: 'de-DE' });
|
||||
await ctx.addInitScript((t) => localStorage.setItem('mindsift_jwt_token', t), token);
|
||||
const page = await ctx.newPage();
|
||||
page.on('pageerror', (e) => console.log('PAGEERROR', e.message));
|
||||
await page.goto(BASE);
|
||||
await page.waitForLoadState('networkidle');
|
||||
return page;
|
||||
}
|
||||
|
||||
const tab = (page, name) => page.getByRole('button', { name: new RegExp(name) }).first().click();
|
||||
|
||||
async function shot(page, file) {
|
||||
await sleep(800); // animations
|
||||
await page.mouse.move(0, 0);
|
||||
await page.screenshot({ path: `${OUT}/${file}` });
|
||||
console.log(`OK ${file}`);
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const admin = await login('admin');
|
||||
const user = await login('user');
|
||||
const browser = await chromium.launch({ args: ['--lang=de-DE'] });
|
||||
|
||||
const page = await open(browser, admin, DESKTOP);
|
||||
await tab(page, 'Triage');
|
||||
await shot(page, 'Triage_desktop.png');
|
||||
|
||||
await tab(page, 'Aufgaben');
|
||||
await page.getByText('Homelab', { exact: true }).first().click().catch(() => {});
|
||||
await page.getByRole('button', { name: /Kanban|Board/ }).first().click().catch(() => {});
|
||||
await shot(page, 'aufgaben_desktop.png');
|
||||
|
||||
// The demo user edits a bullet of the shared note, so the screenshot shows lock and presence
|
||||
const other = await open(browser, user, DESKTOP);
|
||||
await tab(other, 'Workspace');
|
||||
await other.getByText(/^Homelab 2\.0/).first().click();
|
||||
await other.waitForFunction(() => document.querySelectorAll('textarea').length > 5, null, { timeout: 10000 });
|
||||
await other.getByText('Endlich Platz für die Dienste', { exact: false }).first().click();
|
||||
await other.keyboard.press('End');
|
||||
await other.keyboard.type(': Paperless und Immich', { delay: 20 });
|
||||
|
||||
await tab(page, 'Workspace');
|
||||
await page.getByText(/^Homelab 2\.0/).first().click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('textarea').length > 5, null, { timeout: 10000 });
|
||||
await page.getByText('User', { exact: false }).first().waitFor({ timeout: 15000 }).catch(() => {});
|
||||
await sleep(2000);
|
||||
await shot(page, 'workspace_desktop.png');
|
||||
|
||||
await tab(page, 'Listen');
|
||||
await page.getByText('Wocheneinkauf', { exact: true }).first().click().catch(() => {});
|
||||
await shot(page, 'Listen_desktop.png');
|
||||
|
||||
const mobile = await open(browser, admin, MOBILE);
|
||||
await mobile.getByText('Später im Browser weiter nutzen').click({ timeout: 5000 }).catch(() => {}); // install prompt
|
||||
await shot(mobile, 'Mobile_Triage.png');
|
||||
|
||||
await browser.close();
|
||||
};
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds MindSift, starts it in demo mode with a fresh database and renews the README screenshots
|
||||
# in screenshots/. Requires podman (or docker via ENGINE=docker). Usage: e2e/screenshots.sh
|
||||
set -euo pipefail
|
||||
ENGINE="${ENGINE:-podman}"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
WORK="$(mktemp -d)"
|
||||
trap '"$ENGINE" rm -f mindsift-shots >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT
|
||||
|
||||
echo "Baue Image …"
|
||||
# Version in the header, e.g. APP_VERSION=v0.2 e2e/screenshots.sh
|
||||
"$ENGINE" build -q --build-arg APP_VERSION="${APP_VERSION:-}" -t mindsift-e2e-image "$ROOT" >/dev/null
|
||||
"$ENGINE" run -d --name mindsift-shots --network host -e PORT=18081 -e MINDA_DEMO=true \
|
||||
-e JWT_SECRET=screenshots-only-secret -v "$WORK":/data:Z mindsift-e2e-image >/dev/null
|
||||
for _ in $(seq 1 60); do curl -s -m 1 http://localhost:18081/api/v1/health >/dev/null && break; sleep 0.5; done
|
||||
|
||||
echo "Erstelle Screenshots …"
|
||||
"$ENGINE" run --rm --network host -e MINDSIFT_URL=http://localhost:18081 -e SHOTS_DIR=/shots -e LANG=de_DE.UTF-8 \
|
||||
-v "$ROOT/e2e":/e2e:Z -v "$ROOT/screenshots":/shots:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
|
||||
sh -c 'apt-get update -qq >/dev/null && apt-get install -y -qq fonts-inter >/dev/null 2>&1; npm install --no-audit --no-fund >/dev/null 2>&1 && node screenshots.mjs'
|
||||
@@ -0,0 +1,124 @@
|
||||
// Shares a note as editor through the share dialog and checks that the editor can then edit it.
|
||||
// Needs a server in demo mode (accounts admin/admin and user/user); see e2e/README.md.
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080';
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
let failures = 0;
|
||||
const check = (cond, msg) => {
|
||||
console.log(`${cond ? 'OK ' : 'FAIL'} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
async function api(token, method, path, body) {
|
||||
const res = await fetch(BASE + path, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${text}`);
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
async function waitFor(fn, ms) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
try {
|
||||
const v = await fn();
|
||||
if (v) return v;
|
||||
} catch {}
|
||||
await sleep(250);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function openDoc(browser, token, title) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
|
||||
await ctx.addInitScript((t) => localStorage.setItem('mindsift_jwt_token', t), token);
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(BASE);
|
||||
await page.getByRole('button', { name: /Workspace/ }).first().click();
|
||||
await page.getByText(title, { exact: true }).first().click();
|
||||
await page.waitForFunction(() => document.querySelectorAll('textarea').length > 1, null, { timeout: 10000 });
|
||||
return { ctx, page };
|
||||
}
|
||||
|
||||
const TITLE = `Freigabe-Test ${Date.now()}`;
|
||||
const TODO_ID = `fr_todo_${Date.now()}`;
|
||||
const TODO_TEXT = `Rollup-Aufgabe ${Date.now()}`;
|
||||
|
||||
const main = async () => {
|
||||
const admin = (await api(null, 'POST', '/api/v1/auth/login', { login: 'admin', password: 'admin' })).token;
|
||||
const user = (await api(null, 'POST', '/api/v1/auth/login', { login: 'user', password: 'user' })).token;
|
||||
const doc = await api(admin, 'POST', '/api/v1/documents', {
|
||||
title: TITLE,
|
||||
blocks: [
|
||||
{ id: `fr_${Date.now()}`, type: 'bullet', content: 'Vom Eigentümer', level: 0 },
|
||||
{ id: TODO_ID, type: 'todo', content: TODO_TEXT, level: 0 },
|
||||
],
|
||||
});
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const A = await openDoc(browser, admin, TITLE);
|
||||
|
||||
// Share as editor through the dialog
|
||||
await A.page.getByTitle('Notiz teilen und Berechtigungen').click();
|
||||
await A.page.getByRole('button', { name: 'Benutzer', exact: true }).click();
|
||||
const search = A.page.getByPlaceholder(/Benutzer suchen/);
|
||||
await search.click();
|
||||
await search.type('user');
|
||||
await sleep(300);
|
||||
await search.press('Enter'); // the first match is preselected
|
||||
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');
|
||||
await roleSelect.selectOption('editor');
|
||||
await A.page.getByRole('button', { name: /Benutzer hinzufügen/ }).click();
|
||||
const shares = await waitFor(async () => {
|
||||
const list = await api(admin, 'GET', `/api/v1/documents/${doc.id}/shares`);
|
||||
return list.length > 0 ? list : null;
|
||||
}, 5000);
|
||||
check(shares && shares[0].permission === 'write', `Freigabe gespeichert als ${shares?.[0]?.permission}`);
|
||||
const badge = await A.page
|
||||
.getByText('Bearbeiter', { exact: true })
|
||||
.first()
|
||||
.waitFor({ state: 'visible', timeout: 5000 })
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
check(badge, 'Dialog zeigt die Rolle „Bearbeiter“');
|
||||
|
||||
// The editor can edit
|
||||
const snap = await api(user, 'GET', `/api/v1/docs/${doc.id}/snapshot`);
|
||||
check(snap.role === 'editor', `Empfänger hat die Rolle ${snap.role}`);
|
||||
const B = await openDoc(browser, user, TITLE);
|
||||
check(!(await B.page.getByText('schreibgeschützt mit dir geteilt', { exact: false }).first().isVisible().catch(() => false)), 'Kein Nur-Lesen-Hinweis beim Bearbeiter');
|
||||
await B.page.getByText('Vom Eigentümer', { exact: true }).first().click();
|
||||
await B.page.keyboard.press('End');
|
||||
await B.page.keyboard.type(' und vom Bearbeiter');
|
||||
const saved = await waitFor(async () => {
|
||||
const s = await api(admin, 'GET', `/api/v1/docs/${doc.id}/snapshot`);
|
||||
return s.blocks.some((b) => b.content === 'Vom Eigentümer und vom Bearbeiter');
|
||||
}, 6000);
|
||||
check(!!saved, 'Bearbeiter kann die geteilte Notiz bearbeiten');
|
||||
|
||||
// Ticking a to-do in the overview goes through the sync endpoint as well
|
||||
await A.page.bringToFront();
|
||||
await A.page.getByRole('button', { name: 'Schließen', exact: true }).last().click(); // the share dialog is still open
|
||||
await A.page.locator('button:visible', { hasText: 'To-Dos' }).first().click();
|
||||
await A.page.getByTitle('Alle To-Dos als einfache Liste').click();
|
||||
await A.page.locator('div', { hasText: TODO_TEXT }).last().locator('button').first().click();
|
||||
const ticked = await waitFor(async () => {
|
||||
const s = await api(admin, 'GET', `/api/v1/docs/${doc.id}/snapshot`);
|
||||
return s.blocks.find((b) => b.id === TODO_ID)?.props?.checked === true;
|
||||
}, 5000);
|
||||
check(!!ticked, 'Abhaken in der To-Do-Übersicht wird gespeichert');
|
||||
|
||||
await browser.close();
|
||||
console.log(failures ? `${failures} FEHLGESCHLAGEN` : 'ALLE PRÜFUNGEN BESTANDEN');
|
||||
process.exit(failures ? 1 : 0);
|
||||
};
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -9,9 +9,35 @@
|
||||
<meta name="apple-mobile-web-app-title" content="MindSift" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="MindSift — Der Kopf frei in drei Sekunden. Instant Capture, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
<!-- Favicon & Touch Icons -->
|
||||
<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>
|
||||
<script>
|
||||
window.__deferredPwaPrompt = null;
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "0.3.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "echo 'Linting passed'",
|
||||
"test:run": "echo 'Frontend checks passed'"
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.6",
|
||||
"emoji-picker-react": "4.12.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"prismjs": "^1.30.0",
|
||||
@@ -19,13 +22,18 @@
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"fake-indexeddb": "^6.2.6",
|
||||
"jsdom": "^25.0.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.11"
|
||||
"vite": "^5.4.11",
|
||||
"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">
|
||||
<rect width="512" height="512" rx="128" fill="#8ab4f8"/>
|
||||
<g transform="translate(80, 80) scale(14.666)" fill="none" stroke="#0b0f19" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="512" height="512" rx="108" fill="#2563eb"/>
|
||||
<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 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"/>
|
||||
@@ -11,4 +11,4 @@
|
||||
<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>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 851 B After Width: | Height: | Size: 872 B |
@@ -17,8 +17,32 @@
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "192x192 512x512",
|
||||
"src": "/icon-192x192.png",
|
||||
"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",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Layers,
|
||||
CheckSquare,
|
||||
@@ -23,8 +23,9 @@ import {
|
||||
Flame,
|
||||
Mic,
|
||||
MicOff,
|
||||
Search,
|
||||
} 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 {
|
||||
api,
|
||||
clearToken,
|
||||
@@ -32,14 +33,18 @@ import {
|
||||
triggerHaptic,
|
||||
connectWebSocket,
|
||||
disconnectWebSocket,
|
||||
isSharingDisabled,
|
||||
} from './lib/api';
|
||||
import {
|
||||
AVAILABLE_THEMES,
|
||||
customThemeOption,
|
||||
initThemeOnBoot,
|
||||
isCustomThemeId,
|
||||
setActiveTheme as applyTheme,
|
||||
ThemeId,
|
||||
} from './lib/theme';
|
||||
import { InstantCaptureBar } from './features/capture/InstantCaptureBar';
|
||||
import { CountBadge } from './components/CountBadge';
|
||||
import { TriageDeck } from './features/triage/TriageDeck';
|
||||
import { TasksView } from './features/triage/TasksView';
|
||||
import { WorkspaceView } from './features/workspace/WorkspaceView';
|
||||
@@ -52,6 +57,8 @@ import { GroupsView } from './features/teams/GroupsView';
|
||||
import { useMobileActions } from './context/MobileActionContext';
|
||||
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
||||
import { UserAvatar } from './components/UserAvatar';
|
||||
import { CommandPalette } from './components/CommandPalette';
|
||||
import { APP_VERSION } from './version';
|
||||
|
||||
type ActiveTab =
|
||||
| 'triage'
|
||||
@@ -113,6 +120,8 @@ export const App: React.FC = () => {
|
||||
instanceName: 'MindSift',
|
||||
});
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [customThemes, setCustomThemes] = useState<UserTheme[]>([]);
|
||||
const allThemes = useMemo(() => [...AVAILABLE_THEMES, ...customThemes.map(customThemeOption)], [customThemes]);
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
inboxCount: 0,
|
||||
tasksOpenCount: 0,
|
||||
@@ -129,6 +138,8 @@ export const App: React.FC = () => {
|
||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
||||
const [selectedBlockId, setSelectedBlockId] = 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 [refreshRevision, setRefreshRevision] = useState(0);
|
||||
|
||||
@@ -225,7 +236,7 @@ export const App: React.FC = () => {
|
||||
useEffect(() => {
|
||||
closeMenu();
|
||||
if (activeTab === 'triage') {
|
||||
setPrimaryAction(() => openMobileTriageCapture);
|
||||
setPrimaryAction(openMobileTriageCapture);
|
||||
setGroups([
|
||||
{
|
||||
id: 'triage-actions',
|
||||
@@ -272,12 +283,16 @@ export const App: React.FC = () => {
|
||||
badge: stats.listsCount,
|
||||
onClick: () => setActiveTab('lists'),
|
||||
},
|
||||
{
|
||||
id: 'goto-groups',
|
||||
label: 'Teams & Gruppen',
|
||||
icon: <Users className="w-4 h-4" />,
|
||||
onClick: () => setActiveTab('groups'),
|
||||
},
|
||||
...(isSharingDisabled()
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: 'goto-groups',
|
||||
label: 'Gruppen',
|
||||
icon: <Users className="w-4 h-4" />,
|
||||
onClick: () => setActiveTab('groups'),
|
||||
},
|
||||
]),
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -410,12 +425,33 @@ export const App: React.FC = () => {
|
||||
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) => {
|
||||
triggerHaptic(10);
|
||||
applyTheme(themeId);
|
||||
setActiveThemeState(themeId);
|
||||
activateTheme(themeId, customThemes);
|
||||
};
|
||||
|
||||
// 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(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.id]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
try {
|
||||
const [meRes, deckRes, listsRes] = await Promise.all([
|
||||
@@ -562,6 +598,36 @@ export const App: React.FC = () => {
|
||||
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 = () => {
|
||||
triggerHaptic(20);
|
||||
if (window.innerWidth < 640) {
|
||||
@@ -617,7 +683,7 @@ export const App: React.FC = () => {
|
||||
tasks: 'Aufgaben',
|
||||
workspace: 'Workspace',
|
||||
lists: 'Listen',
|
||||
groups: 'Teams & Gruppen',
|
||||
groups: 'Gruppen',
|
||||
user_settings: 'Einstellungen',
|
||||
admin_settings: 'Administration',
|
||||
help: 'Hilfe',
|
||||
@@ -648,7 +714,7 @@ export const App: React.FC = () => {
|
||||
<span className="font-bold text-base text-main tracking-tight leading-none">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">v0.1</span>
|
||||
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
||||
</div>
|
||||
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
|
||||
{activeTabLabelMap[activeTab]}
|
||||
@@ -678,6 +744,19 @@ export const App: React.FC = () => {
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -713,17 +792,28 @@ export const App: React.FC = () => {
|
||||
<span className="font-bold text-sm sm:text-base text-main tracking-tight leading-none">
|
||||
{authStatus.instanceName || 'MindSift'}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">v0.1</span>
|
||||
<span className="text-[10px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
|
||||
<div className="flex-1 max-w-xl mx-auto">
|
||||
<InstantCaptureBar
|
||||
onCaptured={handleCaptured}
|
||||
lists={lists}
|
||||
autoFocusTrigger={captureFocusCount}
|
||||
/>
|
||||
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
|
||||
<div className="flex-1">
|
||||
<InstantCaptureBar
|
||||
onCaptured={handleCaptured}
|
||||
lists={lists}
|
||||
autoFocusTrigger={captureFocusCount}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCommandPalette(true)}
|
||||
className="h-8 px-2.5 rounded-btn bg-panel border border-border text-muted hover:text-main flex items-center gap-1.5 text-xs transition-colors cursor-pointer shrink-0"
|
||||
title="Globale Suche öffnen (Cmd+K oder /)"
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
<span className="hidden lg:inline font-mono text-[10px] text-accent font-semibold">⌘K</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User Profile Pill & Dropdown Menu */}
|
||||
@@ -764,7 +854,7 @@ export const App: React.FC = () => {
|
||||
<span>Design-Theme</span>
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{AVAILABLE_THEMES.map((t) => (
|
||||
{allThemes.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
@@ -801,6 +891,7 @@ export const App: React.FC = () => {
|
||||
<span>Kontoeinstellungen</span>
|
||||
</button>
|
||||
|
||||
{!isSharingDisabled() && (
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(10);
|
||||
@@ -814,6 +905,7 @@ export const App: React.FC = () => {
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Gruppen</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleOpenHelp}
|
||||
@@ -872,11 +964,7 @@ export const App: React.FC = () => {
|
||||
<Layers className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-2' : ''}`}>
|
||||
<span>Triage</span>
|
||||
{stats.inboxCount > 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.inboxCount}
|
||||
</span>
|
||||
)}
|
||||
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -890,11 +978,7 @@ export const App: React.FC = () => {
|
||||
<FileText className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${(stats.docsTodosOpenCount ?? 0) > 0 ? 'pr-2' : ''}`}>
|
||||
<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>
|
||||
)}
|
||||
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -908,11 +992,7 @@ export const App: React.FC = () => {
|
||||
<CheckSquare className="w-4 h-4" />
|
||||
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-2' : ''}`}>
|
||||
<span>Aufgaben</span>
|
||||
{stats.tasksOpenCount > 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.tasksOpenCount}
|
||||
</span>
|
||||
)}
|
||||
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -969,6 +1049,7 @@ export const App: React.FC = () => {
|
||||
onOpenDocument={handleOpenDocument}
|
||||
refreshTrigger={refreshRevision}
|
||||
initialSelectedTaskId={selectedTaskId}
|
||||
openTasksCount={stats.tasksOpenCount}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -987,7 +1068,11 @@ export const App: React.FC = () => {
|
||||
)}
|
||||
|
||||
{activeTab === 'lists' && (
|
||||
<SharedListsView onRefreshGlobal={refreshAll} currentUser={user} />
|
||||
<SharedListsView
|
||||
onRefreshGlobal={refreshAll}
|
||||
currentUser={user}
|
||||
initialListId={selectedListId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'groups' && (
|
||||
@@ -1001,7 +1086,10 @@ export const App: React.FC = () => {
|
||||
<UserSettingsView
|
||||
currentUser={user}
|
||||
activeTheme={activeTheme}
|
||||
themes={allThemes}
|
||||
customThemes={customThemes}
|
||||
onThemeChange={handleChangeTheme}
|
||||
onCustomThemesChange={handleCustomThemesChange}
|
||||
onUserUpdated={(u) => {
|
||||
setUser(u);
|
||||
refreshAll();
|
||||
@@ -1036,11 +1124,7 @@ export const App: React.FC = () => {
|
||||
>
|
||||
<div className="relative">
|
||||
<Layers className="w-5 h-5" />
|
||||
{stats.inboxCount > 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.inboxCount}
|
||||
</span>
|
||||
)}
|
||||
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Triage</span>
|
||||
</button>
|
||||
@@ -1059,11 +1143,7 @@ export const App: React.FC = () => {
|
||||
>
|
||||
<div className="relative">
|
||||
<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>
|
||||
)}
|
||||
<CountBadge count={stats.docsTodosOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Workspace</span>
|
||||
</button>
|
||||
@@ -1101,11 +1181,7 @@ export const App: React.FC = () => {
|
||||
>
|
||||
<div className="relative">
|
||||
<CheckSquare className="w-5 h-5" />
|
||||
{stats.tasksOpenCount > 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.tasksOpenCount}
|
||||
</span>
|
||||
)}
|
||||
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-1.5 -right-2.5" />
|
||||
</div>
|
||||
<span className="text-[10px] leading-tight">Aufgaben</span>
|
||||
</button>
|
||||
@@ -1312,7 +1388,7 @@ export const App: React.FC = () => {
|
||||
<span>Design-Theme</span>
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{AVAILABLE_THEMES.map((t) => (
|
||||
{allThemes.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
@@ -1366,6 +1442,7 @@ export const App: React.FC = () => {
|
||||
<span>Kontoeinstellungen</span>
|
||||
</button>
|
||||
|
||||
{!isSharingDisabled() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -1380,6 +1457,7 @@ export const App: React.FC = () => {
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Gruppen</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@@ -1517,6 +1595,15 @@ export const App: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Command Palette (Cmd+K / Ctrl+K / '/') */}
|
||||
<CommandPalette
|
||||
isOpen={showCommandPalette}
|
||||
onClose={() => setShowCommandPalette(false)}
|
||||
onOpenDocument={handleOpenDocument}
|
||||
onOpenTask={handleOpenTask}
|
||||
onOpenList={handleOpenList}
|
||||
/>
|
||||
</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,327 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Search,
|
||||
FileText,
|
||||
CheckSquare,
|
||||
ShoppingCart,
|
||||
X,
|
||||
Loader2,
|
||||
ArrowRight,
|
||||
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,63 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AuthStatus } from '../lib/types';
|
||||
import { api } from '../lib/api';
|
||||
import { MarkdownViewer } from '../lib/markdown';
|
||||
import { ReadingModal } from './ReadingModal';
|
||||
|
||||
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 ? (
|
||||
<a key={e.type} href={e.url} 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 { useMobileActions } from '../context/MobileActionContext';
|
||||
import { triggerHaptic } from '../lib/api';
|
||||
import { CountBadge } from './CountBadge';
|
||||
|
||||
interface MobileHeaderMenuProps {
|
||||
activeTabLabel: string;
|
||||
@@ -109,7 +110,9 @@ export const MobileHeaderMenu: React.FC<MobileHeaderMenuProps> = ({ activeTabLab
|
||||
</div>
|
||||
|
||||
<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
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${
|
||||
isAccent
|
||||
|
||||
@@ -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 { UserAutocomplete } from '../features/UserAutocomplete';
|
||||
import { UserAvatar } from './UserAvatar';
|
||||
import { isSharingDisabled } from '../lib/api';
|
||||
|
||||
export interface UnifiedShareModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -147,7 +148,11 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
|
||||
|
||||
@@ -8,12 +8,14 @@ export interface UserAvatarProps {
|
||||
} | null;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
size?: 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
alt?: string;
|
||||
showTitle?: boolean; // native name tooltip on hover
|
||||
}
|
||||
|
||||
const SIZE_MAP = {
|
||||
xxs: 'w-4 h-4 text-[7px]',
|
||||
xs: 'w-5 h-5 text-[10px]',
|
||||
sm: 'w-7 h-7 text-xs',
|
||||
md: 'w-9 h-9 text-sm',
|
||||
@@ -68,6 +70,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
|
||||
size = 'md',
|
||||
className = '',
|
||||
alt,
|
||||
showTitle = true,
|
||||
}) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
@@ -94,7 +97,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center justify-center rounded-full aspect-square shrink-0 select-none overflow-hidden font-semibold transition-all ${sizeClass} ${className}`}
|
||||
title={effectiveName || alt}
|
||||
title={showTitle ? effectiveName || alt : undefined}
|
||||
>
|
||||
{hasImage ? (
|
||||
<img
|
||||
|
||||
@@ -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 { 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 {
|
||||
users: User[];
|
||||
selectedUser: User | null;
|
||||
@@ -42,10 +46,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
// Filter users based on query and exclusions
|
||||
const filteredUsers = React.useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return [];
|
||||
const available = users.filter((u) => !excludeUserIds.includes(u.id));
|
||||
if (!q) {
|
||||
return available.slice(0, 15);
|
||||
}
|
||||
return available
|
||||
.filter((u) => {
|
||||
const nameMatch = (u.displayName || '').toLowerCase().includes(q);
|
||||
@@ -53,8 +55,9 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
const userMatch = (u.username || '').toLowerCase().includes(q);
|
||||
return nameMatch || emailMatch || userMatch;
|
||||
})
|
||||
.slice(0, 15);
|
||||
.slice(0, MAX_RESULTS);
|
||||
}, [users, query, excludeUserIds]);
|
||||
const showResults = isOpen && query.trim() !== '';
|
||||
|
||||
const handleSelect = (user: User) => {
|
||||
onSelectUser(user);
|
||||
@@ -70,8 +73,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!isOpen) {
|
||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
||||
if (!showResults) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
setIsOpen(true);
|
||||
}
|
||||
return;
|
||||
@@ -139,7 +142,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setIsOpen(true);
|
||||
setHighlightedIndex(-1);
|
||||
// First match is preselected so Enter picks it right away
|
||||
setHighlightedIndex(0);
|
||||
}}
|
||||
onFocus={() => setIsOpen(true)}
|
||||
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"
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-56 overflow-y-auto bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
|
||||
{showResults && (
|
||||
<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 ? (
|
||||
<div className="p-3 text-xs text-muted text-center">
|
||||
Kein passender Benutzer gefunden
|
||||
|
||||
@@ -11,8 +11,10 @@ import {
|
||||
Check,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { AuthStatus, User } from '../../lib/types';
|
||||
import { AuthStatus, SharingMode, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
import { LegalLinks } from '../../components/LegalLinks';
|
||||
|
||||
interface SetupAndAuthViewProps {
|
||||
authStatus: AuthStatus;
|
||||
@@ -29,9 +31,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
const [setupPassword, setSetupPassword] = useState('');
|
||||
const [setupPasswordConfirm, setSetupPasswordConfirm] = useState('');
|
||||
const [instanceName, setInstanceName] = useState(authStatus.instanceName || 'MindSift');
|
||||
const [sharingMode, setSharingMode] = useState<'open' | 'teams_only' | 'strict_private'>(
|
||||
'teams_only'
|
||||
);
|
||||
const [sharingMode, setSharingMode] = useState<SharingMode>('organizations');
|
||||
const [allowRegistration, setAllowRegistration] = useState(false);
|
||||
const [createSampleData, setCreateSampleData] = useState(false);
|
||||
|
||||
@@ -241,31 +241,31 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSharingMode('strict_private')}
|
||||
onClick={() => setSharingMode('private')}
|
||||
className={`p-2.5 text-left border transition-colors ${
|
||||
sharingMode === 'strict_private'
|
||||
sharingMode === 'private'
|
||||
? 'bg-panel border-accent text-main'
|
||||
: 'border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs">Strikt Privat</p>
|
||||
<p className="font-bold text-xs">Privat</p>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSharingMode('teams_only')}
|
||||
onClick={() => setSharingMode('organizations')}
|
||||
className={`p-2.5 text-left border transition-colors ${
|
||||
sharingMode === 'teams_only'
|
||||
sharingMode === 'organizations'
|
||||
? 'bg-panel border-accent 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">
|
||||
Nutzer sehen nur Mitglieder ihrer eigenen Teams.
|
||||
Nutzer sehen nur Mitglieder ihrer Organisation. Du startest in einer Organisation mit dem Namen der Instanz.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
@@ -287,7 +287,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<span className="font-medium text-main block">
|
||||
Öffentliche Selbstregistrierung erlauben
|
||||
@@ -297,30 +297,21 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
Admin angelegt werden.
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowRegistration}
|
||||
onChange={(e) => setAllowRegistration(e.target.checked)}
|
||||
className="w-4 h-4 accent-blue-600"
|
||||
/>
|
||||
</label>
|
||||
<ToggleSwitch checked={allowRegistration} onChange={setAllowRegistration} />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<span className="font-medium text-main block">
|
||||
Start-Beispieldaten anlegen
|
||||
Beispieldaten anlegen
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createSampleData}
|
||||
onChange={(e) => setCreateSampleData(e.target.checked)}
|
||||
className="w-4 h-4 accent-blue-600"
|
||||
/>
|
||||
</label>
|
||||
<ToggleSwitch checked={createSampleData} onChange={setCreateSampleData} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -340,6 +331,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<LegalLinks legal={authStatus.legal} className="mt-4" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -584,6 +576,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<LegalLinks legal={authStatus.legal} className="mt-4" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -103,7 +103,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
el.tagName === 'TEXTAREA' ||
|
||||
el.isContentEditable;
|
||||
|
||||
if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) {
|
||||
if (!isEditing && (e.key === 'c' || e.key === 'C')) {
|
||||
e.preventDefault();
|
||||
inputRef.current?.focus();
|
||||
} else if (e.key === 'Escape' && document.activeElement === inputRef.current) {
|
||||
|
||||
@@ -13,11 +13,14 @@ import {
|
||||
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
||||
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
|
||||
interface SharedListsViewProps {
|
||||
onRefreshGlobal: () => void;
|
||||
currentUser?: User | null;
|
||||
initialListId?: string | null;
|
||||
}
|
||||
|
||||
export function parseListItemInput(rawText: string, defaultQty?: string): {
|
||||
@@ -77,10 +80,16 @@ export function deduplicateItems(items: ListItem[]): ListItem[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => {
|
||||
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
|
||||
const { setGroups, setPrimaryAction } = useMobileActions();
|
||||
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 [newItemQty, setNewItemQty] = useState('');
|
||||
@@ -126,7 +135,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
items: deduplicateItems(l.items || []),
|
||||
}));
|
||||
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);
|
||||
}
|
||||
} catch {
|
||||
@@ -144,10 +155,32 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
.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(() => {
|
||||
return lists.find((l) => l.id === activeListId) || lists[0] || null;
|
||||
}, [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(() => {
|
||||
if (!activeList?.id) return;
|
||||
|
||||
@@ -825,26 +858,34 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
|
||||
const isSelected = activeList?.id === lst.id;
|
||||
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
|
||||
onClick={() => setActiveListId(lst.id)}
|
||||
className={`h-8 px-3 text-xs font-medium border flex items-center gap-1.5 transition-colors ${
|
||||
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'}`}
|
||||
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
|
||||
>
|
||||
<span>{lst.icon}</span>
|
||||
<span>{lst.title}</span>
|
||||
{openCount > 0 && (
|
||||
<span className="ml-1 badge-neutral">{openCount}</span>
|
||||
)}
|
||||
{/* Counter on the label, like in the nav bar */}
|
||||
<span className={`relative inline-flex items-center ${openCount > 0 ? 'pr-2' : ''}`}>
|
||||
{lst.title}
|
||||
<CountBadge count={openCount} position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
<AvatarStack
|
||||
people={otherMembers(lst)}
|
||||
size="xxs"
|
||||
ringClass={isSelected ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
|
||||
className="ml-0.5"
|
||||
/>
|
||||
</button>
|
||||
{isSelected && lists.length > 1 && (
|
||||
<button
|
||||
onClick={() => handleDeleteList(lst.id)}
|
||||
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" />
|
||||
</button>
|
||||
@@ -1266,6 +1307,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderItemAuthor(item)}
|
||||
<button
|
||||
type="button"
|
||||
draggable={false}
|
||||
@@ -1374,6 +1416,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{renderItemAuthor(item)}
|
||||
{item.completedByName && (
|
||||
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
||||
)}
|
||||
|
||||
@@ -13,22 +13,48 @@ import {
|
||||
Send,
|
||||
AlertCircle,
|
||||
UserPlus,
|
||||
Building2,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Organization,
|
||||
SharingMode,
|
||||
SystemSettings,
|
||||
User,
|
||||
UserInvite,
|
||||
UserRole,
|
||||
} from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { api, setCurrentSharingMode, triggerHaptic } from '../../lib/api';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
|
||||
interface AdminSettingsViewProps {
|
||||
currentUser: User;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
type AdminSection = 'users' | 'server' | 'smtp';
|
||||
type AdminSection = 'users' | 'organizations' | 'server' | 'smtp';
|
||||
|
||||
const SHARING_MODES: { id: SharingMode; title: string; text: string }[] = [
|
||||
{
|
||||
id: 'private',
|
||||
title: '1. Privat (Keiner sieht andere)',
|
||||
text: 'Jeder arbeitet für sich allein. Keine Benutzersuche, kein Teilen, keine Gruppen.',
|
||||
},
|
||||
{
|
||||
id: 'organizations',
|
||||
title: '2. Organisationen (Empfohlen)',
|
||||
text: 'Benutzer sehen nur Mitglieder ihrer eigenen Organisation. Wer keiner Organisation angehört, sieht niemanden.',
|
||||
},
|
||||
{
|
||||
id: 'open',
|
||||
title: '3. Offen (Alle sehen alle)',
|
||||
text: 'Alle Benutzer dieser Instanz können sich gegenseitig finden und miteinander teilen.',
|
||||
},
|
||||
];
|
||||
// Stricter modes come first; switching to a stricter one can remove shares
|
||||
const modeRank = (m: SharingMode) => SHARING_MODES.findIndex((x) => x.id === m);
|
||||
|
||||
export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
currentUser,
|
||||
@@ -44,9 +70,21 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
const [newUserEmail, setNewUserEmail] = useState('');
|
||||
const [newUserPassword, setNewUserPassword] = useState('');
|
||||
const [newUserRole, setNewUserRole] = useState<UserRole>('member');
|
||||
const [newUserOrg, setNewUserOrg] = useState('');
|
||||
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] = useState<UserRole>('member');
|
||||
const [inviteOrg, setInviteOrg] = useState('');
|
||||
|
||||
// --- Organizations State ---
|
||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||
const [newOrgName, setNewOrgName] = useState('');
|
||||
const [renamingOrgId, setRenamingOrgId] = useState<string | null>(null);
|
||||
const [renameOrgVal, setRenameOrgVal] = useState('');
|
||||
const [deletingOrgId, setDeletingOrgId] = useState<string | null>(null);
|
||||
// Moving someone out of an organization removes their shares there, so it needs a confirmation
|
||||
const [pendingMove, setPendingMove] = useState<{ user: User; orgId: string } | null>(null);
|
||||
const [pendingMode, setPendingMode] = useState<{ mode: SharingMode; affected: number } | null>(null);
|
||||
const [copiedInviteId, setCopiedInviteId] = useState<string | null>(null);
|
||||
|
||||
const [resetPwdUserId, setResetPwdUserId] = useState<string | null>(null);
|
||||
@@ -54,7 +92,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
|
||||
// --- Server & SMTP Settings State ---
|
||||
const [settings, setSettings] = useState<SystemSettings>({
|
||||
sharingMode: 'teams_only',
|
||||
sharingMode: 'organizations',
|
||||
allowRegistration: false,
|
||||
instanceName: 'MindSift',
|
||||
smtpHost: '',
|
||||
@@ -63,6 +101,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
smtpPass: '',
|
||||
smtpFrom: '',
|
||||
smtpTls: true,
|
||||
smtpSkipVerify: false,
|
||||
smtpConfiguredViaEnv: false,
|
||||
});
|
||||
const [testSmtpEmail, setTestSmtpEmail] = useState(currentUser.email || '');
|
||||
@@ -80,9 +119,10 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
setInvites(invList);
|
||||
|
||||
if (isServerAdmin) {
|
||||
const sys = await api.adminGetSettings();
|
||||
const [sys, orgs] = await Promise.all([api.adminGetSettings(), api.adminGetOrganizations()]);
|
||||
setOrganizations(orgs);
|
||||
setSettings({
|
||||
sharingMode: sys.sharingMode || 'teams_only',
|
||||
sharingMode: sys.sharingMode || 'organizations',
|
||||
allowRegistration: Boolean(sys.allowRegistration),
|
||||
instanceName: sys.instanceName || 'MindSift',
|
||||
smtpHost: sys.smtpHost || '',
|
||||
@@ -91,6 +131,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
smtpPass: sys.smtpPass || '',
|
||||
smtpFrom: sys.smtpFrom || '',
|
||||
smtpTls: sys.smtpTls ?? true,
|
||||
smtpSkipVerify: Boolean(sys.smtpSkipVerify),
|
||||
smtpConfiguredViaEnv: Boolean(sys.smtpConfiguredViaEnv),
|
||||
});
|
||||
}
|
||||
@@ -116,8 +157,10 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
password: newUserPassword,
|
||||
displayName: newUserName.trim() || newUserEmail.split('@')[0],
|
||||
role: newUserRole,
|
||||
organizationId: newUserOrg || undefined,
|
||||
});
|
||||
setUsers((prev) => [...prev, created]);
|
||||
if (newUserOrg) await reloadOrganizations();
|
||||
setNewUserName('');
|
||||
setNewUserEmail('');
|
||||
setNewUserPassword('');
|
||||
@@ -136,6 +179,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
const inv = await api.adminCreateInvite({
|
||||
email: inviteEmail.trim(),
|
||||
role: inviteRole,
|
||||
organizationId: inviteOrg || undefined,
|
||||
});
|
||||
setInvites((prev) => [inv, ...prev]);
|
||||
setInviteEmail('');
|
||||
@@ -222,6 +266,86 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
// ============================================================================
|
||||
// HANDLERS: SERVER & SMTP SETTINGS
|
||||
// ============================================================================
|
||||
// ============================================================================
|
||||
// HANDLERS: ORGANIZATIONS
|
||||
// ============================================================================
|
||||
const reloadOrganizations = async () => {
|
||||
try {
|
||||
setOrganizations(await api.adminGetOrganizations());
|
||||
} catch {
|
||||
// keep the old list
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateOrganization = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newOrgName.trim()) return;
|
||||
try {
|
||||
await api.adminCreateOrganization(newOrgName.trim());
|
||||
setNewOrgName('');
|
||||
await reloadOrganizations();
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Anlegen der Organisation' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRenameOrganization = async (orgId: string) => {
|
||||
try {
|
||||
await api.adminRenameOrganization(orgId, renameOrgVal.trim());
|
||||
setRenamingOrgId(null);
|
||||
await reloadOrganizations();
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Umbenennen' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteOrganization = async (orgId: string) => {
|
||||
try {
|
||||
await api.adminDeleteOrganization(orgId);
|
||||
setDeletingOrgId(null);
|
||||
setUsers((prev) => prev.map((u) => (u.organizationId === orgId ? { ...u, organizationId: '' } : u)));
|
||||
await reloadOrganizations();
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Löschen' });
|
||||
}
|
||||
};
|
||||
|
||||
const applyUserOrganization = async (u: User, orgId: string) => {
|
||||
setPendingMove(null);
|
||||
try {
|
||||
await api.adminSetUserOrganization(u.id, orgId);
|
||||
setUsers((prev) => prev.map((x) => (x.id === u.id ? { ...x, organizationId: orgId } : x)));
|
||||
await reloadOrganizations();
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Zuordnen' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeUserOrganization = (u: User, orgId: string) => {
|
||||
if (u.organizationId) setPendingMove({ user: u, orgId });
|
||||
else applyUserOrganization(u, orgId);
|
||||
};
|
||||
|
||||
const orgName = (id?: string) => organizations.find((o) => o.id === id)?.name || '';
|
||||
|
||||
// Tightening the mode first asks how much sharing would be lost
|
||||
const handleSelectSharingMode = async (mode: SharingMode) => {
|
||||
if (mode === settings.sharingMode) return;
|
||||
if (modeRank(mode) < modeRank(settings.sharingMode)) {
|
||||
try {
|
||||
const affected = await api.adminSharingImpact(mode);
|
||||
if (affected > 0) {
|
||||
setPendingMode({ mode, affected });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through and save
|
||||
}
|
||||
}
|
||||
setPendingMode(null);
|
||||
handleSaveSystemSettings({ sharingMode: mode });
|
||||
};
|
||||
|
||||
const handleSaveSystemSettings = async (patch?: Partial<SystemSettings>) => {
|
||||
setStatusMsg(null);
|
||||
const next = { ...settings, ...(patch || {}) };
|
||||
@@ -229,6 +353,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
try {
|
||||
const saved = await api.adminUpdateSettings(next);
|
||||
setSettings((prev) => ({ ...prev, ...saved }));
|
||||
setCurrentSharingMode(saved.sharingMode);
|
||||
setStatusMsg({ type: 'ok', text: 'Server-Einstellungen erfolgreich gespeichert.' });
|
||||
} catch (err: any) {
|
||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
|
||||
@@ -327,6 +452,21 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
|
||||
{isServerAdmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setSection('organizations')}
|
||||
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||
section === 'organizations'
|
||||
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
|
||||
: 'text-muted hover:text-main hover:bg-canvas/50'
|
||||
}`}
|
||||
>
|
||||
<Building2 className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">Organisationen</p>
|
||||
<p className="text-[10px] text-faint">Wer wen sehen darf</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSection('server')}
|
||||
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||
@@ -383,7 +523,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleCreateUser} className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">Anzeigename</label>
|
||||
<input
|
||||
@@ -432,6 +572,24 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<option value="admin">Server-Admin (Vollzugriff)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isServerAdmin && (
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">Organisation</label>
|
||||
<select
|
||||
value={newUserOrg}
|
||||
onChange={(e) => setNewUserOrg(e.target.value)}
|
||||
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||
>
|
||||
<option value="">Keine Organisation</option>
|
||||
{organizations.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
@@ -454,7 +612,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleCreateInvite} className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">
|
||||
Gebundene E-Mail (optional)
|
||||
@@ -479,6 +637,24 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<option value="admin">Server-Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isServerAdmin && (
|
||||
<div className="min-w-0">
|
||||
<label className="block text-muted mb-1">Organisation</label>
|
||||
<select
|
||||
value={inviteOrg}
|
||||
onChange={(e) => setInviteOrg(e.target.value)}
|
||||
className="w-full min-w-0 h-8 px-2 input-field bg-panel"
|
||||
>
|
||||
<option value="">Keine Organisation</option>
|
||||
{organizations.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
@@ -511,6 +687,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<span className="text-muted">
|
||||
Rolle: <strong className="text-main">{inv.role}</strong>
|
||||
{inv.email ? ` · Für: ${inv.email}` : ''}
|
||||
{inv.organizationName ? ` · ${inv.organizationName}` : ''}
|
||||
</span>
|
||||
{inv.usedBy ? (
|
||||
<span className="badge-neutral text-[10px]">
|
||||
@@ -568,7 +745,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted text-[11px]">{u.email}</p>
|
||||
<p className="text-muted text-[11px]">
|
||||
{u.email}
|
||||
{isServerAdmin && settings.sharingMode === 'organizations' && !u.organizationId && (
|
||||
<span className="text-status-warn"> · ohne Organisation, sieht niemanden</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -585,6 +767,22 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<option value="admin">Server-Admin</option>
|
||||
</select>
|
||||
|
||||
{isServerAdmin && (
|
||||
<select
|
||||
value={u.organizationId || ''}
|
||||
onChange={(e) => handleChangeUserOrganization(u, e.target.value)}
|
||||
className="h-7 px-2 input-field text-[11px] bg-panel max-w-[11rem]"
|
||||
title="Organisation"
|
||||
>
|
||||
<option value="">Keine Organisation</option>
|
||||
{organizations.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{isServerAdmin && (
|
||||
<>
|
||||
<button
|
||||
@@ -619,6 +817,25 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingMove?.user.id === u.id && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
|
||||
<AlertCircle className="w-3.5 h-3.5 text-status-warn shrink-0" />
|
||||
<span className="flex-1 min-w-0 text-muted">
|
||||
{u.displayName} verlässt „{orgName(u.organizationId)}“ und verliert dort alle Freigaben,
|
||||
Gruppen und Aufgaben-Zuweisungen.
|
||||
</span>
|
||||
<button
|
||||
onClick={() => applyUserOrganization(u, pendingMove.orgId)}
|
||||
className="btn-primary h-7 px-3 text-[11px]"
|
||||
>
|
||||
{pendingMove.orgId ? `Nach „${orgName(pendingMove.orgId)}“ verschieben` : 'Entfernen'}
|
||||
</button>
|
||||
<button onClick={() => setPendingMove(null)} className="btn-action h-7 px-2 text-[11px]">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resetPwdUserId === u.id && (
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-border">
|
||||
<input
|
||||
@@ -651,6 +868,131 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
|
||||
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 2: ORGANISATIONEN (SERVER-ADMIN ONLY) */}
|
||||
{/* ================================================================= */}
|
||||
{section === 'organizations' && isServerAdmin && (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-border pb-3">
|
||||
<h2 className="text-sm font-bold text-main">Organisationen</h2>
|
||||
<p className="text-muted mt-0.5">
|
||||
Jeder Benutzer gehört höchstens einer Organisation an. Im Modus „Organisationen“ sehen sich nur
|
||||
Mitglieder derselben Organisation und können nur untereinander teilen und Gruppen bilden.
|
||||
Die Zuordnung änderst du in der Benutzerliste.
|
||||
</p>
|
||||
{settings.sharingMode !== 'organizations' && (
|
||||
<p className="mt-2 text-status-warn">
|
||||
Der aktuelle Sichtbarkeits-Modus ist nicht „Organisationen“; die Zuordnung wirkt sich derzeit nicht aus.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateOrganization} className="flex gap-2 max-w-md">
|
||||
<input
|
||||
type="text"
|
||||
value={newOrgName}
|
||||
onChange={(e) => setNewOrgName(e.target.value)}
|
||||
placeholder="Name der neuen Organisation"
|
||||
className="flex-1 min-w-0 h-8 px-2.5 input-field"
|
||||
/>
|
||||
<button type="submit" className="btn-primary h-8 px-3 text-xs flex items-center gap-1.5">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Anlegen</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{organizations.length === 0 ? (
|
||||
<p className="text-muted">Noch keine Organisation angelegt.</p>
|
||||
) : (
|
||||
<div className="border border-border divide-y divide-border bg-canvas rounded-card overflow-hidden">
|
||||
{organizations.map((o) => {
|
||||
const members = users.filter((u) => u.organizationId === o.id);
|
||||
return (
|
||||
<div key={o.id} className="p-3 space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{renamingOrgId === o.id ? (
|
||||
<div className="flex flex-1 min-w-0 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={renameOrgVal}
|
||||
onChange={(e) => setRenameOrgVal(e.target.value)}
|
||||
className="flex-1 min-w-0 h-7 px-2 input-field"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={() => handleRenameOrganization(o.id)} className="btn-primary h-7 px-3 text-[11px]">
|
||||
Speichern
|
||||
</button>
|
||||
<button onClick={() => setRenamingOrgId(null)} className="btn-action h-7 px-2 text-[11px]">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Building2 className="w-4 h-4 text-accent shrink-0" />
|
||||
<span className="font-bold text-main truncate">{o.name}</span>
|
||||
<CountBadge count={o.memberCount} showZero />
|
||||
</div>
|
||||
)}
|
||||
{renamingOrgId !== o.id && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRenamingOrgId(o.id);
|
||||
setRenameOrgVal(o.name);
|
||||
}}
|
||||
className="btn-icon"
|
||||
title="Umbenennen"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeletingOrgId(deletingOrgId === o.id ? null : o.id)}
|
||||
className="btn-icon btn-icon-danger"
|
||||
title="Organisation löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{members.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{members.map((m) => (
|
||||
<span
|
||||
key={m.id}
|
||||
className="inline-flex items-center gap-1.5 pl-0.5 pr-2 py-0.5 rounded-pill bg-panel border border-border text-[11px] text-main"
|
||||
>
|
||||
<UserAvatar user={m} size="xxs" showTitle={false} />
|
||||
<span>{m.displayName}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deletingOrgId === o.id && (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-border">
|
||||
<AlertCircle className="w-3.5 h-3.5 text-status-warn shrink-0" />
|
||||
<span className="flex-1 min-w-0 text-muted">
|
||||
Die {o.memberCount} Mitglieder sind danach ohne Organisation und verlieren ihre
|
||||
Freigaben, Gruppen und Zuweisungen untereinander.
|
||||
</span>
|
||||
<button onClick={() => handleDeleteOrganization(o.id)} className="btn-primary h-7 px-3 text-[11px]">
|
||||
Löschen
|
||||
</button>
|
||||
<button onClick={() => setDeletingOrgId(null)} className="btn-action h-7 px-2 text-[11px]">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
|
||||
{/* ================================================================= */}
|
||||
@@ -661,8 +1003,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
Server-Grundeinstellungen & Benutzer-Sichtbarkeit
|
||||
</h2>
|
||||
<p className="text-muted mt-0.5">
|
||||
Als Server-Admin bestimmst du zentral für alle Nutzer, ob sie komplett isoliert
|
||||
arbeiten, sich nur innerhalb ihrer Teams sehen oder ob alle Nutzer für alle sichtbar sind.
|
||||
Als Server-Admin bestimmst du zentral, ob Nutzer komplett isoliert arbeiten, sich nur
|
||||
innerhalb ihrer Organisation sehen oder ob alle Nutzer für alle sichtbar sind.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -698,54 +1040,45 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveSystemSettings({ sharingMode: 'strict_private' })}
|
||||
className={`p-4 text-left border rounded-card transition-colors ${
|
||||
settings.sharingMode === 'strict_private'
|
||||
? 'bg-canvas border-accent text-main shadow-panel'
|
||||
: 'bg-canvas/50 border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs text-main">1. Strikt Privat (Keiner sieht andere)</p>
|
||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">
|
||||
Jeder Benutzer arbeitet komplett für sich allein. Keine geteilten Spaces und keine
|
||||
Benutzersuche möglich.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveSystemSettings({ sharingMode: 'teams_only' })}
|
||||
className={`p-4 text-left border rounded-card transition-colors ${
|
||||
settings.sharingMode === 'teams_only'
|
||||
? 'bg-canvas border-accent text-main shadow-panel'
|
||||
: 'bg-canvas/50 border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs text-main">2. Teams isoliert (Empfohlen)</p>
|
||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">
|
||||
Benutzer sehen ausschließlich Personen, mit denen sie mindestens ein gemeinsames Team
|
||||
teilen.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveSystemSettings({ sharingMode: 'open' })}
|
||||
className={`p-4 text-left border rounded-card transition-colors ${
|
||||
settings.sharingMode === 'open'
|
||||
? 'bg-canvas border-accent text-main shadow-panel'
|
||||
: 'bg-canvas/50 border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs text-main">3. Offen (Alle sehen alle)</p>
|
||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">
|
||||
Alle registrierten Benutzer dieser Instanz können sich gegenseitig sehen und Spaces
|
||||
miteinander teilen.
|
||||
</p>
|
||||
</button>
|
||||
{SHARING_MODES.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectSharingMode(m.id)}
|
||||
className={`p-4 text-left border rounded-card transition-colors ${
|
||||
settings.sharingMode === m.id
|
||||
? 'bg-canvas border-accent text-main shadow-panel'
|
||||
: 'bg-canvas/50 border-border text-muted hover:text-main'
|
||||
}`}
|
||||
>
|
||||
<p className="font-bold text-xs text-main">{m.title}</p>
|
||||
<p className="text-[11px] text-muted mt-1 leading-relaxed">{m.text}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{pendingMode && (
|
||||
<div className="p-3 border border-status-warn/40 bg-status-warn/5 rounded-card flex flex-wrap items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-status-warn shrink-0" />
|
||||
<span className="flex-1 min-w-0 text-main">
|
||||
Beim Wechsel zu „{SHARING_MODES.find((m) => m.id === pendingMode.mode)?.title.replace(/^\d\.\s*/, '')}“
|
||||
fallen {pendingMode.affected} Freigaben und Gruppen-Mitgliedschaften weg, die danach nicht mehr erlaubt sind.
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const mode = pendingMode.mode;
|
||||
setPendingMode(null);
|
||||
handleSaveSystemSettings({ sharingMode: mode });
|
||||
}}
|
||||
className="btn-primary h-7 px-3 text-[11px]"
|
||||
>
|
||||
Trotzdem wechseln
|
||||
</button>
|
||||
<button onClick={() => setPendingMode(null)} className="btn-action h-7 px-2 text-[11px]">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Public Registration Toggle Switch */}
|
||||
@@ -762,25 +1095,11 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}>
|
||||
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={settings.allowRegistration}
|
||||
onClick={() =>
|
||||
handleSaveSystemSettings({ allowRegistration: !settings.allowRegistration })
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-pill border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none ${
|
||||
settings.allowRegistration ? 'bg-accent' : 'bg-panel-elevated border border-border'
|
||||
}`}
|
||||
<ToggleSwitch
|
||||
checked={settings.allowRegistration}
|
||||
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })}
|
||||
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none inline-block h-5 w-5 transform rounded-pill bg-white shadow-md ring-0 transition duration-200 ease-in-out ${
|
||||
settings.allowRegistration ? 'translate-x-5' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -886,20 +1205,33 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center pt-5">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
<div className="sm:col-span-2 space-y-3 pt-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-main font-medium">Verschlüsselung (`SMTP_TLS`)</p>
|
||||
<p className="text-[11px] text-muted">
|
||||
Port 465: TLS ab Verbindungsbeginn, sonst STARTTLS. Ist es an, wird ohne Verschlüsselung nicht gesendet.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={settings.smtpTls ?? true}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpTls: e.target.checked }))
|
||||
}
|
||||
className="w-4 h-4 checkbox-square cursor-pointer"
|
||||
onChange={(smtpTls) => setSettings((prev) => ({ ...prev, smtpTls }))}
|
||||
/>
|
||||
<span className="text-main font-medium">
|
||||
STARTTLS / TLS-Verschlüsselung verwenden (`SMTP_TLS=true`)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-main font-medium">Zertifikatsprüfung deaktivieren (`SMTP_SKIP_VERIFY`)</p>
|
||||
<p className="text-[11px] text-muted">
|
||||
Nur für interne Mailserver mit selbstsigniertem Zertifikat oder abweichendem Namen. Die Verbindung
|
||||
bleibt verschlüsselt, der Server wird aber nicht mehr geprüft.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={settings.smtpSkipVerify ?? false}
|
||||
disabled={!(settings.smtpTls ?? true)}
|
||||
onChange={(smtpSkipVerify) => setSettings((prev) => ({ ...prev, smtpSkipVerify }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -17,16 +17,24 @@ import {
|
||||
Palette,
|
||||
Upload,
|
||||
Camera,
|
||||
Pencil,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { APIKey, User } from '../../lib/types';
|
||||
import { APIKey, AuthStatus, User, UserTheme } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { AVAILABLE_THEMES, ThemeId } from '../../lib/theme';
|
||||
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
|
||||
import themeTemplateCss from '../../themes/theme-template.css?raw';
|
||||
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
||||
import { LegalLinks } from '../../components/LegalLinks';
|
||||
import { AboutModal } from '../../components/AboutModal';
|
||||
|
||||
interface UserSettingsViewProps {
|
||||
currentUser: User;
|
||||
activeTheme: ThemeId;
|
||||
themes: ThemeOption[]; // built-in + custom
|
||||
customThemes: UserTheme[];
|
||||
onThemeChange: (themeId: ThemeId) => void;
|
||||
onCustomThemesChange: (themes: UserTheme[], activate?: ThemeId) => void;
|
||||
onUserUpdated: (u: User) => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
@@ -36,11 +44,102 @@ type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks';
|
||||
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
currentUser,
|
||||
activeTheme,
|
||||
themes,
|
||||
customThemes,
|
||||
onThemeChange,
|
||||
onCustomThemesChange,
|
||||
onUserUpdated,
|
||||
onBack,
|
||||
}) => {
|
||||
const [section, setSection] = useState<UserSettingsSection>('profile');
|
||||
const [legal, setLegal] = useState<AuthStatus['legal']>();
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
useEffect(() => {
|
||||
api.getAuthStatus().then((st) => setLegal(st.legal)).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 ---
|
||||
const [displayName, setDisplayName] = useState(currentUser.displayName || '');
|
||||
@@ -263,17 +362,38 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
setTimeout(() => setCopiedLabel(null), 2000);
|
||||
};
|
||||
|
||||
// Works on mobile browsers too: the link must be in the document and the URL alive until the click ran
|
||||
const saveBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||
};
|
||||
|
||||
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);
|
||||
saveBlob(blob, `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`);
|
||||
};
|
||||
|
||||
const [exportingVault, setExportingVault] = useState(false);
|
||||
const [vaultError, setVaultError] = useState<string | null>(null);
|
||||
const handleExportVault = async () => {
|
||||
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 baseUrl = window.location.origin;
|
||||
@@ -591,14 +711,15 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</div>
|
||||
|
||||
<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 custom = customThemes.find((c) => customThemeId(c) === t.id);
|
||||
return (
|
||||
<div key={t.id} className="flex flex-col">
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => onThemeChange(t.id)}
|
||||
className={`p-4 text-left border transition-all flex flex-col justify-between gap-3 ${
|
||||
className={`flex-1 p-4 text-left border transition-all flex flex-col justify-between gap-3 ${
|
||||
isSelected
|
||||
? 'bg-canvas border-accent shadow-sm'
|
||||
: 'bg-canvas border-border hover:border-strong'
|
||||
@@ -654,23 +775,130 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<code className="text-[10px] font-mono text-faint">{t.file}</code>
|
||||
</div>
|
||||
</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 className="p-3.5 bg-canvas border border-border space-y-1.5">
|
||||
<p className="font-semibold text-main">Eigenes Theme anpassen oder hinzufügen:</p>
|
||||
{/* Custom theme: upload or paste CSS, based on the template */}
|
||||
<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">
|
||||
Alle Theme-Dateien liegen unter{' '}
|
||||
<code className="font-mono text-accent">frontend/src/themes/</code> (inklusive der
|
||||
Kopiervorlage{' '}
|
||||
<code className="font-mono text-accent">theme-template.css</code>). Dort sind alle
|
||||
Eigenschaften in 6 Blöcken gruppiert: <strong>1. Akzentfarbe</strong>,{' '}
|
||||
<strong>2. Oberflächen</strong>, <strong>3. Textfarben</strong>,{' '}
|
||||
<strong>4. Rahmen & Schatten</strong>, <strong>5. Statusfarben</strong> und{' '}
|
||||
<strong>6. Rundungen</strong>.
|
||||
Ein Theme ist CSS mit 6 Blöcken: <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>. Am einfachsten startest du mit der Vorlage und änderst die Werte. Gespeicherte
|
||||
Themes liegen auf dem Server und stehen dir auf allen Geräten zur Verfügung.
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -1023,10 +1251,40 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<span>Backup herunterladen</span>
|
||||
</button>
|
||||
</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>
|
||||
</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)} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
LogOut,
|
||||
} from 'lucide-react';
|
||||
import { Team, User } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { api, isSharingDisabled, triggerHaptic } from '../../lib/api';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
|
||||
@@ -187,6 +187,18 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) =
|
||||
!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 (
|
||||
<div className="w-full space-y-5">
|
||||
{/* Page Header */}
|
||||
@@ -203,13 +215,13 @@ export const GroupsView: React.FC<GroupsViewProps> = ({ currentUser, onBack }) =
|
||||
<div>
|
||||
<h1 className="text-base font-bold text-main tracking-tight flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-accent" />
|
||||
<span>Gruppen & Teams</span>
|
||||
<span>Gruppen</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-panel-elevated text-muted border border-border">
|
||||
{teams.length}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Erstelle und verwalte Gruppen (z. B. Familie, WG oder Projektteams) für gemeinsame Notizen, Aufgaben und Listen.
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
X,
|
||||
Check,
|
||||
@@ -16,8 +16,11 @@ import {
|
||||
Folder,
|
||||
Webhook,
|
||||
Lock,
|
||||
UserPlus,
|
||||
} from 'lucide-react';
|
||||
import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types';
|
||||
import { Thought, SubTask, TaskList, TaskListMember, KanbanColumn, User } from '../../lib/types';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
import { MarkdownViewer } from '../../lib/markdown';
|
||||
|
||||
@@ -34,6 +37,8 @@ interface TaskDetailModalProps {
|
||||
onUpdateTaskList: (task: Thought, newListId: string) => Promise<void>;
|
||||
onUpdateNotes: (task: Thought, newNotes: 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>;
|
||||
onAddSubtask: (task: Thought, title: string) => Promise<void>;
|
||||
onDeleteSubtask: (task: Thought, subtaskId: string) => Promise<void>;
|
||||
@@ -56,6 +61,8 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
onUpdateTaskList,
|
||||
onUpdateNotes,
|
||||
onUpdateTags,
|
||||
assignableMembers = [],
|
||||
onUpdateAssignees,
|
||||
onToggleSubtask,
|
||||
onAddSubtask,
|
||||
onDeleteSubtask,
|
||||
@@ -71,6 +78,12 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
|
||||
const [isEditingNotes, setIsEditingNotes] = useState(false);
|
||||
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 [newTagDraft, setNewTagDraft] = useState('');
|
||||
@@ -172,7 +185,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
return (
|
||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
|
||||
<div
|
||||
className="w-full max-w-2xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] overflow-hidden"
|
||||
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()}
|
||||
>
|
||||
{/* Top Header */}
|
||||
@@ -272,7 +285,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
</div>
|
||||
|
||||
{/* 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 */}
|
||||
<div>
|
||||
{isEditingTitle ? (
|
||||
@@ -517,6 +532,65 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
</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 */}
|
||||
<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">
|
||||
@@ -672,7 +746,9 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
placeholder="Detailnotizen eingeben… (Markdown unterstützt)"
|
||||
rows={6}
|
||||
autoFocus
|
||||
className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-y min-h-[100px]"
|
||||
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">
|
||||
<button
|
||||
@@ -725,6 +801,7 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
||||
|
||||
@@ -26,29 +26,22 @@ import {
|
||||
Filter,
|
||||
Webhook,
|
||||
} from 'lucide-react';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, KanbanColumn, PriorityLevel } from '../../lib/types';
|
||||
import { TaskList, Thought, SubTask, Team, User, TaskListShare, TaskListMember, KanbanColumn, PriorityLevel } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { TaskDetailModal } from './TaskDetailModal';
|
||||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
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 {
|
||||
currentUser?: User | null;
|
||||
onRefreshStats: () => void;
|
||||
onOpenDocument: (docId: string, blockId?: string) => void;
|
||||
refreshTrigger?: number;
|
||||
initialSelectedTaskId?: string | null;
|
||||
openTasksCount?: number; // all open tasks incl. shared lists (same as the nav bar counter)
|
||||
}
|
||||
|
||||
export const TasksView: React.FC<TasksViewProps> = ({
|
||||
@@ -57,6 +50,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onOpenDocument,
|
||||
refreshTrigger = 0,
|
||||
initialSelectedTaskId,
|
||||
openTasksCount,
|
||||
}) => {
|
||||
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
||||
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
||||
@@ -134,16 +128,25 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none');
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
const seq = ++loadSeqRef.current;
|
||||
const listId = selectedListId;
|
||||
if (loadedListRef.current !== listId) setLoading(true);
|
||||
try {
|
||||
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.getUserTeams().catch(() => []),
|
||||
api.getCollaborators().catch(() => []),
|
||||
api.getThoughts('discarded').catch(() => []),
|
||||
]);
|
||||
if (seq !== loadSeqRef.current) return; // a newer reload is under way
|
||||
loadedListRef.current = listId;
|
||||
setTasks(Array.isArray(allTasks) ? allTasks : []);
|
||||
setTaskLists(Array.isArray(allLists) ? allLists : []);
|
||||
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
||||
@@ -152,7 +155,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
} catch {
|
||||
// Prevent unhandled promise rejection from crashing view
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (seq === loadSeqRef.current) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -220,6 +223,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}
|
||||
} else if (msg.event === 'TASK_REORDERED') {
|
||||
loadData();
|
||||
} else if (msg.event === 'TASK_LIST_UPDATED' || msg.event === 'TASK_LIST_DELETED') {
|
||||
// Payload only carries the list ID; reload so permissions reflect this user's view
|
||||
api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -233,6 +239,42 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const isTaskReadOnly = (task: Thought) =>
|
||||
!!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)
|
||||
const activeColumns: KanbanColumn[] = useMemo(() => {
|
||||
if (
|
||||
@@ -461,6 +503,17 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
await api.updateThought(task.id, { notes: newNotes });
|
||||
};
|
||||
|
||||
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[]) => {
|
||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, tags: newTags } : t)));
|
||||
await api.updateThought(task.id, { tags: newTags });
|
||||
@@ -986,6 +1039,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
id: 'list-all',
|
||||
label: 'Alle Aufgaben',
|
||||
icon: <Layers className="w-4 h-4" />,
|
||||
badge: openTasksCount || undefined,
|
||||
active: selectedListId === 'all',
|
||||
onClick: () => setSelectedListId('all'),
|
||||
},
|
||||
@@ -1036,6 +1090,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
safeDiscarded.length,
|
||||
kanbanTasks.length,
|
||||
safeTaskLists,
|
||||
openTasksCount,
|
||||
currentSelectedList,
|
||||
isSelectedListReadOnly,
|
||||
setPrimaryAction,
|
||||
@@ -1271,6 +1326,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderAssignees(task, 'xs')}
|
||||
|
||||
{/* 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()}>
|
||||
<button
|
||||
@@ -1326,41 +1383,42 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
: '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-2' : ''}`}>
|
||||
Alle Aufgaben
|
||||
<CountBadge count={openTasksCount} position="corner" className="-top-1.5 -right-1" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{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
|
||||
onClick={() => setSelectedListId(tl.id)}
|
||||
className={`h-8 px-3 text-xs font-medium border flex items-center gap-1.5 transition-colors ${
|
||||
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'
|
||||
}`}
|
||||
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
|
||||
>
|
||||
<span>{tl.icon}</span>
|
||||
<span>{tl.title}</span>
|
||||
{tl.tasksCount !== undefined && tl.tasksCount > 0 && (
|
||||
<span className="ml-1 badge-neutral">{tl.tasksCount}</span>
|
||||
)}
|
||||
<span className={`relative inline-flex items-center ${tl.tasksCount ? 'pr-2' : ''}`}>
|
||||
{tl.title}
|
||||
<CountBadge count={tl.tasksCount} position="corner" className="-top-1.5 -right-1" />
|
||||
</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;
|
||||
const otherMembers = (tl.members || []).filter((m) => m.userId !== currentUser?.id);
|
||||
if (otherMembers.length > 0) {
|
||||
return (
|
||||
<div className="flex items-center -space-x-1 ml-0.5" title={`Geteilt (${tl.members?.length || 1} Mitglieder)`}>
|
||||
{otherMembers.slice(0, 2).map((m) => (
|
||||
<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">
|
||||
{m.avatarUrl ? (
|
||||
<img src={m.avatarUrl} alt="" className="w-full h-full object-cover rounded-full" />
|
||||
) : (
|
||||
getInitials(m.displayName || m.email)[0]
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<AvatarStack
|
||||
people={otherMembers.map(memberToPerson)}
|
||||
size="xxs"
|
||||
ringClass={selectedListId === tl.id ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
|
||||
className="ml-0.5"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -1374,7 +1432,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
<button
|
||||
onClick={() => handleDeleteTaskList(tl.id)}
|
||||
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" />
|
||||
</button>
|
||||
@@ -1400,7 +1458,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const sharesCount = (currentSelectedList.shares?.length || 0) > 0
|
||||
? currentSelectedList.shares.length
|
||||
: (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 ownerDisplayName = currentSelectedList.ownerName || ownerMember?.displayName || ownerMember?.email || 'Eigentümer';
|
||||
|
||||
@@ -2100,6 +2158,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onUpdateTaskList={handleUpdateTaskTaskList}
|
||||
onUpdateNotes={handleUpdateTaskNotes}
|
||||
onUpdateTags={handleUpdateTaskTags}
|
||||
assignableMembers={detailTask ? assignableMembers(detailTask) : []}
|
||||
onUpdateAssignees={handleUpdateTaskAssignees}
|
||||
onToggleSubtask={handleToggleSubtask}
|
||||
onAddSubtask={handleAddSubtask}
|
||||
onDeleteSubtask={handleDeleteSubtask}
|
||||
@@ -2302,6 +2362,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
#{tag}
|
||||
</button>
|
||||
))}
|
||||
{getAssigneePeople(task).length > 0 && (
|
||||
<span className="ml-auto">{renderAssignees(task, 'xxs')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Column Navigation controls: Move Left / Move Right */}
|
||||
|
||||
@@ -14,10 +14,16 @@ import {
|
||||
Sparkles,
|
||||
Plus,
|
||||
Search,
|
||||
Check,
|
||||
CloudOff,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||
import { api, getToken } from '../../lib/api';
|
||||
import { api } from '../../lib/api';
|
||||
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
|
||||
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
||||
import { PresenceAvatars } from './PresenceAvatars';
|
||||
import { extractHashtags } from '../../lib/hashtags';
|
||||
|
||||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||
|
||||
@@ -38,63 +44,7 @@ export interface DocumentEditorProps {
|
||||
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
||||
}
|
||||
|
||||
interface EmojiItem {
|
||||
emoji: string;
|
||||
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' },
|
||||
];
|
||||
import { EmojiPickerModal } from './EmojiPickerModal';
|
||||
|
||||
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
|
||||
if (!blocks || blocks.length === 0) {
|
||||
@@ -119,6 +69,29 @@ function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): Document
|
||||
}));
|
||||
}
|
||||
|
||||
// True if both lists show the same blocks; avoids re-rendering when the sync engine echoes a local change.
|
||||
function sameBlocks(a: DocumentBlock[], b: DocumentBlock[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = a[i];
|
||||
const y = b[i];
|
||||
if (
|
||||
x.id !== y.id ||
|
||||
x.content !== y.content ||
|
||||
(x.level || 0) !== (y.level || 0) ||
|
||||
x.type !== y.type ||
|
||||
!!x.checked !== !!y.checked ||
|
||||
!!x.collapsed !== !!y.collapsed ||
|
||||
(x.lang || '') !== (y.lang || '') ||
|
||||
(x.taskId || '') !== (y.taskId || '') ||
|
||||
(x.conflictOf || '') !== (y.conflictOf || '')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface BreadcrumbNode {
|
||||
id: string | null;
|
||||
title: string;
|
||||
@@ -221,24 +194,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
const [loading, setLoading] = useState<boolean>(!initialDoc);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||
const [emojiSearch, setEmojiSearch] = useState<string>('');
|
||||
const [zoomedBlockId, setZoomedBlockId] = useState<string | 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]);
|
||||
// Sync engine (operation log): stores every change locally and syncs it with the server
|
||||
const engineRef = useRef<DocSyncEngine | null>(null);
|
||||
const [engineState, setEngineState] = useState<EngineState | null>(null);
|
||||
|
||||
// AutoSave state (REST persistence)
|
||||
const [, setSaveStatus] = useState<DocSaveStatus>('saved');
|
||||
const [, setSaveErrorMessage] = useState<string | null>(null);
|
||||
const saveTimeoutRef = useRef<number | null>(null);
|
||||
const isSavingRef = useRef<boolean>(false);
|
||||
const lastSavedSnapshotRef = useRef<string>('');
|
||||
const isInitialLoadRef = useRef<boolean>(true);
|
||||
|
||||
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
||||
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
|
||||
@@ -249,7 +210,6 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
currentTitleRef.current = title;
|
||||
const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
|
||||
currentBlocksRef.current = blocks;
|
||||
const lastKnownUpdatedAtRef = useRef<string | null>(initialDoc?.updatedAt || null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
||||
@@ -283,144 +243,22 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId);
|
||||
}, [title, doc?.title, blocks, zoomedBlockId]);
|
||||
|
||||
// Strictly Read-Only for shared documents: only the document owner can edit!
|
||||
// Owner and editors may edit; the role comes from the server via the sync engine
|
||||
const canEdit = useMemo(() => {
|
||||
if (!doc) return true;
|
||||
return Boolean(doc.isOwner || doc.permission === 'owner');
|
||||
}, [doc]);
|
||||
if (engineState?.role) return engineState.role === 'owner' || engineState.role === 'editor';
|
||||
if (!doc) return false;
|
||||
return Boolean(doc.isOwner || doc.permission === 'owner' || doc.permission === 'write');
|
||||
}, [doc, engineState?.role]);
|
||||
|
||||
// Extract all #tags present across the current blocks
|
||||
const extractedTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
for (const b of blocks) {
|
||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
if (matches) {
|
||||
for (const m of matches) {
|
||||
tagSet.add(m.slice(1).toLowerCase());
|
||||
}
|
||||
}
|
||||
for (const tag of extractHashtags(b.content)) tagSet.add(tag);
|
||||
}
|
||||
return Array.from(tagSet);
|
||||
}, [blocks]);
|
||||
|
||||
const currentTagsRef = useRef<string[]>(extractedTags);
|
||||
currentTagsRef.current = extractedTags;
|
||||
|
||||
// AutoSave snapshot
|
||||
const currentSnapshot = useMemo(() => {
|
||||
return JSON.stringify({
|
||||
title,
|
||||
blocks,
|
||||
tags: extractedTags,
|
||||
});
|
||||
}, [title, blocks, extractedTags]);
|
||||
|
||||
const currentSnapshotRef = useRef<string>(currentSnapshot);
|
||||
currentSnapshotRef.current = currentSnapshot;
|
||||
|
||||
const executeAutoSave = useCallback(
|
||||
async (immediate: boolean = false) => {
|
||||
if (!docId || !canEdit) return;
|
||||
if (isSavingRef.current && !immediate) return;
|
||||
|
||||
const snapshot = currentSnapshotRef.current;
|
||||
if (snapshot === lastSavedSnapshotRef.current) {
|
||||
setSaveStatus('saved');
|
||||
onSaveStatusChange?.('saved');
|
||||
return;
|
||||
}
|
||||
|
||||
isSavingRef.current = true;
|
||||
setSaveStatus('saving');
|
||||
onSaveStatusChange?.('saving');
|
||||
|
||||
try {
|
||||
const updated = await api.updateDocument(docId, {
|
||||
title: currentTitleRef.current,
|
||||
blocks: currentBlocksRef.current,
|
||||
tags: currentTagsRef.current,
|
||||
});
|
||||
isSavingRef.current = false;
|
||||
lastSavedSnapshotRef.current = snapshot;
|
||||
lastKnownUpdatedAtRef.current = updated.updatedAt || null;
|
||||
setSaveStatus('saved');
|
||||
setSaveErrorMessage(null);
|
||||
onSaveStatusChange?.('saved');
|
||||
if (docRef.current) {
|
||||
docRef.current = updated;
|
||||
onDocMetadataChange(updated);
|
||||
}
|
||||
} catch (err: any) {
|
||||
isSavingRef.current = false;
|
||||
const msg = err?.message || 'Fehler beim Speichern';
|
||||
setSaveStatus('error');
|
||||
setSaveErrorMessage(msg);
|
||||
onSaveStatusChange?.('error', msg);
|
||||
}
|
||||
},
|
||||
[docId, canEdit, onSaveStatusChange, onDocMetadataChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialLoadRef.current) return;
|
||||
if (!canEdit) return;
|
||||
if (currentSnapshot === lastSavedSnapshotRef.current) return;
|
||||
|
||||
setSaveStatus('unsaved');
|
||||
onSaveStatusChange?.('unsaved');
|
||||
|
||||
if (saveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(saveTimeoutRef.current);
|
||||
}
|
||||
saveTimeoutRef.current = window.setTimeout(() => {
|
||||
saveTimeoutRef.current = null;
|
||||
executeAutoSave();
|
||||
}, 800);
|
||||
|
||||
return () => {
|
||||
if (saveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [currentSnapshot, canEdit, executeAutoSave, onSaveStatusChange]);
|
||||
|
||||
// Flush on page unload / pagehide via keepalive fetch
|
||||
useEffect(() => {
|
||||
const handleUnload = () => {
|
||||
if (saveTimeoutRef.current !== null) {
|
||||
window.clearTimeout(saveTimeoutRef.current);
|
||||
saveTimeoutRef.current = null;
|
||||
}
|
||||
const snapshot = currentSnapshotRef.current;
|
||||
if (snapshot !== lastSavedSnapshotRef.current && canEdit) {
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
fetch(`/api/v1/documents/${docId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: currentTitleRef.current,
|
||||
blocks: currentBlocksRef.current,
|
||||
tags: currentTagsRef.current,
|
||||
}),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleUnload);
|
||||
window.addEventListener('pagehide', handleUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleUnload);
|
||||
window.removeEventListener('pagehide', handleUnload);
|
||||
handleUnload();
|
||||
};
|
||||
}, [docId, canEdit]);
|
||||
|
||||
// Fetch document on mount
|
||||
useEffect(() => {
|
||||
@@ -436,29 +274,19 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
if (isCancelled) return;
|
||||
const normalized = ensureNonEmptyBlocks(docId, fetched.blocks);
|
||||
setDoc(fetched);
|
||||
setTitle(fetched.title || 'Unbenannte Notiz');
|
||||
setBlocks(normalized);
|
||||
if (!engineRef.current?.getState().ready) {
|
||||
setTitle(fetched.title || 'Unbenannte Notiz');
|
||||
currentBlocksRef.current = normalized;
|
||||
setBlocks(normalized);
|
||||
}
|
||||
const links = new Map<string, string>();
|
||||
for (const b of fetched.blocks || []) {
|
||||
if (b.taskId) links.set(b.id, b.taskId);
|
||||
}
|
||||
engineRef.current?.setTaskLinks(links);
|
||||
setLoading(false);
|
||||
onDocMetadataChange(fetched);
|
||||
|
||||
const initialTags: string[] = [];
|
||||
for (const b of normalized) {
|
||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
if (matches) {
|
||||
for (const m of matches) {
|
||||
const tag = m.slice(1).toLowerCase();
|
||||
if (!initialTags.includes(tag)) initialTags.push(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
lastSavedSnapshotRef.current = JSON.stringify({
|
||||
title: fetched.title || 'Unbenannte Notiz',
|
||||
blocks: normalized,
|
||||
tags: initialTags,
|
||||
});
|
||||
isInitialLoadRef.current = false;
|
||||
setSaveStatus('saved');
|
||||
onSaveStatusChange?.('saved');
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (isCancelled) return;
|
||||
@@ -471,6 +299,120 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
};
|
||||
}, [docId]);
|
||||
|
||||
// Start the sync engine for this note: shows stored state immediately (also offline), then syncs
|
||||
useEffect(() => {
|
||||
const engine = new DocSyncEngine(docId);
|
||||
engineRef.current = engine;
|
||||
const unsubscribe = engine.subscribe((state) => {
|
||||
setEngineState(state);
|
||||
if (!state.ready) return;
|
||||
const next = ensureNonEmptyBlocks(docId, state.blocks);
|
||||
if (!sameBlocks(currentBlocksRef.current, next)) {
|
||||
currentBlocksRef.current = next;
|
||||
setBlocks(next);
|
||||
}
|
||||
if (state.title && state.title !== currentTitleRef.current) {
|
||||
setTitle(state.title);
|
||||
}
|
||||
});
|
||||
void engine.start();
|
||||
return () => {
|
||||
unsubscribe();
|
||||
engine.stop();
|
||||
if (engineRef.current === engine) engineRef.current = null;
|
||||
};
|
||||
}, [docId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!engineState) return;
|
||||
switch (engineState.status) {
|
||||
case 'saving':
|
||||
onSaveStatusChange?.('saving');
|
||||
break;
|
||||
case 'offline':
|
||||
onSaveStatusChange?.('unsaved', 'Offline – Änderungen sind lokal gesichert und werden gesendet, sobald die Verbindung zurück ist');
|
||||
break;
|
||||
case 'error':
|
||||
onSaveStatusChange?.('error', engineState.errorMessage);
|
||||
break;
|
||||
default:
|
||||
onSaveStatusChange?.('saved');
|
||||
}
|
||||
}, [engineState?.status, engineState?.errorMessage]);
|
||||
|
||||
// Blocks other users are editing right now (blockId -> name)
|
||||
const lockedBlocks = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const l of engineState?.lockedByOthers || []) m.set(l.blockId, l.name || 'Jemand');
|
||||
return m;
|
||||
}, [engineState?.lockedByOthers]);
|
||||
|
||||
const otherPeople = useMemo(
|
||||
() => (engineState?.presence || []).filter((p) => p.userId !== 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)
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const noticeTimerRef = useRef<number | null>(null);
|
||||
const showNotice = useCallback((message: string) => {
|
||||
setNotice(message);
|
||||
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
|
||||
noticeTimerRef.current = window.setTimeout(() => setNotice(null), 3500);
|
||||
}, []);
|
||||
useEffect(() => () => {
|
||||
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const handleLockedAction = useCallback(
|
||||
(holder: string) => showNotice(`${holder} bearbeitet diesen Punkt gerade – bitte kurz warten`),
|
||||
[showNotice]
|
||||
);
|
||||
const handleUndo = useCallback(() => {
|
||||
const res = engineRef.current?.undo();
|
||||
if (res?.message) showNotice(res.message);
|
||||
}, [showNotice]);
|
||||
const handleRedo = useCallback(() => {
|
||||
const res = engineRef.current?.redo();
|
||||
if (res?.message) showNotice(res.message);
|
||||
}, [showNotice]);
|
||||
const handleResolveConflict = useCallback(
|
||||
(blockId: string, accept: boolean) => {
|
||||
const res = engineRef.current?.resolveConflict(blockId, accept);
|
||||
if (res?.message) showNotice(res.message);
|
||||
},
|
||||
[showNotice]
|
||||
);
|
||||
|
||||
// Every local block change goes to the engine, which turns it into operations
|
||||
const applyLocalBlocks = useCallback(
|
||||
(update: DocumentBlock[] | ((prev: DocumentBlock[]) => DocumentBlock[])) => {
|
||||
const prev = currentBlocksRef.current;
|
||||
const next = typeof update === 'function' ? update(prev) : update;
|
||||
if (next === prev) return;
|
||||
currentBlocksRef.current = next;
|
||||
setBlocks(next);
|
||||
engineRef.current?.applyFlat(next);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Keep parent sidebar metadata in sync when blocks/tags change
|
||||
useEffect(() => {
|
||||
if (!docRef.current) return;
|
||||
@@ -488,27 +430,19 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (!canEdit) return;
|
||||
setTitle(e.target.value);
|
||||
engineRef.current?.setTitle(e.target.value);
|
||||
};
|
||||
|
||||
const handleTitleBlur = () => {
|
||||
executeAutoSave();
|
||||
void engineRef.current?.syncNow();
|
||||
};
|
||||
|
||||
const handleUpdateBlock = useCallback(
|
||||
(updatedBlock: DocumentBlock, immediate: boolean = false) => {
|
||||
(updatedBlock: DocumentBlock) => {
|
||||
if (!canEdit) return;
|
||||
setBlocks((prev) => {
|
||||
const next = prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b));
|
||||
currentBlocksRef.current = next;
|
||||
return next;
|
||||
});
|
||||
if (immediate) {
|
||||
window.setTimeout(() => {
|
||||
executeAutoSave(true);
|
||||
}, 10);
|
||||
}
|
||||
applyLocalBlocks((prev) => prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b)));
|
||||
},
|
||||
[canEdit, executeAutoSave]
|
||||
[canEdit, applyLocalBlocks]
|
||||
);
|
||||
|
||||
const handleUpdateBlocks = useCallback(
|
||||
@@ -518,17 +452,15 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
for (const b of updatedBlocks) {
|
||||
map.set(b.id, b);
|
||||
}
|
||||
setBlocks((prev) =>
|
||||
prev.map((b) => map.get(b.id) || b)
|
||||
);
|
||||
applyLocalBlocks((prev) => prev.map((b) => map.get(b.id) || b));
|
||||
},
|
||||
[canEdit]
|
||||
[canEdit, applyLocalBlocks]
|
||||
);
|
||||
|
||||
const handleCreateBlock = useCallback(
|
||||
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
||||
if (!canEdit) return;
|
||||
setBlocks((prev) => {
|
||||
applyLocalBlocks((prev) => {
|
||||
const next = [...prev];
|
||||
if (afterBlockId) {
|
||||
const idx = next.findIndex((b) => b.id === afterBlockId);
|
||||
@@ -541,13 +473,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
return ensureNonEmptyBlocks(docId, next);
|
||||
});
|
||||
},
|
||||
[docId, canEdit]
|
||||
[docId, canEdit, applyLocalBlocks]
|
||||
);
|
||||
|
||||
const handleInsertBlocks = useCallback(
|
||||
(afterBlockId: string, updatedCurrentBlock: DocumentBlock, newBlocks: DocumentBlock[]) => {
|
||||
if (!canEdit) return;
|
||||
setBlocks((prev) => {
|
||||
applyLocalBlocks((prev) => {
|
||||
const next = [...prev];
|
||||
const idx = next.findIndex((b) => b.id === afterBlockId);
|
||||
if (idx >= 0) {
|
||||
@@ -556,32 +488,24 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
} else {
|
||||
next.push(updatedCurrentBlock, ...newBlocks);
|
||||
}
|
||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
||||
currentBlocksRef.current = normalized;
|
||||
return normalized;
|
||||
return ensureNonEmptyBlocks(docId, next);
|
||||
});
|
||||
window.setTimeout(() => {
|
||||
executeAutoSave(true);
|
||||
}, 50);
|
||||
},
|
||||
[docId, canEdit, executeAutoSave]
|
||||
[docId, canEdit, applyLocalBlocks]
|
||||
);
|
||||
|
||||
const handleDeleteBlock = useCallback(
|
||||
(blockId: string) => {
|
||||
if (!canEdit) return;
|
||||
setBlocks((prev) => {
|
||||
const filtered = prev.filter((b) => b.id !== blockId);
|
||||
return ensureNonEmptyBlocks(docId, filtered);
|
||||
});
|
||||
applyLocalBlocks((prev) => ensureNonEmptyBlocks(docId, prev.filter((b) => b.id !== blockId)));
|
||||
},
|
||||
[docId, canEdit]
|
||||
[docId, canEdit, applyLocalBlocks]
|
||||
);
|
||||
|
||||
const handleMoveBlock = useCallback(
|
||||
(blockId: string, targetIndex: number, newLevel?: number) => {
|
||||
if (!canEdit) return;
|
||||
setBlocks((prev) => {
|
||||
applyLocalBlocks((prev) => {
|
||||
const fromIdx = prev.findIndex((b) => b.id === blockId);
|
||||
if (fromIdx === -1) return prev;
|
||||
|
||||
@@ -632,7 +556,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
return ensureNonEmptyBlocks(docId, copy);
|
||||
});
|
||||
},
|
||||
[docId, canEdit]
|
||||
[docId, canEdit, applyLocalBlocks]
|
||||
);
|
||||
|
||||
const handleTogglePin = async () => {
|
||||
@@ -714,7 +638,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full min-h-0 bg-canvas overflow-y-auto">
|
||||
<div className="flex-1 flex flex-col h-full min-h-0 bg-canvas overflow-y-auto" data-sync-channel={engineState?.channel}>
|
||||
{/* Top Document Action Bar */}
|
||||
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-canvas border-b border-border">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
@@ -782,6 +706,33 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
</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 */}
|
||||
{!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">
|
||||
@@ -793,6 +744,31 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
|
||||
{/* Right Action Buttons */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* How changes of others arrive (only relevant for shared notes) */}
|
||||
{engineState && (engineState.shared || engineState.sessions > 1) && (
|
||||
<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={
|
||||
engineState.channel === 'ws'
|
||||
? 'Live-Verbindung über WebSocket'
|
||||
: engineState.channel === 'sse'
|
||||
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
|
||||
: engineState.channel === 'longpoll'
|
||||
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
|
||||
: 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt'
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
engineState.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok'
|
||||
}`}
|
||||
/>
|
||||
{engineState.channel === 'longpoll' ? 'Live (Long-Polling)' : engineState.channel === 'interval' ? 'Abgleich alle 3 s' : 'Live'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Who else has this note open */}
|
||||
<PresenceAvatars people={otherPeople} avatars={avatarsById} />
|
||||
{/* Sharing Section */}
|
||||
{doc && (() => {
|
||||
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
|
||||
@@ -977,6 +953,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onTagClick={onSelectTag}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
initialFocusedBlockId={initialFocusedBlockId}
|
||||
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
||||
lockedBlocks={lockedBlocks}
|
||||
onLockedAction={handleLockedAction}
|
||||
onResolveConflict={handleResolveConflict}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -997,72 +979,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
{doc?.icon || '📝'}
|
||||
</button>
|
||||
{showEmojiPicker && canEdit && (
|
||||
<div
|
||||
className="absolute left-0 mt-1 p-2 panel-surface shadow-lg border border-border rounded-card z-30 w-72 space-y-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<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>
|
||||
<EmojiPickerModal
|
||||
currentIcon={doc?.icon}
|
||||
onSelectEmoji={handleSelectIcon}
|
||||
onClose={() => setShowEmojiPicker(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1084,6 +1005,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && ['z', 'y'].includes(e.key.toLowerCase())) {
|
||||
e.preventDefault();
|
||||
if (e.key.toLowerCase() === 'y' || e.shiftKey) handleRedo();
|
||||
else handleUndo();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
outlinerTreeRef.current?.focusFirstBlock();
|
||||
@@ -1113,10 +1040,25 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onTagClick={onSelectTag}
|
||||
onWikiLinkClick={onWikiLinkClick}
|
||||
initialFocusedBlockId={initialFocusedBlockId}
|
||||
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
||||
lockedBlocks={lockedBlocks}
|
||||
onLockedAction={handleLockedAction}
|
||||
onResolveConflict={handleResolveConflict}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel"
|
||||
>
|
||||
{notice}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import React, { Suspense, useEffect, useRef, useState } from 'react';
|
||||
import { X, Trash2, Loader2, Clock } from 'lucide-react';
|
||||
import type { EmojiClickData } from 'emoji-picker-react';
|
||||
import { EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
|
||||
|
||||
// Lazy-load emoji-picker-react so the main bundle size stays small
|
||||
const EmojiPicker = React.lazy(() => import('emoji-picker-react'));
|
||||
|
||||
const RECENT_EMOJIS_STORAGE_KEY = 'mindsift_recent_emojis';
|
||||
const MAX_RECENT_EMOJIS = 10;
|
||||
|
||||
function getStoredRecentEmojis(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_EMOJIS_STORAGE_KEY);
|
||||
if (!raw) return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻'];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
return parsed.slice(0, MAX_RECENT_EMOJIS);
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻'];
|
||||
}
|
||||
|
||||
function saveRecentEmoji(emoji: string): void {
|
||||
try {
|
||||
const current = getStoredRecentEmojis();
|
||||
const updated = [emoji, ...current.filter((e) => e !== emoji)].slice(0, MAX_RECENT_EMOJIS);
|
||||
localStorage.setItem(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(updated));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
interface EmojiPickerModalProps {
|
||||
currentIcon?: string;
|
||||
onSelectEmoji: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||
currentIcon,
|
||||
onSelectEmoji,
|
||||
onClose,
|
||||
}) => {
|
||||
const [recentEmojis, setRecentEmojis] = useState<string[]>(getStoredRecentEmojis);
|
||||
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) => {
|
||||
saveRecentEmoji(emoji);
|
||||
setRecentEmojis(getStoredRecentEmojis());
|
||||
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-0 z-50 rounded-t-2xl max-h-[85vh] 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)',
|
||||
} 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>
|
||||
|
||||
{/* Recent Emojis Bar */}
|
||||
{recentEmojis.length > 0 && (
|
||||
<div className="shrink-0 space-y-1.5">
|
||||
<div className="flex items-center gap-1 text-[11px] text-muted">
|
||||
<Clock className="w-3 h-3 text-accent" />
|
||||
<span>Zuletzt verwendet:</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 no-scrollbar">
|
||||
{recentEmojis.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => handlePick(emoji)}
|
||||
className="min-w-[44px] min-h-[44px] sm:min-w-[32px] sm:min-h-[32px] rounded-btn bg-panel-elevated hover:bg-canvas border border-border flex items-center justify-center text-xl sm:text-base cursor-pointer transition-transform active:scale-95 shrink-0"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Full Emoji Catalog via emoji-picker-react */}
|
||||
<div className="flex-1 min-h-[300px] 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="350px"
|
||||
previewConfig={{
|
||||
showPreview: false,
|
||||
}}
|
||||
skinTonesDisabled={false}
|
||||
lazyLoadEmojis={true}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -14,11 +14,12 @@ import {
|
||||
ListTodo,
|
||||
Minus,
|
||||
MessageSquare,
|
||||
Hash,
|
||||
ExternalLink,
|
||||
Maximize2,
|
||||
Link as LinkIcon,
|
||||
FileText,
|
||||
Lock,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { LinkModal } from './LinkModal';
|
||||
@@ -57,9 +58,11 @@ export interface OutlinerBlockProps {
|
||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
|
||||
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
|
||||
onMergeWithPrevious?: (blockId: string, content: string) => boolean;
|
||||
onIndentBlock: (blockId: string, delta: 1 | -1) => void;
|
||||
onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void;
|
||||
onFocusBlock?: (blockId: string) => void;
|
||||
onBlurBlock?: (blockId: string) => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
onTagClick?: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
@@ -70,6 +73,23 @@ export interface OutlinerBlockProps {
|
||||
isTargeted?: boolean;
|
||||
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
||||
onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
|
||||
lockedBy?: string; // someone else is editing this block
|
||||
canAppendBelow?: boolean; // the user may edit the note, so Enter in a locked block still adds one below
|
||||
onResolveConflict?: (blockId: string, accept: boolean) => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
}
|
||||
|
||||
// Rendered markdown only drops syntax characters (**, `, [..](url) …), so its text is a subsequence of the raw
|
||||
// text. Walking both strings in parallel maps a caret offset in the rendered text back to the raw text.
|
||||
export function mapRenderedOffsetToRaw(raw: string, rendered: string, renderedOffset: number): number {
|
||||
let j = 0;
|
||||
for (let i = 0; i < renderedOffset && i < rendered.length; i++) {
|
||||
const idx = raw.indexOf(rendered[i], j);
|
||||
if (idx === -1) break;
|
||||
j = idx + 1;
|
||||
}
|
||||
return Math.min(j, raw.length);
|
||||
}
|
||||
|
||||
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
@@ -86,9 +106,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
onUpdateBlock,
|
||||
onCreateBlockBelow,
|
||||
onDeleteBlock,
|
||||
onMergeWithPrevious,
|
||||
onIndentBlock,
|
||||
onNavigateFocus,
|
||||
onFocusBlock,
|
||||
onBlurBlock,
|
||||
onOpenTask,
|
||||
onTagClick,
|
||||
onWikiLinkClick,
|
||||
@@ -99,10 +121,17 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
isTargeted,
|
||||
registerTextareaRef,
|
||||
onPasteMultiLines,
|
||||
lockedBy,
|
||||
canAppendBelow,
|
||||
onResolveConflict,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}) => {
|
||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
||||
const [isLocallyFocused, setIsLocallyFocused] = useState<boolean>(false);
|
||||
const activeFocus = isFocused || isLocallyFocused;
|
||||
// A block someone else is editing can still get the cursor (read-only), e.g. to press Enter at its end
|
||||
const canFocus = canEdit || (!!lockedBy && !!canAppendBelow);
|
||||
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
|
||||
@@ -124,6 +153,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
onPasteMultiLinesRef.current = onPasteMultiLines;
|
||||
const onDeleteBlockRef = useRef(onDeleteBlock);
|
||||
onDeleteBlockRef.current = onDeleteBlock;
|
||||
const onMergeWithPreviousRef = useRef(onMergeWithPrevious);
|
||||
onMergeWithPreviousRef.current = onMergeWithPrevious;
|
||||
const onIndentBlockRef = useRef(onIndentBlock);
|
||||
onIndentBlockRef.current = onIndentBlock;
|
||||
const onNavigateFocusRef = useRef(onNavigateFocus);
|
||||
@@ -149,6 +180,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
[]
|
||||
);
|
||||
|
||||
// Right after undo/redo the focused field must show the engine's text, not keep its own
|
||||
const acceptRemoteUntilRef = useRef<number>(0);
|
||||
const onUndoRef = useRef(onUndo);
|
||||
onUndoRef.current = onUndo;
|
||||
const onRedoRef = useRef(onRedo);
|
||||
onRedoRef.current = onRedo;
|
||||
|
||||
// Sync incoming remote changes ONLY when this textarea is NOT actively being edited
|
||||
// Condition: isFocusedRef.current === true && document.hasFocus()
|
||||
useEffect(() => {
|
||||
@@ -159,7 +197,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
textareaRef.current !== null &&
|
||||
document.activeElement === textareaRef.current;
|
||||
|
||||
if (!isActivelyEditing && block.content !== localContentRef.current) {
|
||||
const acceptAfterUndo = Date.now() < acceptRemoteUntilRef.current;
|
||||
if ((!isActivelyEditing || acceptAfterUndo) && block.content !== localContentRef.current) {
|
||||
setLocalContent(block.content || '');
|
||||
localContentRef.current = block.content || '';
|
||||
}
|
||||
@@ -203,6 +242,41 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const renderedTextRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
// Focus the textarea and place the caret where the user clicked in the rendered (markdown) text layer
|
||||
const focusAtPoint = (clientX: number, clientY: number) => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
const raw = ta.value;
|
||||
let pos = raw.length;
|
||||
const span = renderedTextRef.current;
|
||||
const doc = document as any;
|
||||
let node: Node | null = null;
|
||||
let offset = 0;
|
||||
if (typeof doc.caretPositionFromPoint === 'function') {
|
||||
const p = doc.caretPositionFromPoint(clientX, clientY);
|
||||
if (p) {
|
||||
node = p.offsetNode;
|
||||
offset = p.offset;
|
||||
}
|
||||
} else if (typeof doc.caretRangeFromPoint === 'function') {
|
||||
const r = doc.caretRangeFromPoint(clientX, clientY);
|
||||
if (r) {
|
||||
node = r.startContainer;
|
||||
offset = r.startOffset;
|
||||
}
|
||||
}
|
||||
if (span && node && span.contains(node)) {
|
||||
const range = document.createRange();
|
||||
range.setStart(span, 0);
|
||||
range.setEnd(node, offset);
|
||||
pos = mapRenderedOffsetToRaw(raw, span.textContent || '', range.toString().length);
|
||||
}
|
||||
ta.focus();
|
||||
ta.setSelectionRange(pos, pos);
|
||||
};
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
@@ -216,8 +290,25 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', adjustHeight);
|
||||
return () => window.removeEventListener('resize', adjustHeight);
|
||||
}, [adjustHeight]);
|
||||
// The field can be measured while hidden (phone layout shows the editor only after selecting a note);
|
||||
// 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(() => {
|
||||
if (slashSearch === null) return [];
|
||||
@@ -320,6 +411,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
const handleBlur = () => {
|
||||
isFocusedRef.current = false;
|
||||
setIsLocallyFocused(false);
|
||||
onBlurBlock?.(block.id);
|
||||
if (isPastingRef.current) {
|
||||
isPastingRef.current = false;
|
||||
return;
|
||||
@@ -356,7 +448,44 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
onPasteMultiLinesRef.current?.(block.id, textBefore, textAfter, rawLines);
|
||||
};
|
||||
|
||||
// Ctrl/Cmd+Z undoes the user's own changes in the whole note (not the browser's per-field undo)
|
||||
const runHistory = (redo: boolean) => {
|
||||
if (debounceTimerRef.current !== null) {
|
||||
flushDebounce(localContentRef.current);
|
||||
}
|
||||
acceptRemoteUntilRef.current = Date.now() + 500;
|
||||
if (redo) onRedoRef.current?.();
|
||||
else onUndoRef.current?.();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
// Mobile keyboards and the context menu undo via beforeinput instead of key events
|
||||
const onBeforeInput = (e: InputEvent) => {
|
||||
if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
|
||||
e.preventDefault();
|
||||
runHistory(e.inputType === 'historyRedo');
|
||||
}
|
||||
};
|
||||
el.addEventListener('beforeinput', onBeforeInput);
|
||||
return () => el.removeEventListener('beforeinput', onBeforeInput);
|
||||
});
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && ['z', 'y'].includes(e.key.toLowerCase())) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
runHistory(e.key.toLowerCase() === 'y' || e.shiftKey);
|
||||
return;
|
||||
}
|
||||
// Enter in a block someone else is editing cannot split it (read-only), so it adds a new block below
|
||||
// without touching the locked one. (Chromium does not move the caret with End in read-only fields.)
|
||||
if (lockedBy && canAppendBelow && e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onCreateBlockBelowRef.current(block.id, '');
|
||||
return;
|
||||
}
|
||||
if (!canEdit) return;
|
||||
|
||||
// Slash command navigation
|
||||
@@ -455,6 +584,28 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// 3b. Backspace at the very start of a non-empty block -> append its text to the previous block
|
||||
if (
|
||||
e.key === 'Backspace' &&
|
||||
localContent !== '' &&
|
||||
block.type !== 'code' &&
|
||||
onMergeWithPreviousRef.current &&
|
||||
e.currentTarget.selectionStart === 0 &&
|
||||
e.currentTarget.selectionEnd === 0
|
||||
) {
|
||||
if (onMergeWithPreviousRef.current(block.id, localContent)) {
|
||||
// Text lives in the previous block afterwards, so drop this block's pending save
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSlashSearch(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Arrow navigation between blocks
|
||||
if (e.key === 'ArrowUp' && !e.shiftKey && !e.altKey) {
|
||||
const el = textareaRef.current;
|
||||
@@ -489,9 +640,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
return parts.filter(Boolean);
|
||||
}, [localContent]);
|
||||
|
||||
// #Hashtags are rendered inline inside the text, so only links need the badge bar below
|
||||
const hasSpecialTokens = tokens.some(
|
||||
(t) =>
|
||||
t.startsWith('#') ||
|
||||
(t.startsWith('[[') && t.endsWith(']]')) ||
|
||||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
|
||||
/^https?:\/\//.test(t)
|
||||
@@ -503,11 +654,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
isDragTarget
|
||||
? 'border-t-2 border-accent bg-accent/10'
|
||||
: isTargeted
|
||||
? 'bg-accent/15 ring-2 ring-accent'
|
||||
: activeFocus
|
||||
? 'bg-panel-elevated'
|
||||
: 'hover:bg-panel'
|
||||
}`}
|
||||
: ''
|
||||
} ${block.conflictOf ? 'border-l-2 border-status-warn bg-status-warn/5' : ''}`}
|
||||
style={{
|
||||
paddingLeft: `${visualLevel * 22 + 4}px`,
|
||||
}}
|
||||
@@ -515,23 +664,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
onDrop={() => onDropBlock?.(index)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest('button, a, input, select')) return;
|
||||
if (!activeFocus && canEdit && textareaRef.current) {
|
||||
if (!activeFocus && canFocus && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
const len = textareaRef.current.value.length;
|
||||
textareaRef.current.setSelectionRange(len, len);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* 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 */}
|
||||
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
||||
{hasChildren ? (
|
||||
@@ -540,7 +679,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
onClick={() =>
|
||||
flushDebounce(localContent, { collapsed: !block.collapsed })
|
||||
}
|
||||
className="p-0.5 text-muted hover:text-accent transition-colors"
|
||||
className="p-0.5 text-muted hover:text-accent transition-[color,opacity] opacity-0 group-hover:opacity-100 focus-visible:opacity-100"
|
||||
title={block.collapsed ? 'Unterpunkte ausklappen' : 'Unterpunkte einklappen'}
|
||||
>
|
||||
{block.collapsed ? (
|
||||
@@ -559,7 +698,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
draggable={canEdit}
|
||||
onDragStart={() => canEdit && onDragStartBlock?.(index)}
|
||||
onClick={() => onZoomBlock?.(block.id)}
|
||||
className={`w-4 h-4 flex items-center justify-center rounded-btn hover:bg-panel-hover group/bullet transition-transform ${
|
||||
className={`w-4 h-4 flex items-center justify-center rounded-btn group/bullet transition-transform ${
|
||||
canEdit ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
|
||||
}`}
|
||||
title={
|
||||
@@ -573,7 +712,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
block.collapsed && hasChildren
|
||||
? 'bg-accent ring-2 ring-accent/30'
|
||||
: activeFocus
|
||||
? 'bg-accent scale-125'
|
||||
? 'bg-main scale-125'
|
||||
: 'bg-faint group-hover/bullet:bg-accent group-hover/bullet:scale-125'
|
||||
} ${
|
||||
isEmpty
|
||||
@@ -657,11 +796,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
<div className="relative w-full min-h-[1.5rem]">
|
||||
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!activeFocus && canEdit && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
const len = textareaRef.current.value.length;
|
||||
textareaRef.current.setSelectionRange(len, len);
|
||||
onClick={(e) => {
|
||||
if (!activeFocus && canFocus && textareaRef.current) {
|
||||
// Keep the row's click handler from moving the caret back to the end
|
||||
e.stopPropagation();
|
||||
focusAtPoint(e.clientX, e.clientY);
|
||||
}
|
||||
}}
|
||||
className={`w-full min-h-[1.5rem] leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
|
||||
@@ -679,21 +818,27 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}`}
|
||||
>
|
||||
{/* When actively focused, ghost text is invisible so textarea renders text/caret directly */}
|
||||
<span className={activeFocus ? 'invisible' : ''}>
|
||||
{localContent ? renderInlineMarkdown(localContent) : '\u200b'}
|
||||
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
|
||||
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
|
||||
</span>
|
||||
|
||||
{/* Inline Badge and 3-Dots directly behind the text */}
|
||||
{block.type === 'todo' && (
|
||||
<span className="inline-flex items-center align-middle ml-1.5 gap-1 shrink-0 pointer-events-auto z-10 relative select-none">
|
||||
{block.taskId && (
|
||||
<span
|
||||
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.2 rounded font-medium shrink-0"
|
||||
title="Mit erweiterter Aufgabe synchronisiert"
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenTask?.(block.taskId as string);
|
||||
}}
|
||||
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.2 rounded font-medium shrink-0 cursor-pointer hover:opacity-80"
|
||||
title="Erweiterte Aufgabe öffnen"
|
||||
>
|
||||
<CheckSquare className="w-2.5 h-2.5" />
|
||||
<span>Erweitert</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canEdit && (
|
||||
@@ -732,6 +877,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
type: 'todo',
|
||||
};
|
||||
onUpdateBlockRef.current(updatedBlock, true);
|
||||
onOpenTask?.(thought.id);
|
||||
} catch (err: any) {
|
||||
alert(err.message || 'Fehler beim Erstellen der erweiterten Aufgabe');
|
||||
}
|
||||
@@ -805,24 +951,41 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conflict copy: text that lost against someone else's change */}
|
||||
{block.conflictOf && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px] text-status-warn select-none">
|
||||
<AlertTriangle className="w-3 h-3 shrink-0" />
|
||||
<span>
|
||||
{block.conflictOf === 'title' ? 'Konflikt beim Titel' : 'Konflikt'}
|
||||
{block.conflictAuthor ? ` – Version von ${block.conflictAuthor}` : ''}
|
||||
</span>
|
||||
{onResolveConflict && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResolveConflict(block.id, true)}
|
||||
className="px-1.5 py-0.5 rounded-btn border border-status-warn/40 hover:bg-status-warn/15 cursor-pointer"
|
||||
title={block.conflictOf === 'title' ? 'Diesen Text als Titel übernehmen' : 'Diesen Text in den ursprünglichen Punkt übernehmen'}
|
||||
>
|
||||
Übernehmen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onResolveConflict(block.id, false)}
|
||||
className="px-1.5 py-0.5 rounded-btn border border-border text-muted hover:text-main hover:bg-panel cursor-pointer"
|
||||
title="Diese Version verwerfen"
|
||||
>
|
||||
Verwerfen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rich Tokens Bar (Wiki-Links & #Tags) if present */}
|
||||
{hasSpecialTokens && !isFocused && (
|
||||
<div className="flex flex-wrap items-center gap-1 mt-0.5">
|
||||
{tokens.map((part, idx) => {
|
||||
if (part.startsWith('#') && part.length > 1) {
|
||||
const tag = part.slice(1);
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => onTagClick?.(tag)}
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-accent bg-accent-dim hover:bg-accent/20 rounded-btn transition-colors"
|
||||
>
|
||||
<Hash className="w-2.5 h-2.5 opacity-80" />
|
||||
<span>{tag}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (part.startsWith('[[') && part.endsWith(']]')) {
|
||||
const page = part.slice(2, -2).trim();
|
||||
return (
|
||||
@@ -924,6 +1087,17 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Someone else is editing this block */}
|
||||
{lockedBy && (
|
||||
<span
|
||||
className="shrink-0 self-start mt-1 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-btn text-[10px] text-muted bg-panel border border-border select-none"
|
||||
title={`${lockedBy} bearbeitet diesen Punkt gerade`}
|
||||
>
|
||||
<Lock className="w-2.5 h-2.5" />
|
||||
{lockedBy}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Delete button on hover (Trash2 icon) */}
|
||||
{canEdit && (
|
||||
<button
|
||||
|
||||
@@ -23,6 +23,12 @@ export interface OutlinerTreeProps {
|
||||
onTagClick?: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
initialFocusedBlockId?: string | null;
|
||||
onFocusedBlockChange?: (blockId: string | null) => void;
|
||||
lockedBlocks?: Map<string, string>; // blockId -> name of the user editing it (someone else)
|
||||
onLockedAction?: (holderName: string) => void;
|
||||
onResolveConflict?: (blockId: string, accept: boolean) => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
}
|
||||
|
||||
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
@@ -41,36 +47,61 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
onTagClick,
|
||||
onWikiLinkClick,
|
||||
initialFocusedBlockId,
|
||||
onFocusedBlockChange,
|
||||
lockedBlocks,
|
||||
onLockedAction,
|
||||
onResolveConflict,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}, ref) => {
|
||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||
|
||||
const onFocusedBlockChangeRef = useRef(onFocusedBlockChange);
|
||||
onFocusedBlockChangeRef.current = onFocusedBlockChange;
|
||||
useEffect(() => {
|
||||
onFocusedBlockChangeRef.current?.(focusedBlockId);
|
||||
}, [focusedBlockId]);
|
||||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
|
||||
initialFocusedBlockId || null
|
||||
);
|
||||
const [draggedIndex, setDraggedIndex] = 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 pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' } | null>(null);
|
||||
const pendingFocusRef = useRef<{ blockId: string; cursor: 'start' | 'end' | 'select' | number } | null>(null);
|
||||
|
||||
const focusBlockInput = useCallback(
|
||||
(blockId: string, cursor: 'start' | 'end' | 'select' = 'end') => {
|
||||
(blockId: string, cursor: 'start' | 'end' | 'select' | number = 'end') => {
|
||||
setFocusedBlockId(blockId);
|
||||
const el = textareaMapRef.current.get(blockId);
|
||||
if (el) {
|
||||
requestAnimationFrame(() => {
|
||||
const apply = () => {
|
||||
el.focus();
|
||||
try {
|
||||
if (cursor === 'select') {
|
||||
el.select();
|
||||
} else {
|
||||
const pos = cursor === 'end' ? el.value.length : 0;
|
||||
const pos =
|
||||
typeof cursor === 'number'
|
||||
? Math.min(cursor, el.value.length)
|
||||
: cursor === 'end'
|
||||
? el.value.length
|
||||
: 0;
|
||||
el.setSelectionRange(pos, pos);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
};
|
||||
// A numeric position refers to content that was just changed, so wait one extra frame for it to render
|
||||
requestAnimationFrame(() => (typeof cursor === 'number' ? requestAnimationFrame(apply) : apply()));
|
||||
} else {
|
||||
pendingFocusRef.current = { blockId, cursor };
|
||||
}
|
||||
@@ -110,21 +141,26 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
if (pendingFocusRef.current && pendingFocusRef.current.blockId === blockId) {
|
||||
const target = pendingFocusRef.current;
|
||||
pendingFocusRef.current = null;
|
||||
requestAnimationFrame(() => {
|
||||
setFocusedBlockId(blockId);
|
||||
el.focus();
|
||||
try {
|
||||
if (target.cursor === 'select') {
|
||||
el.select();
|
||||
} else {
|
||||
const pos = target.cursor === 'end' ? el.value.length : 0;
|
||||
el.setSelectionRange(pos, pos);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
// Focus right away (the element is already in the DOM): keystrokes typed directly after Enter
|
||||
// must land in the new block, not in the previous one
|
||||
setFocusedBlockId(blockId);
|
||||
el.focus();
|
||||
try {
|
||||
if (target.cursor === 'select') {
|
||||
el.select();
|
||||
} else {
|
||||
const pos =
|
||||
typeof target.cursor === 'number'
|
||||
? Math.min(target.cursor, el.value.length)
|
||||
: target.cursor === 'end'
|
||||
? el.value.length
|
||||
: 0;
|
||||
el.setSelectionRange(pos, pos);
|
||||
}
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
} else {
|
||||
textareaMapRef.current.delete(blockId);
|
||||
@@ -467,9 +503,33 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
[blocks, canEdit, docId, focusBlockInput, onCreateBlock, onInsertBlocks, onUpdateBlock]
|
||||
);
|
||||
|
||||
// Name of the other user holding the block (or, withBranch, any block below it); structural actions on
|
||||
// such blocks are not started at all, so they cannot end up half applied
|
||||
const lockHolder = useCallback(
|
||||
(blockId: string, withBranch = false): string | undefined => {
|
||||
if (!lockedBlocks || lockedBlocks.size === 0) return undefined;
|
||||
const direct = lockedBlocks.get(blockId);
|
||||
if (direct || !withBranch) return direct;
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx < 0) return undefined;
|
||||
const level = blocks[idx].level || 0;
|
||||
for (let i = idx + 1; i < blocks.length && (blocks[i].level || 0) > level; i++) {
|
||||
const name = lockedBlocks.get(blocks[i].id);
|
||||
if (name) return name;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[blocks, lockedBlocks]
|
||||
);
|
||||
|
||||
const handleDeleteBlock = useCallback(
|
||||
(blockId: string, deleteSubtree = false) => {
|
||||
if (!canEdit) return;
|
||||
const holder = lockHolder(blockId, true);
|
||||
if (holder) {
|
||||
onLockedAction?.(holder);
|
||||
return;
|
||||
}
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx === -1) return;
|
||||
|
||||
@@ -511,7 +571,49 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
onDeleteBlock(blockId);
|
||||
}
|
||||
},
|
||||
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock]
|
||||
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock, lockHolder, onLockedAction]
|
||||
);
|
||||
|
||||
// Backspace at the start of a non-empty block: append its text to the previous visible block
|
||||
const handleMergeWithPrevious = useCallback(
|
||||
(blockId: string, content: string) => {
|
||||
if (!canEdit) return false;
|
||||
const notMergeable = ['code', 'divider', 'table', 'progress'];
|
||||
const visIdx = visibleEntries.findIndex((e) => e.block.id === blockId);
|
||||
if (visIdx <= 0) return false;
|
||||
const prev = visibleEntries[visIdx - 1].block;
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
const cur = blocks[idx];
|
||||
if (!cur || notMergeable.includes(prev.type) || notMergeable.includes(cur.type)) return false;
|
||||
const holder = lockHolder(cur.id, true) || lockHolder(prev.id);
|
||||
if (holder) {
|
||||
onLockedAction?.(holder);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Children of the merged block move up so they become children of the previous block
|
||||
const curLevel = Math.max(0, cur.level || 0);
|
||||
const delta = Math.max(0, prev.level || 0) - curLevel;
|
||||
const movedChildren: DocumentBlock[] = [];
|
||||
for (let j = idx + 1; j < blocks.length; j++) {
|
||||
const lvl = Math.max(0, blocks[j].level || 0);
|
||||
if (lvl <= curLevel) break;
|
||||
movedChildren.push({ ...blocks[j], level: Math.max(levelOffset || 0, lvl + delta) });
|
||||
}
|
||||
|
||||
const joinPos = (prev.content || '').length;
|
||||
const mergedPrev: DocumentBlock = { ...prev, content: (prev.content || '') + content };
|
||||
if (movedChildren.length > 0 && onUpdateBlocks) {
|
||||
onUpdateBlocks([mergedPrev, ...movedChildren]);
|
||||
} else {
|
||||
onUpdateBlock(mergedPrev, true);
|
||||
movedChildren.forEach((c) => onUpdateBlock(c));
|
||||
}
|
||||
onDeleteBlock(cur.id);
|
||||
focusBlockInput(prev.id, joinPos);
|
||||
return true;
|
||||
},
|
||||
[blocks, canEdit, focusBlockInput, levelOffset, onDeleteBlock, onUpdateBlock, onUpdateBlocks, visibleEntries, lockHolder, onLockedAction]
|
||||
);
|
||||
|
||||
const handleIndentBlock = useCallback(
|
||||
@@ -519,6 +621,11 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
if (!canEdit) return;
|
||||
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||
if (idx === -1) return;
|
||||
const holder = lockHolder(blockId);
|
||||
if (holder) {
|
||||
onLockedAction?.(holder);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = blocks[idx];
|
||||
const currentLevel = Math.max(0, current.level || 0);
|
||||
@@ -564,7 +671,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
}
|
||||
}
|
||||
},
|
||||
[blocks, canEdit, levelOffset, onUpdateBlock, onUpdateBlocks]
|
||||
[blocks, canEdit, levelOffset, onUpdateBlock, onUpdateBlocks, lockHolder, onLockedAction]
|
||||
);
|
||||
|
||||
const handleNavigateFocus = useCallback(
|
||||
@@ -633,13 +740,16 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
return;
|
||||
}
|
||||
const movingBlock = blocks[draggedIndex];
|
||||
if (movingBlock) {
|
||||
const holder = movingBlock ? lockHolder(movingBlock.id) : undefined;
|
||||
if (holder) {
|
||||
onLockedAction?.(holder);
|
||||
} else if (movingBlock) {
|
||||
onMoveBlock(movingBlock.id, targetIndex, movingBlock.level);
|
||||
}
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
},
|
||||
[blocks, draggedIndex, onMoveBlock]
|
||||
[blocks, draggedIndex, onMoveBlock, lockHolder, onLockedAction]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -665,16 +775,23 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
maxAllowedLevel={maxAllowedLevel}
|
||||
hasChildren={hasChildren}
|
||||
isFocused={focusedBlockId === block.id}
|
||||
canEdit={canEdit}
|
||||
canEdit={canEdit && !lockedBlocks?.has(block.id)}
|
||||
lockedBy={lockedBlocks?.get(block.id)}
|
||||
canAppendBelow={canEdit}
|
||||
onResolveConflict={canEdit ? onResolveConflict : undefined}
|
||||
onUndo={onUndo}
|
||||
onRedo={onRedo}
|
||||
levelOffset={levelOffset}
|
||||
onZoomBlock={onZoomBlock}
|
||||
onUpdateBlock={onUpdateBlock}
|
||||
onCreateBlockBelow={handleCreateBlockBelow}
|
||||
onPasteMultiLines={handlePasteMultiLines}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onMergeWithPrevious={handleMergeWithPrevious}
|
||||
onIndentBlock={handleIndentBlock}
|
||||
onNavigateFocus={handleNavigateFocus}
|
||||
onFocusBlock={setFocusedBlockId}
|
||||
onBlurBlock={handleBlurBlock}
|
||||
onOpenTask={onOpenTask}
|
||||
onTagClick={onTagClick}
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -49,8 +49,10 @@ import {
|
||||
RealtimeTransport,
|
||||
} from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { sendDocOps } from '../../lib/docSync/oneoff';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
||||
import { extractHashtags } from '../../lib/hashtags';
|
||||
|
||||
interface WorkspaceViewProps {
|
||||
currentUser?: User | null;
|
||||
@@ -288,12 +290,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const tagMap = new Map<string, number>();
|
||||
for (const d of docs) {
|
||||
for (const b of d.blocks || []) {
|
||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
if (matches) {
|
||||
for (const m of matches) {
|
||||
const clean = m.slice(1).toLowerCase();
|
||||
tagMap.set(clean, (tagMap.get(clean) || 0) + 1);
|
||||
}
|
||||
for (const clean of extractHashtags(b.content)) {
|
||||
tagMap.set(clean, (tagMap.get(clean) || 0) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -789,9 +787,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
prev.map((d) => (d.id === doc.id ? { ...d, blocks: updatedBlocks } : d))
|
||||
);
|
||||
try {
|
||||
await api.patchDocumentBlocks(doc.id, {
|
||||
upserted: [{ id: blockId, checked: !currentChecked, type: 'todo', content: '' }],
|
||||
});
|
||||
const [result] = await sendDocOps(doc.id, [{ kind: 'set_props', blockId, patch: { checked: !currentChecked } }]);
|
||||
if (result?.status !== 'applied') loadWorkspaceData();
|
||||
} catch {
|
||||
loadWorkspaceData();
|
||||
}
|
||||
@@ -1117,8 +1114,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<div className="w-full space-y-3">
|
||||
{/* Top Header: Workspace Tabs & Actions */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
||||
{/* Workspace selector tabs & left sidebar toggle */}
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{/* Workspace selector tabs & left sidebar toggle; on phones the three tabs share the full width */}
|
||||
<div className="grid grid-cols-3 w-full gap-1 sm:flex sm:flex-wrap sm:items-center sm:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
disabled={subView !== 'editor'}
|
||||
@@ -1155,7 +1152,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
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'
|
||||
? 'bg-panel border-accent text-main'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
@@ -1172,7 +1169,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setSubView('todos');
|
||||
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'
|
||||
? 'bg-panel border-accent text-main'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
@@ -1189,7 +1186,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setSubView('tags');
|
||||
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'
|
||||
? 'bg-panel border-accent text-main'
|
||||
: 'bg-canvas border-border text-muted hover:text-main'
|
||||
@@ -1201,8 +1198,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Header Section: Save Status & Live Collaboration Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Right Header Section: Save Status (on phones it sits compactly in the note's own header) */}
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
{subView === 'editor' && selectedDocId ? (
|
||||
/* Save Status Pill */
|
||||
<div
|
||||
@@ -1272,7 +1269,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<h2 className="text-xs font-bold text-main uppercase tracking-wider">
|
||||
Notizen & Outliner
|
||||
</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
|
||||
type="button"
|
||||
onClick={(e) => handleOpenNewFolder('', e)}
|
||||
@@ -2268,15 +2266,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
shares={unifiedShares}
|
||||
teams={teams}
|
||||
collaborators={collaborators}
|
||||
allowRoleSelection={false}
|
||||
fixedPermission="viewer"
|
||||
onAddShare={async (targetType, targetId) => {
|
||||
onAddShare={async (targetType, targetId, role) => {
|
||||
const permission = role === 'editor' ? 'write' : 'read';
|
||||
if (shareModalDoc) {
|
||||
await api.createDocShare(
|
||||
shareModalDoc.id,
|
||||
targetType === 'team'
|
||||
? { teamId: targetId, permission: 'read' }
|
||||
: { userId: targetId, permission: 'read' }
|
||||
targetType === 'team' ? { teamId: targetId, permission } : { userId: targetId, permission }
|
||||
);
|
||||
const updatedShares = await api.getDocShares(shareModalDoc.id);
|
||||
setActiveDocShares(updatedShares);
|
||||
@@ -2284,7 +2279,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
await api.createFolderShare(shareModalFolder.id, {
|
||||
targetType,
|
||||
targetId,
|
||||
permission: 'read',
|
||||
permission,
|
||||
});
|
||||
const updatedFolder = await api.getSharedFolder(shareModalFolder.id);
|
||||
setShareModalFolder(updatedFolder);
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { OutlinerBlock, mapRenderedOffsetToRaw } from '../OutlinerBlock';
|
||||
import { DocumentBlock } from '../../../lib/types';
|
||||
|
||||
describe('mapRenderedOffsetToRaw', () => {
|
||||
it('is the identity for plain text', () => {
|
||||
expect(mapRenderedOffsetToRaw('Hallo Welt', 'Hallo Welt', 4)).toBe(4);
|
||||
});
|
||||
it('skips bold markers', () => {
|
||||
expect(mapRenderedOffsetToRaw('Hallo **Welt** du', 'Hallo Welt du', 8)).toBe(10);
|
||||
});
|
||||
it('maps behind closing markers', () => {
|
||||
expect(mapRenderedOffsetToRaw('a **b** c', 'a b c', 4)).toBe(8);
|
||||
});
|
||||
it('maps into a link label', () => {
|
||||
expect(mapRenderedOffsetToRaw('x [Seite](https://a.de) y', 'x Seite y', 4)).toBe(5);
|
||||
});
|
||||
it('handles the empty placeholder', () => {
|
||||
expect(mapRenderedOffsetToRaw('', '', 1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clicking into an unfocused bullet', () => {
|
||||
afterEach(() => {
|
||||
delete (document as any).caretRangeFromPoint;
|
||||
});
|
||||
|
||||
it('places the caret at the click position instead of the end', () => {
|
||||
const block = { id: 'b1', type: 'bullet', content: 'Hallo **Welt** du', level: 0, position: 1 } as DocumentBlock;
|
||||
const { container } = render(
|
||||
<OutlinerBlock
|
||||
docId="d"
|
||||
block={block}
|
||||
index={0}
|
||||
isLast
|
||||
maxAllowedLevel={1}
|
||||
hasChildren={false}
|
||||
isFocused={false}
|
||||
canEdit
|
||||
onUpdateBlock={() => {}}
|
||||
onCreateBlockBelow={() => {}}
|
||||
onDeleteBlock={() => {}}
|
||||
onIndentBlock={() => {}}
|
||||
onNavigateFocus={() => {}}
|
||||
registerTextareaRef={() => {}}
|
||||
/>
|
||||
);
|
||||
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
const strong = container.querySelector('strong') as HTMLElement; // rendered "Welt"
|
||||
// jsdom has no layout: pretend the click hit between "We" and "lt"
|
||||
(document as any).caretRangeFromPoint = () => {
|
||||
const r = document.createRange();
|
||||
r.setStart(strong.firstChild!, 2);
|
||||
return r;
|
||||
};
|
||||
|
||||
fireEvent.click(strong);
|
||||
|
||||
expect(document.activeElement).toBe(ta);
|
||||
expect(ta.selectionStart).toBe(10); // "Hallo **We|lt** du"
|
||||
});
|
||||
});
|
||||
|
||||
function renderBlock(block: DocumentBlock, extra: Record<string, unknown> = {}) {
|
||||
return render(
|
||||
<OutlinerBlock
|
||||
docId="d"
|
||||
block={block}
|
||||
index={0}
|
||||
isLast
|
||||
maxAllowedLevel={1}
|
||||
hasChildren={false}
|
||||
isFocused={false}
|
||||
canEdit
|
||||
onUpdateBlock={() => {}}
|
||||
onCreateBlockBelow={() => {}}
|
||||
onDeleteBlock={() => {}}
|
||||
onIndentBlock={() => {}}
|
||||
onNavigateFocus={() => {}}
|
||||
registerTextareaRef={() => {}}
|
||||
{...extra}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('undo shortcut', () => {
|
||||
it('saves pending typing first, then undoes', () => {
|
||||
const calls: string[] = [];
|
||||
const block = { id: 'b1', type: 'bullet', content: 'Alt', level: 0 } as DocumentBlock;
|
||||
const { container } = renderBlock(block, {
|
||||
onUpdateBlock: (b: DocumentBlock) => calls.push(`update:${b.content}`),
|
||||
onUndo: () => calls.push('undo'),
|
||||
onRedo: () => calls.push('redo'),
|
||||
});
|
||||
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
ta.focus();
|
||||
fireEvent.change(ta, { target: { value: 'Alt neu' } });
|
||||
fireEvent.keyDown(ta, { key: 'z', ctrlKey: true });
|
||||
fireEvent.keyDown(ta, { key: 'z', ctrlKey: true, shiftKey: true });
|
||||
fireEvent.keyDown(ta, { key: 'y', metaKey: true });
|
||||
expect(calls).toEqual(['update:Alt neu', 'undo', 'redo', 'redo']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conflict copies', () => {
|
||||
it('show who lost and offer accept/discard', () => {
|
||||
const onResolve = vi.fn();
|
||||
const block = { id: 'cf', type: 'bullet', content: 'Meine Version', level: 0, conflictOf: 'b1', conflictAuthor: 'Ben' } as DocumentBlock;
|
||||
const { getByText } = renderBlock(block, { onResolveConflict: onResolve });
|
||||
expect(getByText(/Version von Ben/)).toBeTruthy();
|
||||
fireEvent.click(getByText('Übernehmen'));
|
||||
fireEvent.click(getByText('Verwerfen'));
|
||||
expect(onResolve.mock.calls).toEqual([
|
||||
['cf', true],
|
||||
['cf', false],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, { useState } from 'react';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, fireEvent, screen, act } from '@testing-library/react';
|
||||
import { OutlinerTree } from '../OutlinerTree';
|
||||
import { DocumentBlock } from '../../../lib/types';
|
||||
|
||||
// Holds the latest block state rendered by the harness
|
||||
let live: DocumentBlock[] = [];
|
||||
|
||||
let lockedNotices: string[] = [];
|
||||
|
||||
const Harness: React.FC<{ initial: DocumentBlock[]; locked?: Map<string, string> }> = ({ initial, locked }) => {
|
||||
const [blocks, setBlocks] = useState(initial);
|
||||
live = blocks;
|
||||
return (
|
||||
<OutlinerTree
|
||||
docId="d1"
|
||||
blocks={blocks}
|
||||
lockedBlocks={locked}
|
||||
onLockedAction={(name) => lockedNotices.push(name)}
|
||||
canEdit
|
||||
onUpdateBlock={(b) => setBlocks((p) => p.map((x) => (x.id === b.id ? b : x)))}
|
||||
onUpdateBlocks={(u) => setBlocks((p) => p.map((x) => u.find((y) => y.id === x.id) || x))}
|
||||
onCreateBlock={(nb, after) =>
|
||||
setBlocks((p) => {
|
||||
const i = p.findIndex((x) => x.id === after);
|
||||
return i < 0 ? [...p, nb] : [...p.slice(0, i + 1), nb, ...p.slice(i + 1)];
|
||||
})
|
||||
}
|
||||
onDeleteBlock={(id) => setBlocks((p) => p.filter((x) => x.id !== id))}
|
||||
onMoveBlock={() => {}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const mk = (id: string, content: string, level = 0, type: any = 'bullet'): DocumentBlock =>
|
||||
({ id, type, content, level, position: 1 }) as DocumentBlock;
|
||||
|
||||
const area = (text: string) => screen.getAllByDisplayValue(text)[0] as HTMLTextAreaElement;
|
||||
|
||||
const backspaceAt = async (ta: HTMLTextAreaElement, pos = 0) => {
|
||||
ta.focus();
|
||||
ta.setSelectionRange(pos, pos);
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(ta, { key: 'Backspace' });
|
||||
});
|
||||
};
|
||||
|
||||
describe('Backspace at the start of a bullet', () => {
|
||||
it('appends its text to the previous bullet', async () => {
|
||||
render(<Harness initial={[mk('a', 'Hallo '), mk('b', 'Welt')]} />);
|
||||
await backspaceAt(area('Welt'));
|
||||
expect(live.map((b) => b.content)).toEqual(['Hallo Welt']);
|
||||
});
|
||||
|
||||
it('works for legacy blocks without a type', async () => {
|
||||
render(<Harness initial={[mk('a', 'A', 0, undefined), mk('b', 'B', 0, '')]} />);
|
||||
await backspaceAt(area('B'));
|
||||
expect(live.map((b) => b.content)).toEqual(['AB']);
|
||||
});
|
||||
|
||||
it('moves children up under the previous bullet', async () => {
|
||||
render(<Harness initial={[mk('a', 'A'), mk('b', 'B'), mk('c', 'C', 1)]} />);
|
||||
await backspaceAt(area('B'));
|
||||
expect(live.map((b) => [b.content, b.level])).toEqual([
|
||||
['AB', 0],
|
||||
['C', 1],
|
||||
]);
|
||||
});
|
||||
|
||||
it('does nothing when the caret is not at the start', async () => {
|
||||
render(<Harness initial={[mk('a', 'A'), mk('b', 'BB')]} />);
|
||||
await backspaceAt(area('BB'), 1);
|
||||
expect(live).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not merge the first bullet', async () => {
|
||||
render(<Harness initial={[mk('a', 'A'), mk('b', 'B')]} />);
|
||||
await backspaceAt(area('A'));
|
||||
expect(live).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not merge into a code block', async () => {
|
||||
render(<Harness initial={[mk('a', 'x = 1', 0, 'code'), mk('b', 'B')]} />);
|
||||
await backspaceAt(area('B'));
|
||||
expect(live).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocks locked by someone else', () => {
|
||||
it('are read-only and show who edits them', () => {
|
||||
render(<Harness initial={[mk('a', 'Frei'), mk('b', 'Gesperrt')]} locked={new Map([['b', 'Anna']])} />);
|
||||
expect(area('Gesperrt').readOnly).toBe(true);
|
||||
expect(area('Frei').readOnly).toBe(false);
|
||||
expect(screen.getByTitle('Anna bearbeitet diesen Punkt gerade')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('merging into a locked bullet is not started at all', async () => {
|
||||
lockedNotices = [];
|
||||
render(<Harness initial={[mk('a', 'Oben'), mk('b', 'Unten')]} locked={new Map([['a', 'Anna']])} />);
|
||||
await backspaceAt(area('Unten'));
|
||||
expect(live.map((b) => b.content)).toEqual(['Oben', 'Unten']);
|
||||
expect(lockedNotices).toEqual(['Anna']);
|
||||
});
|
||||
|
||||
it('a branch with a locked child cannot be merged away', async () => {
|
||||
lockedNotices = [];
|
||||
render(<Harness initial={[mk('a', 'A'), mk('b', 'B'), mk('c', 'C', 1)]} locked={new Map([['c', 'Ben']])} />);
|
||||
await backspaceAt(area('B'));
|
||||
expect(live.map((b) => b.content)).toEqual(['A', 'B', 'C']);
|
||||
expect(lockedNotices).toEqual(['Ben']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enter on a locked bullet', () => {
|
||||
for (const [label, pos] of [
|
||||
['at the end', 8],
|
||||
['in the middle', 3],
|
||||
] as const) {
|
||||
it(`${label} adds a new bullet below and never splits the locked one`, async () => {
|
||||
render(<Harness initial={[mk('a', 'Gesperrt')]} locked={new Map([['a', 'Anna']])} />);
|
||||
const ta = area('Gesperrt');
|
||||
ta.focus();
|
||||
ta.setSelectionRange(pos, pos);
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(ta, { key: 'Enter' });
|
||||
});
|
||||
expect(live.map((b) => b.content)).toEqual(['Gesperrt', '']);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -293,6 +293,39 @@ body {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -428,3 +461,30 @@ body {
|
||||
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;
|
||||
}
|
||||
|
||||
.mindsift-emoji-picker .EmojiPickerReact .epr-category-nav button {
|
||||
min-height: 44px !important;
|
||||
}
|
||||
}
|
||||
@@ -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,132 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Minimal stand-ins: tests decide when connections open and which messages arrive.
|
||||
class FakeWebSocket {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
static instances: FakeWebSocket[] = [];
|
||||
readyState = FakeWebSocket.CONNECTING;
|
||||
sent: string[] = [];
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onclose: ((e: { wasClean: boolean; code: number }) => void) | null = null;
|
||||
onerror: ((e: unknown) => void) | null = null;
|
||||
constructor(public url: string) {
|
||||
FakeWebSocket.instances.push(this);
|
||||
}
|
||||
open() {
|
||||
this.readyState = FakeWebSocket.OPEN;
|
||||
this.onopen?.();
|
||||
}
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
close() {
|
||||
this.readyState = FakeWebSocket.CLOSED;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeEventSource {
|
||||
static instances: FakeEventSource[] = [];
|
||||
closed = false;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((e: { data: string }) => void) | null = null;
|
||||
onerror: ((e: unknown) => void) | null = null;
|
||||
constructor(public url: string) {
|
||||
FakeEventSource.instances.push(this);
|
||||
}
|
||||
open() {
|
||||
this.onopen?.();
|
||||
}
|
||||
emit(event: string, payload: unknown = {}) {
|
||||
this.onmessage?.({ data: JSON.stringify({ event, payload }) });
|
||||
}
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApi() {
|
||||
vi.resetModules(); // fresh module state (transport flags) for every test
|
||||
return import('../api');
|
||||
}
|
||||
|
||||
describe('realtime fallback', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
FakeWebSocket.instances = [];
|
||||
FakeEventSource.instances = [];
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket);
|
||||
vi.stubGlobal('EventSource', FakeEventSource);
|
||||
sessionStorage.clear();
|
||||
localStorage.setItem('mindsift_jwt_token', 'test-token');
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 })));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('a WebSocket that stays silent is replaced by SSE', async () => {
|
||||
const api = await loadApi();
|
||||
const states: string[] = [];
|
||||
api.onRealtimeStatus((connected, transport) => states.push(`${connected}:${transport}`));
|
||||
api.connectWebSocket();
|
||||
FakeWebSocket.instances[0].open();
|
||||
expect(api.getRealtimeTransport()).toBe('ws');
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(FakeWebSocket.instances[0].sent).toContain(JSON.stringify({ event: 'PING' }));
|
||||
await vi.advanceTimersByTimeAsync(40_000); // no message at all, not even PONG
|
||||
expect(FakeEventSource.instances).toHaveLength(1);
|
||||
FakeEventSource.instances[0].open();
|
||||
FakeEventSource.instances[0].emit('SSE_CONNECTED', { sseId: 'sse_1' });
|
||||
expect(api.getRealtimeTransport()).toBe('sse');
|
||||
api.disconnectWebSocket();
|
||||
});
|
||||
|
||||
it('a WebSocket answering PONG stays in use', async () => {
|
||||
const api = await loadApi();
|
||||
api.connectWebSocket();
|
||||
const ws = FakeWebSocket.instances[0];
|
||||
ws.open();
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
ws.onmessage?.({ data: JSON.stringify({ event: 'PONG' }) });
|
||||
}
|
||||
expect(FakeEventSource.instances).toHaveLength(0);
|
||||
expect(api.getRealtimeTransport()).toBe('ws');
|
||||
api.disconnectWebSocket();
|
||||
});
|
||||
|
||||
it('SSE only counts as connected once the first event arrives', async () => {
|
||||
sessionStorage.setItem('mindsift_ws_blocked', 'true');
|
||||
const api = await loadApi();
|
||||
api.connectWebSocket();
|
||||
const es = FakeEventSource.instances[0];
|
||||
es.open();
|
||||
expect(api.isRealtimeConnected()).toBe(false);
|
||||
es.emit('SSE_CONNECTED', { sseId: 'sse_1' });
|
||||
expect(api.isRealtimeConnected()).toBe(true);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(api.isRealtimeConnected()).toBe(true);
|
||||
api.disconnectWebSocket();
|
||||
});
|
||||
|
||||
it('a buffering proxy (stream opens, no events) switches SSE off for this tab', async () => {
|
||||
sessionStorage.setItem('mindsift_ws_blocked', 'true');
|
||||
const api = await loadApi();
|
||||
api.connectWebSocket();
|
||||
const es = FakeEventSource.instances[0];
|
||||
es.open();
|
||||
await vi.advanceTimersByTimeAsync(5_500);
|
||||
expect(es.closed).toBe(true);
|
||||
expect(api.isRealtimeConnected()).toBe(false);
|
||||
expect(sessionStorage.getItem('mindsift_sse_buffered')).toBe('true');
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(FakeEventSource.instances).toHaveLength(1); // no reconnect loop: note sync uses long polling
|
||||
api.disconnectWebSocket();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { setActiveTheme, initThemeOnBoot, customThemeOption, getActiveTheme } from '../theme';
|
||||
|
||||
const lightCss = `:root[data-theme="papier"] { --bg-app: #fafafa; --accent-primary: #15803d; --radius-card: 8px; }`;
|
||||
|
||||
describe('custom themes', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.getElementById('mindsift-custom-theme')?.remove();
|
||||
});
|
||||
|
||||
it('injects the CSS live and uses its data-theme', () => {
|
||||
setActiveTheme('custom:thm_1', lightCss);
|
||||
const style = document.getElementById('mindsift-custom-theme');
|
||||
expect(style?.textContent).toBe(lightCss);
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('papier');
|
||||
// light background -> no Tailwind dark class
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores data-theme mentioned in comments (like in the template)', () => {
|
||||
setActiveTheme('custom:thm_2', `/* benenne [data-theme="mein-theme"] um */\n:root[data-theme="wald"] { --bg-app: #0b1a10; }`);
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('wald');
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
});
|
||||
|
||||
it('restores the active custom theme on boot from the cache', () => {
|
||||
setActiveTheme('custom:thm_1', lightCss);
|
||||
document.getElementById('mindsift-custom-theme')?.remove();
|
||||
expect(initThemeOnBoot()).toBe('custom:thm_1');
|
||||
expect(document.getElementById('mindsift-custom-theme')?.textContent).toBe(lightCss);
|
||||
});
|
||||
|
||||
it('removes the custom CSS when switching back to a built-in theme', () => {
|
||||
setActiveTheme('custom:thm_1', lightCss);
|
||||
setActiveTheme('dark');
|
||||
expect(document.getElementById('mindsift-custom-theme')).toBeNull();
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||
expect(getActiveTheme()).toBe('dark');
|
||||
});
|
||||
|
||||
it('falls back to dark when a custom theme has no CSS', () => {
|
||||
setActiveTheme('custom:gone');
|
||||
expect(getActiveTheme()).toBe('dark');
|
||||
});
|
||||
|
||||
it('builds gallery previews from the CSS variables', () => {
|
||||
const opt = customThemeOption({ id: 'thm_1', name: 'Papier', css: lightCss, createdAt: '', updatedAt: '' });
|
||||
expect(opt.id).toBe('custom:thm_1');
|
||||
expect(opt.previewBg).toBe('#fafafa');
|
||||
expect(opt.previewAccent).toBe('#15803d');
|
||||
expect(opt.isRounded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -17,17 +17,21 @@ import {
|
||||
SpaceMember,
|
||||
SpaceShare,
|
||||
SystemSettings,
|
||||
SharingMode,
|
||||
UserTheme,
|
||||
Team,
|
||||
TaskList,
|
||||
TaskListShare,
|
||||
Thought,
|
||||
SubTask,
|
||||
SearchResults,
|
||||
TagDetails,
|
||||
TagSummary,
|
||||
User,
|
||||
UserInvite,
|
||||
WSMessage,
|
||||
} from './types';
|
||||
import { extractHashtags } from './hashtags';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'mindsift_jwt_token';
|
||||
const OFFLINE_QUEUE_KEY = 'mindsift_offline_capture_queue';
|
||||
@@ -123,10 +127,25 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Server-wide sharing mode from /auth/status; in private mode the UI hides sharing and groups
|
||||
let currentSharingMode: SharingMode = 'organizations';
|
||||
export const setCurrentSharingMode = (mode: SharingMode) => {
|
||||
currentSharingMode = mode;
|
||||
};
|
||||
export const isSharingDisabled = () => currentSharingMode === 'private';
|
||||
|
||||
export const api = {
|
||||
// --- AUTH, FIRST-BOOT SETUP & SETTINGS ---
|
||||
async getAuthStatus(): Promise<AuthStatus> {
|
||||
return request<AuthStatus>('/api/v1/auth/status');
|
||||
const st = await request<AuthStatus>('/api/v1/auth/status');
|
||||
if (st.sharingMode) setCurrentSharingMode(st.sharingMode);
|
||||
return st;
|
||||
},
|
||||
|
||||
// Public, works before login
|
||||
async getLegalText(type: 'imprint' | 'privacy'): Promise<string> {
|
||||
const res = await request<{ content: string }>(`/api/v1/legal/${type}`);
|
||||
return res.content || '';
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
@@ -146,7 +165,7 @@ export const api = {
|
||||
password: string;
|
||||
displayName: string;
|
||||
instanceName?: string;
|
||||
sharingMode?: 'open' | 'teams_only' | 'strict_private';
|
||||
sharingMode?: SharingMode;
|
||||
allowRegistration?: boolean;
|
||||
createSampleData?: boolean;
|
||||
}): Promise<{ token: string; user: User }> {
|
||||
@@ -254,7 +273,7 @@ export const api = {
|
||||
id: `th_offline_${Date.now()}`,
|
||||
userId: 'offline',
|
||||
content,
|
||||
tags: (content.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g) || []).map((t) => t.slice(1).toLowerCase()),
|
||||
tags: Array.from(new Set(extractHashtags(content))),
|
||||
priority,
|
||||
source,
|
||||
status: 'inbox',
|
||||
@@ -361,6 +380,7 @@ export const api = {
|
||||
notes?: string;
|
||||
subtasks?: SubTask[];
|
||||
tags?: string[];
|
||||
assignees?: string[];
|
||||
}
|
||||
): Promise<Thought> {
|
||||
return request<Thought>(`/api/v1/thoughts/${thoughtId}`, {
|
||||
@@ -595,11 +615,10 @@ export const api = {
|
||||
title?: string;
|
||||
icon?: string;
|
||||
folderPath?: string;
|
||||
blocks?: DocumentBlock[];
|
||||
tags?: string[];
|
||||
isPinned?: boolean;
|
||||
}
|
||||
): Promise<Document> {
|
||||
// Block content is saved through the note sync engine (lib/docSync), not here
|
||||
const d = await request<Document>(`/api/v1/documents/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(patch),
|
||||
@@ -611,25 +630,6 @@ export const api = {
|
||||
};
|
||||
},
|
||||
|
||||
async patchDocumentBlocks(
|
||||
id: string,
|
||||
payload: {
|
||||
upserted?: DocumentBlock[];
|
||||
deletedIds?: string[];
|
||||
blockOrder?: string[];
|
||||
}
|
||||
): Promise<Document> {
|
||||
const d = await request<Document>(`/api/v1/documents/${id}/blocks`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return {
|
||||
...d,
|
||||
blocks: ensureArray(d.blocks),
|
||||
tags: ensureArray(d.tags),
|
||||
};
|
||||
},
|
||||
|
||||
async joinSSERoom(sseId: string, room: string, action: 'join' | 'leave' = 'join'): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>('/api/v1/events/rooms', {
|
||||
method: 'POST',
|
||||
@@ -848,41 +848,7 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
// --- ORGANIZATIONS & TEAMS ---
|
||||
async getOrganizations(): Promise<Organization[]> {
|
||||
const res = await request<Organization[]>('/api/v1/organizations');
|
||||
return ensureArray(res).map((o) => ({
|
||||
...o,
|
||||
teams: ensureArray(o.teams).map((t) => ({
|
||||
...t,
|
||||
members: ensureArray(t.members),
|
||||
})),
|
||||
}));
|
||||
},
|
||||
|
||||
async createOrganization(name: string): Promise<Organization> {
|
||||
return request<Organization>('/api/v1/organizations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteOrganization(id: string): Promise<void> {
|
||||
await request(`/api/v1/organizations/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async getOrgTeams(orgId: string): Promise<Team[]> {
|
||||
const res = await request<Team[]>(`/api/v1/organizations/${orgId}/teams`);
|
||||
return ensureArray(res).map((t) => ({ ...t, members: ensureArray(t.members) }));
|
||||
},
|
||||
|
||||
async createOrgTeam(orgId: string, name: string, description: string): Promise<Team> {
|
||||
return request<Team>(`/api/v1/organizations/${orgId}/teams`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, description }),
|
||||
});
|
||||
},
|
||||
|
||||
// --- GROUPS (user-managed) ---
|
||||
async createTeam(name: string, description = ''): Promise<Team> {
|
||||
return request<Team>('/api/v1/teams', {
|
||||
method: 'POST',
|
||||
@@ -942,6 +908,7 @@ export const api = {
|
||||
password: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
organizationId?: string;
|
||||
}): Promise<User> {
|
||||
return request<User>('/api/v1/admin/users', {
|
||||
method: 'POST',
|
||||
@@ -984,7 +951,7 @@ export const api = {
|
||||
async adminCreateInvite(payload: {
|
||||
email?: string;
|
||||
role: string;
|
||||
teamId?: string;
|
||||
organizationId?: string;
|
||||
}): Promise<UserInvite> {
|
||||
return request<UserInvite>('/api/v1/admin/invites', {
|
||||
method: 'POST',
|
||||
@@ -1007,6 +974,42 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
// Counts shares and group memberships a switch to `mode` would remove
|
||||
async adminSharingImpact(mode: SharingMode): Promise<number> {
|
||||
const res = await request<{ affected: number }>(`/api/v1/admin/settings/sharing-impact?mode=${mode}`);
|
||||
return res.affected || 0;
|
||||
},
|
||||
|
||||
// --- ORGANIZATIONS (server admin only) ---
|
||||
async adminGetOrganizations(): Promise<Organization[]> {
|
||||
return ensureArray(await request<Organization[]>('/api/v1/admin/organizations'));
|
||||
},
|
||||
|
||||
async adminCreateOrganization(name: string): Promise<Organization> {
|
||||
return request<Organization>('/api/v1/admin/organizations', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
},
|
||||
|
||||
async adminRenameOrganization(id: string, name: string): Promise<void> {
|
||||
await request(`/api/v1/admin/organizations/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
},
|
||||
|
||||
async adminDeleteOrganization(id: string): Promise<void> {
|
||||
await request(`/api/v1/admin/organizations/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async adminSetUserOrganization(userId: string, organizationId: string): Promise<void> {
|
||||
await request(`/api/v1/admin/users/${userId}/organization`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ organizationId }),
|
||||
});
|
||||
},
|
||||
|
||||
async adminTestSMTP(toEmail: string): Promise<{ status: string }> {
|
||||
return request<{ status: string }>('/api/v1/admin/smtp/test', {
|
||||
method: 'POST',
|
||||
@@ -1014,6 +1017,23 @@ export const api = {
|
||||
});
|
||||
},
|
||||
|
||||
// --- CUSTOM THEMES ---
|
||||
async getThemes(): Promise<UserTheme[]> {
|
||||
return ensureArray(await request<UserTheme[]>('/api/v1/themes'));
|
||||
},
|
||||
|
||||
// Creates a theme, or updates it when `id` is given
|
||||
async saveTheme(theme: { id?: string; name: string; css: string }): Promise<UserTheme> {
|
||||
return request<UserTheme>('/api/v1/themes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(theme),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteTheme(id: string): Promise<void> {
|
||||
await request(`/api/v1/themes/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- API KEYS, WEBHOOKS & EXPORT ---
|
||||
async getAPIKeys(): Promise<APIKey[]> {
|
||||
const res = await request<APIKey[]>('/api/v1/keys');
|
||||
@@ -1046,8 +1066,8 @@ export const api = {
|
||||
);
|
||||
},
|
||||
|
||||
async searchAll(q: string): Promise<{ thoughts: Thought[]; documents: Document[] }> {
|
||||
return request<{ thoughts: Thought[]; documents: Document[] }>(
|
||||
async searchAll(q: string): Promise<SearchResults> {
|
||||
return request<SearchResults>(
|
||||
`/api/v1/search?q=${encodeURIComponent(q)}`
|
||||
);
|
||||
},
|
||||
@@ -1055,6 +1075,17 @@ export const api = {
|
||||
async exportAll(): Promise<any> {
|
||||
return request<any>('/api/v1/export');
|
||||
},
|
||||
|
||||
// Obsidian-compatible ZIP of the workspace; binary and possibly large, so no JSON helper and no timeout
|
||||
async exportMarkdownVault(): Promise<Blob> {
|
||||
const token = getToken();
|
||||
const res = await fetch('/api/v1/export/markdown', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) throw new Error(`Export fehlgeschlagen (HTTP ${res.status})`);
|
||||
return res.blob();
|
||||
},
|
||||
};
|
||||
|
||||
export function getOfflineQueue(): Thought[] {
|
||||
@@ -1134,6 +1165,16 @@ export function onRealtimeStatus(callback: (connected: boolean, transport: Realt
|
||||
}
|
||||
|
||||
let isWsBlockedByProxy = typeof window !== 'undefined' && sessionStorage.getItem('mindsift_ws_blocked') === 'true';
|
||||
// Some proxies buffer event streams: the connection opens, but events only arrive much later (or never).
|
||||
// Then SSE is useless and the note sync engines fall back to long polling.
|
||||
let isSseBufferedByProxy = typeof window !== 'undefined' && sessionStorage.getItem('mindsift_sse_buffered') === 'true';
|
||||
let sseFirstEventWatchdog: any = null;
|
||||
const SSE_FIRST_EVENT_TIMEOUT_MS = 5000;
|
||||
// Some proxies accept the WebSocket upgrade but then forward nothing. An app-level ping detects that.
|
||||
let wsHeartbeatTimer: any = null;
|
||||
let wsLastMessageAt = 0;
|
||||
const WS_PING_INTERVAL_MS = 15000;
|
||||
const WS_DEAD_AFTER_MS = 35000;
|
||||
let sseReconnectTimer: any = null;
|
||||
let sseReconnectDelay = 2000;
|
||||
const globalSubscribedRooms = new Set<string>();
|
||||
@@ -1153,6 +1194,7 @@ function notifyRealtimeStatusChange(connected: boolean, transport: RealtimeTrans
|
||||
|
||||
function switchToSSE(reason: string) {
|
||||
console.warn(`[MindSift Realtime] ${reason} -> Schalte sofort und dauerhaft auf Server-Sent Events (SSE) um.`);
|
||||
clearInterval(wsHeartbeatTimer);
|
||||
isWsBlockedByProxy = true;
|
||||
try {
|
||||
sessionStorage.setItem('mindsift_ws_blocked', 'true');
|
||||
@@ -1177,6 +1219,12 @@ export function connectSSE(rooms?: string[]): void {
|
||||
clearTimeout(sseReconnectTimer);
|
||||
clearTimeout(wsReconnectTimer);
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
clearTimeout(sseFirstEventWatchdog);
|
||||
|
||||
if (isSseBufferedByProxy) {
|
||||
notifyRealtimeStatusChange(false, 'none');
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeGlobalSSE) {
|
||||
try { activeGlobalSSE.close(); } catch {}
|
||||
@@ -1190,18 +1238,28 @@ export function connectSSE(rooms?: string[]): void {
|
||||
|
||||
es.onopen = () => {
|
||||
sseReconnectDelay = 2000;
|
||||
notifyRealtimeStatusChange(true, 'sse');
|
||||
if (activeSSEId && globalSubscribedRooms.size > 0) {
|
||||
globalSubscribedRooms.forEach((room) => {
|
||||
api.joinSSERoom(activeSSEId!, room, 'join').catch(() => {});
|
||||
});
|
||||
}
|
||||
// Connected only counts once the first event really arrives (see SSE_CONNECTED below)
|
||||
clearTimeout(sseFirstEventWatchdog);
|
||||
sseFirstEventWatchdog = setTimeout(() => {
|
||||
console.warn('[MindSift Realtime] SSE liefert keine Ereignisse (Proxy puffert) -> Long-Polling');
|
||||
isSseBufferedByProxy = true;
|
||||
try {
|
||||
sessionStorage.setItem('mindsift_sse_buffered', 'true');
|
||||
} catch {}
|
||||
try {
|
||||
es.close();
|
||||
} catch {}
|
||||
if (activeGlobalSSE === es) activeGlobalSSE = null;
|
||||
notifyRealtimeStatusChange(false, 'none');
|
||||
}, SSE_FIRST_EVENT_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as WSMessage;
|
||||
if (msg.event === 'SSE_CONNECTED') {
|
||||
clearTimeout(sseFirstEventWatchdog);
|
||||
notifyRealtimeStatusChange(true, 'sse');
|
||||
activeSSEId = msg.payload?.sseId || null;
|
||||
if (activeSSEId && globalSubscribedRooms.size > 0) {
|
||||
globalSubscribedRooms.forEach((room) => {
|
||||
@@ -1223,6 +1281,7 @@ export function connectSSE(rooms?: string[]): void {
|
||||
};
|
||||
|
||||
es.onerror = (e) => {
|
||||
clearTimeout(sseFirstEventWatchdog);
|
||||
console.warn('[MindSift Realtime] SSE Verbindungsfehler:', e);
|
||||
if (activeTransport === 'sse') {
|
||||
notifyRealtimeStatusChange(false, 'none');
|
||||
@@ -1279,6 +1338,17 @@ export function connectWebSocket(): void {
|
||||
|
||||
ws.onopen = () => {
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
wsLastMessageAt = Date.now();
|
||||
clearInterval(wsHeartbeatTimer);
|
||||
wsHeartbeatTimer = setInterval(() => {
|
||||
if (Date.now() - wsLastMessageAt > WS_DEAD_AFTER_MS) {
|
||||
switchToSSE('WebSocket liefert keine Antworten mehr (Proxy leitet nichts weiter)');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ws.send(JSON.stringify({ event: 'PING' }));
|
||||
} catch {}
|
||||
}, WS_PING_INTERVAL_MS);
|
||||
if (activeGlobalSSE) {
|
||||
try { activeGlobalSSE.close(); } catch {}
|
||||
activeGlobalSSE = null;
|
||||
@@ -1295,8 +1365,10 @@ export function connectWebSocket(): void {
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
wsLastMessageAt = Date.now();
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as WSMessage;
|
||||
if (msg.event === 'PONG') return;
|
||||
wsListeners.forEach((fn) => {
|
||||
try {
|
||||
fn(msg);
|
||||
@@ -1312,6 +1384,7 @@ export function connectWebSocket(): void {
|
||||
|
||||
ws.onclose = (evt: CloseEvent) => {
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
clearInterval(wsHeartbeatTimer);
|
||||
activeGlobalWS = null;
|
||||
|
||||
// In corporate environments, blocked WebSockets close abruptly without clean handshake
|
||||
@@ -1368,6 +1441,8 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
|
||||
export function disconnectWebSocket(): void {
|
||||
clearInterval(wsHeartbeatTimer);
|
||||
clearTimeout(sseFirstEventWatchdog);
|
||||
manualDisconnect = true;
|
||||
clearTimeout(wsReconnectTimer);
|
||||
clearTimeout(wsConnectionWatchdog);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { DocumentBlock } from '../../types';
|
||||
import { diffFlat, toDocumentBlocks } from '../diff';
|
||||
import { applyEffect, applyOp, cloneDoc, docsEqual, emptyDoc, Doc } from '../model';
|
||||
|
||||
let opSeq = 0;
|
||||
const nextOp = () => `op${++opSeq}`;
|
||||
|
||||
function build(blocks: DocumentBlock[]): Doc {
|
||||
const d = emptyDoc();
|
||||
for (const op of diffFlat(d, blocks, nextOp)) applyOp(d, op);
|
||||
return d;
|
||||
}
|
||||
|
||||
const B = (id: string, level: number, content = id, extra: Partial<DocumentBlock> = {}): DocumentBlock => ({
|
||||
id,
|
||||
level,
|
||||
content,
|
||||
type: 'bullet',
|
||||
...extra,
|
||||
});
|
||||
|
||||
const shape = (blocks: DocumentBlock[]) => blocks.map((b) => [b.id, b.level || 0, b.content, !!b.checked, b.type]);
|
||||
|
||||
describe('diffFlat', () => {
|
||||
it('builds the tree from levels and produces no ops for an unchanged list', () => {
|
||||
const list = [B('a', 0), B('b', 1), B('c', 2), B('d', 1), B('e', 0)];
|
||||
const d = build(list);
|
||||
expect(shape(toDocumentBlocks(d, 'doc'))).toEqual(shape(list));
|
||||
expect(diffFlat(d, toDocumentBlocks(d, 'doc'), nextOp)).toEqual([]);
|
||||
});
|
||||
|
||||
it('turns typing into a single set_content without moves', () => {
|
||||
const d = build([B('a', 0), B('b', 0), B('c', 0)]);
|
||||
const ops = diffFlat(d, [B('a', 0), B('b', 0, 'b geändert'), B('c', 0)], nextOp);
|
||||
expect(ops.map((o) => o.kind)).toEqual(['set_content']);
|
||||
});
|
||||
|
||||
it('inserting in the middle does not move the neighbours', () => {
|
||||
const d = build([B('a', 0), B('c', 0)]);
|
||||
const ops = diffFlat(d, [B('a', 0), B('b', 0), B('c', 0)], nextOp);
|
||||
expect(ops.map((o) => o.kind)).toEqual(['insert']);
|
||||
});
|
||||
|
||||
it('reaches any target list (random edits)', () => {
|
||||
let seed = 99;
|
||||
const rnd = (n: number) => {
|
||||
seed = (seed * 1103515245 + 12345) % 2147483648;
|
||||
return Math.floor((seed / 2147483648) * n);
|
||||
};
|
||||
let current: DocumentBlock[] = [B('x0', 0)];
|
||||
let d = build(current);
|
||||
for (let step = 0; step < 300; step++) {
|
||||
const next = current.map((b) => ({ ...b }));
|
||||
const i = rnd(next.length);
|
||||
switch (rnd(6)) {
|
||||
case 0:
|
||||
next.splice(i + 1, 0, B(`x${step + 1}`, next[i] ? next[i].level || 0 : 0));
|
||||
break;
|
||||
case 1:
|
||||
if (next.length > 1) next.splice(i, 1);
|
||||
break;
|
||||
case 2:
|
||||
next[i].content += '!';
|
||||
break;
|
||||
case 3:
|
||||
next[i].level = (next[i].level || 0) + 1;
|
||||
break;
|
||||
case 4:
|
||||
next[i].level = Math.max(0, (next[i].level || 0) - 1);
|
||||
break;
|
||||
case 5: {
|
||||
const [moved] = next.splice(i, 1);
|
||||
next.splice(rnd(next.length + 1), 0, moved);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Normalise like the editor would (levels can only grow by one)
|
||||
let prev = -1;
|
||||
for (const b of next) {
|
||||
b.level = Math.min(b.level || 0, prev + 1);
|
||||
prev = b.level;
|
||||
}
|
||||
const before = cloneDoc(d);
|
||||
const replay = cloneDoc(d);
|
||||
for (const op of diffFlat(d, next, nextOp)) {
|
||||
const { effects } = applyOp(d, op);
|
||||
effects.forEach((e) => applyEffect(replay, e));
|
||||
}
|
||||
expect(shape(toDocumentBlocks(d, 'doc'))).toEqual(shape(next));
|
||||
expect(docsEqual(d, replay)).toBe(true);
|
||||
expect(before).not.toBe(d);
|
||||
current = next;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,529 @@
|
||||
import 'fake-indexeddb/auto';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { DocumentBlock } from '../../types';
|
||||
import { DocSyncEngine, formBatch } from '../engine';
|
||||
import { applyEffect, applyOp, cloneDoc, Doc, Effect, emptyDoc, Op, Result } from '../model';
|
||||
import { IndexedDBStorage, MemoryStorage } from '../persist';
|
||||
import { PushChannel, PushKind } from '../push';
|
||||
import { LogEntry, SnapshotResponse, SyncRequest, SyncResponse, Transport, TransportError } from '../transport';
|
||||
|
||||
/** In-memory stand-in for the Go server: same operation rules, log and idempotency. */
|
||||
class FakeServer {
|
||||
doc: Doc = emptyDoc();
|
||||
log: LogEntry[] = [];
|
||||
results = new Map<string, Result>();
|
||||
roles = new Map<string, string>(); // clientId -> role
|
||||
applyCount = new Map<string, number>();
|
||||
floor = 0; // log entries up to here were compacted away
|
||||
private waiters: (() => void)[] = [];
|
||||
|
||||
/** Resolves on the next change (used by the long-poll stand-in). */
|
||||
nextChange(): Promise<void> {
|
||||
return new Promise((r) => this.waiters.push(r));
|
||||
}
|
||||
|
||||
constructor(initial: { id: string; content: string }[] = [{ id: 'b1', content: 'Start' }]) {
|
||||
let key = 'a0';
|
||||
for (const b of initial) {
|
||||
this.apply({ opId: `seed-${b.id}`, kind: 'insert', blockId: b.id, parentId: '', orderKey: key, content: b.content }, 'seed');
|
||||
key = String.fromCharCode(key.charCodeAt(0)) + String.fromCharCode(key.charCodeAt(1) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
apply(op: Op, clientId: string): Result {
|
||||
const known = this.results.get(op.opId);
|
||||
if (known) return known;
|
||||
this.applyCount.set(op.opId, (this.applyCount.get(op.opId) || 0) + 1);
|
||||
const { effects, result } = applyOp(this.doc, op, clientId);
|
||||
for (const e of effects) this.log.push({ seq: this.log.length + 1, userId: clientId, clientId, opId: op.opId, effect: e });
|
||||
if (effects.length > 0) result.seq = this.log.length;
|
||||
this.results.set(op.opId, result);
|
||||
if (effects.length > 0) {
|
||||
const w = this.waiters;
|
||||
this.waiters = [];
|
||||
w.forEach((fn) => fn());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Log compaction: purges the given tombstones (as a log entry) and drops all older log entries. */
|
||||
compact(purge: string[]) {
|
||||
this.floor = this.log.length;
|
||||
const effect: Effect = { kind: 'purge', ids: purge };
|
||||
applyEffect(this.doc, effect);
|
||||
this.log.push({ seq: this.log.length + 1, userId: '', clientId: '', opId: '', effect });
|
||||
}
|
||||
|
||||
snapshot(): SnapshotResponse {
|
||||
return {
|
||||
docId: 'doc',
|
||||
title: this.doc.title,
|
||||
titleVersion: this.doc.titleVersion,
|
||||
headSeq: this.log.length,
|
||||
blocks: Array.from(cloneDoc(this.doc).blocks.values()),
|
||||
role: 'owner',
|
||||
shared: true,
|
||||
};
|
||||
}
|
||||
|
||||
sync(body: SyncRequest): SyncResponse {
|
||||
const role = this.roles.get(body.clientId) || 'owner';
|
||||
if (body.ops.length > 0 && role === 'viewer') throw new TransportError('nur Lesen', 403);
|
||||
const results = body.ops.map((op) => this.apply(op, body.clientId));
|
||||
const compacted = body.lastSeq < this.floor;
|
||||
return {
|
||||
results,
|
||||
resync: compacted || undefined,
|
||||
ops: compacted ? [] : this.log.filter((e) => e.seq > body.lastSeq),
|
||||
headSeq: this.log.length,
|
||||
hasMore: false,
|
||||
role,
|
||||
shared: true,
|
||||
sessions: 2,
|
||||
presence: [],
|
||||
leases: { granted: [], denied: [], others: [] },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Network between one client and the fake server, with switchable failures. */
|
||||
class FakeNet implements Transport {
|
||||
mode: 'ok' | 'down' | 'lose-response' = 'ok';
|
||||
longPollDown = false;
|
||||
changesCalls = 0;
|
||||
duringRequest: (() => void) | null = null; // runs while the request is "on the wire"
|
||||
|
||||
constructor(private server: FakeServer, private role = 'owner') {}
|
||||
async snapshot() {
|
||||
if (this.mode === 'down') throw new TransportError('offline', 0);
|
||||
return { ...this.server.snapshot(), role: this.role };
|
||||
}
|
||||
async sync(_docId: string, body: SyncRequest) {
|
||||
if (this.mode === 'down') throw new TransportError('offline', 0);
|
||||
this.server.roles.set(body.clientId, this.role);
|
||||
const hook = this.duringRequest;
|
||||
this.duringRequest = null;
|
||||
hook?.();
|
||||
const res = this.server.sync(body);
|
||||
if (this.mode === 'lose-response') throw new TransportError('timeout', 0);
|
||||
return res;
|
||||
}
|
||||
async leave() {}
|
||||
async changes(_docId: string, since: number, waitSec: number, signal?: AbortSignal) {
|
||||
this.changesCalls++;
|
||||
if (this.mode === 'down' || this.longPollDown) throw new TransportError('blocked', 0);
|
||||
if (this.server.log.length === since) {
|
||||
await Promise.race([
|
||||
this.server.nextChange(),
|
||||
new Promise((r) => setTimeout(r, waitSec * 1000)),
|
||||
new Promise((_, reject) => signal?.addEventListener('abort', () => reject(new Error('aborted')))),
|
||||
]);
|
||||
}
|
||||
return { headSeq: this.server.log.length, changed: this.server.log.length !== since };
|
||||
}
|
||||
}
|
||||
|
||||
/** Push channel controlled by the test. */
|
||||
class FakePush implements PushChannel {
|
||||
kind: PushKind = 'none';
|
||||
private poke: (() => void) | null = null;
|
||||
private status: ((k: PushKind) => void) | null = null;
|
||||
subscribe(_docId: string, onPoke: () => void, onStatus: (k: PushKind) => void) {
|
||||
this.poke = onPoke;
|
||||
this.status = onStatus;
|
||||
onStatus(this.kind);
|
||||
return () => {
|
||||
this.poke = null;
|
||||
};
|
||||
}
|
||||
setKind(k: PushKind) {
|
||||
this.kind = k;
|
||||
this.status?.(k);
|
||||
}
|
||||
notify() {
|
||||
this.poke?.();
|
||||
}
|
||||
}
|
||||
|
||||
function flatOf(engine: DocSyncEngine): DocumentBlock[] {
|
||||
return engine.getState().blocks;
|
||||
}
|
||||
|
||||
function edit(engine: DocSyncEngine, fn: (blocks: DocumentBlock[]) => DocumentBlock[]) {
|
||||
engine.applyFlat(fn(flatOf(engine).map((b) => ({ ...b }))));
|
||||
}
|
||||
|
||||
const contents = (engine: DocSyncEngine) => flatOf(engine).map((b) => b.content);
|
||||
const serverContents = (server: FakeServer) => docContents(server.doc);
|
||||
function docContents(d: Doc): string[] {
|
||||
return Array.from(d.blocks.values())
|
||||
.filter((b) => !b.deleted)
|
||||
.sort((a, b) => (a.orderKey < b.orderKey ? -1 : a.orderKey > b.orderKey ? 1 : a.id < b.id ? -1 : 1))
|
||||
.map((b) => b.content);
|
||||
}
|
||||
|
||||
async function startEngine(net: Transport, storage = new MemoryStorage()) {
|
||||
const engine = new DocSyncEngine('doc', { transport: net, storage, manual: true });
|
||||
await engine.start();
|
||||
return engine;
|
||||
}
|
||||
|
||||
describe('DocSyncEngine', () => {
|
||||
it('sends edits and confirms them', async () => {
|
||||
const server = new FakeServer();
|
||||
const engine = await startEngine(new FakeNet(server));
|
||||
edit(engine, (b) => {
|
||||
b[0].content = 'Hallo';
|
||||
return [...b, { id: 'b2', type: 'bullet', content: 'Neu', level: 1 }];
|
||||
});
|
||||
expect(engine.getState().pendingCount).toBeGreaterThan(0);
|
||||
await engine.syncNow();
|
||||
expect(engine.getState().status).toBe('saved');
|
||||
expect(engine.getState().pendingCount).toBe(0);
|
||||
expect(serverContents(server)).toEqual(['Hallo', 'Neu']);
|
||||
});
|
||||
|
||||
it('coalesces fast typing into one text operation', async () => {
|
||||
const server = new FakeServer();
|
||||
const engine = await startEngine(new FakeNet(server));
|
||||
for (const text of ['H', 'Ha', 'Hal', 'Hall', 'Hallo']) edit(engine, (b) => [{ ...b[0], content: text }]);
|
||||
await engine.syncNow();
|
||||
const textOps = server.log.filter((e) => e.effect.kind === 'set_content');
|
||||
expect(textOps).toHaveLength(1);
|
||||
expect(textOps[0].effect.content).toBe('Hallo');
|
||||
});
|
||||
|
||||
it('keeps edits through an offline phase and a reload, and applies them exactly once', async () => {
|
||||
const server = new FakeServer();
|
||||
const storage = new MemoryStorage();
|
||||
const net = new FakeNet(server);
|
||||
const first = await startEngine(net, storage);
|
||||
|
||||
net.mode = 'down';
|
||||
edit(first, (b) => [{ ...b[0], content: 'Offline getippt' }, { id: 'b9', type: 'bullet', content: 'Noch einer', level: 0 }]);
|
||||
await new Promise((r) => setTimeout(r, 0)); // let the queue reach storage
|
||||
await first.syncNow();
|
||||
expect(first.getState().status).toBe('offline');
|
||||
expect(contents(first)).toEqual(['Offline getippt', 'Noch einer']);
|
||||
|
||||
// Tab is closed while offline; a new tab opens the note from storage, still offline
|
||||
const second = await startEngine(net, storage);
|
||||
expect(contents(second)).toEqual(['Offline getippt', 'Noch einer']);
|
||||
|
||||
net.mode = 'ok';
|
||||
await second.syncNow();
|
||||
await second.syncNow();
|
||||
expect(serverContents(server)).toEqual(['Offline getippt', 'Noch einer']);
|
||||
for (const count of server.applyCount.values()) expect(count).toBe(1);
|
||||
});
|
||||
|
||||
it('a client offline for weeks reloads after log compaction and keeps its unsent text', async () => {
|
||||
const server = new FakeServer([
|
||||
{ id: 'b1', content: 'Start' },
|
||||
{ id: 'b2', content: 'Wird gelöscht' },
|
||||
]);
|
||||
const netA = new FakeNet(server);
|
||||
const netB = new FakeNet(server);
|
||||
const a = await startEngine(netA);
|
||||
const b = await startEngine(netB);
|
||||
|
||||
netB.mode = 'down';
|
||||
edit(b, (blocks) => [{ ...blocks[0], content: 'Start (B)' }, { ...blocks[1], content: 'B tippt weiter' }]);
|
||||
await b.syncNow();
|
||||
|
||||
// Meanwhile A deletes b2, and weeks later the tombstone is purged and the log compacted
|
||||
edit(a, (blocks) => [blocks[0]]);
|
||||
await a.syncNow();
|
||||
server.compact(['b2']);
|
||||
await a.syncNow();
|
||||
expect(contents(a)).toEqual(['Start']);
|
||||
|
||||
netB.mode = 'ok';
|
||||
await b.syncNow();
|
||||
await b.syncNow();
|
||||
// Nothing typed offline is lost: the edit of the purged block comes back as a new block
|
||||
expect(serverContents(server)).toEqual(['Start (B)', 'B tippt weiter']);
|
||||
expect(contents(b)).toEqual(['Start (B)', 'B tippt weiter']);
|
||||
await a.syncNow();
|
||||
expect(contents(a)).toEqual(['Start (B)', 'B tippt weiter']);
|
||||
});
|
||||
|
||||
it('retries after a lost response without applying twice', async () => {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
const engine = await startEngine(net);
|
||||
edit(engine, (b) => [...b, { id: 'b2', type: 'bullet', content: 'Einmal', level: 0 }]);
|
||||
net.mode = 'lose-response';
|
||||
await engine.syncNow();
|
||||
expect(engine.getState().status).toBe('offline');
|
||||
net.mode = 'ok';
|
||||
await engine.syncNow();
|
||||
expect(serverContents(server)).toEqual(['Start', 'Einmal']);
|
||||
expect(server.log.filter((e) => e.effect.kind === 'insert' && e.effect.blockId === 'b2')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('two clients converge, and a conflict keeps both texts', async () => {
|
||||
const server = new FakeServer();
|
||||
const a = await startEngine(new FakeNet(server));
|
||||
const b = await startEngine(new FakeNet(server));
|
||||
|
||||
edit(a, (blocks) => [{ ...blocks[0], content: 'Von A' }]);
|
||||
edit(b, (blocks) => [{ ...blocks[0], content: 'Von B' }]);
|
||||
await a.syncNow();
|
||||
await b.syncNow(); // B's base version is stale -> conflict copy
|
||||
await a.syncNow();
|
||||
|
||||
expect(contents(a)).toEqual(contents(b));
|
||||
expect(contents(a)).toEqual(['Von A', 'Von B']);
|
||||
expect(flatOf(a)[1].conflictOf).toBe('b1');
|
||||
expect(b.getState().conflicts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps text when two clients create the same first block of an empty note', async () => {
|
||||
const server = new FakeServer([]);
|
||||
const a = await startEngine(new FakeNet(server));
|
||||
const b = await startEngine(new FakeNet(server));
|
||||
a.applyFlat([{ id: 'doc-b0', type: 'text', content: 'Text von A', level: 0 }]);
|
||||
b.applyFlat([{ id: 'doc-b0', type: 'text', content: 'Text von B', level: 0 }]);
|
||||
await a.syncNow();
|
||||
await b.syncNow(); // insert rejected: block exists
|
||||
await b.syncNow(); // text resent as set_content -> conflict copy
|
||||
await a.syncNow();
|
||||
expect(contents(a)).toEqual(contents(b));
|
||||
expect(contents(a)).toEqual(expect.arrayContaining(['Text von A', 'Text von B']));
|
||||
});
|
||||
|
||||
it('does not overwrite a change that arrived while the user kept typing', async () => {
|
||||
const server = new FakeServer([
|
||||
{ id: 'b1', content: 'Start' },
|
||||
{ id: 'b2', content: 'Anderer Punkt' },
|
||||
]);
|
||||
const netA = new FakeNet(server);
|
||||
const a = await startEngine(netA);
|
||||
const b = await startEngine(new FakeNet(server));
|
||||
|
||||
edit(a, (blocks) => [blocks[0], { ...blocks[1], content: 'Anderer Punkt!' }]);
|
||||
netA.duringRequest = () => {
|
||||
// While A's request travels: B's change of b1 reaches the server first, and A starts typing into
|
||||
// b1 (still seeing 'Start')
|
||||
server.apply({ opId: 'b-op', kind: 'set_content', blockId: 'b1', baseVersion: 1, content: 'B war schneller' }, 'cB');
|
||||
edit(a, (blocks) => [{ ...blocks[0], content: 'Start + A' }, blocks[1]]);
|
||||
};
|
||||
await a.syncNow(); // response brings B's change; A's text for b1 is still pending
|
||||
await a.syncNow();
|
||||
await b.syncNow();
|
||||
|
||||
expect(contents(a)).toEqual(contents(b));
|
||||
expect(contents(a)).toEqual(expect.arrayContaining(['B war schneller', 'Start + A', 'Anderer Punkt!']));
|
||||
});
|
||||
|
||||
it('a stale focused text field cannot overwrite a newer remote version', async () => {
|
||||
const server = new FakeServer();
|
||||
const a = await startEngine(new FakeNet(server));
|
||||
const b = await startEngine(new FakeNet(server));
|
||||
|
||||
a.focusBlock('b1'); // A's cursor stays in the block; its field keeps showing 'Start'
|
||||
edit(b, (blocks) => [{ ...blocks[0], content: 'B hat geändert' }]);
|
||||
await b.syncNow();
|
||||
await a.syncNow(); // A receives B's version, but the focused field still shows the old text
|
||||
|
||||
a.applyFlat([{ id: 'b1', type: 'bullet', content: 'Start + A', level: 0 }]);
|
||||
await a.syncNow();
|
||||
await b.syncNow();
|
||||
|
||||
expect(contents(a)).toEqual(contents(b));
|
||||
expect(contents(a)).toEqual(expect.arrayContaining(['B hat geändert', 'Start + A']));
|
||||
});
|
||||
|
||||
it('viewers cannot change anything', async () => {
|
||||
const server = new FakeServer();
|
||||
const viewer = await startEngine(new FakeNet(server, 'viewer'));
|
||||
edit(viewer, (b) => [{ ...b[0], content: 'Leser' }]);
|
||||
await viewer.syncNow();
|
||||
expect(viewer.getState().pendingCount).toBe(0);
|
||||
expect(serverContents(server)).toEqual(['Start']);
|
||||
});
|
||||
|
||||
it('changes of others arrive within seconds right after reopening a shared note from local storage', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
net.longPollDown = true; // worst case: only plain polling works
|
||||
// Opened before: the note is reopened from local storage (like after a reload)
|
||||
const storage = new MemoryStorage();
|
||||
const first = new DocSyncEngine('doc', { transport: net, storage, manual: true });
|
||||
await first.start();
|
||||
first.stop();
|
||||
const engine = new DocSyncEngine('doc', { transport: net, storage, push: new FakePush() });
|
||||
await engine.start();
|
||||
await vi.advanceTimersByTimeAsync(5_000); // long polling gives up after three attempts
|
||||
server.apply({ opId: 'remote', kind: 'set_content', blockId: 'b1', baseVersion: 1, content: 'Von anderen' }, 'other');
|
||||
await vi.advanceTimersByTimeAsync(3_500);
|
||||
expect(contents(engine)).toEqual(['Von anderen']); // not only after 30 s
|
||||
engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('formBatch keeps the last text per block with the base of the first', () => {
|
||||
const batch = formBatch([
|
||||
{ opId: '1', kind: 'set_content', blockId: 'a', content: 'x', baseVersion: 5 },
|
||||
{ opId: '2', kind: 'set_title', title: 'T1', baseVersion: 3 },
|
||||
{ opId: '3', kind: 'set_content', blockId: 'a', content: 'xy', baseVersion: 6 },
|
||||
{ opId: '4', kind: 'set_title', title: 'T2', baseVersion: 4 },
|
||||
]);
|
||||
expect(batch).toEqual([
|
||||
{ opId: '3', kind: 'set_content', blockId: 'a', content: 'xy', baseVersion: 5 },
|
||||
{ opId: '4', kind: 'set_title', title: 'T2', baseVersion: 3 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('stores state in IndexedDB', async () => {
|
||||
const storage = new IndexedDBStorage(indexedDB);
|
||||
await storage.save({ docId: 'd', title: 'T', titleVersion: 1, lastSeq: 7, role: 'owner', blocks: [], pending: [], inflight: null, savedAt: 1 });
|
||||
const loaded = await storage.load('d');
|
||||
expect(loaded?.lastSeq).toBe(7);
|
||||
expect(await storage.load('missing')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('how changes of others arrive', () => {
|
||||
async function live(push: FakePush, net: FakeNet) {
|
||||
const engine = new DocSyncEngine('doc', { transport: net, storage: new MemoryStorage(), push });
|
||||
await engine.start();
|
||||
await vi.advanceTimersByTimeAsync(100); // let the start-up round trips settle
|
||||
return engine;
|
||||
}
|
||||
const otherEdit = (server: FakeServer, text: string) =>
|
||||
server.apply({ opId: `other-${text}`, kind: 'set_content', blockId: 'b1', baseVersion: server.doc.blocks.get('b1')!.version, content: text }, 'other');
|
||||
|
||||
it('push: syncs on a notification and otherwise only checks every 30 s', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
const push = new FakePush();
|
||||
push.kind = 'ws';
|
||||
const engine = await live(push, net);
|
||||
expect(engine.getState().channel).toBe('ws');
|
||||
otherEdit(server, 'Von anderen');
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(contents(engine)).toEqual(['Start']); // no notification, no poll yet
|
||||
push.notify();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(contents(engine)).toEqual(['Von anderen']);
|
||||
expect(net.changesCalls).toBe(0);
|
||||
engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('push: renews leases every 7 s while a block is held', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
const push = new FakePush();
|
||||
push.kind = 'sse';
|
||||
const engine = await live(push, net);
|
||||
let syncs = 0;
|
||||
const orig = net.sync.bind(net);
|
||||
net.sync = async (...a: Parameters<typeof orig>) => {
|
||||
syncs++;
|
||||
return orig(...a);
|
||||
};
|
||||
engine.focusBlock('b1');
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(syncs).toBeGreaterThanOrEqual(2);
|
||||
engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('without push: long polling delivers changes right away', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
const engine = await live(new FakePush(), net);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(engine.getState().channel).toBe('longpoll');
|
||||
otherEdit(server, 'Per Long-Polling');
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
expect(contents(engine)).toEqual(['Per Long-Polling']);
|
||||
engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to polling every 3 s when long polling is blocked too', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
net.longPollDown = true;
|
||||
const engine = await live(new FakePush(), net);
|
||||
await vi.advanceTimersByTimeAsync(5_000); // three failed attempts with back-off
|
||||
expect(engine.getState().channel).toBe('interval');
|
||||
otherEdit(server, 'Per Abfrage');
|
||||
await vi.advanceTimersByTimeAsync(3_500);
|
||||
expect(contents(engine)).toEqual(['Per Abfrage']);
|
||||
engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('push: requests the lease right after focusing a block', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
const push = new FakePush();
|
||||
push.kind = 'ws';
|
||||
const engine = await live(push, net);
|
||||
const holds: string[][] = [];
|
||||
const orig = net.sync.bind(net);
|
||||
net.sync = async (id: string, body: SyncRequest, o?: { keepalive?: boolean }) => {
|
||||
holds.push(body.leases?.hold || []);
|
||||
return orig(id, body, o);
|
||||
};
|
||||
engine.focusBlock('b1');
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(holds.some((h) => h.includes('b1'))).toBe(true);
|
||||
engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('switches between push and long polling when the connection changes', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
const push = new FakePush();
|
||||
push.kind = 'ws';
|
||||
const engine = await live(push, net);
|
||||
push.setKind('none');
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(engine.getState().channel).toBe('longpoll');
|
||||
push.setKind('ws');
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
expect(engine.getState().channel).toBe('ws');
|
||||
otherEdit(server, 'Wieder per Push');
|
||||
push.notify();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(contents(engine)).toEqual(['Wieder per Push']);
|
||||
engine.stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { keyBetween, keysBetween, validOrderKey } from '../orderKey';
|
||||
|
||||
// Generated with internal/collab (Go); both implementations must agree exactly.
|
||||
const VECTORS: [string, string, string][] = [
|
||||
['', '', 'a0'],
|
||||
['a0', '', 'a1'],
|
||||
['', 'a0', 'Zz'],
|
||||
['a0', 'a1', 'a0V'],
|
||||
['a0', 'a0V', 'a0F'],
|
||||
['a0V', 'a1', 'a0k'],
|
||||
['Zz', 'a0', 'ZzV'],
|
||||
['az', '', 'b00'],
|
||||
['', 'Zz', 'Zy'],
|
||||
['a1', 'a1V', 'a1F'],
|
||||
['b00', 'b01', 'b00V'],
|
||||
];
|
||||
|
||||
describe('order keys', () => {
|
||||
it('match the Go implementation', () => {
|
||||
for (const [a, b, want] of VECTORS) expect(keyBetween(a, b)).toBe(want);
|
||||
expect(keysBetween('a0', 'a1', 5)).toEqual(['a07', 'a0F', 'a0V', 'a0c', 'a0k']);
|
||||
let last = '';
|
||||
for (let i = 0; i < 70; i++) last = keyBetween(last, '');
|
||||
expect(last).toBe('b07');
|
||||
});
|
||||
|
||||
it('rejects invalid ranges and keys', () => {
|
||||
expect(() => keyBetween('zz', '')).toThrow();
|
||||
expect(() => keyBetween('a1', 'a0')).toThrow();
|
||||
expect(validOrderKey('a0V0')).toBe(false);
|
||||
});
|
||||
|
||||
it('stays sorted under random inserts', () => {
|
||||
const keys: string[] = [];
|
||||
let seed = 7;
|
||||
const rnd = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648;
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const pos = Math.floor(rnd() * (keys.length + 1));
|
||||
keys.splice(pos, 0, keyBetween(keys[pos - 1] || '', keys[pos] || ''));
|
||||
}
|
||||
const sorted = [...keys].sort();
|
||||
expect(keys).toEqual(sorted);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { DocumentBlock } from '../../types';
|
||||
import { DocSyncEngine } from '../engine';
|
||||
import { applyOp, Doc, emptyDoc, Op, Result } from '../model';
|
||||
import { MemoryStorage } from '../persist';
|
||||
import { LogEntry, SyncRequest, Transport, TransportError } from '../transport';
|
||||
|
||||
/** Minimal stand-in server (same rules as the Go server, plus configurable leases of others). */
|
||||
class Server {
|
||||
doc: Doc = emptyDoc();
|
||||
log: LogEntry[] = [];
|
||||
results = new Map<string, Result>();
|
||||
othersLeases: { blockId: string; userId: string; name: string }[] = [];
|
||||
|
||||
constructor(blocks: [string, string][]) {
|
||||
blocks.forEach(([id, content], i) => this.apply({ opId: `seed${i}`, kind: 'insert', blockId: id, parentId: '', orderKey: `a${i}`, content }, 'seed'));
|
||||
}
|
||||
apply(op: Op, by: string): Result {
|
||||
const known = this.results.get(op.opId);
|
||||
if (known) return known;
|
||||
const { effects, result } = applyOp(this.doc, op, by);
|
||||
for (const e of effects) this.log.push({ seq: this.log.length + 1, userId: by, clientId: by, opId: op.opId, effect: e });
|
||||
if (effects.length) result.seq = this.log.length;
|
||||
this.results.set(op.opId, result);
|
||||
return result;
|
||||
}
|
||||
transport(): Transport {
|
||||
return {
|
||||
snapshot: async () => ({
|
||||
docId: 'd',
|
||||
title: this.doc.title,
|
||||
titleVersion: this.doc.titleVersion,
|
||||
headSeq: this.log.length,
|
||||
blocks: Array.from(this.doc.blocks.values()).map((b) => ({ ...b, props: { ...b.props } })),
|
||||
role: 'owner',
|
||||
shared: true,
|
||||
}),
|
||||
sync: async (_id: string, body: SyncRequest) => {
|
||||
if (!body) throw new TransportError('x', 0);
|
||||
const results = body.ops.map((op) => this.apply(op, body.clientId));
|
||||
return {
|
||||
results,
|
||||
ops: this.log.filter((e) => e.seq > body.lastSeq),
|
||||
headSeq: this.log.length,
|
||||
hasMore: false,
|
||||
role: 'owner',
|
||||
shared: true,
|
||||
sessions: 2,
|
||||
presence: [],
|
||||
leases: { granted: [], denied: [], others: this.othersLeases },
|
||||
};
|
||||
},
|
||||
leave: async () => {},
|
||||
changes: async () => ({ headSeq: this.log.length, changed: false }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let clock = 1_000_000;
|
||||
async function engineFor(server: Server) {
|
||||
const e = new DocSyncEngine('d', { transport: server.transport(), storage: new MemoryStorage(), manual: true, now: () => clock });
|
||||
await e.start();
|
||||
return e;
|
||||
}
|
||||
const flat = (e: DocSyncEngine) => e.getState().blocks;
|
||||
const texts = (e: DocSyncEngine) => flat(e).map((b) => b.content);
|
||||
function edit(e: DocSyncEngine, fn: (b: DocumentBlock[]) => DocumentBlock[]) {
|
||||
e.applyFlat(fn(flat(e).map((b) => ({ ...b }))));
|
||||
}
|
||||
|
||||
describe('undo/redo', () => {
|
||||
it('groups typing in one block and reverts it in one step', async () => {
|
||||
const server = new Server([['a', 'Hallo']]);
|
||||
const e = await engineFor(server);
|
||||
edit(e, (b) => [{ ...b[0], content: 'Hallo W' }]);
|
||||
clock += 300;
|
||||
edit(e, (b) => [{ ...b[0], content: 'Hallo Welt' }]);
|
||||
expect(e.undo().ok).toBe(true);
|
||||
expect(texts(e)).toEqual(['Hallo']);
|
||||
expect(e.getState().canUndo).toBe(false);
|
||||
expect(e.redo().ok).toBe(true);
|
||||
expect(texts(e)).toEqual(['Hallo Welt']);
|
||||
await e.syncNow();
|
||||
expect(server.doc.blocks.get('a')!.content).toBe('Hallo Welt');
|
||||
});
|
||||
|
||||
it('separates typing steps after a pause', async () => {
|
||||
const server = new Server([['a', '']]);
|
||||
const e = await engineFor(server);
|
||||
edit(e, (b) => [{ ...b[0], content: 'Eins' }]);
|
||||
clock += 2000;
|
||||
edit(e, (b) => [{ ...b[0], content: 'Eins Zwei' }]);
|
||||
e.undo();
|
||||
expect(texts(e)).toEqual(['Eins']);
|
||||
});
|
||||
|
||||
it('undoes inserting and deleting blocks', async () => {
|
||||
const server = new Server([['a', 'Erster']]);
|
||||
const e = await engineFor(server);
|
||||
edit(e, (b) => [...b, { id: 'n', type: 'bullet', content: 'Neu', level: 0 }]);
|
||||
clock += 2000;
|
||||
edit(e, (b) => b.filter((x) => x.id !== 'a'));
|
||||
expect(texts(e)).toEqual(['Neu']);
|
||||
e.undo();
|
||||
expect(texts(e)).toEqual(['Erster', 'Neu']);
|
||||
e.undo();
|
||||
expect(texts(e)).toEqual(['Erster']);
|
||||
await e.syncNow();
|
||||
expect(server.doc.blocks.get('a')!.deleted).toBe(false);
|
||||
expect(server.doc.blocks.get('n')!.deleted).toBe(true);
|
||||
});
|
||||
|
||||
it('skips undo when someone else changed the block in between', async () => {
|
||||
const server = new Server([['a', 'Start']]);
|
||||
const e = await engineFor(server);
|
||||
edit(e, (b) => [{ ...b[0], content: 'Meins' }]);
|
||||
await e.syncNow();
|
||||
server.apply({ opId: 'other', kind: 'set_content', blockId: 'a', baseVersion: 2, content: 'Fremd' }, 'other');
|
||||
await e.syncNow();
|
||||
const res = e.undo();
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.message).toMatch(/jemand anderem/);
|
||||
expect(texts(e)).toEqual(['Fremd']);
|
||||
});
|
||||
|
||||
it('skips undo on a block someone else is editing', async () => {
|
||||
const server = new Server([['a', 'Start']]);
|
||||
const e = await engineFor(server);
|
||||
edit(e, (b) => [{ ...b[0], content: 'Meins' }]);
|
||||
await e.syncNow();
|
||||
server.othersLeases = [{ blockId: 'a', userId: 'u2', name: 'Anna' }];
|
||||
await e.syncNow();
|
||||
const res = e.undo();
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.message).toMatch(/Anna/);
|
||||
expect(texts(e)).toEqual(['Meins']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conflict copies', () => {
|
||||
async function withConflict() {
|
||||
const server = new Server([['a', 'Start']]);
|
||||
const e = await engineFor(server);
|
||||
server.apply({ opId: 'first', kind: 'set_content', blockId: 'a', baseVersion: 1, content: 'Gewinner' }, 'other');
|
||||
server.apply({ opId: 'second', kind: 'set_content', blockId: 'a', baseVersion: 1, content: 'Verlierer' }, 'mine');
|
||||
await e.syncNow();
|
||||
const copy = flat(e).find((b) => b.conflictOf === 'a')!;
|
||||
return { server, e, copy };
|
||||
}
|
||||
|
||||
it('accepting replaces the original text and removes the copy', async () => {
|
||||
const { server, e, copy } = await withConflict();
|
||||
expect(e.resolveConflict(copy.id, true).ok).toBe(true);
|
||||
expect(texts(e)).toEqual(['Verlierer']);
|
||||
await e.syncNow();
|
||||
expect(server.doc.blocks.get('a')!.content).toBe('Verlierer');
|
||||
expect(server.doc.blocks.get(copy.id)!.deleted).toBe(true);
|
||||
e.undo();
|
||||
expect(texts(e)).toEqual(['Gewinner', 'Verlierer']);
|
||||
});
|
||||
|
||||
it('is not started while someone else holds the copy', async () => {
|
||||
const { server, e, copy } = await withConflict();
|
||||
server.othersLeases = [{ blockId: copy.id, userId: 'u2', name: 'Ben' }];
|
||||
await e.syncNow();
|
||||
const res = e.resolveConflict(copy.id, true);
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.message).toMatch(/Ben/);
|
||||
expect(texts(e)).toEqual(['Gewinner', 'Verlierer']);
|
||||
});
|
||||
|
||||
it('discarding removes only the copy', async () => {
|
||||
const { e, copy } = await withConflict();
|
||||
e.resolveConflict(copy.id, false);
|
||||
expect(texts(e)).toEqual(['Gewinner']);
|
||||
});
|
||||
|
||||
it('a copy whose original is gone becomes a normal block', async () => {
|
||||
const { server, e, copy } = await withConflict();
|
||||
server.apply({ opId: 'del', kind: 'delete', blockId: 'a' }, 'other');
|
||||
await e.syncNow();
|
||||
e.resolveConflict(copy.id, true);
|
||||
expect(flat(e).find((b) => b.id === copy.id)?.conflictOf).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
// Turns the flat block list the editor works with (level + order) into operations against the tree.
|
||||
// Same algorithm as legacyOps in internal/storage/docsync.go.
|
||||
import type { DocumentBlock } from '../types';
|
||||
import { keysBetween } from './orderKey';
|
||||
import { Doc, Op, flatten, normProps, propsEqual } from './model';
|
||||
|
||||
/** Parent index (-1 = top level) for every entry of a flat list; impossible levels are clamped. */
|
||||
export function parentsFromLevels(levels: number[]): number[] {
|
||||
const parents: number[] = [];
|
||||
const stack: number[] = [];
|
||||
levels.forEach((raw, i) => {
|
||||
let lvl = Math.max(0, raw || 0);
|
||||
if (lvl > stack.length) lvl = stack.length;
|
||||
stack.length = lvl;
|
||||
parents.push(lvl > 0 ? stack[lvl - 1] : -1);
|
||||
stack.push(i);
|
||||
});
|
||||
return parents;
|
||||
}
|
||||
|
||||
/** Marks a longest strictly increasing subsequence of the non-empty keys. */
|
||||
export function longestIncreasing(keys: string[]): boolean[] {
|
||||
const keep = new Array(keys.length).fill(false);
|
||||
const tails: number[] = [];
|
||||
const prev = new Array(keys.length).fill(-1);
|
||||
keys.forEach((k, i) => {
|
||||
if (!k) return;
|
||||
let lo = 0;
|
||||
let hi = tails.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (keys[tails[mid]] < k) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
if (lo > 0) prev[i] = tails[lo - 1];
|
||||
if (lo === tails.length) tails.push(i);
|
||||
else tails[lo] = i;
|
||||
});
|
||||
if (tails.length > 0) {
|
||||
for (let i = tails[tails.length - 1]; i >= 0; i = prev[i]) keep[i] = true;
|
||||
}
|
||||
return keep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the operations that turn the tree into the given flat list. newOpId creates unique op IDs.
|
||||
* Blocks that keep their place keep their order key, so only real changes become operations.
|
||||
*/
|
||||
export function diffFlat(d: Doc, blocks: DocumentBlock[], newOpId: () => string): Op[] {
|
||||
const list = blocks.map((b) => ({ ...b, type: b.type || 'bullet' }));
|
||||
const parentIdx = parentsFromLevels(list.map((b) => b.level || 0));
|
||||
const parentOf = list.map((_, i) => (parentIdx[i] >= 0 ? list[parentIdx[i]].id : ''));
|
||||
|
||||
const byParent = new Map<string, number[]>();
|
||||
list.forEach((_, i) => {
|
||||
const p = parentOf[i];
|
||||
const arr = byParent.get(p);
|
||||
if (arr) arr.push(i);
|
||||
else byParent.set(p, [i]);
|
||||
});
|
||||
|
||||
const keys: string[] = new Array(list.length);
|
||||
byParent.forEach((idxs, parent) => {
|
||||
const existing = idxs.map((i) => {
|
||||
const b = d.blocks.get(list[i].id);
|
||||
return b && !b.deleted && b.parentId === parent ? b.orderKey : '';
|
||||
});
|
||||
const keep = longestIncreasing(existing);
|
||||
let j = 0;
|
||||
while (j < idxs.length) {
|
||||
if (keep[j]) {
|
||||
keys[idxs[j]] = existing[j];
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
const start = j;
|
||||
while (j < idxs.length && !keep[j]) j++;
|
||||
const left = start > 0 ? keys[idxs[start - 1]] : '';
|
||||
const right = j < idxs.length ? existing[j] : '';
|
||||
let fresh: string[];
|
||||
try {
|
||||
fresh = keysBetween(left, right, j - start);
|
||||
} catch {
|
||||
fresh = keysBetween(left, '', j - start);
|
||||
}
|
||||
for (let k = start; k < j; k++) keys[idxs[k]] = fresh[k - start];
|
||||
}
|
||||
});
|
||||
|
||||
const ops: Op[] = [];
|
||||
const push = (op: Omit<Op, 'opId'>) => ops.push({ opId: newOpId(), ...op } as Op);
|
||||
const wanted = new Set<string>();
|
||||
|
||||
list.forEach((b, i) => {
|
||||
wanted.add(b.id);
|
||||
const parent = parentOf[i];
|
||||
const props = normProps({ checked: b.checked, collapsed: b.collapsed, lang: b.lang });
|
||||
const cur = d.blocks.get(b.id);
|
||||
if (!cur) {
|
||||
push({ kind: 'insert', blockId: b.id, parentId: parent, orderKey: keys[i], type: b.type, content: b.content || '', props });
|
||||
return;
|
||||
}
|
||||
if (cur.deleted) push({ kind: 'restore', ids: [b.id] });
|
||||
if (cur.deleted || cur.parentId !== parent || cur.orderKey !== keys[i]) {
|
||||
push({ kind: 'move', blockId: b.id, parentId: parent, orderKey: keys[i] });
|
||||
}
|
||||
if (cur.content !== (b.content || '')) {
|
||||
push({ kind: 'set_content', blockId: b.id, baseVersion: cur.version, content: b.content || '' });
|
||||
}
|
||||
const curProps = normProps({ checked: cur.props.checked, collapsed: cur.props.collapsed, lang: cur.props.lang });
|
||||
if (cur.type !== b.type || !propsEqual(curProps, props)) {
|
||||
push({ kind: 'set_props', blockId: b.id, patch: { type: b.type, checked: !!b.checked, collapsed: !!b.collapsed, lang: b.lang || '' } });
|
||||
}
|
||||
});
|
||||
|
||||
// Deletes last, after moves have taken wanted children out of deleted branches
|
||||
for (const fb of flatten(d)) {
|
||||
if (!wanted.has(fb.id)) push({ kind: 'delete', blockId: fb.id });
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
/** Converts the tree into the flat list used by the editor components. */
|
||||
export function toDocumentBlocks(d: Doc, docId: string, taskLinks?: Map<string, string>): DocumentBlock[] {
|
||||
return flatten(d).map((b, i) => ({
|
||||
id: b.id,
|
||||
documentId: docId,
|
||||
type: b.type as DocumentBlock['type'],
|
||||
content: b.content,
|
||||
checked: !!b.props.checked,
|
||||
collapsed: !!b.props.collapsed,
|
||||
lang: b.props.lang || undefined,
|
||||
level: b.level,
|
||||
position: (i + 1) * 1000,
|
||||
taskId: taskLinks?.get(b.id),
|
||||
conflictOf: b.props.conflictOf,
|
||||
conflictAuthor: b.props.conflictAuthor,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
// DocSyncEngine keeps one note in sync with the server: confirmed state from the server log, plus the
|
||||
// user's own unconfirmed operations on top. Every change is stored in IndexedDB before it is sent, sent
|
||||
// with a unique op ID (safe to resend), and the server decides about conflicts. See
|
||||
// Doku/plans/kollaboration-notizen.md.
|
||||
import type { DocumentBlock } from '../types';
|
||||
import { diffFlat, toDocumentBlocks } from './diff';
|
||||
import { applyEffect, applyOp, cloneDoc, Doc, docFromSnapshot, emptyDoc, Op, Result } from './model';
|
||||
import { defaultStorage, SyncStorage } from './persist';
|
||||
import { httpTransport, LeaseInfo, Presence, Transport, TransportError } from './transport';
|
||||
import { buildStep, failedChecks, touchedBlocks, UndoStep } from './undo';
|
||||
import { PushChannel, PushKind, realtimePush } from './push';
|
||||
|
||||
export type EngineStatus = 'loading' | 'saved' | 'saving' | 'offline' | 'error';
|
||||
|
||||
/** How changes of others reach this client: push (WebSocket/SSE), long polling or plain polling. */
|
||||
export type SyncChannel = 'ws' | 'sse' | 'longpoll' | 'interval';
|
||||
|
||||
export interface EngineState {
|
||||
ready: boolean;
|
||||
status: EngineStatus;
|
||||
errorMessage?: string;
|
||||
role: string; // owner | editor | viewer | '' (unknown yet)
|
||||
title: string;
|
||||
blocks: DocumentBlock[];
|
||||
pendingCount: number;
|
||||
shared: boolean;
|
||||
sessions: number;
|
||||
presence: Presence[];
|
||||
lockedByOthers: LeaseInfo[];
|
||||
conflicts: Result[]; // conflicts of own operations since start
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
channel: SyncChannel;
|
||||
}
|
||||
|
||||
export interface ActionResult {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface EngineOptions {
|
||||
transport?: Transport;
|
||||
storage?: SyncStorage;
|
||||
now?: () => number;
|
||||
/** Disables timers, window listeners, push and long polling (tests drive the engine with syncNow()). */
|
||||
manual?: boolean;
|
||||
/** Push notifications; defaults to the app's realtime connection (not used in manual mode). */
|
||||
push?: PushChannel;
|
||||
}
|
||||
|
||||
const LEASE_GRACE_MS = 20_000; // keep a left block for this long
|
||||
const IDLE_RELEASE_MS = 60_000; // release even the focused block after this long without input
|
||||
const SYNC_DEBOUNCE_MS = 300;
|
||||
const POLL_COLLAB_MS = 3_000; // shared note without push or long polling
|
||||
const POLL_SOLO_MS = 30_000; // private note, opened once
|
||||
const HEARTBEAT_MS = 7_000; // renews block leases while changes arrive by push/long polling
|
||||
const SAFETY_POLL_MS = 30_000; // in case a push notification got lost
|
||||
const LONG_POLL_WAIT_S = 25; // below typical proxy timeouts (nginx 60 s, Cloudflare 100 s)
|
||||
const LONG_POLL_MAX_FAILURES = 3;
|
||||
const BACKOFF_MAX_MS = 30_000;
|
||||
const UNDO_LIMIT = 200;
|
||||
const UNDO_GROUP_MS = 1_000; // keystrokes in the same block are one undo step until a pause this long
|
||||
|
||||
function randomHex(bytes: number): string {
|
||||
const arr = new Uint8Array(bytes);
|
||||
crypto.getRandomValues(arr);
|
||||
return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/** Random block ID for new blocks (unique across clients). */
|
||||
export function newBlockId(): string {
|
||||
return `blk_${randomHex(10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps only the last text change per block (and the last title change). The base version is the one of
|
||||
* the first change: all later keystrokes build on what the user saw when starting to type, so a change
|
||||
* from someone else in between is detected as a conflict instead of being overwritten.
|
||||
*/
|
||||
export function formBatch(pending: Op[]): Op[] {
|
||||
const lastText = new Map<string, number>();
|
||||
const firstTextBase = new Map<string, number | undefined>();
|
||||
let lastTitle = -1;
|
||||
let firstTitleBase: number | undefined;
|
||||
pending.forEach((op, i) => {
|
||||
if (op.kind === 'set_content') {
|
||||
lastText.set(op.blockId!, i);
|
||||
if (!firstTextBase.has(op.blockId!)) firstTextBase.set(op.blockId!, op.baseVersion);
|
||||
}
|
||||
if (op.kind === 'set_title') {
|
||||
if (lastTitle < 0) firstTitleBase = op.baseVersion;
|
||||
lastTitle = i;
|
||||
}
|
||||
});
|
||||
const batch: Op[] = [];
|
||||
pending.forEach((op, i) => {
|
||||
if (op.kind === 'set_content') {
|
||||
if (lastText.get(op.blockId!) !== i) return;
|
||||
batch.push({ ...op, baseVersion: firstTextBase.get(op.blockId!) ?? 0 });
|
||||
return;
|
||||
}
|
||||
if (op.kind === 'set_title') {
|
||||
if (i !== lastTitle) return;
|
||||
batch.push({ ...op, baseVersion: firstTitleBase ?? 0 });
|
||||
return;
|
||||
}
|
||||
batch.push(op);
|
||||
});
|
||||
return batch;
|
||||
}
|
||||
|
||||
export class DocSyncEngine {
|
||||
readonly clientId = `c_${randomHex(8)}`;
|
||||
private transport: Transport;
|
||||
private storage: SyncStorage;
|
||||
private now: () => number;
|
||||
private manual: boolean;
|
||||
|
||||
private confirmed: Doc = emptyDoc();
|
||||
private lastSeq = 0;
|
||||
private pending: Op[] = [];
|
||||
private inflight: Op[] | null = null;
|
||||
private local: Doc = emptyDoc();
|
||||
private opCounter = 0;
|
||||
|
||||
private ready = false;
|
||||
private status: EngineStatus = 'loading';
|
||||
private errorMessage?: string;
|
||||
private role = '';
|
||||
private shared = false;
|
||||
private sessions = 0;
|
||||
private presence: Presence[] = [];
|
||||
private lockedByOthers: LeaseInfo[] = [];
|
||||
private conflicts: Result[] = [];
|
||||
private taskLinks = new Map<string, string>();
|
||||
|
||||
private focused: string | null = null;
|
||||
private activity = new Map<string, number>(); // blockId -> last edit/focus time
|
||||
// Version of each block the editor last showed in agreement with the engine. The focused text field does
|
||||
// not take over remote changes while typing, so its edits are based on this version, not the newest one.
|
||||
private uiSeen = new Map<string, number>();
|
||||
private undoStack: UndoStep[] = [];
|
||||
private redoStack: UndoStep[] = [];
|
||||
|
||||
private push: PushChannel | null;
|
||||
private pushKind: PushKind = 'none';
|
||||
private unsubscribePush: (() => void) | null = null;
|
||||
private pokeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private longPollRunning = false;
|
||||
private longPollBroken = false;
|
||||
private longPollAbort: AbortController | null = null;
|
||||
|
||||
private syncing = false;
|
||||
private syncAgain = false;
|
||||
private stopped = false;
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private backoff = 0;
|
||||
private listeners = new Set<(s: EngineState) => void>();
|
||||
private cleanup: Array<() => void> = [];
|
||||
|
||||
constructor(readonly docId: string, opts: EngineOptions = {}) {
|
||||
this.transport = opts.transport || httpTransport;
|
||||
this.storage = opts.storage || defaultStorage();
|
||||
this.now = opts.now || (() => Date.now());
|
||||
this.manual = !!opts.manual;
|
||||
this.push = opts.push || (this.manual ? null : realtimePush);
|
||||
}
|
||||
|
||||
// --- Public API ---
|
||||
|
||||
/** Loads the stored state (instant, works offline), then syncs with the server. */
|
||||
async start(): Promise<void> {
|
||||
try {
|
||||
const stored = await this.storage.load(this.docId);
|
||||
if (stored) {
|
||||
this.confirmed = docFromSnapshot(stored.title, stored.titleVersion, stored.blocks);
|
||||
this.lastSeq = stored.lastSeq;
|
||||
this.role = stored.role || '';
|
||||
this.pending = stored.pending || [];
|
||||
this.inflight = stored.inflight || null;
|
||||
this.ready = true;
|
||||
this.recompute();
|
||||
}
|
||||
} catch {
|
||||
// Storage unavailable: continue with the server state only
|
||||
}
|
||||
if (!this.manual) this.attachWindowListeners();
|
||||
if (this.push) {
|
||||
this.unsubscribePush = this.push.subscribe(
|
||||
this.docId,
|
||||
() => this.onPoke(),
|
||||
(kind) => this.onPushStatus(kind)
|
||||
);
|
||||
}
|
||||
if (!this.ready) {
|
||||
try {
|
||||
await this.loadSnapshot();
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
this.emit();
|
||||
this.schedule(this.nextBackoff());
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.emit();
|
||||
await this.syncNow();
|
||||
}
|
||||
|
||||
/** Flushes pending changes (keepalive) and leaves the note. */
|
||||
stop(): void {
|
||||
if (this.stopped) return;
|
||||
this.flushOnExit();
|
||||
this.stopped = true;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
if (this.pokeTimer) clearTimeout(this.pokeTimer);
|
||||
this.longPollAbort?.abort();
|
||||
this.unsubscribePush?.();
|
||||
this.unsubscribePush = null;
|
||||
this.cleanup.forEach((fn) => fn());
|
||||
this.cleanup = [];
|
||||
this.listeners.clear();
|
||||
}
|
||||
|
||||
subscribe(fn: (s: EngineState) => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
fn(this.getState());
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
getState(): EngineState {
|
||||
return {
|
||||
ready: this.ready,
|
||||
status: this.status,
|
||||
errorMessage: this.errorMessage,
|
||||
role: this.role,
|
||||
title: this.local.title,
|
||||
blocks: toDocumentBlocks(this.local, this.docId, this.taskLinks),
|
||||
pendingCount: this.pending.length + (this.inflight?.length || 0),
|
||||
shared: this.shared,
|
||||
sessions: this.sessions,
|
||||
presence: this.presence,
|
||||
lockedByOthers: this.lockedByOthers,
|
||||
conflicts: this.conflicts,
|
||||
canUndo: this.undoStack.length > 0,
|
||||
canRedo: this.redoStack.length > 0,
|
||||
channel: this.channel(),
|
||||
};
|
||||
}
|
||||
|
||||
get canEdit(): boolean {
|
||||
return this.role === 'owner' || this.role === 'editor';
|
||||
}
|
||||
|
||||
/** Task IDs of blocks linked to tasks (kept outside the synced tree). */
|
||||
setTaskLinks(links: Map<string, string>): void {
|
||||
this.taskLinks = new Map(links);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** Records the editor's complete block list; differences become operations. */
|
||||
applyFlat(blocks: DocumentBlock[]): void {
|
||||
if (!this.ready || !this.canEdit) return;
|
||||
for (const b of blocks) {
|
||||
if (b.taskId) this.taskLinks.set(b.id, b.taskId);
|
||||
}
|
||||
for (const b of blocks) {
|
||||
const cur = this.local.blocks.get(b.id);
|
||||
if (cur && !cur.deleted && cur.content === (b.content || '')) this.uiSeen.set(b.id, cur.version);
|
||||
}
|
||||
const ops = diffFlat(this.local, blocks, () => this.newOpId());
|
||||
if (ops.length === 0) return;
|
||||
const before = this.local;
|
||||
const t = this.now();
|
||||
for (const op of ops) {
|
||||
if ((op.kind === 'insert' || op.kind === 'set_content') && op.blockId) this.activity.set(op.blockId, t);
|
||||
if (op.kind === 'set_content' && this.uiSeen.has(op.blockId!)) op.baseVersion = this.uiSeen.get(op.blockId!);
|
||||
}
|
||||
this.enqueue(ops);
|
||||
this.record(before, ops);
|
||||
// The editor now shows exactly the local state for these blocks
|
||||
for (const op of ops) {
|
||||
if (op.kind === 'set_content' || op.kind === 'insert') {
|
||||
const cur = this.local.blocks.get(op.blockId!);
|
||||
if (cur) this.uiSeen.set(op.blockId!, cur.version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTitle(title: string): void {
|
||||
if (!this.ready || !this.canEdit || title === this.local.title) return;
|
||||
// The title field always shows the engine's title, so the current local version is what the user saw
|
||||
const before = this.local;
|
||||
const ops: Op[] = [{ opId: this.newOpId(), kind: 'set_title', title, baseVersion: this.local.titleVersion }];
|
||||
this.enqueue(ops);
|
||||
this.record(before, ops);
|
||||
}
|
||||
|
||||
/** Name of the user holding the lease on a block, if it is someone else. */
|
||||
lockedBy(blockId: string): string | undefined {
|
||||
return this.lockedByOthers.find((l) => l.blockId === blockId)?.name;
|
||||
}
|
||||
|
||||
/** Reverts the user's last own change (skipped if someone else changed the blocks since). */
|
||||
undo(): ActionResult {
|
||||
return this.replay(this.undoStack, this.redoStack, 'Rückgängig');
|
||||
}
|
||||
|
||||
/** Re-applies the last undone change. */
|
||||
redo(): ActionResult {
|
||||
return this.replay(this.redoStack, this.undoStack, 'Wiederholen');
|
||||
}
|
||||
|
||||
/** Accepts (replaces the original with the conflict copy) or discards a conflict copy. */
|
||||
resolveConflict(copyId: string, accept: boolean): ActionResult {
|
||||
if (!this.ready || !this.canEdit) return { ok: false };
|
||||
const copy = this.local.blocks.get(copyId);
|
||||
if (!copy || copy.deleted || !copy.props.conflictOf) return { ok: false };
|
||||
const target = copy.props.conflictOf;
|
||||
// The author of the conflicting text holds the copy for a moment; deleting it now would fail half-way
|
||||
const copyHolder = this.lockedBy(copyId);
|
||||
if (copyHolder) return { ok: false, message: `${copyHolder} bearbeitet diese Version gerade – bitte kurz warten` };
|
||||
const ops: Op[] = [];
|
||||
if (!accept) {
|
||||
ops.push({ opId: this.newOpId(), kind: 'delete', blockId: copyId });
|
||||
} else if (target === 'title') {
|
||||
ops.push({ opId: this.newOpId(), kind: 'set_title', title: copy.content, baseVersion: this.local.titleVersion });
|
||||
ops.push({ opId: this.newOpId(), kind: 'delete', blockId: copyId });
|
||||
} else {
|
||||
const original = this.local.blocks.get(target);
|
||||
if (original && !original.deleted) {
|
||||
const holder = this.lockedBy(target);
|
||||
if (holder) return { ok: false, message: `Der ursprüngliche Punkt wird gerade von ${holder} bearbeitet` };
|
||||
ops.push({ opId: this.newOpId(), kind: 'set_content', blockId: target, content: copy.content, baseVersion: original.version });
|
||||
ops.push({ opId: this.newOpId(), kind: 'delete', blockId: copyId });
|
||||
} else {
|
||||
// The original is gone: the copy simply becomes a normal block
|
||||
ops.push({ opId: this.newOpId(), kind: 'set_props', blockId: copyId, patch: { clearConflict: true } });
|
||||
}
|
||||
}
|
||||
const before = this.local;
|
||||
this.enqueue(ops);
|
||||
this.record(before, ops);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Tells the engine which block the cursor is in (null = none); used for block leases. */
|
||||
focusBlock(blockId: string | null): void {
|
||||
const t = this.now();
|
||||
const changed = blockId !== this.focused;
|
||||
if (this.focused) this.activity.set(this.focused, t);
|
||||
this.focused = blockId;
|
||||
if (blockId) this.activity.set(blockId, t);
|
||||
// Announce the lease right away: with push the next regular sync may be up to 30 s away
|
||||
if (changed && blockId && this.canEdit && this.collaborative()) {
|
||||
if (this.syncing) this.syncAgain = true;
|
||||
else this.schedule(SYNC_DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs one sync round trip now (or right after the running one). */
|
||||
async syncNow(): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
if (this.syncing) {
|
||||
this.syncAgain = true;
|
||||
return;
|
||||
}
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.syncing = true;
|
||||
let failed = false;
|
||||
try {
|
||||
await this.roundTrip();
|
||||
this.backoff = 0;
|
||||
} catch (err) {
|
||||
this.handleError(err);
|
||||
failed = true;
|
||||
} finally {
|
||||
this.syncing = false;
|
||||
this.recompute();
|
||||
if (!failed) this.ensureLongPoll();
|
||||
// Decided only now: the response tells whether the note is shared and how changes arrive
|
||||
const delay = failed
|
||||
? this.status === 'error'
|
||||
? this.pollDelay()
|
||||
: this.nextBackoff()
|
||||
: this.syncAgain || this.pending.length > 0
|
||||
? 0
|
||||
: this.pollDelay();
|
||||
this.syncAgain = false;
|
||||
this.emit();
|
||||
this.schedule(delay);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Internals ---
|
||||
|
||||
private newOpId(): string {
|
||||
this.opCounter++;
|
||||
return `${this.clientId}.${this.opCounter}`;
|
||||
}
|
||||
|
||||
private enqueue(ops: Op[]): void {
|
||||
this.pending.push(...ops);
|
||||
this.recompute();
|
||||
if (this.status !== 'offline' && this.status !== 'error') this.status = 'saving';
|
||||
this.emit();
|
||||
// Store first, then send: typed text must survive a crash between the two
|
||||
void this.persist().finally(() => this.schedule(SYNC_DEBOUNCE_MS));
|
||||
}
|
||||
|
||||
private record(before: Doc, ops: Op[]): void {
|
||||
const step = buildStep(before, ops, this.now());
|
||||
if (!step) return;
|
||||
this.redoStack = [];
|
||||
const top = this.undoStack[this.undoStack.length - 1];
|
||||
if (step.textKey && top && top.textKey === step.textKey && step.at - top.at < UNDO_GROUP_MS) {
|
||||
// Same block, still typing: keep the original text as undo target, update the expected result
|
||||
top.checks = step.checks;
|
||||
top.at = step.at;
|
||||
} else {
|
||||
this.undoStack.push(step);
|
||||
if (this.undoStack.length > UNDO_LIMIT) this.undoStack.shift();
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
private replay(from: UndoStep[], to: UndoStep[], label: string): ActionResult {
|
||||
if (!this.ready || !this.canEdit) return { ok: false };
|
||||
const step = from.pop();
|
||||
if (!step) return { ok: false };
|
||||
if (failedChecks(this.local, step).length > 0) {
|
||||
this.emit();
|
||||
return { ok: false, message: `${label} übersprungen: Der Punkt wurde inzwischen von jemand anderem geändert` };
|
||||
}
|
||||
const holder = touchedBlocks(step).map((id) => this.lockedBy(id)).find(Boolean);
|
||||
if (holder) {
|
||||
this.emit();
|
||||
return { ok: false, message: `${label} übersprungen: ${holder} bearbeitet den Punkt gerade` };
|
||||
}
|
||||
const ops: Op[] = step.inverse.map((o) => {
|
||||
const op: Op = { ...o, opId: this.newOpId() } as Op;
|
||||
if (op.kind === 'set_content') op.baseVersion = this.local.blocks.get(op.blockId!)?.version ?? 0;
|
||||
if (op.kind === 'set_title') op.baseVersion = this.local.titleVersion;
|
||||
return op;
|
||||
});
|
||||
const before = this.local;
|
||||
this.enqueue(ops);
|
||||
const back = buildStep(before, ops, this.now());
|
||||
if (back) {
|
||||
back.textKey = undefined;
|
||||
to.push(back);
|
||||
}
|
||||
for (const op of ops) {
|
||||
const b = op.blockId ? this.local.blocks.get(op.blockId) : undefined;
|
||||
if (b) this.uiSeen.set(b.id, b.version);
|
||||
}
|
||||
this.emit();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private recompute(): void {
|
||||
const v = cloneDoc(this.confirmed);
|
||||
for (const op of [...(this.inflight || []), ...this.pending]) {
|
||||
const local = { ...op };
|
||||
if (local.kind === 'set_content') local.baseVersion = v.blocks.get(local.blockId!)?.version ?? 0;
|
||||
if (local.kind === 'set_title') local.baseVersion = v.titleVersion;
|
||||
applyOp(v, local);
|
||||
}
|
||||
this.local = v;
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
// Unfocused blocks take over whatever the engine shows; only the focused text field may lag behind
|
||||
this.local.blocks.forEach((b, id) => {
|
||||
if (id !== this.focused) this.uiSeen.set(id, b.version);
|
||||
});
|
||||
if (this.listeners.size === 0) return;
|
||||
const s = this.getState();
|
||||
this.listeners.forEach((fn) => fn(s));
|
||||
}
|
||||
|
||||
private async persist(): Promise<void> {
|
||||
try {
|
||||
await this.storage.save({
|
||||
docId: this.docId,
|
||||
title: this.confirmed.title,
|
||||
titleVersion: this.confirmed.titleVersion,
|
||||
lastSeq: this.lastSeq,
|
||||
role: this.role,
|
||||
blocks: Array.from(this.confirmed.blocks.values()),
|
||||
pending: this.pending,
|
||||
inflight: this.inflight,
|
||||
savedAt: this.now(),
|
||||
});
|
||||
} catch {
|
||||
// Storage full or unavailable: changes stay in memory and are still sent
|
||||
}
|
||||
}
|
||||
|
||||
private async loadSnapshot(): Promise<void> {
|
||||
const snap = await this.transport.snapshot(this.docId);
|
||||
this.confirmed = docFromSnapshot(snap.title, snap.titleVersion, snap.blocks || []);
|
||||
this.lastSeq = snap.headSeq;
|
||||
this.role = snap.role;
|
||||
this.shared = snap.shared;
|
||||
this.ready = true;
|
||||
this.recompute();
|
||||
await this.persist();
|
||||
}
|
||||
|
||||
private holds(): string[] {
|
||||
const t = this.now();
|
||||
const out: string[] = [];
|
||||
this.activity.forEach((at, id) => {
|
||||
const fresh = t - at < LEASE_GRACE_MS || (id === this.focused && t - at < IDLE_RELEASE_MS);
|
||||
if (fresh && this.local.blocks.has(id)) out.push(id);
|
||||
else if (!fresh) this.activity.delete(id);
|
||||
});
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
private collaborative(): boolean {
|
||||
return this.shared || this.sessions > 1;
|
||||
}
|
||||
|
||||
private async roundTrip(): Promise<void> {
|
||||
if (!this.ready) await this.loadSnapshot();
|
||||
if (!this.inflight && this.pending.length > 0 && this.canEdit) {
|
||||
this.inflight = formBatch(this.pending);
|
||||
this.pending = [];
|
||||
await this.persist();
|
||||
}
|
||||
const ops = this.canEdit ? this.inflight || [] : [];
|
||||
if (ops.length > 0) this.status = 'saving';
|
||||
|
||||
for (;;) {
|
||||
const res = await this.transport.sync(this.docId, {
|
||||
clientId: this.clientId,
|
||||
lastSeq: this.lastSeq,
|
||||
ops: this.inflight ? ops : [],
|
||||
leases: this.canEdit && this.collaborative() ? { hold: this.holds(), release: [] } : undefined,
|
||||
});
|
||||
if (this.inflight) {
|
||||
const sent = new Map(this.inflight.map((op) => [op.opId, op]));
|
||||
for (const r of res.results) {
|
||||
if (r.status === 'conflict') this.conflicts = [...this.conflicts, r];
|
||||
// Someone else created a block with the same ID first (e.g. the first block of an empty note):
|
||||
// keep the typed text by sending it as a regular text change of that block
|
||||
const op = sent.get(r.opId);
|
||||
if (r.status === 'rejected' && r.reason === 'exists' && op?.kind === 'insert' && op.content) {
|
||||
// Base 0: the user never saw the other client's version of this block
|
||||
this.pending.unshift({ opId: this.newOpId(), kind: 'set_content', blockId: op.blockId, content: op.content, baseVersion: 0 });
|
||||
}
|
||||
}
|
||||
this.inflight = null;
|
||||
}
|
||||
this.role = res.role;
|
||||
this.shared = res.shared;
|
||||
this.sessions = res.sessions;
|
||||
this.presence = res.presence || [];
|
||||
this.lockedByOthers = res.leases?.others || [];
|
||||
|
||||
let gap = !!res.resync;
|
||||
for (const entry of res.ops) {
|
||||
if (entry.seq <= this.lastSeq) continue;
|
||||
if (entry.seq !== this.lastSeq + 1) {
|
||||
gap = true;
|
||||
break;
|
||||
}
|
||||
applyEffect(this.confirmed, entry.effect);
|
||||
this.lastSeq = entry.seq;
|
||||
}
|
||||
if (gap) await this.loadSnapshot();
|
||||
await this.persist();
|
||||
if (!res.hasMore || gap) break;
|
||||
}
|
||||
this.errorMessage = undefined;
|
||||
this.status = this.pending.length > 0 ? 'saving' : 'saved';
|
||||
}
|
||||
|
||||
private handleError(err: unknown): void {
|
||||
if (err instanceof TransportError && (err.status === 403 || err.status === 404)) {
|
||||
// Access lost: keep the queue (nothing is thrown away), but stop sending
|
||||
this.status = 'error';
|
||||
this.errorMessage = err.message;
|
||||
if (err.status === 404) this.role = '';
|
||||
else if (this.role !== 'owner') this.role = 'viewer';
|
||||
return;
|
||||
}
|
||||
this.status = 'offline';
|
||||
this.errorMessage = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
private channel(): SyncChannel {
|
||||
if (this.pushKind !== 'none') return this.pushKind;
|
||||
if (this.longPollRunning && !this.longPollBroken) return 'longpoll';
|
||||
return 'interval';
|
||||
}
|
||||
|
||||
private pollDelay(): number {
|
||||
if (!this.collaborative()) return POLL_SOLO_MS;
|
||||
if (this.channel() === 'interval') return POLL_COLLAB_MS;
|
||||
return this.canEdit && this.holds().length > 0 ? HEARTBEAT_MS : SAFETY_POLL_MS;
|
||||
}
|
||||
|
||||
private onPoke(): void {
|
||||
if (this.pokeTimer || this.stopped) return;
|
||||
// Several notifications in a row (e.g. typing of others) lead to one sync
|
||||
this.pokeTimer = setTimeout(() => {
|
||||
this.pokeTimer = null;
|
||||
void this.syncNow();
|
||||
}, 50);
|
||||
}
|
||||
|
||||
private onPushStatus(kind: PushKind): void {
|
||||
const wasLive = this.pushKind !== 'none';
|
||||
this.pushKind = kind;
|
||||
if (kind !== 'none') {
|
||||
this.longPollAbort?.abort();
|
||||
if (!wasLive) void this.syncNow(); // catch up on what was missed while disconnected
|
||||
} else {
|
||||
this.ensureLongPoll();
|
||||
if (!this.syncing) this.schedule(this.pollDelay());
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** Starts long polling when there is no push channel (e.g. a proxy blocks WebSocket and SSE). */
|
||||
private ensureLongPoll(): void {
|
||||
if (this.manual || this.stopped || !this.ready || this.longPollRunning || this.longPollBroken) return;
|
||||
if (this.pushKind !== 'none' || !this.collaborative() || this.status === 'error') return;
|
||||
void this.longPollLoop();
|
||||
}
|
||||
|
||||
private async longPollLoop(): Promise<void> {
|
||||
this.longPollRunning = true;
|
||||
this.emit();
|
||||
let failures = 0;
|
||||
try {
|
||||
while (!this.stopped && this.pushKind === 'none' && this.collaborative() && this.status !== 'error') {
|
||||
const ctrl = new AbortController();
|
||||
this.longPollAbort = ctrl;
|
||||
try {
|
||||
const res = await this.transport.changes(this.docId, this.lastSeq, LONG_POLL_WAIT_S, ctrl.signal);
|
||||
failures = 0;
|
||||
if (res.changed || res.headSeq !== this.lastSeq) await this.syncNow();
|
||||
} catch {
|
||||
if (ctrl.signal.aborted || this.stopped) break;
|
||||
failures++;
|
||||
if (failures >= LONG_POLL_MAX_FAILURES) {
|
||||
// Long polling does not get through either: plain polling every 3 s remains
|
||||
this.longPollBroken = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000 * failures));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this.longPollRunning = false;
|
||||
this.longPollAbort = null;
|
||||
this.emit();
|
||||
if (!this.syncing && !this.stopped) this.schedule(this.pollDelay());
|
||||
}
|
||||
}
|
||||
|
||||
private nextBackoff(): number {
|
||||
this.backoff = this.backoff ? Math.min(this.backoff * 2, BACKOFF_MAX_MS) : 1000;
|
||||
return this.backoff;
|
||||
}
|
||||
|
||||
private schedule(delay: number): void {
|
||||
if (this.manual || this.stopped) return;
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
void this.syncNow();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/** Sends what is pending with keepalive (survives page unload) and releases leases. */
|
||||
private flushOnExit(): void {
|
||||
if (this.canEdit && (this.inflight || this.pending.length > 0)) {
|
||||
const ops = this.inflight || formBatch(this.pending);
|
||||
this.transport
|
||||
.sync(this.docId, { clientId: this.clientId, lastSeq: this.lastSeq, ops }, { keepalive: true })
|
||||
.catch(() => {
|
||||
// Not delivered: the queue is in IndexedDB and is resent on the next visit (same op IDs)
|
||||
});
|
||||
}
|
||||
this.transport.leave(this.docId, this.clientId).catch(() => {});
|
||||
}
|
||||
|
||||
private attachWindowListeners(): void {
|
||||
const onOnline = () => {
|
||||
this.longPollBroken = false; // the network changed: long polling may work again
|
||||
void this.syncNow();
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') this.flushOnExit();
|
||||
else void this.syncNow();
|
||||
};
|
||||
const onPageHide = () => this.flushOnExit();
|
||||
window.addEventListener('online', onOnline);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
window.addEventListener('pagehide', onPageHide);
|
||||
this.cleanup.push(
|
||||
() => window.removeEventListener('online', onOnline),
|
||||
() => document.removeEventListener('visibilitychange', onVisibility),
|
||||
() => window.removeEventListener('pagehide', onPageHide)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
// Block tree, operations and effects of collaborative note editing. Port of internal/collab (doc.go, ops.go).
|
||||
// applyEffect must behave exactly like the server's Doc.ApplyEffect, because clients replay the server log.
|
||||
import { keyBetween, validOrderKey } from './orderKey';
|
||||
|
||||
export interface Props {
|
||||
checked?: boolean;
|
||||
collapsed?: boolean;
|
||||
lang?: string;
|
||||
conflictOf?: string;
|
||||
conflictAuthor?: string;
|
||||
}
|
||||
|
||||
export interface Block {
|
||||
id: string;
|
||||
parentId: string; // '' = top level
|
||||
orderKey: string;
|
||||
type: string;
|
||||
content: string;
|
||||
props: Props;
|
||||
version: number;
|
||||
deleted?: boolean;
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export interface Doc {
|
||||
title: string;
|
||||
titleVersion: number;
|
||||
blocks: Map<string, Block>;
|
||||
}
|
||||
|
||||
export interface PropsPatch {
|
||||
type?: string;
|
||||
checked?: boolean;
|
||||
collapsed?: boolean;
|
||||
lang?: string;
|
||||
clearConflict?: boolean;
|
||||
}
|
||||
|
||||
// 'purge' only appears as a log effect: log compaction removed old tombstones for good
|
||||
export type OpKind = 'insert' | 'set_content' | 'set_props' | 'move' | 'delete' | 'restore' | 'set_title' | 'purge';
|
||||
|
||||
export interface Op {
|
||||
opId: string;
|
||||
kind: OpKind;
|
||||
blockId?: string;
|
||||
baseVersion?: number;
|
||||
parentId?: string;
|
||||
orderKey?: string;
|
||||
type?: string;
|
||||
content?: string;
|
||||
props?: Props;
|
||||
patch?: PropsPatch;
|
||||
ids?: string[];
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface Effect {
|
||||
kind: OpKind;
|
||||
blockId?: string;
|
||||
parentId?: string;
|
||||
orderKey?: string;
|
||||
type?: string;
|
||||
content?: string;
|
||||
props?: Props;
|
||||
version?: number;
|
||||
ids?: string[];
|
||||
title?: string;
|
||||
titleVersion?: number;
|
||||
by?: string;
|
||||
}
|
||||
|
||||
export interface Result {
|
||||
opId: string;
|
||||
status: 'applied' | 'conflict' | 'rejected';
|
||||
reason?: string;
|
||||
version?: number;
|
||||
conflictBlockId?: string;
|
||||
seq?: number;
|
||||
}
|
||||
|
||||
export interface FlatBlock extends Block {
|
||||
level: number;
|
||||
}
|
||||
|
||||
export function emptyDoc(): Doc {
|
||||
return { title: '', titleVersion: 0, blocks: new Map() };
|
||||
}
|
||||
|
||||
export function cloneDoc(d: Doc): Doc {
|
||||
const blocks = new Map<string, Block>();
|
||||
d.blocks.forEach((b, id) => blocks.set(id, { ...b, props: { ...b.props } }));
|
||||
return { title: d.title, titleVersion: d.titleVersion, blocks };
|
||||
}
|
||||
|
||||
/** Normalizes props so that missing and false/empty values compare equal (the server omits them in JSON). */
|
||||
export function normProps(p?: Props): Props {
|
||||
const out: Props = {};
|
||||
if (p?.checked) out.checked = true;
|
||||
if (p?.collapsed) out.collapsed = true;
|
||||
if (p?.lang) out.lang = p.lang;
|
||||
if (p?.conflictOf) out.conflictOf = p.conflictOf;
|
||||
if (p?.conflictAuthor) out.conflictAuthor = p.conflictAuthor;
|
||||
return out;
|
||||
}
|
||||
|
||||
export function propsEqual(a?: Props, b?: Props): boolean {
|
||||
const x = normProps(a);
|
||||
const y = normProps(b);
|
||||
return (
|
||||
x.checked === y.checked &&
|
||||
x.collapsed === y.collapsed &&
|
||||
x.lang === y.lang &&
|
||||
x.conflictOf === y.conflictOf &&
|
||||
x.conflictAuthor === y.conflictAuthor
|
||||
);
|
||||
}
|
||||
|
||||
function siblingLess(a: Block, b: Block): number {
|
||||
if (a.orderKey !== b.orderKey) return a.orderKey < b.orderKey ? -1 : 1;
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
}
|
||||
|
||||
/** Live children of parentId in display order. */
|
||||
export function children(d: Doc, parentId: string): Block[] {
|
||||
const out: Block[] = [];
|
||||
d.blocks.forEach((b) => {
|
||||
if (!b.deleted && b.parentId === parentId) out.push(b);
|
||||
});
|
||||
return out.sort(siblingLess);
|
||||
}
|
||||
|
||||
/** All live blocks in display order with their nesting level. */
|
||||
export function flatten(d: Doc): FlatBlock[] {
|
||||
const byParent = new Map<string, Block[]>();
|
||||
d.blocks.forEach((b) => {
|
||||
if (b.deleted) return;
|
||||
const list = byParent.get(b.parentId);
|
||||
if (list) list.push(b);
|
||||
else byParent.set(b.parentId, [b]);
|
||||
});
|
||||
byParent.forEach((list) => list.sort(siblingLess));
|
||||
const out: FlatBlock[] = [];
|
||||
const walk = (parent: string, level: number) => {
|
||||
for (const b of byParent.get(parent) || []) {
|
||||
out.push({ ...b, level });
|
||||
walk(b.id, level + 1);
|
||||
}
|
||||
};
|
||||
walk('', 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
function subtree(d: Doc, id: string): string[] {
|
||||
const kids = new Map<string, string[]>();
|
||||
d.blocks.forEach((b) => {
|
||||
if (b.deleted) return;
|
||||
const list = kids.get(b.parentId);
|
||||
if (list) list.push(b.id);
|
||||
else kids.set(b.parentId, [b.id]);
|
||||
});
|
||||
const out = [id];
|
||||
for (let i = 0; i < out.length; i++) out.push(...(kids.get(out[i]) || []));
|
||||
return [out[0], ...out.slice(1).sort()];
|
||||
}
|
||||
|
||||
function liveAncestor(d: Doc, id: string): string {
|
||||
const seen = new Set<string>();
|
||||
while (id && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
const b = d.blocks.get(id);
|
||||
if (!b) return '';
|
||||
if (!b.deleted) return id;
|
||||
id = b.parentId;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isAncestor(d: Doc, ancestorId: string, id: string): boolean {
|
||||
const seen = new Set<string>();
|
||||
while (id && !seen.has(id)) {
|
||||
if (id === ancestorId) return true;
|
||||
seen.add(id);
|
||||
const b = d.blocks.get(id);
|
||||
if (!b) return false;
|
||||
id = b.parentId;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Mutates the document with one effect, exactly like the server. */
|
||||
export function applyEffect(d: Doc, e: Effect): void {
|
||||
switch (e.kind) {
|
||||
case 'insert':
|
||||
d.blocks.set(e.blockId!, {
|
||||
id: e.blockId!,
|
||||
parentId: e.parentId || '',
|
||||
orderKey: e.orderKey || '',
|
||||
type: e.type || '',
|
||||
content: e.content || '',
|
||||
props: normProps(e.props),
|
||||
version: e.version || 0,
|
||||
deleted: false,
|
||||
updatedBy: e.by || '',
|
||||
});
|
||||
break;
|
||||
case 'set_content': {
|
||||
const b = d.blocks.get(e.blockId!);
|
||||
if (b) {
|
||||
b.content = e.content || '';
|
||||
b.version = e.version || 0;
|
||||
b.updatedBy = e.by || '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'set_props': {
|
||||
const b = d.blocks.get(e.blockId!);
|
||||
if (b) {
|
||||
b.type = e.type || '';
|
||||
if (e.props) b.props = normProps(e.props);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'move': {
|
||||
const b = d.blocks.get(e.blockId!);
|
||||
if (b) {
|
||||
b.parentId = e.parentId || '';
|
||||
b.orderKey = e.orderKey || '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'delete':
|
||||
for (const id of e.ids || []) {
|
||||
const b = d.blocks.get(id);
|
||||
if (b) b.deleted = true;
|
||||
}
|
||||
break;
|
||||
case 'restore':
|
||||
for (const id of e.ids || []) {
|
||||
const b = d.blocks.get(id);
|
||||
if (b) b.deleted = false;
|
||||
}
|
||||
break;
|
||||
case 'set_title':
|
||||
d.title = e.title || '';
|
||||
d.titleVersion = e.titleVersion || 0;
|
||||
break;
|
||||
case 'purge':
|
||||
for (const id of e.ids || []) d.blocks.delete(id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an operation the way the server does (without leases). The client uses it to show its own
|
||||
* unconfirmed operations on top of the confirmed state; tests use it as a stand-in server.
|
||||
* conflictId derives the ID of a conflict copy (the server uses a hash of the op ID).
|
||||
*/
|
||||
export function applyOp(d: Doc, op: Op, by = '', conflictId: (opId: string) => string = (id) => `blk_cf_${id}`): { effects: Effect[]; result: Result } {
|
||||
const effects: Effect[] = [];
|
||||
const emit = (e: Effect) => {
|
||||
e.by = by;
|
||||
applyEffect(d, e);
|
||||
effects.push(e);
|
||||
};
|
||||
const done = (r: Omit<Result, 'opId'>) => ({ effects, result: { opId: op.opId, ...r } as Result });
|
||||
const reject = (reason: string) => done({ status: 'rejected', reason });
|
||||
|
||||
const restore = (ids: string[]) => {
|
||||
const set = new Set(ids);
|
||||
const sorted = [...ids].sort();
|
||||
emit({ kind: 'restore', ids: sorted });
|
||||
for (const id of sorted) {
|
||||
const b = d.blocks.get(id)!;
|
||||
if (b.parentId === '' || set.has(b.parentId)) continue;
|
||||
const p = d.blocks.get(b.parentId);
|
||||
if (p && !p.deleted) continue;
|
||||
emit({ kind: 'move', blockId: b.id, parentId: liveAncestor(d, b.parentId), orderKey: b.orderKey });
|
||||
}
|
||||
};
|
||||
const keyAfter = (b: Block): string => {
|
||||
const sibs = children(d, b.parentId);
|
||||
let next = '';
|
||||
for (const s of sibs) {
|
||||
if (s.orderKey > b.orderKey) {
|
||||
next = s.orderKey;
|
||||
break;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return keyBetween(b.orderKey, next);
|
||||
} catch {
|
||||
return keyBetween(b.orderKey, '');
|
||||
}
|
||||
};
|
||||
|
||||
switch (op.kind) {
|
||||
case 'insert': {
|
||||
if (!op.blockId || op.blockId.length > 64) return reject('invalid_id');
|
||||
if (d.blocks.has(op.blockId)) return reject('exists');
|
||||
let parent = op.parentId || '';
|
||||
if (parent) {
|
||||
// A purged parent (compaction) puts the block at the top level, like the server
|
||||
parent = d.blocks.has(parent) ? liveAncestor(d, parent) : '';
|
||||
}
|
||||
if (!op.orderKey || !validOrderKey(op.orderKey)) return reject('invalid_order_key');
|
||||
emit({
|
||||
kind: 'insert',
|
||||
blockId: op.blockId,
|
||||
parentId: parent,
|
||||
orderKey: op.orderKey,
|
||||
type: op.type || 'bullet',
|
||||
content: op.content || '',
|
||||
props: normProps(op.props),
|
||||
version: 1,
|
||||
});
|
||||
return done({ status: 'applied', version: 1 });
|
||||
}
|
||||
case 'set_content': {
|
||||
const b = d.blocks.get(op.blockId || '');
|
||||
if (op.content === undefined) return reject('no_content');
|
||||
if (!b) {
|
||||
// The block was purged (offline for weeks): like the server, the text becomes a new block at the end
|
||||
if (!op.content || !op.blockId || op.blockId.length > 64) return reject('missing');
|
||||
const roots = children(d, '');
|
||||
const key = keyBetween(roots.length ? roots[roots.length - 1].orderKey : '', '');
|
||||
emit({ kind: 'insert', blockId: op.blockId, parentId: '', orderKey: key, type: 'bullet', content: op.content, props: {}, version: 1 });
|
||||
return done({ status: 'applied', version: 1, reason: 'revived' });
|
||||
}
|
||||
if (b.deleted) restore([b.id]);
|
||||
if (op.content === b.content) return done({ status: 'applied', version: b.version });
|
||||
if ((op.baseVersion ?? 0) !== b.version) {
|
||||
const id = conflictId(op.opId);
|
||||
if (!d.blocks.has(id)) {
|
||||
emit({
|
||||
kind: 'insert',
|
||||
blockId: id,
|
||||
parentId: b.parentId,
|
||||
orderKey: keyAfter(b),
|
||||
type: b.type,
|
||||
content: op.content,
|
||||
props: { conflictOf: b.id },
|
||||
version: 1,
|
||||
});
|
||||
}
|
||||
return done({ status: 'conflict', version: b.version, conflictBlockId: id });
|
||||
}
|
||||
emit({ kind: 'set_content', blockId: b.id, content: op.content, version: b.version + 1 });
|
||||
return done({ status: 'applied', version: b.version });
|
||||
}
|
||||
case 'set_props': {
|
||||
const b = d.blocks.get(op.blockId || '');
|
||||
if (!b || b.deleted) return reject('missing');
|
||||
if (!op.patch) return reject('no_patch');
|
||||
const type = op.patch.type ? op.patch.type : b.type;
|
||||
const props = normProps({
|
||||
...b.props,
|
||||
...(op.patch.checked !== undefined ? { checked: op.patch.checked } : {}),
|
||||
...(op.patch.collapsed !== undefined ? { collapsed: op.patch.collapsed } : {}),
|
||||
...(op.patch.lang !== undefined ? { lang: op.patch.lang } : {}),
|
||||
...(op.patch.clearConflict ? { conflictOf: undefined, conflictAuthor: undefined } : {}),
|
||||
});
|
||||
if (type !== b.type || !propsEqual(props, b.props)) emit({ kind: 'set_props', blockId: b.id, type, props });
|
||||
return done({ status: 'applied' });
|
||||
}
|
||||
case 'move': {
|
||||
const b = d.blocks.get(op.blockId || '');
|
||||
if (!b || b.deleted) return reject('missing');
|
||||
const parent = op.parentId || '';
|
||||
if (parent) {
|
||||
const p = d.blocks.get(parent);
|
||||
if (!p || p.deleted) return reject('parent_missing');
|
||||
if (isAncestor(d, b.id, parent)) return reject('cycle');
|
||||
}
|
||||
if (!op.orderKey || !validOrderKey(op.orderKey)) return reject('invalid_order_key');
|
||||
if (parent !== b.parentId || op.orderKey !== b.orderKey) emit({ kind: 'move', blockId: b.id, parentId: parent, orderKey: op.orderKey });
|
||||
return done({ status: 'applied' });
|
||||
}
|
||||
case 'delete': {
|
||||
const b = d.blocks.get(op.blockId || '');
|
||||
if (!b) return reject('missing');
|
||||
if (!b.deleted) emit({ kind: 'delete', ids: subtree(d, b.id) });
|
||||
return done({ status: 'applied' });
|
||||
}
|
||||
case 'restore': {
|
||||
const ids = (op.ids || []).filter((id) => d.blocks.get(id)?.deleted);
|
||||
if (ids.length > 0) restore(ids);
|
||||
return done({ status: 'applied' });
|
||||
}
|
||||
case 'set_title': {
|
||||
if (op.title === undefined) return reject('no_title');
|
||||
if (op.title === d.title) return done({ status: 'applied', version: d.titleVersion });
|
||||
if ((op.baseVersion ?? 0) !== d.titleVersion) return done({ status: 'conflict', version: d.titleVersion });
|
||||
emit({ kind: 'set_title', title: op.title, titleVersion: d.titleVersion + 1 });
|
||||
return done({ status: 'applied', version: d.titleVersion });
|
||||
}
|
||||
}
|
||||
return reject('unknown_kind');
|
||||
}
|
||||
|
||||
/** Builds a Doc from snapshot blocks (as delivered by GET /docs/{id}/snapshot). */
|
||||
export function docFromSnapshot(title: string, titleVersion: number, blocks: Block[]): Doc {
|
||||
const d: Doc = { title, titleVersion, blocks: new Map() };
|
||||
for (const b of blocks) {
|
||||
d.blocks.set(b.id, {
|
||||
id: b.id,
|
||||
parentId: b.parentId || '',
|
||||
orderKey: b.orderKey,
|
||||
type: b.type || '',
|
||||
content: b.content || '',
|
||||
props: normProps(b.props),
|
||||
version: b.version || 0,
|
||||
deleted: !!b.deleted,
|
||||
updatedBy: b.updatedBy || '',
|
||||
});
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
export function docsEqual(a: Doc, b: Doc): boolean {
|
||||
if (a.title !== b.title || a.titleVersion !== b.titleVersion || a.blocks.size !== b.blocks.size) return false;
|
||||
for (const [id, x] of a.blocks) {
|
||||
const y = b.blocks.get(id);
|
||||
if (
|
||||
!y ||
|
||||
x.parentId !== y.parentId ||
|
||||
x.orderKey !== y.orderKey ||
|
||||
x.type !== y.type ||
|
||||
x.content !== y.content ||
|
||||
x.version !== y.version ||
|
||||
!!x.deleted !== !!y.deleted ||
|
||||
!propsEqual(x.props, y.props)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Sends a few operations for a note that is not open in an editor (e.g. ticking a to-do in the overview).
|
||||
// Goes through the same sync endpoint and rules as the editor, so the change is logged and pushed to
|
||||
// everyone who has the note open.
|
||||
import type { Op, Result } from './model';
|
||||
import { httpTransport } from './transport';
|
||||
|
||||
export async function sendDocOps(docId: string, ops: Omit<Op, 'opId'>[]): Promise<Result[]> {
|
||||
const bytes = new Uint8Array(8);
|
||||
crypto.getRandomValues(bytes);
|
||||
const clientId = `once_${Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')}`;
|
||||
const res = await httpTransport.sync(docId, {
|
||||
clientId,
|
||||
lastSeq: 0,
|
||||
ops: ops.map((op, i) => ({ ...op, opId: `${clientId}.${i}` }) as Op),
|
||||
skipLog: true,
|
||||
});
|
||||
return res.results;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// Order keys sort siblings (ties broken by block ID). Port of internal/collab/orderkey.go; both sides must
|
||||
// produce identical keys, see orderKey.test.ts for shared test vectors.
|
||||
const DIGITS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
||||
const SMALLEST_INTEGER = 'A00000000000000000000000000';
|
||||
|
||||
function digitIndex(c: string): number {
|
||||
return DIGITS.indexOf(c);
|
||||
}
|
||||
|
||||
function integerLength(head: string): number {
|
||||
if (head >= 'a' && head <= 'z') return head.charCodeAt(0) - 'a'.charCodeAt(0) + 2;
|
||||
if (head >= 'A' && head <= 'Z') return 'Z'.charCodeAt(0) - head.charCodeAt(0) + 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function integerPart(key: string): string | null {
|
||||
if (!key) return null;
|
||||
const n = integerLength(key[0]);
|
||||
if (n === 0 || n > key.length) return null;
|
||||
return key.slice(0, n);
|
||||
}
|
||||
|
||||
export function validOrderKey(k: string): boolean {
|
||||
if (!k || k.length > 256 || k === SMALLEST_INTEGER) return false;
|
||||
const ip = integerPart(k);
|
||||
if (ip === null) return false;
|
||||
for (let i = 1; i < k.length; i++) {
|
||||
if (digitIndex(k[i]) < 0) return false;
|
||||
}
|
||||
const frac = k.slice(ip.length);
|
||||
return frac === '' || frac[frac.length - 1] !== '0';
|
||||
}
|
||||
|
||||
function incrementInteger(x: string): string | null {
|
||||
const head = x[0];
|
||||
const digs = x.slice(1).split('');
|
||||
let carry = true;
|
||||
for (let i = digs.length - 1; carry && i >= 0; i--) {
|
||||
const d = digitIndex(digs[i]) + 1;
|
||||
if (d === DIGITS.length) {
|
||||
digs[i] = '0';
|
||||
} else {
|
||||
digs[i] = DIGITS[d];
|
||||
carry = false;
|
||||
}
|
||||
}
|
||||
if (!carry) return head + digs.join('');
|
||||
if (head === 'Z') return 'a0';
|
||||
if (head === 'z') return null;
|
||||
const h = String.fromCharCode(head.charCodeAt(0) + 1);
|
||||
if (h > 'a') digs.push('0');
|
||||
else digs.pop();
|
||||
return h + digs.join('');
|
||||
}
|
||||
|
||||
function decrementInteger(x: string): string | null {
|
||||
const head = x[0];
|
||||
const digs = x.slice(1).split('');
|
||||
let borrow = true;
|
||||
for (let i = digs.length - 1; borrow && i >= 0; i--) {
|
||||
const d = digitIndex(digs[i]) - 1;
|
||||
if (d === -1) {
|
||||
digs[i] = 'z';
|
||||
} else {
|
||||
digs[i] = DIGITS[d];
|
||||
borrow = false;
|
||||
}
|
||||
}
|
||||
if (!borrow) return head + digs.join('');
|
||||
if (head === 'a') return 'Z' + DIGITS[DIGITS.length - 1];
|
||||
if (head === 'A') return null;
|
||||
const h = String.fromCharCode(head.charCodeAt(0) - 1);
|
||||
if (h < 'Z') digs.push('z');
|
||||
else digs.pop();
|
||||
return h + digs.join('');
|
||||
}
|
||||
|
||||
function midpoint(a: string, b: string): string {
|
||||
if (b !== '') {
|
||||
let n = 0;
|
||||
while (n < b.length) {
|
||||
const ca = n < a.length ? a[n] : '0';
|
||||
if (ca !== b[n]) break;
|
||||
n++;
|
||||
}
|
||||
if (n > 0) return b.slice(0, n) + midpoint(n < a.length ? a.slice(n) : '', b.slice(n));
|
||||
}
|
||||
const da = a !== '' ? digitIndex(a[0]) : 0;
|
||||
const db = b !== '' ? digitIndex(b[0]) : DIGITS.length;
|
||||
if (db - da > 1) return DIGITS[Math.floor((da + db) / 2)];
|
||||
if (b !== '' && b.length > 1) return b.slice(0, 1);
|
||||
return DIGITS[da] + midpoint(a.length > 1 ? a.slice(1) : '', '');
|
||||
}
|
||||
|
||||
/** Returns a key strictly between a and b; '' means open end. Throws on an invalid range. */
|
||||
export function keyBetween(a: string, b: string): string {
|
||||
if ((a !== '' && !validOrderKey(a)) || (b !== '' && !validOrderKey(b)) || (a !== '' && b !== '' && a >= b)) {
|
||||
throw new Error(`invalid order key range ${a}..${b}`);
|
||||
}
|
||||
if (a === '' && b === '') return 'a0';
|
||||
if (a === '') {
|
||||
const ib = integerPart(b)!;
|
||||
const fb = b.slice(ib.length);
|
||||
if (ib === SMALLEST_INTEGER) return ib + midpoint('', fb);
|
||||
if (ib < b) return ib;
|
||||
const res = decrementInteger(ib);
|
||||
if (res === null) throw new Error('order key range exhausted');
|
||||
return res;
|
||||
}
|
||||
if (b === '') {
|
||||
const ia = integerPart(a)!;
|
||||
const i = incrementInteger(ia);
|
||||
return i !== null ? i : ia + midpoint(a.slice(ia.length), '');
|
||||
}
|
||||
const ia = integerPart(a)!;
|
||||
const ib = integerPart(b)!;
|
||||
if (ia === ib) return ia + midpoint(a.slice(ia.length), b.slice(ib.length));
|
||||
const i = incrementInteger(ia);
|
||||
if (i === null) throw new Error('order key range exhausted');
|
||||
return i < b ? i : ia + midpoint(a.slice(ia.length), '');
|
||||
}
|
||||
|
||||
/** Returns n increasing keys between a and b, spread out so bulk inserts stay short. */
|
||||
export function keysBetween(a: string, b: string, n: number): string[] {
|
||||
if (n <= 0) return [];
|
||||
if (n === 1) return [keyBetween(a, b)];
|
||||
if (b === '') {
|
||||
const out: string[] = [];
|
||||
let prev = a;
|
||||
for (let i = 0; i < n; i++) {
|
||||
prev = keyBetween(prev, '');
|
||||
out.push(prev);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (a === '') {
|
||||
const out: string[] = new Array(n);
|
||||
let next = b;
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
next = keyBetween('', next);
|
||||
out[i] = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const mid = Math.floor(n / 2);
|
||||
const c = keyBetween(a, b);
|
||||
return [...keysBetween(a, c, mid), c, ...keysBetween(c, b, n - mid - 1)];
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// Persists the sync state of each note in IndexedDB so that typed text survives reloads, crashes and
|
||||
// offline phases. Falls back to memory when IndexedDB is unavailable (private mode, old browsers).
|
||||
import type { Block, Op } from './model';
|
||||
|
||||
export interface StoredDocState {
|
||||
docId: string;
|
||||
title: string;
|
||||
titleVersion: number;
|
||||
lastSeq: number;
|
||||
role: string;
|
||||
blocks: Block[];
|
||||
pending: Op[];
|
||||
inflight: Op[] | null;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
export interface SyncStorage {
|
||||
load(docId: string): Promise<StoredDocState | null>;
|
||||
save(state: StoredDocState): Promise<void>;
|
||||
}
|
||||
|
||||
const DB_NAME = 'mindsift-sync';
|
||||
const STORE = 'docs';
|
||||
|
||||
export class MemoryStorage implements SyncStorage {
|
||||
private data = new Map<string, StoredDocState>();
|
||||
async load(docId: string) {
|
||||
const s = this.data.get(docId);
|
||||
return s ? JSON.parse(JSON.stringify(s)) : null;
|
||||
}
|
||||
async save(state: StoredDocState) {
|
||||
this.data.set(state.docId, JSON.parse(JSON.stringify(state)));
|
||||
}
|
||||
}
|
||||
|
||||
export class IndexedDBStorage implements SyncStorage {
|
||||
private dbPromise: Promise<IDBDatabase> | null = null;
|
||||
|
||||
constructor(private factory: IDBFactory = indexedDB) {}
|
||||
|
||||
private open(): Promise<IDBDatabase> {
|
||||
if (!this.dbPromise) {
|
||||
this.dbPromise = new Promise((resolve, reject) => {
|
||||
const req = this.factory.open(DB_NAME, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
if (!req.result.objectStoreNames.contains(STORE)) req.result.createObjectStore(STORE, { keyPath: 'docId' });
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
this.dbPromise.catch(() => {
|
||||
this.dbPromise = null;
|
||||
});
|
||||
}
|
||||
return this.dbPromise;
|
||||
}
|
||||
|
||||
async load(docId: string): Promise<StoredDocState | null> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db.transaction(STORE, 'readonly').objectStore(STORE).get(docId);
|
||||
req.onsuccess = () => resolve((req.result as StoredDocState) || null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async save(state: StoredDocState): Promise<void> {
|
||||
const db = await this.open();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).put(state);
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** IndexedDB when available, otherwise memory (state is then lost on reload). */
|
||||
export function defaultStorage(): SyncStorage {
|
||||
try {
|
||||
if (typeof indexedDB !== 'undefined') return new IndexedDBStorage(indexedDB);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return new MemoryStorage();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Push notifications for note sync: the server sends DOC_SEQ into the note's room whenever the note, its
|
||||
// leases or its presence change. Delivery uses the app-wide realtime connection (WebSocket, else SSE).
|
||||
import { createRealtimeConnection, getRealtimeTransport, isRealtimeConnected } from '../api';
|
||||
|
||||
export type PushKind = 'ws' | 'sse' | 'none';
|
||||
|
||||
export interface PushChannel {
|
||||
/** Calls onPoke for changes of docId and onStatus whenever the channel goes up or down. */
|
||||
subscribe(docId: string, onPoke: () => void, onStatus: (kind: PushKind) => void): () => void;
|
||||
}
|
||||
|
||||
export const realtimePush: PushChannel = {
|
||||
subscribe(docId, onPoke, onStatus) {
|
||||
const current = (): PushKind => (isRealtimeConnected() ? (getRealtimeTransport() as PushKind) : 'none');
|
||||
const conn = createRealtimeConnection({
|
||||
docId,
|
||||
onMessage: (msg: any) => {
|
||||
if (msg?.event === 'DOC_SEQ' && msg.payload?.docId === docId) onPoke();
|
||||
},
|
||||
onStatusChange: () => onStatus(current()),
|
||||
});
|
||||
onStatus(current());
|
||||
return () => conn.close();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
// HTTP transport of the note sync protocol (see Doku/api/rest.md). Every data exchange is a plain request,
|
||||
// so it works behind proxies that block WebSockets.
|
||||
import { getToken } from '../api';
|
||||
import type { Block, Effect, Op, Result } from './model';
|
||||
|
||||
export interface LeaseInfo {
|
||||
blockId: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Presence {
|
||||
userId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SnapshotResponse {
|
||||
docId: string;
|
||||
title: string;
|
||||
titleVersion: number;
|
||||
headSeq: number;
|
||||
blocks: Block[];
|
||||
role: string;
|
||||
shared: boolean;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
seq: number;
|
||||
opId?: string;
|
||||
userId: string;
|
||||
clientId: string;
|
||||
effect: Effect;
|
||||
}
|
||||
|
||||
export interface SyncRequest {
|
||||
clientId: string;
|
||||
lastSeq: number;
|
||||
ops: Op[];
|
||||
leases?: { hold: string[]; release: string[] };
|
||||
skipLog?: boolean; // one-off changes do not need the log back
|
||||
}
|
||||
|
||||
export interface SyncResponse {
|
||||
results: Result[];
|
||||
ops: LogEntry[];
|
||||
headSeq: number;
|
||||
hasMore: boolean;
|
||||
resync?: boolean;
|
||||
role: string;
|
||||
shared: boolean;
|
||||
sessions: number;
|
||||
presence: Presence[];
|
||||
leases: { granted: string[]; denied: string[]; others: LeaseInfo[] };
|
||||
}
|
||||
|
||||
/** status 0 = network error or timeout; otherwise the HTTP status. */
|
||||
export class TransportError extends Error {
|
||||
constructor(message: string, public status: number) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface ChangesResponse {
|
||||
headSeq: number;
|
||||
changed: boolean;
|
||||
}
|
||||
|
||||
export interface Transport {
|
||||
snapshot(docId: string): Promise<SnapshotResponse>;
|
||||
/** Long poll: resolves when the note changes, at the latest after waitSec seconds. */
|
||||
changes(docId: string, since: number, waitSec: number, signal?: AbortSignal): Promise<ChangesResponse>;
|
||||
sync(docId: string, body: SyncRequest, opts?: { keepalive?: boolean }): Promise<SyncResponse>;
|
||||
leave(docId: string, clientId: string): Promise<void>;
|
||||
}
|
||||
|
||||
const TIMEOUT_MS = 15000;
|
||||
|
||||
async function call<T>(path: string, init: RequestInit & { keepalive?: boolean } = {}, timeoutMs = TIMEOUT_MS): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const token = getToken();
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
init.signal?.addEventListener('abort', () => controller.abort());
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, { ...init, headers, cache: 'no-store', signal: init.keepalive ? undefined : controller.signal });
|
||||
} catch (err: any) {
|
||||
throw new TransportError(err?.message || 'Netzwerkfehler', 0);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
if (res.status === 401) {
|
||||
window.dispatchEvent(new CustomEvent('mindsift:unauthorized'));
|
||||
}
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`;
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data?.error) msg = data.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new TransportError(msg, res.status);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const httpTransport: Transport = {
|
||||
snapshot: (docId) => call(`/api/v1/docs/${encodeURIComponent(docId)}/snapshot`),
|
||||
changes: (docId, since, waitSec, signal) =>
|
||||
call(`/api/v1/docs/${encodeURIComponent(docId)}/changes?since=${since}&wait=${waitSec}`, { signal }, (waitSec + 10) * 1000),
|
||||
sync: (docId, body, opts) =>
|
||||
call(`/api/v1/docs/${encodeURIComponent(docId)}/sync`, { method: 'POST', body: JSON.stringify(body), keepalive: opts?.keepalive }),
|
||||
leave: async (docId, clientId) => {
|
||||
await call(`/api/v1/docs/${encodeURIComponent(docId)}/leave`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ clientId }),
|
||||
keepalive: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,150 @@
|
||||
// Undo/redo of the user's own changes. For every action the engine records the inverse operations plus a
|
||||
// check of the state the action left behind. Undo only runs if that state is still there, i.e. nobody
|
||||
// else changed the affected blocks in between; otherwise the step is skipped instead of overwriting.
|
||||
import { applyOp, cloneDoc, Doc, normProps, Op, propsEqual } from './model';
|
||||
|
||||
export type UndoCheck =
|
||||
| { kind: 'content'; blockId: string; content: string }
|
||||
| { kind: 'alive'; blockId: string; content: string }
|
||||
| { kind: 'deleted'; ids: string[] }
|
||||
| { kind: 'place'; blockId: string; parentId: string; orderKey: string; restoreParent: string }
|
||||
| { kind: 'props'; blockId: string; type: string; checked: boolean; collapsed: boolean; lang: string }
|
||||
| { kind: 'title'; title: string };
|
||||
|
||||
export interface UndoStep {
|
||||
inverse: Omit<Op, 'opId'>[]; // already in the order to apply
|
||||
checks: UndoCheck[];
|
||||
at: number;
|
||||
textKey?: string; // set for pure typing steps (block ID or '#title'), used to group keystrokes
|
||||
}
|
||||
|
||||
/** Applies ops to a copy of `before` and records how to revert them. */
|
||||
export function buildStep(before: Doc, ops: Op[], at: number): UndoStep | null {
|
||||
const v = cloneDoc(before);
|
||||
const inverse: Omit<Op, 'opId'>[] = [];
|
||||
const checks: UndoCheck[] = [];
|
||||
|
||||
for (const raw of ops) {
|
||||
const op = { ...raw };
|
||||
const pre = op.blockId ? v.blocks.get(op.blockId) : undefined;
|
||||
const preCopy = pre ? { ...pre, props: { ...pre.props } } : undefined;
|
||||
const preTitle = v.title;
|
||||
if (op.kind === 'set_content') op.baseVersion = pre?.version ?? 0;
|
||||
if (op.kind === 'set_title') op.baseVersion = v.titleVersion;
|
||||
const { effects } = applyOp(v, op);
|
||||
if (effects.length === 0) continue;
|
||||
const post = op.blockId ? v.blocks.get(op.blockId) : undefined;
|
||||
|
||||
switch (op.kind) {
|
||||
case 'insert':
|
||||
if (post && !post.deleted) {
|
||||
inverse.push({ kind: 'delete', blockId: post.id });
|
||||
checks.push({ kind: 'alive', blockId: post.id, content: post.content });
|
||||
}
|
||||
break;
|
||||
case 'set_content':
|
||||
if (preCopy && post) {
|
||||
inverse.push({ kind: 'set_content', blockId: post.id, content: preCopy.content });
|
||||
checks.push({ kind: 'content', blockId: post.id, content: post.content });
|
||||
}
|
||||
break;
|
||||
case 'set_props':
|
||||
if (preCopy && post) {
|
||||
const p = normProps(preCopy.props);
|
||||
inverse.push({
|
||||
kind: 'set_props',
|
||||
blockId: post.id,
|
||||
patch: { type: preCopy.type, checked: !!p.checked, collapsed: !!p.collapsed, lang: p.lang || '' },
|
||||
});
|
||||
const q = normProps(post.props);
|
||||
checks.push({ kind: 'props', blockId: post.id, type: post.type, checked: !!q.checked, collapsed: !!q.collapsed, lang: q.lang || '' });
|
||||
}
|
||||
break;
|
||||
case 'move':
|
||||
if (preCopy && post) {
|
||||
inverse.push({ kind: 'move', blockId: post.id, parentId: preCopy.parentId, orderKey: preCopy.orderKey });
|
||||
checks.push({ kind: 'place', blockId: post.id, parentId: post.parentId, orderKey: post.orderKey, restoreParent: preCopy.parentId });
|
||||
}
|
||||
break;
|
||||
case 'delete': {
|
||||
const ids = effects.flatMap((e) => (e.kind === 'delete' ? e.ids || [] : []));
|
||||
if (ids.length > 0) {
|
||||
inverse.push({ kind: 'restore', ids });
|
||||
checks.push({ kind: 'deleted', ids });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'restore': {
|
||||
const ids = effects.flatMap((e) => (e.kind === 'restore' ? e.ids || [] : []));
|
||||
for (const id of ids) inverse.push({ kind: 'delete', blockId: id });
|
||||
for (const id of ids) checks.push({ kind: 'alive', blockId: id, content: v.blocks.get(id)?.content || '' });
|
||||
break;
|
||||
}
|
||||
case 'set_title':
|
||||
inverse.push({ kind: 'set_title', title: preTitle });
|
||||
checks.push({ kind: 'title', title: v.title });
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (inverse.length === 0) return null;
|
||||
inverse.reverse();
|
||||
const onlyText = ops.every((o) => o.kind === 'set_content') && new Set(ops.map((o) => o.blockId)).size === 1;
|
||||
const onlyTitle = ops.every((o) => o.kind === 'set_title');
|
||||
return { inverse, checks, at, textKey: onlyText ? ops[0].blockId : onlyTitle ? '#title' : undefined };
|
||||
}
|
||||
|
||||
/** Returns the IDs of blocks the step touches whose state no longer matches, or [] if undo is safe. */
|
||||
export function failedChecks(d: Doc, step: UndoStep): string[] {
|
||||
const failed: string[] = [];
|
||||
for (const c of step.checks) {
|
||||
switch (c.kind) {
|
||||
case 'content': {
|
||||
const b = d.blocks.get(c.blockId);
|
||||
if (!b || b.deleted || b.content !== c.content) failed.push(c.blockId);
|
||||
break;
|
||||
}
|
||||
case 'alive': {
|
||||
const b = d.blocks.get(c.blockId);
|
||||
if (!b || b.deleted || b.content !== c.content) failed.push(c.blockId);
|
||||
break;
|
||||
}
|
||||
case 'deleted':
|
||||
for (const id of c.ids) if (!d.blocks.get(id)?.deleted) failed.push(id);
|
||||
break;
|
||||
case 'place': {
|
||||
const b = d.blocks.get(c.blockId);
|
||||
const parent = c.restoreParent ? d.blocks.get(c.restoreParent) : null;
|
||||
if (!b || b.deleted || b.parentId !== c.parentId || b.orderKey !== c.orderKey || (c.restoreParent && (!parent || parent.deleted))) {
|
||||
failed.push(c.blockId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'props': {
|
||||
const b = d.blocks.get(c.blockId);
|
||||
if (
|
||||
!b ||
|
||||
b.deleted ||
|
||||
b.type !== c.type ||
|
||||
!propsEqual(normProps({ checked: b.props.checked, collapsed: b.props.collapsed, lang: b.props.lang }), normProps({ checked: c.checked, collapsed: c.collapsed, lang: c.lang }))
|
||||
) {
|
||||
failed.push(c.blockId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'title':
|
||||
if (d.title !== c.title) failed.push('#title');
|
||||
break;
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
}
|
||||
|
||||
/** Block IDs an inverse step would modify (for lease checks). */
|
||||
export function touchedBlocks(step: UndoStep): string[] {
|
||||
const ids = new Set<string>();
|
||||
for (const op of step.inverse) {
|
||||
if (op.blockId) ids.add(op.blockId);
|
||||
for (const id of op.ids || []) ids.add(id);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// A hashtag starts the text or follows whitespace or "(". Fragments in links (example.com/blog#ueberschrift)
|
||||
// and words like "C#" are not tags. Same rule as the server (domain.ExtractTags).
|
||||
const HASHTAG = /(?:^|[\s(])#([a-zA-Z0-9äöüÄÖÜß_-]+)/g;
|
||||
|
||||
/** All hashtags in a text, lower case, in order of appearance (with duplicates). */
|
||||
export function extractHashtags(text: string | undefined | null): string[] {
|
||||
if (!text) return [];
|
||||
return Array.from(text.matchAll(HASHTAG), (m) => m[1].toLowerCase());
|
||||
}
|
||||
@@ -7,11 +7,20 @@ import React from 'react';
|
||||
* - ~~strikethrough~~
|
||||
* - `code`
|
||||
* - [label](url)
|
||||
* - #hashtag (only when `options.onTagClick` is given), rendered inline as a clickable chip
|
||||
*/
|
||||
function renderInlineTokens(text: string): React.ReactNode[] {
|
||||
export interface InlineMarkdownOptions {
|
||||
onTagClick?: (tag: string) => void;
|
||||
}
|
||||
|
||||
function renderInlineTokens(text: string, options?: InlineMarkdownOptions): React.ReactNode[] {
|
||||
if (!text) return [];
|
||||
|
||||
const regex = /(`[^`]+`|\*\*[^*]+\*\*|__[^_]+__|~~[^~]+~~|\*[^*]+\*|_[^_]+_|\[([^\]]+)\]\(([^)]+)\))/g;
|
||||
const tagAlt = options?.onTagClick ? '|#[a-zA-Z0-9äöüÄÖÜß_-]+' : '';
|
||||
const regex = new RegExp(
|
||||
'(`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + tagAlt + ')',
|
||||
'g'
|
||||
);
|
||||
|
||||
const elements: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
@@ -24,7 +33,30 @@ function renderInlineTokens(text: string): React.ReactNode[] {
|
||||
const token = match[0];
|
||||
const key = `${match.index}-${token}`;
|
||||
|
||||
if (token.startsWith('`') && token.endsWith('`')) {
|
||||
if (token.startsWith('#')) {
|
||||
const prevChar = match.index > 0 ? text[match.index - 1] : '';
|
||||
if (prevChar && !/[\s(]/.test(prevChar)) {
|
||||
// e.g. an URL fragment like https://x/#anchor: keep as plain text
|
||||
elements.push(token);
|
||||
} else {
|
||||
const tag = token.slice(1);
|
||||
elements.push(
|
||||
<span
|
||||
key={key}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
options?.onTagClick?.(tag);
|
||||
}}
|
||||
className="text-accent bg-accent-dim hover:bg-accent/20 rounded-btn px-0.5 cursor-pointer transition-colors"
|
||||
title={`Nach #${tag} filtern`}
|
||||
>
|
||||
{token}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
} else if (token.startsWith('`') && token.endsWith('`')) {
|
||||
elements.push(
|
||||
<code key={key} className="px-1.5 py-0.5 rounded bg-panel-elevated text-accent font-mono text-[0.9em] border border-border/50">
|
||||
{token.slice(1, -1)}
|
||||
@@ -92,7 +124,7 @@ function renderInlineTokens(text: string): React.ReactNode[] {
|
||||
* - `code`
|
||||
* - [label](url)
|
||||
*/
|
||||
export function renderInlineMarkdown(text: string): React.ReactNode[] {
|
||||
export function renderInlineMarkdown(text: string, options?: InlineMarkdownOptions): React.ReactNode[] {
|
||||
if (!text) return [];
|
||||
|
||||
if (text.includes('```')) {
|
||||
@@ -122,14 +154,14 @@ export function renderInlineMarkdown(text: string): React.ReactNode[] {
|
||||
</div>
|
||||
);
|
||||
} else if (part) {
|
||||
nodes.push(...renderInlineTokens(part));
|
||||
nodes.push(...renderInlineTokens(part, options));
|
||||
}
|
||||
});
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return renderInlineTokens(text);
|
||||
return renderInlineTokens(text, options);
|
||||
}
|
||||
|
||||
export function MarkdownViewer({ content, className = '' }: { content: string; className?: string }) {
|
||||
|
||||