Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0645ef8b3 | ||
|
|
2bb0dec744 | ||
|
|
687389df64 | ||
|
|
4542caaed5 | ||
|
|
7c9c71227a | ||
|
|
654a86fee0 | ||
|
|
8238c8a73f | ||
|
|
9f0f25c6a7 | ||
|
|
ca56220e68 | ||
|
|
89461f290c | ||
|
|
ae96aa5f2b | ||
|
|
4b67a9f5ee | ||
|
|
b48f0f2d59 | ||
|
|
a4e08bdff8 | ||
|
|
a7f2542c5f | ||
|
|
3de6c6088b | ||
|
|
3f96608992 | ||
|
|
6fb98d4002 | ||
|
|
ec481bdf0f | ||
|
|
e2b9daba75 | ||
|
|
f49d599d12 | ||
|
|
5a9c5bac37 | ||
|
|
9ab16c67a1 | ||
|
|
a927a94a95 | ||
|
|
9ff803f335 | ||
|
|
55112b940c | ||
|
|
d1bff796c6 | ||
|
|
20b907078f | ||
|
|
eb96fce44c | ||
|
|
3e4eb9b14f | ||
|
|
33c208b9a4 | ||
|
|
7860609201 | ||
|
|
fa02e8dee6 | ||
|
|
4f8bac0565 | ||
|
|
284ef1f723 | ||
|
|
a570069472 | ||
|
|
1e3ac9786f | ||
|
|
7ddebb8a94 | ||
|
|
6f2eb08489 | ||
|
|
17349a9573 | ||
|
|
c56bd4d943 | ||
|
|
34b5b4432f | ||
|
|
3fbe015c06 | ||
|
|
0a6777454e | ||
|
|
0d34308601 | ||
|
|
5a8922011d | ||
|
|
29926a02ef | ||
|
|
f91926c208 | ||
|
|
ef574945b8 | ||
|
|
66b28e1998 | ||
|
|
cd26626d84 | ||
|
|
d2b45daf4a | ||
|
|
1a56be572c | ||
|
|
c851b8e3ba | ||
|
|
4a792ce480 | ||
|
|
83b3f27b3e | ||
|
|
f2c92c4bec | ||
|
|
08081e6c1d | ||
|
|
4c2994d976 | ||
|
|
b883315914 | ||
|
|
0ca715ba64 | ||
|
|
8f13cf6c9d | ||
|
|
ac068248d7 | ||
|
|
82e777c877 | ||
|
|
8faed7a160 | ||
|
|
e5dd797201 | ||
|
|
5982563579 | ||
|
|
dc39208b4d | ||
|
|
9ed63281ed | ||
|
|
fb61003d15 | ||
|
|
68bc4d279c | ||
|
|
ae986171bf | ||
|
|
fdd6e38065 | ||
|
|
3cd2edd9b5 | ||
|
|
c1adb10b5e | ||
|
|
2927287904 | ||
|
|
c43c99dc0c | ||
|
|
3dfafaf2b8 | ||
|
|
19f98ae92f | ||
|
|
15dcfa0d96 | ||
|
|
82edc461ec | ||
|
|
28ebbe7263 | ||
|
|
ea537d010c | ||
|
|
ab97638361 | ||
|
|
1406650b7c | ||
|
|
37fa392d5d | ||
|
|
af77b9e809 | ||
|
|
db5c5cde9b | ||
|
|
d97f0e6dbf | ||
|
|
7aa76f76c9 | ||
|
|
ce66933acc | ||
|
|
8c2bfe2cee | ||
|
|
32bb92ecf8 | ||
|
|
f5281a5aea | ||
|
|
5b1241bcc3 | ||
|
|
599d53e0ea | ||
|
|
11e364471e | ||
|
|
5b636b66e2 | ||
|
|
da4fd5d826 | ||
|
|
f76b0f34ff | ||
|
|
474f021ab7 | ||
|
|
da258fdce2 | ||
|
|
d728a6a46c | ||
|
|
3d367ce633 | ||
|
|
5de3985704 | ||
|
|
c6a2cfb85c | ||
|
|
78cc81ff3a | ||
|
|
32b79b5d62 | ||
|
|
ed1a5e2f9d | ||
|
|
46e1137f18 | ||
|
|
0c641a2885 | ||
|
|
df06ab10c8 | ||
|
|
ec54b9c4f7 | ||
|
|
1b35bb3de4 | ||
|
|
79997eb188 | ||
|
|
21dd5e504e | ||
|
|
378872d7a4 | ||
|
|
66d6b97aff | ||
|
|
4a923eaf01 | ||
|
|
57a97a3750 | ||
|
|
945be2c1ad | ||
|
|
8362a852d5 | ||
|
|
25bb787a8e | ||
|
|
6abcfa655b | ||
|
|
5fff5d4361 | ||
|
|
e045f0edd4 | ||
|
|
c2f795c7a7 | ||
|
|
351013c7be | ||
|
|
fc637bc25d | ||
|
|
7888a255b1 | ||
|
|
052c7f2fbc | ||
|
|
3cd59b6ffa | ||
|
|
2303ccce68 | ||
|
|
597b6816a7 | ||
|
|
a1a6992a9f | ||
|
|
508717cc02 | ||
|
|
97d05b5b0d | ||
|
|
6a855fc32c | ||
|
|
ecf28d9549 | ||
|
|
08ae91fcdb | ||
|
|
f84ad43072 | ||
|
|
61541b2851 | ||
|
|
7c2d3178a5 | ||
|
|
3f0e596356 | ||
|
|
333d83304d | ||
|
|
2c9b4762fc | ||
|
|
7fa8f2431a | ||
|
|
8d93bdc3b8 | ||
|
|
8847897e11 | ||
|
|
af6e411c85 | ||
|
|
f8c9f4376d | ||
|
|
acd18abe8a | ||
|
|
9f0a77bcd5 | ||
|
|
3b9b1a5f8e | ||
|
|
d70d141e9a | ||
|
|
9fb33b23ec | ||
|
|
2f0369f7a2 | ||
|
|
61c7dea6a1 | ||
|
|
546bcba666 | ||
|
|
e243e328c7 | ||
|
|
05355989a8 | ||
|
|
b136e5dfad | ||
|
|
226c863a2a | ||
|
|
141f87f3a0 | ||
|
|
38b8c92899 | ||
|
|
1a49e6b304 | ||
|
|
bcb3c702ff | ||
|
|
86d2cd0138 | ||
|
|
d408e7d307 | ||
|
|
c181ffea54 | ||
|
|
375dda982b | ||
|
|
1d68bb9acb | ||
|
|
bc7bdc481e | ||
|
|
7a90e6dae3 | ||
|
|
2bfe74e66b | ||
|
|
1753208cba | ||
|
|
fd72500d17 | ||
|
|
9008d7c915 | ||
|
|
261579f0f2 | ||
|
|
6c154af251 | ||
|
|
ad4829796c | ||
|
|
2542e0e7b8 |
No files matched your search
+33
-2
@@ -18,10 +18,25 @@ BASE_URL=http://localhost:8080
|
|||||||
# Zeitzone für Fälligkeiten und Alarme (Standard Europe/Berlin).
|
# Zeitzone für Fälligkeiten und Alarme (Standard Europe/Berlin).
|
||||||
TZ=Europe/Berlin
|
TZ=Europe/Berlin
|
||||||
|
|
||||||
# true = jeder darf sich ein Konto anlegen; false = nur mit Einladungscode vom Admin.
|
# Registrierung ohne Einladung. Ohne Angabe entscheidet Administration → Server & Isolation.
|
||||||
# Lässt sich später in Administration → Server & Isolation ändern.
|
# 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
|
ALLOW_REGISTRATION=false
|
||||||
|
|
||||||
|
# Aufbewahrung: Darf jede Person „nie“ wählen, also Erledigtes und Gelöschtes dauerhaft behalten?
|
||||||
|
# false = nein (es gilt die Voreinstellung des Bereichs, z. B. 30 Tage), true = ja. Ohne Angabe
|
||||||
|
# entscheidet Administration → Server & Isolation (Standard: erlaubt).
|
||||||
|
# RETENTION_ALLOW_NEVER=false
|
||||||
|
|
||||||
# ─── Sicherheit ────────────────────────────────────────────────────────────────────────────────────
|
# ─── Sicherheit ────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Wessen Angaben zur Besucher-Adresse (X-Forwarded-For / X-Real-IP) geglaubt werden. Wichtig für die
|
# Wessen Angaben zur Besucher-Adresse (X-Forwarded-For / X-Real-IP) geglaubt werden. Wichtig für die
|
||||||
@@ -75,6 +90,13 @@ SMTP_TLS=true
|
|||||||
# true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver).
|
# true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver).
|
||||||
SMTP_SKIP_VERIFY=false
|
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) ──────────────────────────────────────────────────────────
|
# ─── Impressum und Datenschutz (optional) ──────────────────────────────────────────────────────────
|
||||||
# Werden auf der Anmeldeseite und in den Einstellungen verlinkt. Entweder als Link auf eine externe
|
# 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).
|
# Seite (nur http/https) oder als Markdown-Text, direkt hier oder als Datei (per Volume einbinden).
|
||||||
@@ -86,6 +108,9 @@ LEGAL_PRIVACY_CONTENT=
|
|||||||
# Pfad im Container, z. B. /legal/impressum.md
|
# Pfad im Container, z. B. /legal/impressum.md
|
||||||
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_URL=
|
||||||
|
|
||||||
# ─── Bilder und Audio in Notizen ───────────────────────────────────────────────────────────────────
|
# ─── 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
|
# Dateien liegen in data/media/ und sind nur für Personen sichtbar, die die Notiz lesen dürfen. Nicht mehr
|
||||||
@@ -117,6 +142,10 @@ STT_LANGUAGE=de
|
|||||||
STT_API_KEY=
|
STT_API_KEY=
|
||||||
# true = Mikrofon überall ausblenden, auch die Browser-Erkennung.
|
# true = Mikrofon überall ausblenden, auch die Browser-Erkennung.
|
||||||
STT_DISABLED=false
|
STT_DISABLED=false
|
||||||
|
# false = die Spracherkennung des Browsers ist verboten (Chrome/Edge schicken die Aufnahme an Google
|
||||||
|
# bzw. Microsoft). Mit Whisper-Server entfällt dann die Auswahl Whisper/Browser in den Kontoeinstellungen;
|
||||||
|
# ohne Whisper-Server gibt es dann keine Spracheingabe.
|
||||||
|
STT_ALLOW_BROWSER=true
|
||||||
|
|
||||||
# Schutzwerte für den Server-Modus
|
# Schutzwerte für den Server-Modus
|
||||||
# Längste Aufnahme in Sekunden; danach beendet der Browser die Aufnahme selbst.
|
# Längste Aufnahme in Sekunden; danach beendet der Browser die Aufnahme selbst.
|
||||||
@@ -129,3 +158,5 @@ STT_MAX_PARALLEL=1
|
|||||||
STT_PER_USER_PER_HOUR=60
|
STT_PER_USER_PER_HOUR=60
|
||||||
# Höchstdauer einer Erkennung in Sekunden, Warten in der Schlange eingeschlossen.
|
# Höchstdauer einer Erkennung in Sekunden, Warten in der Schlange eingeschlossen.
|
||||||
STT_TIMEOUT_SECONDS=120
|
STT_TIMEOUT_SECONDS=120
|
||||||
|
# Bittet den Whisper-Server, Stille zu überspringen (weniger erfundene Wiederholungen); false, falls der Server das Feld ablehnt.
|
||||||
|
STT_VAD_FILTER=true
|
||||||
Executable
+91
@@ -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}"
|
||||||
Executable
+51
@@ -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"
|
||||||
@@ -5,6 +5,9 @@ name: MindSift CI & Docker Release
|
|||||||
# push/merge main -> :latest
|
# push/merge main -> :latest
|
||||||
# git tag v1.2.0 -> :v1.2.0 and :stable
|
# git tag v1.2.0 -> :v1.2.0 and :stable
|
||||||
# git tag v1.2.0-rc.1 (pre-release) -> :v1.2.0-rc.1 only, :stable stays
|
# 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:
|
branches:
|
||||||
@@ -21,43 +24,16 @@ jobs:
|
|||||||
- 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:]')
|
|
||||||
|
|
||||||
# All tags of this run (space separated)
|
|
||||||
case "${GITHUB_REF:-}" in
|
|
||||||
refs/tags/*-*) TAGS="${DOCKER_TAG}" ;;
|
|
||||||
refs/tags/*) TAGS="${DOCKER_TAG} stable" ;;
|
|
||||||
refs/heads/main) TAGS="latest" ;;
|
|
||||||
*) TAGS="${DOCKER_TAG}" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
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=${TAGS%% *}" >> "$GITHUB_OUTPUT"
|
echo "actor=${GITHUB_ACTOR:-sandrodach}" >> "$GITHUB_OUTPUT"
|
||||||
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT"
|
|
||||||
# Releases (tags) show the tag as version in the app header
|
|
||||||
APP_VERSION=""
|
|
||||||
case "${GITHUB_REF:-}" in
|
|
||||||
refs/tags/*) APP_VERSION="${REF_NAME}" ;;
|
|
||||||
esac
|
|
||||||
echo "app_version=${APP_VERSION}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Target Docker image: ${IMAGE_NAME} tags: ${TAGS} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})"
|
|
||||||
|
|
||||||
- name: Login to Gitea Container Registry
|
- name: Login to Gitea Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -66,12 +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: |
|
||||||
IMAGE="${{ steps.prep.outputs.image }}"
|
IMAGE="${{ steps.prep.outputs.image }}"
|
||||||
TAG_ARGS=""
|
TAGS="${{ steps.plan.outputs.tags }}"
|
||||||
for t in ${{ steps.prep.outputs.tags }}; do TAG_ARGS="${TAG_ARGS} -t ${IMAGE}:${t}"; done
|
echo "ℹ️ ${{ steps.plan.outputs.reason }}"
|
||||||
echo "🚀 Building container & executing Frontend/Backend checks inside image: ${IMAGE} (tags: ${{ steps.prep.outputs.tags }})"
|
if [ "${{ steps.plan.outputs.action }}" = "retag" ]; then
|
||||||
DOCKER_BUILDKIT=1 docker build --progress=plain --build-arg APP_VERSION="${{ steps.prep.outputs.app_version }}" ${TAG_ARGS} .
|
SOURCE="${{ steps.plan.outputs.source_image }}"
|
||||||
echo "✅ All checks & tests passed inside container! Pushing..."
|
docker pull "${SOURCE}"
|
||||||
for t in ${{ steps.prep.outputs.tags }}; do docker push "${IMAGE}:${t}"; done
|
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
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Hinweise für Coding-Agenten
|
||||||
|
|
||||||
|
Regeln für die Arbeit an MindSift, die nicht aus dem Code selbst hervorgehen. Aufbau und Technik stehen in [Doku/](Doku/README.md), vor allem [architecture.md](Doku/architecture.md).
|
||||||
|
|
||||||
|
## Zuständigkeit
|
||||||
|
|
||||||
|
- **Die Website (Repo `MindSift-Website`) betreut ein anderer Agent.** Agenten, die an MindSift arbeiten, ändern dort nichts: keine Dateien, keine Commits, kein Push, auch nicht bei Änderungen wie Lizenz oder Funktionsnamen, die die Seite betreffen. Solche Punkte stattdessen dem Nutzer melden.
|
||||||
|
|
||||||
|
## Sprache und Stil
|
||||||
|
|
||||||
|
- **Deutsch:** Oberflächentexte, Fehlermeldungen für Nutzer, E-Mails, `CHANGELOG.md`, `README.md`, `Doku/`.
|
||||||
|
- **Englisch:** Code-Kommentare und Commit-Nachrichten.
|
||||||
|
- **Commit-Nachrichten** im Stil `feat(bereich): …`, `fix(bereich): …`, `docs: …`, `style(bereich): …`. Am Ende die Zeile `Co-Authored-By: …` des jeweiligen Agenten.
|
||||||
|
- Neuer Code richtet sich nach dem umgebenden Code: Benennung, Kommentardichte, vorhandene Helfer statt neuer Varianten.
|
||||||
|
|
||||||
|
## Umgebung
|
||||||
|
|
||||||
|
Auf dem Entwicklungsrechner sind weder Node noch Go installiert. Alles läuft in Containern (podman):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Frontend: Typen, Lint, Tests
|
||||||
|
podman run --rm -v "$PWD/frontend":/app:Z -w /app docker.io/library/node:22-alpine \
|
||||||
|
sh -c "npx tsc --noEmit && npx eslint src/… && npx vitest run"
|
||||||
|
|
||||||
|
# Backend: vet und Tests
|
||||||
|
podman run --rm -v "$PWD":/src:Z -w /src docker.io/library/golang:1.24-alpine \
|
||||||
|
sh -c "go vet ./... && go test ./internal/..."
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Linter-Warnungen `react-hooks/exhaustive-deps` gibt es schon länger; neuer Code soll keine zusätzlichen Warnungen bringen.
|
||||||
|
|
||||||
|
## Prüfen während der Arbeit
|
||||||
|
|
||||||
|
- **Gezielt prüfen:** `tsc`, die betroffenen Frontend-Tests, `go vet` und `go test`. Den vollen Build macht nur der Server (siehe Release-Ablauf); ein kompletter `podman build .` vorher ist doppelte Arbeit.
|
||||||
|
- **Oberfläche im Browser ansehen**, besonders Handy-Ansichten: Image bauen, im Demo-Modus starten, mit Playwright aus dem `e2e/`-Ordner prüfen (Vorbild: `e2e/screenshots.sh`). Handy-Emulation mit `isMobile: true, hasTouch: true`; Finger-Gesten lassen sich über das Chrome DevTools Protocol (`Input.dispatchTouchEvent`) nachstellen.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman build -q -t mindsift-check .
|
||||||
|
podman run -d --rm --name ms-check --network host -e PORT=18555 -e MINDA_DEMO=true \
|
||||||
|
-e DEMO_RESET_MINUTES=0 -e JWT_SECRET=check-only mindsift-check
|
||||||
|
# Demo-Anmeldung: admin/admin bzw. user/user
|
||||||
|
```
|
||||||
|
- **Zeitabhängiger Code** (Alarme, Tageszusammenfassung, Begrüßungen, Termine): nie `time.Now()` dort, wo eine übergebene Zeit gemeint ist. Die betroffenen Go-Tests zusätzlich mit verschiedenen `TZ`-Werten laufen lassen (z. B. `Europe/Berlin`, `America/Los_Angeles`, `Asia/Tokyo`). Ein uhrzeitabhängiger Test hat schon einmal einen Release verhindert, weil er nur vormittags grün war.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
- Bilder in `screenshots/` (README) sind **WebP**, keine PNG: Desktop und Theme-Raster 1280 px breit, Handy 480 px, Qualität 90. Das bringt rund ein Fünftel der PNG-Größe bei sauberer Schrift.
|
||||||
|
- Erzeugt werden sie mit `e2e/screenshots.sh`; das Skript nimmt PNGs auf und wandelt sie am Ende mit `cwebp` (Alpine-Container) um. Einzelne Bilder neu aufnehmen: denselben Ablauf aus `screenshots.mjs` verwenden und mit dem Umwandlungsschritt aus `screenshots.sh` konvertieren.
|
||||||
|
- Chromiums eigenes WebP (`canvas.toDataURL('image/webp')`) nicht verwenden: verlustfrei wird es größer als die PNG.
|
||||||
|
|
||||||
|
## Release-Ablauf
|
||||||
|
|
||||||
|
**Committen** ist jederzeit erlaubt. **Jeder Push ist ein Release**, also mit Version und CHANGELOG-Eintrag.
|
||||||
|
|
||||||
|
1. **Version erhöhen** (SemVer: Fehlerbehebung → Patch, neue Funktion → Minor):
|
||||||
|
- `frontend/package.json` (Feld `version`)
|
||||||
|
- `frontend/package-lock.json`: nur die beiden Versionszeilen von MindSift selbst (Zeile 3 und 9), nicht die gleichlautenden Versionen von Abhängigkeiten
|
||||||
|
- Versions-Badge oben in `README.md`
|
||||||
|
2. **CHANGELOG:** neuer Abschnitt `## [x.y.z] - JJJJ-MM-TT` unter `## [Unreleased]` (Format *Keep a Changelog*, Abschnitte Added/Changed/Fixed), unten die Vergleichslinks `[Unreleased]` und `[x.y.z]` anpassen. Einträge auf Deutsch und aus Sicht der Nutzer.
|
||||||
|
3. **Doku nachziehen**, wenn sich Verhalten oder API ändern (`README.md`, `Doku/api/rest.md`, `Doku/architecture.md`).
|
||||||
|
4. Committen, dann **nur nach `dev` pushen**.
|
||||||
|
5. **Warten, bis der `dev`-Build auf dem Server grün ist.** Schlägt er fehl: auf `dev` beheben, nicht nach `main` mergen.
|
||||||
|
6. Nach `main` mergen (`git merge --no-ff dev -m "Merge dev into main (vx.y.z)"`) und pushen.
|
||||||
|
7. **Warten, bis der `main`-Build grün ist.** Erst dann gilt das Release als draußen.
|
||||||
|
|
||||||
|
Ist ein Release nie gebaut worden (CI fehlgeschlagen), bleibt die Versionsnummer, und die Korrektur kommt in denselben CHANGELOG-Abschnitt.
|
||||||
|
|
||||||
|
**Git-Tags nur auf ausdrücklichen Wunsch.** Tags lassen zusätzliche Images bauen; genutzt werden nur `:dev` und `:latest`. Wenn gewünscht: `git tag -a vx.y.z -m "MindSift vx.y.z"` auf den Merge-Commit in `main` und den Tag pushen.
|
||||||
|
|
||||||
|
Fremde Branches (z. B. von anderen Agenten) werden vor dem Mergen geprüft.
|
||||||
|
|
||||||
|
### CI auf dem Git-Server
|
||||||
|
|
||||||
|
- Workflow: `.gitea/workflows/build-and-publish.yaml`, Entscheidung in `.gitea/scripts/build-plan.sh`.
|
||||||
|
- `dev` → Image `:dev`, mit allen Tests (Dockerfile: tsc, Lint, Frontend-Tests, `go vet`, Go-Tests).
|
||||||
|
- `main` → Image `:latest` mit `APP_VERSION=v<package.json-Version>`; überspringt die Tests, wenn `:dev` aus demselben Code gebaut wurde. Deshalb erst nach grünem `dev`-Build mergen.
|
||||||
|
- Tag `v*` → `:vx.y.z` und `:stable`.
|
||||||
|
- Status der letzten Läufe:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s "https://git.dsit.me/api/v1/repos/sandrodach/MindSift/actions/tasks?limit=4"
|
||||||
|
# workflow_runs[].head_branch / .status (success|failure|…) / .display_title
|
||||||
|
```
|
||||||
|
|
||||||
|
## Oberfläche
|
||||||
|
|
||||||
|
- **Einheitliche Bedienelemente:** Personen-Suche, Profilbild-Stapel, Zähler und weitere Muster stehen in [Doku/ui-patterns.md](Doku/ui-patterns.md). Vorhandene Komponenten verwenden statt neuer Varianten; neue gemeinsame Muster dort ergänzen.
|
||||||
|
- **Handy und Desktop:** Unter `sm` ist Handy-Layout, ab `lg` die volle Desktop-Ansicht (z. B. Seitenleiste neben der Notiz). 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.
|
||||||
+611
-1
@@ -6,6 +6,549 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.49.1] - 2026-11-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Aufgaben: Nach dem Sprung zu einer Aufgabe (z. B. über das Abzeichen „Erweitert“ in einer Notiz) öffnete sich deren Detailfenster bei jedem Zurückwechseln in den Aufgaben-Reiter erneut.
|
||||||
|
|
||||||
|
## [0.49.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Achtung:** MindSift steht ab dieser Version unter der [GNU Affero General Public License v3.0](LICENSE) (AGPLv3) statt unter The Unlicense. Nutzen, ändern und weitergeben bleibt frei, auch kommerziell; wer eine geänderte Fassung weitergibt oder als Dienst im Netz anbietet, muss deren Quellcode unter derselben Lizenz zugänglich machen. Versionen bis einschließlich 0.48.0 bleiben unter The Unlicense. „Über MindSift“ nennt die neue Lizenz und verlinkt weiterhin den Quellcode.
|
||||||
|
|
||||||
|
## [0.48.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Workspace, Hashtag-Seite: Die Fundstellen lassen sich direkt bearbeiten. Jede Notiz erscheint als feste Überschrift (Klick öffnet sie), darunter der normale Outliner mit den Punkten, die den Hashtag tragen, samt Unterpunkten. Tippen, Enter, Einrücken, Abhaken und Rückgängig wirken direkt in der Originalnotiz, auch gemeinsam mit anderen. Wer den Hashtag aus einem Punkt entfernt oder per Enter einen neuen Punkt ohne Hashtag anlegt, sieht ihn, bis der Cursor ihn verlässt. Die Rahmen um die einzelnen Notizen sind weg, die Seite sieht aus wie eine normale Notiz; Notizen laden erst beim Hineinscrollen.
|
||||||
|
|
||||||
|
## [0.47.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Verlinken-Fenster in Notizen: Ab 3 Buchstaben stehen unter den passenden Notizen auch passende **Punkte aus allen Notizen** (bis zu 8, Suchbegriff markiert, mit Notiztitel). Ein Klick verlinkt direkt diesen Punkt.
|
||||||
|
- Beispieldaten (Demo und auf Wunsch bei der Einrichtung): neuer Ordner „MindSift-Hilfe“ mit kurzen Anleitungen (Erste Schritte, Notizen, Aufgaben, Listen, Tastenkürzel, Spracheingabe, Teilen, API), eine Notiz „Zoom ausprobieren“ mit zehn Ebenen, eine tiefere Küchenplanung in „Hausrenovierung 2027“ und Beispiele für verknüpfte To-dos: „Backup-Konzept“ (Häkchen als Aufgabe mit Unteraufgaben) und die als Notiz vertiefte Aufgabe „Uptime-Monitoring“.
|
||||||
|
|
||||||
|
## [0.46.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Aufgaben: Mikrofon bei den Detailnotizen (rechts in der Kopfzeile). Diktierter Text wird an die Notiz angehängt und öffnet dafür das Bearbeitungsfeld; gespeichert wird wie gewohnt mit „Notiz speichern“.
|
||||||
|
|
||||||
|
## [0.45.2] - 2026-11-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- README und Doku auf den aktuellen Stand gebracht: Papierkörbe und Aufbewahrungsfristen, Spracheingabe pro Person, Strg+Z in den Aufgaben, kombiniertes Kanban in „Alle Aufgaben“, Markieren über mehrere Punkte, Notizen und Listen ohne Emoji, Hinweis bei neuer Version, Datenbank-Migrationen bis 16.
|
||||||
|
|
||||||
|
## [0.45.1] - 2026-11-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Workspace: Ordner haben keinen Aufklapp-Pfeil mehr; das offene bzw. geschlossene Ordnersymbol zeigt den Zustand. Leere und gefüllte Ordner stehen damit bündig, Notizen in einem Ordner sind gleich weit eingerückt wie Unterordner.
|
||||||
|
- Aufgaben am PC: Die Hashtag-Zeile scrollt nicht mehr seitlich. Passen nicht alle Hashtags hinein, endet sie mit „…“; ein Klick darauf zeigt alle in mehreren Zeilen, „Weniger“ klappt sie wieder ein.
|
||||||
|
|
||||||
|
## [0.45.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Notizen: Text lässt sich mit der Maus über mehrere Punkte hinweg markieren. Entf/Backspace löscht ihn wie in einem Texteditor (der Rest des ersten und des letzten Punkts wird ein Punkt, Punkte dazwischen samt Unterpunkten verschwinden), ein getipptes Zeichen ersetzt ihn, Strg+C kopiert, Strg+X schneidet aus. Strg+Z holt alles in einem Schritt zurück.
|
||||||
|
- Aufgaben: Strg+Z (Cmd+Z) macht Löschen, Verschieben (Spalten, Reihenfolge in Kanban und Liste) und Abhaken rückgängig. Nach dem Löschen erscheint unten „Aufgabe im Papierkorb · Rückgängig“, auch am Handy.
|
||||||
|
- Notizen, Aufgabenlisten und Listen lassen sich ohne Emoji anlegen: Alle Emoji-Auswähler haben „Entfernen“. Ohne Emoji steht der Titel allein; im Kopf einer Notiz zeigt ein dezentes Symbol, wo man eins wählen kann.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Kanban: Eine Aufgabe, die in eine selbst angelegte Erledigt-Spalte gezogen wurde, galt nach dem Umsortieren nicht als erledigt (nur die Standard-Spalte „Erledigt“ zählte).
|
||||||
|
|
||||||
|
## [0.44.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Neue Notizen bekommen 🗒️ als Standard-Symbol (vorher 📝 bzw. 📄).
|
||||||
|
- Aufgaben-Liste: „Gruppe“ und „Sortierung“ stehen direkt über der Liste neben den Reitern „Offen / Erledigt / Papierkorb“; „Erledigte in den Papierkorb“ und „Papierkorb leeren“ sind ein Papierkorb-Symbol rechts daneben.
|
||||||
|
- Kanban am PC: Das Board ist so hoch wie das Fenster; nur noch die Spalten scrollen, die Seite nicht mehr zusätzlich (wenn alle Spalten in eine Reihe passen).
|
||||||
|
- Quick Dump: Im aufgeklappten Bereich wählen Ziel und Liste nur aus, gespeichert wird mit Enter bzw. dem Knopf. Der Hinweis „(Klick speichert sofort …)“ entfällt.
|
||||||
|
- Spracheingabe über den Browser: Ein Tippen auf das Mikrofon hört über kurze Pausen hinweg zu und endet erst nach 5 Sekunden Stille.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Workspace: Shift+Klick ab einer angehefteten Notiz, die in ihrem Ordner angeklickt wurde, markierte alles ab der angehefteten Zeile ganz oben statt ab dem Ordner.
|
||||||
|
- Notizen: Ein Punkt mit Unterpunkten rückte durch den Aufklapp-Pfeil ein Stück nach rechts; jetzt stehen alle Punkte genau untereinander.
|
||||||
|
- Globale Suche: Das Eingabefeld springt beim Tippen nicht mehr hin und her (Lade-Symbol und Leeren-Knopf teilen sich einen festen Platz).
|
||||||
|
|
||||||
|
## [0.43.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Papierkorb für Aufgaben: Gelöschte Aufgaben, „Erledigte leeren“ und das automatische Aufräumen erledigter Aufgaben landen im Reiter „Papierkorb“ der Aufgaben, statt sofort weg zu sein. Von dort kommen sie unverändert zurück (mit Beschreibung, Unteraufgaben, Zuweisungen) oder werden endgültig gelöscht. Wer eine Liste sieht, sieht auch ihren Papierkorb; Zurückholen und Löschen braucht Bearbeitungsrecht. Auch `delete_task` per MCP legt in den Papierkorb.
|
||||||
|
- Eigener Papierkorb für die Triage: verworfene Gedanken öffnen sich über den Papierkorb-Knopf oben in der Triage (am Handy im Menü).
|
||||||
|
- Kontoeinstellungen → Aufbewahrung: für erledigte Aufgaben (Standard 30 Tage, danach in den Papierkorb), den Papierkorb der Aufgaben (30), der Triage (14), der Notizen (30) und abgehakte Listeneinträge (7) jeweils 7, 14, 30 oder 90 Tage, ein oder zwei Jahre oder nie. In geteilten Aufgabenlisten, Listen und Notizen gilt die Einstellung der Person, der sie gehören. Was beim Update schon erledigt oder gelöscht war, zählt ab dem Update.
|
||||||
|
- Administration: „Dauerhaftes Aufbewahren erlauben“ (bzw. `RETENTION_ALLOW_NEVER`). Ausgeschaltet gibt es „nie“ nicht mehr; dort gilt dann die Voreinstellung des Bereichs.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Die Auswahl „Erledigte Aufgaben löschen nach …“ und ihr Erklärsatz im Reiter „Erledigt“ entfallen; die Fristen stehen gesammelt unter Aufbewahrung. „Erledigte leeren“ fragt nicht mehr nach, weil sich alles zurückholen lässt.
|
||||||
|
- **Achtung:** Der Papierkorb der Notizen und abgehakte Einträge in Einkaufs- und Checklisten werden jetzt nach der Frist automatisch geleert (Standard 30 bzw. 7 Tage ab dem Update). Wer das nicht möchte, stellt unter Aufbewahrung „nie“ ein.
|
||||||
|
|
||||||
|
## [0.42.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Spracheingabe pro Person: Mit eigenem Whisper-Server wählt jede Person in den Kontoeinstellungen unter „Spracheingabe“, ob ihr Mikrofon den Whisper-Server (Standard) oder die Erkennung des Browsers nutzt. Die Wahl gilt sofort auf allen Geräten.
|
||||||
|
- `STT_ALLOW_BROWSER=false` verbietet die Spracherkennung des Browsers (Chrome und Edge schicken die Aufnahme an Google bzw. Microsoft), z. B. für den Einsatz in Firmen. Mit Whisper-Server entfällt dann die Auswahl, ohne Whisper-Server gibt es dann keine Spracheingabe.
|
||||||
|
|
||||||
|
## [0.41.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Hinweis bei neuer Version: Wurde der Server aktualisiert, erscheint in der offenen App ein Fenster „Neue Version verfügbar“ mit „Jetzt aktualisieren“ (lädt die Seite neu) und „Später“. Geprüft wird beim Zurückkehren in den Tab bzw. die App, alle 15 Minuten und nach einem Neustart des Servers. So bleibt auch die installierte App am Handy nicht auf einem alten Stand.
|
||||||
|
- Nach dem Neuladen der Seite bleibt der zuletzt offene Bereich (Triage, Workspace, Aufgaben, Listen, Einstellungen) offen, statt immer zur Triage zu springen.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Spracheingabe mit eigenem Whisper-Server: Quick-Dump-Leiste, Erfassen-Fenster am Handy und Halte-Knopf schicken die Aufnahme schon während des Sprechens in Stücken zur Erkennung (wie das Diktat). Der Text erscheint dadurch beim Sprechen, nach dem Stoppen fehlt nur noch das letzte Stück.
|
||||||
|
- Quick Dump: Das gewählte Ziel (z. B. „Direkt als Aufgabe“ in eine bestimmte Liste) bleibt nach dem Abschicken eingestellt.
|
||||||
|
- Quick Dump: Die gewählte Liste ist nicht mehr voll in der Akzentfarbe hinterlegt, sondern umrandet, damit die Emojis darauf gut zu sehen sind.
|
||||||
|
- Emoji-Auswähler am PC: kompakter wie ein Kontextmenü (kleinere Emojis und Schrift, ohne Titelleiste, schließt per Klick daneben oder Escape), mit eigener, abgehobener Fläche. Seine Farben folgen jetzt in allen Themes dem Theme (im dunklen Theme war er bisher grau). „Icon entfernen“ steht bei Notizen als Zeile unten.
|
||||||
|
- Workspace: Der Hinweis „n markiert · in einen Ordner oder auf den Papierkorb ziehen“ beim Markieren mehrerer Notizen entfällt. Aufheben geht weiter mit Escape oder einem Klick daneben.
|
||||||
|
|
||||||
|
## [0.40.1] - 2026-11-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Notizen: Wer über eine Aufgabe (oder einen anderen Sprung auf eine bestimmte Stelle) in eine Notiz kam und dort in einem Punkt Enter drückte, landete wieder in der angesprungenen Zeile, die dabei markiert war. Weitertippen überschrieb so z. B. den Titel der verknüpften Aufgabe. Die Sprungstelle wird jetzt nur einmal beim Öffnen angesteuert.
|
||||||
|
|
||||||
|
## [0.40.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Kanban in „Alle Aufgaben“: Die Spalten aller Aufgabenlisten werden nach Namen zusammengeführt (Groß- und Kleinschreibung egal), in Reihenfolge und Farbe der ersten Liste, die eine Spalte hat. Gleich benannte Spalten erscheinen einmal, verschieden benannte getrennt, auch bei den Erledigt-Spalten.
|
||||||
|
- Beim Ziehen sind Spalten, die die Liste der Karte nicht hat, ausgegraut und nehmen die Karte nicht an. Die Pfeile auf einer Karte führen zu den Nachbarspalten ihrer eigenen Liste; Status-Auswahl und Detailansicht zeigen wie bisher nur die Spalten der eigenen Liste.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Kanban in „Alle Aufgaben“: Aufgaben in eigenen Spalten einer Liste (z. B. „Review“) fehlten dort ganz, weil nur die Standardspalten angezeigt wurden. Ebenso tauchen Aufgaben, deren Spalte gelöscht wurde, jetzt in der ersten offenen Spalte auf statt nirgends.
|
||||||
|
|
||||||
|
## [0.39.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Icon beim Anlegen von Aufgabenlisten, Einkaufslisten und Notizen: Statt eines Textfelds öffnet ein Klick auf das Symbol denselben Emoji-Auswähler wie bei einer geöffneten Notiz (mit Suche und zuletzt verwendeten). Escape schließt nur den Auswähler, nicht das Fenster.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- „Als Notiz vertiefen“: Fälligkeit und Priorität stehen in der neuen Notiz direkt unter der Aufgabe, vor der Beschreibung und den Unteraufgaben statt ganz unten. Die Priorität steht auf Deutsch da („Wichtig“, „Dringend“, „Niedrig“).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Kanban: Beim Ablegen flackerte die Karte kurz, bevor sie an der neuen Stelle erschien. Jetzt nimmt sie direkt den Platz der Lücke ein.
|
||||||
|
- Beispiel-Konfiguration für den Whisper-Server (`docker-compose.yml`): Das Modell bleibt mit `WHISPER__TTL=-1` geladen; das bisher angegebene `STT_MODEL_TTL` kennt speaches 0.8.3 nicht, das Modell wurde deshalb nach 5 Minuten entladen.
|
||||||
|
|
||||||
|
## [0.38.1] - 2026-11-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Kanban: Beim Ziehen einer Karte öffnet sich an der Zielstelle sanft eine Lücke in Kartengröße, die übrigen Karten gleiten nach, und die Karte verlässt ihren alten Platz. Nach dem Ablegen leuchtet sie kurz an der neuen Stelle auf. Die blaue Linie, die beim Ziehen über Text und Symbole der Karten flackerte und die Ansicht zittern ließ, gibt es nicht mehr.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Nach dem Umsortieren von Aufgaben (Kanban oder Liste) zeigte eine gewählte Aufgabenliste plötzlich auch Aufgaben aus anderen Listen, bis man sie neu öffnete.
|
||||||
|
|
||||||
|
## [0.38.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- API-Schlüssel lassen sich bearbeiten: Name und Rechte ändern (Stift-Symbol in Einstellungen → API). Der Schlüssel selbst bleibt gleich, Geräte und Agenten müssen nichts anpassen; die Herkunfts-Badges zeigen sofort den neuen Namen.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Quick Dump: Wird der Text mehrzeilig, rutschen die Knöpfe in eine eigene Zeile darunter, und der Text bekommt die volle Breite.
|
||||||
|
- Notizen: Die Badges „Erweitert“ und „Unteraufgabe“ stehen jetzt direkt hinter dem Text in derselben Zeile statt in einer eigenen Zeile darunter.
|
||||||
|
|
||||||
|
## [0.37.2] - 2026-11-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- „Als Notiz vertiefen“ an Aufgabenzeile und Kanban-Karte legte bei einer schon verknüpften Aufgabe jedes Mal eine neue Notiz an. Jetzt öffnet der Knopf die verknüpfte Notiz und ist dafür dezent in der Akzentfarbe markiert; auch der Server legt keine zweite Notiz mehr an.
|
||||||
|
- „Alle Aufgaben“ zeigte bei jeder Aufgabe die Standard-Spalten („Wartet auf andere“) statt der Spalten ihrer Liste. Status-Auswahl, Statuspunkt und Detailansicht nutzen jetzt die Spalten der eigenen Liste, umbenannte Spalten erscheinen also auch dort.
|
||||||
|
- Spracheingabe mit eigenem Whisper-Server: Bei längeren Aufnahmen hängte Whisper manchmal schon gesagte Sätze noch einmal an. MindSift bittet den Server jetzt, Stille zu überspringen (`STT_VAD_FILTER`, Standard an), und entfernt eine wörtlich wiederholte Schlusspassage.
|
||||||
|
- Detailansicht am PC: Das Datumsfeld war zu schmal (das Kalender-Symbol lag über dem Datum), das Uhrzeitfeld unnötig breit.
|
||||||
|
|
||||||
|
## [0.37.1] - 2026-11-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Beim Herunterfahren wartet der Server, bis eine laufende Verdichtung des Notiz-Protokolls fertig ist, bevor er die Datenbank schließt. Dieselbe Lücke ließ einen Test auf dem Build-Server gelegentlich scheitern.
|
||||||
|
|
||||||
|
## [0.37.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Workspace: Mehrere Notizen auf einmal verschieben oder löschen. Am PC markierst du mit Strg/Cmd+Klick einzelne Notizen und mit Shift+Klick einen Bereich; ziehst du eine markierte Notiz in einen Ordner oder auf den Papierkorb, kommen alle markierten mit. Escape hebt die Markierung auf.
|
||||||
|
- Der Papierkorb unten in der Seitenleiste nimmt gezogene Notizen an, auch einzeln und am Handy (lange drücken, dann ziehen). Der Hinweis danach bietet „Rückgängig“.
|
||||||
|
|
||||||
|
## [0.36.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Aufgaben, die mit einer Notiz verknüpft sind („Erweitert“), gleichen jetzt auch den Titel ab, in beide Richtungen. Ihre Unteraufgaben sind mit eigenen Häkchen in der Notiz verknüpft (Badge „Unteraufgabe“): Haken und Text wandern hin und her, auch wenn du das Häkchen in der Notiz verschiebst. Eine neue Unteraufgabe erscheint in der Notiz unter der Aufgabe; ein neues Häkchen direkt eine Ebene unter der Aufgabe wird zur Unteraufgabe. Gelöscht wird nie automatisch: Was du auf einer Seite löschst, bleibt auf der anderen stehen.
|
||||||
|
|
||||||
|
## [0.35.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Erledigte Aufgaben räumen sich auf: Standardmäßig werden sie 30 Tage nach dem Abhaken gelöscht. Im Reiter „Erledigt“ stellst du die Frist ein (7, 30, 90 Tage, ein Jahr oder nie); in geteilten Listen gilt die Einstellung des Eigentümers. Neu ist dort auch „Erledigte leeren“ zum sofortigen Löschen. Aufgaben, die vor diesem Update erledigt wurden, bekommen ihre volle Frist ab dem Update, es verschwindet also nichts sofort.
|
||||||
|
|
||||||
|
## [0.34.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Aufgaben-Detailansicht am PC in zwei Spalten: links breit Titel, Unteraufgaben und Detailnotizen (mit größerem Eingabefeld), rechts schmal alle Einstellungen (Status, Priorität, Fälligkeit, Wiederholung, Liste, Zugewiesen, Hashtags, Herkunft). Am Handy bleibt die Ansicht, wie sie war.
|
||||||
|
|
||||||
|
## [0.33.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- „Als Notiz vertiefen“ bei einer Aufgabe: Die Aufgabe bleibt in ihrer Liste und wird zum ersten Häkchen der neuen Notiz, verknüpft wie eine Aufgabe aus einer Notiz („In Notiz“); Abhaken wirkt an beiden Stellen. Ein Gedanke aus der Triage wird wie bisher zur Notiz.
|
||||||
|
- Quick Dump: Die Listenzeile unter den Zielen richtet sich nach dem Ziel. Bei „Direkt als Aufgabe“ wählst du die Aufgabenliste (oder „Alle Aufgaben“), bei „Direkt auf Liste“ die Einkaufs- oder Checkliste; bei Quick Dump und Notiz gibt es keine Zeile mehr. Die zuletzt gewählte Liste bleibt je Ziel für die Sitzung gemerkt.
|
||||||
|
|
||||||
|
## [0.32.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Spracheingabe auch beim Hinzufügen von Aufgaben (am PC in der Eingabezeile über der Aufgabenliste, am Handy im Fenster „Neue Aufgabe anlegen“ hinter dem +-Knopf) und von Listeneinträgen (am PC in der Zeile, am Handy im Erfassen-Fenster): ein Mikrofon direkt im Feld wie beim Quick Dump. Diktierter Text wird an das Vorhandene angehängt.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Handy: Beim Anlegen einer Kanban-Spalte waren „Erledigt“ und „Hinzufügen“ rechts abgeschnitten; sie stehen jetzt unter dem Textfeld. In der Spaltenliste zeigt „Erledigt“ am Handy nur den Haken, damit der Mülleimer nicht mehr aus dem Bild rutscht.
|
||||||
|
|
||||||
|
## [0.31.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Kanban: Die Farbe jeder Spalte lässt sich einstellen. In „Spalten anpassen“ öffnet der Farbpunkt vor dem Namen die Auswahl: Grau, Anthrazit, Grün, Gelb, Blau, Rot, Lila oder die Akzentfarbe des Themes. Die Farbe erscheint im Spaltenkopf, als Statuspunkt in der Listenansicht und im Detail-Fenster. Blau und Lila passen sich hellen und dunklen Themes an.
|
||||||
|
|
||||||
|
## [0.30.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Aufgaben und Listen per curl und Smart Home: Ein API-Schlüssel mit Leserecht ruft z. B. mit `GET /api/v1/tasks?due=today&status=open` ab, was heute ansteht (Filter auch `overdue`, ein Datum, `status`, `taskListId`), und liest Listen. Mit Änderungsrecht hakt er Aufgaben ab, ändert oder löscht sie und pflegt Listeneinträge. Die Einstellungen zeigen dafür einen fertigen Home-Assistant-Sensor „Heute fällig“.
|
||||||
|
- Herkunfts-Badge: Was ein API-Schlüssel anlegt oder ändert, trägt dauerhaft eine Badge mit seinem Namen, vorne ein Webhook-Symbol (API) oder ein Roboter (MCP). Mouseover oder Tippen zeigt, wer wann angelegt und zuletzt geändert hat; in der Aufgaben-Detailansicht steht es unten. Die Badge gibt es jetzt auch an Listeneinträgen.
|
||||||
|
- Aufgaben in der Listenansicht zeigen ihren Status mit einem farbigen Punkt in der Farbe der Kanban-Spalte: am PC vor der Status-Auswahl, am Handy als Punkt mit Spaltenname (reine Anzeige, ändern weiter in der Detailansicht).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Webhooks und MCP heißen jetzt zusammen „API“: Die Einstellungs-Kategorie „Webhooks & KI-Agenten“ heißt „API“, die Schalter eines Schlüssels sind nach Aufgaben, Listen und Inbox geordnet (lesen, anlegen, ändern), und die Beispiele stehen in zwei Reitern „curl / Smart Home“ und „KI-Agenten (MCP)“. Die Rechte gelten für beide Zugänge gleich: „Aufgaben anlegen“ erlaubt jetzt auch `create_task` per MCP, „Aufgaben ändern“ auch das Einliefern von Aufgaben per Webhook. Bestehende Schlüssel behalten ihre Rechte.
|
||||||
|
- Die Badges „Webhook“ und „KI“ / „KI geändert“ sind durch die Herkunfts-Badge ersetzt. Sie verschwindet nicht mehr, wenn du einen Eintrag selbst bearbeitest.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Ein per Webhook oder MCP eingelieferter Gedanke verlor seine Herkunft, wenn er in der Triage zum Listeneintrag wurde.
|
||||||
|
|
||||||
|
## [0.29.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- KI-Agenten (MCP) können jetzt Aufgaben löschen (`delete_task`) sowie neue Aufgabenlisten (`create_task_list`) und Listen (`create_list`) anlegen. `list_task_lists` nennt zusätzlich die Spalten des Boards, damit ein Agent eine Aufgabe in die richtige Spalte schieben kann.
|
||||||
|
- Zeichen „KI“ an Aufgaben und Listeneinträgen, die ein KI-Agent per MCP angelegt hat, und „KI geändert“ an solchen, die er zuletzt geändert hat. So siehst du auf einen Blick, was von dir stammt. Sobald du den Eintrag selbst änderst oder abhakst, verschwindet das Zeichen.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Einstellungen: Export und Import haben eine eigene Kategorie „Export & Import“ (Server-Backups, Backup, Markdown-Archiv, Daten importieren). Die API-Schlüssel stehen weiter unter „Webhooks & KI-Agenten“.
|
||||||
|
|
||||||
|
## [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
|
## [0.9.0] - 2026-10-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -222,7 +765,74 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
- 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.
|
- 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.9.0...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.1...dev
|
||||||
|
[0.49.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.49.0...v0.49.1
|
||||||
|
[0.49.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.48.0...v0.49.0
|
||||||
|
[0.48.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.47.0...v0.48.0
|
||||||
|
[0.47.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.46.0...v0.47.0
|
||||||
|
[0.46.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.2...v0.46.0
|
||||||
|
[0.45.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.1...v0.45.2
|
||||||
|
[0.45.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.45.0...v0.45.1
|
||||||
|
[0.45.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.44.0...v0.45.0
|
||||||
|
[0.44.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.43.0...v0.44.0
|
||||||
|
[0.43.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.42.0...v0.43.0
|
||||||
|
[0.42.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.41.0...v0.42.0
|
||||||
|
[0.41.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.1...v0.41.0
|
||||||
|
[0.40.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.40.0...v0.40.1
|
||||||
|
[0.40.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.39.0...v0.40.0
|
||||||
|
[0.39.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.38.1...v0.39.0
|
||||||
|
[0.38.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.38.0...v0.38.1
|
||||||
|
[0.38.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.2...v0.38.0
|
||||||
|
[0.37.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.1...v0.37.2
|
||||||
|
[0.37.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.0...v0.37.1
|
||||||
|
[0.37.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.36.0...v0.37.0
|
||||||
|
[0.36.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.35.0...v0.36.0
|
||||||
|
[0.35.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.34.0...v0.35.0
|
||||||
|
[0.34.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.33.0...v0.34.0
|
||||||
|
[0.33.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.32.0...v0.33.0
|
||||||
|
[0.32.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.31.0...v0.32.0
|
||||||
|
[0.31.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.30.0...v0.31.0
|
||||||
|
[0.30.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.29.0...v0.30.0
|
||||||
|
[0.29.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.28.0...v0.29.0
|
||||||
|
[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.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.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.7.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.7.0
|
||||||
|
|||||||
+23
-6
@@ -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 lint && 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 \
|
||||||
@@ -38,16 +45,26 @@ COPY cmd/ ./cmd/
|
|||||||
COPY internal/ ./internal/
|
COPY internal/ ./internal/
|
||||||
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 \
|
||||||
CGO_ENABLED=0 go vet ./... && \
|
go mod tidy && \
|
||||||
CGO_ENABLED=0 go test -v ./... && \
|
if [ "$SKIP_TESTS" != "1" ]; then \
|
||||||
|
CGO_ENABLED=0 go vet ./... && \
|
||||||
|
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
|
||||||
|
|||||||
+90
-15
@@ -1,16 +1,19 @@
|
|||||||
# REST-API
|
# REST-API
|
||||||
|
|
||||||
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern nur Daten ein, über `POST /inbox` und `POST /webhooks/ingest`, und nur für die Ziele ihrer Scopes: `webhook:triage` (Inbox), `webhook:task` (Aufgaben), `webhook:list` (Listeneinträge). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Anfragekörper sind auf 2 MB begrenzt (Notiz-Sync 8 MB, Importe 101 MB). Im Demo-Modus antworten Endpunkte, die etwas verschicken, Server-Einstellungen ändern oder die gemeinsamen Demo-Konten verändern würden, mit `403` „In der Demo deaktiviert“. Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
|
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Schlüssel** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) haben Rechte (Scopes), die für REST und [MCP](#mcp-für-ki-agenten-post-apiv1mcp) gleich gelten, siehe [API-Schlüssel](#api-schlüssel-rechte-und-herkunft). Alles andere, auch Notizen, Einstellungen und Live-Streams, antwortet einem Schlüssel mit `403`. Zeitstempel sind ISO-8601 (UTC). Anfragekörper sind auf 2 MB begrenzt (Notiz-Sync 8 MB, Importe 101 MB). Im Demo-Modus antworten Endpunkte, die etwas verschicken, Server-Einstellungen ändern oder die gemeinsamen Demo-Konten verändern würden, mit `403` „In der Demo deaktiviert“. Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
|
||||||
|
|
||||||
## Öffentlich
|
## Öffentlich
|
||||||
|
|
||||||
| Methode | Pfad | Zweck |
|
| Methode | Pfad | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/health` | Lebenszeichen |
|
| GET | `/health` | Lebenszeichen |
|
||||||
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) |
|
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`, `customLinkLabel`, `customLinkUrl`), `voice` (`mode` = `server`/`browser`/`off`, `maxSeconds`, `browserAllowed`) |
|
||||||
| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist |
|
| 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; `429` nach 20 Versuchen pro Adresse in 15 Minuten |
|
| 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/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/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
|
||||||
@@ -31,7 +34,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` |
|
| 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 |
|
| 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`. `notificationSettings` (`paused`, `includeOwnWatched`, `events.{due,shared,taskDone,watched}.{email,discord}`, `dailySummary.{enabled,time,email,discord}`) für Benachrichtigungen. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` |
|
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`, `retention` (`doneTasks`, `taskTrash`, `triageTrash`, `noteTrash`, `listDone`: Tage aus 7, 14, 30, 90, 365, 730 oder 0 = nie; immer alle fünf, `400` bei anderen Werten oder bei 0, wenn der Server „nie“ abgeschaltet hat), `voiceInput` (`""` = Whisper-Server, `browser`), `doneRetentionDays` (älteres Einzelfeld für `retention.doneTasks`). `GET /auth/me` liefert dazu `retention` und `retentionNeverAllowed`. `notificationSettings` (`paused`, `includeOwnWatched`, `events.{due,shared,taskDone,watched}.{email,discord}`, `dailySummary.{enabled,time,email,discord}`) für Benachrichtigungen. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` |
|
||||||
| 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` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.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 (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
|
| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
|
||||||
@@ -46,7 +49,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| DELETE | `/user/account` | Eigenes Konto mit allen Daten löschen (`{"password"}` nötig); `403` für den letzten Server-Admin. `/auth/me` liefert dafür `isLastAdmin` |
|
| 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 |
|
| 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` |
|
| 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` (Standard `["webhook:triage"]`) |
|
| 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 |
|
||||||
|
| PATCH | `/keys/{id}` | Name (`name`) und/oder Rechte (`scopes`) ändern; der Schlüssel selbst bleibt gleich |
|
||||||
| DELETE | `/keys/{id}` | API-Key löschen |
|
| DELETE | `/keys/{id}` | API-Key löschen |
|
||||||
|
|
||||||
### Globale Suche (`GET /api/v1/search?q=`)
|
### Globale Suche (`GET /api/v1/search?q=`)
|
||||||
@@ -74,17 +79,69 @@ Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### MCP für KI-Agenten (`POST /api/v1/mcp`)
|
||||||
|
|
||||||
|
MindSift ist ein [MCP](https://modelcontextprotocol.io)-Server (Streamable HTTP, zustandslos, nur JSON-RPC 2.0 per `POST`; kein Server-Stream, `GET` antwortet `405`, Batches werden abgelehnt). Anmeldung wie überall per `X-API-Key` oder `Authorization: Bearer`; eine Sitzung darf alles, ein API-Key nur die Werkzeuge seiner Scopes, und `tools/list` zeigt ihm nur diese. Protokollversionen `2025-06-18`, `2025-03-26`, `2024-11-05`. Unterstützte Methoden: `initialize`, `ping`, `tools/list`, `tools/call`; Benachrichtigungen antworten `202`. Fehler eines Werkzeugs kommen als `isError`-Ergebnis zurück, damit der Agent sie lesen kann; Ergebnisse stehen als `structuredContent` (Listen als `{items}`) und als JSON-Text.
|
||||||
|
|
||||||
|
| Werkzeug | Scope | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| `list_task_lists` | `tasks:read` | Aufgabenlisten mit `permission`, offenen Aufgaben und Board-Spalten (`columns`: gültige Werte für `kanbanStatus`) |
|
||||||
|
| `create_task_list` | `tasks:write` | Eigene Aufgabenliste mit den Standardspalten anlegen (`title`, `icon`) |
|
||||||
|
| `list_tasks` | `tasks:read` | Aufgaben (`taskListId`, `status` open/done/all, `dueBefore` JJJJ-MM-TT, `limit`); fällige zuerst |
|
||||||
|
| `get_task` | `tasks:read` | Eine Aufgabe |
|
||||||
|
| `create_task` | `tasks:write` | Aufgabe anlegen (`content`, `taskListId`, `priority`, `dueDate`, `notes`); Quelle `agent` |
|
||||||
|
| `update_task` | `tasks:write` | Felder ändern (`content`, `isCompleted`, `dueDate`, `priority`, `notes`, `kanbanStatus`, `taskListId`, `tags`) |
|
||||||
|
| `complete_task` | `tasks:write` | Abhaken (`completed` false = wieder öffnen) |
|
||||||
|
| `delete_task` | `tasks:write` | Aufgabe löschen (z. B. versehentlich angelegte); sie kommt in den Papierkorb der Aufgaben |
|
||||||
|
| `list_lists` | `lists:read` | Geteilte Listen mit offenen Einträgen |
|
||||||
|
| `create_list` | `lists:write` | Liste anlegen (`title`, `icon`, `listType` shopping/checklist/vacation) |
|
||||||
|
| `get_list` | `lists:read` | Liste mit Einträgen |
|
||||||
|
| `add_list_item` | `lists:write` | Eintrag hinzufügen (`listId`, `content`, `category`, `quantity`) |
|
||||||
|
| `check_list_item` | `lists:write` | Eintrag abhaken (`completed` false = zurück) |
|
||||||
|
| `update_list_item` | `lists:write` | Text, Kategorie, Menge ändern |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
Was ein Agent anlegt oder ändert, bekommt eine dauerhafte Herkunft (`origin`, siehe [API-Schlüssel](#api-schlüssel-rechte-und-herkunft)). `create_task` geht auch mit `webhook:task`, `add_list_item` auch mit `webhook:list`.
|
||||||
|
|
||||||
|
Freigaben gelten wie für die Person selbst: Leser-Listen lassen sich nicht ändern, fremde Listen sind unsichtbar. Einrichtung z. B. in Claude Code: `claude mcp add --transport http mindsift https://mindsift.example.com/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`; die Einstellungen zeigen die Varianten für Codex (`config.toml`) und JSON-Konfigurationen (Claude Desktop, Cursor). claude.ai und ChatGPT im Browser verlangen OAuth und werden noch nicht unterstützt.
|
||||||
|
|
||||||
|
### API-Schlüssel: Rechte und Herkunft
|
||||||
|
|
||||||
|
Ein Schlüssel darf nur diese Endpunkte und braucht dafür eines der genannten Rechte:
|
||||||
|
|
||||||
|
| Recht (Scope) | Bedeutung | REST | MCP-Werkzeuge |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `webhook:triage` | Inbox füllen | `POST /inbox`, `POST /webhooks/ingest` (Ziel `triage`) | `capture_to_inbox` |
|
||||||
|
| `tasks:read` | Aufgaben lesen | `GET /task-lists`, `GET /tasks` | `list_task_lists`, `list_tasks`, `get_task`, `search` |
|
||||||
|
| `webhook:task` | Aufgaben anlegen | `POST /inbox` bzw. `/webhooks/ingest` mit Ziel `task` | `create_task` |
|
||||||
|
| `tasks:write` | Aufgaben ändern (schließt Anlegen ein) | dazu `PATCH`/`DELETE /thoughts/{id}` | dazu `update_task`, `complete_task`, `delete_task`, `create_task_list` |
|
||||||
|
| `lists:read` | Listen lesen | `GET /lists`, `GET /lists/{id}` | `list_lists`, `get_list`, `search` |
|
||||||
|
| `webhook:list` | Listeneinträge hinzufügen | `POST /lists/{id}/items`, `/webhooks/ingest` mit Ziel `list_item` | `add_list_item` |
|
||||||
|
| `lists:write` | Listen ändern (schließt Hinzufügen ein) | dazu `PATCH`/`PUT /lists/{id}/items/{itemId}` | dazu `check_list_item`, `update_list_item`, `create_list` |
|
||||||
|
|
||||||
|
Die Ids `webhook:…` stammen aus der Zeit, als Schlüssel nur einliefern konnten, und bleiben für bestehende Schlüssel gleich.
|
||||||
|
|
||||||
|
`GET /tasks` filtert für Skripte und Smart Home: `status=open|done|all` (Standard alle), `due=today`, `due=overdue` (nur offene) oder `due=JJJJ-MM-TT` (fällig bis einschließlich); Tage zählen in der Zeitzone des Servers (`TZ`). Ohne Parameter bleibt die Antwort wie bisher.
|
||||||
|
|
||||||
|
**Herkunft:** Was ein Schlüssel anlegt oder ändert, trägt an Aufgabe, Gedanke oder Listeneintrag das Feld `origin`: `createdVia`/`createdKey` (angelegt) und `changedVia`/`changedKey`/`changedAt` (zuletzt per Schlüssel geändert), mit `api` oder `mcp` als Zugang und dem Namen des Schlüssels (gelöscht: „Gelöschter Schlüssel“). Die Herkunft bleibt dauerhaft, auch wenn die Person den Eintrag danach selbst bearbeitet, und geht beim Umwandeln in der Triage mit (auch in einen Listeneintrag). Eine MCP-Anfrage aus einer Anmeldung heraus zählt als `mcp` ohne Schlüssel.
|
||||||
|
|
||||||
## Gedanken, Triage, Aufgaben
|
## Gedanken, Triage, Aufgaben
|
||||||
|
|
||||||
| Methode | Pfad | Zweck |
|
| Methode | Pfad | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 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`, `assignees`, `recurrence` = `daily`/`weekly`/`monthly`/`yearly`/leer); Löschen. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
|
| GET/PATCH/DELETE | `/thoughts/{id}` | Einzelabruf; Teiländerung (`content`, `isCompleted`, `dueDate`, `priority`, `taskListId`, `kanbanStatus`, `notes`, `subtasks`, `tags`, `assignees`, `recurrence` = `daily`/`weekly`/`monthly`/`yearly`/leer); Löschen: eine Aufgabe kommt in den Papierkorb der Aufgaben, ein Gedanke aus Posteingang oder Triage-Papierkorb ist endgültig weg. `assignees` ersetzt die Zuweisungen; nur Personen mit Zugriff auf die Liste, sonst `403` |
|
||||||
| DELETE | `/thoughts/discarded/purge` | Papierkorb leeren |
|
| DELETE | `/thoughts/discarded/purge` | Papierkorb der Triage (verworfene Gedanken) 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=&status=&due=` | Aufgaben (optional einer Liste; Filter siehe [API-Schlüssel](#api-schlüssel-rechte-und-herkunft)) |
|
||||||
|
| POST | `/tasks/clear-done` | Erledigte Aufgaben in den Papierkorb der Aufgaben (`taskListId`, leer = alle bearbeitbaren) → `{deleted}` |
|
||||||
|
| GET | `/tasks/trash?taskListId=` | Papierkorb der Aufgaben (einer Liste, sonst alle sichtbaren): `id`, `content`, `taskListId`, `isCompleted`, `trashedAt`, `trashedBy` (Name, leer = automatisch), `canEdit` |
|
||||||
|
| POST | `/tasks/trash/{id}/restore` | Aufgabe unverändert zurückholen (mit Zuweisungen; eine gelöschte Liste macht sie zur eigenen Aufgabe ihres Erstellers) → Aufgabe |
|
||||||
|
| DELETE | `/tasks/trash/{id}` | Endgültig löschen (Bearbeitungsrecht auf die Liste, sonst `403`) |
|
||||||
|
| POST | `/tasks/trash/empty` | Papierkorb leeren (`taskListId`, leer = alle bearbeitbaren) → `{deleted}` |
|
||||||
| 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` |
|
| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` |
|
||||||
@@ -93,7 +150,7 @@ Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
|
|||||||
|
|
||||||
| Methode | Pfad | Zweck |
|
| Methode | Pfad | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET/POST | `/task-lists` | Listen (`permission` je Liste: `owner`/`editor`/`viewer`); anlegen (`title`, `icon`, `color`, `teamId`) |
|
| GET/POST | `/task-lists` | Listen (`permission` je Liste: `owner`/`editor`/`viewer`); anlegen (`title`, `icon`, `color`, `teamId`; `icon: ""` = ohne Symbol, fehlt es: 📋) |
|
||||||
| PUT/DELETE | `/task-lists/{id}` | Ändern (`title`, `icon`, `color`, `teamId`, `columns`) – nicht für Leser; Löschen nur Eigentümer |
|
| PUT/DELETE | `/task-lists/{id}` | Ändern (`title`, `icon`, `color`, `teamId`, `columns`) – nicht für Leser; Löschen nur Eigentümer |
|
||||||
| GET | `/task-lists/{id}/shares` | Freigaben |
|
| GET | `/task-lists/{id}/shares` | Freigaben |
|
||||||
| POST | `/task-lists/{id}/shares` | Nur Eigentümer. Body: `targetType` (`team`/`user`), `targetId`, `permission` (`editor`/`viewer`, Standard `editor`). Erneutes Senden für dasselbe Ziel ändert die Stufe |
|
| POST | `/task-lists/{id}/shares` | Nur Eigentümer. Body: `targetType` (`team`/`user`), `targetId`, `permission` (`editor`/`viewer`, Standard `editor`). Erneutes Senden für dasselbe Ziel ändert die Stufe |
|
||||||
@@ -114,7 +171,7 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
|
|||||||
| DELETE | `/workspace/folders/shared/{id}/shares/{shareId}` | Freigabe eines geteilten Ordners entfernen |
|
| 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`; `icon: ""` = ohne Symbol, fehlt es: 🗒️) |
|
||||||
| 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/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/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` |
|
| 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` |
|
||||||
@@ -124,7 +181,14 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
|
|||||||
| 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 | `/documents/{id}/shares` | 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 |
|
| 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 |
|
| 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}/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` | `{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}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen |
|
||||||
@@ -174,7 +238,7 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
|||||||
|
|
||||||
| Methode | Pfad | Zweck |
|
| Methode | Pfad | Zweck |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) |
|
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`; `icon: ""` = ohne Symbol, fehlt es: 🛒) |
|
||||||
| GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen |
|
| GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen |
|
||||||
| GET/POST | `/lists/{id}/shares` | Freigaben abrufen bzw. anlegen (`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 |
|
| DELETE | `/lists/{id}/shares/{shareId}` | Freigabe entfernen |
|
||||||
@@ -182,6 +246,7 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
|||||||
| 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 |
|
||||||
|
|
||||||
@@ -202,9 +267,11 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
|||||||
| PUT | `/admin/users/{id}/organization` | Organisation setzen (`organizationId`, leer = keine); entzieht Freigaben, Gruppen und Zuweisungen der alten |
|
| 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`) |
|
| GET/POST | `/admin/organizations` | Organisationen mit `memberCount`; anlegen (`name`) |
|
||||||
| PUT/DELETE | `/admin/organizations/{id}` | Umbenennen (`name`); löschen (Mitglieder sind danach ohne Organisation) |
|
| PUT/DELETE | `/admin/organizations/{id}` | Umbenennen (`name`); löschen (Mitglieder sind danach ohne Organisation) |
|
||||||
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
|
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen (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 | `/admin/invites` | Einladungen auflisten bzw. anlegen (`email`, `role`, `organizationId`, `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 |
|
||||||
|
| 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 |
|
| 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/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`) |
|
| GET | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) |
|
||||||
@@ -212,6 +279,7 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
|||||||
| 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 | `/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/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) |
|
||||||
| GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen |
|
| 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
|
||||||
|
|
||||||
@@ -221,7 +289,14 @@ 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)
|
# Was heute ansteht (Recht tasks:read), z. B. für einen Home-Assistant-Sensor
|
||||||
|
curl "https://mindsift.example.com/api/v1/tasks?due=today&status=open" -H "X-API-Key: mindsift_sec_…"
|
||||||
|
|
||||||
|
# Aufgabe abhaken (Recht tasks:write)
|
||||||
|
curl -X PATCH https://mindsift.example.com/api/v1/thoughts/th_… \
|
||||||
|
-H "X-API-Key: mindsift_sec_…" -H "Content-Type: application/json" -d '{"isCompleted":true}'
|
||||||
|
|
||||||
|
# Einkaufsliste per API-Key ergänzen (Recht webhook:list oder lists:write)
|
||||||
curl -X POST https://mindsift.example.com/api/v1/webhooks/ingest \
|
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 '{"target":"list_item","listId":"lst_…","content":"Hafermilch","quantity":"2"}'
|
-d '{"target":"list_item","listId":"lst_…","content":"Hafermilch","quantity":"2"}'
|
||||||
|
|||||||
+21
-6
@@ -49,7 +49,7 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
2. `storage.NewStore()` öffnet SQLite (`busy_timeout`, WAL, max. 8 Verbindungen) und führt `migrate()` aus.
|
2. `storage.NewStore()` öffnet SQLite (`busy_timeout`, WAL, max. 8 Verbindungen) und führt `migrate()` aus.
|
||||||
3. SMTP-Werte aus der Umgebung werden in `system_settings` übernommen, sofern dort noch nichts steht.
|
3. SMTP-Werte aus der Umgebung werden in `system_settings` übernommen, sofern dort noch nichts steht.
|
||||||
4. Mit Demo-Flag legt `EnsureDemoAccounts` die Konten `admin/admin` und `user/user` an.
|
4. Mit Demo-Flag legt `EnsureDemoAccounts` die Konten `admin/admin` und `user/user` an.
|
||||||
5. `api.NewServer` startet den **Alarm-Ticker** (jede Minute), die **Verdichtung des Notiz-Logs** (beim Start, dann alle 6 Stunden) und baut den Router. Server-Timeouts: Lesen 15 s, Schreiben 30 s, Leerlauf 60 s. Beim Beenden wird sauber heruntergefahren.
|
5. `api.NewServer` startet den **Alarm-Ticker** (jede Minute), die **Verdichtung des Notiz-Logs** (beim Start, dann alle 6 Stunden), die **Aufbewahrungsfristen** (2 Minuten nach dem Start, dann stündlich; nicht in der Demo) und baut den Router. Server-Timeouts: Lesen 15 s, Schreiben 30 s, Leerlauf 60 s. Beim Beenden wird sauber heruntergefahren.
|
||||||
|
|
||||||
## Konfiguration
|
## Konfiguration
|
||||||
|
|
||||||
@@ -63,25 +63,33 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen und Limits für Registrierung und „Passwort vergessen“ (`internal/api/loginguard.go`), `false` = aus |
|
| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen 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 |
|
| `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`) |
|
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) |
|
||||||
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
|
| `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. 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`) |
|
| `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`. |
|
| `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_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) |
|
| `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 |
|
| `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_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_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 |
|
| `MEDIA_DEMO_MAX_MB` | `3` | Nur im Demo-Modus: maximale Dateigröße für Medien-Uploads in MB |
|
||||||
|
| `RETENTION_ALLOW_NEVER` | leer | `false` verbietet „nie“ bei den Aufbewahrungsfristen (Kontoeinstellungen → Aufbewahrung); wer „nie“ gewählt hatte, bekommt die Voreinstellung des Bereichs. `true` erlaubt es fest. Leer: Schalter in Administration → Server & Isolation (Standard: erlaubt) |
|
||||||
| `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser |
|
| `STT_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_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_DISABLED` | `false` | `true` blendet die Spracheingabe überall aus |
|
||||||
|
| `STT_ALLOW_BROWSER` | `true` | `false` verbietet die Spracherkennung des Browsers (Chrome/Edge schicken die Aufnahme an Google/Microsoft). Mit Whisper-Server entfällt dann die Auswahl „Whisper-Server / Browser“ in den Kontoeinstellungen; ohne Whisper-Server (und in der Demo) gibt es dann keine Spracheingabe. Mit `true` wählt jede Person selbst, Standard ist der Whisper-Server. |
|
||||||
| `STT_MAX_SECONDS`, `STT_MAX_UPLOAD_MB` | `600` / `25` | Längste Aufnahme (stoppt im Browser), größte Aufnahme |
|
| `STT_MAX_SECONDS`, `STT_MAX_UPLOAD_MB` | `600` / `25` | Längste Aufnahme (stoppt im Browser), größte Aufnahme |
|
||||||
| `STT_MAX_PARALLEL`, `STT_PER_USER_PER_HOUR`, `STT_TIMEOUT_SECONDS` | `1` / `60` / `120` | Gleichzeitige Erkennungen (weitere warten), Erkennungen pro Person und Stunde, Höchstdauer inkl. Warten |
|
| `STT_MAX_PARALLEL`, `STT_PER_USER_PER_HOUR`, `STT_TIMEOUT_SECONDS` | `1` / `60` / `120` | Gleichzeitige Erkennungen (weitere warten), Erkennungen pro Person und Stunde, Höchstdauer inkl. Warten |
|
||||||
|
| `STT_VAD_FILTER` | `true` | Bittet den Whisper-Server, Stille zu überspringen (`vad_filter`, speaches); weniger erfundene Wiederholungen. `false`, falls ein Server das Feld ablehnt. Unabhängig davon entfernt MindSift eine wörtlich wiederholte Schlusspassage. |
|
||||||
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
| `LEGAL_IMPRINT_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`, `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_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.
|
||||||
|
|
||||||
@@ -92,7 +100,7 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
|
|||||||
- **Einladungscodes:** `INV-` plus 100 Zufallsbits in Base32. Die Registrierung beansprucht den Code, bevor das Konto entsteht (kein doppeltes Einlösen).
|
- **Einladungscodes:** `INV-` plus 100 Zufallsbits in Base32. Die Registrierung beansprucht den Code, bevor das Konto entsteht (kein doppeltes Einlösen).
|
||||||
- **JWT und Sitzungen:** HS256, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Jede Anmeldung legt eine Zeile in `sessions` an (Gerät, IP, letzte Nutzung); `jti` ist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im Header `X-Session-Token` ein neues für dieselbe Sitzung; das Frontend übernimmt es (`request()` in `lib/api.ts`). Aktive Geräte bleiben so angemeldet, unbenutzte fallen nach 30 Tagen heraus (`internal/api/sessions.go`). Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
- **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.
|
- **Logout** beendet die Sitzung des Geräts; `GET /auth/sessions` listet die Geräte, `DELETE /auth/sessions/{id}` meldet eines ab. Passwort und Login-E-Mail ändern sich nur mit dem aktuellen Passwort (`currentPassword`, falsche Eingaben zählen für die Login-Sperre); ein neues Passwort meldet alle anderen Geräte ab, dieses Gerät erhält per `X-Session-Token` ein neues Token. Ist SMTP eingerichtet, geht eine Hinweismail an die bisherige Adresse.
|
||||||
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403`.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
@@ -106,7 +114,7 @@ Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, beschrieben in
|
|||||||
|
|
||||||
## Benachrichtigungen
|
## 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) und eigener Discord-Webhook aus den Alarmen –, 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), 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.
|
`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
|
## Link-Vorschau
|
||||||
|
|
||||||
@@ -114,7 +122,7 @@ Punkte mit einer reinen https-Adresse zeigen darüber eine Karte (`LinkPreviewCa
|
|||||||
|
|
||||||
## Spracherkennung
|
## 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).
|
Der Admin richtet sie über Umgebungsvariablen ein (`STT_*`, siehe oben); Administration → Server & Isolation zeigt den aktiven Modus, den Zustand des Whisper-Servers und bietet eine Testaufnahme. Mit Whisper-Server wählt jede Person unter Kontoeinstellungen → Spracheingabe zwischen Whisper (Standard) und der Erkennung des Browsers (`users.voice_input`), sofern `STT_ALLOW_BROWSER` das nicht verbietet; `/auth/status` meldet dazu `voice.browserAllowed`, die App wendet die Wahl in `lib/voice.ts` an. Alle Mikrofon-Knöpfe (Quick-Dump-Leiste, mobiles Erfassen-Sheet, Halten des +-Knopfs) nutzen denselben Hook `useVoiceInput` (`frontend/src/lib/voice.ts`, `useVoiceInput.ts`): `server` nimmt auf und zeigt „Wird erkannt…“ (Quick Dump, Erfassen-Sheet und Halte-Knopf schicken die Aufnahme schon beim Sprechen in Stücken, wie das Diktat), `browser` liefert Text schon beim Sprechen und endet nach 5 s Stille, `off` oder ein Browser ohne die nötige Funktion blendet das Mikrofon aus. Im Server-Modus zeigen Wellen den echten Mikrofonpegel (`VoiceWave`), die Aufnahme endet spätestens nach `STT_MAX_SECONDS`. Der Browser nimmt per `MediaRecorder` auf (WebM/Opus, in Safari MP4/AAC) und schickt die Aufnahme an `POST /voice/transcribe`; MindSift reicht sie an `STT_URL/v1/audio/transcriptions` weiter (`model`, `language`) und gibt nur den Text zurück. Nichts wird gespeichert oder protokolliert außer Fehlern (`internal/api/voice.go`). Beim Start wartet MindSift auf den Whisper-Server und stößt den Download des Modells an, falls es fehlt (`POST /v1/models/<id>` bei speaches).
|
||||||
|
|
||||||
`docker-compose.yml` enthält den Dienst `whisper` (speaches 0.8.3, CPU) als auskommentierten Block: Rauten entfernen, `STT_URL=http://whisper:8000` in `.env` setzen, `docker compose up -d`; das Modell liegt in `./whisper` (Ordner gehört UID 1000). Gemessen auf einem Desktop mit 16 Kernen (int8) für einen Satz von 4 s: `Systran/faster-whisper-small` 1,6 s, `deepdml/faster-whisper-large-v3-turbo-ct2` 8 s bei besserer Erkennung (RAM ca. 1 bzw. 2 GB). Auf geteilten vCPUs ist mit dem Mehrfachen zu rechnen.
|
`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.
|
||||||
|
|
||||||
@@ -151,12 +159,19 @@ Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go te
|
|||||||
- 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.
|
- 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).
|
- Im Demo-Modus läuft kein Zeitplan. Zurückspielen eines Datenbank-Abzugs: Container stoppen, `mindsift.db` durch die entpackte Datei ersetzen, starten. Benutzer spielen ihre eigenen Backups in den Kontoeinstellungen ein, auch nur für einzelne Bereiche (Notizen, Aufgaben, Listen, Inbox, Themes).
|
||||||
|
|
||||||
|
## Hintergrundjob: Aufbewahrung
|
||||||
|
|
||||||
|
[retention.go](../internal/storage/retention.go), gestartet in `internal/api/done_cleanup.go`: Jede Person wählt unter Kontoeinstellungen → Aufbewahrung je Bereich 7, 14, 30, 90, 365 oder 730 Tage oder nie. Stündlich wandern erledigte Aufgaben nach Ablauf in den Papierkorb der Aufgaben (`trashed_tasks`); der Papierkorb der Aufgaben, der Triage (verworfene Gedanken), der Notizen und abgehakte Listeneinträge werden nach Ablauf endgültig geleert. In geteilten Aufgabenlisten, Listen und Notizen gilt die Frist des Eigentümers. Was beim Einführen schon erledigt oder gelöscht war, zählt ab diesem Zeitpunkt (`system_settings.retention_since`). Mit `RETENTION_ALLOW_NEVER=false` (oder dem Schalter „Dauerhaftes Aufbewahren erlauben“ in Administration → Server & Isolation) gibt es „nie“ nicht; gespeichertes „nie“ gilt dann als Voreinstellung des Bereichs.
|
||||||
|
|
||||||
## Offline (PWA)
|
## 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.
|
- **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.
|
- **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.
|
- **Warteschlange (Outbox):** Offline erfasste Gedanken (`POST /inbox`) und abgehakte Aufgaben bzw. Listeneinträge (`PATCH`) werden in `localStorage` gesammelt, wiederholtes Umschalten zählt nur mit dem Endstand. Gesendet wird in Reihenfolge beim `online`-Ereignis, beim Start und alle 30 s, solange etwas wartet; vom Server abgelehnte Einträge (4xx) entfallen. Andere Änderungen brauchen weiterhin eine Verbindung.
|
||||||
|
- **Neue Version:** Jeder Build legt `version.json` (Version und Build-Kennung) neben `index.html`, nie gecacht. Die App vergleicht sie mit ihrer eigenen Kennung beim Zurückkehren in Tab oder App, alle 15 Minuten und nach einem Neustart des Servers (Echtzeit-Verbindung wieder da) und zeigt dann „Neue Version verfügbar“ mit „Jetzt aktualisieren“ ([lib/useUpdateCheck.ts](../frontend/src/lib/useUpdateCheck.ts)).
|
||||||
|
- **Zustand pro Tab:** Der offene Bereich (Triage, Workspace, Aufgaben …), die offene Notiz und die gewählte Liste stehen in `sessionStorage` ([lib/viewSession.ts](../frontend/src/lib/viewSession.ts)) und bleiben beim Neuladen erhalten.
|
||||||
- **Hinweis:** Ein Banner über der unteren Navigation zeigt „Offline“ und die Zahl wartender Änderungen bzw. „Synchronisiere…“.
|
- **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)
|
## App-Icons, PWA & Social-Preview (Open Graph)
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -8,12 +8,15 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j
|
|||||||
### 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`, `organization_id` (leer = keine Organisation), `notification_settings` (JSON der Kanäle und Ereignisse), `last_daily_summary` (Datum des letzten Versands) |
|
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation), `notification_settings` (JSON der Kanäle und Ereignisse), `last_daily_summary` (Datum des letzten Versands), Aufbewahrungsfristen in Tagen (`done_retention_days` 30, `task_trash_days` 30, `triage_trash_days` 14, `note_trash_days` 30, `list_done_days` 7; 0 = nie), `voice_input` (`''` = Whisper-Server, `browser`) |
|
||||||
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
|
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
|
||||||
| `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media/<doc_id>/<id>.<ext>` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` |
|
| `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media/<doc_id>/<id>.<ext>` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` |
|
||||||
| `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` |
|
| `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` |
|
| `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` |
|
| `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) |
|
| `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` |
|
| `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` |
|
| `api_keys` | Webhook-/API-Schlüssel | `name`, `key_prefix`, `key_hash` (SHA-256), `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`), `last_used_at` |
|
||||||
@@ -23,7 +26,8 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j
|
|||||||
| Tabelle | Zweck | Wichtige Spalten |
|
| Tabelle | Zweck | Wichtige Spalten |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` |
|
| `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` |
|
||||||
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `recurrence` (`daily`/`weekly`/`monthly`/`yearly`/leer), `recurrence_spawned`, `sort_order`, `source` (`manual`, `voice`, `webhook` …) |
|
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `recurrence` (`daily`/`weekly`/`monthly`/`yearly`/leer), `recurrence_spawned`, `sort_order`, `source` (`manual`, `voice`, `webhook` …), `completed_at` (per Trigger beim Abhaken gesetzt, beim Öffnen geleert), Herkunft (`created_via`, `created_key_id`, `changed_via`, `changed_key_id`, `changed_at`) |
|
||||||
|
| `trashed_tasks` | Papierkorb der Aufgaben | `id` (der Aufgabe), `user_id`, `task_list_id`, `owner_id` (wessen Frist gilt), `content`, `is_completed`, `data` (die ganze `thoughts`-Zeile als JSON), `assignees` (JSON), `trashed_at`, `trashed_by` |
|
||||||
| `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_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_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`) |
|
||||||
@@ -84,7 +88,7 @@ teams 1─n team_members n─1 users
|
|||||||
- `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=organizations`, `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.
|
||||||
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (Migrationen 3 bis 7: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
|
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“, 8 „Journal“, 9 „Web-Push“, 10 „Registrierung und Einladungslinks“, 11 „E-Mail-Bestätigung“, 12 „Markierung für Einträge von KI-Agenten“, 13 „Herkunft von Einträgen (API-Schlüssel)“, 14 „Erledigte Aufgaben aufräumen“, 15 „Spracheingabe pro Person“, 16 „Aufbewahrung und Papierkorb für Aufgaben“) 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
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Einkaufs-, Pack- und Checklisten für gemeinsames Arbeiten. Code: [SharedListsVi
|
|||||||
- **Kategorien per Hashtag:** `Hafermilch #Kühlregal` landet in der Kategorie „Kühlregal“. Kategorien werden alphabetisch als Abschnitte angezeigt; die Eingabe schlägt vorhandene Hashtags vor.
|
- **Kategorien per Hashtag:** `Hafermilch #Kühlregal` landet in der Kategorie „Kühlregal“. Kategorien werden alphabetisch als Abschnitte angezeigt; die Eingabe schlägt vorhandene Hashtags vor.
|
||||||
- **Menge:** Feld `quantity` (z. B. `2x`, `500g`).
|
- **Menge:** Feld `quantity` (z. B. `2x`, `500g`).
|
||||||
- **Drag & Drop** ordnet Einträge um; in eine andere Kategorie gezogen, wird der Hashtag im Text angepasst (`items/reorder` mit `category` und `content`).
|
- **Drag & Drop** ordnet Einträge um; in eine andere Kategorie gezogen, wird der Hashtag im Text angepasst (`items/reorder` mit `category` und `content`).
|
||||||
- **Abhaken** speichert, wer und wann; Erledigte stehen in einem eigenen Bereich und lassen sich per „Erledigte entfernen“ löschen (`clear-completed`).
|
- **Abhaken** speichert, wer und wann; Erledigte stehen in einem eigenen Bereich und lassen sich per „Erledigte entfernen“ löschen (`clear-completed`). Nach der Frist des Listen-Eigentümers (Kontoeinstellungen → Aufbewahrung, Standard 7 Tage) löscht der Server abgehakte Einträge von selbst ([retention.go](../../internal/storage/retention.go)).
|
||||||
- **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.
|
- **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.
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
|
|||||||
|
|
||||||
## Ansichten
|
## Ansichten
|
||||||
|
|
||||||
- **Liste** mit Filtern (offen, erledigt, Papierkorb), Gruppierung (z. B. nach Status oder Fälligkeit), Sortierung und Hashtag-Filter.
|
- **Liste** mit den Reitern Offen, Erledigt und Papierkorb; Gruppierung (z. B. nach Status oder Fälligkeit) und Sortierung stehen am PC rechts daneben, direkt über der Liste. Die Hashtag-Filter darüber bleiben einzeilig; passen nicht alle hinein, klappt „…“ sie auf mehrere Zeilen auf.
|
||||||
- **Kanban:** Spalten pro Liste frei konfigurierbar (Name, Farbe, „Erledigt“-Spalte). 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). Farben in „Spalten anpassen“ über den Punkt vor dem Namen: Grau, Anthrazit (Textfarbe des Themes), Grün, Gelb, Blau, Rot, Lila, Akzentfarbe (`columns[].color` = `gray`/`slate`/`green`/`yellow`/`blue`/`red`/`purple`/`accent`; ältere Werte `emerald`/`amber` gelten als Grün/Gelb, Spalten ohne Farbe behalten die ihrer Rolle). Der Punkt erscheint im Spaltenkopf, vor dem Status in der Listenansicht und im Detail-Fenster ([ManageColumnsModal.tsx](../../frontend/src/features/triage/ManageColumnsModal.tsx), `columnDotClass`). Blau und Lila kommen aus `--color-info` / `--color-purple`; Themes können sie setzen, sonst gelten Standardwerte aus `index.css`. Standard: *Zu erledigen*, *In Bearbeitung*, *Wartet auf andere*, *Erledigt*. Eine Karte in eine „Erledigt“-Spalte zu ziehen setzt `isCompleted`; maßgeblich ist die als erledigt markierte Spalte der Liste, egal mit welcher ID (`isDoneColumn` beim Umsortieren). Am PC ist das Board so hoch wie das Fenster, wenn alle Spalten in eine Reihe passen; dann scrollen nur die Spalten.
|
||||||
|
- **„Alle Aufgaben“ als Kanban:** Die Spalten aller Listen werden nach Namen zusammengeführt (Groß/Klein egal), in Reihenfolge und Farbe der ersten Liste, die eine Spalte hat; verschieden benannte Spalten bleiben getrennt. Beim Ziehen sind Spalten, die die Liste der Karte nicht hat, ausgegraut und nehmen nichts an. Status-Auswahl, Pfeile auf der Karte und Detail-Fenster nutzen immer die Spalten der eigenen Liste.
|
||||||
|
- **Papierkorb und Aufbewahrung** ([task_trash.go](../../internal/storage/task_trash.go), [retention.go](../../internal/storage/retention.go)): Gelöschte Aufgaben, „Erledigte in den Papierkorb“ (`POST /tasks/clear-done`) und erledigte Aufgaben nach Ablauf ihrer Frist wandern in den Papierkorb der Aufgaben (Tabelle `trashed_tasks`: die ganze Zeile samt Zuweisungen als Kopie). Wer die Liste sieht, sieht ihren Papierkorb; Zurückholen und endgültig Löschen brauchen Bearbeitungsrecht. Zurückgeholt wird die Aufgabe unverändert (eine gelöschte Liste macht sie zur eigenen Aufgabe ihres Erstellers). Die Fristen stellt jede Person unter Kontoeinstellungen → Aufbewahrung ein (erledigte Aufgaben Standard 30 Tage, Papierkorb 30 Tage); in einer Liste gilt die Frist des Eigentümers. Den Zeitpunkt des Abhakens setzt ein Trigger (`thoughts.completed_at`), egal auf welchem Weg abgehakt wird; der Server räumt stündlich auf (nicht in der Demo). Ein mit einer Notiz verknüpftes Häkchen bleibt als normales Häkchen stehen.
|
||||||
|
- **Rückgängig:** `Strg+Z`/`Cmd+Z` nimmt in der Aufgabenansicht die letzten Schritte zurück (bis 20): Löschen (aus dem Papierkorb zurück), Verschieben (Spalten, Reihenfolge) und Abhaken. Nach dem Löschen bietet ein Hinweis unten „Rückgängig“, auch am Handy. In Textfeldern gilt deren eigenes Rückgängig.
|
||||||
- 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).
|
- 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.
|
- „Als Notiz vertiefen“ legt aus einer Aufgabe eine Notiz an (Titel, darunter Fälligkeit und Priorität, dann Notizen und Unteraufgaben). Die Aufgabe bleibt in ihrer Liste und wird mit dem ersten Häkchen der Notiz verknüpft (`converted_type = document_block`); ein Gedanke aus der Triage wird dagegen ganz zur Notiz. Aufgaben aus Notiz-Checkboxen (`enhanced-task`) bleiben verknüpft, „In Notiz öffnen“ springt zum Block.
|
||||||
|
- **Abgleich verknüpfter Aufgaben** ([task_doc_sync.go](../../internal/storage/task_doc_sync.go)): Haken und Titel gehen in beide Richtungen (Notiz → Aufgabe in `ReconcileDocTaskLinks` nach jeder Notiz-Änderung, Aufgabe → Notiz in `SyncTaskToDoc` nach jeder Aufgaben-Änderung). Unteraufgaben gehören zur Verknüpfung: Jede ist 1:1 mit einem Häkchen derselben Notiz verbunden (`subtasks[].blockId`, Badge „Unteraufgabe“, `DocumentBlock.subtaskOf`), auch wenn es verschoben oder anders eingerückt wird. Neue Unteraufgaben bekommen ein Häkchen direkt unter dem Haupt-Häkchen; neue Häkchen direkt eine Ebene darunter werden Unteraufgaben (tiefer eingerückte bleiben Notiz). Löschen wird nicht abgeglichen: Ein gelöschtes Häkchen löst nur die Verknüpfung, eine gelöschte Unteraufgabe lässt ihr Häkchen stehen. Ein Häkchen, das in eine andere Notiz wandert, bekommt dort eine neue ID und verliert damit die Verknüpfung. Die Sync-Antwort der Notiz liefert `taskLinks` und `subtaskLinks`, damit die Badges sofort stimmen.
|
||||||
|
|
||||||
## Aufgabenlisten und Freigabe
|
## Aufgabenlisten und Freigabe
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ Ziel: Gedanken in Sekunden festhalten und später in Ruhe sortieren. Code: [Inst
|
|||||||
## Erfassung
|
## Erfassung
|
||||||
|
|
||||||
- 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`, mit Aufgabenliste), **Listeneintrag** (`list_item`, mit Zielliste) oder **Direkt-Notiz** (`document`). Im aufgeklappten Bereich wählen Ziel und Liste nur aus; gespeichert wird mit Enter bzw. dem Knopf. Ziel und Liste bleiben nach dem Abschicken eingestellt (je Browser-Tab).
|
||||||
- Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt `useVoiceInput` (Whisper-Server oder Spracherkennung des Browsers); Quelle `voice` fügt den Tag `#diktat` an.
|
- Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt `useVoiceInput` (Whisper-Server oder Spracherkennung des Browsers, je nach Kontoeinstellung): Mit Whisper geht die Aufnahme schon beim Sprechen in Stücken zur Erkennung (`chunked`), die Browser-Erkennung endet nach 5 s Stille. Quelle `voice` fügt den Tag `#diktat` an.
|
||||||
- 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)).
|
||||||
|
|
||||||
@@ -33,4 +33,6 @@ inbox ──keep──▶ kept ──convert──▶ converted (task | document
|
|||||||
└─discard─▶ discarded ──restore──▶ inbox (Papierkorb leeren: DELETE /thoughts/discarded/purge)
|
└─discard─▶ discarded ──restore──▶ inbox (Papierkorb leeren: DELETE /thoughts/discarded/purge)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Verworfene Gedanken liegen im eigenen **Papierkorb der Triage** ([TriageTrash.tsx](../../frontend/src/features/triage/TriageTrash.tsx)): Knopf oben in der Triage, am Handy im Menü. Dort lassen sie sich zurückholen, einzeln oder alle endgültig löschen. Nach der Frist aus Kontoeinstellungen → Aufbewahrung (Standard 14 Tage, gezählt ab dem Verwerfen, `triaged_at`) löscht der Server sie.
|
||||||
|
|
||||||
Ein Gedanke mit `status = kept` zählt bereits als Aufgabe und erscheint in der Aufgabenansicht.
|
Ein Gedanke mit `status = kept` zählt bereits als Aufgabe und erscheint in der Aufgabenansicht.
|
||||||
@@ -36,12 +36,15 @@ 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).
|
- **Notiz-Icon (Emoji-Picker):** Vollständiger Emoji-Katalog mit Kategoriereitern, Suche, Verlauf zuletzt verwendeter Symbole (`localStorage`) und Farben aus dem Theme. Am PC kompakt wie ein Kontextmenü auf eigener Fläche, ohne Titelleiste; auf Mobilgeräten (< 640 px) als Bottom-Sheet mit touch-freundlichen Zielen (≥ 44 px) und `text-base`-Suchfeld (verhindert iOS-Zoom). Derselbe Auswähler dient beim Anlegen von Notizen, Aufgabenlisten und Listen (`EmojiIconButton`). „Entfernen“ lässt das Icon ganz weg; ohne Icon steht nur der Titel. Neue Notizen starten mit 🗒️.
|
||||||
|
- **Markieren über mehrere Punkte** ([OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx)): Mit der Maus von einem Punkt in einen anderen ziehen markiert Text über Punktgrenzen (CSS Highlight API, sonst eingefärbte Zeilen). Entf/Backspace löscht wie ein Texteditor (Rest des ersten und letzten Punkts werden ein Punkt, Punkte dazwischen samt Unterpunkten gehen, Unterpunkte des letzten wandern unter den ersten) in einem Schritt (`onReplaceRange`, ein `Strg+Z` holt alles zurück); ein Zeichen ersetzt die Markierung, `Strg+C`/`Strg+X` kopieren bzw. schneiden aus.
|
||||||
|
|
||||||
## Explorer, Tags, To-Dos
|
## Explorer, Tags, To-Dos
|
||||||
|
|
||||||
- Ordnerbaum mit Favoriten (`doc_pins`), Verschieben per Drag & Drop (`/documents/{id}/move`, `/folders/move`).
|
- Ordnerbaum mit Favoriten (`doc_pins`), Verschieben per Drag & Drop (`/documents/{id}/move`, `/folders/move`). Ordner zeigen ihren Zustand am offenen bzw. geschlossenen Ordnersymbol (kein Pfeil).
|
||||||
- **Tags:** Alle `#Hashtags` werden aggregiert (`/workspace/tags`). Eine Tag-Seite zeigt alle Fundstellen gruppiert nach Notiz und kann eine eigene Freitext-Notiz tragen (`tag_pages`).
|
- **Mehrere Notizen** (PC): Strg/Cmd+Klick markiert einzeln, Shift+Klick einen Bereich in der angezeigten Reihenfolge (eine angeheftete Notiz steht zweimal im Baum; der Bereich beginnt an der Stelle, die angeklickt wurde); ein normaler Klick, Escape oder ein Klick ins Leere hebt die Markierung auf. Wer eine markierte Notiz zieht, nimmt alle markierten mit, in einen Ordner oder auf den Papierkorb (Zusammenführen gibt es dabei nicht).
|
||||||
|
- **Auf den Papierkorb ziehen** (auch eine einzelne Notiz, am Handy per langem Drücken wie beim Verschieben in Ordner) verschiebt in den Papierkorb; der Hinweis bietet „Rückgängig“.
|
||||||
|
- **Tags:** Alle `#Hashtags` werden aggregiert (`/workspace/tags`). Eine Tag-Seite trägt eine eigene Freitext-Notiz (`tag_pages`) und zeigt darunter jede Notiz mit Fundstellen als feste Überschrift und darunter den echten Outliner der Punkte mit dem Hashtag samt Unterpunkten (`DocumentEditor` mit `variant="tag"`, je Notiz eine eigene Sync-Verbindung, erst beim Hineinscrollen geladen). Bearbeiten wirkt direkt in der Notiz; ein Punkt ohne Hashtag (entfernt oder neu per Enter) bleibt sichtbar, solange der Cursor darin steht, und verschwindet, sobald er ihn verlässt. Die Reihenfolge der Notizen bleibt, solange die Seite offen ist.
|
||||||
- **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.
|
||||||
|
|
||||||
@@ -50,7 +53,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
|||||||
- Löschen (einzeln oder mit dem Ordner) verschiebt eigene Notizen in den Papierkorb (`is_archived = 1`). Wer eine geteilte Notiz löscht, die ihm nicht gehört, verlässt nur die Freigabe.
|
- 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.
|
- 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.
|
- 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.
|
- Wiederherstellen legt einen inzwischen gelöschten Ordner neu an; existiert ein geteilter Ordner nicht mehr, landet die Notiz auf der Hauptebene. Freigaben bleiben erhalten. Nach der Frist der Eigentümerin bzw. des Eigentümers (Kontoeinstellungen → Aufbewahrung, Standard 30 Tage ab dem Löschen) löscht der Server die Notiz endgültig ([retention.go](../../internal/storage/retention.go)).
|
||||||
|
|
||||||
## Freigabe
|
## Freigabe
|
||||||
|
|
||||||
@@ -66,7 +69,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
|||||||
|
|
||||||
## Export als Markdown-Archiv
|
## Export als Markdown-Archiv
|
||||||
|
|
||||||
Kontoeinstellungen → Private Webhooks & Export → „Markdown-Archiv herunterladen“ (`GET /export/markdown`, Code: [export_markdown.go](../../internal/storage/export_markdown.go)).
|
Kontoeinstellungen → Export & Import → „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.
|
- 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 `- [ ]`.
|
- 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 `- [ ]`.
|
||||||
@@ -75,7 +78,7 @@ Kontoeinstellungen → Private Webhooks & Export → „Markdown-Archiv herunter
|
|||||||
|
|
||||||
## Import
|
## Import
|
||||||
|
|
||||||
Kontoeinstellungen → Private Webhooks & Export → „Daten importieren“, mit **zwei getrennten Wegen** (Code: [import_markdown.go](../../internal/storage/import_markdown.go), [ImportCard.tsx](../../frontend/src/features/settings/ImportCard.tsx)):
|
Kontoeinstellungen → Export & Import → „Daten importieren“, mit **zwei getrennten Wegen** (Code: [import_markdown.go](../../internal/storage/import_markdown.go), [ImportCard.tsx](../../frontend/src/features/settings/ImportCard.tsx)):
|
||||||
|
|
||||||
| Weg | Endpunkt | Dateien | Ergebnis |
|
| Weg | Endpunkt | Dateien | Ergebnis |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
|
|||||||
@@ -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?
|
||||||
+7
-2
@@ -80,8 +80,13 @@ 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) |
|
| `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) |
|
| `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 |
|
||||||
|
|||||||
+27
-2
@@ -36,8 +36,9 @@ Komponente: [AvatarStack.tsx](../frontend/src/components/AvatarStack.tsx) (nutzt
|
|||||||
|
|
||||||
Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx)
|
Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx)
|
||||||
|
|
||||||
- Ein/Aus-Einstellungen sind immer Schalter, keine Checkbox-Haken (Einrichtung, Admin-Bereich).
|
- 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.
|
- 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)
|
## Kleine Dialoge (Anlegen, Umbenennen)
|
||||||
|
|
||||||
@@ -45,7 +46,13 @@ Komponente: [FormSheet.tsx](../frontend/src/components/FormSheet.tsx), Tastatur-
|
|||||||
|
|
||||||
- **Handy:** Bottom-Sheet über die volle Breite, das direkt über der Bildschirmtastatur sitzt (der Abstand kommt aus `visualViewport`), Griff oben, Felder und Buttons mindestens 44 px hoch, Eingaben in 16 px (sonst zoomt iOS), Abbrechen/Erstellen nebeneinander über die ganze Breite.
|
- **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).
|
- **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.
|
- Escape und Klick auf den Hintergrund schließen. Genutzt für neue Aufgabenliste, neue Einkaufsliste, neue Notiz und neuen Ordner; das Emoji-Sheet nutzt denselben Tastatur-Abstand.
|
||||||
|
- **Icon-Feld:** [EmojiIconButton.tsx](../frontend/src/components/EmojiIconButton.tsx) statt eines Textfelds: zeigt das Emoji (ohne Icon ein dezentes „Icon wählen“) und öffnet denselben Emoji-Auswähler wie der Notiz-Kopf, inklusive „Entfernen“. Im Dialog liegt der Auswähler per Portal über der Seite; Escape schließt nur ihn.
|
||||||
|
|
||||||
|
## Hinweis mit „Rückgängig“
|
||||||
|
|
||||||
|
- Kurzer Hinweis unten mittig (`role="status"`, Fläche `bg-panel-elevated`), am Handy über der unteren Leiste; mit Aktion bleibt er 8–10 s, sonst 3–4 s.
|
||||||
|
- Nach Löschen in den Papierkorb: „… im Papierkorb · Rückgängig“ (Workspace und Aufgaben). In den Aufgaben macht `Strg+Z` dasselbe; eine neue Aktion blendet den alten Hinweis aus, damit „Rückgängig“ nie einen anderen Schritt trifft.
|
||||||
|
|
||||||
## Zähler
|
## Zähler
|
||||||
|
|
||||||
@@ -62,6 +69,24 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
|
|||||||
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken.
|
- 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.
|
- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben.
|
||||||
|
|
||||||
|
## Herkunfts-Badge (API-Schlüssel)
|
||||||
|
|
||||||
|
Komponente: [OriginBadge.tsx](../frontend/src/components/OriginBadge.tsx)
|
||||||
|
|
||||||
|
- Eine Badge für alles, was ein API-Schlüssel angelegt oder geändert hat (Feld `origin`): neutraler Rahmen, vorne ein Symbol für den Zugang (Webhook = REST/curl, Roboter = MCP), dahinter der Name des Schlüssels. Ältere Einträge ohne Schlüssel zeigen „API“ bzw. „MCP“.
|
||||||
|
- Höchstens eine Badge pro Eintrag: der Schlüssel, der angelegt hat, sonst der, der zuletzt geändert hat. Sie bleibt dauerhaft, auch nach eigenem Bearbeiten und nach der Triage.
|
||||||
|
- Details (angelegt / zuletzt geändert, mit Schlüssel und Zeit) per Mouseover und per Tippen (kleines Info-Fenster, am Handy mit vergrößerter Trefferfläche); in der Aufgaben-Detailansicht stehen sie zusätzlich unten (`originDetails`). Ein Tipp auf die Badge öffnet nie den Eintrag.
|
||||||
|
- Größen: `sm` in Aufgabenzeilen, Inbox-Liste und Detailansicht, `xs` auf Kanban-Karten und in Listen, `md` auf den Triage-Karten.
|
||||||
|
|
||||||
|
## Mikrofon in Eingabezeilen
|
||||||
|
|
||||||
|
Komponente: [VoiceInputButton.tsx](../frontend/src/components/VoiceInputButton.tsx) (nutzt `useVoiceInput` wie der Quick Dump)
|
||||||
|
|
||||||
|
- Runder Mikrofon-Knopf direkt in der Zeile, rechts im Feld; diktierter Text wird an den Text angehängt, der beim Start im Feld stand. Am Handy 36 px, am PC 28 px.
|
||||||
|
- Während der Aufnahme `MicOff` in Akzentfarbe, mit eigenem Whisper-Server zusätzlich die Live-Wellen, danach „Wird erkannt…“ mit Spinner.
|
||||||
|
- Ausgeblendet, wenn die Spracheingabe aus ist (`STT_DISABLED`, oder `STT_ALLOW_BROWSER=false` ohne Whisper-Server) oder der Browser sie nicht kann (z. B. Firefox ohne eigenen Whisper-Server). Fehler zeigt das Feld kurz als Platzhalter.
|
||||||
|
- Eingesetzt: Quick Dump (eigene Umsetzung), Aufgabe hinzufügen (Zeile am PC, „Neue Aufgabe anlegen“ am Handy), Listeneintrag hinzufügen.
|
||||||
|
|
||||||
## Ladebildschirm & Statusleiste
|
## Ladebildschirm & Statusleiste
|
||||||
|
|
||||||
- `manifest.json`: `name` „MindSift“ (kurz, der Slogan steht in `description`), `background_color` und `theme_color` `#2563eb` – Android baut daraus den Splash-Screen.
|
- `manifest.json`: `name` „MindSift“ (kurz, der Slogan steht in `description`), `background_color` und `theme_color` `#2563eb` – Android baut daraus den Splash-Screen.
|
||||||
|
|||||||
@@ -1,24 +1,661 @@
|
|||||||
This is free and unencumbered software released into the public domain.
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
distribute this software, either in source code form or as a compiled
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
binary, for any purpose, commercial or non-commercial, and by any
|
of this license document, but changing it is not allowed.
|
||||||
means.
|
|
||||||
|
|
||||||
In jurisdictions that recognize copyright laws, the author or authors
|
Preamble
|
||||||
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,
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
software and other kinds of works, specifically designed to ensure
|
||||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
cooperation with the community in the case of network server software.
|
||||||
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>
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
@@ -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>
|
||||||
@@ -35,75 +37,105 @@ 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: ü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).
|
* 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 (mit Aufgabenliste), Listeneintrag oder Notiz; die Wahl bleibt für die nächsten Einträge stehen. Mit eigenem Whisper-Server erscheint der Text schon beim Sprechen; jede Person wählt selbst, ob ihr Mikrofon Whisper oder die Erkennung des Browsers nutzt.
|
||||||
* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste.
|
* **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.
|
||||||
* Papierkorb mit Wiederherstellen.
|
* **Teilen aus anderen Apps:** Als installierte App erscheint MindSift im Teilen-Menü des Smartphones; geteilte Texte und Links landen im Posteingang und lassen sich direkt danach mit einem Tipp zur Aufgabe oder auf eine Liste schicken.
|
||||||
|
* Papierkörbe mit Wiederherstellen für verworfene Gedanken, gelöschte Aufgaben und Notizen; unter Kontoeinstellungen → Aufbewahrung legt jede Person fest, nach wie vielen Tagen Erledigtes und Gelöschtes aufgeräumt wird.
|
||||||
|
|
||||||
<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 (Name, Farbe, Erledigt-Spalte). „Alle Aufgaben“ führt die Spalten aller Listen nach Namen zu einem Board zusammen.
|
||||||
* 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. `Strg+Z` nimmt Löschen, Verschieben und Abhaken zurück.
|
||||||
|
* **Papierkorb und Aufbewahrung:** Gelöschte und aufgeräumte Aufgaben lassen sich aus dem Papierkorb zurückholen; erledigte Aufgaben wandern nach einer selbst gewählten Frist (Standard 30 Tage) dorthin.
|
||||||
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
|
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
|
||||||
* **Aufgaben beobachten:** Beliebige Aufgaben per Klick abonnieren, um über alle Änderungen informiert zu werden.
|
* **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.
|
* **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.
|
* **Tageszusammenfassung:** Tägliche Übersicht fälliger Aufgaben zur selbst gewählten Uhrzeit per E-Mail, Discord, eigenem Webhook oder Push.
|
||||||
* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort per E-Mail, Discord oder eigenem Webhook; granulare Benachrichtigungen für Freigaben, erledigte geteilte Aufgaben und beobachtete Aufgaben sowie tägliche Zusammenfassungen zur gewählten Uhrzeit.
|
* **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. Text lässt sich mit der Maus über mehrere Punkte markieren und wie in einem Texteditor löschen, ersetzen oder kopieren.
|
||||||
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren.
|
* **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`, `/extract`, `/image`, `/audio`, `/diktat`.
|
* **Journal:** eine Notiz pro Tag, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach), mit Kalender zum Springen; einzelne Tage lassen sich teilen. Pro Gerät abschaltbar.
|
||||||
|
* Blocktypen und Slash-Befehle: `/todo`, `/number` (nummerierte Liste: 1., a., i.), `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`, `/extract`, `/image`, `/audio`, `/diktat`.
|
||||||
* **Bilder & Audio in Notizen:** `/image` (Datei, Kamera, Zwischenablage oder Link), `/audio` (Sprachnotiz oder Datei-Upload) und `/diktat` (Live-Transkription).
|
* **Bilder & Audio in Notizen:** `/image` (Datei, Kamera, Zwischenablage oder Link), `/audio` (Sprachnotiz oder Datei-Upload) und `/diktat` (Live-Transkription).
|
||||||
* **Link-Vorschau:** Automatische Vorschaukarten über Punkten mit reiner Web-Adresse.
|
* **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).
|
* **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]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
|
* 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`. Mehrere Notizen mit `Strg`/`Shift`+Klick markieren und gemeinsam in einen Ordner oder auf den Papierkorb ziehen. Notizen und Listen bekommen ein Emoji als Symbol, oder keins.
|
||||||
* 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.
|
* **Aufgaben aus Notizen:** Ein Häkchen lässt sich zur Aufgabe erweitern (und eine Aufgabe zur Notiz vertiefen); Haken, Titel und Unteraufgaben bleiben in beide Richtungen abgeglichen.
|
||||||
|
* Eingefügtes Markdown wird zur Gliederung: Überschriften werden zu Abschnitten mit eingerücktem Inhalt, Listen (auch nummeriert) behalten ihre Ebenen, Absätze werden eigene Punkte. Kürzel beim Tippen: `- `, `1. `, `[] `, `# `, `Strg+B`/`I`/`K`. `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her.
|
||||||
* **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. Abgehakte Einträge verschwinden nach einer wählbaren Frist (Standard 7 Tage) von selbst.
|
||||||
* 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, ob „dauerhaft aufbewahren“ erlaubt ist.
|
||||||
* Sechs Themes (Dark, Light, High-Contrast, Soft, Polarized, Suri) plus Theme-Manager für eigene CSS-Themes, installierbar als PWA. Die App startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist.
|
|
||||||
|
### 🎨 Darstellung & App
|
||||||
|
|
||||||
|
* **Fünf Farbfamilien** mit je einer hellen und dunklen Variante: Klassik, Fjord, Terrakotta, Polarized, Suri. Hell/Dunkel auf Wunsch „Wie System“, dazu eine eigene Akzentfarbe, Ecken rund oder eckig und Knöpfe mit oder ohne Rahmen (pro Gerät). Für eigene Themes gibt es einen Theme-Manager (eine CSS-Datei).
|
||||||
|
* **Installierbar als App (PWA)** auf Smartphone und Desktop. Nach einem Server-Update meldet die offene App „Neue Version verfügbar“ und lädt auf Wunsch neu; beim Neuladen bleibt der zuletzt offene Bereich offen. Sie startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist.
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><img src="screenshots/Theme_fjord.webp" alt="Farbfamilie Fjord, links hell, rechts dunkel" /><br /><sub><b>Fjord</b></sub></td>
|
||||||
|
<td align="center"><img src="screenshots/Theme_terracotta.webp" alt="Farbfamilie Terrakotta, links hell, rechts dunkel" /><br /><sub><b>Terrakotta</b></sub></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><img src="screenshots/Theme_polarized.webp" alt="Farbfamilie Polarized, links hell, rechts dunkel" /><br /><sub><b>Polarized</b></sub></td>
|
||||||
|
<td align="center"><img src="screenshots/Theme_suri.webp" alt="Farbfamilie Suri, links hell, rechts dunkel" /><br /><sub><b>Suri</b></sub></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
### 🔌 Integrationen
|
### 🔌 Integrationen
|
||||||
|
|
||||||
* **Webhook / API-Key:** `POST /api/v1/webhooks/ingest` legt Aufgaben aus Smarthome, Skripten oder Automationen an.
|
* **API mit Schlüsseln:** Pro Schlüssel schaltbare Rechte (Aufgaben und Listen lesen, anlegen, ändern; Inbox füllen), gleich für beide Zugänge. Notizen und Einstellungen bleiben außen vor.
|
||||||
* Persönliche API-Keys, Datenexport als JSON.
|
* **curl / Smart Home:** z. B. `GET /api/v1/tasks?due=today&status=open` für einen Home-Assistant-Sensor „Heute fällig“, abhaken per `PATCH`, Gedanken, Aufgaben und Listeneinträge einliefern per `POST /api/v1/webhooks/ingest`.
|
||||||
|
* **KI-Agenten (MCP):** MindSift ist ein MCP-Server (`POST /api/v1/mcp`, Streamable HTTP). Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten lesen, legen an, ändern, haken ab und löschen Aufgaben, legen Aufgabenlisten und Listen an und führen Einkaufs- und Checklisten. `claude mcp add --transport http mindsift https://…/api/v1/mcp --header "X-API-Key: mindsift_sec_…"`
|
||||||
|
* **Herkunft:** Was ein Schlüssel anlegt oder ändert, trägt dauerhaft eine Badge mit seinem Namen; Mouseover oder Tippen zeigt, wer wann angelegt und geändert hat.
|
||||||
|
* **Ausgehend:** eigener Webhook als Benachrichtigungskanal (JSON per POST), Discord, E-Mail, Push.
|
||||||
|
* Persönliche API-Keys, Datenexport als JSON, Markdown-Archiv (Obsidian-kompatibel).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -136,7 +168,7 @@ Minimal:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
mindsift:
|
mindsift:
|
||||||
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.8.0); :latest = main, :dev = 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"]
|
||||||
@@ -164,23 +196,29 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|
|||||||
| `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)) |
|
| `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 |
|
| `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) |
|
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme (ganztägige Aufgaben melden sich um 9:00) |
|
||||||
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
| `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_FROM_NAME` | `MindSift` | Anzeigename des Absenders bei E-Mails |
|
||||||
| `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten |
|
| `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten |
|
||||||
| `NOTIFY_DIGEST_MINUTES` | `5` | Bündelungsintervall für Benachrichtigungen in Minuten |
|
| `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`) |
|
| `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)) |
|
| `STT_*` | diverse | Optionale eigene Spracherkennung per Whisper (`STT_URL`, `STT_MODEL`, `STT_LANGUAGE`, `STT_ALLOW_BROWSER` …; siehe [Doku/architecture.md](Doku/architecture.md#spracherkennung)) |
|
||||||
|
| `RETENTION_ALLOW_NEVER` | leer (Admin-UI entscheidet) | `false`: Bei den Aufbewahrungsfristen gibt es „nie“ nicht (Erledigtes und Gelöschtes wird immer irgendwann aufgeräumt); `true` erlaubt es fest |
|
||||||
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
| `LEGAL_IMPRINT_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_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. |
|
| `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:** 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'"`.
|
* **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; offene Apps bieten danach selbst das Neuladen an.
|
||||||
* **Image-Tags:** `:dev` = Stand des Branches `dev`, `:latest` = Stand von `main`, `:stable` = zuletzt veröffentlichte Version. Ein Git-Tag wie `v1.2.0` erzeugt `:v1.2.0` und verschiebt `:stable` darauf; Vorabversionen wie `v1.0.0-rc.1` verschieben `:stable` nicht.
|
* **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).
|
* **Versionen & Änderungen:** [Semantic Versioning](https://semver.org/spec/v2.0.0.html), was sich geändert hat, steht in der [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -202,12 +240,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.4 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -219,14 +257,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; 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).
|
* 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
|
||||||
|
|
||||||
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.
|
MindSift ist freie Software unter der [GNU Affero General Public License v3.0](LICENSE) (AGPLv3): Du darfst den Code nutzen, ändern und weitergeben, auch kommerziell. Wer eine geänderte Fassung weitergibt oder als Dienst im Netz anbietet, muss deren Quellcode unter derselben Lizenz zugänglich machen. Es gibt keine Gewährleistung. Versionen bis einschließlich 0.48.0 bleiben unter The Unlicense (gemeinfrei).
|
||||||
|
|
||||||
<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.
|
||||||
@@ -103,4 +103,6 @@ func main() {
|
|||||||
if err := httpServer.Shutdown(ctx); err != nil {
|
if err := httpServer.Shutdown(ctx); err != nil {
|
||||||
slog.Warn("HTTP server shutdown finished with error", "error", err)
|
slog.Warn("HTTP server shutdown finished with error", "error", err)
|
||||||
}
|
}
|
||||||
|
// Let a running compaction finish before the deferred store.Close
|
||||||
|
srv.Close()
|
||||||
}
|
}
|
||||||
+9
-1
@@ -18,6 +18,7 @@ services:
|
|||||||
- 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}
|
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-private}
|
||||||
|
- RETENTION_ALLOW_NEVER=${RETENTION_ALLOW_NEVER:-}
|
||||||
- LOGIN_PROTECTION=${LOGIN_PROTECTION:-true}
|
- LOGIN_PROTECTION=${LOGIN_PROTECTION:-true}
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
- DEBUG=${DEBUG:-false}
|
- DEBUG=${DEBUG:-false}
|
||||||
@@ -33,6 +34,8 @@ services:
|
|||||||
- NOTIFY_DIGEST_MINUTES=${NOTIFY_DIGEST_MINUTES:-5}
|
- NOTIFY_DIGEST_MINUTES=${NOTIFY_DIGEST_MINUTES:-5}
|
||||||
- SMTP_TLS=${SMTP_TLS:-true}
|
- SMTP_TLS=${SMTP_TLS:-true}
|
||||||
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
|
- 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
|
# Impressum / Datenschutz
|
||||||
- LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-}
|
- LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-}
|
||||||
- LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-}
|
- LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-}
|
||||||
@@ -40,6 +43,9 @@ services:
|
|||||||
- LEGAL_PRIVACY_CONTENT=${LEGAL_PRIVACY_CONTENT:-}
|
- LEGAL_PRIVACY_CONTENT=${LEGAL_PRIVACY_CONTENT:-}
|
||||||
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
|
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
|
||||||
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_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
|
# Bilder und Audio in Notizen
|
||||||
- MEDIA_MAX_IMAGE_MB=${MEDIA_MAX_IMAGE_MB:-15}
|
- MEDIA_MAX_IMAGE_MB=${MEDIA_MAX_IMAGE_MB:-15}
|
||||||
- MEDIA_MAX_AUDIO_MB=${MEDIA_MAX_AUDIO_MB:-50}
|
- MEDIA_MAX_AUDIO_MB=${MEDIA_MAX_AUDIO_MB:-50}
|
||||||
@@ -51,11 +57,13 @@ services:
|
|||||||
- STT_LANGUAGE=${STT_LANGUAGE:-de}
|
- STT_LANGUAGE=${STT_LANGUAGE:-de}
|
||||||
- STT_API_KEY=${STT_API_KEY:-}
|
- STT_API_KEY=${STT_API_KEY:-}
|
||||||
- STT_DISABLED=${STT_DISABLED:-false}
|
- STT_DISABLED=${STT_DISABLED:-false}
|
||||||
|
- STT_ALLOW_BROWSER=${STT_ALLOW_BROWSER:-true}
|
||||||
- STT_MAX_SECONDS=${STT_MAX_SECONDS:-600}
|
- STT_MAX_SECONDS=${STT_MAX_SECONDS:-600}
|
||||||
- STT_MAX_UPLOAD_MB=${STT_MAX_UPLOAD_MB:-25}
|
- STT_MAX_UPLOAD_MB=${STT_MAX_UPLOAD_MB:-25}
|
||||||
- STT_MAX_PARALLEL=${STT_MAX_PARALLEL:-1}
|
- STT_MAX_PARALLEL=${STT_MAX_PARALLEL:-1}
|
||||||
- STT_PER_USER_PER_HOUR=${STT_PER_USER_PER_HOUR:-60}
|
- STT_PER_USER_PER_HOUR=${STT_PER_USER_PER_HOUR:-60}
|
||||||
- STT_TIMEOUT_SECONDS=${STT_TIMEOUT_SECONDS:-120}
|
- STT_TIMEOUT_SECONDS=${STT_TIMEOUT_SECONDS:-120}
|
||||||
|
- STT_VAD_FILTER=${STT_VAD_FILTER:-true}
|
||||||
|
|
||||||
# Optional: eigene Spracherkennung (Whisper). Zum Einschalten:
|
# Optional: eigene Spracherkennung (Whisper). Zum Einschalten:
|
||||||
# 1. die Rauten vor dem Block "whisper" unten entfernen
|
# 1. die Rauten vor dem Block "whisper" unten entfernen
|
||||||
@@ -73,6 +81,6 @@ services:
|
|||||||
# - ./whisper:/home/ubuntu/.cache/huggingface/hub
|
# - ./whisper:/home/ubuntu/.cache/huggingface/hub
|
||||||
# environment:
|
# environment:
|
||||||
# - WHISPER__COMPUTE_TYPE=int8 # schneller auf der CPU, kaum Qualitätsverlust
|
# - 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
|
# - WHISPER__TTL=-1 # Modell im Speicher halten statt nach 5 Min. zu entladen
|
||||||
# - ENABLE_UI=false # ohne die Weboberfläche von speaches
|
# - ENABLE_UI=false # ohne die Weboberfläche von speaches
|
||||||
# - LOG_LEVEL=info
|
# - LOG_LEVEL=info
|
||||||
+1
-1
@@ -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.
|
||||||
+26
-8
@@ -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'
|
||||||
+148
-7
@@ -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();
|
||||||
};
|
};
|
||||||
|
|||||||
+36
-9
@@ -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'
|
||||||
+22
-6
@@ -3,8 +3,10 @@
|
|||||||
<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" />
|
||||||
<!-- Blue while loading (matches the splash screen); the app switches it to the theme background -->
|
<!-- Black like the system bars of the installed app; boot-theme.js and then the app switch it to the
|
||||||
<meta name="theme-color" content="#2563eb" />
|
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" />
|
||||||
@@ -43,11 +45,25 @@
|
|||||||
<script src="/pwa-install.js"></script>
|
<script src="/pwa-install.js"></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]">
|
||||||
<!-- Shown until React mounts and replaces it: full blue with the logo, no text, no white flash -->
|
<!-- 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 id="root">
|
||||||
<div style="position:fixed;inset:0;background:#2563eb;display:flex;align-items:center;justify-content:center">
|
<div style="position:fixed;inset:0;background:var(--boot-bg,#2563eb);display:flex;align-items:center;justify-content:center">
|
||||||
<!-- the icon's own blue square disappears on blue, so the visible brain is ~55% of this size -->
|
<!-- icon.svg inline, so its square takes the theme's accent (on blue it disappears in the background) -->
|
||||||
<img src="/icon.svg" width="128" height="128" alt="" style="display:block" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
|||||||
Generated
+24
-3
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.9.0",
|
"version": "0.49.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.9.0",
|
"version": "0.49.1",
|
||||||
"license": "Unlicense",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
"@types/prismjs": "^1.26.6",
|
"@types/prismjs": "^1.26.6",
|
||||||
"emoji-picker-react": "4.12.0",
|
"emoji-picker-react": "4.12.0",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"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",
|
"typescript-eslint": "8.18.2",
|
||||||
"vite": "^5.4.11",
|
"vite": "^5.4.11",
|
||||||
@@ -1024,6 +1026,15 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource-variable/inter": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
@@ -4772,6 +4783,16 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwindcss-animate": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"tailwindcss": ">=3.0.0 || insiders"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/thenify": {
|
"node_modules/thenify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.0",
|
"version": "0.49.1",
|
||||||
"license": "Unlicense",
|
"license": "AGPL-3.0-only",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"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",
|
"emoji-picker-react": "4.12.0",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"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",
|
"typescript-eslint": "8.18.2",
|
||||||
"vite": "^5.4.11",
|
"vite": "^5.4.11",
|
||||||
|
|||||||
Binary file not shown.
|
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
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#2563eb",
|
"background_color": "#000000",
|
||||||
"theme_color": "#2563eb",
|
"theme_color": "#000000",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
@@ -47,18 +47,61 @@
|
|||||||
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
+125
-2
@@ -2,13 +2,17 @@
|
|||||||
// - Page loads (navigation): network first, so a new release is picked up right away; offline the
|
// - Page loads (navigation): network first, so a new release is picked up right away; offline the
|
||||||
// last loaded index.html is used.
|
// last loaded index.html is used.
|
||||||
// - Built bundles (/assets/*, file names contain a content hash): from the cache, loaded once.
|
// - Built bundles (/assets/*, file names contain a content hash): from the cache, loaded once.
|
||||||
// - Icons and manifest: from the cache, refreshed in the background.
|
// - 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).
|
// - 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.
|
// Raise VERSION when this file changes in a way that needs fresh caches.
|
||||||
const VERSION = 'v2';
|
const VERSION = 'v6';
|
||||||
const SHELL_CACHE = `mindsift-shell-${VERSION}`;
|
const SHELL_CACHE = `mindsift-shell-${VERSION}`;
|
||||||
const ASSET_CACHE = `mindsift-assets-${VERSION}`;
|
const ASSET_CACHE = `mindsift-assets-${VERSION}`;
|
||||||
const MAX_ASSETS = 80;
|
const MAX_ASSETS = 80;
|
||||||
|
const SHARE_DB_NAME = 'mindsift-share-target';
|
||||||
|
const SHARE_STORE_NAME = 'shared_items';
|
||||||
const SHELL_URLS = [
|
const SHELL_URLS = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
@@ -18,6 +22,9 @@ const SHELL_URLS = [
|
|||||||
'/icon-192x192.png',
|
'/icon-192x192.png',
|
||||||
'/icon-512x512.png',
|
'/icon-512x512.png',
|
||||||
'/apple-touch-icon.png',
|
'/apple-touch-icon.png',
|
||||||
|
'/badge-96x96.png',
|
||||||
|
'/boot-theme.js',
|
||||||
|
'/pwa-install.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
self.addEventListener('install', (event) => {
|
self.addEventListener('install', (event) => {
|
||||||
@@ -68,6 +75,17 @@ async function cacheFirstAsset(request) {
|
|||||||
return response;
|
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) {
|
async function staleWhileRevalidate(request) {
|
||||||
const cache = await caches.open(SHELL_CACHE);
|
const cache = await caches.open(SHELL_CACHE);
|
||||||
const hit = await cache.match(request);
|
const hit = await cache.match(request);
|
||||||
@@ -80,8 +98,78 @@ async function staleWhileRevalidate(request) {
|
|||||||
return hit || refresh;
|
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) => {
|
self.addEventListener('fetch', (event) => {
|
||||||
const { request } = event;
|
const { request } = event;
|
||||||
|
if (request.method === 'POST') {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin === self.location.origin && url.pathname === '/share-target') {
|
||||||
|
event.respondWith(handleShareTarget(event));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (request.method !== 'GET') return;
|
if (request.method !== 'GET') return;
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
|
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
|
||||||
@@ -90,7 +178,42 @@ self.addEventListener('fetch', (event) => {
|
|||||||
event.respondWith(networkFirstPage(request));
|
event.respondWith(networkFirstPage(request));
|
||||||
} else if (url.pathname.startsWith('/assets/')) {
|
} else if (url.pathname.startsWith('/assets/')) {
|
||||||
event.respondWith(cacheFirstAsset(request));
|
event.respondWith(cacheFirstAsset(request));
|
||||||
|
} else if (url.pathname === '/manifest.json') {
|
||||||
|
event.respondWith(networkFirstShell(request));
|
||||||
} else if (SHELL_URLS.includes(url.pathname)) {
|
} else if (SHELL_URLS.includes(url.pathname)) {
|
||||||
event.respondWith(staleWhileRevalidate(request));
|
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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
+247
-62
@@ -10,6 +10,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
Palette,
|
Palette,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -42,6 +43,9 @@ import {
|
|||||||
AVAILABLE_THEMES,
|
AVAILABLE_THEMES,
|
||||||
customThemeOption,
|
customThemeOption,
|
||||||
initThemeOnBoot,
|
initThemeOnBoot,
|
||||||
|
hasChosenTheme,
|
||||||
|
DEMO_THEME,
|
||||||
|
watchSystemScheme,
|
||||||
isCustomThemeId,
|
isCustomThemeId,
|
||||||
setActiveTheme as applyTheme,
|
setActiveTheme as applyTheme,
|
||||||
ThemeId,
|
ThemeId,
|
||||||
@@ -55,6 +59,8 @@ import { WorkspaceView } from './features/workspace/WorkspaceView';
|
|||||||
import { SharedListsView } from './features/lists/SharedListsView';
|
import { SharedListsView } from './features/lists/SharedListsView';
|
||||||
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
|
||||||
import { OfflineBanner } from './components/OfflineBanner';
|
import { OfflineBanner } from './components/OfflineBanner';
|
||||||
|
import { useServerOnline } from './lib/useServerOnline';
|
||||||
|
import { SharedImportToast } from './components/SharedImportToast';
|
||||||
import { outboxCount, clearOfflineData } from './lib/offline';
|
import { outboxCount, clearOfflineData } from './lib/offline';
|
||||||
import { DemoCountdown } from './components/DemoCountdown';
|
import { DemoCountdown } from './components/DemoCountdown';
|
||||||
import { UserSettingsView } from './features/settings/UserSettingsView';
|
import { UserSettingsView } from './features/settings/UserSettingsView';
|
||||||
@@ -65,17 +71,19 @@ import { useMobileActions } from './context/MobileActionContext';
|
|||||||
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
|
||||||
import { UserAvatar } from './components/UserAvatar';
|
import { UserAvatar } from './components/UserAvatar';
|
||||||
import { CommandPalette } from './components/CommandPalette';
|
import { CommandPalette } from './components/CommandPalette';
|
||||||
|
import { AppFooter } from './components/AppFooter';
|
||||||
|
import { ThemeQuickPicker } from './components/ThemeQuickPicker';
|
||||||
|
import { AboutModal } from './components/AboutModal';
|
||||||
|
import { TriageTrash } from './features/triage/TriageTrash';
|
||||||
|
import { UpdateAvailableModal } from './components/UpdateAvailableModal';
|
||||||
|
import { useUpdateCheck } from './lib/useUpdateCheck';
|
||||||
|
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
|
||||||
import { APP_VERSION } from './version';
|
import { APP_VERSION } from './version';
|
||||||
|
import { readViewSession, writeViewSession } from './lib/viewSession';
|
||||||
|
import { setVoicePreference } from './lib/voice';
|
||||||
|
|
||||||
type ActiveTab =
|
const APP_TABS = ['triage', 'tasks', 'workspace', 'lists', 'groups', 'user_settings', 'admin_settings', 'help'] as const;
|
||||||
| 'triage'
|
type ActiveTab = (typeof APP_TABS)[number];
|
||||||
| 'tasks'
|
|
||||||
| 'workspace'
|
|
||||||
| 'lists'
|
|
||||||
| 'groups'
|
|
||||||
| 'user_settings'
|
|
||||||
| 'admin_settings'
|
|
||||||
| 'help';
|
|
||||||
|
|
||||||
interface ErrorBoundaryState {
|
interface ErrorBoundaryState {
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
@@ -131,6 +139,9 @@ export const App: React.FC = () => {
|
|||||||
const [demoDeadline, setDemoDeadline] = useState<number | null>(null);
|
const [demoDeadline, setDemoDeadline] = useState<number | null>(null);
|
||||||
const [dataEpoch, setDataEpoch] = useState(0);
|
const [dataEpoch, setDataEpoch] = useState(0);
|
||||||
const [demoNotice, setDemoNotice] = useState<string | null>(null);
|
const [demoNotice, setDemoNotice] = useState<string | null>(null);
|
||||||
|
// Just shared from another app: these Quick Dump entries can still be sent on as task / list item
|
||||||
|
const [sharedImportIds, setSharedImportIds] = useState<string[] | null>(null);
|
||||||
|
const closeSharedImport = useCallback(() => setSharedImportIds(null), []);
|
||||||
const lastDemoResetRef = useRef(0);
|
const lastDemoResetRef = useRef(0);
|
||||||
const handleDemoResetRef = useRef<() => void>(() => {});
|
const handleDemoResetRef = useRef<() => void>(() => {});
|
||||||
// Link from the password reset mail (?resetToken=…); removed from the address bar right away
|
// Link from the password reset mail (?resetToken=…); removed from the address bar right away
|
||||||
@@ -158,7 +169,17 @@ export const App: React.FC = () => {
|
|||||||
});
|
});
|
||||||
const [deck, setDeck] = useState<Thought[]>([]);
|
const [deck, setDeck] = useState<Thought[]>([]);
|
||||||
const [lists, setLists] = useState<SharedList[]>([]);
|
const [lists, setLists] = useState<SharedList[]>([]);
|
||||||
const [activeTab, setActiveTab] = useState<ActiveTab>('triage');
|
// The open tab survives a reload of this browser tab (lib/viewSession); a new tab starts at the triage
|
||||||
|
const [activeTab, setActiveTab] = useState<ActiveTab>(() => {
|
||||||
|
const tab = readViewSession<{ tab: ActiveTab }>('app').tab;
|
||||||
|
return tab && APP_TABS.includes(tab) ? tab : 'triage';
|
||||||
|
});
|
||||||
|
useEffect(() => writeViewSession('app', { tab: activeTab }), [activeTab]);
|
||||||
|
// Whisper or the browser for voice input: the account's choice (lib/voice.ts)
|
||||||
|
useEffect(() => setVoicePreference(user?.voiceInput), [user?.voiceInput]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && user.role !== 'admin' && activeTab === 'admin_settings') setActiveTab('triage');
|
||||||
|
}, [user, activeTab]);
|
||||||
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
|
const [previousTab, setPreviousTab] = useState<ActiveTab>('triage');
|
||||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
const [selectedDocId, setSelectedDocId] = useState<string | null>(null);
|
||||||
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
const [selectedBlockId, setSelectedBlockId] = useState<string | null>(null);
|
||||||
@@ -170,6 +191,10 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
// Profile Dropdown State
|
// Profile Dropdown State
|
||||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||||
|
const serverOnline = useServerOnline();
|
||||||
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
|
const [showTriageTrash, setShowTriageTrash] = useState(false);
|
||||||
|
const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck();
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Mobile Navigation & Quick-Action State
|
// Mobile Navigation & Quick-Action State
|
||||||
@@ -183,6 +208,7 @@ export const App: React.FC = () => {
|
|||||||
const mobileBaseVoiceRef = useRef('');
|
const mobileBaseVoiceRef = useRef('');
|
||||||
// Voice input of the mobile capture sheet: own Whisper server or the browser (lib/voice.ts)
|
// Voice input of the mobile capture sheet: own Whisper server or the browser (lib/voice.ts)
|
||||||
const mobileVoice = useVoiceInput({
|
const mobileVoice = useVoiceInput({
|
||||||
|
chunked: true,
|
||||||
onText: (text) => {
|
onText: (text) => {
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
const base = mobileBaseVoiceRef.current;
|
const base = mobileBaseVoiceRef.current;
|
||||||
@@ -239,6 +265,34 @@ export const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { isMenuOpen, setIsMenuOpen, closeMenu, setGroups, primaryAction, setPrimaryAction } = useMobileActions();
|
const { isMenuOpen, setIsMenuOpen, closeMenu, setGroups, primaryAction, setPrimaryAction } = useMobileActions();
|
||||||
|
const primaryActionRef = useRef(primaryAction);
|
||||||
|
primaryActionRef.current = primaryAction;
|
||||||
|
|
||||||
|
// App icon shortcuts (manifest "shortcuts", long-press on the icon): open the tab and its input right away.
|
||||||
|
// ?shortcut=quickdump|task|list; the old links (?mode=capture, ?tab=triage) still lead to the Quick Dump.
|
||||||
|
const [pendingShortcut, setPendingShortcut] = useState<ActiveTab | null>(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const target = params.get('shortcut') || (params.get('mode') === 'capture' || params.get('tab') === 'triage' ? 'quickdump' : '');
|
||||||
|
return ({ quickdump: 'triage', task: 'tasks', list: 'lists' } as Record<string, ActiveTab>)[target] || null;
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingShortcut || !user) return;
|
||||||
|
if (activeTab !== pendingShortcut) {
|
||||||
|
setActiveTab(pendingShortcut);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = pendingShortcut;
|
||||||
|
setPendingShortcut(null);
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
['shortcut', 'mode', 'tab'].forEach((k) => url.searchParams.delete(k));
|
||||||
|
window.history.replaceState({}, '', url.pathname + url.search + url.hash);
|
||||||
|
// The view registers its "+" action once it is mounted, so wait a moment (no cleanup: resetting
|
||||||
|
// pendingShortcut above re-runs this effect and must not cancel the timer)
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (target === 'triage') handleTriggerAddButton();
|
||||||
|
else if (window.innerWidth < 640 || target === 'lists') primaryActionRef.current?.();
|
||||||
|
}, 400);
|
||||||
|
}, [pendingShortcut, user?.id, activeTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
closeMenu();
|
closeMenu();
|
||||||
@@ -263,6 +317,13 @@ export const App: React.FC = () => {
|
|||||||
badge: stats.inboxCount,
|
badge: stats.inboxCount,
|
||||||
onClick: () => refreshAll(),
|
onClick: () => refreshAll(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'triage-trash',
|
||||||
|
label: 'Papierkorb',
|
||||||
|
icon: <Trash2 className="w-4 h-4" />,
|
||||||
|
badge: stats.discardedCount,
|
||||||
|
onClick: () => setShowTriageTrash(true),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -343,7 +404,7 @@ export const App: React.FC = () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}, [activeTab, stats.inboxCount, stats.tasksOpenCount, stats.docsCount, stats.listsCount]);
|
}, [activeTab, stats.inboxCount, stats.tasksOpenCount, stats.docsCount, stats.listsCount, stats.discardedCount]);
|
||||||
|
|
||||||
// Mobile Web-App (PWA) Installation State
|
// Mobile Web-App (PWA) Installation State
|
||||||
const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => {
|
const [isStandaloneApp, setIsStandaloneApp] = useState<boolean>(() => {
|
||||||
@@ -438,6 +499,9 @@ export const App: React.FC = () => {
|
|||||||
setActiveThemeState(isCustomThemeId(themeId) && css === undefined ? 'dark' : themeId);
|
setActiveThemeState(isCustomThemeId(themeId) && css === undefined ? 'dark' : themeId);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// "Wie System": the theme follows the system's light/dark switch while the app is open
|
||||||
|
useEffect(() => watchSystemScheme(setActiveThemeState), []);
|
||||||
|
|
||||||
const handleChangeTheme = (themeId: ThemeId) => {
|
const handleChangeTheme = (themeId: ThemeId) => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
activateTheme(themeId, customThemes);
|
activateTheme(themeId, customThemes);
|
||||||
@@ -552,6 +616,60 @@ export const App: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
|
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
|
||||||
|
|
||||||
|
// Demo: first visit on this device starts with a round theme instead of the square default
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authStatus.demoMode || hasChosenTheme()) return;
|
||||||
|
applyTheme(DEMO_THEME);
|
||||||
|
setActiveThemeState(DEMO_THEME);
|
||||||
|
}, [authStatus.demoMode]);
|
||||||
|
|
||||||
|
// Web Share Target: import shared thoughts from external apps
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const hasPendingShare = params.get('shared') === 'pending';
|
||||||
|
|
||||||
|
const processShares = async () => {
|
||||||
|
try {
|
||||||
|
const shares = await consumePendingShares();
|
||||||
|
if (shares.length === 0) return;
|
||||||
|
|
||||||
|
let importedCount = 0;
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const share of shares) {
|
||||||
|
const content = formatSharedThought(share);
|
||||||
|
if (content) {
|
||||||
|
const created = await api.captureThought(content, 'normal', 'manual', '', { target: 'inbox' });
|
||||||
|
importedCount++;
|
||||||
|
if (created?.id && !created.id.startsWith('th_offline_')) ids.push(created.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (importedCount > 0) {
|
||||||
|
triggerHaptic(15);
|
||||||
|
if (ids.length === importedCount) {
|
||||||
|
setSharedImportIds(ids);
|
||||||
|
} else {
|
||||||
|
// Offline: kept for later, nothing to convert yet
|
||||||
|
setDemoNotice('Im Quick Dump abgelegt');
|
||||||
|
setTimeout(() => setDemoNotice(null), 4000);
|
||||||
|
}
|
||||||
|
refreshAll();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fehler beim Import geteilter Daten:', err);
|
||||||
|
} finally {
|
||||||
|
if (hasPendingShare) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('shared');
|
||||||
|
window.history.replaceState({}, '', url.pathname + (url.search ? url.search : '') + url.hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
processShares();
|
||||||
|
}, [user, refreshAll]);
|
||||||
|
|
||||||
// The demo database was reset: drop cached answers of the old data, remount the views (they load
|
// The demo database was reset: drop cached answers of the old data, remount the views (they load
|
||||||
// fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs).
|
// fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs).
|
||||||
const handleDemoReset = useCallback(() => {
|
const handleDemoReset = useCallback(() => {
|
||||||
@@ -579,8 +697,20 @@ export const App: React.FC = () => {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Refresh stats on task/doc/list real-time events (counters in the navigation)
|
// Refresh stats on task/doc/list real-time events (counters in the navigation). Task events also
|
||||||
|
// reload the triage deck: a note sorted on another device must leave this deck too.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let deckTimer: number | undefined;
|
||||||
|
const reloadDeck = () => {
|
||||||
|
window.clearTimeout(deckTimer);
|
||||||
|
deckTimer = window.setTimeout(() => {
|
||||||
|
api.getTriageDeck().then((res) => setDeck(Array.isArray(res) ? res : [])).catch(() => {});
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
// Events missed while the tab slept (desktop in the background, connection dropped)
|
||||||
|
const onVisible = () => {
|
||||||
|
if (document.visibilityState === 'visible' && getToken()) refreshAll();
|
||||||
|
};
|
||||||
const handleWs = (e: any) => {
|
const handleWs = (e: any) => {
|
||||||
const msg = e.detail;
|
const msg = e.detail;
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
@@ -595,13 +725,17 @@ export const App: React.FC = () => {
|
|||||||
api.getMe().then((res) => {
|
api.getMe().then((res) => {
|
||||||
setStats(res.stats);
|
setStats(res.stats);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
if (msg.event.startsWith('TASK_')) reloadDeck();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('mindsift:ws', handleWs);
|
window.addEventListener('mindsift:ws', handleWs);
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
return () => {
|
return () => {
|
||||||
|
window.clearTimeout(deckTimer);
|
||||||
window.removeEventListener('mindsift:ws', handleWs);
|
window.removeEventListener('mindsift:ws', handleWs);
|
||||||
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [refreshAll]);
|
||||||
|
|
||||||
// Close profile dropdown on outside click
|
// Close profile dropdown on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -750,7 +884,11 @@ export const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30">
|
<div
|
||||||
|
className={`bg-canvas text-main flex flex-col font-sans selection:bg-accent/30 ${
|
||||||
|
activeTab === 'workspace' ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
|
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
|
||||||
<header className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm">
|
<header className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm">
|
||||||
{/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */}
|
{/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */}
|
||||||
@@ -816,11 +954,14 @@ export const App: React.FC = () => {
|
|||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
setShowCommandPalette(true);
|
setShowCommandPalette(true);
|
||||||
}}
|
}}
|
||||||
className="w-11 h-11 rounded-full flex items-center justify-center text-muted hover:text-main bg-panel border border-border transition-transform active:scale-95 cursor-pointer"
|
className="w-11 h-11 rounded-full flex items-center justify-center text-muted hover:text-main transition-transform active:scale-95 cursor-pointer"
|
||||||
title="Globale Suche"
|
title="Globale Suche"
|
||||||
aria-label="Globale Suche öffnen"
|
aria-label="Globale Suche öffnen"
|
||||||
>
|
>
|
||||||
<Search className="w-4 h-4" />
|
{/* Same visible size as the avatar next to it; the 44px button stays the touch target */}
|
||||||
|
<span className="w-9 h-9 rounded-full flex items-center justify-center bg-panel border border-border">
|
||||||
|
<Search className="w-4 h-4" />
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -836,7 +977,17 @@ export const App: React.FC = () => {
|
|||||||
}`}
|
}`}
|
||||||
title="Profil & Einstellungen"
|
title="Profil & Einstellungen"
|
||||||
>
|
>
|
||||||
<UserAvatar user={user} size="sm" className="shadow-sm" />
|
<span className="relative">
|
||||||
|
<UserAvatar user={user} size="md" className="shadow-sm" />
|
||||||
|
{/* Connection status like in a chat app: green online, red without server */}
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
aria-label={serverOnline ? 'Online' : 'Offline – keine Verbindung zum Server'}
|
||||||
|
className={`absolute bottom-0 right-0 w-2.5 h-2.5 rounded-full ring-2 ring-canvas ${
|
||||||
|
serverOnline ? 'bg-status-ok' : 'bg-status-err'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -902,14 +1053,26 @@ export const App: React.FC = () => {
|
|||||||
title="Benutzermenü öffnen"
|
title="Benutzermenü öffnen"
|
||||||
>
|
>
|
||||||
<UserAvatar user={user} size="sm" />
|
<UserAvatar user={user} size="sm" />
|
||||||
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
<span className="flex flex-col items-start leading-tight">
|
||||||
{user.displayName}
|
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
|
||||||
|
{user.displayName}
|
||||||
|
</span>
|
||||||
|
{!serverOnline && (
|
||||||
|
<span
|
||||||
|
role="status"
|
||||||
|
title="Keine Verbindung zum Server – Änderungen werden später synchronisiert"
|
||||||
|
className="hidden sm:inline-flex items-center gap-1 text-[10px] font-normal text-muted"
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-status-err" />
|
||||||
|
offline
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" />
|
<ChevronDown className="w-3.5 h-3.5 text-muted group-hover:text-main transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showProfileMenu && (
|
{showProfileMenu && (
|
||||||
<div className="absolute right-0 top-full mt-1.5 w-64 panel-surface shadow-panel z-50 divide-y divide-border text-xs">
|
<div className="absolute right-0 top-full mt-1.5 w-64 panel-surface shadow-panel overflow-hidden z-50 divide-y divide-border text-xs origin-top-right animate-in fade-in zoom-in-95 slide-in-from-top-1 duration-150">
|
||||||
{/* User Info Header */}
|
{/* User Info Header */}
|
||||||
<div className="p-3 bg-canvas flex items-center gap-2.5">
|
<div className="p-3 bg-canvas flex items-center gap-2.5">
|
||||||
<UserAvatar user={user} size="md" className="border border-border/70" />
|
<UserAvatar user={user} size="md" className="border border-border/70" />
|
||||||
@@ -928,26 +1091,7 @@ export const App: React.FC = () => {
|
|||||||
<Palette className="w-3 h-3 text-accent" />
|
<Palette className="w-3 h-3 text-accent" />
|
||||||
<span>Design-Theme</span>
|
<span>Design-Theme</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="grid grid-cols-2 gap-1">
|
<ThemeQuickPicker activeTheme={activeTheme} themes={allThemes} onChange={handleChangeTheme} />
|
||||||
{allThemes.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleChangeTheme(t.id)}
|
|
||||||
className={`h-7 px-2 text-[11px] font-medium border rounded-btn flex items-center gap-1.5 transition-colors ${
|
|
||||||
activeTheme === t.id
|
|
||||||
? 'bg-panel-elevated border-accent text-main font-semibold'
|
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="w-2.5 h-2.5 border border-strong flex-shrink-0"
|
|
||||||
style={{ backgroundColor: t.previewBg }}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{t.name.replace('MindSift ', '').split(' ')[0]}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu Items */}
|
{/* Menu Items */}
|
||||||
@@ -1103,6 +1247,19 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
|
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
|
||||||
<OfflineBanner />
|
<OfflineBanner />
|
||||||
|
{sharedImportIds && (
|
||||||
|
<SharedImportToast
|
||||||
|
thoughtIds={sharedImportIds}
|
||||||
|
lists={lists}
|
||||||
|
onClose={closeSharedImport}
|
||||||
|
onConverted={(message) => {
|
||||||
|
setSharedImportIds(null);
|
||||||
|
setDemoNotice(message);
|
||||||
|
setTimeout(() => setDemoNotice(null), 3000);
|
||||||
|
refreshAll();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{demoNotice && (
|
{demoNotice && (
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
@@ -1113,9 +1270,14 @@ export const App: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Container */}
|
{/* Main Container */}
|
||||||
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
|
{/* The workspace fills the window exactly and scrolls only inside the note, never the page */}
|
||||||
|
<main
|
||||||
|
className={`flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 ${
|
||||||
|
activeTab === 'workspace' ? 'min-h-0 flex flex-col sm:pb-0' : 'sm:pb-6'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
||||||
<section key={dataEpoch}>
|
<section key={dataEpoch} className={activeTab === 'workspace' ? 'flex-1 min-h-0 flex flex-col' : undefined}>
|
||||||
{activeTab === 'triage' && (
|
{activeTab === 'triage' && (
|
||||||
<TriageDeck
|
<TriageDeck
|
||||||
deck={deck}
|
deck={deck}
|
||||||
@@ -1123,6 +1285,8 @@ export const App: React.FC = () => {
|
|||||||
onDeckChange={refreshAll}
|
onDeckChange={refreshAll}
|
||||||
onOpenDocument={handleOpenDocument}
|
onOpenDocument={handleOpenDocument}
|
||||||
onFocusCapture={handleTriggerAddButton}
|
onFocusCapture={handleTriggerAddButton}
|
||||||
|
discardedCount={stats.discardedCount}
|
||||||
|
onOpenTrash={() => setShowTriageTrash(true)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1133,6 +1297,7 @@ export const App: React.FC = () => {
|
|||||||
onOpenDocument={handleOpenDocument}
|
onOpenDocument={handleOpenDocument}
|
||||||
refreshTrigger={refreshRevision}
|
refreshTrigger={refreshRevision}
|
||||||
initialSelectedTaskId={selectedTaskId}
|
initialSelectedTaskId={selectedTaskId}
|
||||||
|
onInitialTaskShown={() => setSelectedTaskId(null)}
|
||||||
openTasksCount={stats.tasksOpenCount}
|
openTasksCount={stats.tasksOpenCount}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -1193,6 +1358,11 @@ export const App: React.FC = () => {
|
|||||||
</ViewErrorBoundary>
|
</ViewErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<AppFooter
|
||||||
|
legal={authStatus.legal}
|
||||||
|
onOpenAbout={() => setShowAbout(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
|
{/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
|
||||||
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
|
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
|
||||||
{/* Col 1: Triage */}
|
{/* Col 1: Triage */}
|
||||||
@@ -1499,26 +1669,7 @@ export const App: React.FC = () => {
|
|||||||
<Palette className="w-3.5 h-3.5 text-accent" />
|
<Palette className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Design-Theme</span>
|
<span>Design-Theme</span>
|
||||||
</span>
|
</span>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<ThemeQuickPicker activeTheme={activeTheme} themes={allThemes} onChange={handleChangeTheme} touch />
|
||||||
{allThemes.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleChangeTheme(t.id)}
|
|
||||||
className={`h-9 px-2.5 text-xs font-medium border rounded-btn flex items-center gap-2 transition-colors cursor-pointer ${
|
|
||||||
activeTheme === t.id
|
|
||||||
? 'bg-panel-elevated border-accent text-main font-semibold'
|
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="w-3 h-3 border border-strong flex-shrink-0 rounded-full"
|
|
||||||
style={{ backgroundColor: t.previewBg }}
|
|
||||||
/>
|
|
||||||
<span className="truncate">{t.name.replace('MindSift ', '')}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu Items */}
|
{/* Menu Items */}
|
||||||
@@ -1585,6 +1736,19 @@ export const App: React.FC = () => {
|
|||||||
<span>Hilfe & Anleitung</span>
|
<span>Hilfe & Anleitung</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
triggerHaptic(10);
|
||||||
|
setShowMobileProfile(false);
|
||||||
|
setShowAbout(true);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer text-main"
|
||||||
|
>
|
||||||
|
<Brain className="w-4 h-4 text-accent" />
|
||||||
|
<span>Über MindSift</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
{isServerAdmin && (
|
{isServerAdmin && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1617,6 +1781,11 @@ export const App: React.FC = () => {
|
|||||||
<span>Abmelden</span>
|
<span>Abmelden</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Version & About Hint on Mobile */}
|
||||||
|
<div className="pt-2 text-center text-[10px] text-faint font-mono">
|
||||||
|
MindSift {APP_VERSION}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1674,6 +1843,18 @@ export const App: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 pt-1">
|
<div className="space-y-3 pt-1">
|
||||||
|
{/* Chrome may report "installable" only after the guide opened (e.g. shortly after the
|
||||||
|
old app was removed): then the real install is one tap away again */}
|
||||||
|
{deferredPwaPrompt && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleInstallWebApp}
|
||||||
|
className="btn-primary w-full h-12 text-sm font-semibold rounded-xl flex items-center justify-center gap-2 shadow-md active:scale-98 transition-transform cursor-pointer"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
<span>Jetzt installieren</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<div className="p-3.5 rounded-xl bg-canvas border border-border space-y-2.5 text-xs text-main">
|
<div className="p-3.5 rounded-xl bg-canvas border border-border space-y-2.5 text-xs text-main">
|
||||||
<p className="font-semibold text-accent flex items-center gap-1.5">
|
<p className="font-semibold text-accent flex items-center gap-1.5">
|
||||||
<Smartphone className="w-4 h-4" />
|
<Smartphone className="w-4 h-4" />
|
||||||
@@ -1716,6 +1897,10 @@ export const App: React.FC = () => {
|
|||||||
onOpenTask={handleOpenTask}
|
onOpenTask={handleOpenTask}
|
||||||
onOpenList={handleOpenList}
|
onOpenList={handleOpenList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
|
||||||
|
{showTriageTrash && <TriageTrash onClose={() => setShowTriageTrash(false)} onChanged={refreshAll} />}
|
||||||
|
{availableUpdate && <UpdateAvailableModal update={availableUpdate} onLater={dismissUpdate} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ReadingModal } from './ReadingModal';
|
|||||||
|
|
||||||
// Project facts shown in "Über MindSift"; change here when the repository or license changes
|
// 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_REPO_URL = 'https://git.dsit.me/sandrodach/MindSift';
|
||||||
export const PROJECT_LICENSE = 'The Unlicense – gemeinfrei, ohne Bedingungen';
|
export const PROJECT_LICENSE = 'GNU AGPLv3 – freie Software, Änderungen bleiben frei';
|
||||||
|
|
||||||
export const AboutModal: React.FC<{ onClose: () => void }> = ({ onClose }) => (
|
export const AboutModal: React.FC<{ onClose: () => void }> = ({ onClose }) => (
|
||||||
<ReadingModal title="Über MindSift" onClose={onClose}>
|
<ReadingModal title="Über MindSift" onClose={onClose}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { SearchHit } from '../lib/types';
|
import { SearchHit } from '../lib/types';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
|
import { SnippetRenderer } from './SnippetRenderer';
|
||||||
|
|
||||||
interface CommandPaletteProps {
|
interface CommandPaletteProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -19,32 +20,6 @@ interface CommandPaletteProps {
|
|||||||
onOpenList: (listId: 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> = ({
|
export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -204,21 +179,25 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
placeholder="Globale Suche in Notizen, Aufgaben und Listen…"
|
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"
|
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" />}
|
{/* One fixed slot for the spinner or the clear button, so the field keeps its width while typing */}
|
||||||
{query && !loading && (
|
<span className="w-6 h-6 shrink-0 flex items-center justify-center">
|
||||||
<button
|
{loading ? (
|
||||||
type="button"
|
<Loader2 className="w-4 h-4 animate-spin text-accent" />
|
||||||
onClick={() => {
|
) : query ? (
|
||||||
setQuery('');
|
<button
|
||||||
setHits([]);
|
type="button"
|
||||||
inputRef.current?.focus();
|
onClick={() => {
|
||||||
}}
|
setQuery('');
|
||||||
className="text-muted hover:text-main p-1"
|
setHits([]);
|
||||||
title="Eingabe leeren"
|
inputRef.current?.focus();
|
||||||
>
|
}}
|
||||||
<X className="w-4 h-4" />
|
className="w-6 h-6 flex items-center justify-center text-muted hover:text-main"
|
||||||
</button>
|
title="Eingabe leeren"
|
||||||
)}
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -269,7 +248,7 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className="mt-0.5 shrink-0 text-base leading-none">
|
<div className="mt-0.5 shrink-0 text-base leading-none">
|
||||||
{item.icon || (item.type === 'document' ? '📝' : item.type === 'thought' ? '✅' : '🛒')}
|
{item.icon || (item.type === 'document' ? '🗒️' : item.type === 'thought' ? '✅' : '🛒')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 space-y-0.5">
|
<div className="flex-1 min-w-0 space-y-0.5">
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { SmilePlus } from 'lucide-react';
|
||||||
|
import { EmojiPickerModal } from '../features/workspace/EmojiPickerModal';
|
||||||
|
|
||||||
|
interface EmojiIconButtonProps {
|
||||||
|
value: string;
|
||||||
|
// "" = no icon (removed in the picker)
|
||||||
|
onChange: (emoji: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon field of the create dialogs (note, task list, list): the emoji as a button that opens the same
|
||||||
|
// picker as the icon of an open note, removing included (Doku/ui-patterns.md).
|
||||||
|
export const EmojiIconButton: React.FC<EmojiIconButtonProps> = ({ value, onChange }) => {
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [anchor, setAnchor] = useState<DOMRect | null>(null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAnchor((cur) => (cur ? null : buttonRef.current?.getBoundingClientRect() ?? null))}
|
||||||
|
title={value ? 'Icon ändern' : 'Icon wählen'}
|
||||||
|
aria-label={value ? 'Icon ändern' : 'Icon wählen'}
|
||||||
|
className="w-12 sm:w-10 h-11 sm:h-8 flex-shrink-0 flex items-center justify-center input-field text-lg sm:text-base leading-none cursor-pointer hover:border-strong"
|
||||||
|
>
|
||||||
|
{value || <SmilePlus className="w-4 h-4 text-faint" strokeWidth={1.75} />}
|
||||||
|
</button>
|
||||||
|
{anchor && (
|
||||||
|
<EmojiPickerModal
|
||||||
|
currentIcon={value}
|
||||||
|
anchor={anchor}
|
||||||
|
onSelectEmoji={(emoji) => {
|
||||||
|
onChange(emoji);
|
||||||
|
setAnchor(null);
|
||||||
|
}}
|
||||||
|
onClose={() => setAnchor(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -16,7 +16,8 @@ interface FormSheetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Small create/rename dialogs. Phone: bottom sheet directly above the keyboard, full width, large
|
// Small create/rename dialogs. Phone: bottom sheet directly above the keyboard, full width, large
|
||||||
// fields. Desktop: centred dialog (or next to the click). Escape and the backdrop close it.
|
// 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> = ({
|
export const FormSheet: React.FC<FormSheetProps> = ({
|
||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
@@ -36,7 +37,12 @@ export const FormSheet: React.FC<FormSheetProps> = ({
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const desktopAnchor = anchor
|
const desktopAnchor = anchor
|
||||||
? ({ '--sheet-left': `${anchor.x}px`, '--sheet-top': `${anchor.y}px` } as React.CSSProperties)
|
? ({
|
||||||
|
'--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;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -51,10 +57,10 @@ export const FormSheet: React.FC<FormSheetProps> = ({
|
|||||||
'--vv-max': visibleHeight ? `${visibleHeight - 16}px` : '85vh',
|
'--vv-max': visibleHeight ? `${visibleHeight - 16}px` : '85vh',
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto
|
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 ${
|
sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${
|
||||||
anchor
|
anchor
|
||||||
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-left)] sm:top-[var(--sheet-top)]'
|
? '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'
|
: 'sm:max-w-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface LazyMountProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
// Shown until the place scrolls into view (keeps the page's height roughly right)
|
||||||
|
placeholder: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renders its children only once they come near the visible area, and keeps them afterwards. For lists
|
||||||
|
// of heavier parts, e.g. one live note editor per entry on a hashtag page.
|
||||||
|
export const LazyMount: React.FC<LazyMountProps> = ({ children, placeholder }) => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [shown, setShown] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (shown || !el) return;
|
||||||
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
|
setShown(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((e) => e.isIntersecting)) setShown(true);
|
||||||
|
},
|
||||||
|
{ rootMargin: '400px 0px' }
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [shown]);
|
||||||
|
return <div ref={ref}>{shown ? children : placeholder}</div>;
|
||||||
|
};
|
||||||
@@ -28,7 +28,9 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
|
|||||||
}))
|
}))
|
||||||
.filter((e) => e.url || e.hasContent);
|
.filter((e) => e.url || e.hasContent);
|
||||||
|
|
||||||
if (entries.length === 0 && !children) return null;
|
const customHref = legal?.customLinkUrl && safeHref(legal.customLinkUrl);
|
||||||
|
|
||||||
|
if (entries.length === 0 && !children && !customHref) return null;
|
||||||
|
|
||||||
const openText = async (type: LegalType) => {
|
const openText = async (type: LegalType) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -52,6 +54,16 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
|
|||||||
</button>
|
</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}
|
{children}
|
||||||
{error && <span className="text-status-err">{error}</span>}
|
{error && <span className="text-status-err">{error}</span>}
|
||||||
{open && (
|
{open && (
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Bot, Webhook } from 'lucide-react';
|
||||||
|
import { Origin } from '../lib/types';
|
||||||
|
|
||||||
|
interface OriginBadgeProps {
|
||||||
|
origin: Origin | undefined;
|
||||||
|
// When the entry was created, for the details
|
||||||
|
createdAt?: string;
|
||||||
|
// xs: Kanban cards and list items; sm: task rows, inbox, detail view; md: triage cards
|
||||||
|
size?: 'xs' | 'sm' | 'md';
|
||||||
|
}
|
||||||
|
|
||||||
|
const VIA_LABEL = { api: 'API', mcp: 'MCP' } as const;
|
||||||
|
|
||||||
|
const formatWhen = (iso?: string) => {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
// originDetails: who created and who last changed the entry, as one line for tooltip and detail view
|
||||||
|
export const originDetails = (origin: Origin | undefined, createdAt?: string): string => {
|
||||||
|
if (!origin || (!origin.createdVia && !origin.changedVia)) return '';
|
||||||
|
const parts: string[] = [];
|
||||||
|
const created = formatWhen(createdAt);
|
||||||
|
if (origin.createdVia) {
|
||||||
|
const key = origin.createdKey ? ` (${origin.createdKey})` : '';
|
||||||
|
parts.push(`Angelegt per ${VIA_LABEL[origin.createdVia]}${key}${created ? ` am ${created}` : ''}`);
|
||||||
|
} else {
|
||||||
|
parts.push(`In der App angelegt${created ? ` am ${created}` : ''}`);
|
||||||
|
}
|
||||||
|
if (origin.changedVia) {
|
||||||
|
const key = origin.changedKey ? ` (${origin.changedKey})` : '';
|
||||||
|
const when = formatWhen(origin.changedAt);
|
||||||
|
parts.push(`zuletzt geändert per ${VIA_LABEL[origin.changedVia]}${key}${when ? ` am ${when}` : ''}`);
|
||||||
|
}
|
||||||
|
return parts.join(' · ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const SIZE_CLASS = {
|
||||||
|
xs: 'px-1.5 py-0.2 text-[9px]',
|
||||||
|
sm: 'px-1.5 py-0.5 text-[10px]',
|
||||||
|
md: 'px-2 py-0.5 text-[11px]',
|
||||||
|
};
|
||||||
|
|
||||||
|
// One badge for entries an API key created or changed (Doku/ui-patterns.md): icon for the access
|
||||||
|
// (webhook = REST, robot = MCP) and the key's name. Hover shows the details; a tap opens them, so it
|
||||||
|
// works on phones too. It stays for good.
|
||||||
|
export const OriginBadge: React.FC<OriginBadgeProps> = ({ origin, createdAt, size = 'sm' }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = (e: Event) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', close);
|
||||||
|
return () => document.removeEventListener('pointerdown', close);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const via = origin?.createdVia || origin?.changedVia;
|
||||||
|
if (!origin || !via) return null;
|
||||||
|
const key = origin.createdVia ? origin.createdKey : origin.changedKey;
|
||||||
|
const label = key || VIA_LABEL[via];
|
||||||
|
const details = originDetails(origin, createdAt);
|
||||||
|
const Icon = via === 'mcp' ? Bot : Webhook;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span ref={ref} className="relative inline-flex flex-shrink-0 max-w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
draggable={false}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen((o) => !o);
|
||||||
|
}}
|
||||||
|
title={details}
|
||||||
|
aria-label={details}
|
||||||
|
aria-expanded={open}
|
||||||
|
className={`relative border border-border bg-canvas text-muted rounded-btn font-medium inline-flex items-center gap-1 max-w-[12rem] cursor-pointer hover:text-main before:absolute before:content-[''] before:inset-x-0 before:-inset-y-3 sm:before:hidden ${SIZE_CLASS[size]}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-2.5 h-2.5 text-accent flex-shrink-0" />
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<span
|
||||||
|
role="tooltip"
|
||||||
|
className="absolute left-0 top-full mt-1 z-40 w-64 max-w-[80vw] p-2 bg-panel border border-border rounded-card shadow-panel text-[11px] leading-snug text-main whitespace-normal"
|
||||||
|
>
|
||||||
|
{details}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,28 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
|
// Safely render snippet containing <mark>...</mark> without dangerouslySetInnerHTML
|
||||||
|
export const SnippetRenderer: React.FC<{ snippet: string }> = ({ snippet }) => {
|
||||||
|
const parts = useMemo(() => {
|
||||||
|
if (!snippet) return [];
|
||||||
|
return snippet.split(/(<mark>[\s\S]*?<\/mark>)/g);
|
||||||
|
}, [snippet]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="text-muted text-xs line-clamp-2 leading-relaxed">
|
||||||
|
{parts.map((part, i) => {
|
||||||
|
if (part.startsWith('<mark>') && part.endsWith('</mark>')) {
|
||||||
|
const text = part.slice(6, -7);
|
||||||
|
return (
|
||||||
|
<mark
|
||||||
|
key={i}
|
||||||
|
className="bg-accent-dim text-accent font-semibold px-0.5 rounded"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</mark>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span key={i}>{part}</span>;
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { RefreshCw } from 'lucide-react';
|
||||||
|
import { APP_VERSION } from '../version';
|
||||||
|
import type { AvailableUpdate } from '../lib/useUpdateCheck';
|
||||||
|
|
||||||
|
// Shown when the server got a new MindSift (lib/useUpdateCheck): reloading fetches the new app (the
|
||||||
|
// service worker loads pages from the network first).
|
||||||
|
export const UpdateAvailableModal: React.FC<{ update: AvailableUpdate; onLater: () => void }> = ({ update, onLater }) => (
|
||||||
|
<div className="fixed inset-0 !m-0 z-[70] modal-overlay flex items-center justify-center p-4" onClick={onLater}>
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="update-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="w-full max-w-sm panel-surface shadow-panel p-5 space-y-4"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-9 h-9 bg-accent text-on-accent rounded-btn flex items-center justify-center shrink-0">
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<h3 id="update-title" className="text-sm font-semibold text-main">
|
||||||
|
Neue Version verfügbar
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-muted leading-relaxed">
|
||||||
|
Der Server wurde aktualisiert
|
||||||
|
{update.version && update.version !== APP_VERSION ? (
|
||||||
|
<>
|
||||||
|
{' '}auf <span className="font-mono text-main">{update.version}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
. Lade die Seite neu, um sie zu nutzen; ungespeicherte Eingaben in offenen Feldern gehen dabei verloren.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 sm:justify-end">
|
||||||
|
<button type="button" onClick={onLater} className="btn-action flex-1 sm:flex-none h-11 sm:h-8 px-3 text-sm sm:text-xs">
|
||||||
|
Später
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
autoFocus
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="btn-primary flex-1 sm:flex-none h-11 sm:h-8 px-4 text-sm sm:text-xs font-semibold"
|
||||||
|
>
|
||||||
|
Jetzt aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { Loader2, Mic, MicOff } from 'lucide-react';
|
||||||
|
import { useVoiceInput } from '../lib/useVoiceInput';
|
||||||
|
import { triggerHaptic } from '../lib/api';
|
||||||
|
import { VoiceWave } from './VoiceWave';
|
||||||
|
|
||||||
|
interface VoiceInputButtonProps {
|
||||||
|
// The field's text; dictated words are appended to what was there when the recording started
|
||||||
|
value: string;
|
||||||
|
onChange: (text: string) => void;
|
||||||
|
onError?: (message: string) => void;
|
||||||
|
// Field to focus once the text is final
|
||||||
|
focusRef?: React.RefObject<HTMLInputElement | HTMLTextAreaElement>;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Microphone inside an input row, like the one in Quick Dump (Doku/ui-patterns.md). Hidden when voice
|
||||||
|
// input is off or the browser cannot do it (e.g. Firefox without an own Whisper server).
|
||||||
|
export const VoiceInputButton: React.FC<VoiceInputButtonProps> = ({ value, onChange, onError, focusRef, className = '' }) => {
|
||||||
|
const baseRef = useRef('');
|
||||||
|
const voice = useVoiceInput({
|
||||||
|
onText: (text, final) => {
|
||||||
|
const base = baseRef.current;
|
||||||
|
if (text) onChange(base ? `${base} ${text}` : text);
|
||||||
|
if (final) focusRef?.current?.focus();
|
||||||
|
},
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
if (!voice.available) return null;
|
||||||
|
const listening = voice.state === 'listening';
|
||||||
|
const transcribing = voice.state === 'transcribing';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 flex-shrink-0 ${className}`}>
|
||||||
|
{listening && voice.mode === 'server' && <VoiceWave getLevel={voice.level} className="h-3.5 text-accent" />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
if (voice.state === 'idle') {
|
||||||
|
baseRef.current = value.trim();
|
||||||
|
triggerHaptic(20);
|
||||||
|
}
|
||||||
|
voice.toggle();
|
||||||
|
}}
|
||||||
|
disabled={transcribing}
|
||||||
|
title={listening ? 'Aufnahme beenden' : transcribing ? 'Wird erkannt…' : 'Spracheingabe'}
|
||||||
|
aria-label={listening ? 'Aufnahme beenden' : transcribing ? 'Wird erkannt…' : 'Spracheingabe'}
|
||||||
|
className={`w-9 h-9 sm:w-7 sm:h-7 rounded-full flex items-center justify-center transition-colors ${
|
||||||
|
listening || transcribing ? 'bg-accent-dim text-accent' : 'text-muted hover:text-main hover:bg-panel-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{transcribing ? (
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
) : listening ? (
|
||||||
|
<MicOff className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<Mic className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, fireEvent } from '@testing-library/react';
|
||||||
|
import { OriginBadge, originDetails } from '../OriginBadge';
|
||||||
|
|
||||||
|
describe('OriginBadge', () => {
|
||||||
|
it('shows nothing for entries no key touched', () => {
|
||||||
|
expect(render(<OriginBadge origin={undefined} />).container.firstChild).toBeNull();
|
||||||
|
expect(render(<OriginBadge origin={{}} />).container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the key that created the entry, else the one that changed it', () => {
|
||||||
|
const created = render(<OriginBadge origin={{ createdVia: 'api', createdKey: 'Home Assistant', changedVia: 'mcp', changedKey: 'Claude Code' }} />);
|
||||||
|
expect(created.container.textContent).toBe('Home Assistant');
|
||||||
|
const changed = render(<OriginBadge origin={{ changedVia: 'mcp', changedKey: 'Claude Code' }} />);
|
||||||
|
expect(changed.container.textContent).toBe('Claude Code');
|
||||||
|
const old = render(<OriginBadge origin={{ createdVia: 'mcp' }} />);
|
||||||
|
expect(old.container.textContent).toBe('MCP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the details on tap', () => {
|
||||||
|
const { getByRole, queryByRole } = render(
|
||||||
|
<OriginBadge origin={{ changedVia: 'mcp', changedKey: 'Claude Code', changedAt: '2026-11-01T09:40:00Z' }} createdAt="2026-10-31T08:00:00Z" />
|
||||||
|
);
|
||||||
|
expect(queryByRole('tooltip')).toBeNull();
|
||||||
|
fireEvent.click(getByRole('button'));
|
||||||
|
const tip = getByRole('tooltip').textContent || '';
|
||||||
|
expect(tip).toContain('In der App angelegt');
|
||||||
|
expect(tip).toContain('zuletzt geändert per MCP (Claude Code)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes both created and changed', () => {
|
||||||
|
const text = originDetails({ createdVia: 'api', createdKey: 'HA', changedVia: 'mcp', changedKey: 'Claude Code' }, '2026-11-01T08:12:00Z');
|
||||||
|
expect(text).toMatch(/^Angelegt per API \(HA\) am .* · zuletzt geändert per MCP \(Claude Code\)$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Brain,
|
Brain,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -43,13 +43,71 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
const [createSampleData, setCreateSampleData] = useState(false);
|
const [createSampleData, setCreateSampleData] = useState(false);
|
||||||
|
|
||||||
// --- Login / Invite / Register State ---
|
// --- Login / Invite / Register State ---
|
||||||
|
// An invite link (?invite=CODE) opens the invite form with the code filled in
|
||||||
|
const [linkInviteCode] = useState(() => new URLSearchParams(window.location.search).get('invite')?.trim().toUpperCase() || '');
|
||||||
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register' | 'forgot' | 'reset'>(
|
const [authTab, setAuthTab] = useState<'login' | 'invite' | 'register' | 'forgot' | 'reset'>(
|
||||||
resetToken ? 'reset' : 'login'
|
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);
|
||||||
@@ -167,6 +225,13 @@ export const SetupAndAuthView: React.FC<SetupAndAuthViewProps> = ({
|
|||||||
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');
|
||||||
@@ -481,7 +546,17 @@ 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 && (
|
{notice && !error && (
|
||||||
@@ -687,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>
|
||||||
|
|||||||
@@ -80,9 +80,10 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Own Whisper server (text after release) or the browser's recognition (text while speaking)
|
// Own Whisper server (text in chunks while speaking) or the browser's recognition (text while speaking)
|
||||||
const voice = useVoiceInput({
|
const voice = useVoiceInput({
|
||||||
continuous: true,
|
continuous: true,
|
||||||
|
chunked: true,
|
||||||
onText: (text, final) => {
|
onText: (text, final) => {
|
||||||
transcriptRef.current = text;
|
transcriptRef.current = text;
|
||||||
if (!final) {
|
if (!final) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState, useMemo } from 'react';
|
|||||||
import {
|
import {
|
||||||
Mic,
|
Mic,
|
||||||
MicOff,
|
MicOff,
|
||||||
ArrowUp,
|
ArrowDown,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Calendar,
|
Calendar,
|
||||||
Check,
|
Check,
|
||||||
@@ -15,8 +15,9 @@ import {
|
|||||||
Flame,
|
Flame,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { PriorityLevel, SharedList, Thought } from '../../lib/types';
|
import { PriorityLevel, SharedList, TaskList, Thought } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
|
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||||||
import { useVoiceInput } from '../../lib/useVoiceInput';
|
import { useVoiceInput } from '../../lib/useVoiceInput';
|
||||||
import { VoiceWave } from '../../components/VoiceWave';
|
import { VoiceWave } from '../../components/VoiceWave';
|
||||||
|
|
||||||
@@ -47,12 +48,20 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
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>('');
|
// Last chosen list per target, for this tab (lib/viewSession)
|
||||||
|
const remembered = useMemo(() => readViewSession<{ listId: string; taskListId: string }>('capture'), []);
|
||||||
|
const [targetListId, setTargetListId] = useState<string>(remembered.listId || '');
|
||||||
|
const [targetTaskListId, setTargetTaskListId] = useState<string>(remembered.taskListId || '');
|
||||||
|
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
||||||
|
const writableTaskLists = useMemo(() => taskLists.filter((l) => l.permission !== 'viewer'), [taskLists]);
|
||||||
const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker);
|
const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker);
|
||||||
const [voiceError, setVoiceError] = useState<string | null>(null);
|
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);
|
||||||
|
// Text length at which the bar became multiline: there the buttons move below the text and the field
|
||||||
|
// gets wider, so it only goes back to one line once the text is clearly shorter (no flip-flopping)
|
||||||
|
const multilineAtRef = useRef(0);
|
||||||
|
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const containerRef = useRef<HTMLFormElement>(null);
|
const containerRef = useRef<HTMLFormElement>(null);
|
||||||
@@ -61,6 +70,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
|
|
||||||
// Voice input: own Whisper server or the browser's recognition (lib/voice.ts)
|
// Voice input: own Whisper server or the browser's recognition (lib/voice.ts)
|
||||||
const voice = useVoiceInput({
|
const voice = useVoiceInput({
|
||||||
|
chunked: true,
|
||||||
onText: (text, final) => {
|
onText: (text, final) => {
|
||||||
const base = baseVoiceTextRef.current;
|
const base = baseVoiceTextRef.current;
|
||||||
if (text) setContent(base ? `${base} ${text}` : text);
|
if (text) setContent(base ? `${base} ${text}` : text);
|
||||||
@@ -73,6 +83,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
});
|
});
|
||||||
const isListening = voice.state === 'listening';
|
const isListening = voice.state === 'listening';
|
||||||
const isTranscribing = voice.state === 'transcribing';
|
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;
|
||||||
@@ -81,9 +92,20 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
if (targetEl.value) {
|
if (targetEl.value) {
|
||||||
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);
|
const len = targetEl.value.length;
|
||||||
|
const wraps = targetEl.value.includes('\n') || scrollH > 28;
|
||||||
|
setIsMultiline((was) => {
|
||||||
|
if (!was && wraps) {
|
||||||
|
multilineAtRef.current = len;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (was && !wraps) return len >= multilineAtRef.current - 8;
|
||||||
|
return wraps;
|
||||||
|
});
|
||||||
|
setIsOverMaxHeight(scrollH > 140);
|
||||||
} else {
|
} else {
|
||||||
setIsMultiline(false);
|
setIsMultiline(false);
|
||||||
|
setIsOverMaxHeight(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,6 +123,22 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
}
|
}
|
||||||
}, [writableLists, targetListId]);
|
}, [writableLists, targetListId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
writeViewSession('capture', { listId: targetListId, taskListId: targetTaskListId });
|
||||||
|
}, [targetListId, targetTaskListId]);
|
||||||
|
|
||||||
|
// Task lists only matter once the options are open; loaded each time, so new lists show up
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showOptions || hideTargetPicker) return;
|
||||||
|
api
|
||||||
|
.getTaskLists()
|
||||||
|
.then((tl) => setTaskLists(Array.isArray(tl) ? tl : []))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [showOptions, hideTargetPicker]);
|
||||||
|
|
||||||
|
// A remembered task list that is gone (deleted, access removed) falls back to all tasks
|
||||||
|
const selectedTaskList = writableTaskLists.find((l) => l.id === targetTaskListId);
|
||||||
|
|
||||||
// Close dropdown on outside click
|
// Close dropdown on outside click
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (inlineOptions) return;
|
if (inlineOptions) return;
|
||||||
@@ -148,7 +186,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
|
|
||||||
const executeCapture = async (
|
const executeCapture = async (
|
||||||
overrideTarget?: CaptureTarget,
|
overrideTarget?: CaptureTarget,
|
||||||
overrideListId?: string
|
overrideListId?: string,
|
||||||
|
overrideTaskListId?: string
|
||||||
) => {
|
) => {
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
if (!trimmed || isSubmitting) return;
|
if (!trimmed || isSubmitting) return;
|
||||||
@@ -168,7 +207,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
setIsMultiline(false);
|
setIsMultiline(false);
|
||||||
setPriority('normal');
|
setPriority('normal');
|
||||||
setDueDate('');
|
setDueDate('');
|
||||||
setTarget(fixedTarget || 'inbox');
|
// Target and list stay for the next dump; only priority and due date belong to one entry
|
||||||
setShowOptions(defaultShowOptions || hideTargetPicker);
|
setShowOptions(defaultShowOptions || hideTargetPicker);
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
@@ -197,6 +236,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
{
|
{
|
||||||
target: effectiveTarget,
|
target: effectiveTarget,
|
||||||
targetListId: effectiveTarget === 'list_item' ? effectiveListId : '',
|
targetListId: effectiveTarget === 'list_item' ? effectiveListId : '',
|
||||||
|
taskListId:
|
||||||
|
effectiveTarget === 'task' ? (overrideTaskListId ?? (selectedTaskList ? selectedTaskList.id : '')) : '',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
onCaptured(created, effectiveTarget);
|
onCaptured(created, effectiveTarget);
|
||||||
@@ -216,8 +257,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[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 selectedTaskList ? (selectedTaskList.icon ? `${selectedTaskList.icon} ${selectedTaskList.title}` : selectedTaskList.title) : 'Direkt-Aufgabe';
|
||||||
if (target === 'list_item') return selectedList ? `${selectedList.icon} ${selectedList.title}` : 'Auf Liste';
|
if (target === 'list_item') return selectedList ? [selectedList.icon, selectedList.title].filter(Boolean).join(' ') : 'Auf Liste';
|
||||||
if (target === 'document') return 'Direkt-Notiz';
|
if (target === 'document') return 'Direkt-Notiz';
|
||||||
return 'Quick Dump';
|
return 'Quick Dump';
|
||||||
};
|
};
|
||||||
@@ -228,7 +269,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
{/* Quick-Capture Input Bar (Radius controlled by --radius-pill / multiline) */}
|
{/* Quick-Capture Input Bar (Radius controlled by --radius-pill / multiline) */}
|
||||||
<div
|
<div
|
||||||
className={`flex ${
|
className={`flex ${
|
||||||
isMultiline ? 'items-end py-1.5 min-h-[36px]' : 'items-center h-9'
|
isMultiline ? 'flex-wrap items-start py-1.5 min-h-[36px]' : 'items-center h-9'
|
||||||
} pl-1.5 pr-1.5 bg-panel border capture-pill ${
|
} pl-1.5 pr-1.5 bg-panel border capture-pill ${
|
||||||
isMultiline ? 'capture-bar-multiline' : 'rounded-full'
|
isMultiline ? 'capture-bar-multiline' : 'rounded-full'
|
||||||
} transition-all duration-150 ${
|
} transition-all duration-150 ${
|
||||||
@@ -267,23 +308,26 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
: isTranscribing
|
: isTranscribing
|
||||||
? 'Wird erkannt…'
|
? '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={`flex-1 min-w-0 w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 ${
|
||||||
|
isOverMaxHeight ? 'overflow-y-auto' : 'overflow-hidden'
|
||||||
|
}`}
|
||||||
style={{ minHeight: '22px', maxHeight: '140px' }}
|
style={{ minHeight: '22px', maxHeight: '140px' }}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-1 ml-2 flex-shrink-0 ${
|
className={`flex items-center gap-1 flex-shrink-0 ${
|
||||||
isMultiline ? 'self-end mb-0.5' : ''
|
// Multiline: the buttons get their own row under the text, so the text keeps the full width
|
||||||
|
isMultiline ? 'basis-full justify-end mt-1 animate-in fade-in slide-in-from-top-1 duration-150' : 'ml-2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Target & Options Toggle Pill (Hidden when target picker is disabled) */}
|
{/* Target & Options Toggle Pill (Hidden when target picker is disabled) */}
|
||||||
@@ -360,7 +404,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'
|
||||||
@@ -393,7 +437,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted">
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted">
|
||||||
Wohin damit? {content.trim() ? '(Klick speichert sofort oder wählt Ziel)' : '(Ziel für Eingabe wählen)'}
|
Wohin damit?
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-faint">
|
<span className="text-[11px] text-faint">
|
||||||
Standard: Quick Dump für späteres Swipen
|
Standard: Quick Dump für späteres Swipen
|
||||||
@@ -406,9 +450,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTarget('inbox');
|
setTarget('inbox');
|
||||||
if (content.trim()) {
|
|
||||||
executeCapture('inbox');
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||||
target === 'inbox'
|
target === 'inbox'
|
||||||
@@ -425,9 +466,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTarget('task');
|
setTarget('task');
|
||||||
if (content.trim()) {
|
|
||||||
executeCapture('task');
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||||
target === 'task'
|
target === 'task'
|
||||||
@@ -436,7 +474,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<CheckSquare className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
<CheckSquare className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||||
<span className="truncate">Direkt als Aufgabe</span>
|
<span className="truncate">Direkt als Aufgabe{writableTaskLists.length > 0 ? '…' : ''}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Option 3: Direct List Item */}
|
{/* Option 3: Direct List Item */}
|
||||||
@@ -463,9 +501,6 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTarget('document');
|
setTarget('document');
|
||||||
if (content.trim()) {
|
|
||||||
executeCapture('document');
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||||
target === 'document'
|
target === 'document'
|
||||||
@@ -479,15 +514,45 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optional Sub-Row: Shared List Picker */}
|
{/* Sub-row for a direct task: which task list */}
|
||||||
{writableLists.length > 0 && (
|
{target === 'task' && writableTaskLists.length > 0 && (
|
||||||
|
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-muted mr-1 flex items-center gap-1">
|
||||||
|
<CheckSquare className="w-3 h-3 text-accent" />
|
||||||
|
Aufgabenliste:
|
||||||
|
</span>
|
||||||
|
{[{ id: '', icon: '📥', title: 'Alle Aufgaben' }, ...writableTaskLists].map((tl) => {
|
||||||
|
const isSelected = (selectedTaskList?.id || '') === tl.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tl.id || 'all'}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setTargetTaskListId(tl.id);
|
||||||
|
}}
|
||||||
|
className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-accent/10 text-main border-accent font-medium'
|
||||||
|
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tl.icon && <span>{tl.icon}</span>}
|
||||||
|
<span>{tl.title}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sub-row for a list item: which list (shopping, checklist …) */}
|
||||||
|
{target === 'list_item' && writableLists.length > 0 && (
|
||||||
<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>
|
||||||
{writableLists.map((lst) => {
|
{writableLists.map((lst) => {
|
||||||
const isSelected = target === 'list_item' && targetListId === lst.id;
|
const isSelected = targetListId === lst.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={lst.id}
|
key={lst.id}
|
||||||
@@ -495,13 +560,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setTarget('list_item');
|
setTarget('list_item');
|
||||||
setTargetListId(lst.id);
|
setTargetListId(lst.id);
|
||||||
if (content.trim()) {
|
|
||||||
executeCapture('list_item', lst.id);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
|
className={`min-h-[30px] px-2.5 border rounded-btn text-xs flex items-center gap-1.5 transition-colors cursor-pointer ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-accent text-on-accent border-accent font-medium'
|
? 'bg-accent/10 text-main border-accent font-medium'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel-hover'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||||
|
|
||||||
|
const captureThought = vi.fn(async (content: string) => ({ id: 't1', content }));
|
||||||
|
vi.mock('../../../lib/api', () => ({
|
||||||
|
api: {
|
||||||
|
getTaskLists: vi.fn(async () => [
|
||||||
|
{ id: 'tkl_1', title: 'Homelab', icon: '🖥️', permission: 'owner' },
|
||||||
|
{ id: 'tkl_2', title: 'Fremd', icon: '🔒', permission: 'viewer' },
|
||||||
|
]),
|
||||||
|
captureThought: (...args: unknown[]) => captureThought(...(args as [string])),
|
||||||
|
getAuthStatus: vi.fn(async () => ({ voice: { mode: 'off' } })),
|
||||||
|
},
|
||||||
|
triggerHaptic: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { InstantCaptureBar } from '../InstantCaptureBar';
|
||||||
|
|
||||||
|
const lists = [{ id: 'lst_1', title: 'Wocheneinkauf', icon: '🛒', isOwner: true, listType: 'shopping', items: [] }] as any;
|
||||||
|
|
||||||
|
describe('InstantCaptureBar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
captureThought.mockClear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the list row that fits the target and saves into the chosen task list on send', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
render(<InstantCaptureBar onCaptured={() => {}} lists={lists} defaultShowOptions />);
|
||||||
|
});
|
||||||
|
// Quick Dump: no list row
|
||||||
|
expect(screen.queryByText('Wocheneinkauf')).toBeNull();
|
||||||
|
expect(screen.queryByText('Homelab')).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Backup prüfen' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Direkt als Aufgabe/ }));
|
||||||
|
// With task lists, choosing the target does not save yet; read-only lists are not offered
|
||||||
|
expect(captureThought).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText('Wocheneinkauf')).toBeNull();
|
||||||
|
expect(screen.queryByText('Fremd')).toBeNull();
|
||||||
|
// Picking the list only selects it as well; saving takes the send button (or Enter)
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Homelab/ }));
|
||||||
|
expect(captureThought).not.toHaveBeenCalled();
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.submit(screen.getByRole('textbox').closest('form')!);
|
||||||
|
});
|
||||||
|
expect(captureThought).toHaveBeenCalledWith('Backup prüfen', 'normal', 'manual', '', {
|
||||||
|
target: 'task',
|
||||||
|
targetListId: '',
|
||||||
|
taskListId: 'tkl_1',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the shopping lists for a list item', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
render(<InstantCaptureBar onCaptured={() => {}} lists={lists} defaultShowOptions />);
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Direkt auf Liste/ }));
|
||||||
|
// The list chip (the target pill shows the chosen list as well)
|
||||||
|
expect(screen.getAllByRole('button', { name: /Wocheneinkauf/ }).length).toBe(2);
|
||||||
|
expect(screen.queryByText('Homelab')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -264,7 +264,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
<strong className="text-main">Rückverweise:</strong> Am Ende jeder Notiz siehst du unter „Verlinkt auf diese Notiz“, welche anderen Notizen sie mit <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">[[Titel]]</code> verlinken. Ein Klick auf die Fundstelle springt direkt dorthin.
|
<strong className="text-main">Rückverweise:</strong> Am Ende jeder Notiz siehst du unter „Verlinkt auf diese Notiz“, welche anderen Notizen sie mit <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">[[Titel]]</code> verlinken. Ein Klick auf die Fundstelle springt direkt dorthin.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-main">Papierkorb:</strong> Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war.
|
<strong className="text-main">Papierkorb:</strong> Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war. Nach der Frist aus Kontoeinstellungen → Aufbewahrung (Standard 30 Tage) wird sie endgültig gelöscht. Gelöschte Aufgaben liegen im Reiter „Papierkorb“ der Aufgaben, verworfene Gedanken im Papierkorb der Triage.
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -492,8 +492,8 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
{
|
{
|
||||||
id: 'triage-webhooks',
|
id: 'triage-webhooks',
|
||||||
category: 'triage',
|
category: 'triage',
|
||||||
title: 'Externe Erfassung per Webhooks & API',
|
title: 'API: curl, Smart Home & KI-Agenten (MCP)',
|
||||||
subtitle: 'Gedanken von Siri, Home Assistant, n8n oder curl einspeisen',
|
subtitle: 'Aufgaben und Listen per curl oder Home Assistant lesen und ändern, Gedanken einliefern, KI-Agenten wie Claude Code anbinden.',
|
||||||
icon: Bell,
|
icon: Bell,
|
||||||
keywords: [
|
keywords: [
|
||||||
'webhooks',
|
'webhooks',
|
||||||
@@ -504,17 +504,28 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
'shortcuts',
|
'shortcuts',
|
||||||
'automation',
|
'automation',
|
||||||
'curl',
|
'curl',
|
||||||
|
'mcp',
|
||||||
|
'ki',
|
||||||
|
'agent',
|
||||||
|
'claude',
|
||||||
|
'codex',
|
||||||
],
|
],
|
||||||
content: (
|
content: (
|
||||||
<div className="space-y-2 text-muted leading-relaxed">
|
<div className="space-y-2 text-muted leading-relaxed">
|
||||||
<p>
|
<p>
|
||||||
Unter <em className="text-main">Kontoeinstellungen → Private Webhooks & Export</em> kannst du persönliche API-Schlüssel erzeugen. Pro Schlüssel legst du fest, was er einliefern darf: Gedanken in die Inbox, Aufgaben direkt oder Einträge in Listen. Lesen kann ein Schlüssel nie.
|
MindSift hat eine API. Unter <em className="text-main">Kontoeinstellungen → API</em> erzeugst du persönliche Schlüssel und legst mit Schaltern fest, was jeder darf: Aufgaben lesen, anlegen oder ändern, Listen lesen, ergänzen oder ändern und Gedanken in die Inbox liefern. Diese Rechte gelten für beide Zugänge gleich. Notizen und Einstellungen erreicht ein Schlüssel nie.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Mit dem Feld <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">"target"</code> wählst du das Ziel: <code className="font-mono">triage</code> (Standard), <code className="font-mono">task</code> (optional mit <code className="font-mono">taskListId</code>) oder <code className="font-mono">list_item</code> (mit <code className="font-mono">listId</code>). Der Simulator in den Einstellungen zeigt passende Beispiele.
|
<strong className="text-main">curl und Smart Home:</strong> Mit Leserecht liefert z. B. <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">GET /api/v1/tasks?due=today&status=open</code> alles, was heute ansteht, etwa für einen Home-Assistant-Sensor. Weitere Filter: <code className="font-mono">due=overdue</code>, ein Datum, <code className="font-mono">taskListId</code>. Listen kommen über <code className="font-mono">GET /api/v1/lists</code>. Mit Änderungsrecht hakst du per <code className="font-mono">PATCH /api/v1/thoughts/ID</code> ab. Die Einstellungen zeigen fertige Beispiele.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Über den Endpunkt <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/webhooks/ingest</code> kannst du Gedanken aus iOS-Kurzbefehlen, Smart-Home-Systemen oder Drittanwendungen einspeisen. Sie werden automatisch mit dem Tag <code className="text-accent font-mono">#webhook</code> markiert.
|
<strong className="text-main">Einliefern:</strong> <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/webhooks/ingest</code> nimmt Gedanken aus iOS-Kurzbefehlen, Home Assistant oder n8n an. Mit dem Feld <code className="font-mono">"target"</code> wählst du das Ziel: <code className="font-mono">triage</code> (Standard), <code className="font-mono">task</code> (optional mit <code className="font-mono">taskListId</code>) oder <code className="font-mono">list_item</code> (mit <code className="font-mono">listId</code>).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong className="text-main">KI-Agenten:</strong> Über MCP (<code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">POST /api/v1/mcp</code>) lesen, anlegen, ändern, abhaken und löschen Claude Code, OpenAI Codex, Claude Desktop, Cursor und eigene Agenten deine Aufgaben und Listen. Die Einstellungen zeigen den fertigen Befehl für jeden Agenten; ein Agent sieht nur die Werkzeuge, die der Schlüssel erlaubt.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong className="text-main">Herkunft:</strong> Was ein Schlüssel anlegt oder ändert, trägt dauerhaft eine Badge mit seinem Namen, vorne ein Webhook-Symbol (API) oder ein Roboter (MCP). Fährst du darüber oder tippst darauf, siehst du, wer wann angelegt und zuletzt geändert hat.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -656,7 +667,7 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
<strong className="text-main">Eigenes Theme:</strong> Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">.css</code>-Datei hochladen oder einfügen und „Speichern & aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
|
<strong className="text-main">Eigenes Theme:</strong> Vorlage herunterladen oder einfügen, Farben und Rundungen anpassen, als <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">.css</code>-Datei hochladen oder einfügen und „Speichern & aktivieren“. Das Theme greift sofort und steht dir auf allen Geräten zur Verfügung.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-main">Server-Backups:</strong> Hat die Administration automatische Backups eingeschaltet, findest du unter <em>Kontoeinstellungen → Private Webhooks & Export</em> die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben.
|
<strong className="text-main">Server-Backups:</strong> Hat die Administration automatische Backups eingeschaltet, findest du unter <em>Kontoeinstellungen → Export & Import</em> die täglichen Sicherungen deiner Daten. Du kannst sie herunterladen oder wieder einspielen, auch nur für einzelne Bereiche wie Listen oder Aufgaben.
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<strong className="text-main">Ohne Internet:</strong> Als App installiert (oder im Browser) startet MindSift auch offline mit dem zuletzt geladenen Stand. Gedanken erfassen sowie Aufgaben und Listeneinträge abhaken geht weiter; ein Hinweis unten zeigt, wie viele Änderungen warten, und sie werden automatisch übertragen, sobald die Verbindung zurück ist.
|
<strong className="text-main">Ohne Internet:</strong> Als App installiert (oder im Browser) startet MindSift auch offline mit dem zuletzt geladenen Stand. Gedanken erfassen sowie Aufgaben und Listeneinträge abhaken geht weiter; ein Hinweis unten zeigt, wie viele Änderungen warten, und sie werden automatisch übertragen, sobald die Verbindung zurück ist.
|
||||||
@@ -750,6 +761,8 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
|||||||
{ key: '↑ / ↓', scope: 'Workspace Outliner', desc: 'Zum vorherigen / nächsten Block navigieren' },
|
{ key: '↑ / ↓', scope: 'Workspace Outliner', desc: 'Zum vorherigen / nächsten Block navigieren' },
|
||||||
{ key: 'Strg + Z / Cmd + Z', scope: 'Workspace Outliner', desc: 'Letzte Notiz-Aktion rückgängig machen' },
|
{ key: 'Strg + Z / Cmd + Z', scope: 'Workspace Outliner', desc: 'Letzte Notiz-Aktion rückgängig machen' },
|
||||||
{ key: 'Strg + Y / Strg + Shift + Z', scope: 'Workspace Outliner', desc: 'Rückgängig gemachte Aktion wiederholen' },
|
{ key: 'Strg + Y / Strg + Shift + Z', scope: 'Workspace Outliner', desc: 'Rückgängig gemachte Aktion wiederholen' },
|
||||||
|
{ key: 'Maus über mehrere Punkte ziehen', scope: 'Workspace Outliner', desc: 'Text über mehrere Punkte markieren; Entf/Backspace löscht ihn, Tippen ersetzt ihn, Strg + C kopiert' },
|
||||||
|
{ key: 'Strg + Z / Cmd + Z', scope: 'Aufgaben', desc: 'Löschen, Verschieben oder Abhaken rückgängig machen' },
|
||||||
{ key: '/', scope: 'Workspace Outliner', desc: 'Slash-Menü öffnen (/todo, /link, /h1, /code, ...)' },
|
{ key: '/', scope: 'Workspace Outliner', desc: 'Slash-Menü öffnen (/todo, /link, /h1, /code, ...)' },
|
||||||
{ key: '[] oder [ ] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in Todo-Checkbox umwandeln' },
|
{ key: '[] oder [ ] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in Todo-Checkbox umwandeln' },
|
||||||
{ key: '[x] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in erledigte Todo-Checkbox umwandeln' },
|
{ key: '[x] am Anfang', scope: 'Workspace Outliner', desc: 'Sofort in erledigte Todo-Checkbox umwandeln' },
|
||||||
|
|||||||
@@ -4,17 +4,22 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
Check,
|
Check,
|
||||||
Radio,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
X,
|
X,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
|
CloudOff,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
|
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
|
||||||
import { api, 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 { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
|
import { OriginBadge } from '../../components/OriginBadge';
|
||||||
|
import { VoiceInputButton } from '../../components/VoiceInputButton';
|
||||||
import { FormSheet } from '../../components/FormSheet';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
|
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
|
|
||||||
interface SharedListsViewProps {
|
interface SharedListsViewProps {
|
||||||
@@ -83,7 +88,13 @@ export function deduplicateItems(items: ListItem[]): ListItem[] {
|
|||||||
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
|
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>(initialListId || '');
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (initialListId) {
|
if (initialListId) {
|
||||||
@@ -93,6 +104,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
|
|
||||||
const [newItemText, setNewItemText] = useState('');
|
const [newItemText, setNewItemText] = useState('');
|
||||||
const [newItemQty, setNewItemQty] = useState('');
|
const [newItemQty, setNewItemQty] = useState('');
|
||||||
|
const [itemVoiceError, setItemVoiceError] = useState<string | null>(null);
|
||||||
const [isSubmittingItem, setIsSubmittingItem] = useState(false);
|
const [isSubmittingItem, setIsSubmittingItem] = useState(false);
|
||||||
const [editingQtyItemId, setEditingQtyItemId] = useState<string | null>(null);
|
const [editingQtyItemId, setEditingQtyItemId] = useState<string | null>(null);
|
||||||
const [editingQtyValue, setEditingQtyValue] = useState('');
|
const [editingQtyValue, setEditingQtyValue] = useState('');
|
||||||
@@ -113,9 +125,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);
|
||||||
|
|
||||||
@@ -130,9 +153,14 @@ 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 (initialListId && deduplicated.some((l) => l.id === initialListId)) {
|
if (initialListId && deduplicated.some((l) => l.id === initialListId)) {
|
||||||
@@ -260,6 +288,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;
|
||||||
@@ -281,6 +325,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);
|
||||||
@@ -378,6 +424,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();
|
||||||
@@ -767,6 +842,10 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
}, [openItems]);
|
}, [openItems]);
|
||||||
|
|
||||||
const focusNewItemInput = () => {
|
const focusNewItemInput = () => {
|
||||||
|
if (window.innerWidth < 640) {
|
||||||
|
setShowAddSheet(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
inputRef.current?.focus({ preventScroll: true });
|
inputRef.current?.focus({ preventScroll: true });
|
||||||
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
};
|
};
|
||||||
@@ -849,6 +928,126 @@ 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={itemVoiceError || `Was soll auf „${activeList.title}“? (z. B. Hafermilch #Kühlregal)`}
|
||||||
|
// 16px on phones: iOS zooms into smaller fields and the layout jumps under the keyboard
|
||||||
|
className="w-full h-11 sm:h-9 pl-9 pr-11 sm:pr-9 input-field text-base sm:text-sm"
|
||||||
|
/>
|
||||||
|
<VoiceInputButton
|
||||||
|
value={newItemText}
|
||||||
|
onChange={setNewItemText}
|
||||||
|
focusRef={inputRef}
|
||||||
|
onError={(message) => {
|
||||||
|
setItemVoiceError(message);
|
||||||
|
window.setTimeout(() => setItemVoiceError(null), 4000);
|
||||||
|
}}
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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) */}
|
||||||
@@ -861,9 +1060,21 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={lst.id}
|
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 ${
|
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 ? 'bg-panel border-accent text-main' : 'bg-canvas border-border text-muted hover:text-main'
|
||||||
} ${isSelected && lists.length > 1 ? 'pr-1' : ''}`}
|
} ${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)}
|
||||||
@@ -951,33 +1162,16 @@ 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].filter(Boolean).join(' ') : '🛒 Einkaufsliste'}
|
||||||
</span>
|
</span>
|
||||||
{openItems.length > 0 && (
|
{openItems.length > 0 && (
|
||||||
<span className="badge-neutral text-[11px] flex-shrink-0">
|
<span className="badge-neutral text-[11px] flex-shrink-0">
|
||||||
@@ -996,24 +1190,7 @@ 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>
|
||||||
|
|
||||||
@@ -1027,14 +1204,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
<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">
|
||||||
<input
|
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} />
|
||||||
type="text"
|
|
||||||
value={newListIcon}
|
|
||||||
onChange={(e) => setNewListIcon(e.target.value)}
|
|
||||||
maxLength={2}
|
|
||||||
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
|
|
||||||
title="Icon (Emoji)"
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newListTitle}
|
value={newListTitle}
|
||||||
@@ -1052,92 +1222,29 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
{/* 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 */}
|
||||||
<form
|
{isPhone && showAddSheet && (
|
||||||
onSubmit={handleAddItem}
|
<FormSheet
|
||||||
className="flex flex-col sm:flex-row items-center gap-2 panel-surface p-2 sm:p-1.5 relative"
|
title={`Auf „${activeList.title}“`}
|
||||||
>
|
icon={<ShoppingCart className="w-4 h-4 text-accent" />}
|
||||||
<div className="relative flex items-center gap-2 flex-1 w-full px-1.5">
|
onClose={() => setShowAddSheet(false)}
|
||||||
<Plus className="w-4 h-4 text-accent flex-shrink-0" />
|
onSubmit={handleAddItem}
|
||||||
<input
|
submitLabel="Hinzufügen"
|
||||||
ref={inputRef}
|
busy={isSubmittingItem}
|
||||||
type="text"
|
>
|
||||||
value={newItemText}
|
<div className="flex flex-col gap-2 relative">{renderAddItemFields(false)}</div>
|
||||||
onChange={(e) => {
|
</FormSheet>
|
||||||
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)…`}
|
|
||||||
// 16px on phones: iOS zooms into smaller fields and the layout jumps under the keyboard
|
|
||||||
className="flex-1 bg-transparent py-1.5 sm:py-1 text-base sm:text-sm text-main placeholder-faint focus:outline-none"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Hashtag Autocomplete Suggestions Dropdown */}
|
{/* Quick-Add Form (desktop; on phones it is the sheet above) */}
|
||||||
{showSuggestions && activeHashtagQuery && matchingSuggestions.length > 0 && (
|
{!isPhone && (
|
||||||
<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">
|
<form
|
||||||
<div className="px-2.5 py-1 text-[10px] uppercase font-semibold text-faint tracking-wider border-b border-border/50">
|
onSubmit={handleAddItem}
|
||||||
Hashtags
|
className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 panel-surface p-2 relative"
|
||||||
</div>
|
>
|
||||||
{matchingSuggestions.map((tag, idx) => {
|
{renderAddItemFields(true)}
|
||||||
const isSelected = idx === suggestionIndex;
|
</form>
|
||||||
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"
|
|
||||||
inputMode="decimal"
|
|
||||||
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-11 sm:h-7 px-2.5 sm:px-2 input-field text-base sm: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>
|
|
||||||
|
|
||||||
{/* Open Items Grouped by Hashtags / Categories */}
|
{/* Open Items Grouped by Hashtags / Categories */}
|
||||||
{openItems.length === 0 ? (
|
{openItems.length === 0 ? (
|
||||||
@@ -1195,6 +1302,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)}
|
||||||
@@ -1235,6 +1343,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()}>
|
||||||
@@ -1284,6 +1397,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<OriginBadge origin={item.origin} createdAt={item.createdAt} size="xs" />
|
||||||
{renderItemAuthor(item)}
|
{renderItemAuthor(item)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1393,6 +1507,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<OriginBadge origin={item.origin} createdAt={item.createdAt} size="xs" />
|
||||||
{renderItemAuthor(item)}
|
{renderItemAuthor(item)}
|
||||||
{item.completedByName && (
|
{item.completedByName && (
|
||||||
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
<span className="text-xs text-faint">✓ {item.completedByName}</span>
|
||||||
@@ -1425,7 +1540,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
onClose={() => setShowShareModal(false)}
|
onClose={() => setShowShareModal(false)}
|
||||||
title={`Liste teilen: ${activeList.title}`}
|
title={`Liste teilen: ${activeList.title}`}
|
||||||
resourceName={activeList.title}
|
resourceName={activeList.title}
|
||||||
resourceIcon={activeList.icon || '🛒'}
|
resourceIcon={activeList.icon || undefined}
|
||||||
isOwner={activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin')}
|
isOwner={activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin')}
|
||||||
currentUserId={currentUser?.id}
|
currentUserId={currentUser?.id}
|
||||||
shares={(activeList.shares || []).map((s) => ({
|
shares={(activeList.shares || []).map((s) => ({
|
||||||
|
|||||||
@@ -15,9 +15,13 @@ import {
|
|||||||
UserPlus,
|
UserPlus,
|
||||||
Building2,
|
Building2,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Link2,
|
||||||
|
Clock,
|
||||||
|
X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Organization,
|
Organization,
|
||||||
|
RegistrationMode,
|
||||||
SharingMode,
|
SharingMode,
|
||||||
SystemSettings,
|
SystemSettings,
|
||||||
User,
|
User,
|
||||||
@@ -78,6 +82,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
const [inviteEmail, setInviteEmail] = useState('');
|
const [inviteEmail, setInviteEmail] = useState('');
|
||||||
const [inviteRole, setInviteRole] = useState<UserRole>('member');
|
const [inviteRole, setInviteRole] = useState<UserRole>('member');
|
||||||
const [inviteOrg, setInviteOrg] = useState('');
|
const [inviteOrg, setInviteOrg] = useState('');
|
||||||
|
// A link for several people (1 = classic single-use code)
|
||||||
|
const [inviteMaxUses, setInviteMaxUses] = useState(1);
|
||||||
|
// "Per E-Mail einladen": which invite the address field is open for
|
||||||
|
const [mailInviteId, setMailInviteId] = useState<string | null>(null);
|
||||||
|
const [mailInviteTo, setMailInviteTo] = useState('');
|
||||||
|
const [mailInviteBusy, setMailInviteBusy] = useState(false);
|
||||||
|
|
||||||
// --- Organizations State ---
|
// --- Organizations State ---
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
@@ -128,6 +138,12 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
setSettings({
|
setSettings({
|
||||||
sharingMode: sys.sharingMode || 'organizations',
|
sharingMode: sys.sharingMode || 'organizations',
|
||||||
allowRegistration: Boolean(sys.allowRegistration),
|
allowRegistration: Boolean(sys.allowRegistration),
|
||||||
|
registrationMode: sys.registrationMode || (sys.allowRegistration ? 'open' : 'closed'),
|
||||||
|
registrationDomains: sys.registrationDomains || '',
|
||||||
|
registrationConfiguredViaEnv: Boolean(sys.registrationConfiguredViaEnv),
|
||||||
|
registrationVerifyEmail: Boolean(sys.registrationVerifyEmail),
|
||||||
|
registrationVerifyConfiguredViaEnv: Boolean(sys.registrationVerifyConfiguredViaEnv),
|
||||||
|
emailLinksAvailable: Boolean(sys.emailLinksAvailable),
|
||||||
instanceName: sys.instanceName || 'MindSift',
|
instanceName: sys.instanceName || 'MindSift',
|
||||||
smtpHost: sys.smtpHost || '',
|
smtpHost: sys.smtpHost || '',
|
||||||
smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587',
|
smtpPort: sys.smtpPort ? String(sys.smtpPort) : '587',
|
||||||
@@ -142,6 +158,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
backupEnabled: Boolean(sys.backupEnabled),
|
backupEnabled: Boolean(sys.backupEnabled),
|
||||||
backupTime: sys.backupTime || '03:00',
|
backupTime: sys.backupTime || '03:00',
|
||||||
backupRetentionDays: sys.backupRetentionDays || 3,
|
backupRetentionDays: sys.backupRetentionDays || 3,
|
||||||
|
retentionAllowNever: sys.retentionAllowNever ?? true,
|
||||||
|
retentionNeverViaEnv: Boolean(sys.retentionNeverViaEnv),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -197,15 +215,19 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
try {
|
try {
|
||||||
const inv = await api.adminCreateInvite({
|
const inv = await api.adminCreateInvite({
|
||||||
email: inviteEmail.trim(),
|
email: inviteMaxUses > 1 ? '' : inviteEmail.trim(),
|
||||||
role: inviteRole,
|
role: inviteRole,
|
||||||
organizationId: inviteOrg || undefined,
|
organizationId: inviteOrg || undefined,
|
||||||
|
maxUses: inviteMaxUses,
|
||||||
});
|
});
|
||||||
setInvites((prev) => [inv, ...prev]);
|
setInvites((prev) => [inv, ...prev]);
|
||||||
setInviteEmail('');
|
setInviteEmail('');
|
||||||
setStatusMsg({
|
setStatusMsg({
|
||||||
type: 'ok',
|
type: 'ok',
|
||||||
text: `Einladungscode ${inv.code} erfolgreich erstellt.`,
|
text:
|
||||||
|
inv.maxUses > 1
|
||||||
|
? `Einladungslink für ${inv.maxUses} Personen erstellt (${inv.code}).`
|
||||||
|
: `Einladungscode ${inv.code} erfolgreich erstellt.`,
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Einladung' });
|
setStatusMsg({ type: 'err', text: err.message || 'Fehler beim Erstellen der Einladung' });
|
||||||
@@ -221,12 +243,45 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyInviteCode = (inv: UserInvite) => {
|
// Copies the code or the link that opens the sign-up form with the code filled in
|
||||||
navigator.clipboard.writeText(inv.code);
|
const inviteLink = (inv: UserInvite) => `${window.location.origin}/?invite=${encodeURIComponent(inv.code)}`;
|
||||||
|
const [copiedWhat, setCopiedWhat] = useState<'code' | 'link'>('code');
|
||||||
|
const handleCopyInvite = (inv: UserInvite, what: 'code' | 'link') => {
|
||||||
|
navigator.clipboard.writeText(what === 'link' ? inviteLink(inv) : inv.code);
|
||||||
|
setCopiedWhat(what);
|
||||||
setCopiedInviteId(inv.id);
|
setCopiedInviteId(inv.id);
|
||||||
setTimeout(() => setCopiedInviteId(null), 2000);
|
setTimeout(() => setCopiedInviteId(null), 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSendInvite = async (inv: UserInvite) => {
|
||||||
|
if (!mailInviteTo.trim()) return;
|
||||||
|
setMailInviteBusy(true);
|
||||||
|
setStatusMsg(null);
|
||||||
|
try {
|
||||||
|
const updated = await api.adminSendInvite(inv.id, mailInviteTo.trim());
|
||||||
|
setInvites((prev) => prev.map((i) => (i.id === inv.id ? { ...i, ...updated } : i)));
|
||||||
|
setStatusMsg({ type: 'ok', text: `Einladung an ${mailInviteTo.trim()} verschickt.` });
|
||||||
|
setMailInviteId(null);
|
||||||
|
setMailInviteTo('');
|
||||||
|
} catch (err: any) {
|
||||||
|
setStatusMsg({ type: 'err', text: err.message || 'Einladung konnte nicht verschickt werden' });
|
||||||
|
} finally {
|
||||||
|
setMailInviteBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Registrations waiting for approval (registration mode "Mit Freigabe")
|
||||||
|
const handleApproveUser = async (u: User) => {
|
||||||
|
setStatusMsg(null);
|
||||||
|
try {
|
||||||
|
const updated = await api.adminApproveUser(u.id);
|
||||||
|
setUsers((prev) => prev.map((item) => (item.id === u.id ? { ...updated, pendingApproval: false } : item)));
|
||||||
|
setStatusMsg({ type: 'ok', text: `${u.displayName} ist freigeschaltet.` });
|
||||||
|
} catch (err: any) {
|
||||||
|
setStatusMsg({ type: 'err', text: err.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleChangeUserRole = async (u: User, role: UserRole) => {
|
const handleChangeUserRole = async (u: User, role: UserRole) => {
|
||||||
setStatusMsg(null);
|
setStatusMsg(null);
|
||||||
try {
|
try {
|
||||||
@@ -273,7 +328,9 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
const handleDeleteUser = async (u: User) => {
|
const handleDeleteUser = async (u: User) => {
|
||||||
if (
|
if (
|
||||||
!window.confirm(
|
!window.confirm(
|
||||||
`Benutzer "${u.displayName}" (${u.email}) wirklich unwiderruflich löschen?`
|
u.pendingApproval
|
||||||
|
? `Registrierung von "${u.displayName}" (${u.email}) ablehnen? Das Konto wird gelöscht.`
|
||||||
|
: `Benutzer "${u.displayName}" (${u.email}) wirklich unwiderruflich löschen?`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
@@ -642,26 +699,45 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||||
<h3 className="font-bold text-main flex items-center gap-1.5">
|
<h3 className="font-bold text-main flex items-center gap-1.5">
|
||||||
<KeyRound className="w-3.5 h-3.5 text-accent" />
|
<KeyRound className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Einladungscode erzeugen (gültig für 7 Tage)</span>
|
<span>Einladung erzeugen (gültig für 7 Tage)</span>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form onSubmit={handleCreateInvite} className="space-y-3">
|
<form onSubmit={handleCreateInvite} className="space-y-3">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
{/* E-mail gets the room, "how often" only needs a few digits */}
|
||||||
|
<div
|
||||||
|
className={`grid grid-cols-1 gap-3 ${
|
||||||
|
isServerAdmin ? 'sm:grid-cols-[minmax(0,2fr)_8rem_minmax(0,1fr)_minmax(0,1fr)]' : 'sm:grid-cols-[minmax(0,2fr)_8rem_minmax(0,1fr)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<label className="block text-muted mb-1">
|
<label className="block text-muted mb-1">
|
||||||
Gebundene E-Mail (optional)
|
Gebundene E-Mail (optional)
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={inviteEmail}
|
value={inviteMaxUses > 1 ? '' : inviteEmail}
|
||||||
onChange={(e) => setInviteEmail(e.target.value)}
|
onChange={(e) => setInviteEmail(e.target.value)}
|
||||||
placeholder="Leer lassen für beliebige E-Mail"
|
disabled={inviteMaxUses > 1}
|
||||||
|
placeholder={inviteMaxUses > 1 ? 'Nur bei einmaliger Einladung' : 'Leer lassen für beliebige E-Mail'}
|
||||||
|
className="w-full min-w-0 h-8 px-2.5 input-field disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0">
|
||||||
|
<label className="block text-muted mb-1 whitespace-nowrap">Wie oft verwendbar</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={1000}
|
||||||
|
value={inviteMaxUses}
|
||||||
|
onChange={(e) => setInviteMaxUses(Math.max(1, Math.min(1000, parseInt(e.target.value, 10) || 1)))}
|
||||||
|
title="1 = ein Code für eine Person; mehr = ein Link für eine ganze Gruppe"
|
||||||
className="w-full min-w-0 h-8 px-2.5 input-field"
|
className="w-full min-w-0 h-8 px-2.5 input-field"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<label className="block text-muted mb-1">Zugewiesene Server-Rolle</label>
|
<label className="block text-muted mb-1">Rolle</label>
|
||||||
<select
|
<select
|
||||||
value={inviteRole}
|
value={inviteRole}
|
||||||
onChange={(e) => setInviteRole(e.target.value as UserRole)}
|
onChange={(e) => setInviteRole(e.target.value as UserRole)}
|
||||||
@@ -697,7 +773,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
className="btn-action h-8 px-4 text-xs font-semibold flex items-center gap-1.5 border-accent text-main"
|
className="btn-action h-8 px-4 text-xs font-semibold flex items-center gap-1.5 border-accent text-main"
|
||||||
>
|
>
|
||||||
<KeyRound className="w-3.5 h-3.5 text-accent" />
|
<KeyRound className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Einladungscode generieren</span>
|
<span>{inviteMaxUses > 1 ? `Einladungslink für ${inviteMaxUses} Personen erzeugen` : 'Einladungscode generieren'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -709,63 +785,168 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
Aktive & bisherige Einladungscodes:
|
Aktive & bisherige Einladungscodes:
|
||||||
</p>
|
</p>
|
||||||
<div className="divide-y divide-border border border-border bg-panel rounded-card overflow-hidden">
|
<div className="divide-y divide-border border border-border bg-panel rounded-card overflow-hidden">
|
||||||
{invites.map((inv) => (
|
{invites.map((inv) => {
|
||||||
<div
|
const maxUses = inv.maxUses || 1;
|
||||||
key={inv.id}
|
const used = inv.useCount ?? (inv.usedBy ? 1 : 0);
|
||||||
className="p-2 flex flex-wrap items-center justify-between gap-2"
|
const open = used < maxUses && new Date(inv.expiresAt).getTime() > Date.now();
|
||||||
>
|
const copied = copiedInviteId === inv.id;
|
||||||
<div className="flex items-center gap-2.5">
|
return (
|
||||||
<code className="px-2 py-0.5 bg-canvas border border-border font-mono font-bold text-accent rounded-card">
|
<div key={inv.id} className="p-2 space-y-2">
|
||||||
{inv.code}
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
</code>
|
<div className="flex flex-wrap items-center gap-2.5 min-w-0">
|
||||||
<span className="text-muted">
|
<code className="px-2 py-0.5 bg-canvas border border-border font-mono font-bold text-accent rounded-card">
|
||||||
Rolle: <strong className="text-main">{inv.role}</strong>
|
{inv.code}
|
||||||
{inv.email ? ` · Für: ${inv.email}` : ''}
|
</code>
|
||||||
{inv.organizationName ? ` · ${inv.organizationName}` : ''}
|
<span className="text-muted">
|
||||||
</span>
|
Rolle: <strong className="text-main">{inv.role}</strong>
|
||||||
{inv.usedBy ? (
|
{inv.email ? ` · Für: ${inv.email}` : ''}
|
||||||
<span className="badge-neutral text-[10px]">
|
{inv.organizationName ? ` · ${inv.organizationName}` : ''}
|
||||||
Eingelöst
|
{maxUses > 1 ? ` · ${used} von ${maxUses} verwendet` : ''}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
{open ? (
|
||||||
<span className="badge-ok text-[10px]">
|
<span className="badge-ok text-[10px]">Offen</span>
|
||||||
Offen
|
) : (
|
||||||
</span>
|
<span className="badge-neutral text-[10px]">{used >= maxUses ? 'Eingelöst' : 'Abgelaufen'}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{!inv.usedBy && (
|
{open && (
|
||||||
<button
|
<>
|
||||||
onClick={() => handleCopyInviteCode(inv)}
|
<button
|
||||||
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
type="button"
|
||||||
|
onClick={() => handleCopyInvite(inv, 'link')}
|
||||||
|
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
||||||
|
title={inviteLink(inv)}
|
||||||
|
>
|
||||||
|
<Link2 className="w-3 h-3" />
|
||||||
|
<span>{copied && copiedWhat === 'link' ? 'Kopiert!' : 'Link kopieren'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopyInvite(inv, 'code')}
|
||||||
|
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
<span>{copied && copiedWhat === 'code' ? 'Kopiert!' : 'Code'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setMailInviteId(mailInviteId === inv.id ? null : inv.id);
|
||||||
|
setMailInviteTo(inv.email || '');
|
||||||
|
}}
|
||||||
|
className="btn-action h-7 px-2 text-[11px] flex items-center gap-1"
|
||||||
|
title="Einladung per E-Mail verschicken"
|
||||||
|
>
|
||||||
|
<Mail className="w-3 h-3" />
|
||||||
|
<span>Per E-Mail</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteInvite(inv.id)}
|
||||||
|
className="btn-icon btn-icon-danger"
|
||||||
|
title="Einladung löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{mailInviteId === inv.id && (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleSendInvite(inv);
|
||||||
|
}}
|
||||||
|
className="flex flex-wrap items-center gap-2"
|
||||||
>
|
>
|
||||||
<Copy className="w-3 h-3" />
|
<input
|
||||||
<span>{copiedInviteId === inv.id ? 'Kopiert!' : 'Code kopieren'}</span>
|
type="email"
|
||||||
</button>
|
value={mailInviteTo}
|
||||||
|
onChange={(e) => setMailInviteTo(e.target.value)}
|
||||||
|
readOnly={Boolean(inv.email)}
|
||||||
|
placeholder="name@beispiel.de"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
className="flex-1 min-w-[12rem] h-8 px-2.5 input-field"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={mailInviteBusy}
|
||||||
|
className="btn-primary h-8 px-3 text-xs font-semibold flex items-center gap-1.5 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Send className="w-3.5 h-3.5" />
|
||||||
|
<span>{mailInviteBusy ? 'Sende…' : 'Einladung senden'}</span>
|
||||||
|
</button>
|
||||||
|
{maxUses === 1 && !inv.email && (
|
||||||
|
<span className="w-full text-[11px] text-faint">Die Einladung gilt danach nur noch für diese Adresse.</span>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteInvite(inv.id)}
|
|
||||||
className="btn-icon btn-icon-danger"
|
|
||||||
title="Einladung löschen"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Registrations waiting for approval */}
|
||||||
|
{users.some((u) => u.pendingApproval) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-bold text-main flex items-center gap-1.5">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-status-warn" />
|
||||||
|
Wartet auf Freigabe ({users.filter((u) => u.pendingApproval).length})
|
||||||
|
</h3>
|
||||||
|
<div className="border border-status-warn/40 divide-y divide-border bg-canvas rounded-card overflow-hidden">
|
||||||
|
{users
|
||||||
|
.filter((u) => u.pendingApproval)
|
||||||
|
.map((u) => (
|
||||||
|
<div key={u.id} className="p-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<UserAvatar user={u} size="sm" className="border border-border/80" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-bold text-main truncate flex items-center gap-2">
|
||||||
|
{u.displayName}
|
||||||
|
{u.emailUnverified && <span className="badge-neutral text-[10px] font-normal">E-Mail unbestätigt</span>}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted text-[11px] truncate">
|
||||||
|
{u.email} · registriert am {new Date(u.createdAt).toLocaleDateString('de-DE')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleApproveUser(u)}
|
||||||
|
className="btn-primary h-11 sm:h-8 px-3 text-xs font-semibold flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
Freigeben
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleDeleteUser(u)}
|
||||||
|
className="btn-action h-11 sm:h-8 px-3 text-xs flex items-center gap-1.5 hover:text-status-err"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Users List */}
|
{/* Users List */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="font-bold text-main">
|
<h3 className="font-bold text-main">
|
||||||
Registrierte Benutzer ({users.length})
|
Registrierte Benutzer ({users.filter((u) => !u.pendingApproval).length})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="border border-border divide-y divide-border bg-canvas rounded-card overflow-hidden">
|
<div className="border border-border divide-y divide-border bg-canvas rounded-card overflow-hidden">
|
||||||
{users.map((u) => (
|
{users.filter((u) => !u.pendingApproval).map((u) => (
|
||||||
<div key={u.id} className="p-3 space-y-2">
|
<div key={u.id} className="p-3 space-y-2">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -774,8 +955,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-bold text-main">{u.displayName}</span>
|
<span className="font-bold text-main">{u.displayName}</span>
|
||||||
{!u.isActive && (
|
{!u.isActive && (
|
||||||
<span className="badge-err text-[10px]">
|
<span className={`${u.emailUnverified ? 'badge-neutral' : 'badge-err'} text-[10px]`}>
|
||||||
Gesperrt
|
{u.emailUnverified ? 'E-Mail unbestätigt' : 'Gesperrt'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1136,27 +1317,99 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Public Registration Toggle Switch */}
|
{/* Retention: may people keep finished and deleted things for good? */}
|
||||||
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
||||||
<div className="pr-4">
|
<div className="pr-4">
|
||||||
<p className="font-bold text-main">Öffentliche Selbstregistrierung erlauben</p>
|
<p className="font-bold text-main">Dauerhaftes Aufbewahren erlauben</p>
|
||||||
<p className="text-[11px] text-muted mt-0.5">
|
<p className="text-[11px] text-muted mt-0.5">
|
||||||
Wenn deaktiviert, können sich fremde Personen auf dem Login-Bildschirm nicht selbst registrieren.
|
Eingeschaltet kann jede Person unter Kontoeinstellungen → Aufbewahrung „nie“ wählen, also Erledigtes und
|
||||||
Neue Konten erfordern dann zwingend einen Einladungscode oder müssen von einem Admin angelegt werden.
|
Gelöschtes behalten. Ausgeschaltet gilt dort für „nie“ die Voreinstellung des Bereichs (z. B. 30 Tage).
|
||||||
|
{settings.retentionNeverViaEnv && ' Festgelegt per RETENTION_ALLOW_NEVER.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
<div className="flex items-center gap-3 flex-shrink-0">
|
checked={settings.retentionAllowNever !== false}
|
||||||
<span className={`text-xs font-medium hidden sm:inline ${settings.allowRegistration ? 'text-accent' : 'text-muted'}`}>
|
disabled={Boolean(settings.retentionNeverViaEnv)}
|
||||||
{settings.allowRegistration ? 'Aktiviert (Offen)' : 'Gesperrt (Nur Einladung)'}
|
onChange={(retentionAllowNever) => handleSaveSystemSettings({ retentionAllowNever })}
|
||||||
</span>
|
/>
|
||||||
<ToggleSwitch
|
|
||||||
checked={settings.allowRegistration}
|
|
||||||
onChange={(allowRegistration) => handleSaveSystemSettings({ allowRegistration })}
|
|
||||||
title={settings.allowRegistration ? 'Selbstregistrierung deaktivieren' : 'Selbstregistrierung aktivieren'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Who may register without an invite */}
|
||||||
|
{(() => {
|
||||||
|
const mode = settings.registrationMode || 'closed';
|
||||||
|
const locked = Boolean(settings.registrationConfiguredViaEnv);
|
||||||
|
const needsDomains = mode === 'domain' || mode === 'domain+approval';
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-main">Registrierung ohne Einladung</p>
|
||||||
|
<p className="text-[11px] text-muted mt-0.5">
|
||||||
|
Wer sich auf dem Login-Bildschirm selbst ein Konto anlegen darf. Einladungslinks und -codes funktionieren
|
||||||
|
immer. Wartende Konten gibst du unter „Benutzer & Einladungen“ frei.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={mode}
|
||||||
|
disabled={locked}
|
||||||
|
onChange={(e) => handleSaveSystemSettings({ registrationMode: e.target.value as RegistrationMode })}
|
||||||
|
className="w-full h-11 sm:h-8 px-2.5 input-field text-base sm:text-xs disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<option value="closed">Geschlossen – nur mit Einladung</option>
|
||||||
|
<option value="approval">Mit Freigabe – jeder darf sich registrieren, ein Admin schaltet frei</option>
|
||||||
|
<option value="domain">Nur bestimmte E-Mail-Endungen – sofort aktiv</option>
|
||||||
|
<option value="domain+approval">Endungen sofort, alle anderen nach Freigabe</option>
|
||||||
|
<option value="open">Offen – jeder, sofort aktiv</option>
|
||||||
|
</select>
|
||||||
|
{needsDomains && (
|
||||||
|
<label className="block text-[11px] text-muted">
|
||||||
|
Erlaubte E-Mail-Endungen (durch Komma getrennt, <code className="font-mono">*.firma.de</code> auch für Subdomains)
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
disabled={locked}
|
||||||
|
value={settings.registrationDomains || ''}
|
||||||
|
onChange={(e) => setSettings((prev) => ({ ...prev, registrationDomains: e.target.value }))}
|
||||||
|
onBlur={() => handleSaveSystemSettings()}
|
||||||
|
placeholder="firma.de, familie-dach.de"
|
||||||
|
className="block w-full mt-1 h-11 sm:h-8 px-2.5 input-field font-mono text-base sm:text-xs disabled:opacity-60"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{locked && (
|
||||||
|
<p className="text-[11px] text-status-warn">
|
||||||
|
Über die Umgebung festgelegt (<code className="font-mono">REGISTRATION_MODE</code> /{' '}
|
||||||
|
<code className="font-mono">REGISTRATION_DOMAINS</code>) und hier nicht änderbar.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{mode !== 'closed' && (
|
||||||
|
<div className="pt-3 border-t border-border flex items-start justify-between gap-4">
|
||||||
|
<div className="pr-2">
|
||||||
|
<p className="font-semibold text-main">E-Mail-Adresse bestätigen lassen</p>
|
||||||
|
<p className="text-[11px] text-muted mt-0.5">
|
||||||
|
Neue Konten ohne Einladung bekommen einen Link per E-Mail und sind erst danach aktiv (bei Freigabe
|
||||||
|
landen sie danach bei dir). Unbestätigte Konten werden nach 48 Stunden gelöscht.
|
||||||
|
</p>
|
||||||
|
{!settings.emailLinksAvailable && (
|
||||||
|
<p className="text-[11px] text-status-warn mt-1">
|
||||||
|
Wirkt erst mit eingerichtetem Mail-Server und gesetzter <code className="font-mono">BASE_URL</code>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{settings.registrationVerifyConfiguredViaEnv && (
|
||||||
|
<p className="text-[11px] text-status-warn mt-1">
|
||||||
|
Über <code className="font-mono">REGISTRATION_VERIFY_EMAIL</code> festgelegt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={Boolean(settings.registrationVerifyEmail)}
|
||||||
|
disabled={Boolean(settings.registrationVerifyConfiguredViaEnv)}
|
||||||
|
onChange={(registrationVerifyEmail) => handleSaveSystemSettings({ registrationVerifyEmail })}
|
||||||
|
title="E-Mail-Adresse bei der Registrierung bestätigen lassen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -3,36 +3,55 @@ import {
|
|||||||
Key,
|
Key,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Pencil,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
Webhook,
|
Webhook,
|
||||||
Zap,
|
Zap,
|
||||||
Terminal,
|
Terminal,
|
||||||
|
Bot,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types';
|
import { APIKey, ApiKeyScope, PriorityLevel, SharedList, TaskList } from '../../lib/types';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
|
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||||
|
|
||||||
interface ApiKeysPanelProps {
|
interface ApiKeysPanelProps {
|
||||||
onIngested?: () => void;
|
onIngested?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// What a key may deliver; keys never read anything
|
// What a key may do. The rights hold for every access alike: curl/REST and MCP for AI agents. The ids of
|
||||||
const SCOPES: { id: ApiKeyScope; label: string; hint: string }[] = [
|
// the delivering rights (webhook:…) stay from the time keys could only deliver data.
|
||||||
{ id: 'webhook:triage', label: '📥 Triage / Inbox', hint: 'Gedanken in die Inbox' },
|
type ScopeGroup = 'tasks' | 'lists' | 'inbox';
|
||||||
{ id: 'webhook:task', label: '📋 Aufgaben', hint: 'Aufgaben direkt anlegen' },
|
const SCOPES: { id: ApiKeyScope; label: string; chip: string; hint: string; group: ScopeGroup }[] = [
|
||||||
{ id: 'webhook:list', label: '📝 Listeneinträge', hint: 'Einträge zu Listen hinzufügen' },
|
{ id: 'tasks:read', label: 'Lesen', chip: 'Aufgaben lesen', hint: 'Aufgabenlisten und Aufgaben lesen, suchen', group: 'tasks' },
|
||||||
|
{ id: 'webhook:task', label: 'Anlegen', chip: 'Aufgaben anlegen', hint: 'Nur neue Aufgaben anlegen', group: 'tasks' },
|
||||||
|
{ id: 'tasks:write', label: 'Ändern', chip: 'Aufgaben ändern', hint: 'Anlegen, ändern, abhaken und löschen', group: 'tasks' },
|
||||||
|
{ id: 'lists:read', label: 'Lesen', chip: 'Listen lesen', hint: 'Listen und ihre Einträge lesen', group: 'lists' },
|
||||||
|
{ id: 'webhook:list', label: 'Einträge hinzufügen', chip: 'Listeneinträge hinzufügen', hint: 'Nur neue Einträge hinzufügen', group: 'lists' },
|
||||||
|
{ id: 'lists:write', label: 'Ändern', chip: 'Listen ändern', hint: 'Einträge hinzufügen, ändern und abhaken', group: 'lists' },
|
||||||
|
{ id: 'webhook:triage', label: 'Inbox füllen', chip: 'Inbox', hint: 'Gedanken in die Inbox einliefern', group: 'inbox' },
|
||||||
];
|
];
|
||||||
const scopeLabel = (id: string) => SCOPES.find((s) => s.id === id)?.label || id;
|
const SCOPE_GROUPS: [ScopeGroup, string][] = [
|
||||||
|
['tasks', '📋 Aufgaben'],
|
||||||
|
['lists', '🛒 Listen'],
|
||||||
|
['inbox', '📥 Inbox'],
|
||||||
|
];
|
||||||
|
const scopeChip = (id: string) => SCOPES.find((s) => s.id === id)?.chip || id;
|
||||||
|
|
||||||
type SimTarget = 'triage' | 'task' | 'list_item';
|
type SimTarget = 'triage' | 'task' | 'list_item';
|
||||||
|
type McpClient = 'claude' | 'codex' | 'json';
|
||||||
|
type Access = 'rest' | 'mcp';
|
||||||
|
|
||||||
// Personal API keys with scopes, a simulator and copyable examples (settings → Private Webhooks)
|
// Personal API keys with rights (switches) and how to connect: curl / smart home (read examples,
|
||||||
|
// delivery simulator) or AI agents over MCP (settings → API)
|
||||||
export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
||||||
const [keys, setKeys] = useState<APIKey[]>([]);
|
const [keys, setKeys] = useState<APIKey[]>([]);
|
||||||
const [newKeyName, setNewKeyName] = useState('');
|
const [newKeyName, setNewKeyName] = useState('');
|
||||||
const [newKeyScopes, setNewKeyScopes] = useState<ApiKeyScope[]>(['webhook:triage']);
|
const [newKeyScopes, setNewKeyScopes] = useState<ApiKeyScope[]>(['webhook:triage']);
|
||||||
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
const [newlyCreatedKey, setNewlyCreatedKey] = useState<string | null>(null);
|
||||||
const [copiedKey, setCopiedKey] = useState(false);
|
const [copiedKey, setCopiedKey] = useState(false);
|
||||||
|
const [mcpClient, setMcpClient] = useState<McpClient>('claude');
|
||||||
|
const [access, setAccess] = useState<Access>('rest');
|
||||||
|
|
||||||
// Live Webhook Simulator State
|
// Live Webhook Simulator State
|
||||||
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
|
const [simTitle, setSimTitle] = useState('Waschmaschine im Keller ist fertig!');
|
||||||
@@ -55,6 +74,58 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
|||||||
api.getTaskLists().then(setTaskLists).catch(() => {});
|
api.getTaskLists().then(setTaskLists).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Editing an existing key: name and rights (the secret stays the same)
|
||||||
|
const [editKeyId, setEditKeyId] = useState<string | null>(null);
|
||||||
|
const [editName, setEditName] = useState('');
|
||||||
|
const [editScopes, setEditScopes] = useState<ApiKeyScope[]>([]);
|
||||||
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const startEdit = (k: APIKey) => {
|
||||||
|
setEditKeyId(k.id);
|
||||||
|
setEditName(k.name);
|
||||||
|
setEditScopes(k.scopes.split(',').filter(Boolean) as ApiKeyScope[]);
|
||||||
|
setEditError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleEditScope = (id: ApiKeyScope) => {
|
||||||
|
setEditScopes((prev) => {
|
||||||
|
const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id];
|
||||||
|
return next.length > 0 ? next : prev; // at least one
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
if (!editKeyId) return;
|
||||||
|
try {
|
||||||
|
await api.updateAPIKey(editKeyId, { name: editName.trim(), scopes: editScopes });
|
||||||
|
triggerHaptic(15);
|
||||||
|
setEditKeyId(null);
|
||||||
|
await loadKeys();
|
||||||
|
} catch (err: any) {
|
||||||
|
setEditError(err?.message || 'Speichern fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Switches grouped by tasks, lists and inbox (same layout for a new and an existing key)
|
||||||
|
const renderScopeSwitches = (selected: ApiKeyScope[], toggle: (id: ApiKeyScope) => void, label: string) => (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3" role="group" aria-label={label}>
|
||||||
|
{SCOPE_GROUPS.map(([group, heading]) => (
|
||||||
|
<div key={group} className="border border-border rounded-card bg-panel px-3 py-1.5">
|
||||||
|
<p className="text-[11px] text-muted pt-1 pb-0.5 font-semibold">{heading}</p>
|
||||||
|
{SCOPES.filter((sc) => sc.group === group).map((sc) => (
|
||||||
|
<div key={sc.id} className="flex items-center justify-between gap-3 min-h-[44px] sm:min-h-[36px]">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="block text-xs text-main">{sc.label}</span>
|
||||||
|
<span className="block text-[11px] text-muted">{sc.hint}</span>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch checked={selected.includes(sc.id)} onChange={() => toggle(sc.id)} title={sc.chip} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const toggleScope = (id: ApiKeyScope) => {
|
const toggleScope = (id: ApiKeyScope) => {
|
||||||
setNewKeyScopes((prev) => {
|
setNewKeyScopes((prev) => {
|
||||||
const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id];
|
const next = prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id];
|
||||||
@@ -140,6 +211,26 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
|||||||
"tags": ["smarthome", "keller"]
|
"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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* API keys */}
|
{/* API keys */}
|
||||||
@@ -149,10 +240,12 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
|||||||
<Key className="w-4 h-4" />
|
<Key className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-bold text-main">Webhooks & Ingestion API-Schlüssel</h3>
|
<h3 className="text-sm font-bold text-main">API-Schlüssel</h3>
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
Verbinde Home Assistant, Zigbee2MQTT, n8n oder eigene Cronjobs mit MindSift. Ein Key kann nur Daten
|
Ein Schlüssel verbindet Home Assistant, n8n, eigene Skripte oder KI-Agenten (Claude Code, Codex …) mit
|
||||||
einliefern, nie lesen; was genau, legst du pro Key fest.
|
MindSift. Was er darf, legst du mit den Schaltern fest; das gilt per curl und per MCP gleich. Notizen und
|
||||||
|
Einstellungen bleiben für Schlüssel gesperrt. Was ein Schlüssel anlegt oder ändert, trägt seinen Namen als
|
||||||
|
Badge.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,28 +264,7 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
|||||||
<span>API-Key erzeugen</span>
|
<span>API-Key erzeugen</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2" role="group" aria-label="Berechtigungen des Keys">
|
{renderScopeSwitches(newKeyScopes, toggleScope, 'Berechtigungen des Schlüssels')}
|
||||||
<span className="text-xs text-muted">Darf einliefern:</span>
|
|
||||||
{SCOPES.map((sc) => {
|
|
||||||
const on = newKeyScopes.includes(sc.id);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={sc.id}
|
|
||||||
type="button"
|
|
||||||
role="checkbox"
|
|
||||||
aria-checked={on}
|
|
||||||
title={sc.hint}
|
|
||||||
onClick={() => toggleScope(sc.id)}
|
|
||||||
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs inline-flex items-center gap-1.5 transition-colors ${
|
|
||||||
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{on && <Check className="w-3.5 h-3.5 text-accent" />}
|
|
||||||
{sc.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{newlyCreatedKey && (
|
{newlyCreatedKey && (
|
||||||
@@ -213,7 +285,39 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
|||||||
{keys.length === 0 ? (
|
{keys.length === 0 ? (
|
||||||
<p className="text-xs text-faint px-3 py-2">Noch keine dedizierten API-Schlüssel hinterlegt.</p>
|
<p className="text-xs text-faint px-3 py-2">Noch keine dedizierten API-Schlüssel hinterlegt.</p>
|
||||||
) : (
|
) : (
|
||||||
keys.map((k) => (
|
keys.map((k) =>
|
||||||
|
editKeyId === k.id ? (
|
||||||
|
<div key={k.id} className="px-3 py-3 bg-canvas space-y-2">
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
aria-label="Name des Schlüssels"
|
||||||
|
className="w-full sm:flex-1 min-w-0 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => setEditKeyId(null)} className="btn-action h-11 sm:h-9 px-3 text-xs flex-1 sm:flex-none">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveEdit}
|
||||||
|
disabled={!editName.trim()}
|
||||||
|
className="btn-primary h-11 sm:h-9 px-4 text-xs flex-1 sm:flex-none disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderScopeSwitches(editScopes, toggleEditScope, `Berechtigungen von ${k.name}`)}
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Der Schlüssel selbst bleibt gleich; Geräte und Agenten, die ihn nutzen, müssen nichts ändern. Die neuen
|
||||||
|
Rechte gelten ab sofort.
|
||||||
|
</p>
|
||||||
|
{editError && <p className="text-[11px] text-status-err">{editError}</p>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div key={k.id} className="px-3 py-2 bg-canvas flex items-center justify-between gap-3">
|
<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">
|
<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-sm font-medium text-main">{k.name}</span>
|
||||||
@@ -222,8 +326,8 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
|||||||
.split(',')
|
.split(',')
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((sc) => (
|
.map((sc) => (
|
||||||
<span key={sc} className="px-1.5 py-0.5 rounded-btn border border-border text-[10px] text-muted">
|
<span key={sc} className="px-1.5 py-0.5 rounded-btn border border-border text-muted text-[10px]">
|
||||||
{scopeLabel(sc)}
|
{scopeChip(sc)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
{k.lastUsedAt && (
|
{k.lastUsedAt && (
|
||||||
@@ -232,149 +336,274 @@ export const ApiKeysPanel: React.FC<ApiKeysPanelProps> = ({ onIngested }) => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
onClick={() => handleDeleteKey(k.id)}
|
<button
|
||||||
className="btn-icon btn-icon-danger w-7 h-7 shrink-0"
|
onClick={() => startEdit(k)}
|
||||||
aria-label={`API-Key ${k.name} löschen`}
|
className="btn-icon w-7 h-7"
|
||||||
>
|
aria-label={`API-Key ${k.name} bearbeiten`}
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
title="Name und Rechte ändern"
|
||||||
</button>
|
>
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteKey(k.id)}
|
||||||
|
className="btn-icon btn-icon-danger w-7 h-7"
|
||||||
|
aria-label={`API-Key ${k.name} löschen`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
)
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Simulator */}
|
{/* How to connect: one API, two accesses */}
|
||||||
<div className="pt-4 border-t border-border">
|
<div className="pt-4 border-t border-border space-y-4">
|
||||||
<div className="flex items-center gap-3 mb-3">
|
<div className="flex flex-wrap gap-1.5" role="tablist" aria-label="Zugang">
|
||||||
<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'],
|
['rest', 'curl / Smart Home', Terminal],
|
||||||
['task', '📋 Aufgabe'],
|
['mcp', 'KI-Agenten (MCP)', Bot],
|
||||||
['list_item', '📝 Listeneintrag'],
|
|
||||||
] as const
|
] as const
|
||||||
).map(([value, label]) => (
|
).map(([value, label, Icon]) => (
|
||||||
<button
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
role="radio"
|
role="tab"
|
||||||
aria-checked={simTarget === value}
|
aria-selected={access === value}
|
||||||
onClick={() => setSimTarget(value)}
|
onClick={() => setAccess(value)}
|
||||||
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs transition-colors ${
|
className={`h-11 sm:h-8 px-3 rounded-btn border text-xs flex items-center gap-1.5 transition-colors ${
|
||||||
simTarget === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
access === value ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5 text-accent" />
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{simTarget === 'triage' && (
|
{access === 'rest' && (
|
||||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
<div className="space-y-5">
|
||||||
{[
|
{/* Reading: e.g. a smart home sensor for what is due today */}
|
||||||
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
|
<div>
|
||||||
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
|
<div className="flex items-center gap-2 text-xs font-medium text-muted mb-2">
|
||||||
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
|
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||||
].map((preset) => (
|
<span>Lesen, z. B. was heute fällig ist (Recht „Aufgaben lesen“):</span>
|
||||||
<button
|
</div>
|
||||||
key={preset.label}
|
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
|
||||||
type="button"
|
{`curl "${baseUrl}/api/v1/tasks?due=today&status=open" \\
|
||||||
onClick={() => {
|
-H "X-API-Key: ${exampleToken}"`}
|
||||||
setSimTitle(preset.text);
|
</pre>
|
||||||
setSimPriority(preset.prio);
|
<p className="mt-2 text-[11px] text-faint">
|
||||||
}}
|
Filter: <code className="font-mono">due=today</code>, <code className="font-mono">due=overdue</code> oder ein
|
||||||
className="btn-action text-xs"
|
Datum (<code className="font-mono">JJJJ-MM-TT</code>, fällig bis einschließlich), <code className="font-mono">status=open|done</code>,{' '}
|
||||||
>
|
<code className="font-mono">taskListId</code>. Listen: <code className="font-mono">GET /api/v1/lists</code> und{' '}
|
||||||
{preset.label}
|
<code className="font-mono">/lists/ID</code>. Mit „Ändern“ auch <code className="font-mono">PATCH /api/v1/thoughts/ID</code>{' '}
|
||||||
</button>
|
(z. B. <code className="font-mono">{'{"isCompleted": true}'}</code>).
|
||||||
))}
|
</p>
|
||||||
</div>
|
<div className="flex items-center gap-2 text-xs font-medium text-muted mt-3 mb-2">
|
||||||
)}
|
<Terminal className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>Home Assistant: Sensor „Heute fällig“ (configuration.yaml)</span>
|
||||||
|
</div>
|
||||||
|
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
|
||||||
|
{`rest:
|
||||||
|
- resource: "${baseUrl}/api/v1/tasks?due=today&status=open"
|
||||||
|
headers:
|
||||||
|
X-API-Key: "${exampleToken}"
|
||||||
|
scan_interval: 900
|
||||||
|
sensor:
|
||||||
|
- name: "MindSift heute fällig"
|
||||||
|
value_template: "{{ value_json | length }}"`}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
<div className="pt-4 border-t border-border">
|
||||||
<input
|
<div className="flex items-center gap-3 mb-3">
|
||||||
type="text"
|
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
||||||
value={simTitle}
|
<Webhook className="w-4 h-4" />
|
||||||
onChange={(e) => setSimTitle(e.target.value)}
|
</div>
|
||||||
className="flex-1 h-11 sm:h-9 px-3 input-field text-base sm:text-sm"
|
<div>
|
||||||
/>
|
<h3 className="text-sm font-bold text-main">Einliefern testen (Webhook)</h3>
|
||||||
<button
|
<p className="text-xs text-muted">
|
||||||
type="button"
|
Teste das Einliefern per <code className="font-mono">POST /api/v1/webhooks/ingest</code> direkt hier
|
||||||
onClick={handleSimulateWebhook}
|
{newlyCreatedKey ? ' – mit dem eben erzeugten Key und seinen Berechtigungen.' : ' – mit deiner Anmeldung.'}
|
||||||
className="btn-primary h-11 sm:h-9 px-4 text-xs sm:text-sm flex items-center justify-center gap-1.5"
|
</p>
|
||||||
>
|
</div>
|
||||||
<Zap className="w-4 h-4" />
|
</div>
|
||||||
<span>Webhook jetzt abfeuern</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{simStatus && (
|
<div className="flex flex-wrap gap-1.5 mb-3" role="radiogroup" aria-label="Ziel">
|
||||||
<p className={`mt-3 text-xs font-medium ${simStatus.startsWith('❌') ? 'text-status-err' : 'text-status-ok'}`}>{simStatus}</p>
|
{(
|
||||||
)}
|
[
|
||||||
|
['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>
|
||||||
|
|
||||||
{/* Code Example Snippets */}
|
{simTarget === 'triage' && (
|
||||||
<div className="mt-4 space-y-2">
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-muted">
|
{[
|
||||||
<Terminal className="w-3.5 h-3.5 text-accent" />
|
{ label: '🧺 Waschmaschine fertig', text: 'Waschmaschine im Keller ist fertig – Wäsche aufhängen!', prio: 'high' as PriorityLevel },
|
||||||
<span>cURL / Home Assistant REST Command:</span>
|
{ label: '🚨 Rauchmelder Batterie', text: 'Rauchmelder Dachgeschoss meldet niedrige Batterie (12%)', prio: 'urgent' as PriorityLevel },
|
||||||
</div>
|
{ label: '💧 Luftfeuchtigkeit Keller', text: 'Kellerfeuchtigkeit über 70% – Lüften oder Entfeuchter starten', prio: 'urgent' as PriorityLevel },
|
||||||
<pre className="p-3 bg-canvas border border-border font-mono text-xs text-main overflow-x-auto">
|
].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" \\
|
{`curl -X POST "${baseUrl}/api/v1/webhooks/ingest" \\
|
||||||
-H "X-API-Key: ${exampleToken}" \\
|
-H "X-API-Key: ${exampleToken}" \\
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/json" \\
|
||||||
-d '${examplePayload}'`}
|
-d '${examplePayload}'`}
|
||||||
</pre>
|
</pre>
|
||||||
<p className="text-[11px] text-faint">
|
<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
|
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.
|
Proxy-Logs landet. Fehlt dem Key die Berechtigung für das Ziel, antwortet der Server mit 403.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{access === 'mcp' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-7 h-7 bg-accent text-on-accent flex items-center justify-center flex-shrink-0">
|
||||||
|
<Bot className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-main">KI-Agenten anbinden (MCP)</h3>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
MindSift ist ein MCP-Server: Claude Code, Codex, Claude Desktop, Cursor und eigene Agenten nutzen dieselben
|
||||||
|
Schlüssel und Rechte wie curl, nur in der Form, die Agenten erwarten.
|
||||||
|
</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">create_task_list</code>,{' '}
|
||||||
|
<code className="font-mono">list_tasks</code>, <code className="font-mono">get_task</code>,{' '}
|
||||||
|
<code className="font-mono">create_task</code>, <code className="font-mono">update_task</code>,{' '}
|
||||||
|
<code className="font-mono">complete_task</code>, <code className="font-mono">delete_task</code>,{' '}
|
||||||
|
<code className="font-mono">list_lists</code>, <code className="font-mono">create_list</code>,{' '}
|
||||||
|
<code className="font-mono">get_list</code>, <code className="font-mono">add_list_item</code>,{' '}
|
||||||
|
<code className="font-mono">check_list_item</code>, <code className="font-mono">update_list_item</code>,{' '}
|
||||||
|
<code className="font-mono">search</code>, <code className="font-mono">capture_to_inbox</code>. Der Agent sieht
|
||||||
|
nur die Werkzeuge, für die der Schlüssel 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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Archive, Download, Loader2 } from 'lucide-react';
|
import { Archive, Download, Loader2, Trash2 } from 'lucide-react';
|
||||||
import { BackupFile, SystemSettings } from '../../lib/types';
|
import { BackupFile, SystemSettings } from '../../lib/types';
|
||||||
import { api } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||||
@@ -41,6 +41,16 @@ export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSa
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const remove = async (name: string) => {
|
||||||
|
if (!window.confirm(`Backup ${name} endgültig löschen?`)) return;
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setFiles(await api.adminDeleteBackup(name));
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Löschen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
<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="flex items-center justify-between gap-4">
|
||||||
@@ -109,14 +119,25 @@ export const BackupAdminCard: React.FC<BackupAdminCardProps> = ({ settings, onSa
|
|||||||
{new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)}
|
{new Date(f.createdAt).toLocaleString('de-DE')} · {formatBytes(f.size)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => download(f.name)}
|
type="button"
|
||||||
className="btn-action h-11 sm:h-7 px-2.5 text-[11px] inline-flex items-center gap-1 shrink-0"
|
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
|
<Download className="w-3.5 h-3.5" />
|
||||||
</button>
|
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>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Check } from 'lucide-react';
|
|
||||||
import { DATA_SECTIONS, DataSection } from '../../lib/types';
|
import { DATA_SECTIONS, DataSection } from '../../lib/types';
|
||||||
|
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||||
|
|
||||||
interface SectionPickerProps {
|
interface SectionPickerProps {
|
||||||
value: DataSection[];
|
value: DataSection[];
|
||||||
@@ -8,30 +8,27 @@ interface SectionPickerProps {
|
|||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Which parts of the own data a restore or import covers; at least one stays selected
|
// 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' }) => (
|
export const SectionPicker: React.FC<SectionPickerProps> = ({ value, onChange, label = 'Bereiche' }) => (
|
||||||
<div className="flex flex-wrap items-center gap-1.5" role="group" aria-label={label}>
|
<div role="group" aria-label={label}>
|
||||||
<span className="text-[11px] text-muted mr-1">{label}:</span>
|
<p className="text-[11px] text-muted mb-1.5">{label}</p>
|
||||||
{DATA_SECTIONS.map((sec) => {
|
<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">
|
||||||
const on = value.includes(sec.id);
|
{DATA_SECTIONS.map((sec) => {
|
||||||
return (
|
const on = value.includes(sec.id);
|
||||||
<button
|
return (
|
||||||
key={sec.id}
|
<div key={sec.id} className="flex items-center justify-between gap-3 min-h-[44px] sm:min-h-[36px]">
|
||||||
type="button"
|
<span className="text-xs text-main">{sec.label}</span>
|
||||||
role="checkbox"
|
<ToggleSwitch
|
||||||
aria-checked={on}
|
checked={on}
|
||||||
onClick={() => {
|
title={sec.label}
|
||||||
const next = on ? value.filter((v) => v !== sec.id) : [...value, sec.id];
|
onChange={() => {
|
||||||
if (next.length > 0) onChange(next);
|
const next = on ? value.filter((v) => v !== sec.id) : [...value, sec.id];
|
||||||
}}
|
if (next.length > 0) onChange(next);
|
||||||
className={`h-11 sm:h-7 px-2.5 rounded-btn border text-[11px] inline-flex items-center gap-1 transition-colors ${
|
}}
|
||||||
on ? 'border-accent bg-accent/10 text-main' : 'border-border text-muted hover:text-main'
|
/>
|
||||||
}`}
|
</div>
|
||||||
>
|
);
|
||||||
{on && <Check className="w-3 h-3 text-accent" />}
|
})}
|
||||||
{sec.label}
|
</div>
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useRef } from 'react';
|
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
Bell,
|
Bell,
|
||||||
@@ -11,19 +11,41 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Globe,
|
Globe,
|
||||||
Send,
|
Send,
|
||||||
|
X,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Palette,
|
Palette,
|
||||||
Upload,
|
Upload,
|
||||||
Pencil,
|
Pencil,
|
||||||
Copy,
|
Copy,
|
||||||
|
Archive,
|
||||||
|
Mic,
|
||||||
|
Clock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AuthStatus, NotificationEvents, NotificationSettings, User, UserTheme } from '../../lib/types';
|
import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, Retention, User, UserTheme } from '../../lib/types';
|
||||||
import { ApiKeysPanel } from './ApiKeysPanel';
|
import { ApiKeysPanel } from './ApiKeysPanel';
|
||||||
import { ServerBackupsCard } from './ServerBackupsCard';
|
import { ServerBackupsCard } from './ServerBackupsCard';
|
||||||
import { saveBlob } from '../../lib/download';
|
import { saveBlob } from '../../lib/download';
|
||||||
import { api, clearToken, triggerHaptic } from '../../lib/api';
|
import { api, clearToken, triggerHaptic } from '../../lib/api';
|
||||||
import { passwordProblem } from '../../lib/password';
|
import { passwordProblem } from '../../lib/password';
|
||||||
import { customThemeId, ThemeId, ThemeOption } from '../../lib/theme';
|
import { loadVoiceConfig, voiceChoiceAvailable } from '../../lib/voice';
|
||||||
|
import {
|
||||||
|
AVAILABLE_THEMES,
|
||||||
|
CornerStyle,
|
||||||
|
customThemeId,
|
||||||
|
getCornerStyle,
|
||||||
|
setCornerStyle,
|
||||||
|
getFollowSystem,
|
||||||
|
setFollowSystem,
|
||||||
|
resolveTheme,
|
||||||
|
getButtonBorders,
|
||||||
|
setButtonBorders,
|
||||||
|
ButtonBorders,
|
||||||
|
getAccentColor,
|
||||||
|
setAccentColor,
|
||||||
|
THEME_FAMILIES,
|
||||||
|
ThemeId,
|
||||||
|
ThemeOption,
|
||||||
|
} from '../../lib/theme';
|
||||||
import themeTemplateCss from '../../themes/theme-template.css?raw';
|
import themeTemplateCss from '../../themes/theme-template.css?raw';
|
||||||
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
||||||
import { LegalLinks } from '../../components/LegalLinks';
|
import { LegalLinks } from '../../components/LegalLinks';
|
||||||
@@ -32,6 +54,8 @@ import { ImportCard } from './ImportCard';
|
|||||||
import { SessionsPanel } from './SessionsPanel';
|
import { SessionsPanel } from './SessionsPanel';
|
||||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||||
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
|
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
|
||||||
|
import { isJournalEnabled, setJournalEnabled } from '../../lib/journal';
|
||||||
|
import { currentPushSubscription, disablePush, enablePush, pushSupported, removePushDevice } from '../../lib/push';
|
||||||
import { FormSheet } from '../../components/FormSheet';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
|
|
||||||
interface UserSettingsViewProps {
|
interface UserSettingsViewProps {
|
||||||
@@ -45,12 +69,31 @@ interface UserSettingsViewProps {
|
|||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks';
|
// Kontoeinstellungen → Aufbewahrung: the areas and the delays offered (storage.RetentionChoices)
|
||||||
|
const RETENTION_AREAS: { key: keyof Retention; title: string; text: string; verb: string }[] = [
|
||||||
|
{ key: 'doneTasks', title: 'Erledigte Aufgaben', text: 'Wandern danach in den Papierkorb der Aufgaben.', verb: 'in den Papierkorb nach' },
|
||||||
|
{ key: 'taskTrash', title: 'Papierkorb der Aufgaben', text: 'Gelöschte und geleerte Aufgaben.', verb: 'endgültig löschen nach' },
|
||||||
|
{ key: 'triageTrash', title: 'Papierkorb der Triage', text: 'Beim Sortieren verworfene Gedanken.', verb: 'endgültig löschen nach' },
|
||||||
|
{ key: 'noteTrash', title: 'Papierkorb der Notizen', text: 'Gelöschte Notizen im Workspace.', verb: 'endgültig löschen nach' },
|
||||||
|
{ key: 'listDone', title: 'Abgehakte Listeneinträge', text: 'In Einkaufs- und Checklisten.', verb: 'löschen nach' },
|
||||||
|
];
|
||||||
|
const RETENTION_OPTIONS = [
|
||||||
|
{ days: 7, label: '7 Tagen' },
|
||||||
|
{ days: 14, label: '14 Tagen' },
|
||||||
|
{ days: 30, label: '30 Tagen' },
|
||||||
|
{ days: 90, label: '90 Tagen' },
|
||||||
|
{ days: 365, label: 'einem Jahr' },
|
||||||
|
{ days: 730, label: 'zwei Jahren' },
|
||||||
|
{ days: 0, label: 'nie' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'voice' | 'retention' | 'webhooks' | 'data';
|
||||||
|
|
||||||
export function buildNotificationSettings(user: User): NotificationSettings {
|
export function buildNotificationSettings(user: User): NotificationSettings {
|
||||||
const hasLegacy = Array.isArray(user.alarmChannels);
|
const hasLegacy = Array.isArray(user.alarmChannels);
|
||||||
const dueEmailDefault = hasLegacy ? user.alarmChannels!.includes('email') : true;
|
const dueEmailDefault = hasLegacy ? user.alarmChannels!.includes('email') : true;
|
||||||
const dueDiscordDefault = hasLegacy ? user.alarmChannels!.includes('discord') : true;
|
const dueDiscordDefault = hasLegacy ? user.alarmChannels!.includes('discord') : true;
|
||||||
|
const dueWebhookDefault = hasLegacy ? user.alarmChannels!.includes('webhook') : false;
|
||||||
const s = user.notificationSettings;
|
const s = user.notificationSettings;
|
||||||
return {
|
return {
|
||||||
paused: Boolean(s?.paused),
|
paused: Boolean(s?.paused),
|
||||||
@@ -59,18 +102,26 @@ export function buildNotificationSettings(user: User): NotificationSettings {
|
|||||||
due: {
|
due: {
|
||||||
email: s?.events?.due?.email ?? dueEmailDefault,
|
email: s?.events?.due?.email ?? dueEmailDefault,
|
||||||
discord: s?.events?.due?.discord ?? dueDiscordDefault,
|
discord: s?.events?.due?.discord ?? dueDiscordDefault,
|
||||||
|
webhook: s?.events?.due?.webhook ?? dueWebhookDefault,
|
||||||
|
push: s?.events?.due?.push ?? false,
|
||||||
},
|
},
|
||||||
shared: {
|
shared: {
|
||||||
email: s?.events?.shared?.email ?? true,
|
email: s?.events?.shared?.email ?? true,
|
||||||
discord: s?.events?.shared?.discord ?? false,
|
discord: s?.events?.shared?.discord ?? false,
|
||||||
|
webhook: s?.events?.shared?.webhook ?? false,
|
||||||
|
push: s?.events?.shared?.push ?? false,
|
||||||
},
|
},
|
||||||
taskDone: {
|
taskDone: {
|
||||||
email: s?.events?.taskDone?.email ?? false,
|
email: s?.events?.taskDone?.email ?? false,
|
||||||
discord: s?.events?.taskDone?.discord ?? false,
|
discord: s?.events?.taskDone?.discord ?? false,
|
||||||
|
webhook: s?.events?.taskDone?.webhook ?? false,
|
||||||
|
push: s?.events?.taskDone?.push ?? false,
|
||||||
},
|
},
|
||||||
watched: {
|
watched: {
|
||||||
email: s?.events?.watched?.email ?? true,
|
email: s?.events?.watched?.email ?? true,
|
||||||
discord: s?.events?.watched?.discord ?? true,
|
discord: s?.events?.watched?.discord ?? true,
|
||||||
|
webhook: s?.events?.watched?.webhook ?? false,
|
||||||
|
push: s?.events?.watched?.push ?? false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dailySummary: {
|
dailySummary: {
|
||||||
@@ -78,6 +129,8 @@ export function buildNotificationSettings(user: User): NotificationSettings {
|
|||||||
time: s?.dailySummary?.time || '07:00',
|
time: s?.dailySummary?.time || '07:00',
|
||||||
email: s?.dailySummary?.email ?? true,
|
email: s?.dailySummary?.email ?? true,
|
||||||
discord: s?.dailySummary?.discord ?? false,
|
discord: s?.dailySummary?.discord ?? false,
|
||||||
|
webhook: s?.dailySummary?.webhook ?? false,
|
||||||
|
push: s?.dailySummary?.push ?? false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -100,6 +153,36 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
onBack,
|
onBack,
|
||||||
}) => {
|
}) => {
|
||||||
const [section, setSection] = useState<UserSettingsSection>('profile');
|
const [section, setSection] = useState<UserSettingsSection>('profile');
|
||||||
|
// Voice input: the choice Whisper/browser only exists with a Whisper server that allows the browser
|
||||||
|
const [voiceChoice, setVoiceChoice] = useState(false);
|
||||||
|
const [voiceSaving, setVoiceSaving] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
void loadVoiceConfig().then(() => setVoiceChoice(voiceChoiceAvailable()));
|
||||||
|
}, []);
|
||||||
|
// Retention: one delay per area (storage/retention.go); "nie" only when the server allows it
|
||||||
|
const [retentionSaving, setRetentionSaving] = useState(false);
|
||||||
|
const [retentionError, setRetentionError] = useState<string | null>(null);
|
||||||
|
const handleChangeRetention = async (key: keyof Retention, days: number) => {
|
||||||
|
if (!currentUser.retention) return;
|
||||||
|
setRetentionSaving(true);
|
||||||
|
setRetentionError(null);
|
||||||
|
try {
|
||||||
|
onUserUpdated(await api.updateSettings({ retention: { ...currentUser.retention, [key]: days } }));
|
||||||
|
} catch (err: any) {
|
||||||
|
setRetentionError(err?.message || 'Speichern fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setRetentionSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeVoiceInput = async (next: '' | 'browser') => {
|
||||||
|
setVoiceSaving(true);
|
||||||
|
try {
|
||||||
|
onUserUpdated(await api.updateSettings({ voiceInput: next }));
|
||||||
|
} finally {
|
||||||
|
setVoiceSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
const [legal, setLegal] = useState<AuthStatus['legal']>();
|
const [legal, setLegal] = useState<AuthStatus['legal']>();
|
||||||
|
|
||||||
// --- Danger zone ---
|
// --- Danger zone ---
|
||||||
@@ -144,6 +227,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
// The public demo sends nothing and keeps the shared accounts intact (FLAW-33)
|
// The public demo sends nothing and keeps the shared accounts intact (FLAW-33)
|
||||||
const [demoMode, setDemoMode] = useState(false);
|
const [demoMode, setDemoMode] = useState(false);
|
||||||
const linkPreviewsOn = useLinkPreviewsEnabled();
|
const linkPreviewsOn = useLinkPreviewsEnabled();
|
||||||
|
const [journalOn, setJournalOn] = useState(isJournalEnabled);
|
||||||
|
const [corners, setCorners] = useState<CornerStyle>(getCornerStyle);
|
||||||
|
const [followSystem, setFollowSystemState] = useState(getFollowSystem);
|
||||||
|
const [buttonBorders, setButtonBordersState] = useState<ButtonBorders>(getButtonBorders);
|
||||||
|
const [accent, setAccent] = useState<string | null>(getAccentColor);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api
|
api
|
||||||
.getAuthStatus()
|
.getAuthStatus()
|
||||||
@@ -250,9 +338,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// --- Multi-Channel Alarms State ---
|
// --- Multi-Channel Alarms State ---
|
||||||
const [alarmChannels, setAlarmChannels] = useState<string[]>(
|
|
||||||
Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []
|
|
||||||
);
|
|
||||||
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>(() =>
|
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>(() =>
|
||||||
buildNotificationSettings(currentUser)
|
buildNotificationSettings(currentUser)
|
||||||
);
|
);
|
||||||
@@ -266,11 +351,65 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
const [savingAlarms, setSavingAlarms] = useState(false);
|
const [savingAlarms, setSavingAlarms] = useState(false);
|
||||||
const [testingChannel, setTestingChannel] = useState<string | null>(null);
|
const [testingChannel, setTestingChannel] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Web push: devices of this account and whether this browser is one of them
|
||||||
|
const [pushDevices, setPushDevices] = useState<PushDevice[]>([]);
|
||||||
|
const [pushHere, setPushHere] = useState(false);
|
||||||
|
const [pushEndpointHere, setPushEndpointHere] = useState<string | null>(null);
|
||||||
|
const [pushBusy, setPushBusy] = useState(false);
|
||||||
|
const [pushMsg, setPushMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||||
|
const reloadPush = useCallback(async () => {
|
||||||
|
const [devices, sub] = await Promise.all([api.getPushDevices().catch(() => []), currentPushSubscription()]);
|
||||||
|
setPushDevices(devices);
|
||||||
|
setPushHere(Boolean(sub && devices.some((d) => d.endpoint === sub.endpoint)));
|
||||||
|
setPushEndpointHere(sub?.endpoint ?? null);
|
||||||
|
}, []);
|
||||||
|
// An old installation or a device you no longer use: remove it from the list
|
||||||
|
const removeDevice = async (endpoint: string) => {
|
||||||
|
setPushBusy(true);
|
||||||
|
setPushMsg(null);
|
||||||
|
try {
|
||||||
|
await removePushDevice(endpoint);
|
||||||
|
await reloadPush();
|
||||||
|
} catch (err) {
|
||||||
|
setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Gerät konnte nicht entfernt werden' });
|
||||||
|
} finally {
|
||||||
|
setPushBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
void reloadPush();
|
||||||
|
}, [reloadPush]);
|
||||||
|
const togglePushHere = async (on: boolean) => {
|
||||||
|
setPushBusy(true);
|
||||||
|
setPushMsg(null);
|
||||||
|
try {
|
||||||
|
if (on) await enablePush();
|
||||||
|
else await disablePush();
|
||||||
|
await reloadPush();
|
||||||
|
setPushMsg({ type: 'ok', text: on ? 'Push ist auf diesem Gerät eingeschaltet.' : 'Push ist auf diesem Gerät ausgeschaltet.' });
|
||||||
|
} catch (err) {
|
||||||
|
setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Push konnte nicht eingeschaltet werden' });
|
||||||
|
} finally {
|
||||||
|
setPushBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const sendTestPush = async () => {
|
||||||
|
setPushBusy(true);
|
||||||
|
setPushMsg(null);
|
||||||
|
try {
|
||||||
|
const res = await api.testPush();
|
||||||
|
setPushMsg({ type: 'ok', text: `Test-Benachrichtigung an ${res.devices} ${res.devices === 1 ? 'Gerät' : 'Geräte'} gesendet.` });
|
||||||
|
} catch (err) {
|
||||||
|
setPushMsg({ type: 'err', text: err instanceof Error ? err.message : 'Test fehlgeschlagen' });
|
||||||
|
} finally {
|
||||||
|
setPushBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayName(currentUser.displayName || '');
|
setDisplayName(currentUser.displayName || '');
|
||||||
setEmail(currentUser.email || '');
|
setEmail(currentUser.email || '');
|
||||||
setAvatarUrl(currentUser.avatarUrl || '');
|
setAvatarUrl(currentUser.avatarUrl || '');
|
||||||
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
|
|
||||||
setNotificationSettings(buildNotificationSettings(currentUser));
|
setNotificationSettings(buildNotificationSettings(currentUser));
|
||||||
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
|
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
|
||||||
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
|
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
|
||||||
@@ -278,13 +417,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
|
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
const toggleAlarmChannel = (ch: string) => {
|
|
||||||
setAlarmChannels((prev) =>
|
|
||||||
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord', value: boolean) => {
|
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord' | 'webhook' | 'push', value: boolean) => {
|
||||||
setNotificationSettings((prev) => ({
|
setNotificationSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
events: {
|
events: {
|
||||||
@@ -386,7 +520,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
const nextAlarmChannels = [
|
const nextAlarmChannels = [
|
||||||
...(notificationSettings.events.due.email ? ['email'] : []),
|
...(notificationSettings.events.due.email ? ['email'] : []),
|
||||||
...(notificationSettings.events.due.discord ? ['discord'] : []),
|
...(notificationSettings.events.due.discord ? ['discord'] : []),
|
||||||
...(alarmChannels.includes('webhook') ? ['webhook'] : []),
|
...(notificationSettings.events.due.webhook ? ['webhook'] : []),
|
||||||
];
|
];
|
||||||
const updated = await api.updateSettings({
|
const updated = await api.updateSettings({
|
||||||
alarmChannels: nextAlarmChannels,
|
alarmChannels: nextAlarmChannels,
|
||||||
@@ -500,7 +634,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-bold text-main tracking-tight">Kontoeinstellungen</h1>
|
<h1 className="text-base font-bold text-main tracking-tight">Kontoeinstellungen</h1>
|
||||||
<p className="text-xs text-muted">
|
<p className="text-xs text-muted">
|
||||||
Persönliches Profil, Design-Themes, Aufgaben-Alarme, Private Webhooks & Daten-Backup
|
Persönliches Profil, Design-Themes, Aufgaben-Alarme, API, Export & Import
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -559,6 +693,38 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{voiceChoice && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSection('voice')}
|
||||||
|
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||||
|
section === 'voice'
|
||||||
|
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
|
||||||
|
: 'text-muted hover:text-main hover:bg-panel-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Mic className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Spracheingabe</p>
|
||||||
|
<p className="text-[10px] text-faint">Whisper-Server oder Browser</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSection('retention')}
|
||||||
|
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||||
|
section === 'retention'
|
||||||
|
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
|
||||||
|
: 'text-muted hover:text-main hover:bg-panel-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Aufbewahrung</p>
|
||||||
|
<p className="text-[10px] text-faint">Erledigtes & Papierkörbe aufräumen</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setSection('webhooks')}
|
onClick={() => setSection('webhooks')}
|
||||||
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||||
@@ -569,8 +735,23 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
>
|
>
|
||||||
<Key className="w-4 h-4 text-accent flex-shrink-0" />
|
<Key className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">Private Webhooks & Export</p>
|
<p className="font-semibold">API</p>
|
||||||
<p className="text-[10px] text-faint">API-Keys, Curl & JSON-Backup</p>
|
<p className="text-[10px] text-faint">Schlüssel, curl, Smart Home & KI-Agenten</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setSection('data')}
|
||||||
|
className={`w-full px-3.5 py-3 text-left text-xs font-medium flex items-center gap-2.5 transition-colors ${
|
||||||
|
section === 'data'
|
||||||
|
? 'bg-panel-elevated text-main border-l-2 border-l-accent'
|
||||||
|
: 'text-muted hover:text-main hover:bg-panel-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Archive className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">Export & Import</p>
|
||||||
|
<p className="text-[10px] text-faint">Backups, Obsidian & Wiederherstellen</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -581,7 +762,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
{/* SECTION 1: PROFIL & SICHERHEIT */}
|
{/* SECTION 1: PROFIL & SICHERHEIT */}
|
||||||
{/* ================================================================= */}
|
{/* ================================================================= */}
|
||||||
{section === 'profile' && (
|
{section === 'profile' && (
|
||||||
<div className="space-y-5 max-w-2xl">
|
<div className="space-y-5">
|
||||||
<div className="border-b border-border pb-3">
|
<div className="border-b border-border pb-3">
|
||||||
<h2 className="text-sm font-bold text-main">Profil & Anmeldedaten</h2>
|
<h2 className="text-sm font-bold text-main">Profil & Anmeldedaten</h2>
|
||||||
<p className="text-muted mt-0.5">
|
<p className="text-muted mt-0.5">
|
||||||
@@ -857,8 +1038,223 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
<ToggleSwitch checked={linkPreviewsOn} onChange={setLinkPreviewsEnabled} title="Link-Vorschauen anzeigen" />
|
<ToggleSwitch checked={linkPreviewsOn} onChange={setLinkPreviewsEnabled} title="Link-Vorschauen anzeigen" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3.5 rounded-card border border-border bg-canvas flex items-center justify-between gap-4">
|
||||||
|
<div className="text-xs">
|
||||||
|
<p className="font-semibold text-main">Journal anzeigen</p>
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Eine Notiz pro Tag, untereinander mit Kalender – ganz oben in der Notizen-Seitenleiste. Ausschalten
|
||||||
|
blendet es nur aus, die Tage bleiben als Notizen erhalten. Gilt für dieses Gerät.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={journalOn}
|
||||||
|
onChange={(v) => {
|
||||||
|
setJournalEnabled(v);
|
||||||
|
setJournalOn(v);
|
||||||
|
}}
|
||||||
|
title="Journal anzeigen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Colour families: left light, right dark */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-[1fr_minmax(0,9rem)_minmax(0,9rem)] sm:grid-cols-[1fr_12rem_12rem] items-center gap-2 px-1 text-[11px] font-semibold uppercase tracking-wider text-faint">
|
||||||
|
<span>Theme</span>
|
||||||
|
<span className="text-center">Hell</span>
|
||||||
|
<span className="text-center">Dunkel</span>
|
||||||
|
</div>
|
||||||
|
{THEME_FAMILIES.map((f) => (
|
||||||
|
<div
|
||||||
|
key={f.id}
|
||||||
|
className="grid grid-cols-[1fr_minmax(0,9rem)_minmax(0,9rem)] sm:grid-cols-[1fr_12rem_12rem] items-center gap-2 p-2 rounded-card bg-canvas"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 px-1">
|
||||||
|
<p className="font-semibold text-main text-sm truncate">{f.name}</p>
|
||||||
|
<p className="text-[11px] text-muted truncate">{f.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
{[f.light, f.dark].map((id) => {
|
||||||
|
const t = AVAILABLE_THEMES.find((x) => x.id === id)!;
|
||||||
|
const selected = activeTheme === id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onThemeChange(resolveTheme(id))}
|
||||||
|
aria-pressed={selected}
|
||||||
|
title={t.name}
|
||||||
|
className={`h-14 rounded-card p-2 flex flex-col justify-between text-left transition-all cursor-pointer ${
|
||||||
|
selected ? 'ring-2 ring-accent' : 'hover:scale-[1.02]'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: t.previewBg, border: '1px solid rgba(127,127,127,0.25)' }}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<span className="w-6 h-2.5 rounded-full" style={{ backgroundColor: t.previewPanel }} />
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: t.previewAccent }} />
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] font-semibold truncate" style={{ color: t.previewText }}>
|
||||||
|
{selected ? '✓ Aktiv' : t.mode === 'light' ? 'Hell' : 'Dunkel'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Corners for every theme (per device) */}
|
||||||
|
<div className="p-3.5 rounded-card border border-border bg-canvas flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="text-xs">
|
||||||
|
<p className="font-semibold text-main">Ecken</p>
|
||||||
|
<p className="text-[11px] text-faint">Abgerundet oder eckig – unabhängig vom Theme. Gilt für dieses Gerät.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center bg-panel-elevated rounded-btn p-0.5 text-xs" role="radiogroup" aria-label="Ecken">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['theme', 'Wie Theme'],
|
||||||
|
['round', 'Rund'],
|
||||||
|
['square', 'Eckig'],
|
||||||
|
] as [CornerStyle, string][]
|
||||||
|
).map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={corners === value}
|
||||||
|
onClick={() => {
|
||||||
|
setCornerStyle(value);
|
||||||
|
setCorners(value);
|
||||||
|
}}
|
||||||
|
className={`h-9 sm:h-7 px-3 rounded-btn transition-colors cursor-pointer ${
|
||||||
|
corners === value ? 'bg-panel text-main font-semibold shadow-sm' : 'text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Light/dark: fixed, or following the system inside the family */}
|
||||||
|
<div className="p-3.5 rounded-card border border-border bg-canvas flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="text-xs">
|
||||||
|
<p className="font-semibold text-main">Hell oder dunkel</p>
|
||||||
|
<p className="text-[11px] text-faint">„Wie System“ wechselt innerhalb der Farbfamilie mit dem Hell-/Dunkelmodus des Geräts.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center bg-panel-elevated rounded-btn p-0.5 text-xs" role="radiogroup" aria-label="Hell oder dunkel">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
[false, 'Selbst wählen'],
|
||||||
|
[true, 'Wie System'],
|
||||||
|
] as [boolean, string][]
|
||||||
|
).map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={followSystem === value}
|
||||||
|
onClick={() => {
|
||||||
|
setFollowSystem(value);
|
||||||
|
setFollowSystemState(value);
|
||||||
|
if (value) onThemeChange(resolveTheme(activeTheme));
|
||||||
|
}}
|
||||||
|
className={`h-9 sm:h-7 px-3 rounded-btn transition-colors cursor-pointer ${
|
||||||
|
followSystem === value ? 'bg-panel text-main font-semibold shadow-sm' : 'text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Frames around buttons */}
|
||||||
|
<div className="p-3.5 rounded-card border border-border bg-canvas flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="text-xs">
|
||||||
|
<p className="font-semibold text-main">Rahmen an Knöpfen</p>
|
||||||
|
<p className="text-[11px] text-faint">Ohne Rahmen sind Knöpfe nur leicht hinterlegt. Gilt für dieses Gerät.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center bg-panel-elevated rounded-btn p-0.5 text-xs" role="radiogroup" aria-label="Rahmen an Knöpfen">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['on', 'Mit Rahmen'],
|
||||||
|
['off', 'Ohne Rahmen'],
|
||||||
|
] as [ButtonBorders, string][]
|
||||||
|
).map(([value, label]) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={buttonBorders === value}
|
||||||
|
onClick={() => {
|
||||||
|
setButtonBorders(value);
|
||||||
|
setButtonBordersState(value);
|
||||||
|
}}
|
||||||
|
className={`h-9 sm:h-7 px-3 rounded-btn transition-colors cursor-pointer ${
|
||||||
|
buttonBorders === value ? 'bg-panel text-main font-semibold shadow-sm' : 'text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Own accent colour on top of the theme */}
|
||||||
|
<div className="p-3.5 rounded-card border border-border bg-canvas space-y-2.5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-main">Akzentfarbe</p>
|
||||||
|
<p className="text-[11px] text-faint">Ersetzt die Akzentfarbe des Themes (Knöpfe, Links, Markierungen). Gilt für dieses Gerät.</p>
|
||||||
|
</div>
|
||||||
|
{accent && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setAccentColor(null);
|
||||||
|
setAccent(null);
|
||||||
|
}}
|
||||||
|
className="btn-action h-9 sm:h-7 px-2.5 text-xs"
|
||||||
|
>
|
||||||
|
Wie Theme
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{['#2563eb', '#0f766e', '#16a34a', '#ca8a04', '#c2410c', '#dc2626', '#db2777', '#8e44c9', '#475569'].map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setAccentColor(c);
|
||||||
|
setAccent(c);
|
||||||
|
}}
|
||||||
|
title={c}
|
||||||
|
aria-label={`Akzentfarbe ${c}`}
|
||||||
|
className={`w-9 h-9 sm:w-7 sm:h-7 rounded-full transition-transform hover:scale-110 cursor-pointer ${
|
||||||
|
accent === c ? 'ring-2 ring-offset-2 ring-offset-canvas ring-main' : ''
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<label className="flex items-center gap-1.5 text-[11px] text-muted cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={accent || '#2563eb'}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAccentColor(e.target.value);
|
||||||
|
setAccent(e.target.value);
|
||||||
|
}}
|
||||||
|
className="w-9 h-9 sm:w-7 sm:h-7 rounded-full border border-border bg-transparent p-0 cursor-pointer"
|
||||||
|
aria-label="Eigene Akzentfarbe wählen"
|
||||||
|
/>
|
||||||
|
Eigene
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{themes.some((t) => t.isCustom) && <p className="font-semibold text-main text-sm pt-1">Eigene Themes</p>}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{themes.map((t) => {
|
{themes.filter((t) => t.isCustom).map((t) => {
|
||||||
const isSelected = activeTheme === t.id;
|
const isSelected = activeTheme === t.id;
|
||||||
const custom = customThemes.find((c) => customThemeId(c) === t.id);
|
const custom = customThemes.find((c) => customThemeId(c) === t.id);
|
||||||
return (
|
return (
|
||||||
@@ -1119,22 +1515,32 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Routing Matrix: Events x (E-Mail / Discord) */}
|
{/* Routing Matrix: Events x (E-Mail / Discord / own webhook) */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const hasDiscordWebhook = Boolean(discordWebhookUrl.trim());
|
const hasDiscordWebhook = Boolean(discordWebhookUrl.trim());
|
||||||
|
const hasCustomWebhook = Boolean(customWebhookUrl.trim());
|
||||||
|
const hasPush = pushDevices.length > 0;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="p-4 bg-canvas border border-border space-y-3">
|
<div className="p-4 bg-canvas border border-border space-y-3">
|
||||||
<div className="grid grid-cols-[1fr_auto_auto] items-center gap-4 pb-2 border-b border-border">
|
<div className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-1 sm:gap-4 pb-2 border-b border-border">
|
||||||
<span className="font-bold text-main">Ereignis</span>
|
<span className="font-bold text-main">Ereignis</span>
|
||||||
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
|
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
|
||||||
<Mail className="w-3.5 h-3.5 text-accent" />
|
<Mail className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>E-Mail</span>
|
<span>E-Mail</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
|
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
|
||||||
<MessageSquare className="w-3.5 h-3.5 text-accent" />
|
<MessageSquare className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Discord</span>
|
<span>Discord</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
|
||||||
|
<Globe className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>Webhook</span>
|
||||||
|
</span>
|
||||||
|
<span className="w-12 sm:w-16 text-center font-semibold text-muted flex flex-col sm:flex-row items-center justify-center gap-0.5 sm:gap-1">
|
||||||
|
<Bell className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>Push</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
@@ -1143,17 +1549,17 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={row.key}
|
key={row.key}
|
||||||
className="grid grid-cols-[1fr_auto_auto] items-center gap-4 py-2.5"
|
className="grid grid-cols-[1fr_auto_auto_auto_auto] items-center gap-1 sm:gap-4 py-2.5"
|
||||||
>
|
>
|
||||||
<span className="font-medium text-main">{row.label}</span>
|
<span className="font-medium text-main">{row.label}</span>
|
||||||
<div className="w-16 flex justify-center">
|
<div className="w-12 sm:w-16 flex justify-center">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={routing.email}
|
checked={routing.email}
|
||||||
onChange={(v) => setEventChannel(row.key, 'email', v)}
|
onChange={(v) => setEventChannel(row.key, 'email', v)}
|
||||||
title={`${row.label} – E-Mail`}
|
title={`${row.label} – E-Mail`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-16 flex justify-center">
|
<div className="w-12 sm:w-16 flex justify-center">
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={routing.discord}
|
checked={routing.discord}
|
||||||
disabled={!hasDiscordWebhook}
|
disabled={!hasDiscordWebhook}
|
||||||
@@ -1165,6 +1571,26 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-12 sm:w-16 flex justify-center">
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={routing.webhook}
|
||||||
|
disabled={!hasCustomWebhook}
|
||||||
|
onChange={(v) => setEventChannel(row.key, 'webhook', v)}
|
||||||
|
title={
|
||||||
|
hasCustomWebhook
|
||||||
|
? `${row.label} – Webhook`
|
||||||
|
: 'Bitte zuerst unten eine eigene Webhook URL eintragen'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-12 sm:w-16 flex justify-center">
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={routing.push}
|
||||||
|
disabled={!hasPush}
|
||||||
|
onChange={(v) => setEventChannel(row.key, 'push', v)}
|
||||||
|
title={hasPush ? `${row.label} – Push` : 'Bitte zuerst unten Push auf einem Gerät einschalten'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1178,6 +1604,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist.
|
Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{!hasCustomWebhook && (
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Webhook-Schalter werden aktiv, sobald unten eine eigene Webhook-URL eingetragen ist.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Daily Summary */}
|
{/* Daily Summary */}
|
||||||
@@ -1218,8 +1649,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="flex items-center gap-5">
|
{/* Phones: 2×2 grid, the four switches do not fit in one row */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="grid grid-cols-2 gap-x-6 gap-y-3 w-full sm:w-auto sm:flex sm:items-center sm:gap-5">
|
||||||
|
<div className="flex items-center justify-between sm:justify-start gap-2">
|
||||||
<span className="text-xs text-muted">E-Mail</span>
|
<span className="text-xs text-muted">E-Mail</span>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={notificationSettings.dailySummary.email}
|
checked={notificationSettings.dailySummary.email}
|
||||||
@@ -1232,7 +1664,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
title="Tageszusammenfassung – E-Mail"
|
title="Tageszusammenfassung – E-Mail"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between sm:justify-start gap-2">
|
||||||
<span className="text-xs text-muted">Discord</span>
|
<span className="text-xs text-muted">Discord</span>
|
||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
checked={notificationSettings.dailySummary.discord}
|
checked={notificationSettings.dailySummary.discord}
|
||||||
@@ -1250,6 +1682,38 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center justify-between sm:justify-start gap-2">
|
||||||
|
<span className="text-xs text-muted">Webhook</span>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={notificationSettings.dailySummary.webhook}
|
||||||
|
disabled={!hasCustomWebhook}
|
||||||
|
onChange={(v) =>
|
||||||
|
setNotificationSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
dailySummary: { ...prev.dailySummary, webhook: v },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
hasCustomWebhook
|
||||||
|
? 'Tageszusammenfassung – Webhook'
|
||||||
|
: 'Bitte zuerst unten eine eigene Webhook URL eintragen'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between sm:justify-start gap-2">
|
||||||
|
<span className="text-xs text-muted">Push</span>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={notificationSettings.dailySummary.push}
|
||||||
|
disabled={!hasPush}
|
||||||
|
onChange={(v) =>
|
||||||
|
setNotificationSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
dailySummary: { ...prev.dailySummary, push: v },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
title={hasPush ? 'Tageszusammenfassung – Push' : 'Bitte zuerst unten Push auf einem Gerät einschalten'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1359,17 +1823,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
{/* Channel 3: Custom HTTP Webhook */}
|
{/* Channel 3: Custom HTTP Webhook */}
|
||||||
<div
|
<div
|
||||||
className={`p-4 border transition-colors space-y-3 bg-canvas ${
|
className={`p-4 border transition-colors space-y-3 bg-canvas ${
|
||||||
alarmChannels.includes('webhook') ? 'border-accent' : 'border-border'
|
customWebhookUrl.trim() ? 'border-accent' : 'border-border'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer select-none">
|
<div className="flex items-center gap-2.5">
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={alarmChannels.includes('webhook')}
|
|
||||||
onChange={() => toggleAlarmChannel('webhook')}
|
|
||||||
className="w-4 h-4 accent-blue-600"
|
|
||||||
/>
|
|
||||||
<Globe className="w-4 h-4 text-accent" />
|
<Globe className="w-4 h-4 text-accent" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-bold text-main block">
|
<span className="font-bold text-main block">
|
||||||
@@ -1377,10 +1835,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-muted">
|
<span className="text-[11px] text-muted">
|
||||||
Für eigene Empfänger-Skripte, Ntfy, Gotify, Slack, Mattermost oder
|
Für eigene Empfänger-Skripte, Ntfy, Gotify, Slack, Mattermost oder
|
||||||
Automatisierungen.
|
Automatisierungen. Welche Ereignisse hierher gehen, stellst du oben in der Spalte „Webhook“ ein.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1407,6 +1865,75 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Web push: this browser or installed app */}
|
||||||
|
<div className={`p-4 border transition-colors space-y-3 bg-canvas ${pushHere ? 'border-accent' : 'border-border'}`}>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
|
<Bell className="w-4 h-4 text-accent shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="font-bold text-main block">Ziel: Push-Benachrichtigungen</span>
|
||||||
|
<span className="text-[11px] text-muted">
|
||||||
|
Systembenachrichtigung auf diesem Gerät, auch wenn MindSift geschlossen ist. Auf iPhone und
|
||||||
|
iPad nur, wenn MindSift zum Home-Bildschirm hinzugefügt wurde.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="text-xs text-muted">Dieses Gerät</span>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={pushHere}
|
||||||
|
disabled={pushBusy || demoMode || !pushSupported()}
|
||||||
|
onChange={(v) => void togglePushHere(v)}
|
||||||
|
title={pushSupported() ? 'Push auf diesem Gerät' : 'Dieser Browser unterstützt keine Push-Benachrichtigungen'}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void sendTestPush()}
|
||||||
|
disabled={pushBusy || demoMode || pushDevices.length === 0}
|
||||||
|
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Send className="w-3 h-3 text-accent" />
|
||||||
|
<span>Test senden</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pushDevices.length > 0 && (
|
||||||
|
<div className="pl-6 space-y-1">
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Aktiv auf {pushDevices.length} {pushDevices.length === 1 ? 'Gerät' : 'Geräten'}. Alte Installationen
|
||||||
|
verschwinden beim nächsten Push von selbst oder lassen sich hier entfernen.
|
||||||
|
</p>
|
||||||
|
<ul className="border border-border divide-y divide-border bg-panel">
|
||||||
|
{pushDevices.map((d) => (
|
||||||
|
<li key={d.id} className="flex items-center justify-between gap-2 pl-2.5 pr-1 py-1">
|
||||||
|
<span className="min-w-0 text-[11px] text-main truncate">
|
||||||
|
{d.device || 'Gerät'}
|
||||||
|
<span className="text-faint">
|
||||||
|
{' · seit '}
|
||||||
|
{new Date(d.createdAt).toLocaleDateString('de-DE')}
|
||||||
|
{d.endpoint === pushEndpointHere && ' · dieses Gerät'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void removeDevice(d.endpoint)}
|
||||||
|
disabled={pushBusy || demoMode}
|
||||||
|
title="Gerät entfernen"
|
||||||
|
aria-label={`${d.device || 'Gerät'} entfernen`}
|
||||||
|
className="h-9 w-9 sm:h-7 sm:w-7 shrink-0 flex items-center justify-center text-faint hover:text-status-err disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pushMsg && (
|
||||||
|
<p className={`pl-6 text-[11px] ${pushMsg.type === 'ok' ? 'text-status-ok' : 'text-status-err'}`}>{pushMsg.text}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 border-t border-border flex justify-end">
|
<div className="pt-2 border-t border-border flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -1421,25 +1948,135 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ================================================================= */}
|
{/* ================================================================= */}
|
||||||
{/* SECTION 4: PRIVATE WEBHOOKS & EXPORT */}
|
{/* SECTION 4: API (KEYS, CURL / SMART HOME, AI AGENTS) */}
|
||||||
{/* ================================================================= */}
|
{/* ================================================================= */}
|
||||||
{section === 'webhooks' && (
|
{section === 'webhooks' && (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div className="border-b border-border pb-3">
|
<div className="border-b border-border pb-3">
|
||||||
<h2 className="text-sm font-bold text-main">
|
<h2 className="text-sm font-bold text-main">API</h2>
|
||||||
Private Webhooks (Eingehend) & Daten-Export
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted mt-0.5">
|
<p className="text-muted mt-0.5">
|
||||||
Erstelle persönliche API-Schlüssel, um aus externen Skripten, Server-Monitoring
|
Mit persönlichen API-Schlüsseln lesen und ändern Skripte, dein Smart Home oder KI-Agenten wie Claude Code
|
||||||
oder Discord-Bots automatisch Aufgaben und Gedanken in dein MindSift einzuspeisen.
|
und Codex deine Aufgaben, Listen und die Inbox: per curl oder über MCP, mit denselben Rechten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ApiKeysPanel />
|
<ApiKeysPanel />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="pt-4 border-t border-border">
|
{/* ================================================================= */}
|
||||||
<ServerBackupsCard />
|
{/* SECTION: SPRACHEINGABE */}
|
||||||
|
{/* ================================================================= */}
|
||||||
|
{section === 'voice' && voiceChoice && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="border-b border-border pb-3">
|
||||||
|
<h2 className="text-sm font-bold text-main">Spracheingabe</h2>
|
||||||
|
<p className="text-muted mt-0.5">Womit das Mikrofon in MindSift deine Sprache in Text umwandelt.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2" role="radiogroup" aria-label="Spracheingabe">
|
||||||
|
{([
|
||||||
|
{
|
||||||
|
value: '' as const,
|
||||||
|
title: 'Whisper-Server (Standard)',
|
||||||
|
text: 'Die Aufnahme geht nur an den Spracherkennungs-Server dieser MindSift-Installation. Funktioniert in jedem Browser.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'browser' as const,
|
||||||
|
title: 'Browser',
|
||||||
|
text: 'Die Spracherkennung des Browsers. Der Text erscheint sofort beim Sprechen; Chrome und Edge schicken die Aufnahme dafür an Google bzw. Microsoft. In Firefox gibt es sie nicht.',
|
||||||
|
},
|
||||||
|
]).map((opt) => {
|
||||||
|
const selected = (currentUser.voiceInput || '') === opt.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value || 'server'}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
disabled={voiceSaving}
|
||||||
|
onClick={() => !selected && void handleChangeVoiceInput(opt.value)}
|
||||||
|
className={`w-full text-left p-3 rounded-card border flex items-start gap-3 transition-colors disabled:opacity-60 ${
|
||||||
|
selected ? 'border-accent bg-accent/10' : 'border-border bg-canvas hover:bg-panel-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`mt-0.5 w-4 h-4 rounded-full border-2 flex-shrink-0 ${selected ? 'border-accent bg-accent' : 'border-strong'}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="space-y-0.5">
|
||||||
|
<span className="block font-semibold text-main">{opt.title}</span>
|
||||||
|
<span className="block text-muted leading-relaxed">{opt.text}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ================================================================= */}
|
||||||
|
{/* SECTION: AUFBEWAHRUNG */}
|
||||||
|
{/* ================================================================= */}
|
||||||
|
{section === 'retention' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="border-b border-border pb-3">
|
||||||
|
<h2 className="text-sm font-bold text-main">Aufbewahrung</h2>
|
||||||
|
<p className="text-muted mt-0.5">
|
||||||
|
Wie lange Erledigtes und Gelöschtes bleibt, gezählt ab dem Abhaken bzw. Löschen. In geteilten Aufgabenlisten,
|
||||||
|
Listen und Notizen gilt die Einstellung der Person, der sie gehören.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{retentionError && (
|
||||||
|
<div className="alert-err flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
|
||||||
|
<span>{retentionError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{currentUser.retention && (
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{RETENTION_AREAS.map((area) => (
|
||||||
|
<div key={area.key} className="py-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold text-main">{area.title}</p>
|
||||||
|
<p className="text-muted">{area.text}</p>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<span className="text-muted">{area.verb}</span>
|
||||||
|
<select
|
||||||
|
value={currentUser.retention![area.key]}
|
||||||
|
disabled={retentionSaving}
|
||||||
|
onChange={(e) => handleChangeRetention(area.key, Number(e.target.value))}
|
||||||
|
className="h-11 sm:h-8 px-2 input-field text-base sm:text-xs bg-panel"
|
||||||
|
aria-label={`${area.title}: ${area.verb}`}
|
||||||
|
>
|
||||||
|
{RETENTION_OPTIONS.filter((o) => o.days > 0 || currentUser.retentionNeverAllowed !== false).map((o) => (
|
||||||
|
<option key={o.days} value={o.days}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ================================================================= */}
|
||||||
|
{/* SECTION 5: EXPORT & IMPORT */}
|
||||||
|
{/* ================================================================= */}
|
||||||
|
{section === 'data' && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="border-b border-border pb-3">
|
||||||
|
<h2 className="text-sm font-bold text-main">Export & Import</h2>
|
||||||
|
<p className="text-muted mt-0.5">
|
||||||
|
Lade deine Daten als Backup oder Markdown-Archiv herunter, spiele Server-Backups zurück oder importiere
|
||||||
|
MindSift-Daten und Obsidian-Vaults.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ServerBackupsCard />
|
||||||
|
|
||||||
{/* Full Data Backup */}
|
{/* Full Data Backup */}
|
||||||
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
import { render, screen, fireEvent, act, within } from '@testing-library/react';
|
||||||
|
|
||||||
const createAPIKey = vi.fn(async () => ({ id: 'k1', userId: 'u', name: 'HA', keyPrefix: 'mindsift_sec_ab', scopes: 'webhook:triage,webhook:list', createdAt: '' }));
|
const createAPIKey = vi.fn(async () => ({ id: 'k1', userId: 'u', name: 'HA', keyPrefix: 'mindsift_sec_ab', scopes: 'webhook:triage,webhook:list', createdAt: '' }));
|
||||||
|
const updateAPIKey = vi.fn(async () => ({}));
|
||||||
vi.mock('../../../lib/api', () => ({
|
vi.mock('../../../lib/api', () => ({
|
||||||
api: {
|
api: {
|
||||||
getAPIKeys: vi.fn(async () => [
|
getAPIKeys: vi.fn(async () => [
|
||||||
@@ -11,6 +12,7 @@ vi.mock('../../../lib/api', () => ({
|
|||||||
getTaskLists: vi.fn(async () => []),
|
getTaskLists: vi.fn(async () => []),
|
||||||
createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])),
|
createAPIKey: (...args: unknown[]) => createAPIKey(...(args as [])),
|
||||||
deleteAPIKey: vi.fn(),
|
deleteAPIKey: vi.fn(),
|
||||||
|
updateAPIKey: (...args: unknown[]) => updateAPIKey(...(args as [])),
|
||||||
testWebhookIngest: vi.fn(),
|
testWebhookIngest: vi.fn(),
|
||||||
},
|
},
|
||||||
triggerHaptic: vi.fn(),
|
triggerHaptic: vi.fn(),
|
||||||
@@ -21,21 +23,49 @@ import { ApiKeysPanel } from '../ApiKeysPanel';
|
|||||||
describe('ApiKeysPanel', () => {
|
describe('ApiKeysPanel', () => {
|
||||||
beforeEach(() => createAPIKey.mockClear());
|
beforeEach(() => createAPIKey.mockClear());
|
||||||
|
|
||||||
it('shows the scopes of existing keys as badges', async () => {
|
it('shows the rights of existing keys as badges', async () => {
|
||||||
render(<ApiKeysPanel />);
|
render(<ApiKeysPanel />);
|
||||||
expect(await screen.findByText('Rauchmelder')).toBeTruthy();
|
expect(await screen.findByText('Rauchmelder')).toBeTruthy();
|
||||||
expect(screen.getAllByText('📋 Aufgaben').length).toBeGreaterThan(1); // chip + badge
|
expect(screen.getByText('Aufgaben anlegen')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Inbox')).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a key with the chosen scopes and never without one', async () => {
|
it('creates a key with the chosen scopes and never without one', async () => {
|
||||||
render(<ApiKeysPanel />);
|
render(<ApiKeysPanel />);
|
||||||
const triage = screen.getByRole('checkbox', { name: /Triage/ });
|
const triage = screen.getByRole('switch', { name: 'Inbox' });
|
||||||
fireEvent.click(triage); // last remaining scope cannot be switched off
|
fireEvent.click(triage); // last remaining scope cannot be switched off
|
||||||
expect(triage.getAttribute('aria-checked')).toBe('true');
|
expect(triage.getAttribute('aria-checked')).toBe('true');
|
||||||
fireEvent.click(screen.getByRole('checkbox', { name: /Listeneinträge/ }));
|
fireEvent.click(screen.getByRole('switch', { name: 'Listeneinträge hinzufügen' }));
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.click(screen.getByRole('button', { name: /API-Key erzeugen/ }));
|
fireEvent.click(screen.getByRole('button', { name: /API-Key erzeugen/ }));
|
||||||
});
|
});
|
||||||
expect(createAPIKey).toHaveBeenCalledWith('Home Assistant', ['webhook:triage', 'webhook:list']);
|
expect(createAPIKey).toHaveBeenCalledWith('Home Assistant', ['webhook:triage', 'webhook:list']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows curl for scripts and, in the second tab, how to connect an MCP client', async () => {
|
||||||
|
render(<ApiKeysPanel />);
|
||||||
|
fireEvent.click(screen.getByRole('switch', { name: 'Aufgaben ändern' }));
|
||||||
|
expect(screen.getByRole('switch', { name: 'Aufgaben ändern' }).getAttribute('aria-checked')).toBe('true');
|
||||||
|
expect(screen.getAllByText(/tasks\?due=today&status=open/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.queryByText(/claude mcp add/)).toBeNull();
|
||||||
|
fireEvent.click(screen.getByRole('tab', { name: /KI-Agenten/ }));
|
||||||
|
expect(screen.getByText(/claude mcp add --transport http mindsift/)).toBeTruthy();
|
||||||
|
fireEvent.click(screen.getByRole('radio', { name: 'OpenAI Codex' }));
|
||||||
|
expect(screen.getByText(/\[mcp_servers\.mindsift\]/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renames a key and changes its rights', async () => {
|
||||||
|
render(<ApiKeysPanel />);
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: 'API-Key Rauchmelder bearbeiten' }));
|
||||||
|
fireEvent.change(screen.getByLabelText('Name des Schlüssels'), { target: { value: 'Rauchmelder Flur' } });
|
||||||
|
const group = screen.getByRole('group', { name: 'Berechtigungen von Rauchmelder' });
|
||||||
|
fireEvent.click(within(group).getByRole('switch', { name: 'Aufgaben lesen' }));
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
|
||||||
|
});
|
||||||
|
expect(updateAPIKey).toHaveBeenCalledWith('k0', {
|
||||||
|
name: 'Rauchmelder Flur',
|
||||||
|
scopes: ['webhook:triage', 'webhook:task', 'tasks:read'],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { SectionPicker } from '../SectionPicker';
|
||||||
|
|
||||||
|
describe('SectionPicker', () => {
|
||||||
|
it('uses switches and keeps at least one section on', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(<SectionPicker value={['notes']} onChange={onChange} />);
|
||||||
|
const notes = screen.getByRole('switch', { name: 'Notizen' });
|
||||||
|
expect(notes.getAttribute('aria-checked')).toBe('true');
|
||||||
|
fireEvent.click(notes); // last one cannot be switched off
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
fireEvent.click(screen.getByRole('switch', { name: 'Listen' }));
|
||||||
|
expect(onChange).toHaveBeenCalledWith(['notes', 'lists']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ vi.mock('../../../lib/api', async () => {
|
|||||||
...actual.api,
|
...actual.api,
|
||||||
getMe: vi.fn(async () => ({ isLastAdmin: false })),
|
getMe: vi.fn(async () => ({ isLastAdmin: false })),
|
||||||
getAuthStatus: vi.fn(async () => ({ demoMode: false })),
|
getAuthStatus: vi.fn(async () => ({ demoMode: false })),
|
||||||
|
getPushDevices: vi.fn(async () => []),
|
||||||
updateSettings: vi.fn(async (patch) => ({ ...baseUser, ...patch })),
|
updateSettings: vi.fn(async (patch) => ({ ...baseUser, ...patch })),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -88,19 +89,54 @@ describe('UserSettingsView notification matrix', () => {
|
|||||||
paused: true,
|
paused: true,
|
||||||
includeOwnWatched: true,
|
includeOwnWatched: true,
|
||||||
events: {
|
events: {
|
||||||
due: { email: true, discord: true },
|
due: { email: true, discord: true, webhook: false, push: false },
|
||||||
shared: { email: true, discord: false },
|
shared: { email: true, discord: false, webhook: false, push: false },
|
||||||
taskDone: { email: true, discord: false },
|
taskDone: { email: true, discord: false, webhook: false, push: false },
|
||||||
watched: { email: true, discord: false },
|
watched: { email: true, discord: false, webhook: false, push: false },
|
||||||
},
|
},
|
||||||
dailySummary: {
|
dailySummary: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
time: '08:30',
|
time: '08:30',
|
||||||
email: true,
|
email: true,
|
||||||
discord: true,
|
discord: true,
|
||||||
|
webhook: false,
|
||||||
|
push: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('enables the webhook column once an own webhook URL is entered and saves it as due channel', async () => {
|
||||||
|
render(
|
||||||
|
<UserSettingsView
|
||||||
|
currentUser={baseUser}
|
||||||
|
activeTheme="dark"
|
||||||
|
themes={[]}
|
||||||
|
customThemes={[]}
|
||||||
|
onThemeChange={() => {}}
|
||||||
|
onCustomThemesChange={() => {}}
|
||||||
|
onUserUpdated={() => {}}
|
||||||
|
onBack={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('Alarme'));
|
||||||
|
const off = screen.getAllByTitle('Bitte zuerst unten eine eigene Webhook URL eintragen');
|
||||||
|
expect(off.length).toBe(5);
|
||||||
|
expect((off[0] as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/dein-server\.local/), {
|
||||||
|
target: { value: 'https://hooks.example.com/mindsift' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTitle('Fällige Aufgaben (sofort) – Webhook'));
|
||||||
|
fireEvent.click(screen.getByTitle('Freigabe erhalten – Webhook'));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Alarm-Einstellungen speichern' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.updateSettings).toHaveBeenCalledTimes(1));
|
||||||
|
const patch = vi.mocked(api.updateSettings).mock.calls[0][0];
|
||||||
|
expect(patch.customWebhookUrl).toBe('https://hooks.example.com/mindsift');
|
||||||
|
expect(patch.alarmChannels).toEqual(['email', 'webhook']);
|
||||||
|
expect(patch.notificationSettings?.events.due.webhook).toBe(true);
|
||||||
|
expect(patch.notificationSettings?.events.shared.webhook).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -28,6 +28,33 @@ export const DEFAULT_KANBAN_COLUMNS: KanbanColumn[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Wrapper and content split so the hooks always run in the same number (see TaskDetailModal)
|
// Wrapper and content split so the hooks always run in the same number (see TaskDetailModal)
|
||||||
|
// Colours a column can get; all come from the theme (blue and purple via --color-info / --color-purple)
|
||||||
|
export const COLUMN_COLORS: { id: string; label: string; cls: string }[] = [
|
||||||
|
{ id: 'gray', label: 'Grau', cls: 'bg-faint' },
|
||||||
|
{ id: 'slate', label: 'Anthrazit', cls: 'bg-main' },
|
||||||
|
{ id: 'green', label: 'Grün', cls: 'bg-status-ok' },
|
||||||
|
{ id: 'yellow', label: 'Gelb', cls: 'bg-status-warn' },
|
||||||
|
{ id: 'blue', label: 'Blau', cls: 'bg-tone-blue' },
|
||||||
|
{ id: 'red', label: 'Rot', cls: 'bg-status-err' },
|
||||||
|
{ id: 'purple', label: 'Lila', cls: 'bg-tone-purple' },
|
||||||
|
{ id: 'accent', label: 'Akzentfarbe', cls: 'bg-accent' },
|
||||||
|
];
|
||||||
|
// Names the server's default columns use
|
||||||
|
const COLOR_ALIASES: Record<string, string> = { emerald: 'green', amber: 'yellow' };
|
||||||
|
|
||||||
|
const columnColorId = (col: KanbanColumn): string => {
|
||||||
|
if (col.color) return COLOR_ALIASES[col.color] || col.color;
|
||||||
|
// Columns without a colour (older boards) keep the colour of their role
|
||||||
|
if (col.isDone) return 'green';
|
||||||
|
if (col.id === 'in_progress') return 'accent';
|
||||||
|
if (col.id === 'waiting') return 'yellow';
|
||||||
|
return 'gray';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dot colour of a status column (Kanban header, list view, task detail)
|
||||||
|
export const columnDotClass = (col: KanbanColumn | undefined): string =>
|
||||||
|
(col && COLUMN_COLORS.find((c) => c.id === columnColorId(col))?.cls) || 'bg-faint';
|
||||||
|
|
||||||
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = (props) =>
|
export const ManageColumnsModal: React.FC<ManageColumnsModalProps> = (props) =>
|
||||||
props.isOpen ? <ManageColumnsModalContent {...props} /> : null;
|
props.isOpen ? <ManageColumnsModalContent {...props} /> : null;
|
||||||
|
|
||||||
@@ -44,6 +71,44 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
|
|||||||
const [newColName, setNewColName] = useState('');
|
const [newColName, setNewColName] = useState('');
|
||||||
const [newColIsDone, setNewColIsDone] = useState(false);
|
const [newColIsDone, setNewColIsDone] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
// Colour menu: opens at the column's dot like a context menu (fixed, so the scrolling list cannot clip it)
|
||||||
|
const [colorMenu, setColorMenu] = useState<{ id: string; left: number; top?: number; bottom?: number } | null>(null);
|
||||||
|
const colorMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!colorMenu) return;
|
||||||
|
const close = (e: Event) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (colorMenuRef.current?.contains(target) || target.closest?.('[data-color-trigger]')) return;
|
||||||
|
setColorMenu(null);
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setColorMenu(null);
|
||||||
|
const dismiss = () => setColorMenu(null);
|
||||||
|
document.addEventListener('pointerdown', close);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
window.addEventListener('resize', dismiss);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', close);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
window.removeEventListener('resize', dismiss);
|
||||||
|
};
|
||||||
|
}, [colorMenu]);
|
||||||
|
|
||||||
|
const openColorMenu = (id: string, trigger: HTMLElement) => {
|
||||||
|
if (colorMenu?.id === id) {
|
||||||
|
setColorMenu(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = trigger.getBoundingClientRect();
|
||||||
|
const menuHeight = COLUMN_COLORS.length * 36 + 12;
|
||||||
|
const left = Math.min(rect.left, window.innerWidth - 200);
|
||||||
|
// Below the dot if there is room, else above it
|
||||||
|
setColorMenu(
|
||||||
|
rect.bottom + menuHeight + 8 < window.innerHeight
|
||||||
|
? { id, left, top: rect.bottom + 4 }
|
||||||
|
: { id, left, bottom: window.innerHeight - rect.top + 4 }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setColumns(initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS);
|
setColumns(initialColumns && initialColumns.length > 0 ? initialColumns : DEFAULT_KANBAN_COLUMNS);
|
||||||
@@ -66,6 +131,12 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleColor = (id: string, color: string) => {
|
||||||
|
triggerHaptic(10);
|
||||||
|
setColumns((prev) => prev.map((c) => (c.id === id ? { ...c, color } : c)));
|
||||||
|
setColorMenu(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleDone = (id: string) => {
|
const handleToggleDone = (id: string) => {
|
||||||
triggerHaptic(10);
|
triggerHaptic(10);
|
||||||
setColumns((prev) =>
|
setColumns((prev) =>
|
||||||
@@ -160,65 +231,80 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
|
|||||||
{/* Columns List */}
|
{/* Columns List */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-xs font-semibold text-main">Aktive Spalten ({columns.length}):</label>
|
<label className="block text-xs font-semibold text-main">Aktive Spalten ({columns.length}):</label>
|
||||||
<div className="space-y-1.5 max-h-60 overflow-y-auto pr-1">
|
<div className="space-y-1.5 max-h-[45vh] overflow-y-auto pr-1" onScroll={() => setColorMenu(null)}>
|
||||||
{columns.map((col, idx) => (
|
{columns.map((col, idx) => (
|
||||||
<div
|
<div key={col.id} className="p-2 bg-canvas border border-border rounded-card space-y-2">
|
||||||
key={col.id}
|
<div className="flex items-center gap-2">
|
||||||
className="p-2 bg-canvas border border-border rounded-card flex items-center gap-2"
|
<div className="flex flex-col gap-0.5">
|
||||||
>
|
<button
|
||||||
<div className="flex flex-col gap-0.5">
|
type="button"
|
||||||
|
disabled={idx === 0}
|
||||||
|
onClick={() => handleMove(idx, 'up')}
|
||||||
|
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
|
||||||
|
title="Nach oben"
|
||||||
|
>
|
||||||
|
<ArrowUp className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={idx === columns.length - 1}
|
||||||
|
onClick={() => handleMove(idx, 'down')}
|
||||||
|
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
|
||||||
|
title="Nach unten"
|
||||||
|
>
|
||||||
|
<ArrowDown className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={idx === 0}
|
data-color-trigger
|
||||||
onClick={() => handleMove(idx, 'up')}
|
onClick={(e) => openColorMenu(col.id, e.currentTarget)}
|
||||||
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
|
className="w-7 h-7 flex items-center justify-center rounded-btn border border-border bg-panel hover:border-strong flex-shrink-0"
|
||||||
title="Nach oben"
|
title="Farbe der Spalte"
|
||||||
|
aria-label={`Farbe der Spalte ${col.name}`}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={colorMenu?.id === col.id}
|
||||||
>
|
>
|
||||||
<ArrowUp className="w-3 h-3" />
|
<span className={`w-3 h-3 rounded-full ${columnDotClass(col)}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={col.name}
|
||||||
|
onChange={(e) => handleRename(col.id, e.target.value)}
|
||||||
|
className="flex-1 min-w-0 h-7 px-2 text-xs input-field"
|
||||||
|
placeholder="Spaltenname"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* IsDone Toggle */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={idx === columns.length - 1}
|
onClick={() => handleToggleDone(col.id)}
|
||||||
onClick={() => handleMove(idx, 'down')}
|
className={`h-7 px-2 text-[10px] rounded-btn border transition-colors flex items-center gap-1 flex-shrink-0 ${
|
||||||
className="text-muted hover:text-main disabled:opacity-20 p-0.5"
|
col.isDone
|
||||||
title="Nach unten"
|
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
|
||||||
|
: 'bg-panel border-border text-faint hover:text-muted'
|
||||||
|
}`}
|
||||||
|
title={col.isDone ? 'Gilt als Erledigt' : 'Gilt als Offen'}
|
||||||
|
aria-label={col.isDone ? 'Gilt als Erledigt' : 'Gilt als Offen'}
|
||||||
|
aria-pressed={!!col.isDone}
|
||||||
>
|
>
|
||||||
<ArrowDown className="w-3 h-3" />
|
<Check className={`w-2.5 h-2.5 ${col.isDone ? 'stroke-[3]' : 'opacity-40'}`} />
|
||||||
|
{/* Phones: only the tick, so the row keeps room for the bin */}
|
||||||
|
<span className="hidden sm:inline">Erledigt</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(col.id)}
|
||||||
|
disabled={columns.length <= 1}
|
||||||
|
className="text-muted hover:text-status-err disabled:opacity-20 p-1 transition-colors"
|
||||||
|
title="Spalte löschen"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={col.name}
|
|
||||||
onChange={(e) => handleRename(col.id, e.target.value)}
|
|
||||||
className="flex-1 h-7 px-2 text-xs input-field"
|
|
||||||
placeholder="Spaltenname"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* IsDone Toggle */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleToggleDone(col.id)}
|
|
||||||
className={`h-7 px-2 text-[10px] rounded-btn border transition-colors flex items-center gap-1 ${
|
|
||||||
col.isDone
|
|
||||||
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
|
|
||||||
: 'bg-panel border-border text-faint hover:text-muted'
|
|
||||||
}`}
|
|
||||||
title={col.isDone ? 'Gilt als Erledigt' : 'Gilt als Offen'}
|
|
||||||
>
|
|
||||||
<Check className={`w-2.5 h-2.5 ${col.isDone ? 'stroke-[3]' : 'opacity-40'}`} />
|
|
||||||
<span>Erledigt</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleDelete(col.id)}
|
|
||||||
disabled={columns.length <= 1}
|
|
||||||
className="text-muted hover:text-status-err disabled:opacity-20 p-1 transition-colors"
|
|
||||||
title="Spalte löschen"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -227,35 +313,38 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
|
|||||||
{/* Add New Column Form */}
|
{/* Add New Column Form */}
|
||||||
<form onSubmit={handleAddColumn} className="space-y-2 pt-2 border-t border-border">
|
<form onSubmit={handleAddColumn} className="space-y-2 pt-2 border-t border-border">
|
||||||
<label className="block text-xs font-semibold text-main">Neue Spalte hinzufügen:</label>
|
<label className="block text-xs font-semibold text-main">Neue Spalte hinzufügen:</label>
|
||||||
<div className="flex items-center gap-1.5">
|
{/* Phones: the buttons go below the field, so nothing is cut off */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-1.5">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newColName}
|
value={newColName}
|
||||||
onChange={(e) => setNewColName(e.target.value)}
|
onChange={(e) => setNewColName(e.target.value)}
|
||||||
placeholder="z.B. Wartet auf Feedback, Review…"
|
placeholder="z.B. Wartet auf Feedback, Review…"
|
||||||
className="flex-1 h-8 px-2.5 text-xs input-field"
|
className="w-full sm:w-auto sm:flex-1 min-w-0 h-11 sm:h-8 px-2.5 text-base sm:text-xs input-field"
|
||||||
/>
|
/>
|
||||||
<button
|
<div className="flex items-center gap-1.5">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setNewColIsDone((prev) => !prev)}
|
type="button"
|
||||||
className={`h-8 px-2.5 text-xs rounded-btn border transition-colors flex items-center gap-1 ${
|
onClick={() => setNewColIsDone((prev) => !prev)}
|
||||||
newColIsDone
|
className={`flex-1 sm:flex-none justify-center h-11 sm:h-8 px-2.5 text-xs rounded-btn border transition-colors flex items-center gap-1 ${
|
||||||
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
|
newColIsDone
|
||||||
: 'bg-panel border-border text-muted hover:text-main'
|
? 'bg-status-ok/15 border-status-ok text-status-ok font-semibold'
|
||||||
}`}
|
: 'bg-panel border-border text-muted hover:text-main'
|
||||||
title="Sollen Aufgaben in dieser Spalte als erledigt gelten?"
|
}`}
|
||||||
>
|
title="Sollen Aufgaben in dieser Spalte als erledigt gelten?"
|
||||||
<Check className="w-3 h-3" />
|
>
|
||||||
<span>Erledigt</span>
|
<Check className="w-3 h-3" />
|
||||||
</button>
|
<span>Erledigt</span>
|
||||||
<button
|
</button>
|
||||||
type="submit"
|
<button
|
||||||
disabled={!newColName.trim()}
|
type="submit"
|
||||||
className="btn-primary h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-40"
|
disabled={!newColName.trim()}
|
||||||
>
|
className="flex-1 sm:flex-none justify-center btn-primary h-11 sm:h-8 px-3 text-xs flex items-center gap-1 disabled:opacity-40"
|
||||||
<Plus className="w-3.5 h-3.5" />
|
>
|
||||||
<span>Hinzufügen</span>
|
<Plus className="w-3.5 h-3.5" />
|
||||||
</button>
|
<span>Hinzufügen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -291,6 +380,42 @@ const ManageColumnsModalContent: React.FC<ManageColumnsModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{colorMenu &&
|
||||||
|
(() => {
|
||||||
|
const col = columns.find((c) => c.id === colorMenu.id);
|
||||||
|
if (!col) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={colorMenuRef}
|
||||||
|
role="menu"
|
||||||
|
aria-label={`Farbe der Spalte ${col.name}`}
|
||||||
|
style={{ left: colorMenu.left, top: colorMenu.top, bottom: colorMenu.bottom }}
|
||||||
|
className="fixed z-[60] w-44 p-1 bg-panel border border-border rounded-card shadow-panel"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{COLUMN_COLORS.map((c) => {
|
||||||
|
const active = columnColorId(col) === c.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked={active}
|
||||||
|
onClick={() => handleColor(col.id, c.id)}
|
||||||
|
className={`w-full h-11 sm:h-8 px-2 rounded-btn text-xs flex items-center gap-2 text-left transition-colors ${
|
||||||
|
active ? 'bg-accent/10 text-main' : 'text-muted hover:text-main hover:bg-panel-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`w-3 h-3 rounded-full flex-shrink-0 ${c.cls}`} />
|
||||||
|
<span className="flex-1">{c.label}</span>
|
||||||
|
{active && <Check className="w-3 h-3 text-accent stroke-[3]" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
Check,
|
Check,
|
||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Flame,
|
Flame,
|
||||||
Folder,
|
Folder,
|
||||||
Webhook,
|
|
||||||
Lock,
|
Lock,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
Repeat,
|
Repeat,
|
||||||
@@ -20,9 +19,12 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
|
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
|
||||||
import { UserAvatar } from '../../components/UserAvatar';
|
import { UserAvatar } from '../../components/UserAvatar';
|
||||||
|
import { OriginBadge, originDetails } from '../../components/OriginBadge';
|
||||||
|
import { columnDotClass } from './ManageColumnsModal';
|
||||||
import { UserAutocomplete } from '../UserAutocomplete';
|
import { UserAutocomplete } from '../UserAutocomplete';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
import { MarkdownViewer } from '../../lib/markdown';
|
import { MarkdownViewer } from '../../lib/markdown';
|
||||||
|
import { VoiceInputButton } from '../../components/VoiceInputButton';
|
||||||
import { addDays, joinDue, splitDue } from '../../lib/due';
|
import { addDays, joinDue, splitDue } from '../../lib/due';
|
||||||
|
|
||||||
interface TaskDetailModalProps {
|
interface TaskDetailModalProps {
|
||||||
@@ -89,6 +91,11 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
el.style.height = 'auto';
|
el.style.height = 'auto';
|
||||||
el.style.height = `${el.scrollHeight + 2}px`;
|
el.style.height = `${el.scrollHeight + 2}px`;
|
||||||
}, []);
|
}, []);
|
||||||
|
// Dictated text changes the draft without an input event, so the field grows here as well
|
||||||
|
const notesRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditingNotes) growNotes(notesRef.current);
|
||||||
|
}, [notesDraft, isEditingNotes, growNotes]);
|
||||||
|
|
||||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
||||||
const [newTagDraft, setNewTagDraft] = useState('');
|
const [newTagDraft, setNewTagDraft] = useState('');
|
||||||
@@ -204,7 +211,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
|
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-3 sm:p-4 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-2xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] max-h-[92dvh] overflow-hidden"
|
className="w-full max-w-2xl lg:max-w-5xl panel-surface shadow-panel border border-border my-auto flex flex-col max-h-[92vh] max-h-[92dvh] overflow-hidden"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{/* Top Header */}
|
{/* Top Header */}
|
||||||
@@ -238,12 +245,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
<span>Nur Lesen</span>
|
<span>Nur Lesen</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{task.source?.toLowerCase() === 'webhook' && (
|
<OriginBadge origin={task.origin} createdAt={task.createdAt} />
|
||||||
<span className="px-1.5 py-0.5 border border-border bg-canvas text-muted rounded-btn text-[10px] font-medium flex items-center gap-1 flex-shrink-0">
|
|
||||||
<Webhook className="w-2.5 h-2.5 text-accent" />
|
|
||||||
<span>Webhook</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
@@ -327,9 +329,14 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
{/* Scrollable Body (all controls disabled for read-only shares) */}
|
{/* Scrollable Body (all controls disabled for read-only shares) */}
|
||||||
{/* The scroll container is a div: a scrolling <fieldset> is unreliable in mobile browsers */}
|
{/* The scroll container is a div: a scrolling <fieldset> is unreliable in mobile browsers */}
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain [-webkit-overflow-scrolling:touch]">
|
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain [-webkit-overflow-scrolling:touch]">
|
||||||
<fieldset disabled={readOnly} className="p-4 space-y-4 min-w-0 border-0 m-0">
|
{/* Desktop (lg): two columns, wide left (title, subtasks, notes), narrow right (all settings).
|
||||||
|
Phones and tablets keep the single column in the same order as before. */}
|
||||||
|
<fieldset
|
||||||
|
disabled={readOnly}
|
||||||
|
className="p-4 space-y-4 lg:space-y-0 lg:grid lg:grid-cols-[minmax(0,1fr)_18rem] lg:grid-rows-[auto_auto_1fr] lg:gap-x-6 lg:gap-y-4 min-w-0 border-0 m-0"
|
||||||
|
>
|
||||||
{/* Task Title */}
|
{/* Task Title */}
|
||||||
<div>
|
<div className="lg:col-start-1 lg:row-start-1">
|
||||||
{isEditingTitle ? (
|
{isEditingTitle ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<textarea
|
<textarea
|
||||||
@@ -392,8 +399,10 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Settings: right column on the desktop */}
|
||||||
|
<div className="space-y-4 lg:col-start-2 lg:row-start-1 lg:row-span-3 lg:border-l lg:border-border lg:pl-5">
|
||||||
{/* Kanban Status Buttons */}
|
{/* Kanban Status Buttons */}
|
||||||
<div className="space-y-1.5 pt-1 border-t border-border">
|
<div className="space-y-1.5 pt-1 border-t border-border lg:border-t-0 lg:pt-0">
|
||||||
<label className="block text-xs font-semibold text-main">
|
<label className="block text-xs font-semibold text-main">
|
||||||
Zustand / Kanban-Status:
|
Zustand / Kanban-Status:
|
||||||
</label>
|
</label>
|
||||||
@@ -414,17 +423,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
: 'bg-canvas border-border text-muted hover:text-main hover:border-strong'
|
: 'bg-canvas border-border text-muted hover:text-main hover:border-strong'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span className={`w-2 h-2 rounded-full ${columnDotClass(col)}`} />
|
||||||
className={`w-2 h-2 rounded-full ${
|
|
||||||
col.isDone
|
|
||||||
? 'bg-status-ok'
|
|
||||||
: col.id === 'in_progress'
|
|
||||||
? 'bg-accent'
|
|
||||||
: col.id === 'waiting'
|
|
||||||
? 'bg-status-warn'
|
|
||||||
: 'bg-faint'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span>{col.name}</span>
|
<span>{col.name}</span>
|
||||||
{isActive && <Check className="w-3 h-3 text-accent stroke-[3] ml-0.5" />}
|
{isActive && <Check className="w-3 h-3 text-accent stroke-[3] ml-0.5" />}
|
||||||
</button>
|
</button>
|
||||||
@@ -438,7 +437,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
<label className="block text-xs font-semibold text-main">
|
<label className="block text-xs font-semibold text-main">
|
||||||
Priorität:
|
Priorität:
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-1.5">
|
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-2 gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -501,7 +500,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Due Date & List Selection Row */}
|
{/* Due Date & List Selection Row */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-1 border-t border-border">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-1 gap-3 pt-1 border-t border-border">
|
||||||
{/* Due Date */}
|
{/* Due Date */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
<label className="block text-xs font-semibold text-main flex items-center gap-1">
|
||||||
@@ -514,7 +513,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
aria-label="Fälligkeitsdatum"
|
aria-label="Fälligkeitsdatum"
|
||||||
value={dueParts.date}
|
value={dueParts.date}
|
||||||
onChange={(e) => onUpdateDueDate(task, joinDue(e.target.value, dueParts.time))}
|
onChange={(e) => onUpdateDueDate(task, joinDue(e.target.value, dueParts.time))}
|
||||||
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel"
|
className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2 lg:px-1.5 text-base sm:text-xs bg-panel"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
@@ -523,7 +522,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
value={dueParts.time}
|
value={dueParts.time}
|
||||||
disabled={!dueParts.date}
|
disabled={!dueParts.date}
|
||||||
onChange={(e) => onUpdateDueDate(task, joinDue(dueParts.date, e.target.value))}
|
onChange={(e) => onUpdateDueDate(task, joinDue(dueParts.date, e.target.value))}
|
||||||
className="w-[7.5rem] shrink-0 h-11 sm:h-8 input-field px-2 text-base sm:text-xs bg-panel disabled:opacity-50"
|
className="w-[7.5rem] lg:w-[5.25rem] shrink-0 h-11 sm:h-8 input-field px-2 lg:px-1.5 text-base sm:text-xs bg-panel disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
{task.dueDate && (
|
{task.dueDate && (
|
||||||
<button
|
<button
|
||||||
@@ -613,7 +612,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
<option value="">-- Keine Liste (Allgemein) --</option>
|
<option value="">-- Keine Liste (Allgemein) --</option>
|
||||||
{taskLists.map((tl) => (
|
{taskLists.map((tl) => (
|
||||||
<option key={tl.id} value={tl.id}>
|
<option key={tl.id} value={tl.id}>
|
||||||
{tl.icon} {tl.title}
|
{[tl.icon, tl.title].filter(Boolean).join(' ')}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -718,8 +717,17 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Origin: in the right column on the desktop (phones show it in the footer) */}
|
||||||
|
{originDetails(task.origin, task.createdAt) && (
|
||||||
|
<div className="hidden lg:block space-y-1 pt-1 border-t border-border">
|
||||||
|
<label className="block text-xs font-semibold text-main">Herkunft:</label>
|
||||||
|
<p className="text-[11px] text-muted leading-snug">{originDetails(task.origin, task.createdAt)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Subtasks Checklist */}
|
{/* Subtasks Checklist */}
|
||||||
<div className="space-y-2 pt-1 border-t border-border">
|
<div className="space-y-2 pt-1 border-t border-border lg:col-start-1 lg:row-start-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="block text-xs font-semibold text-main flex items-center gap-1.5">
|
<label className="block text-xs font-semibold text-main flex items-center gap-1.5">
|
||||||
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||||
@@ -806,24 +814,37 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notes Section */}
|
{/* Notes Section */}
|
||||||
<div className="space-y-2 pt-1 border-t border-border">
|
<div className="space-y-2 pt-1 border-t border-border lg:col-start-1 lg:row-start-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="block text-xs font-semibold text-main flex items-center gap-1.5">
|
<label className="block text-xs font-semibold text-main flex items-center gap-1.5">
|
||||||
<FileText className="w-3.5 h-3.5 text-accent" />
|
<FileText className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Detailnotizen:</span>
|
<span>Detailnotizen:</span>
|
||||||
</label>
|
</label>
|
||||||
{!readOnly && !isEditingNotes && task.notes && (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{!readOnly && !isEditingNotes && task.notes && (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
setNotesDraft(task.notes || '');
|
onClick={() => {
|
||||||
setIsEditingNotes(true);
|
setNotesDraft(task.notes || '');
|
||||||
}}
|
setIsEditingNotes(true);
|
||||||
className="text-[11px] text-accent hover:underline"
|
}}
|
||||||
>
|
className="text-[11px] text-accent hover:underline"
|
||||||
Bearbeiten
|
>
|
||||||
</button>
|
Bearbeiten
|
||||||
)}
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Dictate into the notes: opens the field and appends; saved with "Notiz speichern" */}
|
||||||
|
{!readOnly && (
|
||||||
|
<VoiceInputButton
|
||||||
|
value={isEditingNotes ? notesDraft : task.notes || ''}
|
||||||
|
onChange={(text) => {
|
||||||
|
setNotesDraft(text);
|
||||||
|
setIsEditingNotes(true);
|
||||||
|
}}
|
||||||
|
focusRef={notesRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isEditingNotes ? (
|
{isEditingNotes ? (
|
||||||
@@ -834,9 +855,12 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
placeholder="Detailnotizen eingeben… (Markdown unterstützt)"
|
placeholder="Detailnotizen eingeben… (Markdown unterstützt)"
|
||||||
rows={6}
|
rows={6}
|
||||||
autoFocus
|
autoFocus
|
||||||
ref={growNotes}
|
ref={(el) => {
|
||||||
|
notesRef.current = el;
|
||||||
|
growNotes(el);
|
||||||
|
}}
|
||||||
onInput={(e) => growNotes(e.currentTarget)}
|
onInput={(e) => growNotes(e.currentTarget)}
|
||||||
className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-none min-h-[100px] overflow-hidden"
|
className="w-full input-field p-2.5 text-xs font-mono leading-relaxed resize-none min-h-[100px] lg:min-h-[260px] overflow-hidden"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-1.5">
|
<div className="flex justify-end gap-1.5">
|
||||||
<button
|
<button
|
||||||
@@ -893,8 +917,11 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
|||||||
|
|
||||||
{/* Modal Footer */}
|
{/* Modal Footer */}
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border bg-panel">
|
||||||
<div className="text-[11px] text-faint">
|
<div className="text-[11px] text-faint min-w-0 pr-3">
|
||||||
Erstellt: {new Date(task.createdAt).toLocaleDateString('de-DE')}
|
<span className="lg:hidden">
|
||||||
|
{originDetails(task.origin, task.createdAt) || `Erstellt: ${new Date(task.createdAt).toLocaleDateString('de-DE')}`}
|
||||||
|
</span>
|
||||||
|
<span className="hidden lg:inline">Erstellt: {new Date(task.createdAt).toLocaleDateString('de-DE')}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -10,7 +10,6 @@ import {
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Mic,
|
Mic,
|
||||||
Webhook,
|
|
||||||
Keyboard,
|
Keyboard,
|
||||||
Tag,
|
Tag,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -18,10 +17,15 @@ import {
|
|||||||
Edit3,
|
Edit3,
|
||||||
Layers,
|
Layers,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
ChevronDown,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { SharedList, Thought } from '../../lib/types';
|
import { SharedList, Thought } from '../../lib/types';
|
||||||
import { addDays, formatDue } from '../../lib/due';
|
import { addDays, formatDue } from '../../lib/due';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
|
import { OriginBadge } from '../../components/OriginBadge';
|
||||||
|
|
||||||
interface TriageDeckProps {
|
interface TriageDeckProps {
|
||||||
deck: Thought[];
|
deck: Thought[];
|
||||||
@@ -29,6 +33,9 @@ interface TriageDeckProps {
|
|||||||
onDeckChange: () => void;
|
onDeckChange: () => void;
|
||||||
onOpenDocument: (docId: string) => void;
|
onOpenDocument: (docId: string) => void;
|
||||||
onFocusCapture: () => void;
|
onFocusCapture: () => void;
|
||||||
|
// The triage's own trash (TriageTrash): thoughts thrown away while sorting
|
||||||
|
discardedCount: number;
|
||||||
|
onOpenTrash: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SwipeableQueueItemProps {
|
interface SwipeableQueueItemProps {
|
||||||
@@ -145,16 +152,12 @@ const SwipeableQueueItem: React.FC<SwipeableQueueItemProps> = ({
|
|||||||
<span className="text-sm sm:text-xs font-medium text-main line-clamp-2 sm:truncate">
|
<span className="text-sm sm:text-xs font-medium text-main line-clamp-2 sm:truncate">
|
||||||
{item.content}
|
{item.content}
|
||||||
</span>
|
</span>
|
||||||
{item.source?.toLowerCase() === 'webhook' && (
|
|
||||||
<span className="px-1.5 py-0.5 text-[10px] bg-canvas text-muted border border-border rounded-btn flex items-center gap-1 flex-shrink-0">
|
|
||||||
<Webhook className="w-2.5 h-2.5 text-accent" /> Webhook
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{item.source?.toLowerCase() === 'voice' && (
|
{item.source?.toLowerCase() === 'voice' && (
|
||||||
<span className="px-1.5 py-0.5 text-[10px] bg-canvas text-accent border border-border rounded-btn flex items-center gap-1 flex-shrink-0">
|
<span className="px-1.5 py-0.5 text-[10px] bg-canvas text-accent border border-border rounded-btn flex items-center gap-1 flex-shrink-0">
|
||||||
<Mic className="w-2.5 h-2.5" /> Diktat
|
<Mic className="w-2.5 h-2.5" /> Diktat
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<OriginBadge origin={item.origin} createdAt={item.createdAt} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */}
|
{/* Desktop quick buttons (hidden on mobile so queue items are clean & easy to tap/swipe) */}
|
||||||
@@ -220,6 +223,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
lists,
|
lists,
|
||||||
onDeckChange,
|
onDeckChange,
|
||||||
onFocusCapture,
|
onFocusCapture,
|
||||||
|
discardedCount,
|
||||||
|
onOpenTrash,
|
||||||
}) => {
|
}) => {
|
||||||
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
|
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
|
||||||
const singleWritableList = writableLists.length === 1 ? writableLists[0] : null;
|
const singleWritableList = writableLists.length === 1 ? writableLists[0] : null;
|
||||||
@@ -227,6 +232,9 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
const [cards, setCards] = useState<Thought[]>(deck);
|
const [cards, setCards] = useState<Thought[]>(deck);
|
||||||
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
|
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
|
||||||
const [showDateModal, setShowDateModal] = useState(false);
|
const [showDateModal, setShowDateModal] = useState(false);
|
||||||
|
// Phones: the waiting queue is folded until asked for
|
||||||
|
const [showQueueMobile, setShowQueueMobile] = useState(false);
|
||||||
|
const queueRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [taskDueDate, setTaskDueDate] = useState(() => addDays(1));
|
const [taskDueDate, setTaskDueDate] = useState(() => addDays(1));
|
||||||
const [showListSelector, setShowListSelector] = useState(false);
|
const [showListSelector, setShowListSelector] = useState(false);
|
||||||
|
|
||||||
@@ -433,13 +441,6 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (s === 'webhook') {
|
|
||||||
return (
|
|
||||||
<span className="px-2 py-0.5 text-[11px] bg-canvas text-muted border border-border rounded-btn flex items-center gap-1">
|
|
||||||
<Webhook className="w-3 h-3 text-accent" /> Webhook
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -474,6 +475,13 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
Letzte Aktion rückgängig
|
Letzte Aktion rückgängig
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{discardedCount > 0 && (
|
||||||
|
<button onClick={onOpenTrash} className="btn-action h-10 px-4 text-sm">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Papierkorb ({discardedCount})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -493,15 +501,24 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{lastTriaged && (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{lastTriaged && (
|
||||||
onClick={handleUndo}
|
<button
|
||||||
className="btn-action h-8 px-3 text-xs"
|
onClick={handleUndo}
|
||||||
>
|
className="btn-action h-8 px-3 text-xs"
|
||||||
<RotateCcw className="w-3.5 h-3.5 text-accent" />
|
>
|
||||||
Rückgängig
|
<RotateCcw className="w-3.5 h-3.5 text-accent" />
|
||||||
</button>
|
Rückgängig
|
||||||
)}
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Phones reach the trash through the menu */}
|
||||||
|
{discardedCount > 0 && (
|
||||||
|
<button onClick={onOpenTrash} className="hidden sm:inline-flex btn-action h-8 px-3 text-xs" title="Papierkorb der Triage">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
{discardedCount}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card Stack Container */}
|
{/* Card Stack Container */}
|
||||||
@@ -515,6 +532,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{priorityBadge(nextCard.priority)}
|
{priorityBadge(nextCard.priority)}
|
||||||
{sourceBadge(nextCard.source)}
|
{sourceBadge(nextCard.source)}
|
||||||
|
<OriginBadge origin={nextCard.origin} createdAt={nextCard.createdAt} size="md" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted line-clamp-2">{nextCard.content}</p>
|
<p className="text-sm text-muted line-clamp-2">{nextCard.content}</p>
|
||||||
<div className="text-[11px] text-faint">Nächster Quick Dump…</div>
|
<div className="text-[11px] text-faint">Nächster Quick Dump…</div>
|
||||||
@@ -584,6 +602,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{priorityBadge(activeCard.priority)}
|
{priorityBadge(activeCard.priority)}
|
||||||
{sourceBadge(activeCard.source)}
|
{sourceBadge(activeCard.source)}
|
||||||
|
<OriginBadge origin={activeCard.origin} createdAt={activeCard.createdAt} size="md" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
@@ -725,7 +744,103 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Swipe + Long-Press), shown on Desktop */}
|
{/* Phones: the swipe actions on top (← discard, → task), below a 2×2 block like the long-press sheet */}
|
||||||
|
<div className="sm:hidden space-y-2">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction('discard')}
|
||||||
|
className="min-h-[52px] px-3 py-2 rounded-xl bg-status-err-bg border border-status-err/30 text-status-err text-sm font-semibold flex items-center justify-center gap-2 touch-manipulation cursor-pointer active:scale-98"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
<span>Wegwerfen</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction('keep', { convertTo: 'task' })}
|
||||||
|
className="min-h-[52px] px-3 py-2 rounded-xl bg-accent text-on-accent text-sm font-semibold flex items-center justify-center gap-2 touch-manipulation cursor-pointer active:scale-98"
|
||||||
|
>
|
||||||
|
<span>Aufgabe</span>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={writableLists.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
if (singleWritableList) {
|
||||||
|
handleAction('keep', { convertTo: 'list_item', targetListId: singleWritableList.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Phones: the list choice of the bottom sheet (the inline choice above is desktop-only)
|
||||||
|
if (!activeCard) return;
|
||||||
|
openActionSheetForCard(activeCard);
|
||||||
|
setSheetMode('list');
|
||||||
|
}}
|
||||||
|
className={`min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 touch-manipulation ${
|
||||||
|
writableLists.length === 0 ? 'opacity-40 cursor-not-allowed' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-main">Auf Liste</div>
|
||||||
|
<div className="text-[11px] text-muted truncate">
|
||||||
|
{writableLists.length === 0
|
||||||
|
? 'Keine Liste vorhanden'
|
||||||
|
: singleWritableList
|
||||||
|
? singleWritableList.title
|
||||||
|
: 'Einkauf / Geteilt'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
// Phones: the date choice of the bottom sheet (the inline one above is desktop-only)
|
||||||
|
if (!activeCard) return;
|
||||||
|
openActionSheetForCard(activeCard);
|
||||||
|
setSheetMode('date');
|
||||||
|
}}
|
||||||
|
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 touch-manipulation"
|
||||||
|
>
|
||||||
|
<Calendar className="w-5 h-5 text-accent flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-main">Mit Termin</div>
|
||||||
|
<div className="text-[11px] text-muted">Datum wählen</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction('keep', { convertTo: 'document' })}
|
||||||
|
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 touch-manipulation"
|
||||||
|
>
|
||||||
|
<FileText className="w-5 h-5 text-accent flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-main">Als Notiz</div>
|
||||||
|
<div className="text-[11px] text-muted">Im Workspace</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={cards.length <= 1}
|
||||||
|
onClick={() => handleSkipCard()}
|
||||||
|
className={`min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 touch-manipulation ${cards.length <= 1 ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
<SkipForward className="w-5 h-5 text-muted flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-main">Später</div>
|
||||||
|
<div className="text-[11px] text-muted">Ans Ende schieben</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Mobile Quick Actions / Swipe), shown on Desktop */}
|
||||||
<div className="hidden sm:flex items-center gap-1.5">
|
<div className="hidden sm:flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction('discard')}
|
onClick={() => handleAction('discard')}
|
||||||
@@ -768,7 +883,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
? `Auf ${singleWritableList.title}`
|
? `Auf ${singleWritableList.title}`
|
||||||
: 'Auf eine geteilte Liste setzen'
|
: 'Auf eine geteilte Liste setzen'
|
||||||
}
|
}
|
||||||
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
|
className={`btn-action flex-1 min-w-0 h-9 px-3 justify-center whitespace-nowrap ${
|
||||||
writableLists.length === 0
|
writableLists.length === 0
|
||||||
? 'opacity-40 cursor-not-allowed'
|
? 'opacity-40 cursor-not-allowed'
|
||||||
: showListSelector
|
: showListSelector
|
||||||
@@ -777,7 +892,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||||
<span>{singleWritableList ? `Auf ${singleWritableList.title}` : 'Auf Liste'}</span>
|
{/* A long list name is cut off (full name in the tooltip), so the bar never grows wider than the card */}
|
||||||
|
<span className="truncate">{singleWritableList ? `Auf ${singleWritableList.title}` : 'Auf Liste'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -815,28 +931,58 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
|
|
||||||
{/* Waiting Quick Dump Queue List (Touch-sized & Swipeable Left/Right) */}
|
{/* Waiting Quick Dump Queue List (Touch-sized & Swipeable Left/Right) */}
|
||||||
{cards.length > 1 && (
|
{cards.length > 1 && (
|
||||||
<div className="pt-2">
|
<div className="pt-2" ref={queueRef}>
|
||||||
<div className="flex items-center justify-between mb-2 px-1">
|
{/* Phones: the queue stays folded so the card and its buttons fit on the screen */}
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted flex items-center gap-1.5">
|
<button
|
||||||
<Layers className="w-3.5 h-3.5 text-accent" />
|
type="button"
|
||||||
Warteschlange ({cards.length - 1})
|
aria-expanded={showQueueMobile}
|
||||||
|
onClick={() => {
|
||||||
|
const opening = !showQueueMobile;
|
||||||
|
setShowQueueMobile(opening);
|
||||||
|
if (opening) {
|
||||||
|
window.setTimeout(() => queueRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 320);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="sm:hidden w-full min-h-[48px] px-3 rounded-xl border border-border bg-panel text-sm font-medium text-main flex items-center justify-center gap-2 touch-manipulation cursor-pointer"
|
||||||
|
>
|
||||||
|
<Layers className="w-4 h-4 text-accent" />
|
||||||
|
<span>
|
||||||
|
Warteschlange {showQueueMobile ? 'ausblenden' : 'anzeigen'} ({cards.length - 1})
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-faint">
|
<ChevronDown
|
||||||
Tippen = Vorholen · 👈 Wegwerfen · 👉 Aufgabe
|
className={`w-4 h-4 text-muted transition-transform duration-300 ${showQueueMobile ? 'rotate-180' : ''}`}
|
||||||
</span>
|
/>
|
||||||
</div>
|
</button>
|
||||||
|
{/* Folds open/closed on phones; always open from sm upwards */}
|
||||||
|
<div
|
||||||
|
className={`grid transition-[grid-template-rows,opacity,visibility] duration-300 ease-out sm:grid-rows-[1fr] sm:opacity-100 sm:visible ${
|
||||||
|
showQueueMobile ? 'grid-rows-[1fr] opacity-100 visible' : 'grid-rows-[0fr] opacity-0 invisible'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden min-h-0">
|
||||||
|
<div className="flex items-center justify-between mb-2 px-1 pt-2 sm:pt-0">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted flex items-center gap-1.5">
|
||||||
|
<Layers className="w-3.5 h-3.5 text-accent" />
|
||||||
|
Warteschlange ({cards.length - 1})
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-faint">
|
||||||
|
Tippen = Vorholen · 👈 Wegwerfen · 👉 Aufgabe
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="panel-surface divide-y divide-border max-h-80 overflow-y-auto overflow-x-hidden">
|
<div className="panel-surface divide-y divide-border max-h-80 overflow-y-auto overflow-x-hidden">
|
||||||
{cards.slice(1).map((item) => (
|
{cards.slice(1).map((item) => (
|
||||||
<SwipeableQueueItem
|
<SwipeableQueueItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
lists={writableLists}
|
lists={writableLists}
|
||||||
onBringToFront={handleBringToFront}
|
onBringToFront={handleBringToFront}
|
||||||
onOpenActionSheet={openActionSheetForCard}
|
onOpenActionSheet={openActionSheetForCard}
|
||||||
onAction={handleAction}
|
onAction={handleAction}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Loader2, RotateCcw, Trash2 } from 'lucide-react';
|
||||||
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
|
import { formatDeletedAt } from '../../lib/due';
|
||||||
|
import type { Thought } from '../../lib/types';
|
||||||
|
import { ReadingModal } from '../../components/ReadingModal';
|
||||||
|
|
||||||
|
interface TriageTrashProps {
|
||||||
|
onClose: () => void;
|
||||||
|
// Something was restored or deleted: counters and the deck change
|
||||||
|
onChanged: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The triage's own trash: thoughts thrown away while sorting. Back into the Quick Dump, or deleted for
|
||||||
|
// good (one by one, all at once, or after the delay from Kontoeinstellungen → Aufbewahrung). Deleting
|
||||||
|
// for good takes a second tap on the same button, like the notes trash.
|
||||||
|
export const TriageTrash: React.FC<TriageTrashProps> = ({ onClose, onChanged }) => {
|
||||||
|
const [items, setItems] = useState<Thought[] | null>(null);
|
||||||
|
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
const list = await api.getThoughts('discarded').catch(() => []);
|
||||||
|
setItems(Array.isArray(list) ? list : []);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const restore = async (item: Thought) => {
|
||||||
|
triggerHaptic(15);
|
||||||
|
await api.triageAction(item.id, 'restore');
|
||||||
|
await load();
|
||||||
|
onChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
const purge = async (id: string) => {
|
||||||
|
if (confirmId !== id) {
|
||||||
|
setConfirmId(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
triggerHaptic(20);
|
||||||
|
setConfirmId(null);
|
||||||
|
if (id === '__all__') await api.purgeDiscarded();
|
||||||
|
else await api.deleteThought(id);
|
||||||
|
await load();
|
||||||
|
onChanged();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ReadingModal title="Papierkorb der Triage" onClose={onClose}>
|
||||||
|
{items === null ? (
|
||||||
|
<div className="py-6 flex justify-center text-muted">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<p className="py-6 text-center text-muted">Keine verworfenen Gedanken.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="divide-y divide-border border border-border rounded-card overflow-hidden">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="px-3 py-2 flex items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm text-main truncate">{item.content}</p>
|
||||||
|
{item.triagedAt && <p className="text-[11px] text-faint">verworfen {formatDeletedAt(item.triagedAt)}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<button type="button" onClick={() => restore(item)} className="btn-action min-h-[38px] sm:min-h-0">
|
||||||
|
<RotateCcw className="w-3 h-3 text-accent" /> Zurück
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => purge(item.id)}
|
||||||
|
className={`btn-icon btn-icon-danger min-h-[38px] min-w-[38px] sm:min-h-0 sm:min-w-0 ${confirmId === item.id ? 'text-status-err' : ''}`}
|
||||||
|
title={confirmId === item.id ? 'Wirklich endgültig löschen?' : 'Endgültig löschen'}
|
||||||
|
aria-label="Endgültig löschen"
|
||||||
|
>
|
||||||
|
{confirmId === item.id ? <span className="text-[11px] font-semibold px-1">Wirklich?</span> : <Trash2 className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button type="button" onClick={() => purge('__all__')} className="btn-action min-h-[38px] sm:min-h-0 text-status-err">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
{confirmId === '__all__' ? `Wirklich ${items.length} endgültig löschen?` : 'Papierkorb leeren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ReadingModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -71,6 +71,8 @@ describe('TriageDeck single-list shortcut', () => {
|
|||||||
onDeckChange={onDeckChange}
|
onDeckChange={onDeckChange}
|
||||||
onOpenDocument={vi.fn()}
|
onOpenDocument={vi.fn()}
|
||||||
onFocusCapture={vi.fn()}
|
onFocusCapture={vi.fn()}
|
||||||
|
discardedCount={0}
|
||||||
|
onOpenTrash={vi.fn()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -100,6 +102,8 @@ describe('TriageDeck single-list shortcut', () => {
|
|||||||
onDeckChange={onDeckChange}
|
onDeckChange={onDeckChange}
|
||||||
onOpenDocument={vi.fn()}
|
onOpenDocument={vi.fn()}
|
||||||
onFocusCapture={vi.fn()}
|
onFocusCapture={vi.fn()}
|
||||||
|
discardedCount={0}
|
||||||
|
onOpenTrash={vi.fn()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -123,6 +127,8 @@ describe('TriageDeck single-list shortcut', () => {
|
|||||||
onDeckChange={onDeckChange}
|
onDeckChange={onDeckChange}
|
||||||
onOpenDocument={vi.fn()}
|
onOpenDocument={vi.fn()}
|
||||||
onFocusCapture={vi.fn()}
|
onFocusCapture={vi.fn()}
|
||||||
|
discardedCount={0}
|
||||||
|
onOpenTrash={vi.fn()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -149,6 +155,8 @@ describe('TriageDeck single-list shortcut', () => {
|
|||||||
onDeckChange={onDeckChange}
|
onDeckChange={onDeckChange}
|
||||||
onOpenDocument={vi.fn()}
|
onOpenDocument={vi.fn()}
|
||||||
onFocusCapture={vi.fn()}
|
onFocusCapture={vi.fn()}
|
||||||
|
discardedCount={0}
|
||||||
|
onOpenTrash={vi.fn()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -160,4 +168,28 @@ describe('TriageDeck single-list shortcut', () => {
|
|||||||
expect(api.triageAction).not.toHaveBeenCalled();
|
expect(api.triageAction).not.toHaveBeenCalled();
|
||||||
expect(onDeckChange).not.toHaveBeenCalled();
|
expect(onDeckChange).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders mobile quick action buttons and triggers discard', async () => {
|
||||||
|
const onDeckChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TriageDeck
|
||||||
|
deck={sampleDeck}
|
||||||
|
lists={[ownList]}
|
||||||
|
onDeckChange={onDeckChange}
|
||||||
|
onOpenDocument={vi.fn()}
|
||||||
|
onFocusCapture={vi.fn()}
|
||||||
|
discardedCount={0}
|
||||||
|
onOpenTrash={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const [mobileDiscard] = screen.getAllByRole('button', { name: /^Wegwerfen$/i });
|
||||||
|
fireEvent.click(mobileDiscard);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'discard', expect.objectContaining({}));
|
||||||
|
expect(onDeckChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { columnDotClass } from '../ManageColumnsModal';
|
||||||
|
|
||||||
|
describe('columnDotClass', () => {
|
||||||
|
it('uses the chosen colour, also the names of the server defaults', () => {
|
||||||
|
expect(columnDotClass({ id: 'x', name: 'Review', color: 'purple' })).toBe('bg-tone-purple');
|
||||||
|
expect(columnDotClass({ id: 'x', name: 'Blockiert', color: 'red' })).toBe('bg-status-err');
|
||||||
|
expect(columnDotClass({ id: 'waiting', name: 'Wartet', color: 'amber' })).toBe('bg-status-warn');
|
||||||
|
expect(columnDotClass({ id: 'done', name: 'Erledigt', color: 'emerald', isDone: true })).toBe('bg-status-ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the colour of the role for columns without one', () => {
|
||||||
|
expect(columnDotClass({ id: 'in_progress', name: 'In Arbeit' })).toBe('bg-accent');
|
||||||
|
expect(columnDotClass({ id: 'x', name: 'Fertig', isDone: true })).toBe('bg-status-ok');
|
||||||
|
expect(columnDotClass({ id: 'x', name: 'Neu' })).toBe('bg-faint');
|
||||||
|
expect(columnDotClass(undefined)).toBe('bg-faint');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
// Drag & drop on the Kanban board with a finger (browsers offer HTML5 drag & drop only for mouse and
|
||||||
|
// pen): hold a card for a moment, it lifts, drag it onto another card or column, let go. A short tap
|
||||||
|
// still opens the task and a swipe still scrolls, because the drag starts only after the hold.
|
||||||
|
// Cards carry data-kanban-task="<id>", columns data-kanban-col="<id>".
|
||||||
|
|
||||||
|
const HOLD_MS = 350;
|
||||||
|
const MOVE_TOLERANCE = 8; // px a finger may wander during the hold before it counts as scrolling
|
||||||
|
const EDGE = 90; // px near the top/bottom edge that scroll the page while dragging
|
||||||
|
|
||||||
|
export type KanbanDropTarget = { taskId: string; position: 'before' | 'after' } | null;
|
||||||
|
|
||||||
|
interface Options {
|
||||||
|
enabled: boolean;
|
||||||
|
onStart: (taskId: string) => void;
|
||||||
|
onHover: (colId: string | null, target: KanbanDropTarget) => void;
|
||||||
|
onDrop: (taskId: string, colId: string | null, target: KanbanDropTarget) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Drag {
|
||||||
|
taskId: string;
|
||||||
|
label: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
offsetX: number;
|
||||||
|
offsetY: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel }: Options) {
|
||||||
|
const [drag, setDrag] = useState<Drag | null>(null);
|
||||||
|
const holdRef = useRef<{ timer: number; x: number; y: number } | null>(null);
|
||||||
|
const dragRef = useRef<Drag | null>(null);
|
||||||
|
const hoverRef = useRef<{ col: string | null; target: KanbanDropTarget }>({ col: null, target: null });
|
||||||
|
const suppressClickRef = useRef(false);
|
||||||
|
const callbacks = useRef({ onStart, onHover, onDrop, onCancel });
|
||||||
|
callbacks.current = { onStart, onHover, onDrop, onCancel };
|
||||||
|
|
||||||
|
const cancelHold = () => {
|
||||||
|
if (holdRef.current) window.clearTimeout(holdRef.current.timer);
|
||||||
|
holdRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hoverAt = (x: number, y: number) => {
|
||||||
|
const el = document.elementFromPoint(x, y) as HTMLElement | null;
|
||||||
|
const card = el?.closest('[data-kanban-task]') as HTMLElement | null;
|
||||||
|
const col = (el?.closest('[data-kanban-col]') as HTMLElement | null)?.dataset.kanbanCol ?? null;
|
||||||
|
let target: KanbanDropTarget = null;
|
||||||
|
const cardId = card?.dataset.kanbanTask;
|
||||||
|
if (card && cardId && cardId !== dragRef.current?.taskId) {
|
||||||
|
const r = card.getBoundingClientRect();
|
||||||
|
target = { taskId: cardId, position: y - r.top < r.height / 2 ? 'before' : 'after' };
|
||||||
|
}
|
||||||
|
const prev = hoverRef.current;
|
||||||
|
if (prev.col !== col || prev.target?.taskId !== target?.taskId || prev.target?.position !== target?.position) {
|
||||||
|
hoverRef.current = { col, target };
|
||||||
|
callbacks.current.onHover(col, target);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// While dragging: the page must not scroll under the finger (non-passive listener), it scrolls
|
||||||
|
// only near the edges
|
||||||
|
useEffect(() => {
|
||||||
|
if (!drag) return;
|
||||||
|
let frame = 0;
|
||||||
|
const scroll = () => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (d) {
|
||||||
|
if (d.y < EDGE) window.scrollBy(0, -Math.ceil((EDGE - d.y) / 6));
|
||||||
|
else if (d.y > window.innerHeight - EDGE) window.scrollBy(0, Math.ceil((d.y - (window.innerHeight - EDGE)) / 6));
|
||||||
|
hoverAt(d.x, d.y);
|
||||||
|
}
|
||||||
|
frame = requestAnimationFrame(scroll);
|
||||||
|
};
|
||||||
|
frame = requestAnimationFrame(scroll);
|
||||||
|
|
||||||
|
const move = (e: TouchEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const t = e.touches[0];
|
||||||
|
if (!t || !dragRef.current) return;
|
||||||
|
dragRef.current = { ...dragRef.current, x: t.clientX, y: t.clientY };
|
||||||
|
setDrag(dragRef.current);
|
||||||
|
};
|
||||||
|
const end = (e: TouchEvent) => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
dragRef.current = null;
|
||||||
|
setDrag(null);
|
||||||
|
suppressClickRef.current = true;
|
||||||
|
window.setTimeout(() => (suppressClickRef.current = false), 400);
|
||||||
|
if (!d) return;
|
||||||
|
if (e.type === 'touchcancel') callbacks.current.onCancel();
|
||||||
|
else callbacks.current.onDrop(d.taskId, hoverRef.current.col, hoverRef.current.target);
|
||||||
|
hoverRef.current = { col: null, target: null };
|
||||||
|
};
|
||||||
|
document.addEventListener('touchmove', move, { passive: false });
|
||||||
|
document.addEventListener('touchend', end);
|
||||||
|
document.addEventListener('touchcancel', end);
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
document.removeEventListener('touchmove', move);
|
||||||
|
document.removeEventListener('touchend', end);
|
||||||
|
document.removeEventListener('touchcancel', end);
|
||||||
|
};
|
||||||
|
// Listeners live as long as one drag; positions are read from refs
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [Boolean(drag)]);
|
||||||
|
|
||||||
|
/** Props for a card; label is what the lifted card shows */
|
||||||
|
const cardProps = useCallback(
|
||||||
|
(taskId: string, label: string, draggable: boolean) => {
|
||||||
|
if (!enabled || !draggable) return {};
|
||||||
|
return {
|
||||||
|
onTouchStart: (e: React.TouchEvent<HTMLElement>) => {
|
||||||
|
if (e.touches.length !== 1) return;
|
||||||
|
const t = e.touches[0];
|
||||||
|
const el = e.currentTarget;
|
||||||
|
cancelHold();
|
||||||
|
holdRef.current = {
|
||||||
|
x: t.clientX,
|
||||||
|
y: t.clientY,
|
||||||
|
timer: window.setTimeout(() => {
|
||||||
|
const hold = holdRef.current;
|
||||||
|
holdRef.current = null;
|
||||||
|
if (!hold) return;
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
navigator.vibrate?.(15);
|
||||||
|
const d: Drag = { taskId, label, x: hold.x, y: hold.y, offsetX: hold.x - r.left, offsetY: hold.y - r.top, width: r.width };
|
||||||
|
dragRef.current = d;
|
||||||
|
hoverRef.current = { col: null, target: null };
|
||||||
|
setDrag(d);
|
||||||
|
callbacks.current.onStart(taskId);
|
||||||
|
}, HOLD_MS),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
onTouchMove: (e: React.TouchEvent<HTMLElement>) => {
|
||||||
|
const h = holdRef.current;
|
||||||
|
const t = e.touches[0];
|
||||||
|
if (h && t && Math.hypot(t.clientX - h.x, t.clientY - h.y) > MOVE_TOLERANCE) cancelHold(); // scrolling
|
||||||
|
},
|
||||||
|
onTouchEnd: cancelHold,
|
||||||
|
onTouchCancel: cancelHold,
|
||||||
|
// The long press must not open the browser's context menu or select text
|
||||||
|
onContextMenu: (e: React.MouseEvent) => e.preventDefault(),
|
||||||
|
style: { WebkitTouchCallout: 'none' } as React.CSSProperties,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[enabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** A tap right after a drag must not open the task */
|
||||||
|
const consumeClick = () => suppressClickRef.current;
|
||||||
|
|
||||||
|
const ghost = drag
|
||||||
|
? createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed z-[80] pointer-events-none panel-surface shadow-panel px-3 py-2.5 text-xs font-medium text-main rotate-1 opacity-95 border-accent"
|
||||||
|
style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY, width: drag.width }}
|
||||||
|
>
|
||||||
|
{drag.label}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { cardProps, ghost, consumeClick, dragging: Boolean(drag) };
|
||||||
|
}
|
||||||
@@ -87,7 +87,7 @@ export const AudioBlock: React.FC<AudioBlockProps> = ({ content, canEdit, onCapt
|
|||||||
return (
|
return (
|
||||||
<div className="my-1 space-y-1">
|
<div className="my-1 space-y-1">
|
||||||
{failed ? (
|
{failed ? (
|
||||||
<div className="flex items-center gap-2 h-12 px-3 rounded-card border border-border bg-panel text-xs text-muted">
|
<div className="flex items-center gap-2 h-12 px-4 rounded-full border border-border bg-panel text-xs text-muted">
|
||||||
<FileAudio className="w-4 h-4" /> Aufnahme nicht verfügbar (offline oder gelöscht)
|
<FileAudio className="w-4 h-4" /> Aufnahme nicht verfügbar (offline oder gelöscht)
|
||||||
</div>
|
</div>
|
||||||
) : src ? (
|
) : src ? (
|
||||||
@@ -99,7 +99,7 @@ export const AudioBlock: React.FC<AudioBlockProps> = ({ content, canEdit, onCapt
|
|||||||
onClick={() => void transcribe()}
|
onClick={() => void transcribe()}
|
||||||
disabled={transcribing}
|
disabled={transcribing}
|
||||||
title="In Text umwandeln (eigene Spracherkennung)"
|
title="In Text umwandeln (eigene Spracherkennung)"
|
||||||
className="btn-secondary h-11 sm:h-8 px-2.5 text-xs flex items-center gap-1.5 shrink-0 disabled:opacity-40"
|
className="btn-secondary !rounded-full h-11 sm:h-8 px-3 text-xs flex items-center gap-1.5 shrink-0 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{transcribing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Wand2 className="w-3.5 h-3.5" />}
|
{transcribing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Wand2 className="w-3.5 h-3.5" />}
|
||||||
<span className="hidden sm:inline">{transcribing ? 'Wird erkannt…' : 'Transkribieren'}</span>
|
<span className="hidden sm:inline">{transcribing ? 'Wird erkannt…' : 'Transkribieren'}</span>
|
||||||
@@ -107,7 +107,7 @@ export const AudioBlock: React.FC<AudioBlockProps> = ({ content, canEdit, onCapt
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center h-12 px-3 rounded-card border border-border bg-panel w-64">
|
<div className="flex items-center h-12 px-4 rounded-full border border-border bg-panel w-64">
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-muted" />
|
<Loader2 className="w-4 h-4 animate-spin text-muted" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export const AudioRecorderPicker: React.FC<AudioRecorderPickerProps> = ({ docId,
|
|||||||
cancel();
|
cancel();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="my-1 inline-flex max-w-full flex-col gap-2 rounded-card border border-border bg-panel-elevated px-3 py-2 focus:outline-none"
|
className="my-1 inline-flex max-w-full flex-col gap-2 rounded-3xl border border-border bg-panel-elevated px-4 py-2 focus:outline-none"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{state === 'recording' && (
|
{state === 'recording' && (
|
||||||
@@ -162,7 +162,7 @@ export const AudioRecorderPicker: React.FC<AudioRecorderPickerProps> = ({ docId,
|
|||||||
onClick={cancel}
|
onClick={cancel}
|
||||||
disabled={state === 'uploading'}
|
disabled={state === 'uploading'}
|
||||||
title="Abbrechen (Esc)"
|
title="Abbrechen (Esc)"
|
||||||
className="w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-btn text-muted hover:text-main hover:bg-panel shrink-0 disabled:opacity-40"
|
className="w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-full text-muted hover:text-main hover:bg-panel shrink-0 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export const BacklinksSection: React.FC<BacklinksSectionProps> = ({ docId, title
|
|||||||
onClick={() => onOpen(l.docId)}
|
onClick={() => onOpen(l.docId)}
|
||||||
className="flex items-center gap-2 min-w-0 max-w-full text-left cursor-pointer group"
|
className="flex items-center gap-2 min-w-0 max-w-full text-left cursor-pointer group"
|
||||||
>
|
>
|
||||||
<span className="shrink-0">{l.icon || '📝'}</span>
|
{l.icon && <span className="shrink-0">{l.icon}</span>}
|
||||||
<span className="text-sm font-semibold text-main truncate group-hover:text-accent">
|
<span className="text-sm font-semibold text-main truncate group-hover:text-accent">
|
||||||
{l.title || 'Unbenannte Notiz'}
|
{l.title || 'Unbenannte Notiz'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
import Prism from 'prismjs';
|
import Prism from 'prismjs';
|
||||||
import { Code, Copy, Check } from 'lucide-react';
|
import { Copy, Check } from 'lucide-react';
|
||||||
import { DocumentBlock } from '../../lib/types';
|
import { DocumentBlock } from '../../lib/types';
|
||||||
|
|
||||||
Prism.manual = true;
|
Prism.manual = true;
|
||||||
@@ -311,6 +311,7 @@ export interface CodeBlockEditorProps {
|
|||||||
registerTextareaRef?: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
registerTextareaRef?: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
||||||
onChangeContent?: (content: string) => void;
|
onChangeContent?: (content: string) => void;
|
||||||
onChangeLang?: (lang: string) => void;
|
onChangeLang?: (lang: string) => void;
|
||||||
|
/** Kept for callers; converting back to text lives in the bullet's action bubble now */
|
||||||
onConvertToText?: () => void;
|
onConvertToText?: () => void;
|
||||||
handleFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
|
handleFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
|
||||||
handleBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
|
handleBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
|
||||||
@@ -326,7 +327,6 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
|||||||
registerTextareaRef,
|
registerTextareaRef,
|
||||||
onChangeContent,
|
onChangeContent,
|
||||||
onChangeLang,
|
onChangeLang,
|
||||||
onConvertToText,
|
|
||||||
handleFocus,
|
handleFocus,
|
||||||
handleBlur,
|
handleBlur,
|
||||||
handleContentChange,
|
handleContentChange,
|
||||||
@@ -542,73 +542,50 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
|||||||
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
|
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-code border border-border rounded-btn my-0.5 max-w-full overflow-hidden">
|
<div className="group/code relative bg-code rounded-2xl my-0.5 max-w-full overflow-hidden">
|
||||||
{/* Code Block Header */}
|
{/* Language at the top left; no header bar */}
|
||||||
<div className="flex items-center justify-between gap-2 px-2 py-1 border-b border-border text-[11px] font-mono text-faint">
|
<div className="flex items-center px-1.5 pt-1 text-[11px] font-mono text-faint">
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
{canEdit ? (
|
||||||
<Code className="w-3.5 h-3.5 text-muted shrink-0" />
|
<select
|
||||||
{canEdit ? (
|
aria-label="Sprache auswählen"
|
||||||
<select
|
value={selectValue}
|
||||||
aria-label="Sprache auswählen"
|
onChange={(e) => onChangeLang?.(e.target.value)}
|
||||||
value={selectValue}
|
className="min-h-[44px] sm:min-h-0 bg-transparent text-faint hover:text-main focus:text-main font-mono text-[11px] font-medium border border-transparent hover:border-border focus:border-accent rounded-btn px-1 py-0.5 outline-none cursor-pointer"
|
||||||
onChange={(e) => onChangeLang?.(e.target.value)}
|
|
||||||
className="min-h-[44px] sm:min-h-0 bg-transparent text-muted hover:text-main focus:text-main font-mono text-[11px] font-medium border border-transparent hover:border-border focus:border-accent rounded-btn px-1.5 py-0.5 outline-none cursor-pointer"
|
|
||||||
>
|
|
||||||
<option value="" className="bg-panel text-main">
|
|
||||||
Text
|
|
||||||
</option>
|
|
||||||
{hasCustomUnknownLang && (
|
|
||||||
<option value={selectValue} className="bg-panel text-main">
|
|
||||||
{selectValue}
|
|
||||||
</option>
|
|
||||||
)}
|
|
||||||
{CODE_LANGUAGE_GROUPS.map((group) => (
|
|
||||||
<optgroup key={group.label} label={group.label} className="bg-panel text-muted">
|
|
||||||
{group.options.map((opt) => (
|
|
||||||
<option key={opt.id} value={opt.id} className="bg-panel text-main">
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
) : (
|
|
||||||
<span className="font-mono text-[11px] text-muted px-1">{block.lang || 'Text'}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopy}
|
|
||||||
className="min-h-[44px] sm:min-h-0 px-2 py-0.5 rounded-btn text-[11px] font-mono text-muted hover:text-main hover:bg-panel-elevated transition-colors inline-flex items-center gap-1 cursor-pointer"
|
|
||||||
title="In die Zwischenablage kopieren"
|
|
||||||
>
|
>
|
||||||
{copied ? (
|
<option value="" className="bg-panel text-main">
|
||||||
<>
|
Text
|
||||||
<Check className="w-3 h-3 text-status-ok" />
|
</option>
|
||||||
<span className="text-status-ok">Kopiert!</span>
|
{hasCustomUnknownLang && (
|
||||||
</>
|
<option value={selectValue} className="bg-panel text-main">
|
||||||
) : (
|
{selectValue}
|
||||||
<>
|
</option>
|
||||||
<Copy className="w-3 h-3 opacity-75" />
|
|
||||||
<span>Kopieren</span>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
{CODE_LANGUAGE_GROUPS.map((group) => (
|
||||||
|
<optgroup key={group.label} label={group.label} className="bg-panel text-muted">
|
||||||
{canEdit && onConvertToText && (
|
{group.options.map((opt) => (
|
||||||
<button
|
<option key={opt.id} value={opt.id} className="bg-panel text-main">
|
||||||
type="button"
|
{opt.label}
|
||||||
onClick={onConvertToText}
|
</option>
|
||||||
className="min-h-[44px] sm:min-h-0 px-2 py-0.5 rounded-btn text-[11px] font-mono text-muted hover:text-accent hover:bg-panel-elevated transition-colors inline-flex items-center cursor-pointer"
|
))}
|
||||||
>
|
</optgroup>
|
||||||
Als Text
|
))}
|
||||||
</button>
|
</select>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<span className="font-mono text-[11px] text-faint px-1">{block.lang || 'Text'}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Copy: round icon button at the bottom right, shown on hover (always on touch screens) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className="absolute bottom-2 right-2 z-10 h-9 w-9 sm:h-8 sm:w-8 rounded-full bg-panel border border-border shadow-panel flex items-center justify-center text-muted hover:text-main opacity-100 sm:opacity-0 sm:group-hover/code:opacity-100 focus-visible:opacity-100 transition-opacity cursor-pointer"
|
||||||
|
title={copied ? 'Kopiert!' : 'In die Zwischenablage kopieren'}
|
||||||
|
aria-label={copied ? 'Kopiert!' : 'Kopieren'}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-3.5 h-3.5 text-status-ok" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Live Highlighted Overlay + Textarea with Line Numbers */}
|
{/* Live Highlighted Overlay + Textarea with Line Numbers */}
|
||||||
<div className="flex relative w-full overflow-hidden">
|
<div className="flex relative w-full overflow-hidden">
|
||||||
{/* Line Numbers Column */}
|
{/* Line Numbers Column */}
|
||||||
@@ -618,7 +595,7 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
|||||||
onScroll={handleLineNumbersScroll}
|
onScroll={handleLineNumbersScroll}
|
||||||
onWheel={handleLineNumbersWheel}
|
onWheel={handleLineNumbersWheel}
|
||||||
data-testid="code-line-numbers"
|
data-testid="code-line-numbers"
|
||||||
className="select-none py-2 pl-2.5 pr-2 font-mono text-xs text-faint leading-relaxed text-right border-r border-border/40 shrink-0 overflow-hidden"
|
className="select-none py-2 pl-2.5 pr-2 font-mono text-xs text-faint leading-relaxed text-right shrink-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
{Array.from({ length: lineCount }, (_, i) => (
|
{Array.from({ length: lineCount }, (_, i) => (
|
||||||
<div key={i + 1} data-line-number={i + 1}>
|
<div key={i + 1} data-line-number={i + 1}>
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel
|
|||||||
}}
|
}}
|
||||||
disabled={transcribing || !!error}
|
disabled={transcribing || !!error}
|
||||||
aria-label="Diktat beenden"
|
aria-label="Diktat beenden"
|
||||||
className="relative w-full min-h-[56px] sm:min-h-[44px] my-1 px-3 py-2 rounded-card border border-accent bg-accent-dim overflow-hidden flex flex-col gap-1.5 text-left cursor-pointer disabled:cursor-default focus:outline-none"
|
className="relative w-full min-h-[56px] sm:min-h-[44px] my-1 px-4 py-2 rounded-3xl border border-accent bg-accent-dim overflow-hidden flex flex-col gap-1.5 text-left cursor-pointer disabled:cursor-default focus:outline-none"
|
||||||
>
|
>
|
||||||
{(interim || chunkBusy) && !error && (
|
{(interim || chunkBusy) && !error && (
|
||||||
<div className="w-full text-sm text-main whitespace-pre-wrap break-words leading-relaxed pointer-events-none">
|
<div className="w-full text-sm text-main whitespace-pre-wrap break-words leading-relaxed pointer-events-none">
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, useMemo, useSyncExternalStore } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { buildBlockLink } from '../../lib/wikiLink';
|
||||||
import {
|
import {
|
||||||
Pin,
|
Pin,
|
||||||
Users,
|
Users,
|
||||||
Trash2,
|
Trash2,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Download,
|
Download,
|
||||||
|
Maximize2,
|
||||||
FileText,
|
FileText,
|
||||||
Loader2,
|
Loader2,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -21,6 +24,8 @@ import {
|
|||||||
Undo2,
|
Undo2,
|
||||||
Redo2,
|
Redo2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
CalendarDays,
|
||||||
|
SmilePlus,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||||
import { api } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
@@ -28,6 +33,7 @@ import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
|
|||||||
import { Presence } from '../../lib/docSync/transport';
|
import { Presence } from '../../lib/docSync/transport';
|
||||||
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
||||||
import { extractHashtags } from '../../lib/hashtags';
|
import { extractHashtags } from '../../lib/hashtags';
|
||||||
|
import { moveSibling } from '../../lib/outlineList';
|
||||||
|
|
||||||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||||
|
|
||||||
@@ -50,7 +56,7 @@ export interface DocumentEditorProps {
|
|||||||
onRestoreDoc?: (docId: string) => void;
|
onRestoreDoc?: (docId: string) => void;
|
||||||
onOpenShareModal: (doc: Document) => void;
|
onOpenShareModal: (doc: Document) => void;
|
||||||
onSelectTag: (tag: string) => void;
|
onSelectTag: (tag: string) => void;
|
||||||
onWikiLinkClick?: (pageTitle: string) => void;
|
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
|
||||||
onOpenDocBlock?: (docId: string, blockId?: string) => void;
|
onOpenDocBlock?: (docId: string, blockId?: string) => void;
|
||||||
onBackMobile?: () => void;
|
onBackMobile?: () => void;
|
||||||
onOpenTask?: (taskId: string) => void;
|
onOpenTask?: (taskId: string) => void;
|
||||||
@@ -61,6 +67,28 @@ export interface DocumentEditorProps {
|
|||||||
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
|
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
|
||||||
onDocCreated?: (doc: Document) => void;
|
onDocCreated?: (doc: Document) => void;
|
||||||
onDocRemoved?: (docId: string) => void;
|
onDocRemoved?: (docId: string) => void;
|
||||||
|
/** 'journal': one day inside the journal list – no own scrolling, header or backlinks, fixed title.
|
||||||
|
* 'tag': inside a hashtag page – the title as a fixed heading, below only the branches with tagFilter */
|
||||||
|
variant?: 'page' | 'journal' | 'tag';
|
||||||
|
/** variant 'tag': the hashtag (without #) whose bullets are shown */
|
||||||
|
tagFilter?: string;
|
||||||
|
/** Large screens: the action buttons render into this element (the workspace tab bar) */
|
||||||
|
actionsTarget?: HTMLElement | null;
|
||||||
|
/** Distraction-free mode: reading width, menu entry to leave it */
|
||||||
|
focusMode?: boolean;
|
||||||
|
onToggleFocusMode?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Large screens (lg) put the note's actions into the workspace tab bar and drop the editor header
|
||||||
|
const LARGE_QUERY = '(min-width: 1024px)';
|
||||||
|
function subscribeLarge(onChange: () => void) {
|
||||||
|
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
|
||||||
|
const mq = window.matchMedia(LARGE_QUERY);
|
||||||
|
mq.addEventListener('change', onChange);
|
||||||
|
return () => mq.removeEventListener('change', onChange);
|
||||||
|
}
|
||||||
|
function isLargeScreen() {
|
||||||
|
return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(LARGE_QUERY).matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
import { EmojiPickerModal } from './EmojiPickerModal';
|
import { EmojiPickerModal } from './EmojiPickerModal';
|
||||||
@@ -104,6 +132,7 @@ function sameBlocks(a: DocumentBlock[], b: DocumentBlock[]): boolean {
|
|||||||
!!x.collapsed !== !!y.collapsed ||
|
!!x.collapsed !== !!y.collapsed ||
|
||||||
(x.lang || '') !== (y.lang || '') ||
|
(x.lang || '') !== (y.lang || '') ||
|
||||||
(x.taskId || '') !== (y.taskId || '') ||
|
(x.taskId || '') !== (y.taskId || '') ||
|
||||||
|
(x.subtaskOf || '') !== (y.subtaskOf || '') ||
|
||||||
(x.conflictOf || '') !== (y.conflictOf || '')
|
(x.conflictOf || '') !== (y.conflictOf || '')
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@@ -211,7 +240,18 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onRequestMerge,
|
onRequestMerge,
|
||||||
onDocCreated,
|
onDocCreated,
|
||||||
onDocRemoved,
|
onDocRemoved,
|
||||||
|
variant = 'page',
|
||||||
|
tagFilter,
|
||||||
|
actionsTarget,
|
||||||
|
focusMode = false,
|
||||||
|
onToggleFocusMode,
|
||||||
}) => {
|
}) => {
|
||||||
|
const inJournal = variant === 'journal';
|
||||||
|
const inTagPage = variant === 'tag';
|
||||||
|
// Embedded in a list of notes (journal, hashtag page): no own scrolling, action bar or backlinks
|
||||||
|
const embedded = inJournal || inTagPage;
|
||||||
|
const large = useSyncExternalStore(subscribeLarge, isLargeScreen, () => false);
|
||||||
|
const desktopBar = !embedded && large && Boolean(actionsTarget);
|
||||||
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
||||||
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
||||||
const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
|
const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
|
||||||
@@ -230,6 +270,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
|
|
||||||
|
|
||||||
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
// The header repeats the title only once the big title has scrolled out of view
|
||||||
|
const [titleScrolledAway, setTitleScrolledAway] = useState(false);
|
||||||
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
|
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
|
||||||
const docRef = useRef<Document | null>(doc);
|
const docRef = useRef<Document | null>(doc);
|
||||||
docRef.current = doc;
|
docRef.current = doc;
|
||||||
@@ -239,6 +282,22 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
|
const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
|
||||||
currentBlocksRef.current = blocks;
|
currentBlocksRef.current = blocks;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = scrollRef.current;
|
||||||
|
const el = titleInputRef.current;
|
||||||
|
if (embedded || !root || !el || typeof IntersectionObserver === 'undefined') {
|
||||||
|
setTitleScrolledAway(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => setTitleScrolledAway(!entry.isIntersecting),
|
||||||
|
// the sticky header covers the top of the scroll area
|
||||||
|
{ root, rootMargin: '-56px 0px 0px 0px' }
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [zoomedBlockId, doc?.id, embedded]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
||||||
const focusTitle = () => {
|
const focusTitle = () => {
|
||||||
@@ -310,10 +369,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
setBlocks(normalized);
|
setBlocks(normalized);
|
||||||
}
|
}
|
||||||
const links = new Map<string, string>();
|
const links = new Map<string, string>();
|
||||||
|
const subtaskLinks = new Map<string, string>();
|
||||||
for (const b of fetched.blocks || []) {
|
for (const b of fetched.blocks || []) {
|
||||||
if (b.taskId) links.set(b.id, b.taskId);
|
if (b.taskId) links.set(b.id, b.taskId);
|
||||||
|
if (b.subtaskOf) subtaskLinks.set(b.id, b.subtaskOf);
|
||||||
}
|
}
|
||||||
engineRef.current?.setTaskLinks(links);
|
engineRef.current?.setTaskLinks(links, subtaskLinks);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
onDocMetadataChange(fetched);
|
onDocMetadataChange(fetched);
|
||||||
|
|
||||||
@@ -454,6 +515,34 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}, actions.length ? 8000 : 3500);
|
}, actions.length ? 8000 : 3500);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Link to a single bullet: [[Titel#^id|Titel › Auszug]] into the clipboard
|
||||||
|
const handleCopyBlockLink = useCallback(
|
||||||
|
(blockId: string) => {
|
||||||
|
const block = blocks.find((b) => b.id === blockId);
|
||||||
|
const link = buildBlockLink(title || doc?.title || 'Notiz', blockId, block?.content || '');
|
||||||
|
const done = () => showNotice('Link zum Punkt kopiert – mit Strg+V in eine Notiz einfügen');
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
navigator.clipboard.writeText(link).then(done, () => showNotice(`Kopieren nicht möglich: ${link}`));
|
||||||
|
} else {
|
||||||
|
showNotice(`Kopieren nicht möglich: ${link}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[blocks, title, doc?.title, showNotice]
|
||||||
|
);
|
||||||
|
|
||||||
|
// A link pointed to a bullet that is gone (moved, extracted, deleted): the note opens with a hint
|
||||||
|
const blocksForTargetRef = useRef(blocks);
|
||||||
|
blocksForTargetRef.current = blocks;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialFocusedBlockId || !doc?.id) return;
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
if (!blocksForTargetRef.current.some((b) => b.id === initialFocusedBlockId)) {
|
||||||
|
showNotice('Der verlinkte Punkt ist in dieser Notiz nicht mehr vorhanden');
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [initialFocusedBlockId, doc?.id, showNotice]);
|
||||||
|
|
||||||
// The last extraction, so undoing it (toast or Strg+Z) also puts the new note into the trash
|
// The last extraction, so undoing it (toast or Strg+Z) also puts the new note into the trash
|
||||||
const lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null);
|
const lastExtractRef = useRef<{ newDocId: string; blockId: string; link: string } | null>(null);
|
||||||
const finishUndoExtract = useCallback(() => {
|
const finishUndoExtract = useCallback(() => {
|
||||||
@@ -627,6 +716,29 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
[docId, canEdit, applyLocalBlocks]
|
[docId, canEdit, applyLocalBlocks]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A selection over several bullets was deleted (OutlinerTree): one change, so Strg+Z undoes it at once
|
||||||
|
const handleReplaceRange = useCallback(
|
||||||
|
(first: DocumentBlock, deleteIds: string[], movedChildren: DocumentBlock[]) => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
const changed = new Map([first, ...movedChildren].map((b) => [b.id, b]));
|
||||||
|
const gone = new Set(deleteIds);
|
||||||
|
applyLocalBlocks((prev) =>
|
||||||
|
ensureNonEmptyBlocks(docId, prev.filter((b) => !gone.has(b.id)).map((b) => changed.get(b.id) || b))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[docId, canEdit, applyLocalBlocks]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Swaps a bullet (with its children) with the previous or next one on the same level, within the same
|
||||||
|
// parent. By id, not by index, so it also works while zoomed into a branch (the tree then sees a slice).
|
||||||
|
const handleMoveSibling = useCallback(
|
||||||
|
(blockId: string, direction: -1 | 1) => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
applyLocalBlocks((prev) => moveSibling(prev, blockId, direction));
|
||||||
|
},
|
||||||
|
[canEdit, applyLocalBlocks]
|
||||||
|
);
|
||||||
|
|
||||||
const handleMoveBlock = useCallback(
|
const handleMoveBlock = useCallback(
|
||||||
(blockId: string, targetIndex: number, newLevel?: number) => {
|
(blockId: string, targetIndex: number, newLevel?: number) => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
@@ -684,6 +796,99 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
[docId, canEdit, applyLocalBlocks]
|
[docId, canEdit, applyLocalBlocks]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Hashtag page (variant 'tag') ---
|
||||||
|
// Shown are the bullets carrying the hashtag with all their children. The bullet with the cursor (and
|
||||||
|
// the bullets above it) and bullets just created here stay even without the hashtag, so typing never
|
||||||
|
// pulls the text away; once the cursor leaves, a bullet without the hashtag disappears.
|
||||||
|
const [tagFocusId, setTagFocusId] = useState<string | null>(null);
|
||||||
|
const [tagFreshIds, setTagFreshIds] = useState<Set<string>>(() => new Set());
|
||||||
|
const tagBlurTimerRef = useRef<number | null>(null);
|
||||||
|
const handleTagFocusChange = useCallback((id: string | null) => {
|
||||||
|
engineRef.current?.focusBlock(id);
|
||||||
|
if (tagBlurTimerRef.current !== null) window.clearTimeout(tagBlurTimerRef.current);
|
||||||
|
tagBlurTimerRef.current = null;
|
||||||
|
if (id === null) {
|
||||||
|
// Moving from one bullet to the next blurs first; only a real leave counts
|
||||||
|
tagBlurTimerRef.current = window.setTimeout(() => {
|
||||||
|
setTagFocusId(null);
|
||||||
|
setTagFreshIds((prev) => (prev.size === 0 ? prev : new Set()));
|
||||||
|
}, 150);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTagFocusId(id);
|
||||||
|
setTagFreshIds((prev) => (prev.size === 0 || prev.has(id) ? prev : new Set()));
|
||||||
|
}, []);
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (tagBlurTimerRef.current !== null) window.clearTimeout(tagBlurTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
// What was shown last time: of the bullets above the cursor only those stay that were already shown
|
||||||
|
// (adding a heading above would start the branch anew and lose the cursor)
|
||||||
|
const tagShownRef = useRef<Set<string>>(new Set());
|
||||||
|
const tagGroups = useMemo(() => {
|
||||||
|
if (!inTagPage || !tagFilter) return [] as DocumentBlock[][];
|
||||||
|
const tag = tagFilter.replace(/^#/, '').toLowerCase();
|
||||||
|
const keep = new Set<string>(tagFreshIds);
|
||||||
|
const fIdx = tagFocusId ? blocks.findIndex((b) => b.id === tagFocusId) : -1;
|
||||||
|
if (fIdx >= 0) {
|
||||||
|
keep.add(blocks[fIdx].id);
|
||||||
|
let lvl = Math.max(0, blocks[fIdx].level || 0);
|
||||||
|
for (let i = fIdx - 1; i >= 0 && lvl > 0; i--) {
|
||||||
|
const l = Math.max(0, blocks[i].level || 0);
|
||||||
|
if (l < lvl) {
|
||||||
|
if (!tagShownRef.current.has(blocks[i].id)) break;
|
||||||
|
keep.add(blocks[i].id);
|
||||||
|
lvl = l;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Runs of shown bullets: a shown bullet brings its children; gaps start a new run
|
||||||
|
const groups: DocumentBlock[][] = [];
|
||||||
|
let current: DocumentBlock[] | null = null;
|
||||||
|
let insideLevel: number | null = null;
|
||||||
|
for (const b of blocks) {
|
||||||
|
const lvl = Math.max(0, b.level || 0);
|
||||||
|
if (insideLevel !== null && lvl <= insideLevel) insideLevel = null;
|
||||||
|
const shown = insideLevel !== null || keep.has(b.id) || extractHashtags(b.content).includes(tag);
|
||||||
|
if (!shown) {
|
||||||
|
current = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!current) {
|
||||||
|
current = [];
|
||||||
|
groups.push(current);
|
||||||
|
}
|
||||||
|
current.push(b);
|
||||||
|
if (insideLevel === null) insideLevel = lvl;
|
||||||
|
}
|
||||||
|
tagShownRef.current = new Set(groups.flat().map((b) => b.id));
|
||||||
|
return groups;
|
||||||
|
}, [inTagPage, tagFilter, blocks, tagFocusId, tagFreshIds]);
|
||||||
|
const handleTagCreateBlock = useCallback(
|
||||||
|
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
||||||
|
setTagFreshIds((prev) => new Set(prev).add(newBlock.id));
|
||||||
|
handleCreateBlock(newBlock, afterBlockId);
|
||||||
|
},
|
||||||
|
[handleCreateBlock]
|
||||||
|
);
|
||||||
|
const handleTagInsertBlocks = useCallback(
|
||||||
|
(afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => {
|
||||||
|
setTagFreshIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
newBlocks.forEach((b) => next.add(b.id));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
handleInsertBlocks(afterBlockId, updatedCurrent, newBlocks);
|
||||||
|
},
|
||||||
|
[handleInsertBlocks]
|
||||||
|
);
|
||||||
|
// Dragging inside one run: its positions are positions in the whole note
|
||||||
|
const tagMoveBlock = (group: DocumentBlock[]) => (blockId: string, targetIndex: number, newLevel?: number) => {
|
||||||
|
const anchor = targetIndex < group.length ? group[targetIndex] : group[group.length - 1];
|
||||||
|
const idx = currentBlocksRef.current.findIndex((b) => b.id === anchor.id);
|
||||||
|
if (idx < 0) return;
|
||||||
|
handleMoveBlock(blockId, targetIndex < group.length ? idx : idx + 1, newLevel);
|
||||||
|
};
|
||||||
|
|
||||||
const handleTogglePin = async () => {
|
const handleTogglePin = async () => {
|
||||||
if (!doc) return;
|
if (!doc) return;
|
||||||
try {
|
try {
|
||||||
@@ -723,6 +928,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``);
|
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``);
|
||||||
} else if (b.type === 'divider') {
|
} else if (b.type === 'divider') {
|
||||||
lines.push(`${indent}- ---`);
|
lines.push(`${indent}- ---`);
|
||||||
|
} else if (b.type === 'numbered') {
|
||||||
|
lines.push(`${indent}1. ${b.content}`);
|
||||||
} else {
|
} else {
|
||||||
lines.push(`${indent}- ${b.content}`);
|
lines.push(`${indent}- ${b.content}`);
|
||||||
}
|
}
|
||||||
@@ -762,16 +969,216 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const breadcrumbNav = (
|
||||||
|
<nav
|
||||||
|
aria-label="Breadcrumb Navigation"
|
||||||
|
className="flex items-center flex-wrap gap-1 text-xs text-muted"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setZoomedBlockId(null)}
|
||||||
|
className="hover:text-accent inline-flex items-center gap-1 font-medium transition-colors cursor-pointer"
|
||||||
|
title="Zurück zum Hauptdokument"
|
||||||
|
>
|
||||||
|
<Home className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||||
|
<span className="truncate max-w-[130px]">
|
||||||
|
{title || doc?.title || 'Dokument'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{breadcrumbs.slice(1).map((crumb, idx) => {
|
||||||
|
const isCurrent = idx === breadcrumbs.length - 2;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={crumb.id || idx}>
|
||||||
|
<ChevronRight className="w-3 h-3 text-faint shrink-0" />
|
||||||
|
{isCurrent ? (
|
||||||
|
<span
|
||||||
|
className="font-bold text-accent truncate max-w-[160px]"
|
||||||
|
title={crumb.title}
|
||||||
|
>
|
||||||
|
{crumb.title || 'Punkt'}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setZoomedBlockId(crumb.id)}
|
||||||
|
className="hover:text-main truncate max-w-[120px] transition-colors cursor-pointer"
|
||||||
|
title={`Zoom zu "${crumb.title}"`}
|
||||||
|
>
|
||||||
|
{crumb.title || 'Punkt'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Undo/redo, share and the menu; on large screens they sit in the workspace tab bar instead
|
||||||
|
const actionsBlock = (
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
{!inTrash && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUndo}
|
||||||
|
disabled={!canEdit || !engineState?.canUndo}
|
||||||
|
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
title="Rückgängig (Strg+Z)"
|
||||||
|
aria-label="Rückgängig (Strg+Z)"
|
||||||
|
>
|
||||||
|
<Undo2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRedo}
|
||||||
|
disabled={!canEdit || !engineState?.canRedo}
|
||||||
|
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
title="Wiederholen (Strg+Y)"
|
||||||
|
aria-label="Wiederholen (Strg+Y)"
|
||||||
|
>
|
||||||
|
<Redo2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{doc && !inTrash && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenShareModal(doc)}
|
||||||
|
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
|
||||||
|
title="Notiz teilen und Berechtigungen"
|
||||||
|
aria-label="Notiz teilen und Berechtigungen"
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative" ref={actionsMenuRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowActionsMenu((prev) => !prev)}
|
||||||
|
aria-expanded={showActionsMenu}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label="Weitere Aktionen"
|
||||||
|
title="Weitere Aktionen"
|
||||||
|
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showActionsMenu && (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute right-0 top-full mt-1.5 w-60 rounded-card bg-panel border border-border shadow-lg p-1 z-30 text-xs origin-top-right animate-in fade-in zoom-in-95 slide-in-from-top-1 duration-150"
|
||||||
|
>
|
||||||
|
{!inTrash && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setShowActionsMenu(false);
|
||||||
|
void handleTogglePin();
|
||||||
|
}}
|
||||||
|
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Pin
|
||||||
|
className={`w-3.5 h-3.5 shrink-0 ${
|
||||||
|
doc?.isPinned ? 'text-status-warn' : 'text-accent'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span>{doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onRequestMerge && doc?.isOwner && !inTrash && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setShowActionsMenu(false);
|
||||||
|
onRequestMerge(doc);
|
||||||
|
}}
|
||||||
|
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<GitMerge className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||||
|
<span>Mit anderer Notiz zusammenführen…</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onToggleFocusMode && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setShowActionsMenu(false);
|
||||||
|
onToggleFocusMode();
|
||||||
|
}}
|
||||||
|
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{focusMode ? (
|
||||||
|
<Minimize2 className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Maximize2 className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="flex-1">{focusMode ? 'Fokus-Modus beenden' : 'Fokus-Modus starten'}</span>
|
||||||
|
<kbd className="hidden sm:inline text-[10px] text-faint font-mono">Strg+⇧+F</kbd>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setShowActionsMenu(false);
|
||||||
|
handleExportMarkdown();
|
||||||
|
}}
|
||||||
|
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||||
|
<span>Als Markdown (.md) exportieren</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
|
||||||
|
<>
|
||||||
|
<div className="my-1 border-t border-border" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setShowActionsMenu(false);
|
||||||
|
onDeleteDoc(docId);
|
||||||
|
}}
|
||||||
|
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-status-err/10 text-left text-status-err font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5 text-status-err shrink-0" />
|
||||||
|
<span>Notiz löschen</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col h-full min-h-0 bg-canvas overflow-y-auto" data-sync-channel={engineState?.channel}>
|
<div
|
||||||
{/* Top Document Action Bar */}
|
ref={scrollRef}
|
||||||
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-canvas border-b border-border">
|
className={embedded ? 'flex flex-col bg-editor' : 'flex-1 flex flex-col h-full min-h-0 bg-editor overflow-y-auto'}
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
data-sync-channel={engineState?.channel}
|
||||||
|
>
|
||||||
|
{desktopBar && actionsTarget && createPortal(actionsBlock, actionsTarget)}
|
||||||
|
|
||||||
|
{/* Top Document Action Bar (the journal has one for all days; large screens use the tab bar) */}
|
||||||
|
{!embedded && !desktopBar && (
|
||||||
|
<div className="sticky top-0 z-20 flex items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-editor border-b border-border">
|
||||||
|
<div className="flex flex-1 items-center gap-2 min-w-0">
|
||||||
{onBackMobile && (
|
{onBackMobile && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBackMobile}
|
onClick={onBackMobile}
|
||||||
className="lg:hidden btn-icon h-11 w-11 sm:h-7 sm:w-7"
|
className="lg:hidden btn-icon shrink-0 h-11 w-11 sm:h-7 sm:w-7"
|
||||||
title="Zurück zur Seitenleiste"
|
title="Zurück zur Seitenleiste"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-3.5 h-3.5" />
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
@@ -780,50 +1187,14 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
|
|
||||||
{/* Breadcrumbs Navigation (Fokus-Modus) or Doc Indicator */}
|
{/* Breadcrumbs Navigation (Fokus-Modus) or Doc Indicator */}
|
||||||
{zoomedBlock && breadcrumbs.length > 0 ? (
|
{zoomedBlock && breadcrumbs.length > 0 ? (
|
||||||
<nav
|
breadcrumbNav
|
||||||
aria-label="Breadcrumb Navigation"
|
|
||||||
className="flex items-center flex-wrap gap-1 text-xs text-muted"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setZoomedBlockId(null)}
|
|
||||||
className="hover:text-accent inline-flex items-center gap-1 font-medium transition-colors cursor-pointer"
|
|
||||||
title="Zurück zum Hauptdokument"
|
|
||||||
>
|
|
||||||
<Home className="w-3.5 h-3.5 text-accent shrink-0" />
|
|
||||||
<span className="truncate max-w-[130px]">
|
|
||||||
{title || doc?.title || 'Dokument'}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{breadcrumbs.slice(1).map((crumb, idx) => {
|
|
||||||
const isCurrent = idx === breadcrumbs.length - 2;
|
|
||||||
return (
|
|
||||||
<React.Fragment key={crumb.id || idx}>
|
|
||||||
<ChevronRight className="w-3 h-3 text-faint shrink-0" />
|
|
||||||
{isCurrent ? (
|
|
||||||
<span
|
|
||||||
className="font-bold text-accent truncate max-w-[160px]"
|
|
||||||
title={crumb.title}
|
|
||||||
>
|
|
||||||
{crumb.title || 'Punkt'}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setZoomedBlockId(crumb.id)}
|
|
||||||
className="hover:text-main truncate max-w-[120px] transition-colors cursor-pointer"
|
|
||||||
title={`Zoom zu "${crumb.title}"`}
|
|
||||||
>
|
|
||||||
{crumb.title || 'Punkt'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted">
|
<div
|
||||||
|
className={`flex min-w-0 items-center gap-1.5 text-xs text-muted transition-opacity duration-200 ${
|
||||||
|
titleScrolledAway ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
aria-hidden={!titleScrolledAway}
|
||||||
|
>
|
||||||
<FileText className="w-3.5 h-3.5 text-accent shrink-0" />
|
<FileText className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||||
<span className="truncate max-w-[180px] font-medium text-main">
|
<span className="truncate max-w-[180px] font-medium text-main">
|
||||||
{title || doc?.title || 'Dokument'}
|
{title || doc?.title || 'Dokument'}
|
||||||
@@ -867,132 +1238,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Action Buttons */}
|
{!desktopBar && actionsBlock}
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{!inTrash && (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleUndo}
|
|
||||||
disabled={!canEdit || !engineState?.canUndo}
|
|
||||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
title="Rückgängig (Strg+Z)"
|
|
||||||
aria-label="Rückgängig (Strg+Z)"
|
|
||||||
>
|
|
||||||
<Undo2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleRedo}
|
|
||||||
disabled={!canEdit || !engineState?.canRedo}
|
|
||||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
title="Wiederholen (Strg+Y)"
|
|
||||||
aria-label="Wiederholen (Strg+Y)"
|
|
||||||
>
|
|
||||||
<Redo2 className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{doc && !inTrash && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onOpenShareModal(doc)}
|
|
||||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
|
|
||||||
title="Notiz teilen und Berechtigungen"
|
|
||||||
aria-label="Notiz teilen und Berechtigungen"
|
|
||||||
>
|
|
||||||
<Users className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="relative" ref={actionsMenuRef}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowActionsMenu((prev) => !prev)}
|
|
||||||
aria-expanded={showActionsMenu}
|
|
||||||
aria-haspopup="menu"
|
|
||||||
aria-label="Weitere Aktionen"
|
|
||||||
title="Weitere Aktionen"
|
|
||||||
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showActionsMenu && (
|
|
||||||
<div
|
|
||||||
role="menu"
|
|
||||||
className="absolute right-0 top-full mt-1.5 w-60 rounded-card bg-panel border border-border shadow-lg p-1 z-30 text-xs"
|
|
||||||
>
|
|
||||||
{!inTrash && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
onClick={() => {
|
|
||||||
setShowActionsMenu(false);
|
|
||||||
void handleTogglePin();
|
|
||||||
}}
|
|
||||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<Pin
|
|
||||||
className={`w-3.5 h-3.5 shrink-0 ${
|
|
||||||
doc?.isPinned ? 'text-status-warn' : 'text-accent'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span>{doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onRequestMerge && doc?.isOwner && !inTrash && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
onClick={() => {
|
|
||||||
setShowActionsMenu(false);
|
|
||||||
onRequestMerge(doc);
|
|
||||||
}}
|
|
||||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<GitMerge className="w-3.5 h-3.5 text-accent shrink-0" />
|
|
||||||
<span>Mit anderer Notiz zusammenführen…</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
onClick={() => {
|
|
||||||
setShowActionsMenu(false);
|
|
||||||
handleExportMarkdown();
|
|
||||||
}}
|
|
||||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<Download className="w-3.5 h-3.5 text-accent shrink-0" />
|
|
||||||
<span>Als Markdown (.md) exportieren</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
|
|
||||||
<>
|
|
||||||
<div className="my-1 border-t border-border" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="menuitem"
|
|
||||||
onClick={() => {
|
|
||||||
setShowActionsMenu(false);
|
|
||||||
onDeleteDoc(docId);
|
|
||||||
}}
|
|
||||||
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-status-err/10 text-left text-status-err font-medium flex items-center gap-2 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-3.5 h-3.5 text-status-err shrink-0" />
|
|
||||||
<span>Notiz löschen</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* In the trash: read-only with a way back */}
|
{/* In the trash: read-only with a way back */}
|
||||||
{inTrash && (
|
{inTrash && (
|
||||||
@@ -1022,7 +1270,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
{/* Phones: almost no side margin; very deep outlines scroll sideways (rows keep a minimum width) */}
|
{/* Phones: almost no side margin; very deep outlines scroll sideways (rows keep a minimum width) */}
|
||||||
<div className="w-full pl-2 pr-1 sm:px-8 py-5 flex-1 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch]">
|
{/* shrink-0: the sideways scroll area must keep its full height, so only the editor scrolls vertically */}
|
||||||
|
<div
|
||||||
|
className={`w-full pl-2 pr-1 sm:px-8 flex-auto shrink-0 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch] ${
|
||||||
|
inTagPage ? 'py-2' : inJournal ? 'py-3' : 'py-5'
|
||||||
|
} ${focusMode ? 'max-w-3xl mx-auto' : ''}`}
|
||||||
|
>
|
||||||
|
{desktopBar && zoomedBlock && breadcrumbs.length > 0 && <div className="mb-3">{breadcrumbNav}</div>}
|
||||||
{zoomedBlock ? (
|
{zoomedBlock ? (
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
|
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
|
||||||
@@ -1061,7 +1315,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}}
|
}}
|
||||||
placeholder="Ast-Titel…"
|
placeholder="Ast-Titel…"
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit}
|
||||||
className="w-full text-2xl sm:text-3xl font-extrabold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight"
|
className="w-full text-2xl sm:text-3xl font-light text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1102,6 +1356,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<OutlinerTree
|
<OutlinerTree
|
||||||
|
compact={inJournal}
|
||||||
docId={docId}
|
docId={docId}
|
||||||
blocks={childBlocks}
|
blocks={childBlocks}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
@@ -1113,10 +1368,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onCreateBlock={handleCreateBlock}
|
onCreateBlock={handleCreateBlock}
|
||||||
onInsertBlocks={handleInsertBlocks}
|
onInsertBlocks={handleInsertBlocks}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
|
onReplaceRange={handleReplaceRange}
|
||||||
onMoveBlock={handleMoveBlock}
|
onMoveBlock={handleMoveBlock}
|
||||||
|
onMoveSibling={handleMoveSibling}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
onTagClick={onSelectTag}
|
onTagClick={onSelectTag}
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
|
onCopyBlockLink={handleCopyBlockLink}
|
||||||
initialFocusedBlockId={initialFocusedBlockId}
|
initialFocusedBlockId={initialFocusedBlockId}
|
||||||
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
||||||
lockedBlocks={lockedBlocks}
|
lockedBlocks={lockedBlocks}
|
||||||
@@ -1131,17 +1389,75 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
STANDARD GESAMTDOKUMENT-ANSICHT
|
STANDARD GESAMTDOKUMENT-ANSICHT
|
||||||
========================================================================= */
|
========================================================================= */
|
||||||
|
inTagPage ? (
|
||||||
|
/* Hashtag page: the note's title as a fixed heading, below the branches with the hashtag,
|
||||||
|
edited right in this note */
|
||||||
|
tagGroups.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenDocBlock?.(docId)}
|
||||||
|
className="flex items-center gap-2 min-w-0 max-w-full text-left text-lg font-semibold text-main hover:text-accent transition-colors cursor-pointer"
|
||||||
|
title="Notiz öffnen"
|
||||||
|
>
|
||||||
|
{doc?.icon && <span className="shrink-0">{doc.icon}</span>}
|
||||||
|
<span className="truncate">{title || 'Unbenannte Notiz'}</span>
|
||||||
|
{doc?.folderPath && <span className="shrink-0 text-[11px] font-normal text-faint">{doc.folderPath}</span>}
|
||||||
|
</button>
|
||||||
|
{tagGroups.map((group) => (
|
||||||
|
<OutlinerTree
|
||||||
|
key={group[0].id}
|
||||||
|
compact
|
||||||
|
trailingArea={false}
|
||||||
|
docId={docId}
|
||||||
|
blocks={group}
|
||||||
|
canEdit={canEdit}
|
||||||
|
levelOffset={Math.min(...group.map((b) => Math.max(0, b.level || 0)))}
|
||||||
|
onZoomBlock={(id) => onOpenDocBlock?.(docId, id)}
|
||||||
|
onUpdateBlock={handleUpdateBlock}
|
||||||
|
onUpdateBlocks={handleUpdateBlocks}
|
||||||
|
onCreateBlock={handleTagCreateBlock}
|
||||||
|
onInsertBlocks={handleTagInsertBlocks}
|
||||||
|
onDeleteBlock={handleDeleteBlock}
|
||||||
|
onReplaceRange={handleReplaceRange}
|
||||||
|
onMoveBlock={tagMoveBlock(group)}
|
||||||
|
onMoveSibling={handleMoveSibling}
|
||||||
|
onOpenTask={onOpenTask}
|
||||||
|
onTagClick={onSelectTag}
|
||||||
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
|
onCopyBlockLink={handleCopyBlockLink}
|
||||||
|
onFocusedBlockChange={handleTagFocusChange}
|
||||||
|
lockedBlocks={lockedBlocks}
|
||||||
|
onLockedAction={handleLockedAction}
|
||||||
|
onResolveConflict={handleResolveConflict}
|
||||||
|
onUndo={handleUndo}
|
||||||
|
onRedo={handleRedo}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Document Icon + Title Header */}
|
{/* Document Icon + Title Header */}
|
||||||
<div className="flex items-center gap-3 mb-4 relative">
|
<div className="flex items-center gap-3 mb-4 relative">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
|
onClick={() => canEdit && !inJournal && setShowEmojiPicker((prev) => !prev)}
|
||||||
className="text-2xl sm:text-3xl hover:opacity-80 transition-opacity cursor-pointer select-none leading-none bg-transparent border-0 outline-none p-0 focus:outline-none focus:ring-0 shadow-none"
|
className={`text-2xl sm:text-3xl transition-opacity select-none leading-none bg-transparent border-0 outline-none p-0 focus:outline-none focus:ring-0 shadow-none ${
|
||||||
title="Icon ändern"
|
inJournal ? 'cursor-default' : 'hover:opacity-80 cursor-pointer'
|
||||||
|
}`}
|
||||||
|
title={inJournal ? undefined : 'Icon ändern'}
|
||||||
>
|
>
|
||||||
{doc?.icon || '📝'}
|
{/* Journal days: a quiet calendar in the theme's text colour instead of the colourful emoji;
|
||||||
|
a note without an icon shows a faint "choose icon" to editors only */}
|
||||||
|
{inJournal ? (
|
||||||
|
<CalendarDays className="w-6 h-6 sm:w-7 sm:h-7 text-muted" strokeWidth={1.75} />
|
||||||
|
) : doc?.icon ? (
|
||||||
|
doc.icon
|
||||||
|
) : canEdit ? (
|
||||||
|
<SmilePlus className="w-6 h-6 sm:w-7 sm:h-7 text-faint" strokeWidth={1.5} aria-label="Icon wählen" />
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
{showEmojiPicker && canEdit && (
|
{showEmojiPicker && canEdit && (
|
||||||
<EmojiPickerModal
|
<EmojiPickerModal
|
||||||
@@ -1162,7 +1478,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
value={title}
|
value={title}
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit || inJournal}
|
||||||
autoFocus={autoFocusTitle}
|
autoFocus={autoFocusTitle}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleTitleChange(e);
|
handleTitleChange(e);
|
||||||
@@ -1183,12 +1499,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}}
|
}}
|
||||||
onBlur={handleTitleBlur}
|
onBlur={handleTitleBlur}
|
||||||
placeholder="Titel der Notiz…"
|
placeholder="Titel der Notiz…"
|
||||||
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
className="flex-1 min-w-0 text-2xl sm:text-3xl font-light text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logseq Outliner Tree */}
|
{/* Logseq Outliner Tree */}
|
||||||
<OutlinerTree
|
<OutlinerTree
|
||||||
|
compact={inJournal}
|
||||||
ref={outlinerTreeRef}
|
ref={outlinerTreeRef}
|
||||||
docId={docId}
|
docId={docId}
|
||||||
blocks={blocks}
|
blocks={blocks}
|
||||||
@@ -1201,10 +1518,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onCreateBlock={handleCreateBlock}
|
onCreateBlock={handleCreateBlock}
|
||||||
onInsertBlocks={handleInsertBlocks}
|
onInsertBlocks={handleInsertBlocks}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
|
onReplaceRange={handleReplaceRange}
|
||||||
onMoveBlock={handleMoveBlock}
|
onMoveBlock={handleMoveBlock}
|
||||||
|
onMoveSibling={handleMoveSibling}
|
||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
onTagClick={onSelectTag}
|
onTagClick={onSelectTag}
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
|
onCopyBlockLink={handleCopyBlockLink}
|
||||||
initialFocusedBlockId={initialFocusedBlockId}
|
initialFocusedBlockId={initialFocusedBlockId}
|
||||||
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
||||||
lockedBlocks={lockedBlocks}
|
lockedBlocks={lockedBlocks}
|
||||||
@@ -1214,31 +1534,37 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onRedo={handleRedo}
|
onRedo={handleRedo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{onOpenDocBlock && !inTrash && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
|
{onOpenDocBlock && !inTrash && !embedded && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
|
||||||
</>
|
</>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{notice && (
|
{notice && (
|
||||||
<div
|
<div
|
||||||
role="status"
|
role="status"
|
||||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel"
|
className="fixed bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-6 left-1/2 -translate-x-1/2 z-50 w-[calc(100vw-1.5rem)] sm:w-auto sm:max-w-[90vw] flex flex-col sm:block pl-4 pr-1 pt-3 pb-1 sm:px-3 sm:py-2 rounded-card text-sm sm:text-xs text-main bg-panel-elevated border border-border shadow-panel"
|
||||||
>
|
>
|
||||||
<span>{notice}</span>
|
{/* Phones: text on top (two lines at most), buttons below; desktop: one line */}
|
||||||
{noticeActions.map((a) => (
|
<span className="max-sm:pr-3 max-sm:line-clamp-2">{notice}</span>
|
||||||
<button
|
{noticeActions.length > 0 && (
|
||||||
key={a.label}
|
<span className="flex justify-end sm:inline">
|
||||||
type="button"
|
{noticeActions.map((a) => (
|
||||||
onClick={() => {
|
<button
|
||||||
setNotice(null);
|
key={a.label}
|
||||||
setNoticeActions([]);
|
type="button"
|
||||||
a.run();
|
onClick={() => {
|
||||||
}}
|
setNotice(null);
|
||||||
className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0"
|
setNoticeActions([]);
|
||||||
>
|
a.run();
|
||||||
{a.label}
|
}}
|
||||||
</button>
|
className="h-11 px-3 sm:h-auto sm:px-0 sm:ml-3 font-semibold text-accent hover:underline"
|
||||||
))}
|
>
|
||||||
|
{a.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { Suspense, useEffect, useRef, useState } from 'react';
|
import React, { Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { X, Trash2, Loader2 } from 'lucide-react';
|
import { X, Trash2, Loader2 } from 'lucide-react';
|
||||||
import type { EmojiClickData } from 'emoji-picker-react';
|
import type { EmojiClickData } from 'emoji-picker-react';
|
||||||
import { Categories, EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
|
import { Categories, EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
|
||||||
@@ -25,17 +26,21 @@ interface EmojiPickerModalProps {
|
|||||||
currentIcon?: string;
|
currentIcon?: string;
|
||||||
onSelectEmoji: (emoji: string) => void;
|
onSelectEmoji: (emoji: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
// Inside a dialog (which clips and transforms its content): the picker is put on the page instead and
|
||||||
|
// opens below this button on desktop
|
||||||
|
anchor?: DOMRect | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||||
currentIcon,
|
currentIcon,
|
||||||
onSelectEmoji,
|
onSelectEmoji,
|
||||||
onClose,
|
onClose,
|
||||||
|
anchor,
|
||||||
}) => {
|
}) => {
|
||||||
const { inset, visibleHeight } = useKeyboardInset();
|
const { inset, visibleHeight } = useKeyboardInset();
|
||||||
const isPhone = typeof window !== 'undefined' && window.innerWidth < 640;
|
const isPhone = typeof window !== 'undefined' && window.innerWidth < 640;
|
||||||
// On phones the picker shrinks with the keyboard so search field and results stay visible
|
// On phones the picker shrinks with the keyboard so search field and results stay visible
|
||||||
const pickerHeight = isPhone ? Math.max(200, Math.min(350, visibleHeight - 110)) : 350;
|
const pickerHeight = isPhone ? Math.max(200, Math.min(350, visibleHeight - 110)) : 300;
|
||||||
const [isDark, setIsDark] = useState<boolean>(() =>
|
const [isDark, setIsDark] = useState<boolean>(() =>
|
||||||
typeof document !== 'undefined'
|
typeof document !== 'undefined'
|
||||||
? document.documentElement.classList.contains('dark')
|
? document.documentElement.classList.contains('dark')
|
||||||
@@ -61,6 +66,7 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation(); // only the picker closes, not the dialog it was opened from
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -74,10 +80,10 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown, true);
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown, true);
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
@@ -90,11 +96,18 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
handlePick(emojiData.emoji);
|
handlePick(emojiData.emoji);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const anchorStyle = anchor
|
||||||
|
? ({
|
||||||
|
'--pick-left': `${Math.max(12, Math.min(anchor.left, window.innerWidth - 312))}px`,
|
||||||
|
'--pick-top': `${Math.min(anchor.bottom + 4, window.innerHeight - 360)}px`,
|
||||||
|
} as React.CSSProperties)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const picker = (
|
||||||
<>
|
<>
|
||||||
{/* Mobile Backdrop (< 640px) */}
|
{/* Mobile Backdrop (< 640px) */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/60 z-40 sm:hidden backdrop-blur-xs"
|
className={`fixed inset-0 bg-black/60 sm:hidden backdrop-blur-xs ${anchor ? 'z-[60]' : 'z-40'}`}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
@@ -102,23 +115,21 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
{/* Main Container: Mobile Bottom-Sheet (<640px) & Desktop Popover (>=640px) */}
|
{/* Main Container: Mobile Bottom-Sheet (<640px) & Desktop Popover (>=640px) */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="
|
className={`
|
||||||
fixed inset-x-0 bottom-[var(--kb-inset)] z-50 rounded-t-2xl max-h-[var(--vv-max)] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3
|
fixed inset-x-0 bottom-[var(--kb-inset)] rounded-t-2xl max-h-[var(--vv-max)] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3
|
||||||
sm:absolute sm:inset-auto sm:left-0 sm:top-full sm:mt-1 sm:z-40 sm:w-[360px] sm:max-h-[520px] sm:rounded-card sm:border sm:border-border sm:shadow-xl
|
sm:inset-auto sm:w-[300px] sm:max-h-[360px] sm:p-1 sm:space-y-0 sm:bg-panel-elevated sm:rounded-card sm:border sm:border-border sm:shadow-xl ${
|
||||||
"
|
anchor
|
||||||
|
? 'z-[61] sm:fixed sm:left-[var(--pick-left)] sm:top-[max(12px,var(--pick-top))]'
|
||||||
|
: 'z-50 sm:absolute sm:left-0 sm:top-full sm:mt-1 sm:z-40'
|
||||||
|
}
|
||||||
|
`}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
// Inject MindSift CSS variables into emoji-picker-react for seamless theming
|
...anchorStyle,
|
||||||
'--epr-bg-color': 'var(--bg-panel)',
|
// Theme colours for the picker (index.css); desktop: its own raised surface, so it stands apart
|
||||||
'--epr-category-label-bg-color': 'var(--bg-panel)',
|
// from the dialog it opens from
|
||||||
'--epr-picker-border-color': 'var(--border-color)',
|
'--pick-surface': isPhone ? 'var(--bg-panel)' : 'var(--bg-panel-elevated)',
|
||||||
'--epr-text-color': 'var(--text-main)',
|
'--pick-hover': isPhone ? 'var(--bg-panel-elevated)' : 'var(--bg-panel-hover)',
|
||||||
'--epr-search-input-bg-color': 'var(--bg-app)',
|
|
||||||
'--epr-search-input-bg-color-active': 'var(--bg-panel-elevated)',
|
|
||||||
'--epr-hover-bg-color': 'var(--bg-panel-elevated)',
|
|
||||||
'--epr-focus-bg-color': 'var(--bg-panel-elevated)',
|
|
||||||
'--epr-highlight-color': 'var(--accent-primary)',
|
|
||||||
'--epr-category-icon-active-color': 'var(--accent-primary)',
|
|
||||||
'--kb-inset': `${inset}px`,
|
'--kb-inset': `${inset}px`,
|
||||||
'--vv-max': `${Math.round(visibleHeight * 0.9)}px`,
|
'--vv-max': `${Math.round(visibleHeight * 0.9)}px`,
|
||||||
} as React.CSSProperties
|
} as React.CSSProperties
|
||||||
@@ -127,8 +138,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
{/* Mobile handle indicator */}
|
{/* Mobile handle indicator */}
|
||||||
<div className="w-10 h-1 bg-border rounded-full mx-auto sm:hidden shrink-0" />
|
<div className="w-10 h-1 bg-border rounded-full mx-auto sm:hidden shrink-0" />
|
||||||
|
|
||||||
{/* Top Header: Title, Actions & Close */}
|
{/* Top Header: Title, Actions & Close (phone only; on desktop a click outside closes it) */}
|
||||||
<div className="flex items-center justify-between gap-2 shrink-0 border-b border-border pb-2">
|
<div className="flex sm:hidden items-center justify-between gap-2 shrink-0 border-b border-border pb-2">
|
||||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-main">
|
<div className="flex items-center gap-1.5 text-xs font-semibold text-main">
|
||||||
<span>Icon auswählen</span>
|
<span>Icon auswählen</span>
|
||||||
{currentIcon && (
|
{currentIcon && (
|
||||||
@@ -190,7 +201,20 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: removing the icon (none at all) as a quiet last row, like an entry of a context menu */}
|
||||||
|
{currentIcon && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectEmoji('')}
|
||||||
|
className="hidden sm:flex items-center gap-1.5 w-full h-7 px-2 mt-1 rounded-btn text-xs text-muted hover:text-status-err hover:bg-panel-hover transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
Icon entfernen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
return anchor ? createPortal(picker, document.body) : picker;
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { MONTHS, monthGrid, parseISODate, todayISO } from '../../lib/journal';
|
||||||
|
|
||||||
|
interface JournalCalendarProps {
|
||||||
|
/** Day currently in view; the calendar follows its month */
|
||||||
|
activeDate: string;
|
||||||
|
/** Days with written entries (get a dot) */
|
||||||
|
entryDates: Set<string>;
|
||||||
|
onSelect: (date: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||||
|
|
||||||
|
export const JournalCalendar: React.FC<JournalCalendarProps> = ({ activeDate, entryDates, onSelect }) => {
|
||||||
|
const active = parseISODate(activeDate);
|
||||||
|
const [view, setView] = useState({ year: active.getFullYear(), month: active.getMonth() });
|
||||||
|
useEffect(() => {
|
||||||
|
const d = parseISODate(activeDate);
|
||||||
|
setView({ year: d.getFullYear(), month: d.getMonth() });
|
||||||
|
}, [activeDate]);
|
||||||
|
|
||||||
|
const today = todayISO();
|
||||||
|
const weeks = useMemo(() => monthGrid(view.year, view.month), [view]);
|
||||||
|
const years = useMemo(() => {
|
||||||
|
const now = new Date().getFullYear();
|
||||||
|
let first = now - 5;
|
||||||
|
entryDates.forEach((d) => {
|
||||||
|
first = Math.min(first, Number(d.slice(0, 4)));
|
||||||
|
});
|
||||||
|
const list: number[] = [];
|
||||||
|
for (let y = now + 1; y >= first; y--) list.push(y);
|
||||||
|
return list;
|
||||||
|
}, [entryDates]);
|
||||||
|
|
||||||
|
const shiftMonth = (delta: number) =>
|
||||||
|
setView((v) => {
|
||||||
|
const d = new Date(v.year, v.month + delta, 1);
|
||||||
|
return { year: d.getFullYear(), month: d.getMonth() };
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="select-none text-xs" aria-label="Journal-Kalender">
|
||||||
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => shiftMonth(-1)}
|
||||||
|
className="h-9 w-9 sm:h-7 sm:w-7 shrink-0 rounded-btn flex items-center justify-center text-muted hover:text-main hover:bg-panel transition-colors cursor-pointer"
|
||||||
|
title="Vorheriger Monat"
|
||||||
|
aria-label="Vorheriger Monat"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
value={view.month}
|
||||||
|
onChange={(e) => setView((v) => ({ ...v, month: Number(e.target.value) }))}
|
||||||
|
className="flex-1 min-w-0 h-9 sm:h-7 px-1.5 rounded-btn bg-panel text-main border-0 text-xs font-semibold outline-none focus:ring-1 focus:ring-accent cursor-pointer"
|
||||||
|
aria-label="Monat"
|
||||||
|
>
|
||||||
|
{MONTHS.map((m, i) => (
|
||||||
|
<option key={m} value={i}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={view.year}
|
||||||
|
onChange={(e) => setView((v) => ({ ...v, year: Number(e.target.value) }))}
|
||||||
|
className="w-[4.5rem] h-9 sm:h-7 px-1.5 rounded-btn bg-panel text-main border-0 text-xs font-semibold outline-none focus:ring-1 focus:ring-accent cursor-pointer"
|
||||||
|
aria-label="Jahr"
|
||||||
|
>
|
||||||
|
{years.map((y) => (
|
||||||
|
<option key={y} value={y}>
|
||||||
|
{y}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => shiftMonth(1)}
|
||||||
|
className="h-9 w-9 sm:h-7 sm:w-7 shrink-0 rounded-btn flex items-center justify-center text-muted hover:text-main hover:bg-panel transition-colors cursor-pointer"
|
||||||
|
title="Nächster Monat"
|
||||||
|
aria-label="Nächster Monat"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-0.5 text-center text-[10px] text-faint font-semibold mb-0.5">
|
||||||
|
{WEEKDAYS.map((w) => (
|
||||||
|
<span key={w}>{w}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-0.5">
|
||||||
|
{weeks.flat().map((date, i) =>
|
||||||
|
date ? (
|
||||||
|
<button
|
||||||
|
key={date}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(date)}
|
||||||
|
aria-current={date === activeDate ? 'date' : undefined}
|
||||||
|
title={parseISODate(date).toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
className={`relative h-9 sm:h-7 rounded-btn text-xs tabular-nums transition-colors cursor-pointer ${
|
||||||
|
date === activeDate
|
||||||
|
? 'bg-accent-dim text-accent font-bold'
|
||||||
|
: date === today
|
||||||
|
? 'text-accent font-semibold ring-1 ring-inset ring-accent-border hover:bg-panel-elevated'
|
||||||
|
: 'text-main hover:bg-panel-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{Number(date.slice(8))}
|
||||||
|
{entryDates.has(date) && (
|
||||||
|
<span className="absolute left-1/2 -translate-x-1/2 bottom-0.5 w-1 h-1 rounded-full bg-accent" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span key={`empty-${i}`} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { ArrowLeft, CalendarDays, ChevronDown, Users } from 'lucide-react';
|
||||||
|
import { Document, JournalDay, User } from '../../lib/types';
|
||||||
|
import { shortDateLabel, todayISO } from '../../lib/journal';
|
||||||
|
import { DocumentEditor } from './DocumentEditor';
|
||||||
|
import { JournalCalendar } from './JournalCalendar';
|
||||||
|
|
||||||
|
const PAGE = 7;
|
||||||
|
|
||||||
|
interface JournalViewProps {
|
||||||
|
days: JournalDay[];
|
||||||
|
/** Days jumped to over the calendar (shown even while still empty) */
|
||||||
|
extraDates: string[];
|
||||||
|
activeDate: string;
|
||||||
|
onActiveDateChange: (date: string) => void;
|
||||||
|
/** Set by the calendar; the view scrolls there (nonce: the same day again) */
|
||||||
|
jumpRequest: { date: string; nonce: number } | null;
|
||||||
|
/** Opens (creates on first use) the note of a day */
|
||||||
|
onOpenDay: (date: string) => Promise<Document | null>;
|
||||||
|
onJump: (date: string) => void;
|
||||||
|
getDoc: (id: string) => Document | undefined;
|
||||||
|
currentUser?: User | null;
|
||||||
|
availableFolders: string[];
|
||||||
|
onOpenShareModal: (doc: Document) => void;
|
||||||
|
onDocMetadataChange: (updated: Document) => void;
|
||||||
|
onDeleteDoc: (docId: string) => void;
|
||||||
|
onSelectTag: (tag: string) => void;
|
||||||
|
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
|
||||||
|
onOpenDocBlock?: (docId: string, blockId?: string) => void;
|
||||||
|
onOpenTask?: (taskId: string) => void;
|
||||||
|
onBackMobile: () => void;
|
||||||
|
/** A day was saved (its calendar dot may change) */
|
||||||
|
onDaySaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The journal: one note per day below each other, today on top, older days load while scrolling.
|
||||||
|
export const JournalView: React.FC<JournalViewProps> = ({
|
||||||
|
days,
|
||||||
|
extraDates,
|
||||||
|
activeDate,
|
||||||
|
onActiveDateChange,
|
||||||
|
jumpRequest,
|
||||||
|
onOpenDay,
|
||||||
|
onJump,
|
||||||
|
getDoc,
|
||||||
|
currentUser,
|
||||||
|
availableFolders,
|
||||||
|
onOpenShareModal,
|
||||||
|
onDocMetadataChange,
|
||||||
|
onDeleteDoc,
|
||||||
|
onSelectTag,
|
||||||
|
onWikiLinkClick,
|
||||||
|
onOpenDocBlock,
|
||||||
|
onOpenTask,
|
||||||
|
onBackMobile,
|
||||||
|
onDaySaved,
|
||||||
|
}) => {
|
||||||
|
const today = todayISO();
|
||||||
|
const docIdByDate = useMemo(() => new Map(days.map((d) => [d.date, d.docId])), [days]);
|
||||||
|
const entryDates = useMemo(() => new Set(days.filter((d) => !d.empty).map((d) => d.date)), [days]);
|
||||||
|
|
||||||
|
// Today, every day with text and the days jumped to – newest first
|
||||||
|
const dates = useMemo(() => {
|
||||||
|
const set = new Set<string>([today, ...extraDates]);
|
||||||
|
days.forEach((d) => {
|
||||||
|
if (!d.empty) set.add(d.date);
|
||||||
|
});
|
||||||
|
return Array.from(set).sort((a, b) => b.localeCompare(a));
|
||||||
|
}, [days, extraDates, today]);
|
||||||
|
|
||||||
|
const [limit, setLimit] = useState(PAGE);
|
||||||
|
const shown = dates.slice(0, limit);
|
||||||
|
const [showCalendar, setShowCalendar] = useState(false);
|
||||||
|
|
||||||
|
// Days on screen without a note yet (today, a day jumped to) get one
|
||||||
|
const opening = useRef(new Set<string>());
|
||||||
|
useEffect(() => {
|
||||||
|
shown.forEach((date) => {
|
||||||
|
if (!docIdByDate.has(date) && !opening.current.has(date)) {
|
||||||
|
opening.current.add(date);
|
||||||
|
void onOpenDay(date).finally(() => opening.current.delete(date));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [shown, docIdByDate, onOpenDay]);
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const sectionRefs = useRef(new Map<string, HTMLElement>());
|
||||||
|
|
||||||
|
// Jump: make sure the day is rendered, then scroll to it once its note is there
|
||||||
|
const pendingJump = useRef<string | null>(null);
|
||||||
|
const handledJump = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jumpRequest || handledJump.current === jumpRequest.nonce) return;
|
||||||
|
const idx = dates.indexOf(jumpRequest.date);
|
||||||
|
if (idx === -1) return; // the parent adds the day first
|
||||||
|
handledJump.current = jumpRequest.nonce;
|
||||||
|
if (idx >= limit) setLimit(idx + PAGE);
|
||||||
|
pendingJump.current = jumpRequest.date;
|
||||||
|
}, [jumpRequest, dates, limit]);
|
||||||
|
useEffect(() => {
|
||||||
|
const date = pendingJump.current;
|
||||||
|
if (!date) return;
|
||||||
|
const el = sectionRefs.current.get(date);
|
||||||
|
if (el && docIdByDate.has(date)) {
|
||||||
|
pendingJump.current = null;
|
||||||
|
el.scrollIntoView({ block: 'start' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The day at the top of the scroll area is the active one (calendar, share button)
|
||||||
|
const onScroll = useCallback(() => {
|
||||||
|
const root = scrollRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const top = root.getBoundingClientRect().top + 48;
|
||||||
|
let current = shown[0];
|
||||||
|
for (const date of shown) {
|
||||||
|
const el = sectionRefs.current.get(date);
|
||||||
|
if (!el) continue;
|
||||||
|
if (el.getBoundingClientRect().top <= top) current = date;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
if (current && current !== activeDate) onActiveDateChange(current);
|
||||||
|
}, [shown, activeDate, onActiveDateChange]);
|
||||||
|
|
||||||
|
// Load older days when the end comes into view
|
||||||
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
const root = scrollRef.current;
|
||||||
|
if (!el || !root || typeof IntersectionObserver === 'undefined' || limit >= dates.length) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) setLimit((l) => l + PAGE);
|
||||||
|
},
|
||||||
|
{ root, rootMargin: '400px 0px' }
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [limit, dates.length]);
|
||||||
|
|
||||||
|
const activeDocId = docIdByDate.get(activeDate);
|
||||||
|
const activeDoc = activeDocId ? getDoc(activeDocId) : undefined;
|
||||||
|
const sharedCount = activeDoc?.sharedWith?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 bg-editor view-enter">
|
||||||
|
{/* One header for all days: the day in view and its sharing */}
|
||||||
|
<div className="shrink-0 flex items-center gap-2 px-4 sm:px-6 py-2 border-b border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBackMobile}
|
||||||
|
className="lg:hidden btn-icon shrink-0 h-11 w-11"
|
||||||
|
title="Zurück zur Seitenleiste"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCalendar((v) => !v)}
|
||||||
|
className="lg:pointer-events-none flex-1 min-w-0 flex items-center gap-1.5 text-xs text-main font-medium text-left cursor-pointer"
|
||||||
|
aria-expanded={showCalendar}
|
||||||
|
title="Kalender"
|
||||||
|
>
|
||||||
|
<CalendarDays className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||||
|
<span className="truncate">Journal · {shortDateLabel(activeDate)}</span>
|
||||||
|
<ChevronDown className={`lg:hidden w-3.5 h-3.5 text-muted shrink-0 transition-transform ${showCalendar ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{activeDate !== today && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onJump(today)}
|
||||||
|
className="btn-action shrink-0 h-11 sm:h-7 px-2.5 text-xs"
|
||||||
|
>
|
||||||
|
Heute
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!activeDoc || !activeDoc.isOwner}
|
||||||
|
onClick={() => activeDoc && onOpenShareModal(activeDoc)}
|
||||||
|
className="btn-icon shrink-0 h-11 w-11 sm:h-7 sm:w-auto sm:px-2 gap-1 disabled:opacity-40"
|
||||||
|
title={activeDoc ? `${activeDoc.title} teilen` : 'Teilen'}
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline text-xs">{sharedCount > 0 ? `Geteilt (${sharedCount})` : 'Teilen'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCalendar && (
|
||||||
|
<div className="lg:hidden shrink-0 px-4 py-3 border-b border-border bg-sidebar animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
|
<JournalCalendar
|
||||||
|
activeDate={activeDate}
|
||||||
|
entryDates={entryDates}
|
||||||
|
onSelect={(date) => {
|
||||||
|
setShowCalendar(false);
|
||||||
|
onJump(date);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={scrollRef} onScroll={onScroll} className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
{shown.map((date) => {
|
||||||
|
const docId = docIdByDate.get(date);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={date}
|
||||||
|
data-journal-date={date}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) sectionRefs.current.set(date, el);
|
||||||
|
else sectionRefs.current.delete(date);
|
||||||
|
}}
|
||||||
|
className="border-b border-border last:border-b-0 min-h-[8rem]"
|
||||||
|
>
|
||||||
|
{docId ? (
|
||||||
|
<DocumentEditor
|
||||||
|
key={docId}
|
||||||
|
variant="journal"
|
||||||
|
docId={docId}
|
||||||
|
currentUser={currentUser}
|
||||||
|
initialDoc={getDoc(docId) || null}
|
||||||
|
availableFolders={availableFolders}
|
||||||
|
onDocMetadataChange={onDocMetadataChange}
|
||||||
|
onDeleteDoc={onDeleteDoc}
|
||||||
|
onOpenShareModal={onOpenShareModal}
|
||||||
|
onSelectTag={onSelectTag}
|
||||||
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
|
onOpenDocBlock={onOpenDocBlock}
|
||||||
|
onOpenTask={onOpenTask}
|
||||||
|
onSaveStatusChange={(status) => {
|
||||||
|
if (status === 'saved') onDaySaved();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 sm:px-8 py-6 text-xs text-muted">{shortDateLabel(date)} wird geöffnet…</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{limit < dates.length && <div ref={sentinelRef} className="h-12" />}
|
||||||
|
{/* Room below the oldest day, so every day can be scrolled to the top */}
|
||||||
|
<div className="h-[60vh]" aria-hidden />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,9 +8,13 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
|
ArrowLeft,
|
||||||
|
ListTree,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { api } from '../../lib/api';
|
import { api } from '../../lib/api';
|
||||||
import { Document } from '../../lib/types';
|
import { Document, DocumentBlock } from '../../lib/types';
|
||||||
|
import { buildBlockLink } from '../../lib/wikiLink';
|
||||||
|
import { SnippetRenderer } from '../../components/SnippetRenderer';
|
||||||
|
|
||||||
export interface LinkModalProps {
|
export interface LinkModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -100,6 +104,81 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
|||||||
);
|
);
|
||||||
}, [docs, search]);
|
}, [docs, search]);
|
||||||
|
|
||||||
|
// Picking a single bullet of a note (link to that bullet)
|
||||||
|
const [pickDoc, setPickDoc] = useState<Document | null>(null);
|
||||||
|
const [pickBlocks, setPickBlocks] = useState<DocumentBlock[] | null>(null);
|
||||||
|
const [blockSearch, setBlockSearch] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setPickDoc(null);
|
||||||
|
setPickBlocks(null);
|
||||||
|
setBlockSearch('');
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
const openBlockPicker = (doc: Document) => {
|
||||||
|
setPickDoc(doc);
|
||||||
|
setBlockSearch('');
|
||||||
|
if (doc.blocks && doc.blocks.length > 0) {
|
||||||
|
setPickBlocks(doc.blocks);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPickBlocks(null);
|
||||||
|
api
|
||||||
|
.getDocument(doc.id)
|
||||||
|
.then((full) => setPickBlocks(full.blocks || []))
|
||||||
|
.catch(() => setPickBlocks([]));
|
||||||
|
};
|
||||||
|
const pickableBlocks = useMemo(() => {
|
||||||
|
const q = blockSearch.trim().toLowerCase();
|
||||||
|
return (pickBlocks || []).filter(
|
||||||
|
(b) => b.content.trim() !== '' && b.type !== 'divider' && (!q || b.content.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}, [pickBlocks, blockSearch]);
|
||||||
|
|
||||||
|
// Bullets of all notes matching the search (full-text index, like the global search), from 3 letters
|
||||||
|
type BlockHit = { docId: string; blockId: string; title: string; icon?: string; snippet: string; content: string };
|
||||||
|
const [blockHits, setBlockHits] = useState<BlockHit[]>([]);
|
||||||
|
const [blockHitsLoading, setBlockHitsLoading] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const q = search.trim();
|
||||||
|
if (!isOpen || tab !== 'note' || pickDoc || q.length < 3) {
|
||||||
|
setBlockHits([]);
|
||||||
|
setBlockHitsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setBlockHitsLoading(true);
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
api
|
||||||
|
.searchAll(q)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const docsById = new Map((res.documents || []).map((d) => [d.id, d]));
|
||||||
|
const hits = (res.hits || [])
|
||||||
|
.filter((h) => h.type === 'document' && h.blockId && h.docId)
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((h) => {
|
||||||
|
const block = docsById.get(h.docId!)?.blocks?.find((b) => b.id === h.blockId);
|
||||||
|
return {
|
||||||
|
docId: h.docId!,
|
||||||
|
blockId: h.blockId!,
|
||||||
|
title: h.title,
|
||||||
|
icon: h.icon,
|
||||||
|
snippet: h.snippet,
|
||||||
|
content: block?.content ?? h.snippet.replace(/<\/?mark>/g, ''),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setBlockHits(hits);
|
||||||
|
})
|
||||||
|
.catch(() => !cancelled && setBlockHits([]))
|
||||||
|
.finally(() => !cancelled && setBlockHitsLoading(false));
|
||||||
|
}, 250);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [search, isOpen, tab, pickDoc]);
|
||||||
|
|
||||||
const handleSelectNote = (doc: { title: string }) => {
|
const handleSelectNote = (doc: { title: string }) => {
|
||||||
const noteTitle = doc.title.trim() || 'Notiz';
|
const noteTitle = doc.title.trim() || 'Notiz';
|
||||||
onInsertLink(`[[${noteTitle}]]`);
|
onInsertLink(`[[${noteTitle}]]`);
|
||||||
@@ -244,8 +323,59 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bullets of one note: link to a single bullet */}
|
||||||
|
{tab === 'note' && pickDoc && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPickDoc(null)}
|
||||||
|
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 shrink-0"
|
||||||
|
title="Zurück zur Notizliste"
|
||||||
|
aria-label="Zurück zur Notizliste"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-semibold text-main truncate">
|
||||||
|
Punkt in „{pickDoc.title || 'Unbenannte Notiz'}“ wählen
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={blockSearch}
|
||||||
|
onChange={(e) => setBlockSearch(e.target.value)}
|
||||||
|
placeholder="Punkte filtern…"
|
||||||
|
className="input-field w-full h-11 sm:h-8 px-2.5 text-xs"
|
||||||
|
aria-label="Punkte filtern"
|
||||||
|
/>
|
||||||
|
<div className="space-y-0.5 max-h-64 overflow-y-auto pr-0.5">
|
||||||
|
{pickBlocks === null ? (
|
||||||
|
<div className="p-4 text-xs text-muted flex items-center gap-2 justify-center">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-accent" />
|
||||||
|
<span>Punkte werden geladen…</span>
|
||||||
|
</div>
|
||||||
|
) : pickableBlocks.length === 0 ? (
|
||||||
|
<div className="p-4 text-xs text-muted text-center">Keine passenden Punkte.</div>
|
||||||
|
) : (
|
||||||
|
pickableBlocks.map((b) => (
|
||||||
|
<button
|
||||||
|
key={b.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onInsertLink(buildBlockLink(pickDoc.title || 'Notiz', b.id, b.content))}
|
||||||
|
className="w-full text-left min-h-[44px] sm:min-h-0 py-1.5 pr-2 rounded-btn text-xs text-main hover:bg-panel-elevated hover:text-accent flex items-start gap-1.5 cursor-pointer"
|
||||||
|
style={{ paddingLeft: `${0.5 + Math.min(b.level || 0, 5) * 0.875}rem` }}
|
||||||
|
>
|
||||||
|
<span className="text-faint shrink-0">•</span>
|
||||||
|
<span className="line-clamp-2 break-words">{b.content}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Note List Section (Matching Active Shares list in UnifiedShareModal) */}
|
{/* Note List Section (Matching Active Shares list in UnifiedShareModal) */}
|
||||||
{tab === 'note' && (
|
{tab === 'note' && !pickDoc && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5">
|
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5">
|
||||||
<span>Verfügbare Notizen</span>
|
<span>Verfügbare Notizen</span>
|
||||||
@@ -277,7 +407,7 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
|||||||
className="flex items-center justify-between p-2.5 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors group cursor-pointer"
|
className="flex items-center justify-between p-2.5 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors group cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
<span className="text-base shrink-0">{d.icon || '📝'}</span>
|
{d.icon && <span className="text-base shrink-0">{d.icon}</span>}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-xs font-semibold text-main truncate group-hover:text-accent">
|
<div className="text-xs font-semibold text-main truncate group-hover:text-accent">
|
||||||
{d.title || 'Unbenannte Notiz'}
|
{d.title || 'Unbenannte Notiz'}
|
||||||
@@ -289,17 +419,58 @@ export const LinkModal: React.FC<LinkModalProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
type="button"
|
<button
|
||||||
className="btn-action text-xs h-7 px-2.5 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 text-accent cursor-pointer shrink-0"
|
type="button"
|
||||||
>
|
onClick={(e) => {
|
||||||
<span>Einfügen</span>
|
e.stopPropagation();
|
||||||
<ExternalLink className="w-3 h-3" />
|
openBlockPicker(d as Document);
|
||||||
</button>
|
}}
|
||||||
|
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 text-muted hover:text-accent"
|
||||||
|
title="Einen einzelnen Punkt dieser Notiz verlinken"
|
||||||
|
aria-label="Punkt dieser Notiz wählen"
|
||||||
|
>
|
||||||
|
<ListTree className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-action text-xs h-7 px-2.5 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 text-accent cursor-pointer"
|
||||||
|
>
|
||||||
|
<span>Einfügen</span>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Matching bullets of all notes: a click links that bullet */}
|
||||||
|
{search.trim().length >= 3 && (blockHitsLoading || blockHits.length > 0) && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5 pt-1">
|
||||||
|
<span>Punkte</span>
|
||||||
|
{blockHitsLoading && <Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-0.5">
|
||||||
|
{blockHits.map((h) => (
|
||||||
|
<button
|
||||||
|
key={`${h.docId}:${h.blockId}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onInsertLink(buildBlockLink(h.title || 'Notiz', h.blockId, h.content))}
|
||||||
|
className="w-full text-left p-2.5 min-h-11 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors cursor-pointer space-y-0.5"
|
||||||
|
title="Diesen Punkt verlinken"
|
||||||
|
>
|
||||||
|
<SnippetRenderer snippet={h.snippet} />
|
||||||
|
<span className="block text-[10px] text-faint truncate">
|
||||||
|
{h.icon ? `${h.icon} ` : ''}
|
||||||
|
{h.title || 'Unbenannte Notiz'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ export const LinkPreviewCard: React.FC<LinkPreviewCardProps> = ({ url, canEdit,
|
|||||||
href={href}
|
href={href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-stretch gap-3 rounded-card border border-border bg-panel hover:bg-panel-elevated transition-colors overflow-hidden no-underline"
|
className="flex items-stretch gap-3 min-h-[6.5rem] rounded-card border border-border bg-panel hover:bg-panel-elevated transition-colors overflow-hidden no-underline"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1 py-2 pl-3 pr-11 sm:pr-8 space-y-0.5">
|
<div className="min-w-0 flex-1 py-3 pl-3 pr-11 sm:pr-8 space-y-1 flex flex-col justify-center">
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-faint min-w-0">
|
<div className="flex items-center gap-1.5 text-[11px] text-faint min-w-0">
|
||||||
{icon ? (
|
{icon ? (
|
||||||
<img src={icon} alt="" className="w-3.5 h-3.5 rounded-sm shrink-0" />
|
<img src={icon} alt="" className="w-3.5 h-3.5 rounded-sm shrink-0" />
|
||||||
@@ -64,9 +64,9 @@ export const LinkPreviewCard: React.FC<LinkPreviewCardProps> = ({ url, canEdit,
|
|||||||
<span className="truncate">{preview.siteName}</span>
|
<span className="truncate">{preview.siteName}</span>
|
||||||
</div>
|
</div>
|
||||||
{preview.title && <div className="text-sm font-semibold text-main line-clamp-2 leading-snug">{preview.title}</div>}
|
{preview.title && <div className="text-sm font-semibold text-main line-clamp-2 leading-snug">{preview.title}</div>}
|
||||||
{preview.description && <div className="text-xs text-muted truncate">{preview.description}</div>}
|
{preview.description && <div className="text-xs text-muted line-clamp-2">{preview.description}</div>}
|
||||||
</div>
|
</div>
|
||||||
{image && <img src={image} alt="" className="w-16 sm:w-20 object-cover shrink-0 border-l border-border" />}
|
{image && <img src={image} alt="" className="w-28 sm:w-40 object-cover shrink-0 border-l border-border" />}
|
||||||
</a>
|
</a>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export const MergeDialog: React.FC<MergeDialogProps> = ({ docs, sourceId, target
|
|||||||
onClick={() => setTarget(d.id)}
|
onClick={() => setTarget(d.id)}
|
||||||
className="w-full min-h-[44px] sm:min-h-0 px-3 py-2 flex items-center gap-2 text-left text-xs text-main hover:bg-panel-elevated"
|
className="w-full min-h-[44px] sm:min-h-0 px-3 py-2 flex items-center gap-2 text-left text-xs text-main hover:bg-panel-elevated"
|
||||||
>
|
>
|
||||||
<span className="shrink-0">{d.icon || '📝'}</span>
|
{d.icon && <span className="shrink-0">{d.icon}</span>}
|
||||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||||
{d.folderPath && <span className="text-[10px] text-faint truncate max-w-[40%]">{d.folderPath}</span>}
|
{d.folderPath && <span className="text-[10px] text-faint truncate max-w-[40%]">{d.folderPath}</span>}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { ArrowDown, ArrowUp, IndentDecrease, IndentIncrease } from 'lucide-react';
|
||||||
|
import { useKeyboardInset } from '../../lib/useKeyboardInset';
|
||||||
|
|
||||||
|
// Phones and tablets have no Tab key: while a bullet is being edited, a slim bar sits right above the
|
||||||
|
// on-screen keyboard (only while it is open) with outdent / indent and move up / down (the bullet takes its children along).
|
||||||
|
export const isTouchOnly =
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
!!window.matchMedia?.('(hover: none) and (pointer: coarse)').matches &&
|
||||||
|
!window.matchMedia?.('(any-pointer: fine)').matches;
|
||||||
|
|
||||||
|
interface MobileOutlineBarProps {
|
||||||
|
onOutdent: () => void;
|
||||||
|
onIndent: () => void;
|
||||||
|
onMoveUp: () => void;
|
||||||
|
onMoveDown: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileOutlineBar: React.FC<MobileOutlineBarProps> = ({ onOutdent, onIndent, onMoveUp, onMoveDown }) => {
|
||||||
|
// iOS keeps fixed elements behind the keyboard: lift the bar by the keyboard's height
|
||||||
|
const { inset, keyboardOpen } = useKeyboardInset();
|
||||||
|
|
||||||
|
const button = (label: string, icon: React.ReactNode, action: () => void) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
// Keep the focus (and the keyboard) in the bullet: no focus change on press, act on click
|
||||||
|
onPointerDown={(e) => e.preventDefault()}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={action}
|
||||||
|
className="h-10 flex-1 max-w-[4.5rem] flex items-center justify-center rounded-btn border border-border bg-canvas text-main active:bg-panel-elevated touch-manipulation"
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keyboard closed (e.g. back button) while the bullet keeps its focus: no bar over the bottom navigation
|
||||||
|
if (!keyboardOpen) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
className="fixed left-0 right-0 z-[70] flex items-center justify-center gap-2 px-3 py-1.5 bg-panel/95 backdrop-blur-md border-t border-border"
|
||||||
|
style={{ bottom: inset }}
|
||||||
|
role="toolbar"
|
||||||
|
aria-label="Punkt bearbeiten"
|
||||||
|
>
|
||||||
|
{button('Ausrücken', <IndentDecrease className="w-5 h-5" />, onOutdent)}
|
||||||
|
{button('Einrücken', <IndentIncrease className="w-5 h-5" />, onIndent)}
|
||||||
|
<span className="w-px h-6 bg-border mx-1" aria-hidden />
|
||||||
|
{button('Nach oben', <ArrowUp className="w-5 h-5" />, onMoveUp)}
|
||||||
|
{button('Nach unten', <ArrowDown className="w-5 h-5" />, onMoveDown)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,10 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||||
import { OutlinerBlock } from './OutlinerBlock';
|
import { OutlinerBlock, mapRenderedOffsetToRaw } from './OutlinerBlock';
|
||||||
|
import { parsePastedLines } from '../../lib/pasteOutline';
|
||||||
|
import { numberedLabels } from '../../lib/outlineList';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
|
import { MobileOutlineBar, isTouchOnly } from './MobileOutlineBar';
|
||||||
|
|
||||||
export interface OutlinerTreeHandle {
|
export interface OutlinerTreeHandle {
|
||||||
focusFirstBlock: () => void;
|
focusFirstBlock: () => void;
|
||||||
@@ -9,6 +13,8 @@ export interface OutlinerTreeHandle {
|
|||||||
|
|
||||||
export interface OutlinerTreeProps {
|
export interface OutlinerTreeProps {
|
||||||
docId: string;
|
docId: string;
|
||||||
|
/** Smaller click area below the last bullet (journal days) */
|
||||||
|
compact?: boolean;
|
||||||
blocks: DocumentBlock[];
|
blocks: DocumentBlock[];
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
levelOffset?: number;
|
levelOffset?: number;
|
||||||
@@ -20,9 +26,12 @@ export interface OutlinerTreeProps {
|
|||||||
onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void;
|
onInsertBlocks?: (afterBlockId: string, updatedCurrent: DocumentBlock, newBlocks: DocumentBlock[]) => void;
|
||||||
onDeleteBlock: (blockId: string) => void;
|
onDeleteBlock: (blockId: string) => void;
|
||||||
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
onMoveBlock: (blockId: string, newIndex: number, level?: number) => void;
|
||||||
|
/** Swap a bullet (with children) with its previous / next sibling (phone bar ↑ ↓) */
|
||||||
|
onMoveSibling?: (blockId: string, direction: -1 | 1) => void;
|
||||||
onOpenTask?: (taskId: string) => void;
|
onOpenTask?: (taskId: string) => void;
|
||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
onWikiLinkClick?: (pageTitle: string) => void;
|
onWikiLinkClick?: (pageTitle: string, blockId?: string) => void;
|
||||||
|
onCopyBlockLink?: (blockId: string) => void;
|
||||||
initialFocusedBlockId?: string | null;
|
initialFocusedBlockId?: string | null;
|
||||||
onFocusedBlockChange?: (blockId: string | null) => void;
|
onFocusedBlockChange?: (blockId: string | null) => void;
|
||||||
lockedBlocks?: Map<string, string>; // blockId -> name of the user editing it (someone else)
|
lockedBlocks?: Map<string, string>; // blockId -> name of the user editing it (someone else)
|
||||||
@@ -30,10 +39,16 @@ export interface OutlinerTreeProps {
|
|||||||
onResolveConflict?: (blockId: string, accept: boolean) => void;
|
onResolveConflict?: (blockId: string, accept: boolean) => void;
|
||||||
onUndo?: () => void;
|
onUndo?: () => void;
|
||||||
onRedo?: () => void;
|
onRedo?: () => void;
|
||||||
|
/** Deleting a selection over several bullets in one step: the first bullet with its new text, the
|
||||||
|
* bullets to delete, and children of the last bullet that move under the first */
|
||||||
|
onReplaceRange?: (first: DocumentBlock, deleteIds: string[], movedChildren: DocumentBlock[]) => void;
|
||||||
|
/** The empty area below the last bullet (click adds one); off where several trees stack (hashtag page) */
|
||||||
|
trailingArea?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||||
docId,
|
docId,
|
||||||
|
compact = false,
|
||||||
blocks,
|
blocks,
|
||||||
canEdit,
|
canEdit,
|
||||||
levelOffset = 0,
|
levelOffset = 0,
|
||||||
@@ -45,9 +60,11 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onInsertBlocks,
|
onInsertBlocks,
|
||||||
onDeleteBlock,
|
onDeleteBlock,
|
||||||
onMoveBlock,
|
onMoveBlock,
|
||||||
|
onMoveSibling,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
onTagClick,
|
onTagClick,
|
||||||
onWikiLinkClick,
|
onWikiLinkClick,
|
||||||
|
onCopyBlockLink,
|
||||||
initialFocusedBlockId,
|
initialFocusedBlockId,
|
||||||
onFocusedBlockChange,
|
onFocusedBlockChange,
|
||||||
lockedBlocks,
|
lockedBlocks,
|
||||||
@@ -55,6 +72,8 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onResolveConflict,
|
onResolveConflict,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
|
onReplaceRange,
|
||||||
|
trailingArea = true,
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -169,8 +188,14 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Jump target (e.g. from a task): focused once, as soon as its block is loaded. Running again on later
|
||||||
|
// block changes would pull the cursor back to it after every Enter in the note.
|
||||||
|
const focusedTargetRef = useRef<string | null>(null);
|
||||||
|
const targetLoaded = !!initialFocusedBlockId && blocks.some((b) => b.id === initialFocusedBlockId);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialFocusedBlockId) {
|
if (!initialFocusedBlockId) focusedTargetRef.current = null;
|
||||||
|
if (initialFocusedBlockId && targetLoaded && focusedTargetRef.current !== initialFocusedBlockId) {
|
||||||
|
focusedTargetRef.current = initialFocusedBlockId;
|
||||||
setHighlightedBlockId(initialFocusedBlockId);
|
setHighlightedBlockId(initialFocusedBlockId);
|
||||||
focusBlockInput(initialFocusedBlockId, 'select');
|
focusBlockInput(initialFocusedBlockId, 'select');
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -188,7 +213,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
clearTimeout(fadeTimer);
|
clearTimeout(fadeTimer);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [initialFocusedBlockId, blocks.length, focusBlockInput]);
|
}, [initialFocusedBlockId, targetLoaded, focusBlockInput]);
|
||||||
|
|
||||||
// Compute visibility based on collapsed ancestor blocks
|
// Compute visibility based on collapsed ancestor blocks
|
||||||
const visibleEntries = useMemo(() => {
|
const visibleEntries = useMemo(() => {
|
||||||
@@ -235,13 +260,35 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
return result;
|
return result;
|
||||||
}, [blocks, levelOffset]);
|
}, [blocks, levelOffset]);
|
||||||
|
|
||||||
|
const listLabels = useMemo(() => numberedLabels(blocks), [blocks]);
|
||||||
|
|
||||||
|
// Whether another bullet on the same level follows (before the branch ends); an empty bullet
|
||||||
|
// is then not outdented by Enter
|
||||||
|
const siblingFollows = useCallback(
|
||||||
|
(index: number) => {
|
||||||
|
const level = blocks[index]?.level || 0;
|
||||||
|
for (let i = index + 1; i < blocks.length; i++) {
|
||||||
|
const l = blocks[i].level || 0;
|
||||||
|
if (l < level) return false;
|
||||||
|
if (l === level) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[blocks]
|
||||||
|
);
|
||||||
|
|
||||||
const handleCreateBlockBelow = useCallback(
|
const handleCreateBlockBelow = useCallback(
|
||||||
(afterBlockId: string, newContent: string = '') => {
|
(afterBlockId: string, newContent: string = '') => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
const idx = blocks.findIndex((b) => b.id === afterBlockId);
|
const idx = blocks.findIndex((b) => b.id === afterBlockId);
|
||||||
const currentBlock = idx >= 0 ? blocks[idx] : null;
|
const currentBlock = idx >= 0 ? blocks[idx] : null;
|
||||||
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
|
const isHeading =
|
||||||
const inheritedType: BlockType = 'text';
|
(currentBlock?.type === 'h1' || currentBlock?.type === 'h2' || currentBlock?.type === 'h3') &&
|
||||||
|
!currentBlock.collapsed;
|
||||||
|
// Enter on an open heading starts its section: the new bullet goes below it as a child
|
||||||
|
const level = currentBlock ? Math.max(0, currentBlock.level || 0) + (isHeading ? 1 : 0) : 0;
|
||||||
|
// Numbered lists continue on Enter (an empty item ends the list, see OutlinerBlock)
|
||||||
|
const inheritedType: BlockType = currentBlock?.type === 'numbered' ? 'numbered' : 'text';
|
||||||
|
|
||||||
const newBlock: DocumentBlock = {
|
const newBlock: DocumentBlock = {
|
||||||
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
@@ -270,178 +317,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
const currentBlock = blocks[idx];
|
const currentBlock = blocks[idx];
|
||||||
const baseLevel = Math.max(0, currentBlock.level || 0);
|
const baseLevel = Math.max(0, currentBlock.level || 0);
|
||||||
|
|
||||||
// Detect indentation style (2-space vs 4-space tabs)
|
const parsed = parsePastedLines(rawLines);
|
||||||
let detectedSpaceStep = 2;
|
|
||||||
const leadingSpacesList: number[] = [];
|
|
||||||
for (const line of rawLines) {
|
|
||||||
if (!line.trim()) continue;
|
|
||||||
let sp = 0;
|
|
||||||
while (sp < line.length && line[sp] === ' ') sp++;
|
|
||||||
if (sp > 0) leadingSpacesList.push(sp);
|
|
||||||
}
|
|
||||||
if (leadingSpacesList.length > 0 && leadingSpacesList.every((s) => s % 4 === 0)) {
|
|
||||||
detectedSpaceStep = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse lines into structured blocks
|
|
||||||
interface ParsedPasteItem {
|
|
||||||
type: BlockType;
|
|
||||||
checked?: boolean;
|
|
||||||
content: string;
|
|
||||||
indent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed: ParsedPasteItem[] = [];
|
|
||||||
let currentItem: ParsedPasteItem | null = null;
|
|
||||||
let inCodeBlock = false;
|
|
||||||
let afterHeading = false;
|
|
||||||
|
|
||||||
const pushCurrentItem = () => {
|
|
||||||
if (currentItem) {
|
|
||||||
currentItem.content = currentItem.content.trimEnd();
|
|
||||||
parsed.push(currentItem);
|
|
||||||
currentItem = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < rawLines.length; i++) {
|
|
||||||
const rawLine = rawLines[i];
|
|
||||||
|
|
||||||
// 1. Inside a fenced code block
|
|
||||||
if (inCodeBlock) {
|
|
||||||
if (currentItem) {
|
|
||||||
currentItem.content += '\n' + rawLine;
|
|
||||||
}
|
|
||||||
if (rawLine.trim().startsWith('```')) {
|
|
||||||
inCodeBlock = false;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Starts a code fence
|
|
||||||
if (rawLine.trim().startsWith('```')) {
|
|
||||||
inCodeBlock = true;
|
|
||||||
if (afterHeading) {
|
|
||||||
afterHeading = false;
|
|
||||||
pushCurrentItem();
|
|
||||||
currentItem = {
|
|
||||||
type: 'text',
|
|
||||||
content: rawLine,
|
|
||||||
indent: 0,
|
|
||||||
};
|
|
||||||
} else if (currentItem) {
|
|
||||||
currentItem.content += (currentItem.content.endsWith('\n') ? '' : '\n') + rawLine;
|
|
||||||
} else {
|
|
||||||
currentItem = {
|
|
||||||
type: 'text',
|
|
||||||
content: rawLine,
|
|
||||||
indent: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Measure leading indent
|
|
||||||
let tabs = 0;
|
|
||||||
let spaces = 0;
|
|
||||||
let charIdx = 0;
|
|
||||||
while (charIdx < rawLine.length) {
|
|
||||||
if (rawLine[charIdx] === '\t') {
|
|
||||||
tabs++;
|
|
||||||
charIdx++;
|
|
||||||
} else if (rawLine[charIdx] === ' ') {
|
|
||||||
spaces++;
|
|
||||||
charIdx++;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const strippedLine = rawLine.slice(charIdx);
|
|
||||||
const lineIndent = tabs + Math.floor(spaces / detectedSpaceStep);
|
|
||||||
|
|
||||||
// Blank lines
|
|
||||||
if (strippedLine.trim() === '') {
|
|
||||||
if (currentItem) {
|
|
||||||
currentItem.content += '\n';
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Headings: #, ##, ###
|
|
||||||
const h3Match = strippedLine.match(/^###\s+(.*)$/);
|
|
||||||
const h2Match = strippedLine.match(/^##\s+(.*)$/);
|
|
||||||
const h1Match = strippedLine.match(/^#\s+(.*)$/);
|
|
||||||
if (h1Match || h2Match || h3Match) {
|
|
||||||
pushCurrentItem();
|
|
||||||
afterHeading = true;
|
|
||||||
const type: BlockType = h1Match ? 'h1' : h2Match ? 'h2' : 'h3';
|
|
||||||
const content = (h1Match || h2Match || h3Match)![1];
|
|
||||||
parsed.push({ type, content, indent: lineIndent });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dividers: ---, ***, ___
|
|
||||||
if (/^(?:---|---|\*\*\*|___)\s*$/.test(strippedLine.trim())) {
|
|
||||||
pushCurrentItem();
|
|
||||||
afterHeading = false;
|
|
||||||
parsed.push({ type: 'divider', content: '---', indent: lineIndent });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Todo items: - [ ], - [x], * [ ], * [x]
|
|
||||||
const todoMatch = strippedLine.match(/^[-*]\s*\[([ xX])\]\s*(.*)$/);
|
|
||||||
if (todoMatch) {
|
|
||||||
pushCurrentItem();
|
|
||||||
afterHeading = false;
|
|
||||||
parsed.push({
|
|
||||||
type: 'todo',
|
|
||||||
checked: todoMatch[1].toLowerCase() === 'x',
|
|
||||||
content: todoMatch[2],
|
|
||||||
indent: lineIndent,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
|
|
||||||
const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
|
|
||||||
const numMatch = strippedLine.match(/^\d+[.)]\s+(.*)$/);
|
|
||||||
if (bulletMatch || numMatch) {
|
|
||||||
pushCurrentItem();
|
|
||||||
afterHeading = false;
|
|
||||||
parsed.push({
|
|
||||||
type: 'text',
|
|
||||||
content: (bulletMatch ? bulletMatch[1] : numMatch![1]),
|
|
||||||
indent: lineIndent,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular text line
|
|
||||||
if (afterHeading) {
|
|
||||||
// This starts the single bullet point after the heading
|
|
||||||
afterHeading = false;
|
|
||||||
pushCurrentItem();
|
|
||||||
currentItem = {
|
|
||||||
type: 'text',
|
|
||||||
content: strippedLine,
|
|
||||||
indent: lineIndent,
|
|
||||||
};
|
|
||||||
} else if (currentItem) {
|
|
||||||
if (currentItem.content.endsWith('\n')) {
|
|
||||||
currentItem.content += strippedLine;
|
|
||||||
} else {
|
|
||||||
currentItem.content += '\n' + strippedLine;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
currentItem = {
|
|
||||||
type: 'text',
|
|
||||||
content: strippedLine,
|
|
||||||
indent: lineIndent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pushCurrentItem();
|
|
||||||
|
|
||||||
if (parsed.length === 0) return;
|
if (parsed.length === 0) return;
|
||||||
|
|
||||||
@@ -754,6 +630,39 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
[blocks, draggedIndex, onMoveBlock, lockHolder, onLockedAction]
|
[blocks, draggedIndex, onMoveBlock, lockHolder, onLockedAction]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Phone bar: runs the action at once and puts the focus back into the same bullet within the same tap
|
||||||
|
// (React may move the field in the DOM, which drops the focus; iOS reopens the keyboard only inside a tap)
|
||||||
|
const runOnFocused = (action: (blockId: string) => void) => {
|
||||||
|
const id = focusedBlockId;
|
||||||
|
if (!id) return;
|
||||||
|
const holder = lockHolder(id);
|
||||||
|
if (holder) {
|
||||||
|
onLockedAction?.(holder);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const before = textareaMapRef.current.get(id);
|
||||||
|
const caret = before ? before.selectionStart : null;
|
||||||
|
flushSync(() => action(id));
|
||||||
|
const el = textareaMapRef.current.get(id);
|
||||||
|
if (!el) return;
|
||||||
|
if (document.activeElement !== el) {
|
||||||
|
el.focus({ preventScroll: true });
|
||||||
|
if (caret !== null) {
|
||||||
|
try {
|
||||||
|
el.setSelectionRange(caret, caret);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep the bullet visible above the bar (about 56 px) and the keyboard
|
||||||
|
const vv = window.visualViewport;
|
||||||
|
const visibleBottom = (vv ? vv.height + vv.offsetTop : window.innerHeight) - 64;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (rect.bottom > visibleBottom || rect.top < 0) el.scrollIntoView({ block: 'center' });
|
||||||
|
};
|
||||||
|
const showMobileBar = isTouchOnly && canEdit && !!focusedBlockId && blocks.some((b) => b.id === focusedBlockId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const clearDrag = () => {
|
const clearDrag = () => {
|
||||||
setDraggedIndex(null);
|
setDraggedIndex(null);
|
||||||
@@ -763,8 +672,245 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
return () => window.removeEventListener('dragend', clearDrag);
|
return () => window.removeEventListener('dragend', clearDrag);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// --- Selection over several bullets ---
|
||||||
|
// Each bullet is a text field of its own, so the browser cannot select across them. Dragging from one
|
||||||
|
// bullet into another is tracked here instead: start (anchor) and end (focus) as bullet + position in
|
||||||
|
// its raw text. The start bullet keeps the browser's own selection; the others are highlighted with the
|
||||||
|
// CSS Highlight API (rows are tinted where it is missing). Delete/Backspace removes the selected text
|
||||||
|
// like a text editor in one step (Strg+Z brings it back), a typed character replaces it, Strg+C copies.
|
||||||
|
type RangeSel = { anchorId: string; anchorOffset: number; focusId: string; focusOffset: number; focusNode?: Node; focusNodeOffset?: number };
|
||||||
|
const [rangeSel, setRangeSel] = useState<RangeSel | null>(null);
|
||||||
|
const rangeSelRef = useRef<RangeSel | null>(null);
|
||||||
|
rangeSelRef.current = rangeSel;
|
||||||
|
const treeRootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dragAnchorRef = useRef<{ id: string; offset: number } | null>(null);
|
||||||
|
const highlightSupported = typeof CSS !== 'undefined' && 'highlights' in CSS && typeof (window as any).Highlight === 'function';
|
||||||
|
|
||||||
|
const caretAtPoint = (x: number, y: number): { node: Node; offset: number } | null => {
|
||||||
|
const doc = document as any;
|
||||||
|
if (typeof doc.caretPositionFromPoint === 'function') {
|
||||||
|
const p = doc.caretPositionFromPoint(x, y);
|
||||||
|
return p ? { node: p.offsetNode, offset: p.offset } : null;
|
||||||
|
}
|
||||||
|
if (typeof doc.caretRangeFromPoint === 'function') {
|
||||||
|
const r = doc.caretRangeFromPoint(x, y);
|
||||||
|
return r ? { node: r.startContainer, offset: r.startOffset } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Position in the raw text of a bullet for a point in its rendered text (outside it: start or end)
|
||||||
|
const rawOffsetAtPoint = (row: HTMLElement, id: string, x: number, y: number) => {
|
||||||
|
const raw = textareaMapRef.current.get(id)?.value ?? blocks.find((b) => b.id === id)?.content ?? '';
|
||||||
|
const span = row.querySelector<HTMLElement>('[data-outline-text]');
|
||||||
|
const caret = caretAtPoint(x, y);
|
||||||
|
if (span && caret && span.contains(caret.node)) {
|
||||||
|
const range = document.createRange();
|
||||||
|
range.setStart(span, 0);
|
||||||
|
range.setEnd(caret.node, caret.offset);
|
||||||
|
return { offset: mapRenderedOffsetToRaw(raw, span.textContent || '', range.toString().length), node: caret.node, nodeOffset: caret.offset };
|
||||||
|
}
|
||||||
|
const rect = (span || row).getBoundingClientRect();
|
||||||
|
const before = y < rect.top || (y <= rect.bottom && x < rect.left);
|
||||||
|
return { offset: before ? 0 : raw.length };
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearRangeSel = useCallback(() => {
|
||||||
|
dragAnchorRef.current = null;
|
||||||
|
setRangeSel(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Highlight the selected text of the bullets other than the start one
|
||||||
|
useEffect(() => {
|
||||||
|
if (!highlightSupported) return;
|
||||||
|
const registry = (CSS as any).highlights;
|
||||||
|
if (!rangeSel || !treeRootRef.current) {
|
||||||
|
registry.delete('outline-range');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const order = visibleEntries.map((e) => e.block.id);
|
||||||
|
const a = order.indexOf(rangeSel.anchorId);
|
||||||
|
const f = order.indexOf(rangeSel.focusId);
|
||||||
|
if (a < 0 || f < 0) {
|
||||||
|
registry.delete('outline-range');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ranges: Range[] = [];
|
||||||
|
const spanOf = (id: string) =>
|
||||||
|
treeRootRef.current!.querySelector<HTMLElement>(`[data-outline-block="${id}"] [data-outline-text]`);
|
||||||
|
for (let i = Math.min(a, f); i <= Math.max(a, f); i++) {
|
||||||
|
const id = order[i];
|
||||||
|
if (id === rangeSel.anchorId) continue; // shown by the browser in its text field
|
||||||
|
const span = spanOf(id);
|
||||||
|
if (!span) continue;
|
||||||
|
const r = document.createRange();
|
||||||
|
r.selectNodeContents(span);
|
||||||
|
if (id === rangeSel.focusId && rangeSel.focusNode && span.contains(rangeSel.focusNode)) {
|
||||||
|
if (f > a) r.setEnd(rangeSel.focusNode, rangeSel.focusNodeOffset ?? 0);
|
||||||
|
else r.setStart(rangeSel.focusNode, rangeSel.focusNodeOffset ?? 0);
|
||||||
|
}
|
||||||
|
ranges.push(r);
|
||||||
|
}
|
||||||
|
registry.set('outline-range', new (window as any).Highlight(...ranges));
|
||||||
|
return () => registry.delete('outline-range');
|
||||||
|
}, [rangeSel, visibleEntries, highlightSupported]);
|
||||||
|
|
||||||
|
// Mouse: a drag that leaves its bullet becomes a selection over several bullets
|
||||||
|
useEffect(() => {
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
const start = dragAnchorRef.current;
|
||||||
|
if (!start || !(e.buttons & 1)) return;
|
||||||
|
const row = (document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null)?.closest<HTMLElement>('[data-outline-block]');
|
||||||
|
if (!row || !treeRootRef.current?.contains(row)) return;
|
||||||
|
const id = row.dataset.outlineBlock!;
|
||||||
|
if (id === start.id) {
|
||||||
|
if (rangeSelRef.current) setRangeSel(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const at = rawOffsetAtPoint(row, id, e.clientX, e.clientY);
|
||||||
|
setRangeSel({ anchorId: start.id, anchorOffset: start.offset, focusId: id, focusOffset: at.offset, focusNode: at.node, focusNodeOffset: at.nodeOffset });
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
dragAnchorRef.current = null;
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
// rawOffsetAtPoint only reads refs and blocks
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [blocks]);
|
||||||
|
|
||||||
|
const handleTreeMouseDownCapture = (e: React.MouseEvent) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
setRangeSel(null);
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const row = target.closest<HTMLElement>('[data-outline-block]');
|
||||||
|
if (!row || target.closest('button, a, input, select, [contenteditable="true"]')) {
|
||||||
|
dragAnchorRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = row.dataset.outlineBlock!;
|
||||||
|
// The bullet's own handler places the caret first; read it right after
|
||||||
|
dragAnchorRef.current = { id, offset: rawOffsetAtPoint(row, id, e.clientX, e.clientY).offset };
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const ta = textareaMapRef.current.get(id);
|
||||||
|
if (dragAnchorRef.current?.id === id && ta && document.activeElement === ta) {
|
||||||
|
dragAnchorRef.current = { id, offset: ta.selectionStart };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// The selection in text order: first bullet + offset, last bullet + offset, bullets in between
|
||||||
|
const resolveRangeSel = (sel: RangeSel) => {
|
||||||
|
const order = visibleEntries.map((en) => en.block.id);
|
||||||
|
const a = order.indexOf(sel.anchorId);
|
||||||
|
const f = order.indexOf(sel.focusId);
|
||||||
|
if (a < 0 || f < 0 || a === f) return null;
|
||||||
|
const [firstId, firstOffset, lastId, lastOffset] =
|
||||||
|
a < f ? [sel.anchorId, sel.anchorOffset, sel.focusId, sel.focusOffset] : [sel.focusId, sel.focusOffset, sel.anchorId, sel.anchorOffset];
|
||||||
|
const fIdx = blocks.findIndex((b) => b.id === firstId);
|
||||||
|
const lIdx = blocks.findIndex((b) => b.id === lastId);
|
||||||
|
if (fIdx < 0 || lIdx < 0 || lIdx <= fIdx) return null;
|
||||||
|
return { first: blocks[fIdx], firstOffset, last: blocks[lIdx], lastOffset, fIdx, lIdx };
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedText = (sel: RangeSel) => {
|
||||||
|
const r = resolveRangeSel(sel);
|
||||||
|
if (!r) return '';
|
||||||
|
const lines = [(r.first.content || '').slice(r.firstOffset)];
|
||||||
|
for (let i = r.fIdx + 1; i < r.lIdx; i++) lines.push(blocks[i].content || '');
|
||||||
|
lines.push((r.last.content || '').slice(0, r.lastOffset));
|
||||||
|
return lines.join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteRangeSel = (sel: RangeSel, insert = '') => {
|
||||||
|
const r = resolveRangeSel(sel);
|
||||||
|
if (!r || !canEdit) return false;
|
||||||
|
const notMergeable = ['code', 'divider', 'table', 'progress'];
|
||||||
|
if (notMergeable.includes(r.first.type) || notMergeable.includes(r.last.type)) return false;
|
||||||
|
const deleted = blocks.slice(r.fIdx + 1, r.lIdx + 1);
|
||||||
|
const holder = [r.first, ...deleted].map((b) => lockHolder(b.id)).find(Boolean);
|
||||||
|
if (holder) {
|
||||||
|
onLockedAction?.(holder);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Children of the last bullet stay and move under the first, like merging two bullets
|
||||||
|
const lastLevel = Math.max(0, r.last.level || 0);
|
||||||
|
const delta = Math.max(0, r.first.level || 0) - lastLevel;
|
||||||
|
const moved: DocumentBlock[] = [];
|
||||||
|
for (let j = r.lIdx + 1; j < blocks.length; j++) {
|
||||||
|
const lvl = Math.max(0, blocks[j].level || 0);
|
||||||
|
if (lvl <= lastLevel) break;
|
||||||
|
moved.push({ ...blocks[j], level: Math.max(levelOffset || 0, lvl + delta) });
|
||||||
|
}
|
||||||
|
const merged: DocumentBlock = {
|
||||||
|
...r.first,
|
||||||
|
content: (r.first.content || '').slice(0, r.firstOffset) + insert + (r.last.content || '').slice(r.lastOffset),
|
||||||
|
};
|
||||||
|
const deleteIds = deleted.map((b) => b.id);
|
||||||
|
if (onReplaceRange) {
|
||||||
|
onReplaceRange(merged, deleteIds, moved);
|
||||||
|
} else {
|
||||||
|
if (onUpdateBlocks) onUpdateBlocks([merged, ...moved]);
|
||||||
|
else [merged, ...moved].forEach((b) => onUpdateBlock(b, true));
|
||||||
|
deleteIds.forEach((id) => onDeleteBlock(id));
|
||||||
|
}
|
||||||
|
clearRangeSel();
|
||||||
|
focusBlockInput(r.first.id, r.firstOffset + insert.length);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keys while a selection over several bullets exists
|
||||||
|
const deleteRangeSelRef = useRef(deleteRangeSel);
|
||||||
|
deleteRangeSelRef.current = deleteRangeSel;
|
||||||
|
const selectedTextRef = useRef(selectedText);
|
||||||
|
selectedTextRef.current = selectedText;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!rangeSel) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const sel = rangeSelRef.current;
|
||||||
|
if (!sel) return;
|
||||||
|
const mod = e.ctrlKey || e.metaKey;
|
||||||
|
const stop = () => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
};
|
||||||
|
if (mod && (e.key === 'c' || e.key === 'x')) {
|
||||||
|
stop();
|
||||||
|
void navigator.clipboard?.writeText(selectedTextRef.current(sel)).catch(() => {});
|
||||||
|
if (e.key === 'x') deleteRangeSelRef.current(sel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||||
|
stop();
|
||||||
|
deleteRangeSelRef.current(sel);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mod && !e.altKey && e.key.length === 1) {
|
||||||
|
stop();
|
||||||
|
deleteRangeSelRef.current(sel, e.key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!['Shift', 'Control', 'Meta', 'Alt'].includes(e.key)) clearRangeSel();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey, true);
|
||||||
|
return () => window.removeEventListener('keydown', onKey, true);
|
||||||
|
}, [rangeSel, clearRangeSel]);
|
||||||
|
|
||||||
|
const rangeRowIds = useMemo(() => {
|
||||||
|
if (!rangeSel || highlightSupported) return null;
|
||||||
|
const order = visibleEntries.map((e) => e.block.id);
|
||||||
|
const a = order.indexOf(rangeSel.anchorId);
|
||||||
|
const f = order.indexOf(rangeSel.focusId);
|
||||||
|
if (a < 0 || f < 0) return null;
|
||||||
|
return new Set(order.slice(Math.min(a, f), Math.max(a, f) + 1));
|
||||||
|
}, [rangeSel, visibleEntries, highlightSupported]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5" ref={treeRootRef} onMouseDownCapture={handleTreeMouseDownCapture}>
|
||||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
|
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }, entryIdx) => {
|
||||||
const isLast = entryIdx === visibleEntries.length - 1;
|
const isLast = entryIdx === visibleEntries.length - 1;
|
||||||
return (
|
return (
|
||||||
@@ -774,6 +920,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
block={block}
|
block={block}
|
||||||
index={index}
|
index={index}
|
||||||
isLast={isLast}
|
isLast={isLast}
|
||||||
|
siblingFollows={siblingFollows(index)}
|
||||||
maxAllowedLevel={maxAllowedLevel}
|
maxAllowedLevel={maxAllowedLevel}
|
||||||
hasChildren={hasChildren}
|
hasChildren={hasChildren}
|
||||||
isFocused={focusedBlockId === block.id}
|
isFocused={focusedBlockId === block.id}
|
||||||
@@ -789,6 +936,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onUpdateBlock={onUpdateBlock}
|
onUpdateBlock={onUpdateBlock}
|
||||||
onCreateBlockBelow={handleCreateBlockBelow}
|
onCreateBlockBelow={handleCreateBlockBelow}
|
||||||
onPasteMultiLines={handlePasteMultiLines}
|
onPasteMultiLines={handlePasteMultiLines}
|
||||||
|
listMarker={listLabels.get(block.id)}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
onMergeWithPrevious={handleMergeWithPrevious}
|
onMergeWithPrevious={handleMergeWithPrevious}
|
||||||
onIndentBlock={handleIndentBlock}
|
onIndentBlock={handleIndentBlock}
|
||||||
@@ -798,19 +946,21 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onOpenTask={onOpenTask}
|
onOpenTask={onOpenTask}
|
||||||
onTagClick={onTagClick}
|
onTagClick={onTagClick}
|
||||||
onWikiLinkClick={onWikiLinkClick}
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
|
onCopyBlockLink={onCopyBlockLink}
|
||||||
onDragStartBlock={handleDragStartBlock}
|
onDragStartBlock={handleDragStartBlock}
|
||||||
onDragOverBlock={handleDragOverBlock}
|
onDragOverBlock={handleDragOverBlock}
|
||||||
onDropBlock={handleDropBlock}
|
onDropBlock={handleDropBlock}
|
||||||
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
||||||
isTargeted={highlightedBlockId === block.id}
|
isTargeted={highlightedBlockId === block.id}
|
||||||
registerTextareaRef={registerTextareaRef}
|
registerTextareaRef={registerTextareaRef}
|
||||||
|
inRangeSelection={rangeRowIds?.has(block.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && trailingArea && (
|
||||||
<div
|
<div
|
||||||
className={`min-h-[14rem] pt-2 pl-2 transition-colors cursor-text ${
|
className={`${compact ? 'min-h-[2.5rem]' : 'min-h-[14rem]'} pt-2 pl-2 transition-colors cursor-text ${
|
||||||
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
|
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -846,6 +996,14 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onDrop={() => handleDropBlock(blocks.length)}
|
onDrop={() => handleDropBlock(blocks.length)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{showMobileBar && (
|
||||||
|
<MobileOutlineBar
|
||||||
|
onOutdent={() => runOnFocused((id) => handleIndentBlock(id, -1))}
|
||||||
|
onIndent={() => runOnFocused((id) => handleIndentBlock(id, 1))}
|
||||||
|
onMoveUp={() => runOnFocused((id) => onMoveSibling?.(id, -1))}
|
||||||
|
onMoveDown={() => runOnFocused((id) => onMoveSibling?.(id, 1))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import { ArrowLeft, Folder, RotateCcw, Trash2 } from 'lucide-react';
|
import { ArrowLeft, Folder, RotateCcw, Trash2 } from 'lucide-react';
|
||||||
import { TrashedDocument } from '../../lib/types';
|
import { TrashedDocument } from '../../lib/types';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
|
import { formatDeletedAt } from '../../lib/due';
|
||||||
|
|
||||||
interface TrashViewProps {
|
interface TrashViewProps {
|
||||||
items: TrashedDocument[];
|
items: TrashedDocument[];
|
||||||
@@ -12,12 +13,6 @@ interface TrashViewProps {
|
|||||||
onBackMobile: () => void;
|
onBackMobile: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDeletedAt = (iso: string) => {
|
|
||||||
const d = new Date(iso);
|
|
||||||
if (Number.isNaN(d.getTime())) return '';
|
|
||||||
return d.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Deleted notes: open read-only, restore, or delete for good. Deleting for good always takes a second
|
// Deleted notes: open read-only, restore, or delete for good. Deleting for good always takes a second
|
||||||
// tap on the same button ("Wirklich löschen?"), which works the same with mouse and touch.
|
// tap on the same button ("Wirklich löschen?"), which works the same with mouse and touch.
|
||||||
export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore, onPurge, onEmpty, onBackMobile }) => {
|
export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore, onPurge, onEmpty, onBackMobile }) => {
|
||||||
@@ -93,7 +88,7 @@ export const TrashView: React.FC<TrashViewProps> = ({ items, onOpen, onRestore,
|
|||||||
className="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
|
className="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
|
||||||
title="Schreibgeschützt ansehen"
|
title="Schreibgeschützt ansehen"
|
||||||
>
|
>
|
||||||
<span className="text-lg shrink-0">{t.icon || '📝'}</span>
|
{t.icon && <span className="text-lg shrink-0">{t.icon}</span>}
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block text-sm font-semibold text-main truncate group-hover:text-accent">
|
<span className="block text-sm font-semibold text-main truncate group-hover:text-accent">
|
||||||
{t.title || 'Unbenannte Notiz'}
|
{t.title || 'Unbenannte Notiz'}
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -77,19 +77,14 @@ describe('CodeBlockEditor', () => {
|
|||||||
expect(updates[0].lang).toBe('yaml');
|
expect(updates[0].lang).toBe('yaml');
|
||||||
expect(select.value).toBe('yaml');
|
expect(select.value).toBe('yaml');
|
||||||
|
|
||||||
// "Als Text" converts the code block back to a bullet
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Als Text' }));
|
|
||||||
expect(updates[updates.length - 1]?.type).toBe('bullet');
|
|
||||||
|
|
||||||
unmount();
|
unmount();
|
||||||
|
|
||||||
// Reader view (!canEdit): shows plain text instead of <select> and hides "Als Text"
|
// Reader view (!canEdit): shows plain text instead of <select>
|
||||||
const readerView = render(
|
const readerView = render(
|
||||||
<CodeBlockEditor block={makeCodeBlock({ lang: 'yaml' })} canEdit={false} />
|
<CodeBlockEditor block={makeCodeBlock({ lang: 'yaml' })} canEdit={false} />
|
||||||
);
|
);
|
||||||
expect(readerView.container.querySelector('select')).toBeNull();
|
expect(readerView.container.querySelector('select')).toBeNull();
|
||||||
expect(readerView.getByText('yaml')).toBeTruthy();
|
expect(readerView.getByText('yaml')).toBeTruthy();
|
||||||
expect(readerView.queryByText('Als Text')).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('copies the code content to the clipboard and shows "Kopiert!" for 1.5s', () => {
|
it('copies the code content to the clipboard and shows "Kopiert!" for 1.5s', () => {
|
||||||
@@ -108,14 +103,14 @@ describe('CodeBlockEditor', () => {
|
|||||||
fireEvent.click(copyBtn);
|
fireEvent.click(copyBtn);
|
||||||
|
|
||||||
expect(writeTextMock).toHaveBeenCalledWith('echo "hello world"');
|
expect(writeTextMock).toHaveBeenCalledWith('echo "hello world"');
|
||||||
expect(screen.getByText('Kopiert!')).toBeTruthy();
|
expect(screen.getByRole('button', { name: 'Kopiert!' })).toBeTruthy();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(1500);
|
vi.advanceTimersByTime(1500);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.queryByText('Kopiert!')).toBeNull();
|
expect(screen.queryByRole('button', { name: 'Kopiert!' })).toBeNull();
|
||||||
expect(screen.getByText('Kopieren')).toBeTruthy();
|
expect(screen.getByRole('button', { name: 'Kopieren' })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('indents by 2 spaces on Tab and outdents on Shift+Tab without indenting the bullet', () => {
|
it('indents by 2 spaces on Tab and outdents on Shift+Tab without indenting the bullet', () => {
|
||||||
|
|||||||
@@ -129,9 +129,9 @@ describe('slash command menu and outliner fixes', () => {
|
|||||||
fireEvent.change(ta, { target: { value: 'https://a.de/b/' } });
|
fireEvent.change(ta, { target: { value: 'https://a.de/b/' } });
|
||||||
expect(queryByText('Befehl auswählen')).toBeNull();
|
expect(queryByText('Befehl auswählen')).toBeNull();
|
||||||
|
|
||||||
fireEvent.change(ta, { target: { value: 'Text /co' } });
|
fireEvent.change(ta, { target: { value: 'Text /ca' } });
|
||||||
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||||
expect(queryByText('/code')).toBeTruthy();
|
expect(queryByText('/callout')).toBeTruthy();
|
||||||
|
|
||||||
// Removing the slash closes the menu
|
// Removing the slash closes the menu
|
||||||
fireEvent.change(ta, { target: { value: 'Text ' } });
|
fireEvent.change(ta, { target: { value: 'Text ' } });
|
||||||
@@ -207,6 +207,43 @@ describe('slash command menu and outliner fixes', () => {
|
|||||||
expect(onCreateBelow).toHaveBeenCalledWith('b1', '');
|
expect(onCreateBelow).toHaveBeenCalledWith('b1', '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists commands whose name starts with the typed text first (/li → /link before /number)', () => {
|
||||||
|
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
|
||||||
|
const { container } = renderBlock(block);
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
fireEvent.change(ta, { target: { value: '/li', selectionStart: 3, selectionEnd: 3 } });
|
||||||
|
const labels = [...container.querySelectorAll('button')].map((b) => b.textContent || '').filter((t) => t.startsWith('/'));
|
||||||
|
expect(labels[0]).toMatch(/^\/link/);
|
||||||
|
expect(labels.findIndex((l) => l.startsWith('/number'))).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an empty indented bullet on its level when another bullet on that level follows', () => {
|
||||||
|
const onIndent = vi.fn();
|
||||||
|
const onCreateBelow = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<OutlinerBlock
|
||||||
|
docId="d"
|
||||||
|
block={{ id: 'b1', type: 'bullet', content: '', level: 1 } as DocumentBlock}
|
||||||
|
index={0}
|
||||||
|
isLast={false}
|
||||||
|
siblingFollows
|
||||||
|
maxAllowedLevel={2}
|
||||||
|
hasChildren={false}
|
||||||
|
isFocused
|
||||||
|
canEdit
|
||||||
|
onUpdateBlock={() => {}}
|
||||||
|
onCreateBlockBelow={onCreateBelow}
|
||||||
|
onDeleteBlock={() => {}}
|
||||||
|
onIndentBlock={onIndent}
|
||||||
|
onNavigateFocus={() => {}}
|
||||||
|
registerTextareaRef={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(container.querySelector('textarea') as HTMLTextAreaElement, { key: 'Enter' });
|
||||||
|
expect(onIndent).not.toHaveBeenCalled();
|
||||||
|
expect(onCreateBelow).toHaveBeenCalledWith('b1', '');
|
||||||
|
});
|
||||||
|
|
||||||
it('inserts a bare URL for /link with a web address in an empty bullet, and [Text](url) otherwise', () => {
|
it('inserts a bare URL for /link with a web address in an empty bullet, and [Text](url) otherwise', () => {
|
||||||
const updates: DocumentBlock[] = [];
|
const updates: DocumentBlock[] = [];
|
||||||
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
|
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
|
||||||
@@ -375,3 +412,106 @@ describe('formatting shortcuts (Teil C)', () => {
|
|||||||
expect(readerTa.value).toBe('text');
|
expect(readerTa.value).toBe('text');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('outliner polish (Teil A)', () => {
|
||||||
|
it('opens slash menu at cursor position in the middle of text and inserts link at cursor', () => {
|
||||||
|
const updates: DocumentBlock[] = [];
|
||||||
|
const block = { id: 'b1', type: 'bullet', content: 'Vorher Nachher', level: 0 } as DocumentBlock;
|
||||||
|
const { container, queryByText, getByText, getByPlaceholderText, getByRole } = renderBlock(block, {
|
||||||
|
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
|
||||||
|
});
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
ta.focus();
|
||||||
|
|
||||||
|
// Type "/link" in the middle of the text (selectionStart at 12, right after "/link")
|
||||||
|
fireEvent.change(ta, { target: { value: 'Vorher /link Nachher', selectionStart: 12, selectionEnd: 12 } });
|
||||||
|
|
||||||
|
// Menu should be open with /link, but without /divider or /h1
|
||||||
|
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||||
|
expect(queryByText('/link')).toBeTruthy();
|
||||||
|
expect(queryByText('/divider')).toBeNull();
|
||||||
|
expect(queryByText('/h1')).toBeNull();
|
||||||
|
|
||||||
|
// Select /link by pressing Enter
|
||||||
|
fireEvent.keyDown(ta, { key: 'Enter' });
|
||||||
|
|
||||||
|
// Link modal opens
|
||||||
|
expect(queryByText('Web-Link')).toBeTruthy();
|
||||||
|
fireEvent.click(getByText('Web-Link'));
|
||||||
|
fireEvent.change(getByPlaceholderText('z. B. MindSift Dokumentation'), { target: { value: 'Doku' } });
|
||||||
|
fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/test' } });
|
||||||
|
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
|
||||||
|
|
||||||
|
// Result has link inserted in the middle
|
||||||
|
expect(updates[updates.length - 1]?.content).toBe('Vorher [Doku](https://example.com/test) Nachher');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer /divider at end of text, but offers it in empty block', () => {
|
||||||
|
const block = { id: 'b1', type: 'bullet', content: 'Ein Text', level: 0 } as DocumentBlock;
|
||||||
|
const { container, queryByText } = renderBlock(block);
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
ta.focus();
|
||||||
|
|
||||||
|
// At end of non-empty text
|
||||||
|
fireEvent.change(ta, { target: { value: 'Ein Text /div' } });
|
||||||
|
|
||||||
|
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||||
|
expect(queryByText('/divider')).toBeNull();
|
||||||
|
|
||||||
|
// In an empty block
|
||||||
|
fireEvent.change(ta, { target: { value: '/div' } });
|
||||||
|
|
||||||
|
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||||
|
expect(queryByText('/divider')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts H1 block back to bullet via /text slash command', () => {
|
||||||
|
const updates: DocumentBlock[] = [];
|
||||||
|
const block = { id: 'b1', type: 'h1', content: 'Meine Überschrift', level: 0 } as DocumentBlock;
|
||||||
|
const { container, queryByText } = renderBlock(block, {
|
||||||
|
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
|
||||||
|
});
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
ta.focus();
|
||||||
|
|
||||||
|
fireEvent.change(ta, { target: { value: 'Meine Überschrift /text' } });
|
||||||
|
|
||||||
|
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||||
|
expect(queryByText('/text')).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.keyDown(ta, { key: 'Enter' });
|
||||||
|
|
||||||
|
const last = updates[updates.length - 1];
|
||||||
|
expect(last?.type).toBe('bullet');
|
||||||
|
expect(last?.content).toBe('Meine Überschrift');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes slash menu on mousedown outside', () => {
|
||||||
|
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
|
||||||
|
const { container, queryByText } = renderBlock(block);
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
ta.focus();
|
||||||
|
|
||||||
|
fireEvent.change(ta, { target: { value: '/' } });
|
||||||
|
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||||
|
|
||||||
|
// Click outside on document body
|
||||||
|
fireEvent.mouseDown(document.body);
|
||||||
|
expect(queryByText('Befehl auswählen')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts focused divider to empty bullet on Backspace', () => {
|
||||||
|
const updates: DocumentBlock[] = [];
|
||||||
|
const block = { id: 'd1', type: 'divider', content: '', level: 0 } as DocumentBlock;
|
||||||
|
const { container } = renderBlock(block, {
|
||||||
|
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
|
||||||
|
});
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
ta.focus();
|
||||||
|
|
||||||
|
fireEvent.keyDown(ta, { key: 'Backspace' });
|
||||||
|
const last = updates[updates.length - 1];
|
||||||
|
expect(last?.type).toBe('bullet');
|
||||||
|
expect(last?.content).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
+147
-11
@@ -11,10 +11,14 @@
|
|||||||
|
|
||||||
@import './themes/theme-dark.css';
|
@import './themes/theme-dark.css';
|
||||||
@import './themes/theme-light.css';
|
@import './themes/theme-light.css';
|
||||||
@import './themes/theme-high-contrast.css';
|
@import './themes/theme-terracotta.css';
|
||||||
@import './themes/theme-soft.css';
|
@import './themes/theme-terracotta-night.css';
|
||||||
@import './themes/theme-polarized.css';
|
@import './themes/theme-polarized.css';
|
||||||
@import './themes/theme-suri.css';
|
@import './themes/theme-suri.css';
|
||||||
|
@import './themes/theme-fjord.css';
|
||||||
|
@import './themes/theme-fjord-night.css';
|
||||||
|
@import './themes/theme-polarized-light.css';
|
||||||
|
@import './themes/theme-suri-light.css';
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@@ -28,7 +32,7 @@
|
|||||||
body {
|
body {
|
||||||
background-color: var(--bg-app);
|
background-color: var(--bg-app);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Inter Variable', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
transition: background-color 140ms ease, color 140ms ease;
|
transition: background-color 140ms ease, color 140ms ease;
|
||||||
}
|
}
|
||||||
@@ -318,12 +322,14 @@ body {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.count-badge-accent {
|
.count-badge-accent {
|
||||||
background-color: var(--accent-primary);
|
background: var(--accent-dim);
|
||||||
color: var(--text-on-accent);
|
color: var(--accent-primary);
|
||||||
|
border: 1px solid var(--accent-border);
|
||||||
}
|
}
|
||||||
.count-badge-muted {
|
.count-badge-muted {
|
||||||
background-color: var(--text-muted);
|
background-color: var(--bg-panel-elevated);
|
||||||
color: var(--bg-panel);
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
.count-badge-corner {
|
.count-badge-corner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -411,8 +417,9 @@ body {
|
|||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Prism Syntax Highlighting Tokens (VS Code Dark+ / Light+ via Theme Variables) */
|
/* Prism Syntax Highlighting Tokens (VS Code Dark+ / Light+ via Theme Variables).
|
||||||
:root {
|
Fallbacks only (:where has no specificity): a theme's own --code-* values always win. */
|
||||||
|
:where(:root) {
|
||||||
--code-keyword: #569cd6;
|
--code-keyword: #569cd6;
|
||||||
--code-string: #ce9178;
|
--code-string: #ce9178;
|
||||||
--code-number: #b5cea8;
|
--code-number: #b5cea8;
|
||||||
@@ -424,8 +431,18 @@ body {
|
|||||||
--code-variable: #9cdcfe;
|
--code-variable: #9cdcfe;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root:not(.dark),
|
/* Extra tones (Kanban column colours). Fallbacks like the code tokens: a theme's own values win. */
|
||||||
:root[data-theme="light"] {
|
:where(:root) {
|
||||||
|
--color-info: #60a5fa;
|
||||||
|
--color-purple: #a78bfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(:root:not(.dark)) {
|
||||||
|
--color-info: #2563eb;
|
||||||
|
--color-purple: #7c3aed;
|
||||||
|
}
|
||||||
|
|
||||||
|
:where(:root:not(.dark)) {
|
||||||
--code-keyword: #0000ff;
|
--code-keyword: #0000ff;
|
||||||
--code-string: #a31515;
|
--code-string: #a31515;
|
||||||
--code-number: #098658;
|
--code-number: #098658;
|
||||||
@@ -521,6 +538,29 @@ body {
|
|||||||
.mindsift-emoji-picker .EmojiPickerReact {
|
.mindsift-emoji-picker .EmojiPickerReact {
|
||||||
border: none !important;
|
border: none !important;
|
||||||
font-family: inherit !important;
|
font-family: inherit !important;
|
||||||
|
/* Theme colours. The library sets its own (dark: #222 …) on this element, so they are overridden here,
|
||||||
|
for the light and the dark variant; --pick-surface / --pick-hover come from EmojiPickerModal. */
|
||||||
|
--epr-bg-color: var(--pick-surface);
|
||||||
|
--epr-dark-bg-color: var(--pick-surface);
|
||||||
|
--epr-category-label-bg-color: var(--pick-surface);
|
||||||
|
--epr-dark-category-label-bg-color: var(--pick-surface);
|
||||||
|
--epr-picker-border-color: var(--border-color);
|
||||||
|
--epr-dark-picker-border-color: var(--border-color);
|
||||||
|
--epr-text-color: var(--text-main);
|
||||||
|
--epr-dark-text-color: var(--text-main);
|
||||||
|
--epr-search-input-text-color: var(--text-main);
|
||||||
|
--epr-search-input-bg-color: var(--bg-input);
|
||||||
|
--epr-dark-search-input-bg-color: var(--bg-input);
|
||||||
|
--epr-search-input-bg-color-active: var(--bg-input);
|
||||||
|
--epr-dark-search-input-bg-color-active: var(--bg-input);
|
||||||
|
--epr-hover-bg-color: var(--pick-hover);
|
||||||
|
--epr-dark-hover-bg-color: var(--pick-hover);
|
||||||
|
--epr-focus-bg-color: var(--pick-hover);
|
||||||
|
--epr-dark-focus-bg-color: var(--pick-hover);
|
||||||
|
--epr-highlight-color: var(--accent-primary);
|
||||||
|
--epr-dark-highlight-color: var(--accent-primary);
|
||||||
|
--epr-category-icon-active-color: var(--accent-primary);
|
||||||
|
--epr-dark-category-icon-active-color: var(--accent-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 639px) {
|
@media (max-width: 639px) {
|
||||||
@@ -544,12 +584,46 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 640px) {
|
||||||
|
/* Desktop: compact like a context menu, in step with the app's small type (the sprite of the category
|
||||||
|
icons scales with its size variable) */
|
||||||
|
.mindsift-emoji-picker .EmojiPickerReact {
|
||||||
|
--epr-emoji-size: 22px;
|
||||||
|
--epr-emoji-padding: 4px;
|
||||||
|
--epr-horizontal-padding: 8px;
|
||||||
|
--epr-header-padding: 8px 8px 6px;
|
||||||
|
--epr-search-input-height: 30px;
|
||||||
|
--epr-search-input-border-radius: var(--radius-btn, 6px);
|
||||||
|
--epr-category-navigation-button-size: 22px;
|
||||||
|
--epr-category-label-height: 24px;
|
||||||
|
--epr-category-label-padding: 0 8px;
|
||||||
|
}
|
||||||
|
.mindsift-emoji-picker .EmojiPickerReact input[type='text'] {
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
.mindsift-emoji-picker .EmojiPickerReact .epr-emoji-category-label {
|
||||||
|
font-size: 11px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection over several bullets in a note (OutlinerTree, CSS Highlight API) */
|
||||||
|
::highlight(outline-range) {
|
||||||
|
background-color: color-mix(in srgb, var(--accent-primary) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Read-only sections (e.g. server settings in the demo): everything inside looks switched off */
|
/* Read-only sections (e.g. server settings in the demo): everything inside looks switched off */
|
||||||
fieldset:disabled :is(button, input, select, textarea) {
|
fieldset:disabled :is(button, input, select, textarea) {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Workspace surfaces: the note editor and the folder sidebar. Themes may override them (the light theme
|
||||||
|
uses a white sheet next to a grey sidebar); by default they follow the page and panel colours. */
|
||||||
|
:root {
|
||||||
|
--bg-editor: var(--bg-app);
|
||||||
|
--bg-sidebar: var(--bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
/* Outliner: indent per level and the width a row keeps for its text. Phones indent less and let very
|
/* Outliner: indent per level and the width a row keeps for its text. Phones indent less and let very
|
||||||
deep rows run wider than the screen (the note then scrolls sideways) instead of squeezing the text. */
|
deep rows run wider than the screen (the note then scrolls sideways) instead of squeezing the text. */
|
||||||
:root {
|
:root {
|
||||||
@@ -562,3 +636,65 @@ fieldset:disabled :is(button, input, select, textarea) {
|
|||||||
--outline-min-text: 0px;
|
--outline-min-text: 0px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Focus mode: the note fades in over the app */
|
||||||
|
@keyframes focus-mode-in {
|
||||||
|
from { opacity: 0; transform: scale(0.99); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.focus-mode-enter {
|
||||||
|
animation: focus-mode-in 300ms ease-in-out;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.focus-mode-enter { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Views that replace each other (journal ↔ note) fade in instead of jumping */
|
||||||
|
@keyframes view-in {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
|
.view-enter {
|
||||||
|
animation: view-in 280ms ease-out;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.view-enter { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* No sliding or zooming for people who asked their system for less motion */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.animate-in,
|
||||||
|
.animate-out {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Corner switch (Darstellung): overrides the theme's radii on every theme */
|
||||||
|
:root[data-corners='square'] {
|
||||||
|
--radius-card: 0px !important;
|
||||||
|
--radius-btn: 2px !important;
|
||||||
|
--radius-input: 2px !important;
|
||||||
|
--radius-checkbox: 0px !important;
|
||||||
|
}
|
||||||
|
:root[data-corners='round'] {
|
||||||
|
--radius-card: 12px !important;
|
||||||
|
--radius-btn: 8px !important;
|
||||||
|
--radius-input: 8px !important;
|
||||||
|
--radius-checkbox: 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button frames switch (Darstellung): without frames, regular buttons are a soft tint instead */
|
||||||
|
:root[data-button-borders='off'] :is(.btn-action, .btn-icon, .btn-secondary, .tab-btn, .chip-btn) {
|
||||||
|
border-color: transparent !important;
|
||||||
|
}
|
||||||
|
:root[data-button-borders='off'] :is(.btn-action, .btn-icon, .btn-secondary, .chip-btn):not(:disabled) {
|
||||||
|
background-color: color-mix(in srgb, var(--text-main) 6%, transparent);
|
||||||
|
}
|
||||||
|
:root[data-button-borders='off'] :is(.btn-action, .btn-icon, .btn-secondary, .chip-btn):not(:disabled):hover {
|
||||||
|
background-color: color-mix(in srgb, var(--text-main) 11%, transparent);
|
||||||
|
}
|
||||||
|
/* … the selected tab keeps a visible accent tint */
|
||||||
|
:root[data-button-borders='off'] .chip-btn.border-accent {
|
||||||
|
background-color: var(--accent-dim) !important;
|
||||||
|
color: var(--accent-primary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { monthGrid, toISODate, parseISODate } from '../journal';
|
||||||
|
|
||||||
|
describe('journal dates', () => {
|
||||||
|
it('builds a Monday-first month grid', () => {
|
||||||
|
// October 2026 starts on a Thursday
|
||||||
|
const weeks = monthGrid(2026, 9);
|
||||||
|
expect(weeks[0]).toEqual([null, null, null, '2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04']);
|
||||||
|
expect(weeks.flat().filter(Boolean)).toHaveLength(31);
|
||||||
|
expect(weeks.every((w) => w.length === 7)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips local dates', () => {
|
||||||
|
expect(toISODate(parseISODate('2026-03-01'))).toBe('2026-03-01');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,3 +21,11 @@ describe('link preview helpers', () => {
|
|||||||
expect(linkCardUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull();
|
expect(linkCardUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('linkHost', () => {
|
||||||
|
it('shows host and path without scheme and www', async () => {
|
||||||
|
const { linkHost } = await import('../linkPreview');
|
||||||
|
expect(linkHost('https://www.example.com/')).toBe('example.com');
|
||||||
|
expect(linkHost('https://example.com/blog/post')).toBe('example.com/blog/post');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,6 +10,26 @@ describe('renderInlineMarkdown', () => {
|
|||||||
expect(strong?.textContent).toBe('fettem');
|
expect(strong?.textContent).toBe('fettem');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders inline code inside bold text', () => {
|
||||||
|
const { container } = render(<div>{renderInlineMarkdown('Der Block **`services`** startet')}</div>);
|
||||||
|
const code = container.querySelector('strong code');
|
||||||
|
expect(code?.textContent).toBe('services');
|
||||||
|
expect(container.textContent).toBe('Der Block services startet');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links bare web addresses without the trailing punctuation and keeps markdown links intact', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<div>{renderInlineMarkdown('Siehe https://example.com/a_b_c. und [Doku](https://example.org/x) oder https://x.de/#frag')}</div>
|
||||||
|
);
|
||||||
|
const links = [...container.querySelectorAll('a')].map((a) => [a.textContent, a.getAttribute('href')]);
|
||||||
|
expect(links).toEqual([
|
||||||
|
['https://example.com/a_b_c', 'https://example.com/a_b_c'],
|
||||||
|
['Doku', 'https://example.org/x'],
|
||||||
|
['https://x.de/#frag', 'https://x.de/#frag'],
|
||||||
|
]);
|
||||||
|
expect(container.textContent).toBe('Siehe https://example.com/a_b_c. und Doku oder https://x.de/#frag');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders italic text with <em> tag', () => {
|
it('renders italic text with <em> tag', () => {
|
||||||
const { container } = render(<div>{renderInlineMarkdown('Text mit *kursivem* Wort')}</div>);
|
const { container } = render(<div>{renderInlineMarkdown('Text mit *kursivem* Wort')}</div>);
|
||||||
const em = container.querySelector('em');
|
const em = container.querySelector('em');
|
||||||
|
|||||||
@@ -47,3 +47,28 @@ describe('outbox', () => {
|
|||||||
expect(isCacheablePath('/api/v1/export/markdown')).toBe(false);
|
expect(isCacheablePath('/api/v1/export/markdown')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('list items added offline', () => {
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it('show up as stand-ins and follow edits until they are sent', async () => {
|
||||||
|
const { pendingListItems, updateQueued, dropQueued, OFFLINE_ITEM_PREFIX } = await import('../offline');
|
||||||
|
const milk = enqueue('POST', '/api/v1/lists/lst_1/items', { content: 'Hafermilch', category: 'Kühlregal', quantity: '' });
|
||||||
|
const bread = enqueue('POST', '/api/v1/lists/lst_2/items', { content: 'Brot', category: '', quantity: '1' });
|
||||||
|
enqueue('POST', '/api/v1/inbox', { content: 'kein Listeneintrag' });
|
||||||
|
|
||||||
|
let items = pendingListItems();
|
||||||
|
expect(items.map((i) => [i.listId, i.content])).toEqual([
|
||||||
|
['lst_1', 'Hafermilch'],
|
||||||
|
['lst_2', 'Brot'],
|
||||||
|
]);
|
||||||
|
expect(items[0].id).toBe(OFFLINE_ITEM_PREFIX + milk.id);
|
||||||
|
|
||||||
|
updateQueued(milk.id, { quantity: '2' });
|
||||||
|
dropQueued(bread.id);
|
||||||
|
items = pendingListItems();
|
||||||
|
expect(items).toHaveLength(1);
|
||||||
|
expect(items[0].quantity).toBe('2');
|
||||||
|
expect(getOutbox()).toHaveLength(2); // the inbox entry stays
|
||||||
|
});
|
||||||
|
});
|
||||||
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user