Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
22c52adc10 | ||
|
|
1b0c8ba18f | ||
|
|
5ff99c5434 | ||
|
|
8ecde4872d | ||
|
|
07d86a0e2f | ||
|
|
a317ce0fee | ||
|
|
1d6863824b | ||
|
|
108ac6621c | ||
|
|
e15b028171 | ||
|
|
6952ae15e3 | ||
|
|
e8c7c55dd5 | ||
|
|
57de85d530 | ||
|
|
d86623f2c9 | ||
|
|
e83c1ae7dd | ||
|
|
6ae4fbc2bf | ||
|
|
4a3ee2c570 | ||
|
|
f5f21e1c9c | ||
|
|
f2df3f8d86 | ||
|
|
b5e89d6bd7 | ||
|
|
ad6a8ead61 | ||
|
|
b9df907ead | ||
|
|
e6ea8155e4 |
No files matched your search
+11
-1
@@ -8,7 +8,7 @@
|
|||||||
PORT=8080
|
PORT=8080
|
||||||
|
|
||||||
# Geheimer Schlüssel für die Anmeldung, mindestens 32 zufällige Zeichen, z. B. von: openssl rand -hex 32
|
# Geheimer Schlüssel für die Anmeldung, mindestens 32 zufällige Zeichen, z. B. von: openssl rand -hex 32
|
||||||
# Bekannte Beispielwerte (wie dieser) werden ignoriert; dann erzeugt MindSift selbst einen und legt ihn in data/ ab..
|
# Bekannte Beispielwerte (wie dieser) werden ignoriert; dann erzeugt MindSift selbst einen und legt ihn in data/ ab.
|
||||||
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
|
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
|
||||||
|
|
||||||
# Öffentliche Adresse der Instanz, so wie sie im Browser aufgerufen wird. Wird für Links in E-Mails
|
# Öffentliche Adresse der Instanz, so wie sie im Browser aufgerufen wird. Wird für Links in E-Mails
|
||||||
@@ -75,6 +75,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 +93,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
|
||||||
|
|||||||
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
|
||||||
+276
-1
@@ -6,6 +6,253 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.22.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Kanban am Handy und Tablet: Karten lassen sich mit dem Finger verschieben – kurz gedrückt halten, bis sie sich anhebt, dann auf eine andere Karte oder Spalte ziehen und loslassen. Am Rand scrollt die Seite mit (am Handy stehen die Spalten untereinander). Ein kurzer Tipp öffnet die Aufgabe wie bisher, Wischen scrollt.
|
||||||
|
- Admin-Einstellungen: Datenbank-Backups lassen sich neben „Download“ auch löschen (mit Rückfrage). Nur für Admins; Benutzer-Backups laufen weiter über die Aufbewahrungsdauer ab. Neu dafür ist `DELETE /api/v1/admin/backups/{name}`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Meldung nach dem Teilen: größer und mit dem Daumen gut erreichbar – Text oben, darunter zwei große Knöpfe **Liste** und **Aufgabe** wie in der Triage, ein kleines ✕ in der Ecke; ein Tipp daneben schließt sie.
|
||||||
|
|
||||||
|
## [0.21.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Teilen aus anderen Apps: Geteiltes landet wie bisher sofort im Quick Dump. Die Meldung danach bietet ein paar Sekunden lang **→ Aufgabe** und **→ Liste** an, um es mit einem Tipp stattdessen dort abzulegen (bei nur einer beschreibbaren Liste direkt, sonst mit kurzer Listenauswahl). Wer nichts tippt, behält den gewohnten Ablauf.
|
||||||
|
|
||||||
|
## [0.20.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- App-Verknüpfungen (App-Icon gedrückt halten): Statt „Capture“ und „Triage“, die nur die Startseite öffneten, gibt es jetzt **Quick Dump**, **Neue Aufgabe** und **Einkaufsliste** mit eigenen Symbolen. Jede öffnet den passenden Bereich mit dem Eingabefeld (Quick-Dump-Fenster, „Neue Aufgabe“, neuer Listeneintrag). Alte Verknüpfungen führen weiter zum Quick Dump. Neue Verknüpfungen übernimmt Android, sobald es die App aktualisiert (oder nach Neuinstallation).
|
||||||
|
|
||||||
|
## [0.19.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Notizen am Handy und Tablet: Solange man in einem Punkt schreibt, sitzt direkt über der Bildschirmtastatur eine schmale Leiste mit Ausrücken, Einrücken, Nach oben und Nach unten (ersetzt Tab / Umschalt+Tab). Der Punkt nimmt seine Unterpunkte mit, bleibt beim Verschieben unter seinem Elternpunkt, und die Tastatur bleibt offen. Mit Maus oder Stift erscheint die Leiste nicht.
|
||||||
|
|
||||||
|
## [0.18.1] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Workspace am Handy: Nach dem Öffnen des Journals und „Zurück“ blieb der Journal-Eintrag halb markiert, und er war kleiner beschriftet als die Ordner. Er sieht jetzt aus wie ein normaler Eintrag (gleiche Höhe und Schrift wie die Ordner); am Desktop öffnet er sich wie bisher als Bereich mit Kalender.
|
||||||
|
|
||||||
|
## [0.18.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- E-Mails haben ein einheitliches Aussehen im Stil der App: Logo mittig oben, darunter eine Karte mit Begrüßung („Guten Morgen, …“ je nach Tageszeit), dem eigentlichen Inhalt und einem Knopf zur App, unten der Hinweis, warum die E-Mail kommt. Das Logo steckt in der E-Mail selbst (erscheint also auch ohne nachgeladene Bilder und ohne `BASE_URL`); jede E-Mail hat zusätzlich eine gleich aufgebaute Textfassung.
|
||||||
|
- Tageszusammenfassung: Aufgaben stehen in Abschnitten „Überfällig“, „Heute“ und „Morgen“, mit Uhrzeit und Liste darunter.
|
||||||
|
- Fälligkeits-Alarm, Neuigkeiten, Passwort zurücksetzen, Kontoänderung (mit hervorgehobenem Sicherheitshinweis), Test-Alarm und SMTP-Test nutzen dasselbe Layout; bisher kamen die meisten davon als reiner Text.
|
||||||
|
|
||||||
|
## [0.17.4] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Benutzermenü: Bei runden Ecken (Theme-Einstellung) ragte der eckige Kopfbereich mit Name und Rolle oben über den Rand des Menüs hinaus.
|
||||||
|
|
||||||
|
## [0.17.3] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fälligkeits-Benachrichtigungen kamen nach jedem Verschieben im Kanban-Board erneut: Beim Verschieben wurde für alle offenen Aufgaben der betroffenen Spalten vermerkt, dass ihr Alarm noch aussteht. Jetzt bekommt nur eine Aufgabe, die von „Erledigt“ wieder geöffnet wird, ihren Alarm erneut.
|
||||||
|
|
||||||
|
## [0.17.2] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Workspace am Handy: Notizen, To-Dos und Hashtags wechselt man jetzt wie bei Aufgaben und Einkaufslisten über das Menü oben (Logo); die Reiterzeile entfällt dort. Ordner und Notizen in der Liste sind so hoch wie das Suchfeld (44 px) und damit leichter zu treffen.
|
||||||
|
- Aufgaben am Handy: Die Kopfleiste ist bei „Alle Aufgaben“ genauso hoch wie bei einzelnen Listen und wie bei den Einkaufslisten.
|
||||||
|
|
||||||
|
## [0.17.1] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Kanban maximieren: Nur noch das Board wird breiter, Listen-Reiter und „Neue Aufgabe anlegen“ behalten ihre Breite. Das Board wächst weich animiert, jede Spalte wird höchstens 380 px breit und das Board bleibt mittig (bei wenigen Spalten also schmaler als das Fenster).
|
||||||
|
|
||||||
|
- Ladebildschirm: Statt immer in Blau erscheint er in den Farben des zuletzt auf dem Gerät genutzten Themes (Hintergrund, Logo in der Akzentfarbe, Statusleiste passend); beim allerersten Besuch bleibt er blau. Neu dafür ist `boot-theme.js` (eigene Datei wegen der Content-Security-Policy); die App-Caches werden einmal erneuert.
|
||||||
|
|
||||||
|
- Journal: Vor dem Titel eines Tages steht statt des bunten Kalender-Emojis ein schlichtes Kalender-Symbol in der Textfarbe des Themes (nicht anklickbar, das Icon lässt sich im Journal ohnehin nicht ändern).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- App installieren am Handy: Meldet Chrome erst nach dem Öffnen der Anleitung, dass MindSift installierbar ist (z. B. kurz nach dem Entfernen der alten App), erscheint in der Anleitung jetzt ein Knopf „Jetzt installieren“ für die direkte Installation.
|
||||||
|
|
||||||
|
## [0.17.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Aufgaben und Kanban: Eine Aufgabe lässt sich auf den Reiter einer anderen Aufgabenliste ziehen und landet dort wie eine neue Aufgabe. Ihren Kanban-Status behält sie, wenn die Zielliste diese Spalte hat, sonst startet sie in der ersten Spalte. Nur lesbare Listen nehmen nichts an.
|
||||||
|
- Einkaufslisten: Einträge lassen sich genauso auf den Reiter einer anderen Liste ziehen; Kategorie, Menge und Haken bleiben erhalten. Neu dafür ist `POST /api/v1/lists/{id}/items/{itemId}/move` (Bearbeitungsrecht in beiden Listen nötig).
|
||||||
|
- Aufgaben und Einkaufslisten merken sich beim Wechsel zwischen den Reitern der App die offene Liste, Aufgaben außerdem Liste oder Kanban (pro Browser-Tab, wie der Workspace).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Einkaufslisten: Die Verbindungsanzeige oben rechts sieht jetzt aus wie bei den Notizen (Punkt und „Live“ bzw. „Offline“) statt des alten Funk-Symbols.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Push-Benachrichtigungen am Handy: In der Statusleiste erschien statt des Gehirn-Symbols nur ein einfarbiges Quadrat. Android nutzt dort nur die Transparenz des Bildes; MindSift liefert dafür jetzt ein eigenes Symbol (weißes Gehirn auf transparentem Grund). Die App-Caches werden einmal erneuert.
|
||||||
|
|
||||||
|
## [0.16.1] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- README-Screenshots aufgeräumt: Nur noch das Titelbild ist diagonal hell/dunkel geteilt, Handy und übrige Ansichten zeigen jeweils ein Theme. Neu ist ein Raster mit den Farbfamilien Fjord, Terrakotta, Polarized und Suri (je hell/dunkel diagonal, im Kanban-Board).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Einstellungen am Handy: Bei der Tageszusammenfassung ragten die Kanal-Schalter (E-Mail, Discord, Webhook, Push) rechts aus dem Bild. Am Handy stehen sie jetzt zu zweit untereinander.
|
||||||
|
|
||||||
|
## [0.16.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Kanban: Neben „Spalten anpassen“ streckt ein kleiner Knopf (diagonale Pfeile) das Board auf die volle Fensterbreite; alle Spalten stehen dann nebeneinander. Die Einstellung merkt sich der Browser, am Handy gibt es den Knopf nicht.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Aufgaben: Ein neuer Quick-Dump-Eintrag (z. B. per Spracheingabe über das gehaltene Plus) tauchte kurz oben in den Aufgaben auf, obwohl er in die Triage gehört. Live-Änderungen nehmen jetzt nur noch echte Aufgaben auf; eine per „Rückgängig“ zurück in die Triage geholte Aufgabe verschwindet auch sofort aus der Liste.
|
||||||
|
- Kanban: Bei runden Ecken (Theme-Einstellung) ragten die eckigen Spaltenüberschriften oben über den Spaltenrand hinaus.
|
||||||
|
|
||||||
|
## [0.15.1] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Triage am Handy: Die Warteschlange lässt sich wieder zuklappen („Warteschlange ausblenden“) und fährt beim Auf- und Zuklappen weich auf bzw. zu.
|
||||||
|
- Triage auf dem Desktop: Notizen, die auf einem anderen Gerät einsortiert oder gelöscht wurden, verschwinden jetzt sofort auch hier (bisher wurde nur der Aufgaben-Tab aktualisiert). Kommt der Tab wieder in den Vordergrund, lädt er außerdem neu, falls Änderungen im Hintergrund verpasst wurden.
|
||||||
|
- Aufgaben: Ein Tipp irgendwo auf die Aufgabe öffnet sie jetzt zuverlässig, nicht nur auf den Text. Bisher schluckten die Zeile mit Datum/Tags und in Kanban die Pfeil-Leiste jeden Tipp, und am Handy konnte ein etwas längerer Tipp als Ziehen gewertet werden (Ziehen gibt es nur noch mit Maus oder Stift).
|
||||||
|
|
||||||
|
## [0.15.0] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Hashtag-Übersicht: Kacheln sind breiter (so viele Spalten wie mit mindestens 18rem passen), Tag-Namen und Notiztitel haben mehr Platz.
|
||||||
|
- README überarbeitet und Screenshots erneuert: jedes Bild zeigt links das helle und rechts das dunkle Theme (diagonal geteilt), neu sind Journal und Notiz am Handy. `e2e/screenshots.sh` erzeugt die Paare selbst (`SHOT_LIGHT`/`SHOT_DARK` für andere Farbfamilien) und läuft wie die Tests mit eigenem Container und Port.
|
||||||
|
|
||||||
|
## [0.14.3] - 2026-10-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Slash-Menü: Befehle, deren Name mit dem Getippten beginnt, stehen oben (`/li` → zuerst `/link`), danach Aliase, dann Treffer im Namen, zuletzt in der Beschreibung.
|
||||||
|
- Link-Vorschau: Karte und Bild etwa doppelt so hoch; statt der nackten Adresse steht darunter eine kleine Link-Markierung (Domain und Pfad) in der Akzentfarbe des Themes.
|
||||||
|
- Links in normalen Punkten: Die grüne Markierungsleiste unter dem Punkt entfällt; Web-Adressen im Text sind jetzt direkt anklickbar (Satzzeichen am Ende gehören nicht zum Link).
|
||||||
|
|
||||||
|
## [0.14.2] - 2026-10-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Installierte App: Der Service Worker lieferte `manifest.json` zuerst aus dem Cache, sodass der Browser bei der Prüfung auf geänderte App-Farben noch die alte blaue Fassung sah. Das Manifest kommt jetzt immer frisch vom Server (Cache nur offline); die App-Caches werden einmal erneuert.
|
||||||
|
|
||||||
|
## [0.14.1] - 2026-10-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Benutzermenü: Der Hell/Dunkel-Schalter ragte aus dem Menü heraus und zeigte „Wie System“ nicht an, wenn sich dabei das Theme nicht änderte. Er steht jetzt in einer eigenen Zeile (Hell · Dunkel · System) und zeigt jede Auswahl sofort.
|
||||||
|
- Footer: eine gleichmäßige, kleinere Zeile („MindSift v… · Über MindSift“, rechts die Rechtstexte) in einheitlicher Schrift statt fett/Monospace gemischt, die schief wirkte.
|
||||||
|
|
||||||
|
## [0.14.0] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Themes als Farbfamilien mit je einer hellen und dunklen Variante: Klassik, **Fjord** (neu, Gletscherblau / Nachtblau), Terrakotta, Polarized (neu: **Polarized Hell**), Suri (neu: **Suri Hell**).
|
||||||
|
- Darstellung in den Kontoeinstellungen: Tabelle Familie × Hell/Dunkel, „Hell oder dunkel: Wie System“ (wechselt innerhalb der Familie mit dem Gerät), Ecken (Wie Theme / Rund / Eckig), Rahmen an Knöpfen (mit / ohne) und eine eigene Akzentfarbe (Vorschläge oder frei wählbar). Alles gilt pro Gerät.
|
||||||
|
- Benutzermenü und Handy-Profil: kompakte Schnellwahl mit einem Farbpunkt pro Familie und Hell / Dunkel / Wie System; eigene Themes darunter.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Code-Farben aus `index.css` sind nur noch Rückfallwerte; die Code-Farben eines Themes gewinnen immer.
|
||||||
|
|
||||||
|
## [0.13.0] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Neues Theme **Terrakotta Nacht**: das dunkle Gegenstück zu Terrakotta – Espresso-Braun, Creme-Schrift, glühender Terrakotta-Akzent, Salbeigrün, weiche Ecken.
|
||||||
|
- Animationen: Menüs (Benutzermenü, Drei-Punkte-Menü), Dialoge und Bottom-Sheets blenden und gleiten weich ein; am Handy schiebt sich eine Notiz von rechts herein und die Liste beim Zurück von links. Am PC bleibt der Notizwechsel sofort. Bei „Bewegung reduzieren“ im System gibt es keine Animationen.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Theme „Soft Slate“; wer es aktiv hatte, bekommt automatisch „Terrakotta Nacht“.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Die vorhandenen Ein-/Ausblend-Animationen (`animate-in`, `fade-in`, `slide-in-from-*`) liefen nie, weil das Tailwind-Plugin fehlte (`tailwindcss-animate`).
|
||||||
|
|
||||||
|
## [0.12.2] - 2026-10-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Triage am Desktop: Bei nur einer Liste mit langem Namen ragte „Aufgabe“ rechts über die Karte hinaus; der Listenname im Knopf wird jetzt gekürzt (voller Name im Tooltip).
|
||||||
|
|
||||||
|
## [0.12.1] - 2026-10-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Workspace → To-Dos: Die Gruppierung (Ordner → Notiz) wird pro Ebene gleichmäßig abgestuft – im hellen Theme jede Ebene etwas dunkler, im dunklen etwas heller – statt zwischen grau und weiß hin und her zu springen.
|
||||||
|
|
||||||
|
## [0.12.0] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Neues Theme **Terrakotta**: warmes Cremepapier für Notizen, sandfarbene Seitenleiste, tintendunkle Schrift, gebrannter Terrakotta-Akzent mit Salbeigrün und weiche Ecken; Code-Hervorhebung in Erdtönen.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Theme „High Contrast“. Wer es aktiv hatte, bekommt automatisch „MindSift Dark“.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Themes: Ob ein Theme hell ist (helle Code-Farben, kein Dunkelmodus), richtet sich nach seiner Hintergrundfarbe statt nur nach „Light“.
|
||||||
|
- Kopfzeile am Handy: Such-Knopf und Profilbild sind gleich große Kreise.
|
||||||
|
|
||||||
|
## [0.11.1] - 2026-10-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Triage am Handy: unter der Karte zwei große Knöpfe „← Wegwerfen“ und „Aufgabe →“ (wie Wischen), darunter ein Viererblock wie im Gedrückthalten-Menü: Auf Liste, Mit Termin, Als Notiz, Später. Die Warteschlange ist am Handy zugeklappt; „Warteschlange anzeigen“ klappt sie auf und scrollt hin.
|
||||||
|
|
||||||
|
## [0.11.0] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Fokus-Modus: Ordner und Notizen gleiten von links herein – über den Ordner-Knopf oder einen Klick auf den Titel in der Kopfleiste, auf breiten Bildschirmen auch, wenn die Maus den linken Rand berührt. Eine andere Notiz öffnet sich, ohne den Fokus-Modus zu verlassen; `Esc` schließt zuerst die Leiste.
|
||||||
|
- Fokus-Modus für Notizen: `Strg+Umschalt+F` oder „Fokus-Modus starten“ im Drei-Punkte-Menü. Nur die Notiz in angenehmer Lesebreite über dem ganzen Fenster; die Kopfleiste (Titel, Rückgängig, Teilen, Menü, „Beenden“) gleitet herein, wenn die Maus den oberen Rand berührt. `Esc` beendet ihn; am Handy über einen runden Knopf unten rechts.
|
||||||
|
- **Push-Benachrichtigungen** (Web Push) auf Handy und Desktop, auch wenn MindSift geschlossen ist: unter Kontoeinstellungen → Alarme pro Gerät einschalten und testen; „Push“ ist eine eigene Spalte für jedes Ereignis (auch fällige Aufgaben) und die Tageszusammenfassung. iPhone und iPad ab iOS 16.4, wenn MindSift zum Home-Bildschirm hinzugefügt wurde. Schlüssel per `VAPID_*` oder automatisch erzeugt. API: `/push/key`, `/push/devices`, `/push/subscribe`, `/push/unsubscribe`, `/push/test`.
|
||||||
|
- Links auf einzelne Punkte: „Link zu diesem Punkt kopieren“ in der Aktionsblase eines Punkts oder im Link-Dialog (`/link`) über „Punkt dieser Notiz wählen“. Format `[[Notiz#^id|Notiz › Auszug]]`; ein Klick öffnet die Notiz, springt zum Punkt und hebt ihn hervor. Gibt es den Punkt nicht mehr (verschoben, ausgelagert, gelöscht), öffnet sich die Notiz mit einem Hinweis. Wiki-Links stehen im Text jetzt mit ihrem Anzeigetext und sind direkt anklickbar.
|
||||||
|
- Globaler Desktop-App-Footer mit Versionsnummer, „Über MindSift“, dynamischen Rechtstexten (Impressum, Datenschutz) sowie optional konfigurierbarem Custom-Link (`LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL`).
|
||||||
|
- „Über MindSift“-Dialog und Versionsanzeige im mobilen Profil-Menü (Avatar-Menü oben rechts).
|
||||||
|
- PWA Web Share Target: Text, Links und Bilder können über den nativen Teilen-Dialog des Smartphones direkt in den MindSift-Posteingang geteilt werden (`share_target` im Manifest, Abfangen im Service Worker via IndexedDB und Übernahme beim App-Start; Fallback-Route `POST /share-target` im Server).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Inline-Code mit dezentem hellen Schimmer im dunklen Theme (Textfarbe mit geringer Deckkraft als Hintergrund) statt dunklem Code-Hintergrund, in hellen Themes entsprechend leicht grau.
|
||||||
|
- Installierte App: Statusleiste beim Start und Android-Navigationsleiste schwarz statt blau (die Navigationsleiste kann der Browser nur aus dem Manifest färben, nicht pro Theme); die Statusleiste folgt danach wie bisher dem Theme.
|
||||||
|
- Notizen-Seitenleiste: Die ausgewählte Notiz ist nur noch hinterlegt (ohne Rahmen); das Journal-Panel und die Monats-/Jahresauswahl im Kalender kommen ebenfalls ohne Rahmen aus.
|
||||||
|
- Code-Blöcke ohne Rahmen und Kopfleiste: oben links nur die Sprache, Kopieren als runder Symbol-Knopf unten rechts (erscheint beim Überfahren, am Handy immer). „In normalen Text umwandeln“ steht für Code-Blöcke (und nummerierte Punkte) in der Aktionsblase des Punkts.
|
||||||
|
- Code-, Audio- und Diktat-Blöcke haben unabhängig vom Theme runde Ecken; Callouts bleiben eckig.
|
||||||
|
- Inline-Code ist einfarbig (Text in Haupttextfarbe auf dem Code-Hintergrund des Themes) statt in Akzentfarbe, für bessere Lesbarkeit.
|
||||||
|
- Notizen: Enter in einem leeren eingerückten Punkt rückt ihn nur noch aus, wenn danach kein Punkt mehr auf derselben Ebene folgt; sonst entsteht wie gewohnt ein neuer Punkt darunter (auch nummerierte Listen werden dann nicht beendet).
|
||||||
|
- Workspace-Leiste: Live, Geteilt und Speicherstatus ohne Rahmen im gleichen Stil, ein senkrechter Trennstrich vor den Notiz-Knöpfen. Footer ohne Linie darüber.
|
||||||
|
- Journal: Panel und Kalender öffnen sich mit einer sanften Animation, die Journal-Ansicht blendet ein.
|
||||||
|
- Einfügen und Markdown-Import: Eine Liste direkt nach einem Absatz („Folgende Punkte:“) wird unter diesem Absatz eingerückt.
|
||||||
|
- Workspace am Desktop (ab 1024 px): Die eigene Kopfleiste der Notiz entfällt. Rückgängig, Wiederholen, Teilen und das Drei-Punkte-Menü stehen oben in der Workspace-Leiste, in fester Reihenfolge: Avatare, Live, Geteilt, Speicherstatus (nur noch als Symbol, Text als Tooltip), dann die Knöpfe. Beim Fokus-Modus stehen die Breadcrumbs über dem Titel. Am Handy und Tablet bleibt die Kopfleiste.
|
||||||
|
- Notizen-Seitenleiste: Überschrift „Notizen & Outliner“ entfernt, Suche und die Knöpfe für neuen Ordner und neue Notiz stehen in einer Zeile.
|
||||||
|
- Journal: Geöffnet ist es ein hinterlegtes Panel mit Rahmen, „Journal“ ist dessen Überschrift; ein zweiter Klick schließt es und zeigt wieder die vorherige Notiz. Ob das Journal offen war und welche Notiz offen war, bleibt beim Wechsel zwischen den Tabs erhalten (Journal-Tage öffnen sich nicht mehr als einzelne Notiz).
|
||||||
|
- Notizen: Beim Einfügen und beim Markdown-Import werden Absätze und Listen unter einer Überschrift eingerückt (H2 unter H1 eine Ebene tiefer), so lassen sich Abschnitte einklappen und fokussieren. Enter am Ende einer Überschrift legt den neuen Punkt als Unterpunkt an; `- `, `* ` oder `+ ` in einem leeren Punkt machen ihn zum normalen Aufzählungspunkt.
|
||||||
|
- CI: Tests laufen pro Code-Stand nur einmal. `main` baut ohne erneute Tests, wenn derselbe Stand schon als `dev` getestet wurde; ein Release-Tag auf denselben Commit wie `latest` baut nicht neu, sondern taggt `latest` um. Die Zuordnung steht als Label im Image (keine zusätzlichen Image-Tags in der Registry). Dockerfile: `SKIP_TESTS`, BuildKit-Caches für Go und npm.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Notizen: Formatierung innerhalb von fett, kursiv oder durchgestrichen wird angezeigt, z. B. fetter Inline-Code `**`services`**` (bisher standen Backticks im Text).
|
||||||
|
|
||||||
|
## [0.10.0] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Nummerierte Listen im Outliner: `/number` (auch `/nummer`, `/liste`) oder `1. ` am Anfang eines leeren Punkts; Enter setzt die Liste fort, Enter in einem leeren Punkt beendet sie. Eingerückte Listen zählen mit Buchstaben (a., b.), die nächste Ebene römisch (i., ii.), danach wieder mit Zahlen. Export und Import als Markdown (`1.`).
|
||||||
|
- **Journal** im Workspace: ganz oben in der Seitenleiste; eine Notiz pro Tag mit festem Datumstitel, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach). Solange das Journal offen ist, zeigt ein Kalender mit Monats- und Jahresauswahl den Tag, bei dem man gerade ist; Tage mit Einträgen haben einen Punkt, ein Klick springt hin (und legt den Tag bei Bedarf an). „Teilen“ gilt dem gerade sichtbaren Tag. Am Handy klappt der Kalender über die Kopfzeile auf. Unter Darstellung pro Gerät abschaltbar. API: `GET /journal`, `POST /journal/{date}`.
|
||||||
|
- Benachrichtigungen: Der eigene Webhook ist jetzt ein vollwertiger Kanal neben E-Mail und Discord – eigene Spalte „Webhook“ für jedes Ereignis und die Tageszusammenfassung; gebündelte Nachrichten kommen als JSON (`event: "digest"` mit Liste der Ereignisse).
|
||||||
|
- Outliner: Neuer Slash-Befehl `/text` (sowie Aliase `/p`, `/paragraph`, `/bullet`, `/standard`), um beliebige Blöcke (Überschriften, Callouts, Aufgaben, Trennlinien) in Standard-Text umzuwandeln; Aufgaben-Optionen und Block-Aktionsblase bieten ebenfalls die Rückumwandlung an.
|
||||||
|
- Triage-Deck: Touch-freundliche Schnellaktions-Leiste unter der aktiven mobilen Karte (Wegwerfen, Liste, Notiz, Aufgabe) mit Daumenbedienung (≥ 44 px).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Schrift: MindSift liefert die Schrift Inter jetzt selbst aus (ohne externen Dienst), damit die Oberfläche auf allen Geräten gleich aussieht. Notiztitel stehen in einer feinen, dünnen Schrift und etwas größer.
|
||||||
|
- Notizen: Der Titel in der Kopfleiste erscheint erst, wenn der große Titel aus dem Bild gescrollt ist; am Handy passt die Kopfleiste in eine Zeile.
|
||||||
|
- Das Bild für `main` (`latest`) zeigt die Versionsnummer ohne `-dev`; nur `dev` und andere Zweige behalten den Zusatz.
|
||||||
|
- Outliner: Schriftgrößen für H1 (`text-xl sm:text-2xl font-bold`), H2 (`text-lg sm:text-xl font-semibold`) und H3 (`text-base font-semibold`) deutlich angehoben bei synchroner Textarea und dezentem oberen Abstand.
|
||||||
|
- Outliner: Aktions-Buttons (Zoom, Auslagern, Löschen, Text-Umwandlung) als schwebende, kontrastreiche Blase (`backdrop-blur-sm`) positioniert, wodurch der Fließtext beim Einblenden nicht mehr ruckartig umbricht.
|
||||||
|
- Outliner: Aktiver Eintrag im Slash-Befehlsmenü dezent als `bg-accent-dim text-accent` hervorgehoben statt vollflächig deckend.
|
||||||
|
- Quick Dump (Instant Capture): Platzhaltertexte gekürzt, vertikales Scrolling im einzeiligen Zustand unterbunden und Absende-Pfeil nach unten ausgerichtet.
|
||||||
|
- Zähler-Badges (`CountBadge`): Akzent-Badge über `--accent-dim` mit Rand, Muted-Badge über `--bg-panel-elevated` und `--text-muted` für hohe Lesbarkeit in allen Themes.
|
||||||
|
- Workspace: Popover-Dialoge (`FormSheet` mit Anker) öffnen zentriert unter dem Mauszeiger mit Rand-Schutz (Viewport-Clamping).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Notizen: Eingefügter Text mit Zeilenumbrüchen, der in einem einzigen Punkt landet (z. B. ein Absatz), verschwand – er wird jetzt übernommen. Leerzeilen trennen eingefügte Absätze in eigene Punkte, nummerierte Markdown-Listen bleiben nummeriert (auch mit 3 oder 4 Leerzeichen Einrückung), umbrochene Zeilen gehören zum Punkt darüber.
|
||||||
|
- Kontoeinstellungen: Der eigene Webhook ließ sich zwar eintragen, aber nicht mehr einschalten (seit dem Umbau der Benachrichtigungen in 0.8.0).
|
||||||
|
- Workspace: kein doppelter Scrollbalken mehr – die Seite selbst scrollt nicht, nur die Notiz; am Handy scrollt die Notiz nicht mehr getrennt von der Seite.
|
||||||
|
- Workspace: Ein abgebrochenes Ziehen in der Seitenleiste lässt „Auf Hauptebene verschieben“ nicht mehr stehen.
|
||||||
|
- Workspace: Der Knopf zum Ein-/Ausklappen der Ordnerleiste ist im eingeklappten Zustand dezent (nur das Symbol in Akzentfarbe).
|
||||||
|
- Outliner: Slash-Befehle werden an der tatsächlichen Cursorposition im Text erkannt (`/link`, `/diktat`); `/divider`, `/image`, `/audio` und `/code` werden nur noch in leeren Blöcken angeboten; das Menü schließt bei Klick ins Leere und Blur.
|
||||||
|
- Outliner: Aufgaben-Badges und Drei-Punkte-Menü aus dem Fließtext-Container entkoppelt, um störende Zeilenumbrüche am Zeilenende zu beheben.
|
||||||
|
- Outliner: Trennlinien (`divider`) können fokussiert und per `Backspace` oder `Entf` direkt in einen leeren Textpunkt umgewandelt werden.
|
||||||
|
- Kontoeinstellungen: Der Bereich „Profil & Anmeldedaten“ nutzt die volle Breite wie die übrigen Bereiche.
|
||||||
|
- Light-Theme: Notizen stehen auf einem weißen Blatt, die Ordnerleiste ist dezent grau abgesetzt (bisher umgekehrt). Themes können das über die neuen Variablen `--bg-editor` und `--bg-sidebar` selbst festlegen.
|
||||||
|
|
||||||
|
## [0.9.0] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Formatierungs-Kürzel im Outliner: `Strg+B` / `Cmd+B` für fett, `Strg+I` / `Cmd+I` für kursiv (mit Ein-/Ausschalten bei markiertem Text) und `Strg+K` / `Cmd+K` für Links (mit Markierung `[Text](…)` mit vorselektierter Adresse, ohne Markierung Link-Dialog).
|
||||||
|
- Code-Blöcke: Zeilennummernspalte links mit synchronem Scrollen (wächst dynamisch bei 100+ Zeilen, zählt auch leere Endzeilen mit); horizontaler Bildlauf bei langen Zeilen ohne automatischen Zeilenumbruch (`white-space: pre`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Formatierungs-Kürzel wirken nicht in schreibgeschützten Notizen.
|
||||||
|
- Light-Theme: Kontraste für Code-Blöcke (`--bg-code`), Callouts (`--bg-panel-elevated`), Zitate und Rahmen auf Notizhintergründen geschärft; VS Code Light+ Syntaxfarben für hohe Lesbarkeit optimiert.
|
||||||
|
|
||||||
## [0.8.0] - 2026-10-30
|
## [0.8.0] - 2026-10-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -22,6 +269,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Notizen: Link-Vorschau-Karten erscheinen nur noch, wenn der gesamte Punkt aus einer einzigen `https`-Adresse besteht (oder über `/link` in einem leeren Punkt eingefügt wurde), nicht mehr bei Links mitten im Fließtext.
|
- Notizen: Link-Vorschau-Karten erscheinen nur noch, wenn der gesamte Punkt aus einer einzigen `https`-Adresse besteht (oder über `/link` in einem leeren Punkt eingefügt wurde), nicht mehr bei Links mitten im Fließtext.
|
||||||
- Notizen: Das Slash-Menü (`/`) öffnet sich nur noch am Textanfang oder nach einem Leerzeichen (nicht mehr beim Einfügen von Web-Adressen mit Schrägstrichen), schließt zuverlässig mit `Esc` oder `Backspace` und scrollt bei Pfeiltasten-Navigation automatisch mit.
|
- Notizen: Das Slash-Menü (`/`) öffnet sich nur noch am Textanfang oder nach einem Leerzeichen (nicht mehr beim Einfügen von Web-Adressen mit Schrägstrichen), schließt zuverlässig mit `Esc` oder `Backspace` und scrollt bei Pfeiltasten-Navigation automatisch mit.
|
||||||
- Notizen: `Enter` in einem leeren Punkt auf oberster Ebene legt auch am Ende der Notiz einen neuen Punkt darunter an.
|
- Notizen: `Enter` in einem leeren Punkt auf oberster Ebene legt auch am Ende der Notiz einen neuen Punkt darunter an.
|
||||||
|
- Benachrichtigungen: Die Tageszusammenfassung schließt nun auch Aufgaben aus Aufgabenlisten ein, die über eine Gruppe freigegeben wurden.
|
||||||
|
|
||||||
## [0.7.0] - 2026-10-30
|
## [0.7.0] - 2026-10-30
|
||||||
|
|
||||||
@@ -211,7 +459,34 @@ 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.8.0...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.22.0...dev
|
||||||
|
[0.22.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.21.0...v0.22.0
|
||||||
|
[0.21.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.20.0...v0.21.0
|
||||||
|
[0.20.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.19.0...v0.20.0
|
||||||
|
[0.19.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.18.1...v0.19.0
|
||||||
|
[0.18.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.18.0...v0.18.1
|
||||||
|
[0.18.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.4...v0.18.0
|
||||||
|
[0.17.4]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.3...v0.17.4
|
||||||
|
[0.17.3]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.2...v0.17.3
|
||||||
|
[0.17.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.1...v0.17.2
|
||||||
|
[0.17.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.17.0...v0.17.1
|
||||||
|
[0.17.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.16.1...v0.17.0
|
||||||
|
[0.16.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.16.0...v0.16.1
|
||||||
|
[0.16.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.15.1...v0.16.0
|
||||||
|
[0.15.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.15.0...v0.15.1
|
||||||
|
[0.15.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.3...v0.15.0
|
||||||
|
[0.14.3]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.2...v0.14.3
|
||||||
|
[0.14.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.1...v0.14.2
|
||||||
|
[0.14.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.14.0...v0.14.1
|
||||||
|
[0.14.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.13.0...v0.14.0
|
||||||
|
[0.13.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.12.2...v0.13.0
|
||||||
|
[0.12.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.12.1...v0.12.2
|
||||||
|
[0.12.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.12.0...v0.12.1
|
||||||
|
[0.12.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.11.1...v0.12.0
|
||||||
|
[0.11.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.11.0...v0.11.1
|
||||||
|
[0.11.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.10.0...v0.11.0
|
||||||
|
[0.10.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.9.0...v0.10.0
|
||||||
|
[0.9.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.8.0...v0.9.0
|
||||||
[0.8.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...v0.8.0
|
[0.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
|
||||||
[0.6.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.6.1
|
[0.6.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.6.1
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+33
-18
@@ -7,8 +7,9 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| 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`) |
|
||||||
| 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/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben; `429` nach 20 Versuchen pro Adresse in 15 Minuten |
|
||||||
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
| POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` |
|
||||||
@@ -16,18 +17,6 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
| 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 |
|
||||||
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
||||||
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=` (in der URL nur API-Keys, keine Sitzungs-Tokens). Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
|
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=` (in der URL nur API-Keys, keine Sitzungs-Tokens). Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
|
||||||
| POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
|
|
||||||
| POST | `/documents/{id}/media` | Bild oder Audio zu einer Notiz hochladen (`multipart/form-data`, Feld `file`; Schreibrecht nötig) → `{id, kind, mime, size, url}`. `415` falsches Format, `413` zu groß oder Kontingent voll |
|
|
||||||
| POST | `/documents/{id}/media/fetch` | Externes Bild kopieren: `{url}` (nur https, keine internen Adressen; nicht in der Demo) → wie oben |
|
|
||||||
| GET | `/media/{id}` | Datei abrufen (Leserecht auf eine Notiz, die sie zeigt), mit Range-Anfragen; sonst `404` |
|
|
||||||
| 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/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) |
|
|
||||||
| POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` |
|
|
||||||
| POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen |
|
|
||||||
| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` |
|
|
||||||
| GET | `/link-preview?url=https://…` | Vorschau einer Web-Seite (`title`, `description`, `siteName`, `hasImage`, `hasIcon`, `ok`, `key`), vom Server geholt und 7 Tage zwischengespeichert; `422` ohne https, `403` in der Demo, `429` zu viele Abrufe |
|
|
||||||
| GET | `/link-preview/{key}/image` bzw. `/icon` | Bild bzw. Icon einer Vorschau |
|
|
||||||
| POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) |
|
|
||||||
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header |
|
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header |
|
||||||
| GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
|
| GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
|
||||||
|
|
||||||
@@ -37,6 +26,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET | `/auth/me` | Benutzer und Kennzahlen |
|
| GET | `/auth/me` | Benutzer und Kennzahlen |
|
||||||
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
|
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
|
||||||
|
| POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
|
||||||
|
| POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) |
|
||||||
| POST | `/auth/logout` | Sitzung dieses Geräts beenden |
|
| POST | `/auth/logout` | Sitzung dieses Geräts beenden |
|
||||||
| 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 |
|
||||||
@@ -56,7 +47,8 @@ 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/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (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` (Standard `["webhook:triage"]`) |
|
||||||
|
| DELETE | `/keys/{id}` | API-Key löschen |
|
||||||
|
|
||||||
### Globale Suche (`GET /api/v1/search?q=`)
|
### Globale Suche (`GET /api/v1/search?q=`)
|
||||||
|
|
||||||
@@ -96,6 +88,7 @@ Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
|
|||||||
| GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) |
|
| GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) |
|
||||||
| POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) |
|
| POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) |
|
||||||
| POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen |
|
| POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen |
|
||||||
|
| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` |
|
||||||
|
|
||||||
## Aufgabenlisten
|
## Aufgabenlisten
|
||||||
|
|
||||||
@@ -118,7 +111,8 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
|
|||||||
| GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) |
|
| GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) |
|
||||||
| GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen |
|
| GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen |
|
||||||
| PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) |
|
| PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) |
|
||||||
| GET/POST/DELETE | `/workspace/folders/shared/{id}/shares[/{shareId}]` | Freigaben (`permission`: `read`/`write`) |
|
| GET/POST | `/workspace/folders/shared/{id}/shares` | Freigaben geteilter Ordner (`permission`: `read`/`write`) |
|
||||||
|
| DELETE | `/workspace/folders/shared/{id}/shares/{shareId}` | Freigabe eines geteilten Ordners entfernen |
|
||||||
| GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
|
| GET | `/workspace/tags`, `/workspace/tags/{tag}` | Tag-Übersicht und -Details |
|
||||||
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
|
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
|
||||||
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
|
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
|
||||||
@@ -129,7 +123,24 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
|
|||||||
| DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen |
|
| DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen |
|
||||||
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
|
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
|
||||||
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
|
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
|
||||||
| GET/POST/DELETE | `/documents/{id}/shares[/{userId}]` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
|
| GET/POST | `/documents/{id}/shares` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
|
||||||
|
| DELETE | `/documents/{id}/shares/{userId}` | Notiz-Freigabe entfernen |
|
||||||
|
| POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt |
|
||||||
|
| GET | `/push/key` | `{enabled, publicKey}` – öffentlicher VAPID-Schlüssel für `pushManager.subscribe` (in der Demo `enabled: false`) |
|
||||||
|
| GET | `/push/devices` | Eigene Geräte mit Push → `{devices: [{id, endpoint, device, createdAt}]}` |
|
||||||
|
| POST | `/push/subscribe` | `{endpoint, keys: {p256dh, auth}, device}` – Gerät anmelden (nur `https`-Endpunkte) |
|
||||||
|
| POST | `/push/unsubscribe` | `{endpoint}` – Gerät abmelden |
|
||||||
|
| POST | `/push/test` | Test-Benachrichtigung an alle eigenen Geräte → `{devices}`; `400` ohne Gerät |
|
||||||
|
| GET | `/journal` | Tage des eigenen Journals, neueste zuerst → `{days: [{date, docId, empty}]}` (`empty`: Notiz noch ohne Text; Tage im Papierkorb fehlen) |
|
||||||
|
| POST | `/journal/{date}` | Notiz des Tages (`JJJJ-MM-TT`) öffnen; gibt es noch keine, wird sie angelegt (Titel z. B. „Freitag, 30. Oktober 2026“, Icon 📅) → Dokument, `201` bei Neuanlage. Die Journal-Notizen sind normale Notizen (Teilen, Suche, Sync) |
|
||||||
|
| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) |
|
||||||
|
| POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` |
|
||||||
|
| POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen |
|
||||||
|
| POST | `/documents/{id}/media` | Bild oder Audio zu einer Notiz hochladen (`multipart/form-data`, Feld `file`; Schreibrecht nötig) → `{id, kind, mime, size, url}`. `415` falsches Format, `413` zu groß oder Kontingent voll |
|
||||||
|
| POST | `/documents/{id}/media/fetch` | Externes Bild kopieren: `{url}` (nur https, keine internen Adressen; nicht in der Demo) → wie oben |
|
||||||
|
| GET | `/media/{id}` | Datei abrufen (Leserecht auf eine Notiz, die sie zeigt), mit Range-Anfragen; sonst `404` |
|
||||||
|
| GET | `/link-preview?url=https://…` | Vorschau einer Web-Seite (`title`, `description`, `siteName`, `hasImage`, `hasIcon`, `ok`, `key`), vom Server geholt und 7 Tage zwischengespeichert; `422` ohne https, `403` in der Demo, `429` zu viele Abrufe |
|
||||||
|
| GET | `/link-preview/{key}/image` bzw. `/icon` | Bild bzw. Icon einer Vorschau |
|
||||||
|
|
||||||
Der Inhalt einer Notiz (Blöcke) wird ausschließlich über `POST /docs/{id}/sync` geändert. `POST /documents` nimmt beim Anlegen weiterhin eine Start-Blockliste an; sie wird intern in Operationen umgerechnet.
|
Der Inhalt einer Notiz (Blöcke) wird ausschließlich über `POST /docs/{id}/sync` geändert. `POST /documents` nimmt beim Anlegen weiterhin eine Start-Blockliste an; sie wird intern in Operationen umgerechnet.
|
||||||
|
|
||||||
@@ -173,11 +184,13 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) |
|
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) |
|
||||||
| GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen |
|
| GET/DELETE | `/lists/{id}` | Abruf mit Einträgen; Löschen |
|
||||||
| GET/POST/DELETE | `/lists/{id}/shares[/{shareId}]` | Freigaben (`targetType`, `targetId`, `role`: `editor`/`viewer`) |
|
| GET/POST | `/lists/{id}/shares` | Freigaben abrufen bzw. anlegen (`targetType`, `targetId`, `role`: `editor`/`viewer`) |
|
||||||
|
| DELETE | `/lists/{id}/shares/{shareId}` | Freigabe entfernen |
|
||||||
| POST | `/lists/{id}/items` | Eintrag (`content`, `category`, `quantity`) |
|
| POST | `/lists/{id}/items` | Eintrag (`content`, `category`, `quantity`) |
|
||||||
| PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) |
|
| PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) |
|
||||||
| PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) |
|
| PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) |
|
||||||
| DELETE | `/lists/{id}/items/{itemId}` | Löschen |
|
| DELETE | `/lists/{id}/items/{itemId}` | Löschen |
|
||||||
|
| POST | `/lists/{id}/items/{itemId}/move` | In eine andere Liste verschieben (`targetListId`); Bearbeitungsrecht in beiden Listen nötig, der Eintrag landet oben |
|
||||||
| POST/PUT | `/lists/{id}/items/reorder` | Reihenfolge/Kategorie (`items[{id, sortOrder, category, content}]`) |
|
| POST/PUT | `/lists/{id}/items/reorder` | Reihenfolge/Kategorie (`items[{id, sortOrder, category, content}]`) |
|
||||||
| POST | `/lists/{id}/clear-completed` | Erledigte entfernen |
|
| POST | `/lists/{id}/clear-completed` | Erledigte entfernen |
|
||||||
|
|
||||||
@@ -200,13 +213,15 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
|||||||
| 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 |
|
||||||
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
|
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
|
||||||
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `organizationId`, `validDays`) |
|
| GET/POST | `/admin/invites` | Einladungen auflisten bzw. anlegen (`email`, `role`, `organizationId`, `validDays`) |
|
||||||
|
| 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`) |
|
||||||
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
|
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
|
||||||
| GET | `/admin/voice` | Aktive Spracherkennung (`mode`: `server`/`browser`/`off`), Einstellungen aus `STT_*` ohne Schlüssel, Grenzen und im Server-Modus ein frischer Zustand (`status.reachable`, `status.modelReady`) |
|
| GET | `/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
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ Client → Server versteht der Server nur: `{"event":"JOIN_ROOM","room":"<id>"}`
|
|||||||
| `LIST_CREATED`, `LIST_UPDATED`, `LIST_DELETED`, `LIST_SHARED`, `LIST_UNSHARED`, `LIST_CLEARED` | Listenverwaltung | Benutzer bzw. Raum der Liste |
|
| `LIST_CREATED`, `LIST_UPDATED`, `LIST_DELETED`, `LIST_SHARED`, `LIST_UNSHARED`, `LIST_CLEARED` | Listenverwaltung | Benutzer bzw. Raum der Liste |
|
||||||
| `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste |
|
| `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste |
|
||||||
| `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum |
|
| `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum |
|
||||||
|
| `DEMO_RESET` | Periodischer Reset im Demo-Modus; Clients verwerfen lokale Caches und laden Daten neu | Alle aktiven Verbindungen |
|
||||||
| `SSE_CONNECTED` | Nur SSE, direkt nach Verbindung | Verbindung |
|
| `SSE_CONNECTED` | Nur SSE, direkt nach Verbindung | Verbindung |
|
||||||
|
|
||||||
## Verhalten im Frontend
|
## Verhalten im Frontend
|
||||||
|
|||||||
+10
-2
@@ -70,13 +70,21 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
|||||||
| `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 |
|
||||||
|
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | – | Schlüsselpaar für Web-Push; ohne Angabe einmal erzeugt und in `push_keys` gespeichert |
|
||||||
|
| `VAPID_SUBJECT` | `BASE_URL` (wenn https) | Kontakt für die Push-Dienste (`mailto:` oder `https://`) |
|
||||||
|
| `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB` | `15` / `50` | Größtes Bild bzw. größte Audiodatei in Notizen in MB |
|
||||||
|
| `MEDIA_QUOTA_MB` | `0` | Speicherplatz für Medien pro Benutzer in MB (`0` = unbegrenzt) |
|
||||||
|
| `MEDIA_DEMO_MAX_MB` | `3` | Nur im Demo-Modus: maximale Dateigröße für Medien-Uploads in MB |
|
||||||
| `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser |
|
| `STT_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_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 |
|
||||||
| `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` | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster |
|
||||||
|
| `LEGAL_IMPRINT_CONTENT_FILE`, `LEGAL_PRIVACY_CONTENT_FILE` | leer | Pfad zu einer Datei im Container mit Markdown-Inhalt für Impressum bzw. Datenschutz. Ohne URL und Text bleibt der Link unsichtbar. |
|
||||||
|
| `LEGAL_CUSTOM_LINK_LABEL`, `LEGAL_CUSTOM_LINK_URL` | leer | Eigener Link im globalen Desktop-Footer (nur `https://`, sonst ignoriert) |
|
||||||
|
|
||||||
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
|
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
|
||||||
|
|
||||||
@@ -101,7 +109,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
|
||||||
|
|
||||||
|
|||||||
+9
-6
@@ -8,22 +8,25 @@ 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) |
|
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation), `notification_settings` (JSON der Kanäle und Ereignisse), `last_daily_summary` (Datum des letzten Versands) |
|
||||||
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `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 | `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 | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `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` |
|
||||||
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
|
| `system_settings` | Schlüssel/Wert für Instanz-Einstellungen | `sharing_mode`, `allow_registration`, `instance_name`, `smtp_*` |
|
||||||
|
|
||||||
### Gedanken und Aufgaben
|
### Gedanken und Aufgaben
|
||||||
| Tabelle | Zweck | Wichtige Spalten |
|
| Tabelle | Zweck | Wichtige Spalten |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `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), `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` …) |
|
||||||
| `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`) |
|
||||||
@@ -80,11 +83,11 @@ teams 1─n team_members n─1 users
|
|||||||
## Migrationen und Aufräumarbeiten beim Start
|
## Migrationen und Aufräumarbeiten beim Start
|
||||||
|
|
||||||
- Spalten/Tabellen/Indizes werden nachgezogen (`ALTER TABLE … ADD COLUMN`, Fehler werden ignoriert).
|
- Spalten/Tabellen/Indizes werden nachgezogen (`ALTER TABLE … ADD COLUMN`, Fehler werden ignoriert).
|
||||||
- Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`.
|
- Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`, `revoked_tokens`.
|
||||||
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
||||||
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
||||||
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=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 genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 („Basis-Schema v0.4.1“, `migrateBaseline`) baut das komplette Schema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
|
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (Migrationen 3 bis 9: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“, 8 „Journal“, 9 „Web-Push“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
|
||||||
|
|
||||||
## Sicherung
|
## Sicherung
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -131,7 +131,7 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
|
|||||||
|
|
||||||
### Notizen
|
### Notizen
|
||||||
- **Versionsverlauf ansehen und zurückholen:** das Operations-Log (`doc_ops`) ist vorhanden, eine Ansicht fehlt.
|
- **Versionsverlauf ansehen und zurückholen:** das Operations-Log (`doc_ops`) ist vorhanden, eine Ansicht fehlt.
|
||||||
- **Bilder und Anhänge** in Notizen.
|
- ~~**Bilder und Anhänge** in Notizen.~~ (Umgesetzt in v0.7.0 mit `/image`, `/audio` und Medienablage).
|
||||||
- **Vorlagen und Tagesnotiz** (z. B. „Wochenrückblick“, „Heute“).
|
- **Vorlagen und Tagesnotiz** (z. B. „Wochenrückblick“, „Heute“).
|
||||||
- **Öffentlicher Lese-Link** für einzelne Notizen, mit Ablaufdatum.
|
- **Öffentlicher Lese-Link** für einzelne Notizen, mit Ablaufdatum.
|
||||||
- **Verlinkungs-Graph** auf Basis der Backlinks.
|
- **Verlinkungs-Graph** auf Basis der Backlinks.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Eine Aufgabe ist ein `thought` ([database.md](../database.md)) mit `kanbanStatus
|
|||||||
- Gespeichert in `task_assignees`; `PATCH /thoughts/{id}` mit `assignees` ersetzt die Liste. Der Server lehnt Personen ohne Zugriff ab (`403`, `storage.ErrAssigneeNoAccess`).
|
- Gespeichert in `task_assignees`; `PATCH /thoughts/{id}` mit `assignees` ersetzt die Liste. Der Server lehnt Personen ohne Zugriff ab (`403`, `storage.ErrAssigneeNoAccess`).
|
||||||
- **Zuweisungen folgen den Rechten:** `Store.PruneTaskAssignees` entfernt jede Zuweisung, deren Person die Liste nicht mehr sehen kann. Es läuft nach Entfernen einer Freigabe, Austritt aus oder Entfernen aus einer Gruppe, Löschen einer Gruppe, Löschen der Liste und Verschieben der Aufgabe in eine andere oder keine Liste; beim Löschen eines Kontos werden dessen Zuweisungen direkt gelöscht. Wer über einen zweiten Weg (z. B. direkt und über eine Gruppe) weiter Zugriff hat, bleibt zugewiesen.
|
- **Zuweisungen folgen den Rechten:** `Store.PruneTaskAssignees` entfernt jede Zuweisung, deren Person die Liste nicht mehr sehen kann. Es läuft nach Entfernen einer Freigabe, Austritt aus oder Entfernen aus einer Gruppe, Löschen einer Gruppe, Löschen der Liste und Verschieben der Aufgabe in eine andere oder keine Liste; beim Löschen eines Kontos werden dessen Zuweisungen direkt gelöscht. Wer über einen zweiten Weg (z. B. direkt und über eine Gruppe) weiter Zugriff hat, bleibt zugewiesen.
|
||||||
- Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)).
|
- Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)).
|
||||||
- Bekannte Lücke: (Umsortieren per ID).
|
- **Aufgaben beobachten (`task_watchers`):** Im Detail-Fenster lässt sich eine Aufgabe über „Beobachten“ abonnieren (`POST /tasks/{id}/watch`, `DELETE /tasks/{id}/watch`). Jede Änderung an einer beobachteten Aufgabe wird dem Beobachter über seine konfigurierten Benachrichtigungskanäle gemeldet.
|
||||||
|
|
||||||
## Wiederkehrende Aufgaben
|
## Wiederkehrende Aufgaben
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offe
|
|||||||
| Discord | Webhook-URL | Benutzereinstellungen |
|
| Discord | Webhook-URL | Benutzereinstellungen |
|
||||||
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
|
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
|
||||||
|
|
||||||
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war.
|
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war. Neben Fälligkeits-Alarmen (die sofort versendet werden) unterstützt MindSift granulare Benachrichtigungen für Freigaben, erledigte geteilte Aufgaben und beobachtete Aufgaben, die über `notification_queue` gebündelt nach `NOTIFY_DIGEST_MINUTES` versendet werden ([architecture.md](../architecture.md#benachrichtigungen)).
|
||||||
|
|
||||||
**E-Mail-Versand** ([mail.go](../../internal/api/mail.go)) führt den SMTP-Dialog Schritt für Schritt:
|
**E-Mail-Versand** ([mail.go](../../internal/api/mail.go)) führt den SMTP-Dialog Schritt für Schritt:
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Ziel: Gedanken in Sekunden festhalten und später in Ruhe sortieren. Code: [Inst
|
|||||||
|
|
||||||
- Die Eingabeleiste sitzt im Header; `C` oder `/` fokussiert sie (außer beim Tippen in einem Feld).
|
- Die Eingabeleiste sitzt im Header; `C` oder `/` fokussiert sie (außer beim Tippen in einem Feld).
|
||||||
- Ziel (`target`): **Posteingang** (`inbox`, Standard), **Direkt-Aufgabe** (`task`), **Listeneintrag** (`list_item`, mit Zielliste) oder **Direkt-Notiz** (`document`).
|
- Ziel (`target`): **Posteingang** (`inbox`, Standard), **Direkt-Aufgabe** (`task`), **Listeneintrag** (`list_item`, mit Zielliste) oder **Direkt-Notiz** (`document`).
|
||||||
- Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt die Web Speech API des Browsers; Quelle `voice` fügt den Tag `#diktat` an.
|
- Mehrzeilige Eingabe wird unterstützt. Spracheingabe nutzt `useVoiceInput` (Whisper-Server oder Spracherkennung des Browsers); Quelle `voice` fügt den Tag `#diktat` an.
|
||||||
- Quelle `webhook` (per API-Key/Webhook) fügt den Tag `#webhook` an. Hashtags im Text werden als Tags extrahiert (`domain.ExtractTags`).
|
- Quelle `webhook` (per API-Key/Webhook) fügt den Tag `#webhook` an. Hashtags im Text werden als Tags extrahiert (`domain.ExtractTags`).
|
||||||
- Per API: `POST /inbox` und `POST /webhooks/ingest` (siehe [rest.md](../api/rest.md)).
|
- Per API: `POST /inbox` und `POST /webhooks/ingest` (siehe [rest.md](../api/rest.md)).
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Modul: Workspace (Outliner-Notizen)
|
# Modul: Workspace (Outliner-Notizen)
|
||||||
|
|
||||||
Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [CodeBlockItem.tsx](../../frontend/src/features/workspace/CodeBlockItem.tsx), [LinkModal.tsx](../../frontend/src/features/workspace/LinkModal.tsx); Backend: Dokument-, Ordner- und Tag-Handler, Sync-Handler in `internal/api/docsync.go`, `internal/collab`, `internal/storage/docsync.go`.
|
Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [CodeBlockEditor.tsx](../../frontend/src/features/workspace/CodeBlockEditor.tsx), [LinkModal.tsx](../../frontend/src/features/workspace/LinkModal.tsx); Backend: Dokument-, Ordner- und Tag-Handler, Sync-Handler in `internal/api/docsync.go`, `internal/collab`, `internal/storage/docsync.go`.
|
||||||
|
|
||||||
## Datenhaltung
|
## Datenhaltung
|
||||||
|
|
||||||
@@ -15,7 +15,10 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
|||||||
- **Outliner:** `Enter` neuer Block, `Tab`/`Shift+Tab` ein-/ausrücken, `Shift+Enter` weicher Umbruch, Blöcke ein-/ausklappen.
|
- **Outliner:** `Enter` neuer Block, `Tab`/`Shift+Tab` ein-/ausrücken, `Shift+Enter` weicher Umbruch, Blöcke ein-/ausklappen.
|
||||||
- **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück.
|
- **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück.
|
||||||
- **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
|
- **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
|
||||||
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`).
|
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code` (mit Syntax-Highlighting im VS-Code-Stil), `/callout`, `/divider`, `/extract` (Ast als eigene Notiz auslagern), `/image` (Bilder einfügen/hochladen, Aliase `/bild`, `/foto`), `/audio` (Sprachnotiz/Audiodatei, Alias `/sprachnotiz`), `/diktat` (Spracheingabe mit Live-Mitschreiben, Alias `/dictate`) (Liste in `OutlinerBlock.tsx`). Das Menü öffnet sich nur am Textanfang oder nach einem Leerzeichen und schließt mit `Esc` oder `Backspace`.
|
||||||
|
- **Link-Vorschau:** Besteht ein Bulletpoint ausschließlich aus einer einzelnen `https`-Adresse (oder wurde über `/link` in einem leeren Punkt eingefügt), zeigt MindSift darüber eine interaktive Vorschaukarte (`LinkPreviewCard`, serverseitig via `/link-preview`). ✕ blendet die Karte aus und wandelt die Adresse synchronisiert in `[Titel](url)` um.
|
||||||
|
- **Ast auslagern (`/extract`):** Wandelt den aktuellen Punkt samt Unterpunkten in eine eigenständige Notiz im selben Ordner um. Der Server legt die Notiz an und liefert den Link zurück; der Editor ersetzt den Ast lokal über die Sync-Engine als eigene Operation (Strg+Z nimmt die Auslagerung zurück und legt die neue Notiz via `POST /documents/{id}/extract/undo` in den Papierkorb).
|
||||||
|
- **Notizen zusammenführen (Merge):** Per Drag & Drop einer Notiz auf eine andere in der Seitenleiste oder über das Drei-Punkte-Menü im Notizkopf („Mit anderer Notiz zusammenführen…“). Die eigene Quellnotiz wird als `h2`-Abschnitt angehängt und in den Papierkorb verschoben; der Hinweis bietet vollständiges Rückgängigmachen (`POST /documents/{id}/merge/undo`).
|
||||||
- **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`).
|
- **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`).
|
||||||
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
|
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
|
||||||
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
|
- **Einfügen:** Mehrzeiliger Text wird anhand von Markdown-Präfixen und Einrückung in Blöcke zerlegt; Überschriften gruppieren darunterliegenden Text und Code.
|
||||||
@@ -51,13 +54,13 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
|
|||||||
|
|
||||||
## Freigabe
|
## Freigabe
|
||||||
|
|
||||||
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen geben. Empfänger haben **nur Leserechte**; nur der Eigentümer schreibt ([sharing-and-permissions.md](../sharing-and-permissions.md)).
|
- Einzelne Notizen und ganze **geteilte Ordner** lassen sich an Benutzer oder Gruppen freigeben – wahlweise mit der Rolle **Bearbeiter** (`write`) oder **Leser** (`read`) ([sharing-and-permissions.md](../sharing-and-permissions.md)). Bearbeiter ändern Inhalt und Struktur über die Sync-Engine; Leser sehen Änderungen live.
|
||||||
- Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt.
|
- Empfänger ordnen geteilte Notizen und Ordner in ihrem eigenen Baum ein (`doc_user_folders`, `workspace_folder_mounts`), ohne dass der Eigentümer etwas davon merkt.
|
||||||
- Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)).
|
- Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)).
|
||||||
|
|
||||||
## Grenzen
|
## Grenzen
|
||||||
|
|
||||||
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie.
|
- Keine gleichzeitige Bearbeitung im selben einzelnen Bullet (Sperren pro Bullet und Konfliktkopie statt Zeichen-für-Zeichen-Zusammenführung), keine Cursor-Anzeige fremder Eingaben, keine Versionshistorie.
|
||||||
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
|
- Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*.
|
||||||
- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht.
|
- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht.
|
||||||
|
|
||||||
|
|||||||
@@ -70,4 +70,4 @@ Bei der Einrichtung im Modus `organizations` wird eine Organisation mit dem Name
|
|||||||
|
|
||||||
- Jede Anmeldung ist eine Sitzung (`sessions`), die einzeln beendet werden kann (Logout, Profil → Aktive Sitzungen). Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ beenden alle Sitzungen (`token_version`); eine Passwortänderung alle außer der aktuellen.
|
- Jede Anmeldung ist eine Sitzung (`sessions`), die einzeln beendet werden kann (Logout, Profil → Aktive Sitzungen). Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ beenden alle Sitzungen (`token_version`); eine Passwortänderung alle außer der aktuellen.
|
||||||
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
|
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
|
||||||
- Bekannte Lücken in der Zugriffskontrolle:.
|
- Bekannte Lücken in der Zugriffskontrolle: siehe [flaws.md](flaws.md).
|
||||||
+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 |
|
||||||
|
|||||||
+2
-2
@@ -73,9 +73,9 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla
|
|||||||
Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx)
|
Komponente: [HoldToRecordFab.tsx](../frontend/src/features/capture/HoldToRecordFab.tsx)
|
||||||
|
|
||||||
- **Kurz tippen (< 350 ms):** Führt wie bisher die primäre Aktion der aktuellen Ansicht (`primaryAction`) oder den mobilen Erfassungsdialog (`openMobileTriageCapture`) aus.
|
- **Kurz tippen (< 350 ms):** Führt wie bisher die primäre Aktion der aktuellen Ansicht (`primaryAction`) oder den mobilen Erfassungsdialog (`openMobileTriageCapture`) aus.
|
||||||
- **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über die Web Speech API (`de-DE`, `continuous`, `interimResults`). Der Button wird zum pulsierenden Mikrofon (`bg-status-err`), darüber zeigt eine schwebende Pille „Ich höre zu… (loslassen speichert)“ samt Live-Transkript.
|
- **Gedrückt halten (≥ 350 ms):** Startet direkt eine Sprachaufnahme über `useVoiceInput` (im Server-Modus per Whisper mit echten Pegel-Wellen, sonst über die Spracherkennung des Browsers). Der Button wird zum pulsierenden Mikrofon (`bg-status-err`), darüber zeigt eine schwebende Pille „Ich höre zu… (loslassen speichert)“ samt Live-Transkript.
|
||||||
- **Loslassen:** Stoppt die Aufnahme, wartet kurz auf das letzte Wort und speichert den Text unabhängig von der aktiven Ansicht immer als Gedanken in die Inbox (`source: 'voice'`, `target: 'inbox'`).
|
- **Loslassen:** Stoppt die Aufnahme, wartet kurz auf das letzte Wort und speichert den Text unabhängig von der aktiven Ansicht immer als Gedanken in die Inbox (`source: 'voice'`, `target: 'inbox'`).
|
||||||
- **Nach oben ziehen (> 60 px):** Schaltet die Pille in den Abbruch-Zustand („🗑️ Zum Abbrechen loslassen“); Loslassen dort verwirft die Aufnahme (`abort()`).
|
- **Nach oben ziehen (> 60 px):** Schaltet die Pille in den Abbruch-Zustand („🗑️ Zum Abbrechen loslassen“); Loslassen dort verwirft die Aufnahme (`abort()`).
|
||||||
- **Fallback:** Unterstützt der Browser keine Spracherkennung oder ist das Mikrofon gesperrt (`not-allowed`), öffnet Halten wie beim Tippen den Dialog und blendet eine kurze Statusmeldung über der Bottom-Leiste ein.
|
- **Fallback:** Ist die Spracheingabe abgeschaltet (`STT_DISABLED=true`), unterstützt der Browser keine Spracherkennung oder ist das Mikrofon gesperrt (`not-allowed`), öffnet Halten wie beim Tippen den Dialog und blendet eine kurze Statusmeldung über der Bottom-Leiste ein.
|
||||||
|
|
||||||
|
|
||||||
@@ -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.png" alt="MindSift Triage am Desktop, links hell, rechts dunkel" width="85%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<sub>Links das helle, rechts das dunkle Theme.</sub>
|
||||||
|
|
||||||
[Philosophie](#-die-philosophie) • [Funktionen](#-funktionen) • [Schnellstart](#-schnellstart) • [Betrieb](#-betrieb--konfiguration) • [Entwicklung](#-entwicklung) • [Dokumentation](#-dokumentation)
|
[Philosophie](#-die-philosophie) • [Funktionen](#-funktionen) • [Schnellstart](#-schnellstart) • [Betrieb](#-betrieb--konfiguration) • [Entwicklung](#-entwicklung) • [Dokumentation](#-dokumentation)
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -34,22 +36,29 @@ 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)).
|
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe: über den Browser oder datenschutzfreundlich über einen eigenen Whisper-Container (optional in `docker-compose.yml`, siehe [Architektur](Doku/architecture.md#spracherkennung)); auf Smartphones auch per Gedrückthalten des `+`-Knopfs (Hold-to-Record mit Pegel-Wellen).
|
||||||
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
|
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
|
||||||
* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste.
|
* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste oder über große Knöpfe unter der Karte; die Warteschlange klappt bei Bedarf auf.
|
||||||
|
* **Teilen aus anderen Apps:** Als installierte App erscheint MindSift im Teilen-Menü des Smartphones; geteilte Texte und Links landen im Posteingang und lassen sich direkt danach mit einem Tipp zur Aufgabe oder auf eine Liste schicken.
|
||||||
* Papierkorb mit Wiederherstellen.
|
* Papierkorb mit Wiederherstellen.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/Mobile_Triage.png" alt="MindSift Mobile Triage" width="280px" />
|
<img src="screenshots/Mobile_Triage.png" alt="MindSift Triage am Smartphone, helles Theme" width="280px" />
|
||||||
|
|
||||||
|
<img src="screenshots/Mobile_Workspace.png" alt="MindSift Notiz am Smartphone, dunkles Theme" width="280px" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### ✅ Aufgaben & Kanban
|
### ✅ Aufgaben & Kanban
|
||||||
|
|
||||||
* Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar.
|
* Listen- und Kanban-Ansicht, Spalten pro Aufgabenliste frei konfigurierbar.
|
||||||
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
|
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
|
||||||
* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board.
|
* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board, auch auf den Reiter einer anderen Aufgabenliste.
|
||||||
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
|
* **Aufgabenlisten teilen** mit Benutzern oder Gruppen, wahlweise als **Bearbeiter** oder **nur lesend**.
|
||||||
* **Alarme** bei Fälligkeit per E-Mail, Discord oder eigenem Webhook.
|
* **Aufgaben beobachten:** Beliebige Aufgaben per Klick abonnieren, um über alle Änderungen informiert zu werden.
|
||||||
|
* **Wiederholungen:** Wiederkehrende Aufgaben (täglich, wöchentlich, monatlich, jährlich) mit automatischer Folgeaufgabe beim Abhaken.
|
||||||
|
* **Tageszusammenfassung:** Tägliche Übersicht fälliger Aufgaben zur selbst gewählten Uhrzeit per E-Mail, Discord, eigenem Webhook oder Push.
|
||||||
|
* **Push-Benachrichtigungen** auf Handy und Desktop (auch bei geschlossener App; iPhone/iPad als Home-Bildschirm-App), als eigener Kanal je Ereignis.
|
||||||
|
* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort, alle übrigen Ereignisse (Freigaben, erledigte geteilte Aufgaben, beobachtete Aufgaben) gebündelt; pro Ereignis wählbar über E-Mail, Discord, eigenen Webhook und Push.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
|
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
|
||||||
@@ -57,30 +66,38 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
|||||||
|
|
||||||
### 📝 Workspace: Outliner-Notizen
|
### 📝 Workspace: Outliner-Notizen
|
||||||
|
|
||||||
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste.
|
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste; am Handy eine Leiste über der Tastatur zum Ein-/Ausrücken und Verschieben.
|
||||||
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren.
|
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren; **Fokus-Modus** (`Strg+Umschalt+F`) zeigt nur die Notiz in Lesebreite, Ordner gleiten bei Bedarf von links herein.
|
||||||
* Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`.
|
* **Journal:** eine Notiz pro Tag, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach), mit Kalender zum Springen; einzelne Tage lassen sich teilen. Pro Gerät abschaltbar.
|
||||||
* Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
|
* Blocktypen und Slash-Befehle: `/todo`, `/number` (nummerierte Liste: 1., a., i.), `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`, `/extract`, `/image`, `/audio`, `/diktat`.
|
||||||
|
* **Bilder & Audio in Notizen:** `/image` (Datei, Kamera, Zwischenablage oder Link), `/audio` (Sprachnotiz oder Datei-Upload) und `/diktat` (Live-Transkription).
|
||||||
|
* **Link-Vorschau:** Vorschaukarte (Titel, Beschreibung, Bild) für Punkte, die nur aus einer Web-Adresse bestehen, z. B. über `/link`; die Seite ruft der Server ab, nicht dein Browser. Pro Gerät abschaltbar.
|
||||||
|
* **Ast auslagern & Notizen zusammenführen:** Bulletpoints samt Unterbaum in eigenständige Notizen auslagern (`/extract`) oder Notizen per Drag & Drop bzw. Menü zusammenführen (jeweils mit vollständiger Rückgängig-Funktion).
|
||||||
|
* Wiki-Links `[[Notiz]]`, auch auf **einzelne Punkte** (`[[Notiz#^id|Text]]`, kopierbar an jedem Punkt), Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
|
||||||
* Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`.
|
* Ordner, Favoriten, **To-Do-Rollup** über alle Notizen und Export einer Notiz als `.md`.
|
||||||
* Einfügen von mehrzeiligem Markdown wird in Blöcke zerlegt; `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her.
|
* Eingefügtes Markdown wird zur Gliederung: Überschriften werden zu Abschnitten mit eingerücktem Inhalt, Listen (auch nummeriert) behalten ihre Ebenen, Absätze werden eigene Punkte. Kürzel beim Tippen: `- `, `1. `, `[] `, `# `, `Strg+B`/`I`/`K`. `Strg+Z` / `Strg+Umschalt+Z` nimmt eigene Änderungen zurück bzw. stellt sie wieder her.
|
||||||
* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen als **Bearbeiter** oder **Leser** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein.
|
* **Teilen:** Notizen und ganze Ordner lassen sich Benutzern oder Gruppen als **Bearbeiter** oder **Leser** freigeben. Empfänger sortieren sie in ihren eigenen Baum ein.
|
||||||
|
|
||||||
#### 🤝 Gemeinsam bearbeiten
|
#### 🤝 Gemeinsam bearbeiten
|
||||||
|
|
||||||
* **Live:** Mehrere Personen schreiben gleichzeitig in derselben Notiz; Änderungen erscheinen bei den anderen in Bruchteilen einer Sekunde. Der Kopf der Notiz zeigt, wer gerade mitliest.
|
* **Live:** Mehrere Personen schreiben gleichzeitig in derselben Notiz; Änderungen erscheinen bei den anderen in Bruchteilen einer Sekunde. Die Leiste über der Notiz zeigt, wer gerade mitliest.
|
||||||
* **Kein Überschreiben:** Wer in einem Bullet schreibt, hält ihn kurz für sich; andere sehen ein Schloss mit Namen und arbeiten in den übrigen Bullets weiter. Kommt es doch zu einem Konflikt (z. B. nach Offline-Arbeit), bleibt der zweite Text als markierte Kopie mit „Übernehmen“/„Verwerfen“ erhalten.
|
* **Kein Überschreiben:** Wer in einem Bullet schreibt, hält ihn kurz für sich; andere sehen ein Schloss mit Namen und arbeiten in den übrigen Bullets weiter. Kommt es doch zu einem Konflikt (z. B. nach Offline-Arbeit), bleibt der zweite Text als markierte Kopie mit „Übernehmen“/„Verwerfen“ erhalten.
|
||||||
* **Offline-fest:** Jede Eingabe wird zuerst im Browser gesichert und später übertragen, auch nach Neuladen oder Tagen ohne Netz.
|
* **Offline-fest:** Jede Eingabe wird zuerst im Browser gesichert und später übertragen, auch nach Neuladen oder Tagen ohne Netz.
|
||||||
* **Hinter strengen Proxys:** WebSocket → SSE → Long-Polling → Abfrage alle 3 s; MindSift wählt automatisch, was durchkommt. Der aktive Weg steht im Editor („Live“, „Abgleich alle 3 s“).
|
* **Hinter strengen Proxys:** WebSocket → SSE → Long-Polling → Abfrage alle 3 s; MindSift wählt automatisch, was durchkommt. Der aktive Weg steht in der Leiste über der Notiz („Live“, „Abgleich alle 3 s“).
|
||||||
|
|
||||||
Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md).
|
Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md).
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace Outliner" width="90%" />
|
<img src="screenshots/workspace_desktop.png" alt="MindSift Workspace: zwei Personen in derselben Notiz" width="90%" />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="screenshots/journal_desktop.png" alt="MindSift Journal mit Kalender" width="90%" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🛒 Geteilte Listen
|
### 🛒 Geteilte Listen
|
||||||
|
|
||||||
* Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien.
|
* Einkaufs-, Pack- und Checklisten mit Kategorien per Hashtag (`Hafermilch #Kühlregal`), Mengenangaben und Drag & Drop zwischen Kategorien oder in eine andere Liste.
|
||||||
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
|
* Freigabe an Benutzer und Gruppen (Bearbeiter oder Leser); abgehakte Einträge erscheinen bei allen in Echtzeit.
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -91,13 +108,29 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
|
|||||||
|
|
||||||
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
|
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
|
||||||
* Gruppen mit Gruppen-Admins für Freigaben.
|
* Gruppen mit Gruppen-Admins für Freigaben.
|
||||||
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
|
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus, Sicherungen, Spracherkennung.
|
||||||
* Vier Themes (Dark, Light, High-Contrast, Soft), installierbar als PWA. Die App startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist.
|
|
||||||
|
### 🎨 Darstellung & App
|
||||||
|
|
||||||
|
* **Fünf Farbfamilien** mit je einer hellen und dunklen Variante: Klassik, Fjord, Terrakotta, Polarized, Suri. Hell/Dunkel auf Wunsch „Wie System“, dazu eine eigene Akzentfarbe, Ecken rund oder eckig und Knöpfe mit oder ohne Rahmen (pro Gerät). Für eigene Themes gibt es einen Theme-Manager (eine CSS-Datei).
|
||||||
|
* **Installierbar als App (PWA)** auf Smartphone und Desktop. Sie startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist.
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><img src="screenshots/Theme_fjord.png" alt="Farbfamilie Fjord, links hell, rechts dunkel" /><br /><sub><b>Fjord</b></sub></td>
|
||||||
|
<td align="center"><img src="screenshots/Theme_terracotta.png" alt="Farbfamilie Terrakotta, links hell, rechts dunkel" /><br /><sub><b>Terrakotta</b></sub></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><img src="screenshots/Theme_polarized.png" alt="Farbfamilie Polarized, links hell, rechts dunkel" /><br /><sub><b>Polarized</b></sub></td>
|
||||||
|
<td align="center"><img src="screenshots/Theme_suri.png" alt="Farbfamilie Suri, links hell, rechts dunkel" /><br /><sub><b>Suri</b></sub></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
### 🔌 Integrationen
|
### 🔌 Integrationen
|
||||||
|
|
||||||
* **Webhook / API-Key:** `POST /api/v1/webhooks/ingest` legt Aufgaben aus Smarthome, Skripten oder Automationen an.
|
* **Eingehend (API-Key):** `POST /api/v1/webhooks/ingest` legt Gedanken, Aufgaben oder Listeneinträge aus Smarthome, Skripten oder Automationen an.
|
||||||
* Persönliche API-Keys, Datenexport als JSON.
|
* **Ausgehend:** eigener Webhook als Benachrichtigungskanal (JSON per POST), Discord, E-Mail, Push.
|
||||||
|
* Persönliche API-Keys, Datenexport als JSON, Markdown-Archiv (Obsidian-kompatibel).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -130,7 +163,7 @@ Minimal:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
mindsift:
|
mindsift:
|
||||||
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.4); :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"]
|
||||||
@@ -161,16 +194,22 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|
|||||||
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
||||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
||||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
|
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
|
||||||
|
| `SMTP_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 |
|
||||||
|
| `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY`, `VAPID_SUBJECT` | automatisch | Schlüssel für Push-Benachrichtigungen; ohne Angabe einmal erzeugt und in der Datenbank gespeichert. `VAPID_SUBJECT` (Kontakt für die Push-Dienste, `mailto:` oder `https://`) sonst aus `BASE_URL` |
|
||||||
|
| `MEDIA_*` | diverse | Grenzen für Bilder und Audio in Notizen (`MEDIA_MAX_IMAGE_MB=15`, `MEDIA_MAX_AUDIO_MB=50`, `MEDIA_QUOTA_MB=0`) |
|
||||||
|
| `STT_*` | diverse | Optionale eigene Spracherkennung per Whisper (`STT_URL`, `STT_MODEL`, `STT_LANGUAGE` …; siehe [Doku/architecture.md](Doku/architecture.md#spracherkennung)) |
|
||||||
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
| `LEGAL_IMPRINT_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.
|
||||||
* **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).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -192,12 +231,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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -209,7 +248,7 @@ 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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -33,6 +33,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 +42,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}
|
||||||
|
|||||||
+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'
|
||||||
+145
-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,51 @@ 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);
|
||||||
|
await plain(page, 'journal_desktop.png', 'dark');
|
||||||
|
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();
|
||||||
};
|
};
|
||||||
|
|||||||
+24
-8
@@ -1,20 +1,36 @@
|
|||||||
#!/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).
|
||||||
|
# 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
|
||||||
|
|
||||||
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 \
|
||||||
|
-e SHOT_LIGHT="${SHOT_LIGHT:-light}" -e SHOT_DARK="${SHOT_DARK:-dark}" -e LANG=de_DE.UTF-8 -e TZ=Europe/Berlin \
|
||||||
-v "$ROOT/e2e":/e2e:Z -v "$ROOT/screenshots":/shots:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
|
-v "$ROOT/e2e":/e2e:Z -v "$ROOT/screenshots":/shots:Z -w /e2e mcr.microsoft.com/playwright:v1.48.2-jammy \
|
||||||
sh -c 'apt-get update -qq >/dev/null && apt-get install -y -qq fonts-inter >/dev/null 2>&1; npm install --no-audit --no-fund >/dev/null 2>&1 && node screenshots.mjs'
|
sh -c 'npm install --no-audit --no-fund >/dev/null 2>&1 && node screenshots.mjs'
|
||||||
+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
+23
-2
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.8.0",
|
"version": "0.22.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.8.0",
|
"version": "0.22.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"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,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.0",
|
"version": "0.22.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
+177
-49
@@ -42,6 +42,7 @@ import {
|
|||||||
AVAILABLE_THEMES,
|
AVAILABLE_THEMES,
|
||||||
customThemeOption,
|
customThemeOption,
|
||||||
initThemeOnBoot,
|
initThemeOnBoot,
|
||||||
|
watchSystemScheme,
|
||||||
isCustomThemeId,
|
isCustomThemeId,
|
||||||
setActiveTheme as applyTheme,
|
setActiveTheme as applyTheme,
|
||||||
ThemeId,
|
ThemeId,
|
||||||
@@ -55,6 +56,7 @@ 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 { 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,6 +67,10 @@ 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 { consumePendingShares, formatSharedThought } from './lib/shareTarget';
|
||||||
import { APP_VERSION } from './version';
|
import { APP_VERSION } from './version';
|
||||||
|
|
||||||
type ActiveTab =
|
type ActiveTab =
|
||||||
@@ -131,6 +137,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
|
||||||
@@ -170,6 +179,7 @@ export const App: React.FC = () => {
|
|||||||
|
|
||||||
// Profile Dropdown State
|
// Profile Dropdown State
|
||||||
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
const [showProfileMenu, setShowProfileMenu] = useState(false);
|
||||||
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// Mobile Navigation & Quick-Action State
|
// Mobile Navigation & Quick-Action State
|
||||||
@@ -239,6 +249,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();
|
||||||
@@ -438,6 +476,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 +593,53 @@ export const App: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
|
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
|
||||||
|
|
||||||
|
// 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 +667,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 +695,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 +854,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 +924,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 +947,7 @@ export const App: React.FC = () => {
|
|||||||
}`}
|
}`}
|
||||||
title="Profil & Einstellungen"
|
title="Profil & Einstellungen"
|
||||||
>
|
>
|
||||||
<UserAvatar user={user} size="sm" className="shadow-sm" />
|
<UserAvatar user={user} size="md" className="shadow-sm" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -909,7 +1020,7 @@ export const App: React.FC = () => {
|
|||||||
</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 +1039,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 +1195,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 +1218,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}
|
||||||
@@ -1193,6 +1303,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 +1614,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 +1681,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 +1726,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 +1788,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 +1842,8 @@ export const App: React.FC = () => {
|
|||||||
onOpenTask={handleOpenTask}
|
onOpenTask={handleOpenTask}
|
||||||
onOpenList={handleOpenList}
|
onOpenList={handleOpenList}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -36,7 +36,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 (
|
||||||
@@ -54,7 +59,7 @@ export const FormSheet: React.FC<FormSheetProps> = ({
|
|||||||
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 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'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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,116 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { ArrowRight, CheckSquare, Inbox, ShoppingCart, X } from 'lucide-react';
|
||||||
|
import { api, triggerHaptic } from '../lib/api';
|
||||||
|
import type { SharedList } from '../lib/types';
|
||||||
|
|
||||||
|
// After sharing from another app: the items are already in the Quick Dump (nothing to tap), and for a
|
||||||
|
// few seconds they can be sent on as a task or onto a list instead – one tap, the flow stays as fast.
|
||||||
|
const AUTO_HIDE_MS = 8000;
|
||||||
|
|
||||||
|
interface SharedImportToastProps {
|
||||||
|
thoughtIds: string[]; // created on the server (offline captures have no id yet and get no buttons)
|
||||||
|
lists: SharedList[];
|
||||||
|
onClose: () => void;
|
||||||
|
onConverted: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SharedImportToast: React.FC<SharedImportToastProps> = ({ thoughtIds, lists, onClose, onConverted }) => {
|
||||||
|
const [choosingList, setChoosingList] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
|
||||||
|
const count = thoughtIds.length;
|
||||||
|
|
||||||
|
// Disappears on its own unless a list is being picked or a conversion runs
|
||||||
|
useEffect(() => {
|
||||||
|
if (choosingList || busy) return;
|
||||||
|
const timer = window.setTimeout(onClose, AUTO_HIDE_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [choosingList, busy, onClose]);
|
||||||
|
|
||||||
|
const convert = async (convertTo: 'task' | 'list_item', list?: SharedList) => {
|
||||||
|
triggerHaptic(15);
|
||||||
|
setBusy(true);
|
||||||
|
setFailed(false);
|
||||||
|
try {
|
||||||
|
for (const id of thoughtIds) {
|
||||||
|
await api.triageAction(id, 'keep', { convertTo, targetListId: list?.id });
|
||||||
|
}
|
||||||
|
const what = count === 1 ? 'Eintrag' : `${count} Einträge`;
|
||||||
|
onConverted(convertTo === 'task' ? `${count === 1 ? 'Als Aufgabe' : `${count} Aufgaben`} angelegt` : `${what} auf „${list?.title}“`);
|
||||||
|
} catch {
|
||||||
|
setFailed(true);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toList = () => {
|
||||||
|
if (writableLists.length === 1) convert('list_item', writableLists[0]);
|
||||||
|
else setChoosingList(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const big =
|
||||||
|
'min-h-[52px] px-3 py-2 rounded-xl text-sm font-semibold flex items-center justify-center gap-2 touch-manipulation cursor-pointer active:scale-98 disabled:opacity-50';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Tapping anywhere else closes it (transparent, the page stays visible) */}
|
||||||
|
<div className="fixed inset-0 z-[59]" onClick={onClose} aria-hidden />
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] w-[calc(100vw-1.5rem)] max-w-md panel-surface shadow-panel overflow-hidden p-4 space-y-3 animate-in fade-in slide-in-from-bottom-2 duration-200"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Schließen"
|
||||||
|
className="absolute top-2 right-2 h-8 w-8 flex items-center justify-center rounded-btn text-faint hover:text-main"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<div className="pr-8">
|
||||||
|
<p className="flex items-center gap-2 text-base font-semibold text-main">
|
||||||
|
<Inbox className="w-5 h-5 text-accent shrink-0" />
|
||||||
|
{count === 1 ? 'Im Quick Dump abgelegt' : `${count} Einträge im Quick Dump abgelegt`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted mt-1">
|
||||||
|
{choosingList ? 'Auf welche Liste?' : 'Oder gleich woanders hin:'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failed && <p className="text-xs text-status-err">Umwandeln hat nicht geklappt – der Eintrag bleibt im Quick Dump.</p>}
|
||||||
|
|
||||||
|
{!choosingList ? (
|
||||||
|
<div className={`grid gap-2 ${writableLists.length > 0 ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||||
|
{writableLists.length > 0 && (
|
||||||
|
<button type="button" disabled={busy} onClick={toList} className={`${big} bg-canvas border border-border text-main`}>
|
||||||
|
<ShoppingCart className="w-4 h-4 text-accent" />
|
||||||
|
<span>Liste</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" disabled={busy} onClick={() => convert('task')} className={`${big} bg-accent text-on-accent`}>
|
||||||
|
<CheckSquare className="w-4 h-4" />
|
||||||
|
<span>Aufgabe</span>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{writableLists.map((l) => (
|
||||||
|
<button
|
||||||
|
key={l.id}
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => convert('list_item', l)}
|
||||||
|
className={`${big} bg-canvas border border-border text-main`}
|
||||||
|
>
|
||||||
|
<span>{l.icon}</span>
|
||||||
|
<span className="truncate">{l.title}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
AVAILABLE_THEMES,
|
||||||
|
getFollowSystem,
|
||||||
|
resolveTheme,
|
||||||
|
setFollowSystem,
|
||||||
|
THEME_FAMILIES,
|
||||||
|
ThemeId,
|
||||||
|
ThemeOption,
|
||||||
|
} from '../lib/theme';
|
||||||
|
|
||||||
|
interface ThemeQuickPickerProps {
|
||||||
|
activeTheme: ThemeId;
|
||||||
|
/** All themes including the user's own (shown as small chips) */
|
||||||
|
themes: ThemeOption[];
|
||||||
|
onChange: (id: ThemeId) => void;
|
||||||
|
/** Bigger touch targets in the phone sheet */
|
||||||
|
touch?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact switcher for the user menu: one dot per colour family plus light/dark; own themes below
|
||||||
|
export const ThemeQuickPicker: React.FC<ThemeQuickPickerProps> = ({ activeTheme, themes, onChange, touch = false }) => {
|
||||||
|
const active = AVAILABLE_THEMES.find((t) => t.id === activeTheme);
|
||||||
|
const mode = active?.mode ?? 'dark';
|
||||||
|
const customThemes = themes.filter((t) => t.isCustom);
|
||||||
|
const dot = touch ? 'w-9 h-9' : 'w-7 h-7';
|
||||||
|
// Kept in state: choosing "System" may not change the theme, but the selection must still show
|
||||||
|
const [followSystem, setFollow] = useState(getFollowSystem);
|
||||||
|
const pick = (id: ThemeId) => onChange(resolveTheme(id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-1">
|
||||||
|
<div className="flex w-full items-center justify-between" role="radiogroup" aria-label="Farbfamilie">
|
||||||
|
{THEME_FAMILIES.map((f) => {
|
||||||
|
const preview = AVAILABLE_THEMES.find((t) => t.id === (mode === 'light' ? f.light : f.dark));
|
||||||
|
const selected = active?.family === f.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
onClick={() => pick(mode === 'light' ? f.light : f.dark)}
|
||||||
|
title={f.name}
|
||||||
|
className={`${dot} rounded-full flex items-center justify-center transition-transform active:scale-95 cursor-pointer ${
|
||||||
|
selected ? 'ring-2 ring-accent ring-offset-2 ring-offset-panel' : 'hover:scale-105'
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: preview?.previewBg }}
|
||||||
|
>
|
||||||
|
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: preview?.previewAccent }} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Own row: three labelled segments, so it fits narrow menus */}
|
||||||
|
<div className="grid grid-cols-3 rounded-full bg-panel-elevated p-0.5" role="radiogroup" aria-label="Hell oder dunkel">
|
||||||
|
{(['light', 'dark', 'auto'] as const).map((m) => {
|
||||||
|
const family = active?.family ? THEME_FAMILIES.find((f) => f.id === active.family) : undefined;
|
||||||
|
const selected = m === 'auto' ? followSystem : !followSystem && !!active && mode === m;
|
||||||
|
const disabled = !family;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (!family) return;
|
||||||
|
setFollowSystem(m === 'auto');
|
||||||
|
setFollow(m === 'auto');
|
||||||
|
onChange(m === 'auto' ? resolveTheme(family.dark) : family[m]);
|
||||||
|
}}
|
||||||
|
title={m === 'light' ? 'Hell' : m === 'dark' ? 'Dunkel' : 'Wie System'}
|
||||||
|
aria-label={m === 'light' ? 'Hell' : m === 'dark' ? 'Dunkel' : 'Wie System'}
|
||||||
|
className={`${touch ? 'h-9' : 'h-7'} rounded-full flex items-center justify-center gap-1 text-[11px] font-medium transition-colors cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||||
|
selected ? 'bg-panel text-accent shadow-sm' : 'text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m === 'light' ? (
|
||||||
|
<Sun className="w-3.5 h-3.5" />
|
||||||
|
) : m === 'dark' ? (
|
||||||
|
<Moon className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<Monitor className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
<span>{m === 'light' ? 'Hell' : m === 'dark' ? 'Dunkel' : 'System'}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{customThemes.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{customThemes.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFollowSystem(false);
|
||||||
|
setFollow(false);
|
||||||
|
onChange(t.id);
|
||||||
|
}}
|
||||||
|
className={`h-7 px-2 rounded-full text-[11px] flex items-center gap-1.5 border transition-colors cursor-pointer ${
|
||||||
|
activeTheme === t.id ? 'border-accent text-main' : 'border-border text-muted hover:text-main'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: t.previewAccent }} />
|
||||||
|
<span className="truncate max-w-[8rem]">{t.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,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' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
@@ -73,6 +73,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;
|
||||||
@@ -82,8 +83,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
const scrollH = targetEl.scrollHeight;
|
const scrollH = targetEl.scrollHeight;
|
||||||
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
|
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
|
||||||
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
|
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
|
||||||
|
setIsOverMaxHeight(scrollH > 140);
|
||||||
} else {
|
} else {
|
||||||
setIsMultiline(false);
|
setIsMultiline(false);
|
||||||
|
setIsOverMaxHeight(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -267,16 +270,18 @@ 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={`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"
|
||||||
/>
|
/>
|
||||||
@@ -360,7 +365,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ArrowUp className="w-3 h-3" />
|
<ArrowDown className="w-3 h-3" />
|
||||||
<span className="inline">
|
<span className="inline">
|
||||||
{hideTargetPicker || (fixedTarget || target) === 'inbox'
|
{hideTargetPicker || (fixedTarget || target) === 'inbox'
|
||||||
? 'Quick Dump'
|
? 'Quick Dump'
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Users,
|
Users,
|
||||||
Check,
|
Check,
|
||||||
Radio,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
X,
|
X,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
} 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 { 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';
|
||||||
@@ -83,7 +83,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) {
|
||||||
@@ -116,6 +122,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
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);
|
||||||
|
|
||||||
@@ -378,6 +386,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();
|
||||||
@@ -849,6 +886,23 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
]);
|
]);
|
||||||
}, [lists, activeList, completedItems.length, setGroups, setPrimaryAction]);
|
}, [lists, activeList, completedItems.length, setGroups, setPrimaryAction]);
|
||||||
|
|
||||||
|
// 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 +915,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,30 +1017,13 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Live Sync Status */}
|
{livePill}
|
||||||
<div
|
|
||||||
className={`h-8 w-8 flex items-center justify-center rounded-btn border transition-colors ${
|
|
||||||
wsConnected
|
|
||||||
? realtimeTransport === 'sse'
|
|
||||||
? 'bg-teal-500/10 border-teal-500/30 text-teal-400'
|
|
||||||
: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-500'
|
|
||||||
: 'bg-canvas border-border text-muted'
|
|
||||||
}`}
|
|
||||||
title={
|
|
||||||
wsConnected
|
|
||||||
? realtimeTransport === 'sse'
|
|
||||||
? 'Live synchronisiert (Server-Sent Events / Proxy-Stream)'
|
|
||||||
: 'Live synchronisiert (WebSocket online)'
|
|
||||||
: 'Offline'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Radio className={`w-3.5 h-3.5 ${wsConnected ? 'animate-pulse' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Compact Header Bar (List switching & actions live in MindSift Logo Menu) */}
|
{/* Mobile Compact Header Bar (List switching & actions live in MindSift Logo Menu) */}
|
||||||
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 rounded-btn bg-panel border border-border !mt-0">
|
{/* Same height with or without the 36 px share button (Tasks, Shopping lists) */}
|
||||||
|
<div className="flex sm:hidden items-center justify-between gap-2 px-3 py-2 min-h-[54px] rounded-btn bg-panel border border-border !mt-0">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<span className="text-sm font-semibold text-main truncate">
|
<span className="text-sm font-semibold text-main truncate">
|
||||||
{activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'}
|
{activeList ? `${activeList.icon} ${activeList.title}` : '🛒 Einkaufsliste'}
|
||||||
@@ -996,24 +1045,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>
|
||||||
|
|
||||||
@@ -1195,6 +1227,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)}
|
||||||
|
|||||||
@@ -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,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,
|
||||||
@@ -17,13 +17,30 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Copy,
|
Copy,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { AuthStatus, NotificationEvents, NotificationSettings, User, UserTheme } from '../../lib/types';
|
import { AuthStatus, NotificationEvents, NotificationSettings, PushDevice, 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 {
|
||||||
|
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 +49,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 } from '../../lib/push';
|
||||||
import { FormSheet } from '../../components/FormSheet';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
|
|
||||||
interface UserSettingsViewProps {
|
interface UserSettingsViewProps {
|
||||||
@@ -51,6 +70,7 @@ 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 +79,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 +106,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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -144,6 +174,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 +285,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 +298,50 @@ 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 [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)));
|
||||||
|
}, []);
|
||||||
|
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 +349,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 +452,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,
|
||||||
@@ -581,7 +647,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 +923,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 +1400,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 +1434,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 +1456,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 +1489,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 +1534,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 +1549,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 +1567,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 +1708,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 +1720,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 +1750,49 @@ 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 && (
|
||||||
|
<p className="pl-6 text-[11px] text-faint">
|
||||||
|
Aktiv auf {pushDevices.length} {pushDevices.length === 1 ? 'Gerät' : 'Geräten'}:{' '}
|
||||||
|
{pushDevices.map((d) => d.device || 'Gerät').join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
Columns,
|
Columns,
|
||||||
|
Maximize2,
|
||||||
|
Minimize2,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Edit3,
|
Edit3,
|
||||||
@@ -28,6 +30,8 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
|
||||||
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
|
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
|
||||||
|
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||||||
|
import { useKanbanTouchDrag } from './useKanbanTouchDrag';
|
||||||
import { api, triggerHaptic } from '../../lib/api';
|
import { api, triggerHaptic } from '../../lib/api';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { TaskDetailModal } from './TaskDetailModal';
|
import { TaskDetailModal } from './TaskDetailModal';
|
||||||
@@ -37,6 +41,21 @@ import { CountBadge } from '../../components/CountBadge';
|
|||||||
import { FormSheet } from '../../components/FormSheet';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
|
|
||||||
|
// Drag & drop needs a mouse or pen; on touch-only devices a draggable row can swallow a slightly
|
||||||
|
// longer tap, so the row would not open
|
||||||
|
const canDragTasks =
|
||||||
|
typeof window !== 'undefined' && !!window.matchMedia?.('(any-pointer: fine)').matches;
|
||||||
|
|
||||||
|
// Rows inside a task card keep taps on their own controls; taps on empty space or plain labels
|
||||||
|
// (due date, webhook) reach the card and open the task
|
||||||
|
const stopOnControl = (e: React.MouseEvent) => {
|
||||||
|
if ((e.target as HTMLElement).closest('button, select, a, input')) e.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Same rule as the server's task query: a fresh Quick Dump note (status inbox) is no task yet
|
||||||
|
const isTaskThought = (t: Thought) =>
|
||||||
|
t.status === 'kept' || (t.status === 'converted' && t.convertedType === 'task');
|
||||||
|
|
||||||
interface TasksViewProps {
|
interface TasksViewProps {
|
||||||
currentUser?: User | null;
|
currentUser?: User | null;
|
||||||
onRefreshStats: () => void;
|
onRefreshStats: () => void;
|
||||||
@@ -46,6 +65,26 @@ interface TasksViewProps {
|
|||||||
openTasksCount?: number; // all open tasks incl. shared lists (same as the nav bar counter)
|
openTasksCount?: number; // all open tasks incl. shared lists (same as the nav bar counter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const KANBAN_WIDE_KEY = 'mindsift:kanban_wide';
|
||||||
|
// Wide Kanban: a column grows to at most this width, the board stays centred
|
||||||
|
const KANBAN_COL_MAX = 380;
|
||||||
|
const KANBAN_GAP = 12; // gap-3
|
||||||
|
|
||||||
|
// Only the board leaves the page column (header and "new task" keep their width): its width follows
|
||||||
|
// the columns (at most KANBAN_COL_MAX each, at most the window) and a margin keeps it centred – a
|
||||||
|
// negative one when it is wider than the page column. Width and margin are animated.
|
||||||
|
const kanbanBoardStyle = (wide: boolean, columns: number): React.CSSProperties => {
|
||||||
|
if (!wide) return { '--kanban-w': '100%', '--kanban-ml': '0px' } as React.CSSProperties;
|
||||||
|
const w = `min(100vw - 4rem, ${columns * KANBAN_COL_MAX + (columns - 1) * KANBAN_GAP}px)`;
|
||||||
|
return {
|
||||||
|
'--kanban-cols': columns,
|
||||||
|
'--kanban-w': w,
|
||||||
|
'--kanban-ml': `calc((100% - ${w}) / 2)`,
|
||||||
|
} as React.CSSProperties;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TasksSession = { listId: string; viewMode: 'list' | 'kanban' };
|
||||||
|
|
||||||
export const TasksView: React.FC<TasksViewProps> = ({
|
export const TasksView: React.FC<TasksViewProps> = ({
|
||||||
currentUser,
|
currentUser,
|
||||||
onRefreshStats,
|
onRefreshStats,
|
||||||
@@ -54,11 +93,37 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
initialSelectedTaskId,
|
initialSelectedTaskId,
|
||||||
openTasksCount,
|
openTasksCount,
|
||||||
}) => {
|
}) => {
|
||||||
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
// Open list and view survive switching to other tabs (like the workspace's open note)
|
||||||
|
const [viewMode, setViewMode] = useState<'list' | 'kanban'>(() =>
|
||||||
|
readViewSession<TasksSession>('tasks').viewMode === 'kanban' ? 'kanban' : 'list'
|
||||||
|
);
|
||||||
|
// Wide Kanban: the board takes the whole window and shows every column in one row
|
||||||
|
const [kanbanWide, setKanbanWide] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(KANBAN_WIDE_KEY) === '1';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const toggleKanbanWide = () => {
|
||||||
|
const next = !kanbanWide;
|
||||||
|
setKanbanWide(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KANBAN_WIDE_KEY, next ? '1' : '0');
|
||||||
|
} catch {
|
||||||
|
// only a convenience
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const isKanbanWide = viewMode === 'kanban' && kanbanWide;
|
||||||
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open');
|
||||||
const [tasks, setTasks] = useState<Thought[]>([]);
|
const [tasks, setTasks] = useState<Thought[]>([]);
|
||||||
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
||||||
const [selectedListId, setSelectedListId] = useState<string>('all');
|
const [selectedListId, setSelectedListId] = useState<string>(
|
||||||
|
() => readViewSession<TasksSession>('tasks').listId || 'all'
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
writeViewSession<TasksSession>('tasks', { listId: selectedListId, viewMode });
|
||||||
|
}, [selectedListId, viewMode]);
|
||||||
const [teams, setTeams] = useState<Team[]>([]);
|
const [teams, setTeams] = useState<Team[]>([]);
|
||||||
const [collaborators, setCollaborators] = useState<User[]>([]);
|
const [collaborators, setCollaborators] = useState<User[]>([]);
|
||||||
const [discarded, setDiscarded] = useState<Thought[]>([]);
|
const [discarded, setDiscarded] = useState<Thought[]>([]);
|
||||||
@@ -68,6 +133,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
||||||
const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null);
|
const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null);
|
||||||
const [dragOverKanbanCol, setDragOverKanbanCol] = useState<string | null>(null);
|
const [dragOverKanbanCol, setDragOverKanbanCol] = useState<string | null>(null);
|
||||||
|
// List tab a dragged task hovers over (dropping moves it into that list)
|
||||||
|
const [dragOverListTab, setDragOverListTab] = useState<string | null>(null);
|
||||||
|
|
||||||
// Quick Task Add state
|
// Quick Task Add state
|
||||||
const [quickTitle, setQuickTitle] = useState('');
|
const [quickTitle, setQuickTitle] = useState('');
|
||||||
@@ -147,6 +214,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
loadedListRef.current = listId;
|
loadedListRef.current = listId;
|
||||||
setTasks(Array.isArray(allTasks) ? allTasks : []);
|
setTasks(Array.isArray(allTasks) ? allTasks : []);
|
||||||
setTaskLists(Array.isArray(allLists) ? allLists : []);
|
setTaskLists(Array.isArray(allLists) ? allLists : []);
|
||||||
|
// The remembered list was deleted or unshared meanwhile
|
||||||
|
if (listId !== 'all' && Array.isArray(allLists) && !allLists.some((l) => l.id === listId)) {
|
||||||
|
setSelectedListId('all');
|
||||||
|
}
|
||||||
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
setTeams(Array.isArray(allTeams) ? allTeams : []);
|
||||||
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
|
setCollaborators(Array.isArray(allCollabs) ? allCollabs : []);
|
||||||
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
|
setDiscarded(Array.isArray(allDiscarded) ? allDiscarded : []);
|
||||||
@@ -175,13 +246,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
|
|
||||||
if (msg.event === 'TASK_CREATED') {
|
if (msg.event === 'TASK_CREATED') {
|
||||||
const created = msg.payload as Thought;
|
const created = msg.payload as Thought;
|
||||||
if (
|
if (created && isTaskThought(created)) {
|
||||||
created &&
|
|
||||||
(created.status === 'converted' ||
|
|
||||||
created.kanbanStatus ||
|
|
||||||
created.taskListId ||
|
|
||||||
created.isCompleted !== undefined)
|
|
||||||
) {
|
|
||||||
setTasks((prev) => {
|
setTasks((prev) => {
|
||||||
if (prev.some((t) => t.id === created.id)) return prev;
|
if (prev.some((t) => t.id === created.id)) return prev;
|
||||||
if (selectedListIdRef.current !== 'all' && created.taskListId !== selectedListIdRef.current) return prev;
|
if (selectedListIdRef.current !== 'all' && created.taskListId !== selectedListIdRef.current) return prev;
|
||||||
@@ -196,17 +261,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const exists = prev.some((t) => t.id === updated.id);
|
const exists = prev.some((t) => t.id === updated.id);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
if (
|
if (
|
||||||
(updated.status === 'converted' ||
|
isTaskThought(updated) &&
|
||||||
updated.kanbanStatus ||
|
|
||||||
updated.taskListId ||
|
|
||||||
updated.isCompleted !== undefined) &&
|
|
||||||
(selectedListIdRef.current === 'all' || updated.taskListId === selectedListIdRef.current)
|
(selectedListIdRef.current === 'all' || updated.taskListId === selectedListIdRef.current)
|
||||||
) {
|
) {
|
||||||
return [updated, ...prev];
|
return [updated, ...prev];
|
||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
if (selectedListIdRef.current !== 'all' && updated.taskListId !== selectedListIdRef.current) {
|
// Undone back into the Quick Dump, discarded, or moved to another list
|
||||||
|
if (
|
||||||
|
!isTaskThought(updated) ||
|
||||||
|
(selectedListIdRef.current !== 'all' && updated.taskListId !== selectedListIdRef.current)
|
||||||
|
) {
|
||||||
return prev.filter((t) => t.id !== updated.id);
|
return prev.filter((t) => t.id !== updated.id);
|
||||||
}
|
}
|
||||||
return prev.map((t) => (t.id === updated.id ? updated : t));
|
return prev.map((t) => (t.id === updated.id ? updated : t));
|
||||||
@@ -497,6 +563,35 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
onRefreshStats();
|
onRefreshStats();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A task dragged onto a list tab lands in that list like a new task: it keeps its Kanban status
|
||||||
|
// if the list has that column, otherwise it starts in the list's first column
|
||||||
|
const handleDropOnListTab = async (e: React.DragEvent, list: TaskList) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const sourceId = e.dataTransfer.getData('application/mindsift-task-id') || draggingTaskId;
|
||||||
|
setDraggingTaskId(null);
|
||||||
|
setDragOverTaskTarget(null);
|
||||||
|
setDragOverKanbanCol(null);
|
||||||
|
setDragOverListTab(null);
|
||||||
|
const task = tasks.find((t) => t.id === sourceId);
|
||||||
|
if (!task || task.taskListId === list.id || list.permission === 'viewer') return;
|
||||||
|
triggerHaptic(15);
|
||||||
|
const columns = Array.isArray(list.columns) && list.columns.length > 0 ? list.columns : globalColumns;
|
||||||
|
const keepStatus = !task.kanbanStatus || columns.some((c) => c.id === task.kanbanStatus);
|
||||||
|
const kanbanStatus = keepStatus ? task.kanbanStatus : (columns.find((c) => !c.isDone) || columns[0])?.id;
|
||||||
|
setTasks((prev) =>
|
||||||
|
selectedListId !== 'all'
|
||||||
|
? prev.filter((t) => t.id !== task.id)
|
||||||
|
: prev.map((t) => (t.id === task.id ? { ...t, taskListId: list.id, kanbanStatus } : t))
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await api.updateThought(task.id, { taskListId: list.id, ...(keepStatus ? {} : { kanbanStatus }) });
|
||||||
|
} catch {
|
||||||
|
loadData();
|
||||||
|
}
|
||||||
|
api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {});
|
||||||
|
onRefreshStats();
|
||||||
|
};
|
||||||
|
|
||||||
const handleUpdateTaskNotes = async (task: Thought, newNotes: string) => {
|
const handleUpdateTaskNotes = async (task: Thought, newNotes: string) => {
|
||||||
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, notes: newNotes } : t)));
|
setTasks((prev) => prev.map((t) => (t.id === task.id ? { ...t, notes: newNotes } : t)));
|
||||||
await api.updateThought(task.id, { notes: newNotes });
|
await api.updateThought(task.id, { notes: newNotes });
|
||||||
@@ -713,7 +808,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
setDraggingTaskId(null);
|
setDraggingTaskId(null);
|
||||||
setDragOverTaskTarget(null);
|
setDragOverTaskTarget(null);
|
||||||
setDragOverKanbanCol(null);
|
setDragOverKanbanCol(null);
|
||||||
|
await moveTaskNextTo(sourceId, targetTask, position, newKanbanCol);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shared by mouse drag & drop and the touch drag on the Kanban board
|
||||||
|
const moveTaskNextTo = async (sourceId: string | null, targetTask: Thought, position: 'before' | 'after', newKanbanCol?: string) => {
|
||||||
if (!sourceId || sourceId === targetTask.id) return;
|
if (!sourceId || sourceId === targetTask.id) return;
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
|
|
||||||
@@ -770,7 +869,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
setDraggingTaskId(null);
|
setDraggingTaskId(null);
|
||||||
setDragOverTaskTarget(null);
|
setDragOverTaskTarget(null);
|
||||||
setDragOverKanbanCol(null);
|
setDragOverKanbanCol(null);
|
||||||
|
await moveTaskToColumn(sourceId, colId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveTaskToColumn = async (sourceId: string | null, colId: string) => {
|
||||||
if (!sourceId) return;
|
if (!sourceId) return;
|
||||||
triggerHaptic(15);
|
triggerHaptic(15);
|
||||||
|
|
||||||
@@ -811,6 +913,28 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Kanban with a finger: hold a card, drag it, let go (mouse and pen use HTML5 drag & drop)
|
||||||
|
const clearDragState = () => {
|
||||||
|
setDraggingTaskId(null);
|
||||||
|
setDragOverTaskTarget(null);
|
||||||
|
setDragOverKanbanCol(null);
|
||||||
|
};
|
||||||
|
const kanbanTouch = useKanbanTouchDrag({
|
||||||
|
enabled: !canDragTasks && viewMode === 'kanban',
|
||||||
|
onStart: (taskId) => setDraggingTaskId(taskId),
|
||||||
|
onHover: (colId, target) => {
|
||||||
|
setDragOverKanbanCol(colId);
|
||||||
|
setDragOverTaskTarget(target);
|
||||||
|
},
|
||||||
|
onDrop: (taskId, colId, target) => {
|
||||||
|
clearDragState();
|
||||||
|
const targetTask = target ? tasks.find((t) => t.id === target.taskId) : undefined;
|
||||||
|
if (targetTask && target) void moveTaskNextTo(taskId, targetTask, target.position, colId ?? undefined);
|
||||||
|
else if (colId) void moveTaskToColumn(taskId, colId);
|
||||||
|
},
|
||||||
|
onCancel: clearDragState,
|
||||||
|
});
|
||||||
|
|
||||||
const handleCreateTaskList = async (e: React.FormEvent) => {
|
const handleCreateTaskList = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const title = newListTitle.trim();
|
const title = newListTitle.trim();
|
||||||
@@ -1093,12 +1217,13 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
key={task.id}
|
||||||
draggable={!readOnly}
|
draggable={canDragTasks && !readOnly}
|
||||||
onDragStart={(e) => handleTaskDragStart(e, task)}
|
onDragStart={(e) => handleTaskDragStart(e, task)}
|
||||||
onDragEnd={() => {
|
onDragEnd={() => {
|
||||||
setDraggingTaskId(null);
|
setDraggingTaskId(null);
|
||||||
setDragOverTaskTarget(null);
|
setDragOverTaskTarget(null);
|
||||||
setDragOverKanbanCol(null);
|
setDragOverKanbanCol(null);
|
||||||
|
setDragOverListTab(null);
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
||||||
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
||||||
@@ -1143,13 +1268,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Clickable Title & Metadata */}
|
{/* Clickable Title & Metadata */}
|
||||||
<div
|
<div className="min-w-0 flex-1 cursor-pointer space-y-1">
|
||||||
onClick={() => {
|
|
||||||
setDetailTaskId(task.id);
|
|
||||||
setShowDetailModal(true);
|
|
||||||
}}
|
|
||||||
className="min-w-0 flex-1 cursor-pointer space-y-1"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<p
|
<p
|
||||||
className={`text-sm break-words leading-tight ${
|
className={`text-sm break-words leading-tight ${
|
||||||
@@ -1166,7 +1285,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
{/* Interactive Badges Bar */}
|
{/* Interactive Badges Bar */}
|
||||||
<div
|
<div
|
||||||
className="flex flex-wrap items-center gap-1.5 pt-0.5 empty:hidden"
|
className="flex flex-wrap items-center gap-1.5 pt-0.5 empty:hidden"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={stopOnControl}
|
||||||
>
|
>
|
||||||
{(task.convertedType === 'document_block' || task.docId) && (
|
{(task.convertedType === 'document_block' || task.docId) && (
|
||||||
<button
|
<button
|
||||||
@@ -1353,9 +1472,20 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
{safeTaskLists.map((tl) => (
|
{safeTaskLists.map((tl) => (
|
||||||
<div
|
<div
|
||||||
key={tl.id}
|
key={tl.id}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
const task = draggingTaskId ? tasks.find((t) => t.id === draggingTaskId) : null;
|
||||||
|
if (!task || task.taskListId === tl.id || tl.permission === 'viewer') return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
if (dragOverListTab !== tl.id) setDragOverListTab(tl.id);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => {
|
||||||
|
if (dragOverListTab === tl.id) setDragOverListTab(null);
|
||||||
|
}}
|
||||||
|
onDrop={(e) => handleDropOnListTab(e, tl)}
|
||||||
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 ${
|
||||||
selectedListId === tl.id ? 'bg-panel border-accent text-main pr-1' : 'bg-canvas border-border text-muted hover:text-main'
|
selectedListId === tl.id ? 'bg-panel border-accent text-main pr-1' : 'bg-canvas border-border text-muted hover:text-main'
|
||||||
}`}
|
} ${dragOverListTab === tl.id ? 'ring-2 ring-accent/60 bg-accent/10 text-main' : ''}`}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedListId(tl.id)}
|
onClick={() => setSelectedListId(tl.id)}
|
||||||
@@ -1489,7 +1619,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Compact Context Bar (replaces cluttered button rows; options live in MindSift Logo Menu) */}
|
{/* Mobile Compact Context Bar (replaces cluttered button rows; options 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">
|
||||||
{currentSelectedList ? `${currentSelectedList.icon} ${currentSelectedList.title}` : '📋 Alle Aufgaben'}
|
{currentSelectedList ? `${currentSelectedList.icon} ${currentSelectedList.title}` : '📋 Alle Aufgaben'}
|
||||||
@@ -1646,16 +1777,31 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
{/* Group By & Sort By Dropdowns & Manage Columns button */}
|
{/* Group By & Sort By Dropdowns & Manage Columns button */}
|
||||||
<div className="flex items-center gap-2 flex-shrink-0 ml-auto">
|
<div className="flex items-center gap-2 flex-shrink-0 ml-auto">
|
||||||
{viewMode === 'kanban' ? (
|
{viewMode === 'kanban' ? (
|
||||||
!isSelectedListReadOnly && (
|
<>
|
||||||
<button
|
{!isSelectedListReadOnly && (
|
||||||
onClick={() => setShowManageColumnsModal(true)}
|
<button
|
||||||
className="btn-action h-7 px-2.5 text-xs flex items-center gap-1.5"
|
onClick={() => setShowManageColumnsModal(true)}
|
||||||
title="Kanban-Spalten hinzufügen, umbenennen oder neu anordnen"
|
className="btn-action h-7 px-2.5 text-xs flex items-center gap-1.5"
|
||||||
>
|
title="Kanban-Spalten hinzufügen, umbenennen oder neu anordnen"
|
||||||
<Columns className="w-3.5 h-3.5 text-accent" />
|
>
|
||||||
<span>Spalten anpassen</span>
|
<Columns className="w-3.5 h-3.5 text-accent" />
|
||||||
</button>
|
<span>Spalten anpassen</span>
|
||||||
)
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleKanbanWide}
|
||||||
|
aria-pressed={kanbanWide}
|
||||||
|
className="btn-action h-7 w-7 p-0 hidden lg:inline-flex items-center justify-center"
|
||||||
|
title={kanbanWide ? 'Board wieder normal breit' : 'Board auf volle Breite strecken'}
|
||||||
|
>
|
||||||
|
{kanbanWide ? (
|
||||||
|
<Minimize2 className="w-3.5 h-3.5 text-accent" />
|
||||||
|
) : (
|
||||||
|
<Maximize2 className="w-3.5 h-3.5 text-accent" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Group By */}
|
{/* Group By */}
|
||||||
@@ -2108,7 +2254,13 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
{viewMode === 'kanban' ? (
|
{viewMode === 'kanban' ? (
|
||||||
/* DYNAMIC KANBAN BOARD */
|
/* DYNAMIC KANBAN BOARD */
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 pt-1">
|
{kanbanTouch.ghost}
|
||||||
|
<div
|
||||||
|
className={`grid grid-cols-1 md:grid-cols-2 gap-3 pt-1 lg:w-[var(--kanban-w)] lg:ml-[var(--kanban-ml)] transition-[width,margin] duration-300 ease-out ${
|
||||||
|
isKanbanWide ? 'lg:grid-cols-[repeat(var(--kanban-cols),minmax(0,1fr))]' : 'lg:grid-cols-4'
|
||||||
|
}`}
|
||||||
|
style={kanbanBoardStyle(isKanbanWide, activeColumns.length)}
|
||||||
|
>
|
||||||
{activeColumns.map((col, colIdx) => {
|
{activeColumns.map((col, colIdx) => {
|
||||||
const colTasks = kanbanTasks.filter((t) => {
|
const colTasks = kanbanTasks.filter((t) => {
|
||||||
if (col.isDone) {
|
if (col.isDone) {
|
||||||
@@ -2130,7 +2282,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
if (dragOverKanbanCol === col.id) setDragOverKanbanCol(null);
|
if (dragOverKanbanCol === col.id) setDragOverKanbanCol(null);
|
||||||
}}
|
}}
|
||||||
onDrop={(e) => handleDropOnKanbanCol(e, col.id)}
|
onDrop={(e) => handleDropOnKanbanCol(e, col.id)}
|
||||||
className={`bg-canvas border border-border flex flex-col min-h-[420px] transition-all ${
|
data-kanban-col={col.id}
|
||||||
|
className={`bg-canvas border border-border flex flex-col min-h-[420px] overflow-hidden transition-all ${
|
||||||
dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''
|
dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -2163,21 +2316,25 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={task.id}
|
key={task.id}
|
||||||
draggable={!readOnly}
|
draggable={canDragTasks && !readOnly}
|
||||||
onDragStart={(e) => handleTaskDragStart(e, task)}
|
onDragStart={(e) => handleTaskDragStart(e, task)}
|
||||||
onDragEnd={() => {
|
onDragEnd={() => {
|
||||||
setDraggingTaskId(null);
|
setDraggingTaskId(null);
|
||||||
setDragOverTaskTarget(null);
|
setDragOverTaskTarget(null);
|
||||||
setDragOverKanbanCol(null);
|
setDragOverKanbanCol(null);
|
||||||
|
setDragOverListTab(null);
|
||||||
}}
|
}}
|
||||||
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
||||||
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
||||||
onDrop={(e) => handleDropOnTask(e, task, col.id)}
|
onDrop={(e) => handleDropOnTask(e, task, col.id)}
|
||||||
|
data-kanban-task={task.id}
|
||||||
|
{...kanbanTouch.cardProps(task.id, task.content, !readOnly)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
if (kanbanTouch.consumeClick()) return;
|
||||||
setDetailTaskId(task.id);
|
setDetailTaskId(task.id);
|
||||||
setShowDetailModal(true);
|
setShowDetailModal(true);
|
||||||
}}
|
}}
|
||||||
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative ${
|
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative select-none ${
|
||||||
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
|
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
|
||||||
} ${
|
} ${
|
||||||
isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
|
isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
|
||||||
@@ -2230,7 +2387,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Badges: In Notiz, Priority, Status, Tags */}
|
{/* Badges: In Notiz, Priority, Status, Tags */}
|
||||||
<div className="flex flex-wrap items-center gap-1 pt-0.5" onClick={(e) => e.stopPropagation()}>
|
<div className="flex flex-wrap items-center gap-1 pt-0.5" onClick={stopOnControl}>
|
||||||
{(task.convertedType === 'document_block' || task.docId) && (
|
{(task.convertedType === 'document_block' || task.docId) && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2315,7 +2472,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between pt-1 border-t border-border text-[10px]"
|
className="flex items-center justify-between pt-1 border-t border-border text-[10px]"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={stopOnControl}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import {
|
|||||||
Edit3,
|
Edit3,
|
||||||
Layers,
|
Layers,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
ChevronDown,
|
||||||
} 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';
|
||||||
@@ -227,6 +230,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);
|
||||||
|
|
||||||
@@ -725,7 +731,99 @@ 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;
|
||||||
|
}
|
||||||
|
setShowDateModal(false);
|
||||||
|
setShowListSelector((prev) => !prev);
|
||||||
|
}}
|
||||||
|
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' : showListSelector ? 'border-accent bg-accent-dim' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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={() => {
|
||||||
|
setShowListSelector(false);
|
||||||
|
setShowDateModal((prev) => !prev);
|
||||||
|
}}
|
||||||
|
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 ${showDateModal ? 'border-accent bg-accent-dim' : ''}`}
|
||||||
|
>
|
||||||
|
<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 +866,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 +875,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 +914,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -160,4 +160,26 @@ 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()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
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,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>
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -347,6 +347,7 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
|||||||
const copyTimerRef = useRef<number | null>(null);
|
const copyTimerRef = useRef<number | null>(null);
|
||||||
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const preRef = useRef<HTMLPreElement | null>(null);
|
const preRef = useRef<HTMLPreElement | null>(null);
|
||||||
|
const lineNumbersRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -506,119 +507,139 @@ export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
if (preRef.current) {
|
if (preRef.current) {
|
||||||
preRef.current.scrollTop = e.currentTarget.scrollTop;
|
preRef.current.scrollTop = target.scrollTop;
|
||||||
preRef.current.scrollLeft = e.currentTarget.scrollLeft;
|
preRef.current.scrollLeft = target.scrollLeft;
|
||||||
|
}
|
||||||
|
if (lineNumbersRef.current) {
|
||||||
|
lineNumbersRef.current.scrollTop = target.scrollTop;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLineNumbersScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
const target = e.currentTarget;
|
||||||
|
if (localTextareaRef.current) {
|
||||||
|
localTextareaRef.current.scrollTop = target.scrollTop;
|
||||||
|
}
|
||||||
|
if (preRef.current) {
|
||||||
|
preRef.current.scrollTop = target.scrollTop;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLineNumbersWheel = (e: React.WheelEvent) => {
|
||||||
|
if (localTextareaRef.current) {
|
||||||
|
localTextareaRef.current.scrollTop += e.deltaY;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const lineCount = useMemo(() => {
|
||||||
|
return currentContent.split('\n').length;
|
||||||
|
}, [currentContent]);
|
||||||
|
|
||||||
const rawLang = block.lang || '';
|
const rawLang = block.lang || '';
|
||||||
const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : '';
|
const selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : '';
|
||||||
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 overflow-x-auto max-w-full">
|
<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>
|
||||||
|
|
||||||
{/* Live Highlighted Overlay + Textarea */}
|
{/* Copy: round icon button at the bottom right, shown on hover (always on touch screens) */}
|
||||||
<div className="relative w-full overflow-x-auto">
|
<button
|
||||||
<pre
|
type="button"
|
||||||
ref={preRef}
|
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 */}
|
||||||
|
<div className="flex relative w-full overflow-hidden">
|
||||||
|
{/* Line Numbers Column */}
|
||||||
|
<div
|
||||||
|
ref={lineNumbersRef}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className="absolute inset-0 m-0 p-2 font-mono text-xs text-main leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] pointer-events-none select-none overflow-hidden"
|
onScroll={handleLineNumbersScroll}
|
||||||
|
onWheel={handleLineNumbersWheel}
|
||||||
|
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 shrink-0 overflow-hidden"
|
||||||
>
|
>
|
||||||
<code
|
{Array.from({ length: lineCount }, (_, i) => (
|
||||||
className={prismLang ? `language-${prismLang}` : 'language-none'}
|
<div key={i + 1} data-line-number={i + 1}>
|
||||||
dangerouslySetInnerHTML={{
|
{i + 1}
|
||||||
__html: highlightedHtml + (currentContent.endsWith('\n') ? '\n' : ''),
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Code Content (Overlay + Textarea) */}
|
||||||
|
<div className="relative flex-1 min-w-0 overflow-hidden">
|
||||||
|
<pre
|
||||||
|
ref={preRef}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-0 m-0 p-2 font-mono text-xs text-main leading-relaxed whitespace-pre pointer-events-none select-none overflow-hidden"
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
className={prismLang ? `language-${prismLang}` : 'language-none'}
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: highlightedHtml + (currentContent.endsWith('\n') ? '\n' : ''),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</pre>
|
||||||
|
<textarea
|
||||||
|
ref={(el) => {
|
||||||
|
localTextareaRef.current = el;
|
||||||
|
if (textareaRef) textareaRef.current = el;
|
||||||
|
registerTextareaRef?.(block.id, el);
|
||||||
}}
|
}}
|
||||||
|
rows={1}
|
||||||
|
value={currentContent}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
spellCheck={false}
|
||||||
|
autoCapitalize="off"
|
||||||
|
autoComplete="off"
|
||||||
|
autoCorrect="off"
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onChange={onTextareaChange}
|
||||||
|
onKeyDown={onTextareaKeyDown}
|
||||||
|
onScroll={handleScroll}
|
||||||
|
style={{ caretColor: 'var(--text-main)' }}
|
||||||
|
className="relative block w-full m-0 p-2 font-mono text-xs leading-relaxed whitespace-pre bg-transparent text-transparent caret-main selection:bg-accent/25 selection:text-transparent border-0 focus:outline-none focus:ring-0 resize-none overflow-x-auto"
|
||||||
/>
|
/>
|
||||||
</pre>
|
</div>
|
||||||
<textarea
|
|
||||||
ref={(el) => {
|
|
||||||
localTextareaRef.current = el;
|
|
||||||
if (textareaRef) textareaRef.current = el;
|
|
||||||
registerTextareaRef?.(block.id, el);
|
|
||||||
}}
|
|
||||||
rows={1}
|
|
||||||
value={currentContent}
|
|
||||||
readOnly={!canEdit}
|
|
||||||
spellCheck={false}
|
|
||||||
autoCapitalize="off"
|
|
||||||
autoComplete="off"
|
|
||||||
autoCorrect="off"
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onChange={onTextareaChange}
|
|
||||||
onKeyDown={onTextareaKeyDown}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
style={{ caretColor: 'var(--text-main)' }}
|
|
||||||
className="relative block w-full m-0 p-2 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent text-transparent caret-main selection:bg-accent/25 selection:text-transparent border-0 focus:outline-none focus:ring-0 resize-none overflow-hidden"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,7 @@ import {
|
|||||||
Undo2,
|
Undo2,
|
||||||
Redo2,
|
Redo2,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
|
CalendarDays,
|
||||||
} 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 +32,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 +55,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 +66,25 @@ 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 */
|
||||||
|
variant?: 'page' | 'journal';
|
||||||
|
/** 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';
|
||||||
@@ -211,7 +235,14 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onRequestMerge,
|
onRequestMerge,
|
||||||
onDocCreated,
|
onDocCreated,
|
||||||
onDocRemoved,
|
onDocRemoved,
|
||||||
|
variant = 'page',
|
||||||
|
actionsTarget,
|
||||||
|
focusMode = false,
|
||||||
|
onToggleFocusMode,
|
||||||
}) => {
|
}) => {
|
||||||
|
const inJournal = variant === 'journal';
|
||||||
|
const large = useSyncExternalStore(subscribeLarge, isLargeScreen, () => false);
|
||||||
|
const desktopBar = !inJournal && 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 +261,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 +273,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 (inJournal || !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, inJournal]);
|
||||||
|
|
||||||
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 = () => {
|
||||||
@@ -454,6 +504,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 +705,16 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
[docId, canEdit, applyLocalBlocks]
|
[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;
|
||||||
@@ -723,6 +811,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 +852,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={inJournal ? '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) */}
|
||||||
|
{!inJournal && !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 +1070,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 +1121,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 +1153,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] ${
|
||||||
|
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 +1198,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 +1239,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}
|
||||||
@@ -1114,9 +1252,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onInsertBlocks={handleInsertBlocks}
|
onInsertBlocks={handleInsertBlocks}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
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}
|
||||||
@@ -1137,11 +1277,18 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
<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 */}
|
||||||
|
{inJournal ? (
|
||||||
|
<CalendarDays className="w-6 h-6 sm:w-7 sm:h-7 text-muted" strokeWidth={1.75} />
|
||||||
|
) : (
|
||||||
|
doc?.icon || '📝'
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
{showEmojiPicker && canEdit && (
|
{showEmojiPicker && canEdit && (
|
||||||
<EmojiPickerModal
|
<EmojiPickerModal
|
||||||
@@ -1162,7 +1309,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 +1330,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}
|
||||||
@@ -1202,9 +1350,11 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onInsertBlocks={handleInsertBlocks}
|
onInsertBlocks={handleInsertBlocks}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
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,7 +1364,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onRedo={handleRedo}
|
onRedo={handleRedo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{onOpenDocBlock && !inTrash && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
|
{onOpenDocBlock && !inTrash && !inJournal && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,12 @@ 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';
|
||||||
|
|
||||||
export interface LinkModalProps {
|
export interface LinkModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -100,6 +103,37 @@ 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]);
|
||||||
|
|
||||||
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 +278,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>
|
||||||
@@ -289,13 +374,27 @@ 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>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
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 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 } = 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
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 } 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)
|
||||||
@@ -34,6 +43,7 @@ export interface OutlinerTreeProps {
|
|||||||
|
|
||||||
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 +55,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,
|
||||||
@@ -235,13 +247,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 +304,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 +617,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);
|
||||||
@@ -774,6 +670,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 +686,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,6 +696,7 @@ 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}
|
||||||
@@ -810,7 +709,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<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 +745,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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
CalendarDays,
|
||||||
|
Minimize2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Document,
|
Document,
|
||||||
@@ -36,6 +38,7 @@ import {
|
|||||||
TrashedDocument,
|
TrashedDocument,
|
||||||
UnifiedShare,
|
UnifiedShare,
|
||||||
User,
|
User,
|
||||||
|
JournalDay,
|
||||||
} from '../../lib/types';
|
} from '../../lib/types';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
@@ -49,6 +52,9 @@ import { useMobileActions } from '../../context/MobileActionContext';
|
|||||||
import { DocumentEditor, DocSaveStatus, DocHeaderStatus } from './DocumentEditor';
|
import { DocumentEditor, DocSaveStatus, DocHeaderStatus } from './DocumentEditor';
|
||||||
import { PresenceAvatars } from './PresenceAvatars';
|
import { PresenceAvatars } from './PresenceAvatars';
|
||||||
import { TrashView } from './TrashView';
|
import { TrashView } from './TrashView';
|
||||||
|
import { JournalView } from './JournalView';
|
||||||
|
import { JournalCalendar } from './JournalCalendar';
|
||||||
|
import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession } from '../../lib/journal';
|
||||||
import { MergeDialog } from './MergeDialog';
|
import { MergeDialog } from './MergeDialog';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
import { extractHashtags } from '../../lib/hashtags';
|
import { extractHashtags } from '../../lib/hashtags';
|
||||||
@@ -63,7 +69,7 @@ interface WorkspaceViewProps {
|
|||||||
onOpenSearch?: () => void;
|
onOpenSearch?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash';
|
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash' | 'journal';
|
||||||
|
|
||||||
interface FolderTreeNode {
|
interface FolderTreeNode {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -135,7 +141,7 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
|||||||
}}
|
}}
|
||||||
onDrop={(e) => handleDropOnFolder(node.fullPath, e)}
|
onDrop={(e) => handleDropOnFolder(node.fullPath, e)}
|
||||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||||
className={`group flex items-center justify-between pr-1.5 h-7 rounded-btn text-xs cursor-pointer border-l-2 transition-colors ${
|
className={`group flex items-center justify-between pr-1.5 h-11 sm:h-7 rounded-btn text-sm sm:text-xs cursor-pointer border-l-2 transition-colors ${
|
||||||
isDragOver
|
isDragOver
|
||||||
? 'bg-accent/20 border-accent text-main font-semibold'
|
? 'bg-accent/20 border-accent text-main font-semibold'
|
||||||
: 'border-transparent hover:bg-panel-elevated text-muted'
|
: 'border-transparent hover:bg-panel-elevated text-muted'
|
||||||
@@ -256,11 +262,11 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
|||||||
{...docDropProps(d.id)}
|
{...docDropProps(d.id)}
|
||||||
onClick={() => onSelectDoc(d.id)}
|
onClick={() => onSelectDoc(d.id)}
|
||||||
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
|
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
|
||||||
className={`w-full flex items-center gap-2 pr-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
|
className={`w-full flex items-center gap-2 pr-2 py-1.5 min-h-11 sm:min-h-0 rounded-btn text-left text-sm sm:text-xs cursor-pointer border transition-colors ${
|
||||||
mergeHoverId === d.id
|
mergeHoverId === d.id
|
||||||
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
|
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
|
||||||
: selectedDocId === d.id && subView === 'editor'
|
: selectedDocId === d.id && subView === 'editor'
|
||||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
? 'bg-panel-elevated text-main font-semibold border-transparent'
|
||||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -314,10 +320,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
return list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
return list.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||||
}, [docs, serverTagsSummary]);
|
}, [docs, serverTagsSummary]);
|
||||||
|
|
||||||
const [selectedDocId, setSelectedDocId] = useState<string | null>(initialDocId || null);
|
const [selectedDocId, setSelectedDocId] = useState<string | null>(
|
||||||
|
initialDocId || readWorkspaceSession().docId || null
|
||||||
|
);
|
||||||
const [targetBlockId, setTargetBlockId] = useState<string | null>(initialBlockId || null);
|
const [targetBlockId, setTargetBlockId] = useState<string | null>(initialBlockId || null);
|
||||||
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
|
const [newlyCreatedDocId, setNewlyCreatedDocId] = useState<string | null>(null);
|
||||||
const [subView, setSubView] = useState<WorkspaceSubView>('editor');
|
// Journal open and the last regular note survive switching to other tabs (the view remounts)
|
||||||
|
const [subView, setSubView] = useState<WorkspaceSubView>(() =>
|
||||||
|
!initialDocId && isJournalEnabled() && readWorkspaceSession().journal ? 'journal' : 'editor'
|
||||||
|
);
|
||||||
const [trash, setTrash] = useState<TrashedDocument[]>([]);
|
const [trash, setTrash] = useState<TrashedDocument[]>([]);
|
||||||
// Bumped after a restore so the open editor reloads the note without the trash banner
|
// Bumped after a restore so the open editor reloads the note without the trash banner
|
||||||
const [editorNonce, setEditorNonce] = useState(0);
|
const [editorNonce, setEditorNonce] = useState(0);
|
||||||
@@ -327,6 +338,33 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reloadTrash();
|
reloadTrash();
|
||||||
}, [reloadTrash]);
|
}, [reloadTrash]);
|
||||||
|
|
||||||
|
// Journal: one note per day, listed separately from the folder tree (can be switched off per device)
|
||||||
|
const [journalEnabled] = useState(isJournalEnabled);
|
||||||
|
const [journalDays, setJournalDays] = useState<JournalDay[]>([]);
|
||||||
|
const [journalExtra, setJournalExtra] = useState<string[]>([]);
|
||||||
|
const [journalActive, setJournalActive] = useState(todayISO);
|
||||||
|
const [journalJump, setJournalJump] = useState<{ date: string; nonce: number } | null>(null);
|
||||||
|
const reloadJournal = useCallback(() => {
|
||||||
|
if (!journalEnabled) return;
|
||||||
|
api.getJournal().then(setJournalDays).catch(() => {});
|
||||||
|
}, [journalEnabled]);
|
||||||
|
useEffect(() => {
|
||||||
|
reloadJournal();
|
||||||
|
}, [reloadJournal]);
|
||||||
|
// After typing, refresh which days have text (calendar dots) – at most every few seconds
|
||||||
|
const journalReloadTimer = useRef<number | null>(null);
|
||||||
|
const reloadJournalSoon = useCallback(() => {
|
||||||
|
if (journalReloadTimer.current !== null) return;
|
||||||
|
journalReloadTimer.current = window.setTimeout(() => {
|
||||||
|
journalReloadTimer.current = null;
|
||||||
|
reloadJournal();
|
||||||
|
}, 3000);
|
||||||
|
}, [reloadJournal]);
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (journalReloadTimer.current !== null) window.clearTimeout(journalReloadTimer.current);
|
||||||
|
}, []);
|
||||||
|
const journalDocIds = useMemo(() => new Set(journalDays.map((d) => d.docId)), [journalDays]);
|
||||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||||
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
|
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
|
||||||
const [tagNoteDraft, setTagNoteDraft] = useState<string>('');
|
const [tagNoteDraft, setTagNoteDraft] = useState<string>('');
|
||||||
@@ -366,7 +404,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
setShowNewFolderModal(true);
|
setShowNewFolderModal(true);
|
||||||
};
|
};
|
||||||
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(Boolean(initialDocId));
|
const [mobileShowDetail, setMobileShowDetail] = useState<boolean>(
|
||||||
|
Boolean(initialDocId) || subView === 'journal'
|
||||||
|
);
|
||||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState<boolean>(() => {
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState<boolean>(() => {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem('mindsift:workspace_sidebar_collapsed') === 'true';
|
return localStorage.getItem('mindsift:workspace_sidebar_collapsed') === 'true';
|
||||||
@@ -388,6 +428,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
|
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
|
||||||
const [docSaveError, setDocSaveError] = useState<string | null>(null);
|
const [docSaveError, setDocSaveError] = useState<string | null>(null);
|
||||||
const [docHeaderStatus, setDocHeaderStatus] = useState<DocHeaderStatus | null>(null);
|
const [docHeaderStatus, setDocHeaderStatus] = useState<DocHeaderStatus | null>(null);
|
||||||
|
const [docActionsTarget, setDocActionsTarget] = useState<HTMLDivElement | null>(null);
|
||||||
|
// Distraction-free mode: only the note, full window, reading width
|
||||||
|
const [focusMode, setFocusMode] = useState(false);
|
||||||
|
// Focus mode: the folder sidebar slides in from the left (button, title click or mouse at the edge)
|
||||||
|
const [zenSidebar, setZenSidebar] = useState<'closed' | 'click' | 'hover'>('closed');
|
||||||
|
const [zenActionsTarget, setZenActionsTarget] = useState<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
|
||||||
// Drag and Drop state
|
// Drag and Drop state
|
||||||
@@ -408,6 +454,17 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
|
const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
|
||||||
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
||||||
|
// A drag that ends without a drop (released elsewhere, Escape) fires only dragend: clear every drag state
|
||||||
|
useEffect(() => {
|
||||||
|
const clear = () => {
|
||||||
|
setDraggingDocId(null);
|
||||||
|
setDraggingFolderPath(null);
|
||||||
|
setDragOverFolder(null);
|
||||||
|
setMergeHoverId(null);
|
||||||
|
};
|
||||||
|
document.addEventListener('dragend', clear);
|
||||||
|
return () => document.removeEventListener('dragend', clear);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Rollup drilldown & View Mode
|
// Rollup drilldown & View Mode
|
||||||
const [todoFolderDrilldown, setTodoFolderDrilldown] = useState<string | null>(null);
|
const [todoFolderDrilldown, setTodoFolderDrilldown] = useState<string | null>(null);
|
||||||
@@ -430,12 +487,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
const loadWorkspaceData = useCallback(async () => {
|
const loadWorkspaceData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [docsRes, foldersRes, sharedFoldersRes, tagsRes] = await Promise.all([
|
const [docsRes, foldersRes, sharedFoldersRes, tagsRes, journalRes] = await Promise.all([
|
||||||
api.getDocuments(),
|
api.getDocuments(),
|
||||||
api.getFolders(),
|
api.getFolders(),
|
||||||
api.getSharedFolders().catch(() => []),
|
api.getSharedFolders().catch(() => []),
|
||||||
api.getWorkspaceTags().catch(() => []),
|
api.getWorkspaceTags().catch(() => []),
|
||||||
|
isJournalEnabled() ? api.getJournal().catch(() => [] as JournalDay[]) : Promise.resolve([] as JournalDay[]),
|
||||||
]);
|
]);
|
||||||
|
const journalIds = new Set(journalRes.map((d) => d.docId));
|
||||||
setDocs(docsRes);
|
setDocs(docsRes);
|
||||||
setFolders(foldersRes);
|
setFolders(foldersRes);
|
||||||
setSharedFolders(sharedFoldersRes);
|
setSharedFolders(sharedFoldersRes);
|
||||||
@@ -445,7 +504,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
if (prevId && docsRes.some((d) => d.id === prevId)) {
|
if (prevId && docsRes.some((d) => d.id === prevId)) {
|
||||||
return prevId;
|
return prevId;
|
||||||
}
|
}
|
||||||
return docsRes.length > 0 ? docsRes[0].id : null;
|
// Not a journal day: those open in the journal
|
||||||
|
const first = docsRes.find((d) => !(d.isOwner && journalIds.has(d.id)));
|
||||||
|
return first ? first.id : null;
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -549,49 +610,50 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setMobileShowDetail(true);
|
setMobileShowDetail(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openJournalDay = useCallback(async (date: string): Promise<Document | null> => {
|
||||||
|
try {
|
||||||
|
const doc = await api.openJournalDay(date);
|
||||||
|
setDocs((prev) => (prev.some((d) => d.id === doc.id) ? prev : [doc, ...prev]));
|
||||||
|
setJournalDays((prev) =>
|
||||||
|
[
|
||||||
|
...prev.filter((d) => d.date !== date),
|
||||||
|
{ date, docId: doc.id, empty: !(doc.content || '').trim() },
|
||||||
|
].sort((a, b) => b.date.localeCompare(a.date))
|
||||||
|
);
|
||||||
|
return doc;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const jumpToJournalDay = useCallback((date: string) => {
|
||||||
|
setJournalExtra((prev) => (prev.includes(date) ? prev : [...prev, date]));
|
||||||
|
setJournalActive(date);
|
||||||
|
setJournalJump({ date, nonce: Date.now() });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openJournal = useCallback(() => {
|
||||||
|
reloadJournal();
|
||||||
|
setSubView('journal');
|
||||||
|
setMobileShowDetail(true);
|
||||||
|
}, [reloadJournal]);
|
||||||
|
|
||||||
|
// A second click closes the journal and shows the regular note again
|
||||||
|
const toggleJournal = useCallback(() => {
|
||||||
|
if (subView === 'journal') {
|
||||||
|
setSubView('editor');
|
||||||
|
setSelectedDocId((prev) => prev ?? treeDocsRef.current[0]?.id ?? null);
|
||||||
|
} else {
|
||||||
|
openJournal();
|
||||||
|
}
|
||||||
|
}, [subView, openJournal]);
|
||||||
|
|
||||||
const openTrash = useCallback(() => {
|
const openTrash = useCallback(() => {
|
||||||
reloadTrash();
|
reloadTrash();
|
||||||
setSubView('trash');
|
setSubView('trash');
|
||||||
setMobileShowDetail(true);
|
setMobileShowDetail(true);
|
||||||
}, [reloadTrash]);
|
}, [reloadTrash]);
|
||||||
|
|
||||||
// Register mobile FAB actions
|
|
||||||
useEffect(() => {
|
|
||||||
setPrimaryAction(() => handleCreateDocument(''));
|
|
||||||
setGroups([
|
|
||||||
{
|
|
||||||
id: 'workspace-actions',
|
|
||||||
title: 'Notizen & Ordner',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'new-note',
|
|
||||||
label: 'Neue Notiz',
|
|
||||||
icon: <FilePlus className="w-4 h-4" />,
|
|
||||||
variant: 'accent',
|
|
||||||
onClick: () => handleCreateDocument(''),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'new-folder',
|
|
||||||
label: 'Neuer Ordner',
|
|
||||||
icon: <FolderPlus className="w-4 h-4" />,
|
|
||||||
onClick: () => setShowNewFolderModal(true),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'trash',
|
|
||||||
label: 'Papierkorb',
|
|
||||||
icon: <Trash2 className="w-4 h-4" />,
|
|
||||||
badge: trash.length,
|
|
||||||
onClick: openTrash,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
return () => {
|
|
||||||
setGroups([]);
|
|
||||||
setPrimaryAction(undefined);
|
|
||||||
};
|
|
||||||
}, [handleCreateDocument, setGroups, setPrimaryAction, trash.length, openTrash]);
|
|
||||||
|
|
||||||
const handleDeleteDocument = useCallback(
|
const handleDeleteDocument = useCallback(
|
||||||
async (docId: string) => {
|
async (docId: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -846,10 +908,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleWikiLinkClick = useCallback(
|
const handleWikiLinkClick = useCallback(
|
||||||
(pageTitle: string) => {
|
(pageTitle: string, blockId?: string) => {
|
||||||
const clean = pageTitle.trim().toLowerCase();
|
const clean = pageTitle.trim().toLowerCase();
|
||||||
const match = docs.find((d) => d.title.trim().toLowerCase() === clean);
|
const match = docs.find((d) => d.title.trim().toLowerCase() === clean);
|
||||||
if (match) {
|
if (match && blockId) {
|
||||||
|
handleJumpToDocBlock(match.id, blockId);
|
||||||
|
} else if (match) {
|
||||||
setSelectedDocId(match.id);
|
setSelectedDocId(match.id);
|
||||||
setSubView('editor');
|
setSubView('editor');
|
||||||
setMobileShowDetail(true);
|
setMobileShowDetail(true);
|
||||||
@@ -857,7 +921,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
handleCreateDocument('');
|
handleCreateDocument('');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[docs, handleCreateDocument]
|
[docs, handleCreateDocument, handleJumpToDocBlock]
|
||||||
);
|
);
|
||||||
|
|
||||||
const openDocShareModal = useCallback(async (doc: Document) => {
|
const openDocShareModal = useCallback(async (doc: Document) => {
|
||||||
@@ -928,6 +992,40 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Own journal notes appear under "Journal", not in the folder tree
|
||||||
|
const treeDocs = useMemo(
|
||||||
|
() => (journalDocIds.size ? docs.filter((d) => !(d.isOwner && journalDocIds.has(d.id))) : docs),
|
||||||
|
[docs, journalDocIds]
|
||||||
|
);
|
||||||
|
const treeDocsRef = useRef(treeDocs);
|
||||||
|
treeDocsRef.current = treeDocs;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
writeWorkspaceSession({ journal: subView === 'journal', docId: selectedDocId });
|
||||||
|
}, [subView, selectedDocId]);
|
||||||
|
|
||||||
|
const canFocus = subView === 'editor' && Boolean(selectedDocId);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canFocus) setFocusMode(false);
|
||||||
|
}, [canFocus]);
|
||||||
|
// Leaving focus mode or picking another note closes the sliding sidebar
|
||||||
|
useEffect(() => {
|
||||||
|
setZenSidebar('closed');
|
||||||
|
}, [focusMode, selectedDocId]);
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'f' && canFocus) {
|
||||||
|
e.preventDefault();
|
||||||
|
setFocusMode((v) => !v);
|
||||||
|
} else if (e.key === 'Escape' && focusMode && !e.defaultPrevented) {
|
||||||
|
if (zenSidebar !== 'closed') setZenSidebar('closed');
|
||||||
|
else setFocusMode(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [canFocus, focusMode, zenSidebar]);
|
||||||
|
|
||||||
const allFolderPaths = useMemo(() => {
|
const allFolderPaths = useMemo(() => {
|
||||||
const set = new Set<string>(folders);
|
const set = new Set<string>(folders);
|
||||||
for (const d of docs) {
|
for (const d of docs) {
|
||||||
@@ -966,7 +1064,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const sharedFolderObj = sharedFolders.find(
|
const sharedFolderObj = sharedFolders.find(
|
||||||
(sf) => sf.mountPath === p || sf.name === p
|
(sf) => sf.mountPath === p || sf.name === p
|
||||||
);
|
);
|
||||||
const folderDocs = docs.filter((d) => d.folderPath === p);
|
const folderDocs = treeDocs.filter((d) => d.folderPath === p);
|
||||||
|
|
||||||
const node: FolderTreeNode = {
|
const node: FolderTreeNode = {
|
||||||
name: leafName,
|
name: leafName,
|
||||||
@@ -991,16 +1089,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return rootNodes;
|
return rootNodes;
|
||||||
}, [allFolderPaths, docs, sharedFolders]);
|
}, [allFolderPaths, treeDocs, sharedFolders]);
|
||||||
|
|
||||||
const pinnedDocs = useMemo(
|
const pinnedDocs = useMemo(
|
||||||
() => docs.filter((d) => d.isPinned),
|
() => treeDocs.filter((d) => d.isPinned),
|
||||||
[docs]
|
[treeDocs]
|
||||||
);
|
);
|
||||||
|
|
||||||
const rootDocs = useMemo(
|
const rootDocs = useMemo(
|
||||||
() => docs.filter((d) => !d.folderPath && !d.isPinned),
|
() => treeDocs.filter((d) => !d.folderPath && !d.isPinned),
|
||||||
[docs]
|
[treeDocs]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Global To-Dos rollup collections
|
// Global To-Dos rollup collections
|
||||||
@@ -1041,6 +1139,82 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
return count;
|
return count;
|
||||||
}, [docs]);
|
}, [docs]);
|
||||||
|
|
||||||
|
// Register mobile FAB actions and the menu at the top; on phones the menu also switches between
|
||||||
|
// notes, to-dos and hashtags (like the lists in Tasks and Shopping lists), the tab row is hidden there
|
||||||
|
useEffect(() => {
|
||||||
|
setPrimaryAction(() => handleCreateDocument(''));
|
||||||
|
setGroups([
|
||||||
|
{
|
||||||
|
id: 'workspace-views',
|
||||||
|
title: 'Ansicht',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'view-notes',
|
||||||
|
label: 'Notizen',
|
||||||
|
icon: <FileText className="w-4 h-4" />,
|
||||||
|
active: subView === 'editor' || subView === 'journal' || subView === 'trash',
|
||||||
|
onClick: () => {
|
||||||
|
setSubView('editor');
|
||||||
|
setMobileShowDetail(false);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'view-todos',
|
||||||
|
label: 'To-Dos',
|
||||||
|
icon: <Check className="w-4 h-4" />,
|
||||||
|
badge: totalOpenTodos || undefined,
|
||||||
|
active: subView === 'todos',
|
||||||
|
onClick: () => {
|
||||||
|
setSubView('todos');
|
||||||
|
setMobileShowDetail(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'view-tags',
|
||||||
|
label: 'Hashtags',
|
||||||
|
icon: <Hash className="w-4 h-4" />,
|
||||||
|
badge: tagsSummary.length || undefined,
|
||||||
|
active: subView === 'tags' || subView === 'tag_detail',
|
||||||
|
onClick: () => {
|
||||||
|
setSubView('tags');
|
||||||
|
setMobileShowDetail(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'workspace-actions',
|
||||||
|
title: 'Notizen & Ordner',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'new-note',
|
||||||
|
label: 'Neue Notiz',
|
||||||
|
icon: <FilePlus className="w-4 h-4" />,
|
||||||
|
variant: 'accent',
|
||||||
|
onClick: () => handleCreateDocument(''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'new-folder',
|
||||||
|
label: 'Neuer Ordner',
|
||||||
|
icon: <FolderPlus className="w-4 h-4" />,
|
||||||
|
onClick: () => setShowNewFolderModal(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'trash',
|
||||||
|
label: 'Papierkorb',
|
||||||
|
icon: <Trash2 className="w-4 h-4" />,
|
||||||
|
badge: trash.length,
|
||||||
|
onClick: openTrash,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return () => {
|
||||||
|
setGroups([]);
|
||||||
|
setPrimaryAction(undefined);
|
||||||
|
};
|
||||||
|
}, [handleCreateDocument, setGroups, setPrimaryAction, trash.length, openTrash, subView, totalOpenTodos, tagsSummary.length]);
|
||||||
|
|
||||||
// Real-time matched blocks and all nested children for the selected hashtag
|
// Real-time matched blocks and all nested children for the selected hashtag
|
||||||
const activeTagGroups = useMemo(() => {
|
const activeTagGroups = useMemo(() => {
|
||||||
if (!selectedTag) return [];
|
if (!selectedTag) return [];
|
||||||
@@ -1218,21 +1392,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}, [tagsSummary, tagSearchQuery, tagSortBy]);
|
}, [tagsSummary, tagSearchQuery, tagSortBy]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-3">
|
<div className="w-full h-full min-h-0 flex flex-col gap-3">
|
||||||
{/* Top Header: Workspace Tabs & Actions */}
|
{/* Top Header: Workspace Tabs & Actions */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
{/* Phones: the views are switched in the menu at the top, so the row is hidden there */}
|
||||||
{/* Workspace selector tabs & left sidebar toggle; on phones the three tabs share the full width */}
|
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
||||||
|
{/* Workspace selector tabs & left sidebar toggle */}
|
||||||
<div className="grid grid-cols-3 w-full gap-1 sm:flex sm:flex-wrap sm:items-center sm:w-auto">
|
<div className="grid grid-cols-3 w-full gap-1 sm:flex sm:flex-wrap sm:items-center sm:w-auto">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={subView !== 'editor'}
|
disabled={subView !== 'editor'}
|
||||||
onClick={subView === 'editor' ? toggleSidebar : undefined}
|
onClick={subView === 'editor' ? toggleSidebar : undefined}
|
||||||
className={`hidden sm:inline-flex h-8 w-8 items-center justify-center rounded-btn border transition-colors ${
|
className={`chip-btn hidden sm:inline-flex h-8 w-8 items-center justify-center rounded-btn border transition-colors ${
|
||||||
subView !== 'editor'
|
subView !== 'editor'
|
||||||
? 'opacity-35 bg-canvas border-border text-faint cursor-not-allowed pointer-events-none'
|
? 'opacity-35 bg-canvas border-border text-faint cursor-not-allowed pointer-events-none'
|
||||||
: isSidebarCollapsed
|
: isSidebarCollapsed
|
||||||
? 'bg-panel border-accent text-accent cursor-pointer'
|
? 'bg-canvas border-border text-accent hover:bg-panel cursor-pointer'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main cursor-pointer'
|
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
subView !== 'editor'
|
subView !== 'editor'
|
||||||
@@ -1259,7 +1434,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSubView('editor')}
|
onClick={() => setSubView('editor')}
|
||||||
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
|
className={`chip-btn h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
|
||||||
subView === 'editor'
|
subView === 'editor'
|
||||||
? 'bg-panel border-accent text-main'
|
? 'bg-panel border-accent text-main'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
@@ -1275,7 +1450,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setSubView('todos');
|
setSubView('todos');
|
||||||
setMobileShowDetail(true);
|
setMobileShowDetail(true);
|
||||||
}}
|
}}
|
||||||
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
|
className={`chip-btn h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
|
||||||
subView === 'todos'
|
subView === 'todos'
|
||||||
? 'bg-panel border-accent text-main'
|
? 'bg-panel border-accent text-main'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
@@ -1294,7 +1469,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setSubView('tags');
|
setSubView('tags');
|
||||||
setMobileShowDetail(true);
|
setMobileShowDetail(true);
|
||||||
}}
|
}}
|
||||||
className={`h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
|
className={`chip-btn h-8 px-2 sm:px-3 text-xs font-medium border rounded-btn transition-colors flex items-center justify-center gap-1.5 cursor-pointer min-w-0 ${
|
||||||
subView === 'tags' || subView === 'tag_detail'
|
subView === 'tags' || subView === 'tag_detail'
|
||||||
? 'bg-panel border-accent text-main'
|
? 'bg-panel border-accent text-main'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main'
|
: 'bg-canvas border-border text-muted hover:text-main'
|
||||||
@@ -1312,10 +1487,18 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<div className="hidden sm:flex items-center gap-2">
|
<div className="hidden sm:flex items-center gap-2">
|
||||||
{subView === 'editor' && selectedDocId ? (
|
{subView === 'editor' && selectedDocId ? (
|
||||||
<>
|
<>
|
||||||
|
{/* Fixed order: people first (they come and go without moving the buttons), status, actions */}
|
||||||
|
{docHeaderStatus && (
|
||||||
|
<PresenceAvatars
|
||||||
|
people={docHeaderStatus.otherPeople}
|
||||||
|
avatars={docHeaderStatus.avatarsById}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Live connection pill */}
|
{/* Live connection pill */}
|
||||||
{docHeaderStatus?.showLive && (
|
{docHeaderStatus?.showLive && (
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
|
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel text-muted"
|
||||||
title={
|
title={
|
||||||
docHeaderStatus.channel === 'ws'
|
docHeaderStatus.channel === 'ws'
|
||||||
? 'Live-Verbindung über WebSocket'
|
? 'Live-Verbindung über WebSocket'
|
||||||
@@ -1339,14 +1522,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Who else has this note open */}
|
|
||||||
{docHeaderStatus && (
|
|
||||||
<PresenceAvatars
|
|
||||||
people={docHeaderStatus.otherPeople}
|
|
||||||
avatars={docHeaderStatus.avatarsById}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Share pill */}
|
{/* Share pill */}
|
||||||
{selectedDocObj &&
|
{selectedDocObj &&
|
||||||
!selectedDocObj.isArchived &&
|
!selectedDocObj.isArchived &&
|
||||||
@@ -1365,7 +1540,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openDocShareModal(selectedDocObj)}
|
onClick={() => openDocShareModal(selectedDocObj)}
|
||||||
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
|
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel font-semibold text-accent hover:bg-panel-elevated transition-colors cursor-pointer"
|
||||||
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
|
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
|
||||||
>
|
>
|
||||||
<Users className="w-2.5 h-2.5" />
|
<Users className="w-2.5 h-2.5" />
|
||||||
@@ -1375,7 +1550,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openDocShareModal(selectedDocObj)}
|
onClick={() => openDocShareModal(selectedDocObj)}
|
||||||
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
|
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel font-medium text-muted hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
|
||||||
title="Teilen & Berechtigungen öffnen"
|
title="Teilen & Berechtigungen öffnen"
|
||||||
>
|
>
|
||||||
<Users className="w-2.5 h-2.5" />
|
<Users className="w-2.5 h-2.5" />
|
||||||
@@ -1384,16 +1559,26 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Save Status Pill */}
|
{/* Save status: icon only, the text is the tooltip */}
|
||||||
<div
|
<div
|
||||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${
|
role="img"
|
||||||
|
aria-label={
|
||||||
docSaveStatus === 'saved'
|
docSaveStatus === 'saved'
|
||||||
? 'bg-canvas border-border text-muted'
|
? 'Gespeichert'
|
||||||
: docSaveStatus === 'saving'
|
: docSaveStatus === 'saving'
|
||||||
? 'bg-panel border-accent/40 text-accent animate-pulse'
|
? 'Speichert…'
|
||||||
: docSaveStatus === 'unsaved'
|
: docSaveStatus === 'unsaved'
|
||||||
? 'bg-status-warn/10 border-status-warn/30 text-status-warn'
|
? 'Ungespeichert'
|
||||||
: 'bg-status-err/10 border-status-err/30 text-status-err'
|
: 'Fehler beim Speichern'
|
||||||
|
}
|
||||||
|
className={`inline-flex items-center justify-center h-7 w-7 rounded-btn transition-colors ${
|
||||||
|
docSaveStatus === 'saved'
|
||||||
|
? 'bg-panel'
|
||||||
|
: docSaveStatus === 'saving'
|
||||||
|
? 'bg-panel animate-pulse'
|
||||||
|
: docSaveStatus === 'unsaved'
|
||||||
|
? 'bg-status-warn/10'
|
||||||
|
: 'bg-status-err/10'
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
docSaveStatus === 'saved'
|
docSaveStatus === 'saved'
|
||||||
@@ -1408,90 +1593,203 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
{docSaveStatus === 'saved' && (
|
{docSaveStatus === 'saved' && (
|
||||||
<>
|
<>
|
||||||
<Check className="w-3.5 h-3.5 text-status-ok" />
|
<Check className="w-3.5 h-3.5 text-status-ok" />
|
||||||
<span>Gespeichert</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{docSaveStatus === 'saving' && (
|
{docSaveStatus === 'saving' && (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
|
||||||
<span>Speichert…</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{docSaveStatus === 'unsaved' && (
|
{docSaveStatus === 'unsaved' && (
|
||||||
<>
|
<>
|
||||||
<Clock className="w-3.5 h-3.5 text-status-warn" />
|
<Clock className="w-3.5 h-3.5 text-status-warn" />
|
||||||
<span>Ungespeichert</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{docSaveStatus === 'error' && (
|
{docSaveStatus === 'error' && (
|
||||||
<>
|
<>
|
||||||
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
|
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
|
||||||
<span>Fehler</span>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{/* The open note renders undo/redo, share and its menu here on large screens */}
|
||||||
|
{subView === 'editor' && selectedDocId && (
|
||||||
|
<span className="hidden lg:block w-px h-5 bg-border mx-0.5" aria-hidden />
|
||||||
|
)}
|
||||||
|
<div ref={setDocActionsTarget} className="hidden lg:flex items-center" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Workspace Split Pane */}
|
{/* Main Workspace Split Pane */}
|
||||||
<div className="flex h-[calc(100vh-7.5rem)] bg-canvas rounded-card border border-border overflow-hidden">
|
<div
|
||||||
{/* LEFT MASTER SIDEBAR */}
|
className={
|
||||||
<aside
|
focusMode
|
||||||
className={`${
|
? 'fixed inset-0 z-[45] flex bg-editor focus-mode-enter'
|
||||||
(subView !== 'editor' && subView !== 'trash') || isSidebarCollapsed
|
: 'flex flex-1 min-h-0 bg-editor rounded-card border border-border overflow-hidden'
|
||||||
? 'hidden'
|
}
|
||||||
: mobileShowDetail
|
>
|
||||||
? 'hidden lg:flex'
|
{focusMode && (
|
||||||
: 'flex'
|
<>
|
||||||
} w-full lg:w-72 xl:w-80 shrink-0 flex-col bg-panel border-r border-border transition-all duration-200`}
|
{/* Header slides in when the mouse touches the top edge (large screens) */}
|
||||||
>
|
<div className="hidden lg:block fixed top-0 inset-x-0 h-3 z-[46] group">
|
||||||
{/* Sidebar Header & Actions */}
|
<div className="absolute top-0 inset-x-0 flex items-center gap-2 px-6 py-2 bg-panel/95 backdrop-blur-sm border-b border-border shadow-panel -translate-y-full group-hover:translate-y-0 group-focus-within:translate-y-0 transition-transform duration-200">
|
||||||
<div className="p-3 border-b border-border space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-xs font-bold text-main uppercase tracking-wider">
|
|
||||||
Notizen & Outliner
|
|
||||||
</h2>
|
|
||||||
{/* On phones these are in the menu at the top (and the + button), so not repeated here */}
|
|
||||||
<div className="hidden sm:flex items-center gap-1">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => handleOpenNewFolder('', e)}
|
onClick={() => setZenSidebar((v) => (v === 'closed' ? 'click' : 'closed'))}
|
||||||
className="btn-action p-1 text-muted hover:text-main"
|
className="btn-icon h-7 w-7 shrink-0"
|
||||||
title="Neuer Ordner"
|
title="Ordner und Notizen einblenden"
|
||||||
|
aria-label="Ordner und Notizen einblenden"
|
||||||
|
aria-expanded={zenSidebar !== 'closed'}
|
||||||
>
|
>
|
||||||
<FolderPlus className="w-4 h-4" />
|
<FolderOpen className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleCreateDocument('')}
|
onClick={() => setZenSidebar((v) => (v === 'closed' ? 'click' : 'closed'))}
|
||||||
className="h-7 px-2.5 bg-canvas hover:bg-panel border border-border border-dashed rounded-btn text-xs text-muted hover:text-main inline-flex items-center gap-1 transition-colors cursor-pointer"
|
className="flex-1 min-w-0 truncate text-left text-xs font-medium text-main hover:text-accent cursor-pointer"
|
||||||
title="Neue Notiz anlegen"
|
title="Andere Notiz wählen"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
{selectedDocObj?.icon} {selectedDocObj?.title}
|
||||||
<span>Notiz</span>
|
</button>
|
||||||
|
<div ref={setZenActionsTarget} className="flex items-center" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFocusMode(false)}
|
||||||
|
className="btn-action h-7 px-2.5 text-xs inline-flex items-center gap-1.5"
|
||||||
|
title="Fokus-Modus beenden (Esc)"
|
||||||
|
>
|
||||||
|
<Minimize2 className="w-3.5 h-3.5" />
|
||||||
|
Beenden
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Wide screens: touching the left edge opens the sidebar (the note sits far from it) */}
|
||||||
|
<div
|
||||||
|
className="hidden xl:block fixed left-0 inset-y-0 w-2 z-[46]"
|
||||||
|
onMouseEnter={() => setZenSidebar((v) => (v === 'closed' ? 'hover' : v))}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{zenSidebar === 'click' && (
|
||||||
|
<div className="fixed inset-0 z-[46] bg-overlay/30" onClick={() => setZenSidebar('closed')} aria-hidden />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFocusMode(false)}
|
||||||
|
className="lg:hidden fixed bottom-4 right-4 z-[46] h-12 w-12 rounded-full bg-panel border border-border shadow-panel flex items-center justify-center text-main"
|
||||||
|
title="Fokus-Modus beenden"
|
||||||
|
aria-label="Fokus-Modus beenden"
|
||||||
|
>
|
||||||
|
<Minimize2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{/* LEFT MASTER SIDEBAR */}
|
||||||
|
<aside
|
||||||
|
onMouseLeave={() => setZenSidebar((v) => (v === 'hover' ? 'closed' : v))}
|
||||||
|
className={
|
||||||
|
focusMode
|
||||||
|
? `fixed left-0 inset-y-0 z-[47] w-80 max-w-[85vw] flex flex-col bg-sidebar border-r border-border shadow-panel transition-transform duration-300 ease-out ${
|
||||||
|
zenSidebar !== 'closed' ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
}`
|
||||||
|
: `${
|
||||||
|
(subView !== 'editor' && subView !== 'trash' && subView !== 'journal') || isSidebarCollapsed
|
||||||
|
? 'hidden'
|
||||||
|
: mobileShowDetail
|
||||||
|
? 'hidden lg:flex'
|
||||||
|
: 'flex'
|
||||||
|
} w-full lg:w-72 xl:w-80 shrink-0 flex-col bg-sidebar border-r border-border transition-all duration-200 max-lg:animate-in max-lg:slide-in-from-left-1/4 max-lg:fade-in max-lg:duration-200`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Sidebar header: search and the create buttons in one row */}
|
||||||
|
<div className="p-3 border-b border-border flex items-center gap-1.5">
|
||||||
{/* Opens the global full-text search, which also finds the content of every bullet */}
|
{/* Opens the global full-text search, which also finds the content of every bullet */}
|
||||||
{onOpenSearch && (
|
{onOpenSearch && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenSearch}
|
onClick={onOpenSearch}
|
||||||
className="input-field w-full h-11 sm:h-7 px-2.5 flex items-center gap-2 text-xs text-faint hover:text-main cursor-pointer"
|
className="input-field flex-1 min-w-0 h-11 sm:h-8 px-2.5 flex items-center gap-2 text-xs text-faint hover:text-main cursor-pointer"
|
||||||
title="Globale Suche (Strg+K / ⌘K oder /)"
|
title="Globale Suche (Strg+K / ⌘K oder /)"
|
||||||
>
|
>
|
||||||
<Search className="w-3.5 h-3.5 shrink-0" />
|
<Search className="w-3.5 h-3.5 shrink-0" />
|
||||||
<span className="flex-1 text-left truncate">Notizen durchsuchen…</span>
|
<span className="flex-1 text-left truncate">Notizen durchsuchen…</span>
|
||||||
<span className="hidden sm:inline font-mono text-[10px]">⌘K</span>
|
<span className="hidden xl:inline font-mono text-[10px]">⌘K</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* On phones these are in the menu at the top (and the + button), so not repeated here */}
|
||||||
|
<div className="hidden sm:flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => handleOpenNewFolder('', e)}
|
||||||
|
className="btn-icon h-8 w-8"
|
||||||
|
title="Neuer Ordner"
|
||||||
|
aria-label="Neuer Ordner"
|
||||||
|
>
|
||||||
|
<FolderPlus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCreateDocument('')}
|
||||||
|
className="btn-icon h-8 w-8"
|
||||||
|
title="Neue Notiz anlegen"
|
||||||
|
aria-label="Neue Notiz anlegen"
|
||||||
|
>
|
||||||
|
<FilePlus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Navigation Tree */}
|
{/* Scrollable Navigation Tree */}
|
||||||
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
||||||
|
{/* Journal on top; open, it becomes a panel with the calendar (click the heading to close).
|
||||||
|
Panel and calendar animate open (grid rows 0fr → 1fr) instead of jumping. Only from lg on:
|
||||||
|
below, the journal is a view of its own and the button stays a plain entry like the folders
|
||||||
|
(otherwise it stayed half highlighted after "back"). */}
|
||||||
|
{journalEnabled && (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={`rounded-card border-2 transition-[background-color,border-color,padding] duration-300 ease-out ${
|
||||||
|
subView === 'journal' ? 'border-transparent p-0 lg:bg-panel-elevated lg:p-2.5' : 'border-transparent p-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleJournal}
|
||||||
|
aria-expanded={subView === 'journal'}
|
||||||
|
title={subView === 'journal' ? 'Journal schließen' : 'Journal öffnen'}
|
||||||
|
className={`w-full flex items-center gap-2 text-left cursor-pointer rounded-btn transition-all duration-300 h-11 sm:h-8 px-2 text-sm sm:text-xs text-main hover:bg-panel-elevated ${
|
||||||
|
subView === 'journal' ? 'lg:h-7 lg:px-0.5 lg:text-sm lg:font-bold lg:hover:bg-transparent' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CalendarDays
|
||||||
|
className={`text-accent transition-all duration-300 w-3.5 h-3.5 ${subView === 'journal' ? 'lg:w-4 lg:h-4' : ''}`}
|
||||||
|
/>
|
||||||
|
<span>Journal</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className={`hidden lg:grid transition-[grid-template-rows,opacity] duration-300 ease-out ${
|
||||||
|
subView === 'journal' ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
|
||||||
|
}`}
|
||||||
|
aria-hidden={subView !== 'journal'}
|
||||||
|
>
|
||||||
|
<div className="overflow-hidden min-h-0">
|
||||||
|
<div className="pt-2">
|
||||||
|
<JournalCalendar
|
||||||
|
activeDate={journalActive}
|
||||||
|
entryDates={new Set(journalDays.filter((d) => !d.empty).map((d) => d.date))}
|
||||||
|
onSelect={jumpToJournalDay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`h-px bg-border mt-1.5 transition-opacity duration-300 ${
|
||||||
|
subView === 'journal' ? 'opacity-100 lg:opacity-0' : 'opacity-100'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? (
|
{pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? (
|
||||||
<div className="px-2 py-2 text-xs text-muted">Noch keine Notizen</div>
|
<div className="px-2 py-2 text-xs text-muted">Noch keine Notizen</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1516,11 +1814,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setSubView('editor');
|
setSubView('editor');
|
||||||
setMobileShowDetail(true);
|
setMobileShowDetail(true);
|
||||||
}}
|
}}
|
||||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
|
className={`w-full flex items-center gap-2 px-2 py-1.5 min-h-11 sm:min-h-0 rounded-btn text-left text-sm sm:text-xs cursor-pointer border transition-colors ${
|
||||||
mergeHoverId === d.id
|
mergeHoverId === d.id
|
||||||
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
|
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
|
||||||
: selectedDocId === d.id && subView === 'editor'
|
: selectedDocId === d.id && subView === 'editor'
|
||||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
? 'bg-panel-elevated text-main font-semibold border-transparent'
|
||||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -1579,11 +1877,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setSubView('editor');
|
setSubView('editor');
|
||||||
setMobileShowDetail(true);
|
setMobileShowDetail(true);
|
||||||
}}
|
}}
|
||||||
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-left text-xs cursor-pointer border transition-colors ${
|
className={`w-full flex items-center gap-2 px-2 py-1.5 min-h-11 sm:min-h-0 rounded-btn text-left text-sm sm:text-xs cursor-pointer border transition-colors ${
|
||||||
mergeHoverId === d.id
|
mergeHoverId === d.id
|
||||||
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
|
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
|
||||||
: selectedDocId === d.id && subView === 'editor'
|
: selectedDocId === d.id && subView === 'editor'
|
||||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
? 'bg-panel-elevated text-main font-semibold border-transparent'
|
||||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -1652,7 +1950,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
onClick={openTrash}
|
onClick={openTrash}
|
||||||
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
|
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
|
||||||
subView === 'trash'
|
subView === 'trash'
|
||||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
? 'bg-panel-elevated text-main font-semibold border-transparent'
|
||||||
: 'border-transparent text-muted hover:text-main hover:bg-panel-elevated'
|
: 'border-transparent text-muted hover:text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -1666,10 +1964,32 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
{/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */}
|
{/* RIGHT DETAIL AREA (STRICTLY ISOLATED BY KEY) */}
|
||||||
<main
|
<main
|
||||||
className={`${
|
className={`${
|
||||||
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex'
|
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed || focusMode ? 'flex' : 'hidden lg:flex'
|
||||||
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-canvas`}
|
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-editor max-lg:animate-in max-lg:slide-in-from-right-1/3 max-lg:fade-in max-lg:duration-200`}
|
||||||
>
|
>
|
||||||
{subView === 'trash' ? (
|
{subView === 'journal' && journalEnabled ? (
|
||||||
|
<JournalView
|
||||||
|
days={journalDays}
|
||||||
|
extraDates={journalExtra}
|
||||||
|
activeDate={journalActive}
|
||||||
|
onActiveDateChange={setJournalActive}
|
||||||
|
jumpRequest={journalJump}
|
||||||
|
onOpenDay={openJournalDay}
|
||||||
|
onJump={jumpToJournalDay}
|
||||||
|
getDoc={(id) => docs.find((d) => d.id === id)}
|
||||||
|
currentUser={currentUser}
|
||||||
|
availableFolders={allFolderPaths}
|
||||||
|
onOpenShareModal={openDocShareModal}
|
||||||
|
onDocMetadataChange={handleDocMetadataChange}
|
||||||
|
onDeleteDoc={handleDeleteDocument}
|
||||||
|
onSelectTag={handleSelectTag}
|
||||||
|
onWikiLinkClick={handleWikiLinkClick}
|
||||||
|
onOpenDocBlock={handleJumpToDocBlock}
|
||||||
|
onOpenTask={onOpenTask}
|
||||||
|
onBackMobile={() => setMobileShowDetail(false)}
|
||||||
|
onDaySaved={reloadJournalSoon}
|
||||||
|
/>
|
||||||
|
) : subView === 'trash' ? (
|
||||||
<TrashView
|
<TrashView
|
||||||
items={trash}
|
items={trash}
|
||||||
onOpen={handleOpenTrashedDoc}
|
onOpen={handleOpenTrashedDoc}
|
||||||
@@ -1722,6 +2042,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setDocSaveError(err || null);
|
setDocSaveError(err || null);
|
||||||
}}
|
}}
|
||||||
onHeaderStatusChange={setDocHeaderStatus}
|
onHeaderStatusChange={setDocHeaderStatus}
|
||||||
|
actionsTarget={focusMode ? zenActionsTarget : docActionsTarget}
|
||||||
|
focusMode={focusMode}
|
||||||
|
onToggleFocusMode={() => setFocusMode((v) => !v)}
|
||||||
/>
|
/>
|
||||||
) : subView === 'tags' ? (
|
) : subView === 'tags' ? (
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
@@ -1819,7 +2142,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3.5">
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(min(100%,18rem),1fr))] gap-3.5">
|
||||||
|
{/* As many columns as fit with at least 18rem each, so tag names and note titles have room */}
|
||||||
{filteredTags.map((t) => {
|
{filteredTags.map((t) => {
|
||||||
const meta = tagsMetadataMap.get(t.tag.toLowerCase());
|
const meta = tagsMetadataMap.get(t.tag.toLowerCase());
|
||||||
return (
|
return (
|
||||||
@@ -2087,7 +2411,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : subView === 'todos' ? (
|
) : subView === 'todos' ? (
|
||||||
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
|
/* Each nesting level lays a light tint of the text colour on top: darker per level in
|
||||||
|
light themes, lighter per level in dark ones */
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-editor">
|
||||||
<div className="w-full space-y-4">
|
<div className="w-full space-y-4">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between border-b border-border pb-3 gap-2">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between border-b border-border pb-3 gap-2">
|
||||||
<div>
|
<div>
|
||||||
@@ -2101,7 +2427,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{/* View Grouping Selector (matching hashtag switcher style) */}
|
{/* View Grouping Selector (matching hashtag switcher style) */}
|
||||||
<div className="flex items-center bg-canvas border border-border rounded-btn p-0.5 text-[11px]">
|
<div className="flex items-center bg-main/[0.04] border border-border rounded-btn p-0.5 text-[11px]">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -2154,7 +2480,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
{/* Grouped Folders & To-Dos */}
|
{/* Grouped Folders & To-Dos */}
|
||||||
{allRollupTodos.length === 0 ? (
|
{allRollupTodos.length === 0 ? (
|
||||||
<div className="p-8 text-center text-muted text-xs space-y-1 border border-border rounded-card bg-panel">
|
<div className="p-8 text-center text-muted text-xs space-y-1 border border-border rounded-card bg-main/[0.03]">
|
||||||
<Sparkles className="w-6 h-6 text-accent mx-auto mb-1" />
|
<Sparkles className="w-6 h-6 text-accent mx-auto mb-1" />
|
||||||
<p className="font-semibold text-main">Keine To-Dos in Notizen gefunden!</p>
|
<p className="font-semibold text-main">Keine To-Dos in Notizen gefunden!</p>
|
||||||
<p className="text-faint">
|
<p className="text-faint">
|
||||||
@@ -2163,11 +2489,11 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : todoGrouping === 'list' ? (
|
) : todoGrouping === 'list' ? (
|
||||||
/* Flat list of all todos across notes */
|
/* Flat list of all todos across notes */
|
||||||
<div className="border border-border rounded-card bg-panel p-2 space-y-1">
|
<div className="border border-border rounded-card bg-main/[0.03] p-2 space-y-1">
|
||||||
{allRollupTodos.map((item) => (
|
{allRollupTodos.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={`${item.doc.id}-${item.blockId}`}
|
key={`${item.doc.id}-${item.blockId}`}
|
||||||
className="flex items-center justify-between gap-2 p-2 rounded-btn hover:bg-panel-elevated text-xs transition-colors group cursor-pointer"
|
className="flex items-center justify-between gap-2 p-2 rounded-btn hover:bg-main/[0.06] text-xs transition-colors group cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<button
|
<button
|
||||||
@@ -2198,7 +2524,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
<span
|
<span
|
||||||
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
|
onClick={() => handleJumpToDocBlock(item.doc.id, item.blockId)}
|
||||||
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-btn bg-panel-elevated text-muted hover:text-main"
|
className="inline-flex items-center gap-1 text-[11px] px-2 py-0.5 rounded-btn bg-main/[0.06] text-muted hover:text-main"
|
||||||
title={item.doc.title}
|
title={item.doc.title}
|
||||||
>
|
>
|
||||||
<span>{item.doc.icon || '📝'}</span>
|
<span>{item.doc.icon || '📝'}</span>
|
||||||
@@ -2237,10 +2563,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={folderName}
|
key={folderName}
|
||||||
className="border border-border rounded-card bg-panel overflow-hidden"
|
className="border border-border rounded-card bg-main/[0.03] overflow-hidden"
|
||||||
>
|
>
|
||||||
{/* Folder Header */}
|
{/* Folder Header */}
|
||||||
<div className="p-2 bg-panel border-b border-border flex items-center justify-between">
|
<div className="p-2 border-b border-border flex items-center justify-between">
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setCollapsedTodoFolders((prev) => {
|
setCollapsedTodoFolders((prev) => {
|
||||||
@@ -2267,7 +2593,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTodoFolderDrilldown(folderName)}
|
onClick={() => setTodoFolderDrilldown(folderName)}
|
||||||
className="text-[10px] text-muted hover:text-main px-1.5 py-0.5 rounded-btn hover:bg-panel-elevated cursor-pointer"
|
className="text-[10px] text-muted hover:text-main px-1.5 py-0.5 rounded-btn hover:bg-main/[0.06] cursor-pointer"
|
||||||
title="Diesen Ordner isolieren"
|
title="Diesen Ordner isolieren"
|
||||||
>
|
>
|
||||||
Nur diesen Ordner
|
Nur diesen Ordner
|
||||||
@@ -2283,7 +2609,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={doc.id}
|
key={doc.id}
|
||||||
className="border border-border/60 rounded-btn p-2 bg-panel-elevated/40 space-y-1.5"
|
className="border border-border/60 rounded-btn p-2 bg-main/[0.04] space-y-1.5"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between pb-1 border-b border-border/40">
|
<div className="flex items-center justify-between pb-1 border-b border-border/40">
|
||||||
<div
|
<div
|
||||||
@@ -2325,7 +2651,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
{docItems.map((item) => (
|
{docItems.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={`${item.doc.id}-${item.blockId}`}
|
key={`${item.doc.id}-${item.blockId}`}
|
||||||
className="flex items-center justify-between gap-2 p-1 rounded-btn hover:bg-panel-elevated text-xs transition-colors group cursor-pointer"
|
className="flex items-center justify-between gap-2 p-1 rounded-btn hover:bg-main/[0.06] text-xs transition-colors group cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<button
|
<button
|
||||||
@@ -2386,7 +2712,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-canvas">
|
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-editor">
|
||||||
<FileText className="w-10 h-10 text-faint mb-2" />
|
<FileText className="w-10 h-10 text-faint mb-2" />
|
||||||
<h3 className="text-sm font-semibold text-main mb-1">
|
<h3 className="text-sm font-semibold text-main mb-1">
|
||||||
Keine Notiz ausgewählt
|
Keine Notiz ausgewählt
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
@@ -209,4 +204,55 @@ describe('CodeBlockEditor', () => {
|
|||||||
expect(res2.updates[res2.updates.length - 1]?.type).toBe('code');
|
expect(res2.updates[res2.updates.length - 1]?.type).toBe('code');
|
||||||
expect(res2.updates[res2.updates.length - 1]?.lang).toBe('json');
|
expect(res2.updates[res2.updates.length - 1]?.lang).toBe('json');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders line numbers column with aria-hidden, user-select: none, and correct line count including trailing empty lines and 100+ lines', () => {
|
||||||
|
// 1. Single empty line
|
||||||
|
const { container, rerender } = render(
|
||||||
|
<CodeBlockEditor block={makeCodeBlock({ id: 'code-empty', content: '' })} content="" canEdit />
|
||||||
|
);
|
||||||
|
const getLineContainer = () => container.querySelector('[data-testid="code-line-numbers"]') as HTMLElement;
|
||||||
|
expect(getLineContainer()).toBeTruthy();
|
||||||
|
expect(getLineContainer().getAttribute('aria-hidden')).toBe('true');
|
||||||
|
expect(getLineContainer().className).toContain('select-none');
|
||||||
|
|
||||||
|
let lineEls = container.querySelectorAll('[data-line-number]');
|
||||||
|
expect(lineEls).toHaveLength(1);
|
||||||
|
expect(lineEls[0].textContent?.trim()).toBe('1');
|
||||||
|
|
||||||
|
// 2. Trailing empty line gives an extra line number (e.g. line 1 + empty line 2)
|
||||||
|
rerender(
|
||||||
|
<CodeBlockEditor
|
||||||
|
block={makeCodeBlock({ id: 'code-trailing', content: `first line
|
||||||
|
` })}
|
||||||
|
content={`first line
|
||||||
|
`}
|
||||||
|
canEdit
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
lineEls = container.querySelectorAll('[data-line-number]');
|
||||||
|
expect(lineEls).toHaveLength(2);
|
||||||
|
expect(lineEls[0].textContent?.trim()).toBe('1');
|
||||||
|
expect(lineEls[1].textContent?.trim()).toBe('2');
|
||||||
|
|
||||||
|
// 3. 120 lines (100+ lines test)
|
||||||
|
const longCode = Array.from({ length: 120 }, (_, i) => `const x_${i + 1} = ${i + 1};`).join('\n');
|
||||||
|
rerender(
|
||||||
|
<CodeBlockEditor
|
||||||
|
block={makeCodeBlock({ id: 'code-long', content: longCode })}
|
||||||
|
content={longCode}
|
||||||
|
canEdit
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
lineEls = container.querySelectorAll('[data-line-number]');
|
||||||
|
expect(lineEls).toHaveLength(120);
|
||||||
|
expect(lineEls[0].textContent?.trim()).toBe('1');
|
||||||
|
expect(lineEls[99].textContent?.trim()).toBe('100');
|
||||||
|
expect(lineEls[119].textContent?.trim()).toBe('120');
|
||||||
|
|
||||||
|
// Textarea has whitespace-pre and overlay has whitespace-pre
|
||||||
|
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
const pre = container.querySelector('pre') as HTMLPreElement;
|
||||||
|
expect(textarea.className).toContain('whitespace-pre');
|
||||||
|
expect(pre.className).toContain('whitespace-pre');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
@@ -255,3 +292,226 @@ describe('slash command menu and outliner fixes', () => {
|
|||||||
expect(updates[updates.length - 1]?.content).toBe('Siehe [Doku](https://example.com/doku)');
|
expect(updates[updates.length - 1]?.content).toBe('Siehe [Doku](https://example.com/doku)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatting shortcuts (Teil C)', () => {
|
||||||
|
it('toggles bold with and without selection (Ctrl+B and Cmd+B)', () => {
|
||||||
|
const block = { id: 'b1', type: 'bullet', content: 'hallo welt', level: 0 } as DocumentBlock;
|
||||||
|
const { container } = renderBlock(block);
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
ta.focus();
|
||||||
|
|
||||||
|
// 1. With selection: wraps in **
|
||||||
|
ta.setSelectionRange(6, 10); // "welt"
|
||||||
|
const event1 = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
|
||||||
|
ta.dispatchEvent(event1);
|
||||||
|
expect(event1.defaultPrevented).toBe(true);
|
||||||
|
expect(ta.value).toBe('hallo **welt**');
|
||||||
|
expect(ta.selectionStart).toBe(8);
|
||||||
|
expect(ta.selectionEnd).toBe(12);
|
||||||
|
|
||||||
|
// 2. With selection where delimiters are directly outside: unwraps **
|
||||||
|
const event2 = new KeyboardEvent('keydown', { key: 'b', metaKey: true, bubbles: true, cancelable: true });
|
||||||
|
ta.dispatchEvent(event2);
|
||||||
|
expect(event2.defaultPrevented).toBe(true);
|
||||||
|
expect(ta.value).toBe('hallo welt');
|
||||||
|
expect(ta.selectionStart).toBe(6);
|
||||||
|
expect(ta.selectionEnd).toBe(10);
|
||||||
|
|
||||||
|
// 3. With selection including delimiters: unwraps **
|
||||||
|
ta.value = 'hallo **welt**';
|
||||||
|
ta.setSelectionRange(6, 14); // "**welt**"
|
||||||
|
const event3 = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
|
||||||
|
ta.dispatchEvent(event3);
|
||||||
|
expect(ta.value).toBe('hallo welt');
|
||||||
|
expect(ta.selectionStart).toBe(6);
|
||||||
|
expect(ta.selectionEnd).toBe(10);
|
||||||
|
|
||||||
|
// 4. Without selection: inserts **** and places cursor between
|
||||||
|
ta.value = 'start';
|
||||||
|
ta.setSelectionRange(5, 5);
|
||||||
|
const event4 = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
|
||||||
|
ta.dispatchEvent(event4);
|
||||||
|
expect(ta.value).toBe('start****');
|
||||||
|
expect(ta.selectionStart).toBe(7);
|
||||||
|
expect(ta.selectionEnd).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles italic with and without selection (Ctrl+I)', () => {
|
||||||
|
const block = { id: 'b1', type: 'bullet', content: 'hallo welt', level: 0 } as DocumentBlock;
|
||||||
|
const { container } = renderBlock(block);
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
ta.focus();
|
||||||
|
|
||||||
|
// 1. With selection: wraps in *
|
||||||
|
ta.setSelectionRange(6, 10); // "welt"
|
||||||
|
fireEvent.keyDown(ta, { key: 'i', ctrlKey: true });
|
||||||
|
expect(ta.value).toBe('hallo *welt*');
|
||||||
|
expect(ta.selectionStart).toBe(7);
|
||||||
|
expect(ta.selectionEnd).toBe(11);
|
||||||
|
|
||||||
|
// 2. Toggles off when already italic
|
||||||
|
fireEvent.keyDown(ta, { key: 'i', ctrlKey: true });
|
||||||
|
expect(ta.value).toBe('hallo welt');
|
||||||
|
expect(ta.selectionStart).toBe(6);
|
||||||
|
expect(ta.selectionEnd).toBe(10);
|
||||||
|
|
||||||
|
// 3. Without selection: inserts ** and places cursor between
|
||||||
|
fireEvent.change(ta, { target: { value: 'a' } });
|
||||||
|
ta.setSelectionRange(1, 1);
|
||||||
|
fireEvent.keyDown(ta, { key: 'i', ctrlKey: true });
|
||||||
|
expect(ta.value).toBe('a**');
|
||||||
|
expect(ta.selectionStart).toBe(2);
|
||||||
|
expect(ta.selectionEnd).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles Ctrl+K: with selection wraps in [Text](…) and selects placeholder, without selection opens modal', () => {
|
||||||
|
const block = { id: 'b1', type: 'bullet', content: 'besuche mindsift heute', level: 0 } as DocumentBlock;
|
||||||
|
const { container, queryByText } = renderBlock(block);
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
ta.focus();
|
||||||
|
|
||||||
|
// 1. With selection
|
||||||
|
ta.setSelectionRange(8, 16); // "mindsift"
|
||||||
|
const eventWithSel = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true });
|
||||||
|
ta.dispatchEvent(eventWithSel);
|
||||||
|
expect(eventWithSel.defaultPrevented).toBe(true);
|
||||||
|
expect(ta.value).toBe('besuche [mindsift](…) heute');
|
||||||
|
// Placeholder "…" is selected
|
||||||
|
expect(ta.selectionStart).toBe(19);
|
||||||
|
expect(ta.selectionEnd).toBe(20);
|
||||||
|
expect(ta.value.slice(ta.selectionStart, ta.selectionEnd)).toBe('…');
|
||||||
|
|
||||||
|
// 2. Without selection: opens LinkModal
|
||||||
|
ta.setSelectionRange(0, 0);
|
||||||
|
expect(queryByText('Link einfügen')).toBeNull();
|
||||||
|
fireEvent.keyDown(ta, { key: 'k', ctrlKey: true });
|
||||||
|
expect(queryByText('Link einfügen')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing in code blocks or for read-only readers', () => {
|
||||||
|
// In code block
|
||||||
|
const codeBlock = { id: 'c1', type: 'code', content: 'const x = 1;', level: 0 } as DocumentBlock;
|
||||||
|
const codeView = renderBlock(codeBlock);
|
||||||
|
const codeTa = codeView.container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
codeTa.focus();
|
||||||
|
codeTa.setSelectionRange(6, 7); // "x"
|
||||||
|
const codeEvent = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
|
||||||
|
codeTa.dispatchEvent(codeEvent);
|
||||||
|
expect(codeEvent.defaultPrevented).toBe(false);
|
||||||
|
expect(codeTa.value).toBe('const x = 1;');
|
||||||
|
|
||||||
|
// For readers (!canEdit)
|
||||||
|
const bulletBlock = { id: 'b1', type: 'bullet', content: 'text', level: 0 } as DocumentBlock;
|
||||||
|
const readerView = renderBlock(bulletBlock, { canEdit: false });
|
||||||
|
const readerTa = readerView.container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
readerTa.focus();
|
||||||
|
readerTa.setSelectionRange(0, 4);
|
||||||
|
const readerEvent = new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true, cancelable: true });
|
||||||
|
readerTa.dispatchEvent(readerEvent);
|
||||||
|
expect(readerEvent.defaultPrevented).toBe(false);
|
||||||
|
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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
+88
-13
@@ -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,16 +431,15 @@ body {
|
|||||||
--code-variable: #9cdcfe;
|
--code-variable: #9cdcfe;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root:not(.dark),
|
:where(:root:not(.dark)) {
|
||||||
:root[data-theme="light"] {
|
|
||||||
--code-keyword: #0000ff;
|
--code-keyword: #0000ff;
|
||||||
--code-string: #a31515;
|
--code-string: #a31515;
|
||||||
--code-number: #098658;
|
--code-number: #098658;
|
||||||
--code-comment: #008000;
|
--code-comment: #007000;
|
||||||
--code-property: #0451a5;
|
--code-property: #0451a5;
|
||||||
--code-function: #795e26;
|
--code-function: #795e26;
|
||||||
--code-type: #267f99;
|
--code-type: #267f99;
|
||||||
--code-punctuation: #3b3b3b;
|
--code-punctuation: #2e2e2e;
|
||||||
--code-variable: #001080;
|
--code-variable: #001080;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -550,6 +556,13 @@ fieldset:disabled :is(button, input, select, textarea) {
|
|||||||
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 +575,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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { renderInlineMarkdown } from '../markdown';
|
||||||
|
|
||||||
|
describe('renderInlineMarkdown', () => {
|
||||||
|
it('renders bold text with <strong> tag', () => {
|
||||||
|
const { container } = render(<div>{renderInlineMarkdown('Text mit **fettem** Wort')}</div>);
|
||||||
|
const strong = container.querySelector('strong');
|
||||||
|
expect(strong).toBeTruthy();
|
||||||
|
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', () => {
|
||||||
|
const { container } = render(<div>{renderInlineMarkdown('Text mit *kursivem* Wort')}</div>);
|
||||||
|
const em = container.querySelector('em');
|
||||||
|
expect(em).toBeTruthy();
|
||||||
|
expect(em?.textContent).toBe('kursivem');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders both bold and italic text in one paragraph', () => {
|
||||||
|
const { container } = render(<div>{renderInlineMarkdown('Hier ist **fett** und *kursiv* vereint')}</div>);
|
||||||
|
const strong = container.querySelector('strong');
|
||||||
|
const em = container.querySelector('em');
|
||||||
|
expect(strong?.textContent).toBe('fett');
|
||||||
|
expect(em?.textContent).toBe('kursiv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders links correctly', () => {
|
||||||
|
const { container } = render(<div>{renderInlineMarkdown('[Testseite](https://example.com)')}</div>);
|
||||||
|
const link = container.querySelector('a');
|
||||||
|
expect(link).toBeTruthy();
|
||||||
|
expect(link?.getAttribute('href')).toBe('https://example.com/');
|
||||||
|
expect(link?.textContent).toBe('Testseite');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { listLabel, moveSibling, numberedLabels } from '../outlineList';
|
||||||
|
import type { DocumentBlock } from '../types';
|
||||||
|
|
||||||
|
const blk = (id: string, level: number, type: DocumentBlock['type'] = 'numbered'): DocumentBlock => ({
|
||||||
|
id, documentId: 'd', type, content: id, level, position: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('numbered lists', () => {
|
||||||
|
it('formats numbers, letters and roman numerals by depth', () => {
|
||||||
|
expect(listLabel(3, 0)).toBe('3.');
|
||||||
|
expect(listLabel(2, 1)).toBe('b.');
|
||||||
|
expect(listLabel(27, 1)).toBe('aa.');
|
||||||
|
expect(listLabel(4, 2)).toBe('iv.');
|
||||||
|
expect(listLabel(1, 3)).toBe('1.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts siblings, nests and restarts after other blocks', () => {
|
||||||
|
const labels = numberedLabels([
|
||||||
|
blk('a', 0), blk('a1', 1), blk('a2', 1), blk('a2i', 2), blk('b', 0),
|
||||||
|
blk('note', 0, 'text'), blk('c', 0), blk('plain', 1, 'text'), blk('c1', 2),
|
||||||
|
]);
|
||||||
|
expect(Object.fromEntries(labels)).toEqual({
|
||||||
|
a: '1.', a1: 'a.', a2: 'b.', a2i: 'i.', b: '2.', c: '1.', c1: 'a.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('moveSibling (phone bar ↑ ↓)', () => {
|
||||||
|
// A
|
||||||
|
// a1
|
||||||
|
// a2
|
||||||
|
// a2x
|
||||||
|
// B
|
||||||
|
// b1
|
||||||
|
// C
|
||||||
|
const tree = [blk('A', 0), blk('a1', 1), blk('a2', 1), blk('a2x', 2), blk('B', 0), blk('b1', 1), blk('C', 0)];
|
||||||
|
const ids = (bs: DocumentBlock[]) => bs.map((b) => b.id).join(' ');
|
||||||
|
|
||||||
|
it('swaps with the previous sibling and takes the children along', () => {
|
||||||
|
expect(ids(moveSibling(tree, 'B', -1))).toBe('B b1 A a1 a2 a2x C');
|
||||||
|
expect(ids(moveSibling(tree, 'a2', -1))).toBe('A a2 a2x a1 B b1 C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swaps with the next sibling, jumping over its whole branch', () => {
|
||||||
|
expect(ids(moveSibling(tree, 'A', 1))).toBe('B b1 A a1 a2 a2x C');
|
||||||
|
expect(ids(moveSibling(tree, 'a1', 1))).toBe('A a2 a2x a1 B b1 C');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never leaves its parent and stops at the ends', () => {
|
||||||
|
expect(moveSibling(tree, 'a1', -1)).toBe(tree); // first child
|
||||||
|
expect(moveSibling(tree, 'a2', 1)).toBe(tree); // last child
|
||||||
|
expect(moveSibling(tree, 'A', -1)).toBe(tree);
|
||||||
|
expect(moveSibling(tree, 'C', 1)).toBe(tree);
|
||||||
|
expect(moveSibling(tree, 'missing', 1)).toBe(tree);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parsePastedLines } from '../pasteOutline';
|
||||||
|
|
||||||
|
const parse = (text: string) => parsePastedLines(text.split('\n'));
|
||||||
|
|
||||||
|
describe('parsePastedLines', () => {
|
||||||
|
it('splits paragraphs at blank lines and keeps wrapped lines together', () => {
|
||||||
|
expect(parse('Erster Absatz\nnoch erster\n\nZweiter Absatz')).toEqual([
|
||||||
|
{ type: 'text', content: 'Erster Absatz\nnoch erster', indent: 0 },
|
||||||
|
{ type: 'text', content: 'Zweiter Absatz', indent: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps numbered lists with nesting (3-space indent)', () => {
|
||||||
|
expect(parse('1. Eins\n 1. Unterpunkt\n 1. Tiefer\n2. Zwei')).toEqual([
|
||||||
|
{ type: 'numbered', content: 'Eins', indent: 0 },
|
||||||
|
{ type: 'numbered', content: 'Unterpunkt', indent: 1 },
|
||||||
|
{ type: 'numbered', content: 'Tiefer', indent: 2 },
|
||||||
|
{ type: 'numbered', content: 'Zwei', indent: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads headings, todos, bullets and dividers and nests sections under headings', () => {
|
||||||
|
expect(parse('# Titel\nText danach\n- [x] erledigt\n* Punkt\n + tiefer\n---\nOhne Abschnitt')).toEqual([
|
||||||
|
{ type: 'h1', content: 'Titel', indent: 0 },
|
||||||
|
{ type: 'text', content: 'Text danach', indent: 1 },
|
||||||
|
{ type: 'todo', checked: true, content: 'erledigt', indent: 2 },
|
||||||
|
{ type: 'text', content: 'Punkt', indent: 2 },
|
||||||
|
{ type: 'text', content: 'tiefer', indent: 3 },
|
||||||
|
{ type: 'divider', content: '---', indent: 0 },
|
||||||
|
{ type: 'text', content: 'Ohne Abschnitt', indent: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nests H2 below H1 and closes sections at the same rank', () => {
|
||||||
|
expect(parse('# A\n## A1\n1. eins\n## A2\nText\n# B\n- b')).toEqual([
|
||||||
|
{ type: 'h1', content: 'A', indent: 0 },
|
||||||
|
{ type: 'h2', content: 'A1', indent: 1 },
|
||||||
|
{ type: 'numbered', content: 'eins', indent: 2 },
|
||||||
|
{ type: 'h2', content: 'A2', indent: 1 },
|
||||||
|
{ type: 'text', content: 'Text', indent: 2 },
|
||||||
|
{ type: 'h1', content: 'B', indent: 0 },
|
||||||
|
{ type: 'text', content: 'b', indent: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps fenced code as one block', () => {
|
||||||
|
const items = parse('```go\nfunc main() {}\n\n```\nDanach');
|
||||||
|
expect(items[0].content).toBe('```go\nfunc main() {}\n\n```');
|
||||||
|
expect(items[1]).toEqual({ type: 'text', content: 'Danach', indent: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nests a list under the paragraph that introduces it', () => {
|
||||||
|
expect(parse('# Plan\nFolgende Punkte:\n\n- eins\n - tiefer\n- zwei\n\nDanach Text\n1. a\n2. b')).toEqual([
|
||||||
|
{ type: 'h1', content: 'Plan', indent: 0 },
|
||||||
|
{ type: 'text', content: 'Folgende Punkte:', indent: 1 },
|
||||||
|
{ type: 'text', content: 'eins', indent: 2 },
|
||||||
|
{ type: 'text', content: 'tiefer', indent: 3 },
|
||||||
|
{ type: 'text', content: 'zwei', indent: 2 },
|
||||||
|
{ type: 'text', content: 'Danach Text', indent: 1 },
|
||||||
|
{ type: 'numbered', content: 'a', indent: 2 },
|
||||||
|
{ type: 'numbered', content: 'b', indent: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { deviceName, urlBase64ToUint8Array } from '../push';
|
||||||
|
|
||||||
|
describe('push helpers', () => {
|
||||||
|
it('decodes the VAPID key (base64url without padding)', () => {
|
||||||
|
expect(Array.from(urlBase64ToUint8Array('AQID_-8'))).toEqual([1, 2, 3, 255, 239]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names the device', () => {
|
||||||
|
expect(deviceName('Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0')).toBe('Firefox auf Linux');
|
||||||
|
expect(deviceName('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/129.0 Mobile Safari/537.36')).toBe('Chrome auf Android');
|
||||||
|
expect(deviceName('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1')).toBe('Safari auf iOS');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatSharedThought } from '../shareTarget';
|
||||||
|
|
||||||
|
describe('formatSharedThought', () => {
|
||||||
|
it('formats pure text correctly', () => {
|
||||||
|
expect(formatSharedThought({ text: 'Spontaner Einfall' })).toBe('Spontaner Einfall');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats pure url correctly', () => {
|
||||||
|
expect(formatSharedThought({ url: 'https://example.com/article' })).toBe('https://example.com/article');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines title and url when text is empty', () => {
|
||||||
|
expect(formatSharedThought({ title: 'Toller Artikel', url: 'https://example.com/article' }))
|
||||||
|
.toBe('Toller Artikel\nhttps://example.com/article');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate URL when text already contains it', () => {
|
||||||
|
const input = {
|
||||||
|
title: 'Artikel',
|
||||||
|
text: 'Lies das hier: https://example.com/article unbedingt!',
|
||||||
|
url: 'https://example.com/article',
|
||||||
|
};
|
||||||
|
expect(formatSharedThought(input))
|
||||||
|
.toBe('Artikel\nLies das hier: https://example.com/article unbedingt!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate URL when text is exactly the URL', () => {
|
||||||
|
const input = {
|
||||||
|
title: 'News',
|
||||||
|
text: 'https://news.example.com',
|
||||||
|
url: 'https://news.example.com',
|
||||||
|
};
|
||||||
|
expect(formatSharedThought(input))
|
||||||
|
.toBe('News\nhttps://news.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate title if text starts with title', () => {
|
||||||
|
const input = {
|
||||||
|
title: 'MindSift Update',
|
||||||
|
text: 'MindSift Update ist jetzt verfügbar.',
|
||||||
|
};
|
||||||
|
expect(formatSharedThought(input))
|
||||||
|
.toBe('MindSift Update ist jetzt verfügbar.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines title, text and distinct URL', () => {
|
||||||
|
const input = {
|
||||||
|
title: 'Empfehlung',
|
||||||
|
text: 'Ein großartiges Tool für schnelles Erfassen.',
|
||||||
|
url: 'https://mindsift.app',
|
||||||
|
};
|
||||||
|
expect(formatSharedThought(input))
|
||||||
|
.toBe('Empfehlung\nEin großartiges Tool für schnelles Erfassen.\nhttps://mindsift.app');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles image with text', () => {
|
||||||
|
const input = {
|
||||||
|
title: 'Foto vom Ausflug',
|
||||||
|
fileName: 'berge.jpg',
|
||||||
|
hasMedia: true,
|
||||||
|
};
|
||||||
|
expect(formatSharedThought(input))
|
||||||
|
.toBe('Foto vom Ausflug\n(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles image only with filename', () => {
|
||||||
|
const input = {
|
||||||
|
fileName: 'screenshot.png',
|
||||||
|
hasMedia: true,
|
||||||
|
};
|
||||||
|
expect(formatSharedThought(input))
|
||||||
|
.toBe('[screenshot.png] (Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles image only without filename', () => {
|
||||||
|
const input = {
|
||||||
|
hasMedia: true,
|
||||||
|
};
|
||||||
|
expect(formatSharedThought(input))
|
||||||
|
.toBe('(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty string if nothing is provided', () => {
|
||||||
|
expect(formatSharedThought({})).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { setActiveTheme, initThemeOnBoot, customThemeOption, getActiveTheme } from '../theme';
|
import {
|
||||||
|
setActiveTheme,
|
||||||
|
initThemeOnBoot,
|
||||||
|
customThemeOption,
|
||||||
|
getActiveTheme,
|
||||||
|
themeCounterpart,
|
||||||
|
setCornerStyle,
|
||||||
|
getCornerStyle,
|
||||||
|
THEME_FAMILIES,
|
||||||
|
AVAILABLE_THEMES,
|
||||||
|
setFollowSystem,
|
||||||
|
resolveTheme,
|
||||||
|
setAccentColor,
|
||||||
|
setButtonBorders,
|
||||||
|
} from '../theme';
|
||||||
|
|
||||||
const lightCss = `:root[data-theme="papier"] { --bg-app: #fafafa; --accent-primary: #15803d; --radius-card: 8px; }`;
|
const lightCss = `:root[data-theme="papier"] { --bg-app: #fafafa; --accent-primary: #15803d; --radius-card: 8px; }`;
|
||||||
|
|
||||||
@@ -65,3 +79,80 @@ describe('custom themes', () => {
|
|||||||
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('built-in themes', () => {
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it('moves removed themes to their successors', () => {
|
||||||
|
localStorage.setItem('mindsift_active_theme', 'soft');
|
||||||
|
expect(getActiveTheme()).toBe('terracotta-night');
|
||||||
|
localStorage.setItem('mindsift_active_theme', 'high-contrast');
|
||||||
|
expect(getActiveTheme()).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats Terrakotta as a light theme and Terrakotta Nacht as dark', () => {
|
||||||
|
setActiveTheme('terracotta');
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBe(false);
|
||||||
|
setActiveTheme('terracotta-night');
|
||||||
|
expect(document.documentElement.classList.contains('dark')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('theme families and corners', () => {
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it('pairs every family with an existing light and dark theme', () => {
|
||||||
|
for (const f of THEME_FAMILIES) {
|
||||||
|
expect(AVAILABLE_THEMES.find((t) => t.id === f.light)?.mode).toBe('light');
|
||||||
|
expect(AVAILABLE_THEMES.find((t) => t.id === f.dark)?.mode).toBe('dark');
|
||||||
|
}
|
||||||
|
expect(themeCounterpart('fjord')).toBe('fjord-night');
|
||||||
|
expect(themeCounterpart('polarized')).toBe('polarized-light');
|
||||||
|
expect(themeCounterpart('custom:x')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forces round or square corners and keeps the choice', () => {
|
||||||
|
setCornerStyle('square');
|
||||||
|
expect(document.documentElement.getAttribute('data-corners')).toBe('square');
|
||||||
|
expect(getCornerStyle()).toBe('square');
|
||||||
|
setCornerStyle('theme');
|
||||||
|
expect(document.documentElement.hasAttribute('data-corners')).toBe(false);
|
||||||
|
expect(getCornerStyle()).toBe('theme');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('follow system, accent and button frames', () => {
|
||||||
|
beforeEach(() => localStorage.clear());
|
||||||
|
|
||||||
|
it('picks the family variant that matches the system when following it', () => {
|
||||||
|
const original = window.matchMedia;
|
||||||
|
window.matchMedia = ((q: string) => ({ matches: q.includes('dark'), media: q, addEventListener() {}, removeEventListener() {} })) as unknown as typeof window.matchMedia;
|
||||||
|
try {
|
||||||
|
expect(resolveTheme('fjord')).toBe('fjord');
|
||||||
|
setFollowSystem(true);
|
||||||
|
expect(resolveTheme('fjord')).toBe('fjord-night');
|
||||||
|
expect(resolveTheme('suri-light')).toBe('suri');
|
||||||
|
expect(resolveTheme('custom:x')).toBe('custom:x');
|
||||||
|
} finally {
|
||||||
|
window.matchMedia = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets the accent variables and removes them again', () => {
|
||||||
|
setActiveTheme('dark');
|
||||||
|
setAccentColor('#c2410c');
|
||||||
|
const style = document.documentElement.style;
|
||||||
|
expect(style.getPropertyValue('--accent-primary')).toBe('#c2410c');
|
||||||
|
expect(style.getPropertyValue('--text-on-accent')).toBe('#ffffff');
|
||||||
|
expect(style.getPropertyValue('--accent-dim')).toBe('rgba(194, 65, 12, 0.16)');
|
||||||
|
setAccentColor(null);
|
||||||
|
expect(style.getPropertyValue('--accent-primary')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches button frames off and on', () => {
|
||||||
|
setButtonBorders('off');
|
||||||
|
expect(document.documentElement.getAttribute('data-button-borders')).toBe('off');
|
||||||
|
setButtonBorders('on');
|
||||||
|
expect(document.documentElement.hasAttribute('data-button-borders')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { buildBlockLink, parseWikiLink, wikiLinkText } from '../wikiLink';
|
||||||
|
|
||||||
|
describe('wiki links', () => {
|
||||||
|
it('parses plain, labelled, section and bullet links', () => {
|
||||||
|
expect(parseWikiLink('Homelab')).toEqual({ title: 'Homelab', blockId: undefined, label: undefined });
|
||||||
|
expect(parseWikiLink('Homelab|hier')).toEqual({ title: 'Homelab', blockId: undefined, label: 'hier' });
|
||||||
|
expect(parseWikiLink('Homelab#Abschnitt')).toEqual({ title: 'Homelab', blockId: undefined, label: undefined });
|
||||||
|
expect(parseWikiLink('Homelab#^blk_a_1|Homelab › Backup')).toEqual({
|
||||||
|
title: 'Homelab', blockId: 'blk_a_1', label: 'Homelab › Backup',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds a bullet link that parses back', () => {
|
||||||
|
const link = buildBlockLink('Homelab [neu]', 'blk_x', 'Backups liefen bisher nur manuell – das soll sich ändern #backup');
|
||||||
|
expect(link).toBe('[[Homelab neu#^blk_x|Homelab neu › Backups liefen bisher nur manuell – das…]]');
|
||||||
|
const parsed = parseWikiLink(link.slice(2, -2));
|
||||||
|
expect(parsed.blockId).toBe('blk_x');
|
||||||
|
expect(wikiLinkText(parsed)).toBe('Homelab neu › Backups liefen bisher nur manuell – das…');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
DashboardStats,
|
DashboardStats,
|
||||||
Document,
|
Document,
|
||||||
TrashedDocument,
|
TrashedDocument,
|
||||||
|
JournalDay,
|
||||||
|
PushDevice,
|
||||||
BackupFile,
|
BackupFile,
|
||||||
DataSection,
|
DataSection,
|
||||||
ApiKeyScope,
|
ApiKeyScope,
|
||||||
@@ -723,6 +725,38 @@ export const api = {
|
|||||||
return request<TrashedDocument[]>('/api/v1/documents/trash');
|
return request<TrashedDocument[]>('/api/v1/documents/trash');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getPushKey(): Promise<{ enabled: boolean; publicKey?: string }> {
|
||||||
|
return request('/api/v1/push/key');
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPushDevices(): Promise<PushDevice[]> {
|
||||||
|
const res = await request<{ devices: PushDevice[] }>('/api/v1/push/devices');
|
||||||
|
return res.devices || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async subscribePush(sub: PushSubscriptionJSON, device: string): Promise<void> {
|
||||||
|
await request('/api/v1/push/subscribe', { method: 'POST', body: JSON.stringify({ ...sub, device }) });
|
||||||
|
},
|
||||||
|
|
||||||
|
async unsubscribePush(endpoint: string): Promise<void> {
|
||||||
|
await request('/api/v1/push/unsubscribe', { method: 'POST', body: JSON.stringify({ endpoint }) });
|
||||||
|
},
|
||||||
|
|
||||||
|
async testPush(): Promise<{ devices: number }> {
|
||||||
|
return request('/api/v1/push/test', { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Days of the own journal (newest first) with their notes. */
|
||||||
|
async getJournal(): Promise<JournalDay[]> {
|
||||||
|
const res = await request<{ days: JournalDay[] }>('/api/v1/journal');
|
||||||
|
return res.days || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The note of a journal day (YYYY-MM-DD); created on first use. */
|
||||||
|
async openJournalDay(date: string): Promise<Document> {
|
||||||
|
return request<Document>(`/api/v1/journal/${date}`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
/** Creates a note from a bullet's children; the editor then replaces the branch by `link` itself. */
|
/** Creates a note from a bullet's children; the editor then replaces the branch by `link` itself. */
|
||||||
async extractBranch(docId: string, blockId: string, clientId: string): Promise<{ document: Document; link: string }> {
|
async extractBranch(docId: string, blockId: string, clientId: string): Promise<{ document: Document; link: string }> {
|
||||||
return request<{ document: Document; link: string }>(`/api/v1/documents/${docId}/extract`, {
|
return request<{ document: Document; link: string }>(`/api/v1/documents/${docId}/extract`, {
|
||||||
@@ -960,6 +994,13 @@ export const api = {
|
|||||||
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
await request(`/api/v1/lists/${listId}/items/${itemId}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async moveListItem(listId: string, itemId: string, targetListId: string): Promise<ListItem> {
|
||||||
|
return request<ListItem>(`/api/v1/lists/${listId}/items/${itemId}/move`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ targetListId }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async clearCompletedItems(listId: string): Promise<{ removed: number }> {
|
async clearCompletedItems(listId: string): Promise<{ removed: number }> {
|
||||||
return request<{ removed: number }>(`/api/v1/lists/${listId}/clear-completed`, {
|
return request<{ removed: number }>(`/api/v1/lists/${listId}/clear-completed`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1264,6 +1305,10 @@ export const api = {
|
|||||||
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups', { method: 'POST' }));
|
return ensureArray(await request<BackupFile[]>('/api/v1/admin/backups', { method: 'POST' }));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async adminDeleteBackup(name: string): Promise<BackupFile[]> {
|
||||||
|
return ensureArray(await request<BackupFile[]>(`/api/v1/admin/backups/${encodeURIComponent(name)}`, { method: 'DELETE' }));
|
||||||
|
},
|
||||||
|
|
||||||
async adminDownloadBackup(name: string): Promise<Blob> {
|
async adminDownloadBackup(name: string): Promise<Blob> {
|
||||||
return downloadBlob(`/api/v1/admin/backups/${encodeURIComponent(name)}`);
|
return downloadBlob(`/api/v1/admin/backups/${encodeURIComponent(name)}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Journal helpers: dates are local calendar days as YYYY-MM-DD (the server stores them as given).
|
||||||
|
|
||||||
|
export const JOURNAL_ENABLED_KEY = 'mindsift_journal_enabled';
|
||||||
|
|
||||||
|
export function isJournalEnabled(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(JOURNAL_ENABLED_KEY) !== 'false';
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setJournalEnabled(on: boolean) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(JOURNAL_ENABLED_KEY, on ? 'true' : 'false');
|
||||||
|
} catch {
|
||||||
|
// storage blocked: the setting simply does not stick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toISODate(d: Date): string {
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todayISO(): string {
|
||||||
|
return toISODate(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseISODate(date: string): Date {
|
||||||
|
const [y, m, d] = date.split('-').map(Number);
|
||||||
|
return new Date(y, (m || 1) - 1, d || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MONTHS = [
|
||||||
|
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||||
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** "Fr, 30. Okt. 2026" for compact labels */
|
||||||
|
export function shortDateLabel(date: string): string {
|
||||||
|
return parseISODate(date).toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weeks of a month for a Monday-first grid; days outside the month are null. */
|
||||||
|
export function monthGrid(year: number, month: number): (string | null)[][] {
|
||||||
|
const first = new Date(year, month, 1);
|
||||||
|
const offset = (first.getDay() + 6) % 7; // Monday = 0
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const cells: (string | null)[] = Array(offset).fill(null);
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) cells.push(toISODate(new Date(year, month, d)));
|
||||||
|
while (cells.length % 7 !== 0) cells.push(null);
|
||||||
|
const weeks: (string | null)[][] = [];
|
||||||
|
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
|
||||||
|
return weeks;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workspace state for this browser tab: is the journal open, which regular note was open last
|
||||||
|
const WORKSPACE_SESSION_KEY = 'mindsift_workspace_view';
|
||||||
|
|
||||||
|
export function readWorkspaceSession(): { journal: boolean; docId: string | null } {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(sessionStorage.getItem(WORKSPACE_SESSION_KEY) || '{}');
|
||||||
|
return { journal: raw.journal === true, docId: typeof raw.docId === 'string' ? raw.docId : null };
|
||||||
|
} catch {
|
||||||
|
return { journal: false, docId: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeWorkspaceSession(state: { journal: boolean; docId: string | null }) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(WORKSPACE_SESSION_KEY, JSON.stringify(state));
|
||||||
|
} catch {
|
||||||
|
// storage blocked: the view simply starts fresh
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,17 @@ export function linkCardUrl(content: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Replaces the address by a named link, which shows no preview. */
|
/** Replaces the address by a named link, which shows no preview. */
|
||||||
|
/** "example.com/pfad" for a short link marker (no scheme, no www.) */
|
||||||
|
export function linkHost(url: string): string {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
const path = u.pathname === '/' ? '' : u.pathname;
|
||||||
|
return u.host.replace(/^www\./, '') + path;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function hidePreviewContent(content: string, url: string, title: string): string {
|
export function hidePreviewContent(content: string, url: string, title: string): string {
|
||||||
const label = (title || new URL(url).hostname).replace(/[[\]]/g, '').trim() || url;
|
const label = (title || new URL(url).hostname).replace(/[[\]]/g, '').trim() || url;
|
||||||
return content.replace(url, `[${label}](${url})`);
|
return content.replace(url, `[${label}](${url})`);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { safeHref } from './safeUrl';
|
import { safeHref } from './safeUrl';
|
||||||
|
import { parseWikiLink, wikiLinkText } from './wikiLink';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses inline markdown:
|
* Parses inline markdown:
|
||||||
@@ -9,9 +10,11 @@ import { safeHref } from './safeUrl';
|
|||||||
* - `code`
|
* - `code`
|
||||||
* - [label](url)
|
* - [label](url)
|
||||||
* - #hashtag (only when `options.onTagClick` is given), rendered inline as a clickable chip
|
* - #hashtag (only when `options.onTagClick` is given), rendered inline as a clickable chip
|
||||||
|
* - [[wiki link]] shown by its label (clickable with `options.onWikiLinkClick`)
|
||||||
*/
|
*/
|
||||||
export interface InlineMarkdownOptions {
|
export interface InlineMarkdownOptions {
|
||||||
onTagClick?: (tag: string) => void;
|
onTagClick?: (tag: string) => void;
|
||||||
|
onWikiLinkClick?: (title: string, blockId?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderInlineTokens(text: string, options?: InlineMarkdownOptions): React.ReactNode[] {
|
function renderInlineTokens(text: string, options?: InlineMarkdownOptions): React.ReactNode[] {
|
||||||
@@ -19,7 +22,7 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
|||||||
|
|
||||||
const tagAlt = options?.onTagClick ? '|#[a-zA-Z0-9äöüÄÖÜß_-]+' : '';
|
const tagAlt = options?.onTagClick ? '|#[a-zA-Z0-9äöüÄÖÜß_-]+' : '';
|
||||||
const regex = new RegExp(
|
const regex = new RegExp(
|
||||||
'(`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + tagAlt + ')',
|
'(\\[\\[[^\\]]+\\]\\]|https?:\\/\\/[^\\s<>()\\[\\]]+[^\\s<>()\\[\\].,;:!?"\']|`[^`]+`|\\*\\*[^*]+\\*\\*|__[^_]+__|~~[^~]+~~|\\*[^*]+\\*|_[^_]+_|\\[([^\\]]+)\\]\\(([^)]+)\\)' + tagAlt + ')',
|
||||||
'g'
|
'g'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -34,7 +37,49 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
|||||||
const token = match[0];
|
const token = match[0];
|
||||||
const key = `${match.index}-${token}`;
|
const key = `${match.index}-${token}`;
|
||||||
|
|
||||||
if (token.startsWith('#')) {
|
if (/^https?:\/\//.test(token)) {
|
||||||
|
const href = safeHref(token);
|
||||||
|
elements.push(
|
||||||
|
href ? (
|
||||||
|
<a
|
||||||
|
key={key}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-accent underline underline-offset-2 decoration-accent/40 hover:decoration-accent break-all"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{token}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
token
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (token.startsWith('[[') && token.endsWith(']]')) {
|
||||||
|
const link = parseWikiLink(token.slice(2, -2));
|
||||||
|
const onClick = options?.onWikiLinkClick;
|
||||||
|
elements.push(
|
||||||
|
onClick ? (
|
||||||
|
<span
|
||||||
|
key={key}
|
||||||
|
role="button"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick(link.title, link.blockId);
|
||||||
|
}}
|
||||||
|
className="text-status-info underline decoration-status-info/40 underline-offset-2 hover:decoration-status-info cursor-pointer"
|
||||||
|
title={link.blockId ? `Zum Punkt in „${link.title}“` : `Notiz öffnen: ${link.title}`}
|
||||||
|
>
|
||||||
|
{wikiLinkText(link)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span key={key} className="text-status-info">
|
||||||
|
{wikiLinkText(link)}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else if (token.startsWith('#')) {
|
||||||
const prevChar = match.index > 0 ? text[match.index - 1] : '';
|
const prevChar = match.index > 0 ? text[match.index - 1] : '';
|
||||||
if (prevChar && !/[\s(]/.test(prevChar)) {
|
if (prevChar && !/[\s(]/.test(prevChar)) {
|
||||||
// e.g. an URL fragment like https://x/#anchor: keep as plain text
|
// e.g. an URL fragment like https://x/#anchor: keep as plain text
|
||||||
@@ -59,7 +104,7 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
|||||||
}
|
}
|
||||||
} else if (token.startsWith('`') && token.endsWith('`')) {
|
} else if (token.startsWith('`') && token.endsWith('`')) {
|
||||||
elements.push(
|
elements.push(
|
||||||
<code key={key} className="px-1.5 py-0.5 rounded bg-panel-elevated text-accent font-mono text-[0.9em] border border-border/50">
|
<code key={key} className="px-1.5 py-0.5 rounded bg-main/[0.12] text-main font-mono text-[0.9em]">
|
||||||
{token.slice(1, -1)}
|
{token.slice(1, -1)}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
@@ -69,7 +114,7 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
|||||||
) {
|
) {
|
||||||
elements.push(
|
elements.push(
|
||||||
<strong key={key} className="font-semibold text-main">
|
<strong key={key} className="font-semibold text-main">
|
||||||
{token.slice(2, -2)}
|
{renderInlineTokens(token.slice(2, -2), options)}
|
||||||
</strong>
|
</strong>
|
||||||
);
|
);
|
||||||
} else if (
|
} else if (
|
||||||
@@ -78,13 +123,13 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
|||||||
) {
|
) {
|
||||||
elements.push(
|
elements.push(
|
||||||
<em key={key} className="italic text-main/90">
|
<em key={key} className="italic text-main/90">
|
||||||
{token.slice(1, -1)}
|
{renderInlineTokens(token.slice(1, -1), options)}
|
||||||
</em>
|
</em>
|
||||||
);
|
);
|
||||||
} else if (token.startsWith('~~') && token.endsWith('~~')) {
|
} else if (token.startsWith('~~') && token.endsWith('~~')) {
|
||||||
elements.push(
|
elements.push(
|
||||||
<del key={key} className="line-through text-muted/80">
|
<del key={key} className="line-through text-muted/80">
|
||||||
{token.slice(2, -2)}
|
{renderInlineTokens(token.slice(2, -2), options)}
|
||||||
</del>
|
</del>
|
||||||
);
|
);
|
||||||
} else if (token.startsWith('[') && match[2] && match[3] && safeHref(match[3])) {
|
} else if (token.startsWith('[') && match[2] && match[3] && safeHref(match[3])) {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import type { DocumentBlock } from './types';
|
||||||
|
|
||||||
|
// Numbered lists in the outliner: consecutive 'numbered' siblings count 1, 2, 3 …; a numbered list
|
||||||
|
// inside another one uses a, b, c, the next level i, ii, iii, then numbers again.
|
||||||
|
|
||||||
|
function alpha(n: number): string {
|
||||||
|
let s = '';
|
||||||
|
while (n > 0) {
|
||||||
|
n--;
|
||||||
|
s = String.fromCharCode(97 + (n % 26)) + s;
|
||||||
|
n = Math.floor(n / 26);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROMAN: [number, string][] = [
|
||||||
|
[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'],
|
||||||
|
[50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function roman(n: number): string {
|
||||||
|
let s = '';
|
||||||
|
for (const [v, r] of ROMAN) {
|
||||||
|
while (n >= v) {
|
||||||
|
s += r;
|
||||||
|
n -= v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listLabel(n: number, depth: number): string {
|
||||||
|
switch (depth % 3) {
|
||||||
|
case 1:
|
||||||
|
return `${alpha(n)}.`;
|
||||||
|
case 2:
|
||||||
|
return `${roman(n)}.`;
|
||||||
|
default:
|
||||||
|
return `${n}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label ("1.", "b.", "iii.") for every numbered block, in document order. */
|
||||||
|
export function numberedLabels(blocks: DocumentBlock[]): Map<string, string> {
|
||||||
|
const labels = new Map<string, string>();
|
||||||
|
const runs = new Map<number, number>(); // level -> count of the current run
|
||||||
|
const chain: DocumentBlock[] = []; // last block seen per level = ancestors of the next one
|
||||||
|
for (const b of blocks) {
|
||||||
|
const level = Math.max(0, b.level || 0);
|
||||||
|
for (const l of Array.from(runs.keys())) {
|
||||||
|
if (l > level) runs.delete(l);
|
||||||
|
}
|
||||||
|
chain.length = Math.min(chain.length, level);
|
||||||
|
if (b.type === 'numbered') {
|
||||||
|
const n = (runs.get(level) ?? 0) + 1;
|
||||||
|
runs.set(level, n);
|
||||||
|
const depth = chain.filter((a) => a && a.type === 'numbered').length;
|
||||||
|
labels.set(b.id, listLabel(n, depth));
|
||||||
|
} else {
|
||||||
|
runs.delete(level);
|
||||||
|
}
|
||||||
|
chain[level] = b;
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swaps a bullet (with its children) with the previous (-1) or next (+1) bullet on the same level
|
||||||
|
// under the same parent. Returns the same array when there is no such sibling.
|
||||||
|
export function moveSibling<T extends Pick<DocumentBlock, 'id' | 'level'>>(blocks: T[], blockId: string, direction: -1 | 1): T[] {
|
||||||
|
const lvl = (b: T) => Math.max(0, b.level || 0);
|
||||||
|
const subtreeEnd = (start: number) => {
|
||||||
|
let end = start + 1;
|
||||||
|
while (end < blocks.length && lvl(blocks[end]) > lvl(blocks[start])) end++;
|
||||||
|
return end;
|
||||||
|
};
|
||||||
|
const idx = blocks.findIndex((b) => b.id === blockId);
|
||||||
|
if (idx === -1) return blocks;
|
||||||
|
const level = lvl(blocks[idx]);
|
||||||
|
const end = subtreeEnd(idx);
|
||||||
|
if (direction === -1) {
|
||||||
|
for (let i = idx - 1; i >= 0; i--) {
|
||||||
|
if (lvl(blocks[i]) < level) return blocks; // reached the parent: already the first child
|
||||||
|
if (lvl(blocks[i]) === level) {
|
||||||
|
return [...blocks.slice(0, i), ...blocks.slice(idx, end), ...blocks.slice(i, idx), ...blocks.slice(end)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
if (end >= blocks.length || lvl(blocks[end]) !== level) return blocks; // already the last child
|
||||||
|
const nextEnd = subtreeEnd(end);
|
||||||
|
return [...blocks.slice(0, idx), ...blocks.slice(end, nextEnd), ...blocks.slice(idx, end), ...blocks.slice(nextEnd)];
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { BlockType } from './types';
|
||||||
|
|
||||||
|
// Turns pasted (Markdown) text into outline items: headings, todos, bullets, numbered items, dividers,
|
||||||
|
// fenced code and paragraphs. A blank line ends a paragraph; indentation becomes the nesting level.
|
||||||
|
// Content below a heading is nested under it (H2 below H1 one level deeper), so sections fold and zoom.
|
||||||
|
|
||||||
|
export interface PastedItem {
|
||||||
|
type: BlockType;
|
||||||
|
checked?: boolean;
|
||||||
|
content: string;
|
||||||
|
indent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function leadingWidth(line: string): { width: number; rest: string } {
|
||||||
|
let width = 0;
|
||||||
|
let i = 0;
|
||||||
|
while (i < line.length && (line[i] === ' ' || line[i] === '\t')) {
|
||||||
|
width += line[i] === '\t' ? 4 : 1;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return { width, rest: line.slice(i) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePastedLines(rawLines: string[]): PastedItem[] {
|
||||||
|
// Indentation steps differ (2, 3 or 4 spaces, tabs): the distinct widths in use are the levels
|
||||||
|
const widths = Array.from(
|
||||||
|
new Set(rawLines.filter((l) => l.trim() !== '').map((l) => leadingWidth(l).width))
|
||||||
|
).sort((a, b) => a - b);
|
||||||
|
const levelOf = (w: number) => Math.max(0, widths.indexOf(w));
|
||||||
|
|
||||||
|
const items: PastedItem[] = [];
|
||||||
|
let current: PastedItem | null = null;
|
||||||
|
let inCode = false;
|
||||||
|
let afterHeading = false;
|
||||||
|
const headings: { rank: number; level: number }[] = []; // open headings, innermost last
|
||||||
|
// A list right after a paragraph ("Folgende Punkte:") nests under it: level of that paragraph,
|
||||||
|
// and the extra indent of the current list run
|
||||||
|
let paragraphLevel: number | null = null;
|
||||||
|
let listBoost = 0;
|
||||||
|
let inList = false;
|
||||||
|
const sectionLevel = () => (headings.length ? headings[headings.length - 1].level + 1 : 0);
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (current) {
|
||||||
|
current.content = current.content.trimEnd();
|
||||||
|
items.push(current);
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of rawLines) {
|
||||||
|
if (inCode) {
|
||||||
|
if (current) (current as PastedItem).content += '\n' + rawLine;
|
||||||
|
if (rawLine.trim().startsWith('```')) {
|
||||||
|
inCode = false;
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawLine.trim().startsWith('```')) {
|
||||||
|
inCode = true;
|
||||||
|
flush();
|
||||||
|
current = { type: 'text', content: rawLine, indent: sectionLevel() };
|
||||||
|
afterHeading = false;
|
||||||
|
paragraphLevel = null;
|
||||||
|
inList = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width, rest } = leadingWidth(rawLine);
|
||||||
|
const indent = sectionLevel() + levelOf(width);
|
||||||
|
const line = rest.trimEnd();
|
||||||
|
|
||||||
|
// A blank line ends the paragraph or list item
|
||||||
|
if (line === '') {
|
||||||
|
flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = line.match(/^(#{1,3})\s+(.*)$/);
|
||||||
|
if (heading) {
|
||||||
|
flush();
|
||||||
|
afterHeading = true;
|
||||||
|
paragraphLevel = null;
|
||||||
|
inList = false;
|
||||||
|
const rank = heading[1].length;
|
||||||
|
while (headings.length && headings[headings.length - 1].rank >= rank) headings.pop();
|
||||||
|
const level = sectionLevel();
|
||||||
|
headings.push({ rank, level });
|
||||||
|
items.push({ type: (['h1', 'h2', 'h3'] as const)[rank - 1], content: heading[2], indent: level });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(line)) {
|
||||||
|
flush();
|
||||||
|
afterHeading = false;
|
||||||
|
headings.length = 0; // a divider closes the sections
|
||||||
|
paragraphLevel = null;
|
||||||
|
inList = false;
|
||||||
|
items.push({ type: 'divider', content: '---', indent: 0 });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const todo = line.match(/^[-*+]\s*\[([ xX])\]\s*(.*)$/);
|
||||||
|
const bullet = line.match(/^[-*+•]\s+(.*)$/);
|
||||||
|
const numbered = line.match(/^\d+[.)]\s+(.*)$/);
|
||||||
|
if (todo || bullet || numbered) {
|
||||||
|
flush();
|
||||||
|
afterHeading = false;
|
||||||
|
if (!inList) {
|
||||||
|
inList = true;
|
||||||
|
listBoost = paragraphLevel !== null && indent <= paragraphLevel ? paragraphLevel + 1 - indent : 0;
|
||||||
|
}
|
||||||
|
const level = indent + listBoost;
|
||||||
|
current = todo
|
||||||
|
? { type: 'todo', checked: todo[1].toLowerCase() === 'x', content: todo[2], indent: level }
|
||||||
|
: numbered
|
||||||
|
? { type: 'numbered', content: numbered[1], indent: level }
|
||||||
|
: { type: 'text', content: bullet![1], indent: level };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain text: continues the paragraph or list item above (a wrapped line), else starts a paragraph
|
||||||
|
if (current && !afterHeading) {
|
||||||
|
(current as PastedItem).content += '\n' + line;
|
||||||
|
} else {
|
||||||
|
flush();
|
||||||
|
current = { type: 'text', content: line, indent };
|
||||||
|
paragraphLevel = indent;
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
afterHeading = false;
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return items;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
// Web push in this browser: the service worker shows the notifications, the server sends them over the
|
||||||
|
// browser's push service (VAPID). iPhones and iPads need MindSift added to the home screen first.
|
||||||
|
|
||||||
|
export function pushSupported(): boolean {
|
||||||
|
return typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** iOS/iPadOS outside the installed home screen app: push is not offered there */
|
||||||
|
export function needsHomeScreenInstall(): boolean {
|
||||||
|
if (typeof navigator === 'undefined') return false;
|
||||||
|
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
const standalone =
|
||||||
|
window.matchMedia?.('(display-mode: standalone)').matches || (navigator as Navigator & { standalone?: boolean }).standalone === true;
|
||||||
|
return ios && !standalone;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deviceName(ua = typeof navigator !== 'undefined' ? navigator.userAgent : ''): string {
|
||||||
|
const browser = /Edg\//.test(ua) ? 'Edge' : /Firefox\//.test(ua) ? 'Firefox' : /Chrome\//.test(ua) ? 'Chrome' : /Safari\//.test(ua) ? 'Safari' : 'Browser';
|
||||||
|
const os = /Android/.test(ua) ? 'Android' : /iPhone|iPad|iPod/.test(ua) ? 'iOS' : /Windows/.test(ua) ? 'Windows' : /Mac OS X/.test(ua) ? 'macOS' : /Linux/.test(ua) ? 'Linux' : '';
|
||||||
|
return os ? `${browser} auf ${os}` : browser;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||||
|
const padded = (base64 + '='.repeat((4 - (base64.length % 4)) % 4)).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const raw = atob(padded);
|
||||||
|
const out = new Uint8Array(raw.length);
|
||||||
|
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function registration(): Promise<ServiceWorkerRegistration> {
|
||||||
|
return navigator.serviceWorker.ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The subscription of this browser, if push is switched on here. */
|
||||||
|
export async function currentPushSubscription(): Promise<PushSubscription | null> {
|
||||||
|
if (!pushSupported()) return null;
|
||||||
|
try {
|
||||||
|
return await (await registration()).pushManager.getSubscription();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enablePush(): Promise<void> {
|
||||||
|
if (!pushSupported()) throw new Error('Dieser Browser unterstützt keine Push-Benachrichtigungen');
|
||||||
|
if (needsHomeScreenInstall()) throw new Error('Auf iPhone und iPad zuerst MindSift „Zum Home-Bildschirm“ hinzufügen und von dort öffnen');
|
||||||
|
const key = await api.getPushKey();
|
||||||
|
if (!key.enabled || !key.publicKey) throw new Error('Push ist auf diesem Server nicht verfügbar');
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') throw new Error('Benachrichtigungen wurden im Browser nicht erlaubt');
|
||||||
|
const reg = await registration();
|
||||||
|
let sub = await reg.pushManager.getSubscription();
|
||||||
|
if (!sub) {
|
||||||
|
try {
|
||||||
|
sub = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(key.publicKey) as BufferSource,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// e.g. "Registration failed – permission denied": blocked for the site or no push service reachable
|
||||||
|
throw new Error('Der Browser hat die Anmeldung beim Push-Dienst abgelehnt (Berechtigung blockiert oder Push-Dienst nicht erreichbar)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await api.subscribePush(sub.toJSON(), deviceName());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disablePush(): Promise<void> {
|
||||||
|
const sub = await currentPushSubscription();
|
||||||
|
if (!sub) return;
|
||||||
|
const endpoint = sub.endpoint;
|
||||||
|
await sub.unsubscribe().catch(() => false);
|
||||||
|
await api.unsubscribePush(endpoint).catch(() => {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
export interface SharedData {
|
||||||
|
id?: number;
|
||||||
|
title?: string;
|
||||||
|
text?: string;
|
||||||
|
url?: string;
|
||||||
|
fileName?: string;
|
||||||
|
hasMedia?: boolean;
|
||||||
|
createdAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHARE_DB_NAME = 'mindsift-share-target';
|
||||||
|
const SHARE_STORE_NAME = 'shared_items';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines title, text, url and media note into a single thought string.
|
||||||
|
* Ensures URLs are not duplicated if already present in the shared text or title.
|
||||||
|
*/
|
||||||
|
export function formatSharedThought(share: SharedData): string {
|
||||||
|
const title = (share.title || '').trim();
|
||||||
|
const text = (share.text || '').trim();
|
||||||
|
const url = (share.url || '').trim();
|
||||||
|
const mediaNote = '(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)';
|
||||||
|
|
||||||
|
let content = '';
|
||||||
|
|
||||||
|
// Check if url is already contained in text or title
|
||||||
|
const urlInText = url !== '' && text.includes(url);
|
||||||
|
const urlInTitle = url !== '' && title.includes(url);
|
||||||
|
|
||||||
|
// Combine title and text
|
||||||
|
if (title && text) {
|
||||||
|
if (text === title) {
|
||||||
|
content = text;
|
||||||
|
} else if (text.startsWith(title)) {
|
||||||
|
content = text;
|
||||||
|
} else {
|
||||||
|
content = `${title}\n${text}`;
|
||||||
|
}
|
||||||
|
} else if (title) {
|
||||||
|
content = title;
|
||||||
|
} else if (text) {
|
||||||
|
content = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append URL if not already included in text or title
|
||||||
|
if (url && !urlInText && !urlInTitle) {
|
||||||
|
if (content) {
|
||||||
|
content = `${content}\n${url}`;
|
||||||
|
} else {
|
||||||
|
content = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media note handling
|
||||||
|
if (share.hasMedia || share.fileName) {
|
||||||
|
if (content) {
|
||||||
|
content = `${content}\n${mediaNote}`;
|
||||||
|
} else if (share.fileName) {
|
||||||
|
content = `[${share.fileName}] ${mediaNote}`;
|
||||||
|
} else {
|
||||||
|
content = mediaNote;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves and removes all pending shared items stored in IndexedDB by the Service Worker.
|
||||||
|
*/
|
||||||
|
export async function consumePendingShares(): Promise<SharedData[]> {
|
||||||
|
if (typeof indexedDB === 'undefined') return [];
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
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 = () => {
|
||||||
|
const db = req.result;
|
||||||
|
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
|
||||||
|
db.close();
|
||||||
|
return resolve([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx = db.transaction(SHARE_STORE_NAME, 'readwrite');
|
||||||
|
const store = tx.objectStore(SHARE_STORE_NAME);
|
||||||
|
const getAllReq = store.getAll();
|
||||||
|
|
||||||
|
getAllReq.onsuccess = () => {
|
||||||
|
const items: SharedData[] = getAllReq.result || [];
|
||||||
|
store.clear();
|
||||||
|
tx.oncomplete = () => {
|
||||||
|
db.close();
|
||||||
|
resolve(items);
|
||||||
|
};
|
||||||
|
tx.onerror = () => {
|
||||||
|
db.close();
|
||||||
|
resolve(items);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
getAllReq.onerror = () => {
|
||||||
|
db.close();
|
||||||
|
resolve([]);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
req.onerror = () => resolve([]);
|
||||||
|
} catch {
|
||||||
|
resolve([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+233
-24
@@ -15,8 +15,28 @@ export interface ThemeOption {
|
|||||||
previewText: string;
|
previewText: string;
|
||||||
isRounded: boolean;
|
isRounded: boolean;
|
||||||
isCustom?: boolean;
|
isCustom?: boolean;
|
||||||
|
/** Built-in themes come in families with a light and a dark variant */
|
||||||
|
family?: string;
|
||||||
|
mode?: 'light' | 'dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ThemeFamily {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
subtitle: string;
|
||||||
|
light: ThemeId;
|
||||||
|
dark: ThemeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Left light, right dark – the settings and the quick switcher show them in this order
|
||||||
|
export const THEME_FAMILIES: ThemeFamily[] = [
|
||||||
|
{ id: 'classic', name: 'Klassik', subtitle: 'Neutral mit Blau, eckig', light: 'light', dark: 'dark' },
|
||||||
|
{ id: 'fjord', name: 'Fjord', subtitle: 'Kühles Papier, Gletscherblau', light: 'fjord', dark: 'fjord-night' },
|
||||||
|
{ id: 'terracotta', name: 'Terrakotta', subtitle: 'Warmes Papier, gebrannte Erde', light: 'terracotta', dark: 'terracotta-night' },
|
||||||
|
{ id: 'polarized', name: 'Polarized', subtitle: 'Petrol und Türkis, eckig', light: 'polarized-light', dark: 'polarized' },
|
||||||
|
{ id: 'suri', name: 'Suri', subtitle: 'Lavendel, Pflaume, Orchidee', light: 'suri-light', dark: 'suri' },
|
||||||
|
];
|
||||||
|
|
||||||
export const THEME_STORAGE_KEY = 'mindsift_active_theme';
|
export const THEME_STORAGE_KEY = 'mindsift_active_theme';
|
||||||
// CSS of the active custom theme, so it applies on boot before the API answers (no flash)
|
// CSS of the active custom theme, so it applies on boot before the API answers (no flash)
|
||||||
const CUSTOM_CSS_CACHE_KEY = 'mindsift_custom_theme_css';
|
const CUSTOM_CSS_CACHE_KEY = 'mindsift_custom_theme_css';
|
||||||
@@ -47,25 +67,25 @@ export const AVAILABLE_THEMES: ThemeOption[] = [
|
|||||||
isRounded: false,
|
isRounded: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'high-contrast',
|
id: 'terracotta',
|
||||||
name: 'High Contrast (Scharf)',
|
name: 'Terrakotta (Warmes Papier)',
|
||||||
subtitle: 'Tiefschwarz (#000) & weiße Konturen',
|
subtitle: 'Cremepapier, Tinte & gebrannte Terrakotta (12px)',
|
||||||
file: 'src/themes/theme-high-contrast.css',
|
file: 'src/themes/theme-terracotta.css',
|
||||||
previewBg: '#000000',
|
previewBg: '#efe6d8',
|
||||||
previewPanel: '#09090b',
|
previewPanel: '#fbf6ee',
|
||||||
previewAccent: '#38bdf8',
|
previewAccent: '#c2410c',
|
||||||
previewText: '#ffffff',
|
previewText: '#2b2118',
|
||||||
isRounded: false,
|
isRounded: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'soft',
|
id: 'terracotta-night',
|
||||||
name: 'Soft Slate (Abgerundet)',
|
name: 'Terrakotta Nacht (Glut)',
|
||||||
subtitle: 'Schiefer-Blau mit weichen Radien (10px)',
|
subtitle: 'Espresso, Creme & glühende Terrakotta (12px)',
|
||||||
file: 'src/themes/theme-soft.css',
|
file: 'src/themes/theme-terracotta-night.css',
|
||||||
previewBg: '#0f172a',
|
previewBg: '#17110d',
|
||||||
previewPanel: '#1e293b',
|
previewPanel: '#211913',
|
||||||
previewAccent: '#3b82f6',
|
previewAccent: '#e8794a',
|
||||||
previewText: '#f8fafc',
|
previewText: '#f3e8da',
|
||||||
isRounded: true,
|
isRounded: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -90,7 +110,177 @@ export const AVAILABLE_THEMES: ThemeOption[] = [
|
|||||||
previewText: '#fbf7ff',
|
previewText: '#fbf7ff',
|
||||||
isRounded: true,
|
isRounded: true,
|
||||||
},
|
},
|
||||||
];
|
{
|
||||||
|
id: 'fjord',
|
||||||
|
name: 'Fjord (Gletscherblau)',
|
||||||
|
subtitle: 'Kühles Papier, Tintenblau & Fjordblau (12px)',
|
||||||
|
file: 'src/themes/theme-fjord.css',
|
||||||
|
previewBg: '#e9eef5',
|
||||||
|
previewPanel: '#f7f9fc',
|
||||||
|
previewAccent: '#2b6cb0',
|
||||||
|
previewText: '#14202e',
|
||||||
|
isRounded: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fjord-night',
|
||||||
|
name: 'Fjord Nacht (Tiefsee)',
|
||||||
|
subtitle: 'Nachtblau, Mondlicht & leuchtendes Gletscherblau (12px)',
|
||||||
|
file: 'src/themes/theme-fjord-night.css',
|
||||||
|
previewBg: '#0e1621',
|
||||||
|
previewPanel: '#152131',
|
||||||
|
previewAccent: '#5b9bdf',
|
||||||
|
previewText: '#e6eef8',
|
||||||
|
isRounded: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'polarized-light',
|
||||||
|
name: 'Polarized Hell (Lagune)',
|
||||||
|
subtitle: 'Kreidiges Mintweiß mit tiefem Petrol (0px)',
|
||||||
|
file: 'src/themes/theme-polarized-light.css',
|
||||||
|
previewBg: '#e6f0ef',
|
||||||
|
previewPanel: '#f6fbfa',
|
||||||
|
previewAccent: '#0f766e',
|
||||||
|
previewText: '#0f2422',
|
||||||
|
isRounded: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'suri-light',
|
||||||
|
name: 'Suri Hell (Lavendel)',
|
||||||
|
subtitle: 'Lavendelpapier, Pflaume & Orchidee (10px)',
|
||||||
|
file: 'src/themes/theme-suri-light.css',
|
||||||
|
previewBg: '#efe9f4',
|
||||||
|
previewPanel: '#fbf8fd',
|
||||||
|
previewAccent: '#8e44c9',
|
||||||
|
previewText: '#23182e',
|
||||||
|
isRounded: true,
|
||||||
|
},
|
||||||
|
].map((t) => {
|
||||||
|
const family = THEME_FAMILIES.find((f) => f.light === t.id || f.dark === t.id);
|
||||||
|
return family ? { ...t, family: family.id, mode: family.light === t.id ? ('light' as const) : ('dark' as const) } : t;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The same family in the other mode (light ↔ dark); custom themes have none */
|
||||||
|
export function themeCounterpart(id: ThemeId): ThemeId | null {
|
||||||
|
const f = THEME_FAMILIES.find((x) => x.light === id || x.dark === id);
|
||||||
|
if (!f) return null;
|
||||||
|
return f.light === id ? f.dark : f.light;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light/dark follows the system inside the chosen family (per device)
|
||||||
|
const FOLLOW_SYSTEM_KEY = 'mindsift_theme_follow_system';
|
||||||
|
|
||||||
|
export function getFollowSystem(): boolean {
|
||||||
|
return load(FOLLOW_SYSTEM_KEY) === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setFollowSystem(on: boolean): void {
|
||||||
|
store(FOLLOW_SYSTEM_KEY, on ? '1' : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemPrefersDark(): boolean {
|
||||||
|
return typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** With "follow system": the family's variant that matches the system; otherwise the theme itself */
|
||||||
|
export function resolveTheme(id: ThemeId): ThemeId {
|
||||||
|
if (!getFollowSystem()) return id;
|
||||||
|
const f = THEME_FAMILIES.find((x) => x.light === id || x.dark === id);
|
||||||
|
if (!f) return id;
|
||||||
|
return systemPrefersDark() ? f.dark : f.light;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switches the theme when the system changes between light and dark; returns the unsubscribe */
|
||||||
|
export function watchSystemScheme(onChange: (id: ThemeId) => void): () => void {
|
||||||
|
if (typeof window === 'undefined' || !window.matchMedia) return () => {};
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handler = () => {
|
||||||
|
if (!getFollowSystem()) return;
|
||||||
|
const next = resolveTheme(getActiveTheme());
|
||||||
|
if (next !== getActiveTheme()) {
|
||||||
|
setActiveTheme(next);
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
mq.addEventListener('change', handler);
|
||||||
|
return () => mq.removeEventListener('change', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buttons with or without frames (per device): "off" removes the border of the regular buttons
|
||||||
|
export type ButtonBorders = 'on' | 'off';
|
||||||
|
const BUTTON_BORDERS_KEY = 'mindsift_button_borders';
|
||||||
|
|
||||||
|
export function getButtonBorders(): ButtonBorders {
|
||||||
|
return load(BUTTON_BORDERS_KEY) === 'off' ? 'off' : 'on';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setButtonBorders(value: ButtonBorders): void {
|
||||||
|
store(BUTTON_BORDERS_KEY, value === 'off' ? 'off' : null);
|
||||||
|
applyButtonBorders(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyButtonBorders(value: ButtonBorders): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
if (value === 'off') document.documentElement.setAttribute('data-button-borders', 'off');
|
||||||
|
else document.documentElement.removeAttribute('data-button-borders');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Own accent colour on top of any theme (per device); null = the theme's accent
|
||||||
|
const ACCENT_KEY = 'mindsift_accent';
|
||||||
|
const ACCENT_VARS = ['--accent-primary', '--accent-hover', '--accent-dim', '--accent-border', '--text-on-accent'];
|
||||||
|
|
||||||
|
export function getAccentColor(): string | null {
|
||||||
|
const v = load(ACCENT_KEY);
|
||||||
|
return v && /^#[0-9a-f]{6}$/i.test(v) ? v : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAccentColor(hex: string | null): void {
|
||||||
|
store(ACCENT_KEY, hex);
|
||||||
|
applyAccent(hex);
|
||||||
|
syncThemeColorMeta(); // keeps the loading screen's colours current
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex: string): [number, number, number] {
|
||||||
|
return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)) as [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAccent(hex: string | null): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const style = document.documentElement.style;
|
||||||
|
if (!hex) {
|
||||||
|
ACCENT_VARS.forEach((v) => style.removeProperty(v));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [r, g, b] = hexToRgb(hex);
|
||||||
|
// Hover goes towards the text colour: darker on light themes, lighter on dark ones
|
||||||
|
const dark = document.documentElement.classList.contains('dark');
|
||||||
|
const mix = (c: number) => Math.round(dark ? c + (255 - c) * 0.2 : c * 0.82);
|
||||||
|
const toHex = (c: number) => c.toString(16).padStart(2, '0');
|
||||||
|
style.setProperty('--accent-primary', hex);
|
||||||
|
style.setProperty('--accent-hover', `#${toHex(mix(r))}${toHex(mix(g))}${toHex(mix(b))}`);
|
||||||
|
style.setProperty('--accent-dim', `rgba(${r}, ${g}, ${b}, ${dark ? 0.16 : 0.1})`);
|
||||||
|
style.setProperty('--accent-border', `rgba(${r}, ${g}, ${b}, 0.38)`);
|
||||||
|
style.setProperty('--text-on-accent', 0.2126 * r + 0.7152 * g + 0.0722 * b > 160 ? '#111111' : '#ffffff');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Corners: follow the theme, or force round / square for every theme (per device)
|
||||||
|
export type CornerStyle = 'theme' | 'round' | 'square';
|
||||||
|
const CORNERS_STORAGE_KEY = 'mindsift_corners';
|
||||||
|
|
||||||
|
export function getCornerStyle(): CornerStyle {
|
||||||
|
const v = load(CORNERS_STORAGE_KEY);
|
||||||
|
return v === 'round' || v === 'square' ? v : 'theme';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCornerStyle(style: CornerStyle): void {
|
||||||
|
store(CORNERS_STORAGE_KEY, style === 'theme' ? null : style);
|
||||||
|
applyCorners(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCorners(style: CornerStyle): void {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
if (style === 'theme') document.documentElement.removeAttribute('data-corners');
|
||||||
|
else document.documentElement.setAttribute('data-corners', style);
|
||||||
|
}
|
||||||
|
|
||||||
export const isCustomThemeId = (id: ThemeId) => id.startsWith(CUSTOM_THEME_PREFIX);
|
export const isCustomThemeId = (id: ThemeId) => id.startsWith(CUSTOM_THEME_PREFIX);
|
||||||
export const customThemeId = (t: UserTheme): ThemeId => CUSTOM_THEME_PREFIX + t.id;
|
export const customThemeId = (t: UserTheme): ThemeId => CUSTOM_THEME_PREFIX + t.id;
|
||||||
@@ -152,22 +342,33 @@ function applyToDocument(themeId: ThemeId, customCss: string | null): void {
|
|||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const attr = customCss !== null ? themeAttribute(customCss) : themeId;
|
const attr = customCss !== null ? themeAttribute(customCss) : themeId;
|
||||||
root.setAttribute('data-theme', attr);
|
root.setAttribute('data-theme', attr);
|
||||||
const light = customCss !== null ? isLightColor(readCssVar(customCss, 'bg-app')) : themeId === 'light';
|
const light =
|
||||||
|
customCss !== null
|
||||||
|
? isLightColor(readCssVar(customCss, 'bg-app'))
|
||||||
|
: isLightColor(AVAILABLE_THEMES.find((t) => t.id === themeId)?.previewBg);
|
||||||
root.classList.toggle('dark', !light);
|
root.classList.toggle('dark', !light);
|
||||||
|
applyAccent(getAccentColor());
|
||||||
syncThemeColorMeta();
|
syncThemeColorMeta();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The status bar (theme-color) starts blue for the splash screen; once the app runs it follows the
|
// The status bar (theme-color) starts black (like the Android navigation bar of the installed app,
|
||||||
// active theme's background so it blends with the header.
|
// which only takes the manifest colour); once the app runs it follows the theme's background.
|
||||||
|
// The colours are also kept for the next start: public/boot-theme.js paints the loading screen in them.
|
||||||
function syncThemeColorMeta(): void {
|
function syncThemeColorMeta(): void {
|
||||||
const meta = document.querySelector('meta[name="theme-color"]');
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
if (!meta || typeof requestAnimationFrame === 'undefined') return;
|
if (!meta || typeof requestAnimationFrame === 'undefined') return;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-app').trim();
|
const css = getComputedStyle(document.documentElement);
|
||||||
|
const bg = css.getPropertyValue('--bg-app').trim();
|
||||||
if (bg) meta.setAttribute('content', bg);
|
if (bg) meta.setAttribute('content', bg);
|
||||||
|
const accent = css.getPropertyValue('--accent-primary').trim();
|
||||||
|
const onAccent = css.getPropertyValue('--text-on-accent').trim();
|
||||||
|
if (bg && accent) store(BOOT_COLORS_KEY, JSON.stringify({ bg, accent, onAccent }));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BOOT_COLORS_KEY = 'mindsift_boot_colors';
|
||||||
|
|
||||||
function store(key: string, value: string | null): void {
|
function store(key: string, value: string | null): void {
|
||||||
try {
|
try {
|
||||||
if (value === null) localStorage.removeItem(key);
|
if (value === null) localStorage.removeItem(key);
|
||||||
@@ -185,8 +386,12 @@ function load(key: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Removed themes and their successors
|
||||||
|
const RETIRED_THEMES: Record<string, ThemeId> = { soft: 'terracotta-night', 'high-contrast': 'dark' };
|
||||||
|
|
||||||
export function getActiveTheme(): ThemeId {
|
export function getActiveTheme(): ThemeId {
|
||||||
const saved = load(THEME_STORAGE_KEY);
|
const stored = load(THEME_STORAGE_KEY);
|
||||||
|
const saved = stored && RETIRED_THEMES[stored] ? RETIRED_THEMES[stored] : stored;
|
||||||
if (saved && AVAILABLE_THEMES.some((t) => t.id === saved)) return saved;
|
if (saved && AVAILABLE_THEMES.some((t) => t.id === saved)) return saved;
|
||||||
if (saved && isCustomThemeId(saved) && load(CUSTOM_CSS_CACHE_KEY)) return saved;
|
if (saved && isCustomThemeId(saved) && load(CUSTOM_CSS_CACHE_KEY)) return saved;
|
||||||
return 'dark';
|
return 'dark';
|
||||||
@@ -202,7 +407,11 @@ export function setActiveTheme(themeId: ThemeId, customCss?: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initThemeOnBoot(): ThemeId {
|
export function initThemeOnBoot(): ThemeId {
|
||||||
const active = getActiveTheme();
|
applyCorners(getCornerStyle());
|
||||||
|
applyButtonBorders(getButtonBorders());
|
||||||
|
const saved = getActiveTheme();
|
||||||
|
const active = resolveTheme(saved);
|
||||||
|
if (active !== saved) store(THEME_STORAGE_KEY, active);
|
||||||
applyToDocument(active, isCustomThemeId(active) ? load(CUSTOM_CSS_CACHE_KEY) : null);
|
applyToDocument(active, isCustomThemeId(active) ? load(CUSTOM_CSS_CACHE_KEY) : null);
|
||||||
return active;
|
return active;
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,8 @@ export interface AuthStatus {
|
|||||||
hasImprintContent?: boolean;
|
hasImprintContent?: boolean;
|
||||||
privacyUrl?: string;
|
privacyUrl?: string;
|
||||||
hasPrivacyContent?: boolean;
|
hasPrivacyContent?: boolean;
|
||||||
|
customLinkLabel?: string;
|
||||||
|
customLinkUrl?: string;
|
||||||
};
|
};
|
||||||
// Voice input: own Whisper server, the browser's recognition, or switched off (STT_* environment)
|
// Voice input: own Whisper server, the browser's recognition, or switched off (STT_* environment)
|
||||||
voice?: { mode: VoiceMode; maxSeconds: number };
|
voice?: { mode: VoiceMode; maxSeconds: number };
|
||||||
@@ -49,6 +51,8 @@ export type SharingMode = 'private' | 'organizations' | 'open';
|
|||||||
export interface NotificationChannelRouting {
|
export interface NotificationChannelRouting {
|
||||||
email: boolean;
|
email: boolean;
|
||||||
discord: boolean;
|
discord: boolean;
|
||||||
|
webhook: boolean;
|
||||||
|
push: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationEvents {
|
export interface NotificationEvents {
|
||||||
@@ -63,6 +67,16 @@ export interface DailySummarySettings {
|
|||||||
time: string;
|
time: string;
|
||||||
email: boolean;
|
email: boolean;
|
||||||
discord: boolean;
|
discord: boolean;
|
||||||
|
webhook: boolean;
|
||||||
|
push: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A browser or installed app that receives web push */
|
||||||
|
export interface PushDevice {
|
||||||
|
id: number;
|
||||||
|
endpoint: string;
|
||||||
|
device: string;
|
||||||
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
@@ -259,7 +273,7 @@ export const DATA_SECTIONS: { id: DataSection; label: string }[] = [
|
|||||||
{ id: 'themes', label: 'Themes' },
|
{ id: 'themes', label: 'Themes' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress' | 'image' | 'audio';
|
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'numbered' | 'code' | 'callout' | 'divider' | 'table' | 'progress' | 'image' | 'audio';
|
||||||
|
|
||||||
export interface DocumentBlock {
|
export interface DocumentBlock {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -320,6 +334,13 @@ export interface Backlink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A note in the trash (GET /documents/trash). */
|
/** A note in the trash (GET /documents/trash). */
|
||||||
|
/** A day of the own journal; `empty` while its note has no text yet. */
|
||||||
|
export interface JournalDay {
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
docId: string;
|
||||||
|
empty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrashedDocument {
|
export interface TrashedDocument {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// View state for this browser tab that survives switching between the app's tabs (the views
|
||||||
|
// remount), like the workspace's open note: which list was open, list or Kanban view
|
||||||
|
const PREFIX = 'mindsift_view:';
|
||||||
|
|
||||||
|
export function readViewSession<T extends object>(view: string): Partial<T> {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(sessionStorage.getItem(PREFIX + view) || '{}');
|
||||||
|
return raw && typeof raw === 'object' ? raw : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeViewSession<T extends object>(view: string, state: T) {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(PREFIX + view, JSON.stringify(state));
|
||||||
|
} catch {
|
||||||
|
// storage blocked: the view simply starts fresh
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// Wiki links: [[Titel]], [[Titel|Anzeigetext]] and links to a single bullet [[Titel#^blockId|Anzeigetext]].
|
||||||
|
// A missing bullet just opens the note (links are not updated when a bullet moves to another note).
|
||||||
|
|
||||||
|
export interface WikiLink {
|
||||||
|
title: string;
|
||||||
|
blockId?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses the inside of [[…]]. */
|
||||||
|
export function parseWikiLink(inner: string): WikiLink {
|
||||||
|
const [target, ...rest] = inner.split('|');
|
||||||
|
const label = rest.join('|').trim() || undefined;
|
||||||
|
const hash = target.indexOf('#');
|
||||||
|
const title = (hash === -1 ? target : target.slice(0, hash)).trim();
|
||||||
|
const anchor = hash === -1 ? '' : target.slice(hash + 1).trim();
|
||||||
|
return { title, blockId: anchor.startsWith('^') ? anchor.slice(1) || undefined : undefined, label };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text shown for a link: its label, else the note title (with » for a bullet). */
|
||||||
|
export function wikiLinkText(link: WikiLink): string {
|
||||||
|
if (link.label) return link.label;
|
||||||
|
return link.blockId ? `${link.title} »` : link.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Link to one bullet; the label is a short excerpt of the bullet so the reader knows what it points to. */
|
||||||
|
export function buildBlockLink(title: string, blockId: string, blockContent: string): string {
|
||||||
|
const clean = (s: string) => s.replace(/[[\]|]/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
let excerpt = clean(blockContent);
|
||||||
|
if (excerpt.length > 40) excerpt = `${excerpt.slice(0, 39).trimEnd()}…`;
|
||||||
|
const label = excerpt ? `${clean(title)} › ${excerpt}` : clean(title);
|
||||||
|
return `[[${clean(title)}#^${blockId}|${label}]]`;
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { MobileActionProvider } from './context/MobileActionContext';
|
import { MobileActionProvider } from './context/MobileActionContext';
|
||||||
|
// Inter ships with the app (no external font service); the variable font covers all weights
|
||||||
|
import '@fontsource-variable/inter';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
MINDSIFT THEME: FJORD NACHT (Tiefsee & Mondlicht)
|
||||||
|
Das dunkle Gegenstück zu Fjord: tiefes Nachtblau statt Schwarz,
|
||||||
|
mondhelle Schrift, leuchtendes Gletscherblau, weiche Ecken.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
:root[data-theme="fjord-night"] {
|
||||||
|
/* --- 1. AKZENTFARBE (Leuchtendes Gletscherblau) --- */
|
||||||
|
--accent-primary: #5b9bdf;
|
||||||
|
--accent-hover: #7db2ea;
|
||||||
|
--accent-dim: rgba(91, 155, 223, 0.16);
|
||||||
|
--accent-border: rgba(91, 155, 223, 0.40);
|
||||||
|
--text-on-accent: #0b1622;
|
||||||
|
|
||||||
|
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE (Tiefsee) --- */
|
||||||
|
--bg-app: #0e1621;
|
||||||
|
--bg-panel: #152131;
|
||||||
|
--bg-panel-elevated: #1c2b3e;
|
||||||
|
--bg-panel-hover: #24364c;
|
||||||
|
--bg-input: #101a26;
|
||||||
|
--bg-overlay: rgba(5, 10, 16, 0.72);
|
||||||
|
--bg-code: #0b121b;
|
||||||
|
--bg-editor: #121d2a;
|
||||||
|
--bg-sidebar: #0e1621;
|
||||||
|
|
||||||
|
/* --- 3. TEXT & KONTRASTE (Mondlicht) --- */
|
||||||
|
--text-main: #e6eef8;
|
||||||
|
--text-muted: #9fb1c6;
|
||||||
|
--text-faint: #6b7d93;
|
||||||
|
|
||||||
|
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
|
||||||
|
--border-color: rgba(180, 205, 235, 0.12);
|
||||||
|
--border-subtle: rgba(180, 205, 235, 0.06);
|
||||||
|
--border-strong: rgba(180, 205, 235, 0.24);
|
||||||
|
--shadow-panel: 0 12px 32px rgba(2, 6, 12, 0.55);
|
||||||
|
--scrollbar-thumb: rgba(91, 155, 223, 0.22);
|
||||||
|
--scrollbar-thumb-hover: rgba(91, 155, 223, 0.38);
|
||||||
|
|
||||||
|
/* --- 5. STATUS- & SIGNALFARBEN --- */
|
||||||
|
--color-success: #6fcf97;
|
||||||
|
--bg-success: rgba(111, 207, 151, 0.14);
|
||||||
|
--border-success: rgba(111, 207, 151, 0.36);
|
||||||
|
|
||||||
|
--color-danger: #f28b82;
|
||||||
|
--bg-danger: rgba(242, 139, 130, 0.14);
|
||||||
|
--border-danger: rgba(242, 139, 130, 0.36);
|
||||||
|
|
||||||
|
--color-warning: #f6c177;
|
||||||
|
--bg-warning: rgba(246, 193, 119, 0.14);
|
||||||
|
--border-warning: rgba(246, 193, 119, 0.36);
|
||||||
|
|
||||||
|
/* --- 6. RUNDUNGEN (Weich) --- */
|
||||||
|
--radius-card: 12px;
|
||||||
|
--radius-btn: 8px;
|
||||||
|
--radius-input: 8px;
|
||||||
|
--radius-checkbox: 4px;
|
||||||
|
--radius-pill: 9999px;
|
||||||
|
|
||||||
|
/* --- 7. CODE-SYNTAX-HIGHLIGHTING --- */
|
||||||
|
--code-keyword: #7db2ea;
|
||||||
|
--code-string: #9fd4a3;
|
||||||
|
--code-number: #f6c177;
|
||||||
|
--code-comment: #6b7d93;
|
||||||
|
--code-property: #c3d8f0;
|
||||||
|
--code-function: #c4a7f5;
|
||||||
|
--code-type: #6fd3c8;
|
||||||
|
--code-punctuation: #9fb1c6;
|
||||||
|
--code-variable: #c3d8f0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
MINDSIFT THEME: FJORD (Kühles Papier & Gletscherblau)
|
||||||
|
Bläulich-weißes Papier, nebelgraue Seitenleiste, tintenblaue Schrift,
|
||||||
|
klares Fjordblau als Akzent und weiche Ecken.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
:root[data-theme="fjord"] {
|
||||||
|
/* --- 1. AKZENTFARBE (Fjordblau) --- */
|
||||||
|
--accent-primary: #2b6cb0;
|
||||||
|
--accent-hover: #1f5590;
|
||||||
|
--accent-dim: rgba(43, 108, 176, 0.10);
|
||||||
|
--accent-border: rgba(43, 108, 176, 0.34);
|
||||||
|
--text-on-accent: #ffffff;
|
||||||
|
|
||||||
|
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE (Papier, Nebel, Eis) --- */
|
||||||
|
--bg-app: #e9eef5;
|
||||||
|
--bg-panel: #f7f9fc;
|
||||||
|
--bg-panel-elevated: #dde5ef;
|
||||||
|
--bg-panel-hover: #cfd9e6;
|
||||||
|
--bg-input: #fbfcfe;
|
||||||
|
--bg-overlay: rgba(14, 24, 38, 0.45);
|
||||||
|
--bg-code: #eef2f8;
|
||||||
|
--bg-editor: #fafcff;
|
||||||
|
--bg-sidebar: #edf2f8;
|
||||||
|
|
||||||
|
/* --- 3. TEXT & KONTRASTE (Tintenblau) --- */
|
||||||
|
--text-main: #14202e;
|
||||||
|
--text-muted: #45556a;
|
||||||
|
--text-faint: #7a8798;
|
||||||
|
|
||||||
|
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
|
||||||
|
--border-color: rgba(30, 55, 90, 0.16);
|
||||||
|
--border-subtle: rgba(30, 55, 90, 0.08);
|
||||||
|
--border-strong: rgba(30, 55, 90, 0.30);
|
||||||
|
--shadow-panel: 0 10px 28px rgba(20, 40, 70, 0.10);
|
||||||
|
--scrollbar-thumb: rgba(30, 55, 90, 0.22);
|
||||||
|
--scrollbar-thumb-hover: rgba(30, 55, 90, 0.38);
|
||||||
|
|
||||||
|
/* --- 5. STATUS- & SIGNALFARBEN --- */
|
||||||
|
--color-success: #2f855a;
|
||||||
|
--bg-success: rgba(47, 133, 90, 0.11);
|
||||||
|
--border-success: rgba(47, 133, 90, 0.34);
|
||||||
|
|
||||||
|
--color-danger: #c53030;
|
||||||
|
--bg-danger: rgba(197, 48, 48, 0.09);
|
||||||
|
--border-danger: rgba(197, 48, 48, 0.34);
|
||||||
|
|
||||||
|
--color-warning: #b7791f;
|
||||||
|
--bg-warning: rgba(183, 121, 31, 0.11);
|
||||||
|
--border-warning: rgba(183, 121, 31, 0.34);
|
||||||
|
|
||||||
|
/* --- 6. RUNDUNGEN (Weich) --- */
|
||||||
|
--radius-card: 12px;
|
||||||
|
--radius-btn: 8px;
|
||||||
|
--radius-input: 8px;
|
||||||
|
--radius-checkbox: 4px;
|
||||||
|
--radius-pill: 9999px;
|
||||||
|
|
||||||
|
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (kühle Töne auf Papier) --- */
|
||||||
|
--code-keyword: #2b6cb0;
|
||||||
|
--code-string: #2f855a;
|
||||||
|
--code-number: #b7791f;
|
||||||
|
--code-comment: #7a8798;
|
||||||
|
--code-property: #1a4e7a;
|
||||||
|
--code-function: #6b46c1;
|
||||||
|
--code-type: #0e7490;
|
||||||
|
--code-punctuation: #45556a;
|
||||||
|
--code-variable: #1a4e7a;
|
||||||
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
/* ==========================================================================
|
|
||||||
MINDSIFT THEME: HIGH CONTRAST (Maximaler Kontrast & Schärfe)
|
|
||||||
Alle Farben, Kontraste und Rundungen zentral in einer einzigen Datei.
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
:root[data-theme="high-contrast"] {
|
|
||||||
/* --- 1. AKZENTFARBE --- */
|
|
||||||
--accent-primary: #38bdf8;
|
|
||||||
--accent-hover: #7dd3fc;
|
|
||||||
--accent-dim: rgba(56, 189, 248, 0.22);
|
|
||||||
--accent-border: #38bdf8;
|
|
||||||
--text-on-accent: #000000;
|
|
||||||
|
|
||||||
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
|
|
||||||
--bg-app: #000000;
|
|
||||||
--bg-panel: #09090b;
|
|
||||||
--bg-panel-elevated: #18181b;
|
|
||||||
--bg-panel-hover: #27272a;
|
|
||||||
--bg-input: #000000;
|
|
||||||
--bg-overlay: rgba(0, 0, 0, 0.88);
|
|
||||||
--bg-code: #000000;
|
|
||||||
|
|
||||||
/* --- 3. TEXT & KONTRASTE --- */
|
|
||||||
--text-main: #ffffff;
|
|
||||||
--text-muted: #e4e4e7;
|
|
||||||
--text-faint: #a1a1aa;
|
|
||||||
|
|
||||||
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
|
|
||||||
--border-color: rgba(255, 255, 255, 0.38);
|
|
||||||
--border-subtle: rgba(255, 255, 255, 0.20);
|
|
||||||
--border-strong: #ffffff;
|
|
||||||
--shadow-panel: 0 0 0 1px #ffffff;
|
|
||||||
--scrollbar-thumb: rgba(255, 255, 255, 0.45);
|
|
||||||
--scrollbar-thumb-hover: #ffffff;
|
|
||||||
|
|
||||||
/* --- 5. STATUS- & SIGNALFARBEN --- */
|
|
||||||
--color-success: #4ade80;
|
|
||||||
--bg-success: rgba(74, 222, 128, 0.20);
|
|
||||||
--border-success: #4ade80;
|
|
||||||
|
|
||||||
--color-danger: #f87171;
|
|
||||||
--bg-danger: rgba(248, 113, 113, 0.20);
|
|
||||||
--border-danger: #f87171;
|
|
||||||
|
|
||||||
--color-warning: #fbbf24;
|
|
||||||
--bg-warning: rgba(251, 191, 36, 0.20);
|
|
||||||
--border-warning: #fbbf24;
|
|
||||||
|
|
||||||
/* --- 6. RUNDUNGEN (Geometrie: Komplett scharfkantig) --- */
|
|
||||||
--radius-card: 0px;
|
|
||||||
--radius-btn: 0px;
|
|
||||||
--radius-input: 0px;
|
|
||||||
--radius-checkbox: 0px;
|
|
||||||
--radius-pill: 0px;
|
|
||||||
|
|
||||||
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+) --- */
|
|
||||||
--code-keyword: #569cd6;
|
|
||||||
--code-string: #ce9178;
|
|
||||||
--code-number: #b5cea8;
|
|
||||||
--code-comment: #6a9955;
|
|
||||||
--code-property: #9cdcfe;
|
|
||||||
--code-function: #dcdcaa;
|
|
||||||
--code-type: #4ec9b0;
|
|
||||||
--code-punctuation: #ffffff;
|
|
||||||
--code-variable: #9cdcfe;
|
|
||||||
}
|
|
||||||
@@ -14,24 +14,26 @@
|
|||||||
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
|
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
|
||||||
--bg-app: #f3f4f6;
|
--bg-app: #f3f4f6;
|
||||||
--bg-panel: #ffffff;
|
--bg-panel: #ffffff;
|
||||||
--bg-panel-elevated: #ebedf0;
|
--bg-panel-elevated: #e2e8f0;
|
||||||
--bg-panel-hover: #e2e5ea;
|
--bg-panel-hover: #cbd5e1;
|
||||||
--bg-input: #ffffff;
|
--bg-input: #ffffff;
|
||||||
--bg-overlay: rgba(15, 23, 42, 0.45);
|
--bg-overlay: rgba(15, 23, 42, 0.45);
|
||||||
--bg-code: #f1f5f9;
|
--bg-code: #e2e8f0;
|
||||||
|
--bg-editor: #ffffff;
|
||||||
|
--bg-sidebar: #f3f4f6;
|
||||||
|
|
||||||
/* --- 3. TEXT & KONTRASTE --- */
|
/* --- 3. TEXT & KONTRASTE --- */
|
||||||
--text-main: #111827;
|
--text-main: #111827;
|
||||||
--text-muted: #4b5563;
|
--text-muted: #374151;
|
||||||
--text-faint: #9ca3af;
|
--text-faint: #6b7280;
|
||||||
|
|
||||||
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
|
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
|
||||||
--border-color: rgba(17, 24, 39, 0.13);
|
--border-color: rgba(17, 24, 39, 0.18);
|
||||||
--border-subtle: rgba(17, 24, 39, 0.06);
|
--border-subtle: rgba(17, 24, 39, 0.08);
|
||||||
--border-strong: rgba(17, 24, 39, 0.28);
|
--border-strong: rgba(17, 24, 39, 0.32);
|
||||||
--shadow-panel: 0 8px 24px rgba(15, 23, 42, 0.08);
|
--shadow-panel: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||||
--scrollbar-thumb: rgba(17, 24, 39, 0.18);
|
--scrollbar-thumb: rgba(17, 24, 39, 0.22);
|
||||||
--scrollbar-thumb-hover: rgba(17, 24, 39, 0.32);
|
--scrollbar-thumb-hover: rgba(17, 24, 39, 0.38);
|
||||||
|
|
||||||
/* --- 5. STATUS- & SIGNALFARBEN --- */
|
/* --- 5. STATUS- & SIGNALFARBEN --- */
|
||||||
--color-success: #15803d;
|
--color-success: #15803d;
|
||||||
@@ -57,10 +59,10 @@
|
|||||||
--code-keyword: #0000ff;
|
--code-keyword: #0000ff;
|
||||||
--code-string: #a31515;
|
--code-string: #a31515;
|
||||||
--code-number: #098658;
|
--code-number: #098658;
|
||||||
--code-comment: #008000;
|
--code-comment: #007000;
|
||||||
--code-property: #0451a5;
|
--code-property: #0451a5;
|
||||||
--code-function: #795e26;
|
--code-function: #795e26;
|
||||||
--code-type: #267f99;
|
--code-type: #267f99;
|
||||||
--code-punctuation: #3b3b3b;
|
--code-punctuation: #2e2e2e;
|
||||||
--code-variable: #001080;
|
--code-variable: #001080;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
MINDSIFT THEME: POLARIZED HELL (Lagune & Kreide)
|
||||||
|
Das helle Gegenstück zu Polarized: kreidiges Mintweiß, tiefes Petrol als
|
||||||
|
Akzent, dunkle Schiefer-Schrift, klar und eckig wie das Original.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
:root[data-theme="polarized-light"] {
|
||||||
|
/* --- 1. AKZENTFARBE (Tiefes Petrol) --- */
|
||||||
|
--accent-primary: #0f766e;
|
||||||
|
--accent-hover: #115e59;
|
||||||
|
--accent-dim: rgba(15, 118, 110, 0.10);
|
||||||
|
--accent-border: rgba(15, 118, 110, 0.34);
|
||||||
|
--text-on-accent: #ffffff;
|
||||||
|
|
||||||
|
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE (Kreide, Lagune) --- */
|
||||||
|
--bg-app: #e6f0ef;
|
||||||
|
--bg-panel: #f6fbfa;
|
||||||
|
--bg-panel-elevated: #d8e8e6;
|
||||||
|
--bg-panel-hover: #c7dcd9;
|
||||||
|
--bg-input: #fbfefd;
|
||||||
|
--bg-overlay: rgba(8, 30, 28, 0.45);
|
||||||
|
--bg-code: #eaf3f2;
|
||||||
|
--bg-editor: #f9fdfc;
|
||||||
|
--bg-sidebar: #ebf4f3;
|
||||||
|
|
||||||
|
/* --- 3. TEXT & KONTRASTE --- */
|
||||||
|
--text-main: #0f2422;
|
||||||
|
--text-muted: #3f5c59;
|
||||||
|
--text-faint: #728a87;
|
||||||
|
|
||||||
|
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
|
||||||
|
--border-color: rgba(15, 80, 75, 0.17);
|
||||||
|
--border-subtle: rgba(15, 80, 75, 0.08);
|
||||||
|
--border-strong: rgba(15, 80, 75, 0.32);
|
||||||
|
--shadow-panel: 0 8px 24px rgba(10, 45, 42, 0.09);
|
||||||
|
--scrollbar-thumb: rgba(15, 80, 75, 0.22);
|
||||||
|
--scrollbar-thumb-hover: rgba(15, 80, 75, 0.38);
|
||||||
|
|
||||||
|
/* --- 5. STATUS- & SIGNALFARBEN --- */
|
||||||
|
--color-success: #15803d;
|
||||||
|
--bg-success: rgba(21, 128, 61, 0.10);
|
||||||
|
--border-success: rgba(21, 128, 61, 0.34);
|
||||||
|
|
||||||
|
--color-danger: #be123c;
|
||||||
|
--bg-danger: rgba(190, 18, 60, 0.09);
|
||||||
|
--border-danger: rgba(190, 18, 60, 0.34);
|
||||||
|
|
||||||
|
--color-warning: #b45309;
|
||||||
|
--bg-warning: rgba(180, 83, 9, 0.10);
|
||||||
|
--border-warning: rgba(180, 83, 9, 0.34);
|
||||||
|
|
||||||
|
/* --- 6. RUNDUNGEN (Klar & eckig wie Polarized) --- */
|
||||||
|
--radius-card: 0px;
|
||||||
|
--radius-btn: 2px;
|
||||||
|
--radius-input: 2px;
|
||||||
|
--radius-checkbox: 0px;
|
||||||
|
--radius-pill: 9999px;
|
||||||
|
|
||||||
|
/* --- 7. CODE-SYNTAX-HIGHLIGHTING --- */
|
||||||
|
--code-keyword: #0f766e;
|
||||||
|
--code-string: #9a3412;
|
||||||
|
--code-number: #b45309;
|
||||||
|
--code-comment: #728a87;
|
||||||
|
--code-property: #155e75;
|
||||||
|
--code-function: #7e22ce;
|
||||||
|
--code-type: #0e7490;
|
||||||
|
--code-punctuation: #3f5c59;
|
||||||
|
--code-variable: #155e75;
|
||||||
|
}
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
/* ==========================================================================
|
|
||||||
MINDSIFT THEME: SOFT SLATE (Abgerundete Geometrie & Sanftes Schiefer-Blau)
|
|
||||||
Demonstriert, wie über die Rundungs-Variablen in einer einzigen Datei
|
|
||||||
das gesamte App-Layout auf abgerundete Ecken umschaltet.
|
|
||||||
========================================================================== */
|
|
||||||
|
|
||||||
:root[data-theme="soft"] {
|
|
||||||
/* --- 1. AKZENTFARBE --- */
|
|
||||||
--accent-primary: #3b82f6;
|
|
||||||
--accent-hover: #60a5fa;
|
|
||||||
--accent-dim: rgba(59, 130, 246, 0.15);
|
|
||||||
--accent-border: rgba(96, 165, 250, 0.40);
|
|
||||||
--text-on-accent: #ffffff;
|
|
||||||
|
|
||||||
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
|
|
||||||
--bg-app: #0f172a;
|
|
||||||
--bg-panel: #1e293b;
|
|
||||||
--bg-panel-elevated: #293548;
|
|
||||||
--bg-panel-hover: #334155;
|
|
||||||
--bg-input: #0f172a;
|
|
||||||
--bg-overlay: rgba(2, 6, 23, 0.72);
|
|
||||||
--bg-code: #090d16;
|
|
||||||
|
|
||||||
/* --- 3. TEXT & KONTRASTE --- */
|
|
||||||
--text-main: #f8fafc;
|
|
||||||
--text-muted: #94a3b8;
|
|
||||||
--text-faint: #64748b;
|
|
||||||
|
|
||||||
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
|
|
||||||
--border-color: rgba(148, 163, 184, 0.16);
|
|
||||||
--border-subtle: rgba(148, 163, 184, 0.08);
|
|
||||||
--border-strong: rgba(148, 163, 184, 0.32);
|
|
||||||
--shadow-panel: 0 12px 32px rgba(2, 6, 23, 0.40);
|
|
||||||
--scrollbar-thumb: rgba(148, 163, 184, 0.22);
|
|
||||||
--scrollbar-thumb-hover: rgba(148, 163, 184, 0.38);
|
|
||||||
|
|
||||||
/* --- 5. STATUS- & SIGNALFARBEN --- */
|
|
||||||
--color-success: #10b981;
|
|
||||||
--bg-success: rgba(16, 185, 129, 0.14);
|
|
||||||
--border-success: rgba(16, 185, 129, 0.38);
|
|
||||||
|
|
||||||
--color-danger: #f43f5e;
|
|
||||||
--bg-danger: rgba(244, 63, 94, 0.14);
|
|
||||||
--border-danger: rgba(244, 63, 94, 0.38);
|
|
||||||
|
|
||||||
--color-warning: #f59e0b;
|
|
||||||
--bg-warning: rgba(245, 158, 11, 0.14);
|
|
||||||
--border-warning: rgba(245, 158, 11, 0.38);
|
|
||||||
|
|
||||||
/* --- 6. RUNDUNGEN (Weich abgerundete Ecken überall) --- */
|
|
||||||
--radius-card: 10px;
|
|
||||||
--radius-btn: 6px;
|
|
||||||
--radius-input: 6px;
|
|
||||||
--radius-checkbox: 4px;
|
|
||||||
--radius-pill: 9999px;
|
|
||||||
|
|
||||||
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+) --- */
|
|
||||||
--code-keyword: #569cd6;
|
|
||||||
--code-string: #ce9178;
|
|
||||||
--code-number: #b5cea8;
|
|
||||||
--code-comment: #6a9955;
|
|
||||||
--code-property: #9cdcfe;
|
|
||||||
--code-function: #dcdcaa;
|
|
||||||
--code-type: #4ec9b0;
|
|
||||||
--code-punctuation: #d4d4d4;
|
|
||||||
--code-variable: #9cdcfe;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
MINDSIFT THEME: SURI HELL (Lavendel & Pflaume)
|
||||||
|
Das helle Gegenstück zu Suri: lavendelhelles Papier, pflaumendunkle
|
||||||
|
Schrift, samtiger Orchideen-Akzent und weich gerundete Ecken.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
:root[data-theme="suri-light"] {
|
||||||
|
/* --- 1. AKZENTFARBE (Orchidee) --- */
|
||||||
|
--accent-primary: #8e44c9;
|
||||||
|
--accent-hover: #7431a8;
|
||||||
|
--accent-dim: rgba(142, 68, 201, 0.10);
|
||||||
|
--accent-border: rgba(142, 68, 201, 0.34);
|
||||||
|
--text-on-accent: #ffffff;
|
||||||
|
|
||||||
|
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE (Lavendel) --- */
|
||||||
|
--bg-app: #efe9f4;
|
||||||
|
--bg-panel: #fbf8fd;
|
||||||
|
--bg-panel-elevated: #e6dcef;
|
||||||
|
--bg-panel-hover: #d9cce6;
|
||||||
|
--bg-input: #fdfbfe;
|
||||||
|
--bg-overlay: rgba(30, 18, 40, 0.45);
|
||||||
|
--bg-code: #f2ecf7;
|
||||||
|
--bg-editor: #fdfaff;
|
||||||
|
--bg-sidebar: #f2ecf7;
|
||||||
|
|
||||||
|
/* --- 3. TEXT & KONTRASTE (Pflaume) --- */
|
||||||
|
--text-main: #23182e;
|
||||||
|
--text-muted: #574a68;
|
||||||
|
--text-faint: #8a7c9c;
|
||||||
|
|
||||||
|
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
|
||||||
|
--border-color: rgba(70, 40, 100, 0.16);
|
||||||
|
--border-subtle: rgba(70, 40, 100, 0.08);
|
||||||
|
--border-strong: rgba(70, 40, 100, 0.30);
|
||||||
|
--shadow-panel: 0 10px 28px rgba(50, 25, 75, 0.10);
|
||||||
|
--scrollbar-thumb: rgba(70, 40, 100, 0.22);
|
||||||
|
--scrollbar-thumb-hover: rgba(70, 40, 100, 0.38);
|
||||||
|
|
||||||
|
/* --- 5. STATUS- & SIGNALFARBEN --- */
|
||||||
|
--color-success: #2f855a;
|
||||||
|
--bg-success: rgba(47, 133, 90, 0.11);
|
||||||
|
--border-success: rgba(47, 133, 90, 0.34);
|
||||||
|
|
||||||
|
--color-danger: #c2185b;
|
||||||
|
--bg-danger: rgba(194, 24, 91, 0.09);
|
||||||
|
--border-danger: rgba(194, 24, 91, 0.34);
|
||||||
|
|
||||||
|
--color-warning: #b7791f;
|
||||||
|
--bg-warning: rgba(183, 121, 31, 0.11);
|
||||||
|
--border-warning: rgba(183, 121, 31, 0.34);
|
||||||
|
|
||||||
|
/* --- 6. RUNDUNGEN (Weich wie Suri) --- */
|
||||||
|
--radius-card: 10px;
|
||||||
|
--radius-btn: 6px;
|
||||||
|
--radius-input: 6px;
|
||||||
|
--radius-checkbox: 4px;
|
||||||
|
--radius-pill: 9999px;
|
||||||
|
|
||||||
|
/* --- 7. CODE-SYNTAX-HIGHLIGHTING --- */
|
||||||
|
--code-keyword: #7431a8;
|
||||||
|
--code-string: #2f855a;
|
||||||
|
--code-number: #b7791f;
|
||||||
|
--code-comment: #8a7c9c;
|
||||||
|
--code-property: #5b2a86;
|
||||||
|
--code-function: #b4235f;
|
||||||
|
--code-type: #0e7490;
|
||||||
|
--code-punctuation: #574a68;
|
||||||
|
--code-variable: #5b2a86;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@
|
|||||||
--bg-input: #121316; /* Textfelder, Selects, Textareas */
|
--bg-input: #121316; /* Textfelder, Selects, Textareas */
|
||||||
--bg-overlay: rgba(0, 0, 0, 0.72); /* Hintergrund bei Modals/Dialogen */
|
--bg-overlay: rgba(0, 0, 0, 0.72); /* Hintergrund bei Modals/Dialogen */
|
||||||
--bg-code: #0d0e11; /* Code-Blöcke & Terminal-Boxen */
|
--bg-code: #0d0e11; /* Code-Blöcke & Terminal-Boxen */
|
||||||
|
/* Optional: --bg-editor (Notiz-Editor, Standard --bg-app) und --bg-sidebar (Ordnerleiste, Standard --bg-panel) */
|
||||||
|
|
||||||
/* --- 3. TEXT & KONTRASTE --- */
|
/* --- 3. TEXT & KONTRASTE --- */
|
||||||
--text-main: #f4f4f5; /* Haupttext & Überschriften */
|
--text-main: #f4f4f5; /* Haupttext & Überschriften */
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
MINDSIFT THEME: TERRAKOTTA NACHT (Espresso & Glut)
|
||||||
|
Das dunkle Gegenstück zu Terrakotta: Espresso-Braun statt Schwarz,
|
||||||
|
warme Creme-Schrift, glühender Terrakotta-Akzent, Salbeigrün, weiche Ecken.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
:root[data-theme="terracotta-night"] {
|
||||||
|
/* --- 1. AKZENTFARBE (Glühende Terrakotta) --- */
|
||||||
|
--accent-primary: #e8794a;
|
||||||
|
--accent-hover: #f39669;
|
||||||
|
--accent-dim: rgba(232, 121, 74, 0.15);
|
||||||
|
--accent-border: rgba(232, 121, 74, 0.40);
|
||||||
|
--text-on-accent: #1c1410;
|
||||||
|
|
||||||
|
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE (Espresso, Mokka, Leder) --- */
|
||||||
|
--bg-app: #17110d;
|
||||||
|
--bg-panel: #211913;
|
||||||
|
--bg-panel-elevated: #2c2219;
|
||||||
|
--bg-panel-hover: #382b20;
|
||||||
|
--bg-input: #1a130f;
|
||||||
|
--bg-overlay: rgba(10, 6, 4, 0.72);
|
||||||
|
--bg-code: #120d0a;
|
||||||
|
--bg-editor: #1c1510;
|
||||||
|
--bg-sidebar: #17110d;
|
||||||
|
|
||||||
|
/* --- 3. TEXT & KONTRASTE (Creme auf Espresso) --- */
|
||||||
|
--text-main: #f3e8da;
|
||||||
|
--text-muted: #c4b19c;
|
||||||
|
--text-faint: #8d7a66;
|
||||||
|
|
||||||
|
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
|
||||||
|
--border-color: rgba(243, 214, 184, 0.12);
|
||||||
|
--border-subtle: rgba(243, 214, 184, 0.06);
|
||||||
|
--border-strong: rgba(243, 214, 184, 0.24);
|
||||||
|
--shadow-panel: 0 12px 32px rgba(5, 3, 2, 0.55);
|
||||||
|
--scrollbar-thumb: rgba(232, 121, 74, 0.22);
|
||||||
|
--scrollbar-thumb-hover: rgba(232, 121, 74, 0.38);
|
||||||
|
|
||||||
|
/* --- 5. STATUS- & SIGNALFARBEN (Salbei, Ziegel, Honig) --- */
|
||||||
|
--color-success: #8fbf8a;
|
||||||
|
--bg-success: rgba(143, 191, 138, 0.14);
|
||||||
|
--border-success: rgba(143, 191, 138, 0.36);
|
||||||
|
|
||||||
|
--color-danger: #f07a6a;
|
||||||
|
--bg-danger: rgba(240, 122, 106, 0.14);
|
||||||
|
--border-danger: rgba(240, 122, 106, 0.36);
|
||||||
|
|
||||||
|
--color-warning: #e6b450;
|
||||||
|
--bg-warning: rgba(230, 180, 80, 0.14);
|
||||||
|
--border-warning: rgba(230, 180, 80, 0.36);
|
||||||
|
|
||||||
|
/* --- 6. RUNDUNGEN (Weich wie Papierkarten) --- */
|
||||||
|
--radius-card: 12px;
|
||||||
|
--radius-btn: 8px;
|
||||||
|
--radius-input: 8px;
|
||||||
|
--radius-checkbox: 4px;
|
||||||
|
--radius-pill: 9999px;
|
||||||
|
|
||||||
|
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (warme Erdtöne auf Espresso) --- */
|
||||||
|
--code-keyword: #f39669;
|
||||||
|
--code-string: #a9cf9c;
|
||||||
|
--code-number: #e6b450;
|
||||||
|
--code-comment: #8d7a66;
|
||||||
|
--code-property: #f0c9a4;
|
||||||
|
--code-function: #d8b4fe;
|
||||||
|
--code-type: #7fc7b8;
|
||||||
|
--code-punctuation: #c4b19c;
|
||||||
|
--code-variable: #f0c9a4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
MINDSIFT THEME: TERRAKOTTA (Warmes Papier & Tinte)
|
||||||
|
Cremefarbenes Notizpapier, sandfarbene Seitenleiste, tintendunkle Schrift,
|
||||||
|
gebrannter Terrakotta-Akzent mit Salbeigrün und weiche Ecken.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
:root[data-theme="terracotta"] {
|
||||||
|
/* --- 1. AKZENTFARBE (Gebrannte Terrakotta) --- */
|
||||||
|
--accent-primary: #c2410c;
|
||||||
|
--accent-hover: #9a3412;
|
||||||
|
--accent-dim: rgba(194, 65, 12, 0.10);
|
||||||
|
--accent-border: rgba(194, 65, 12, 0.34);
|
||||||
|
--text-on-accent: #fffaf3;
|
||||||
|
|
||||||
|
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE (Papier, Sand, Leinen) --- */
|
||||||
|
--bg-app: #efe6d8;
|
||||||
|
--bg-panel: #fbf6ee;
|
||||||
|
--bg-panel-elevated: #eadfcd;
|
||||||
|
--bg-panel-hover: #e0d2bb;
|
||||||
|
--bg-input: #fffbf5;
|
||||||
|
--bg-overlay: rgba(46, 33, 22, 0.45);
|
||||||
|
--bg-code: #f1e8da;
|
||||||
|
--bg-editor: #fdf9f2;
|
||||||
|
--bg-sidebar: #f2eadc;
|
||||||
|
|
||||||
|
/* --- 3. TEXT & KONTRASTE (Tinte) --- */
|
||||||
|
--text-main: #2b2118;
|
||||||
|
--text-muted: #5c4b3c;
|
||||||
|
--text-faint: #8a7764;
|
||||||
|
|
||||||
|
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
|
||||||
|
--border-color: rgba(92, 64, 40, 0.18);
|
||||||
|
--border-subtle: rgba(92, 64, 40, 0.09);
|
||||||
|
--border-strong: rgba(92, 64, 40, 0.32);
|
||||||
|
--shadow-panel: 0 10px 28px rgba(74, 50, 28, 0.10);
|
||||||
|
--scrollbar-thumb: rgba(92, 64, 40, 0.22);
|
||||||
|
--scrollbar-thumb-hover: rgba(92, 64, 40, 0.38);
|
||||||
|
|
||||||
|
/* --- 5. STATUS- & SIGNALFARBEN (Salbei, Ziegel, Ocker) --- */
|
||||||
|
--color-success: #4d7c4f;
|
||||||
|
--bg-success: rgba(77, 124, 79, 0.12);
|
||||||
|
--border-success: rgba(77, 124, 79, 0.36);
|
||||||
|
|
||||||
|
--color-danger: #b42318;
|
||||||
|
--bg-danger: rgba(180, 35, 24, 0.09);
|
||||||
|
--border-danger: rgba(180, 35, 24, 0.34);
|
||||||
|
|
||||||
|
--color-warning: #a16207;
|
||||||
|
--bg-warning: rgba(161, 98, 7, 0.11);
|
||||||
|
--border-warning: rgba(161, 98, 7, 0.34);
|
||||||
|
|
||||||
|
/* --- 6. RUNDUNGEN (Weich wie Papierkarten) --- */
|
||||||
|
--radius-card: 12px;
|
||||||
|
--radius-btn: 8px;
|
||||||
|
--radius-input: 8px;
|
||||||
|
--radius-checkbox: 4px;
|
||||||
|
--radius-pill: 9999px;
|
||||||
|
|
||||||
|
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (warme Erdtöne, gut lesbar auf Papier) --- */
|
||||||
|
--code-keyword: #9a3412;
|
||||||
|
--code-string: #4d7c4f;
|
||||||
|
--code-number: #b45309;
|
||||||
|
--code-comment: #8a7764;
|
||||||
|
--code-property: #7c2d12;
|
||||||
|
--code-function: #6b4f9e;
|
||||||
|
--code-type: #0f766e;
|
||||||
|
--code-punctuation: #5c4b3c;
|
||||||
|
--code-variable: #7c2d12;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import animate from 'tailwindcss-animate';
|
||||||
// Theme colours are CSS variables, so Tailwind cannot apply opacity modifiers (bg-accent/15) by itself.
|
// Theme colours are CSS variables, so Tailwind cannot apply opacity modifiers (bg-accent/15) by itself.
|
||||||
// v() mixes the variable with transparent for a modifier; plain classes keep the bare variable, so only
|
// v() mixes the variable with transparent for a modifier; plain classes keep the bare variable, so only
|
||||||
// the tints depend on color-mix() support.
|
// the tints depend on color-mix() support.
|
||||||
@@ -14,6 +15,8 @@ export default {
|
|||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
canvas: v('--bg-app'),
|
canvas: v('--bg-app'),
|
||||||
|
editor: v('--bg-editor'),
|
||||||
|
sidebar: v('--bg-sidebar'),
|
||||||
panel: {
|
panel: {
|
||||||
DEFAULT: v('--bg-panel'),
|
DEFAULT: v('--bg-panel'),
|
||||||
elevated: v('--bg-panel-elevated'),
|
elevated: v('--bg-panel-elevated'),
|
||||||
@@ -83,6 +86,7 @@ export default {
|
|||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: [
|
sans: [
|
||||||
|
'Inter Variable',
|
||||||
'Inter',
|
'Inter',
|
||||||
'system-ui',
|
'system-ui',
|
||||||
'-apple-system',
|
'-apple-system',
|
||||||
@@ -95,5 +99,6 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
// animate-in / fade-in / slide-in-from-* / zoom-in-* used by menus, sheets and dialogs
|
||||||
|
plugins: [animate],
|
||||||
};
|
};
|
||||||
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite';
|
|||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
// Shown in the header. Release builds get the git tag (APP_VERSION build argument, e.g. "v0.6.0");
|
// Shown in the header. Tag and main builds get APP_VERSION as build argument (e.g. "v0.6.0");
|
||||||
// other builds show the package version with a "-dev" suffix (Semantic Versioning, see CHANGELOG.md).
|
// other builds show the package version with a "-dev" suffix (Semantic Versioning, see CHANGELOG.md).
|
||||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
||||||
const appVersion = process.env.APP_VERSION || `v${pkg.version}-dev`;
|
const appVersion = process.env.APP_VERSION || `v${pkg.version}-dev`;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ module github.com/sandrodach/mindsift
|
|||||||
go 1.23
|
go 1.23
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||||
github.com/go-chi/chi/v5 v5.2.0
|
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
|
||||||
|
|||||||
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user