Merge branch 'dev' into main: v0.10.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
0cb7803504
47 files changed
+2124
-467
No files matched your search
@@ -51,10 +51,12 @@ jobs:
|
|||||||
echo "tag=${TAGS%% *}" >> "$GITHUB_OUTPUT"
|
echo "tag=${TAGS%% *}" >> "$GITHUB_OUTPUT"
|
||||||
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
||||||
echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT"
|
echo "actor=${ACTOR_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
# Releases (tags) show the tag as version in the app header
|
# Version in the app header: tags show the tag, main the package version, everything else
|
||||||
|
# (dev, feature branches) keeps the "-dev" suffix from vite.config.ts
|
||||||
APP_VERSION=""
|
APP_VERSION=""
|
||||||
case "${GITHUB_REF:-}" in
|
case "${GITHUB_REF:-}" in
|
||||||
refs/tags/*) APP_VERSION="${REF_NAME}" ;;
|
refs/tags/*) APP_VERSION="${REF_NAME}" ;;
|
||||||
|
refs/heads/main) APP_VERSION="v$(sed -nE 's/^ "version": "([^"]+)".*/\1/p' frontend/package.json | head -n1)" ;;
|
||||||
esac
|
esac
|
||||||
echo "app_version=${APP_VERSION}" >> "$GITHUB_OUTPUT"
|
echo "app_version=${APP_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
echo "Target Docker image: ${IMAGE_NAME} tags: ${TAGS} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})"
|
echo "Target Docker image: ${IMAGE_NAME} tags: ${TAGS} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})"
|
||||||
|
|||||||
+34
-1
@@ -6,6 +6,38 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.10.0] - 2026-10-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Nummerierte Listen im Outliner: `/number` (auch `/nummer`, `/liste`) oder `1. ` am Anfang eines leeren Punkts; Enter setzt die Liste fort, Enter in einem leeren Punkt beendet sie. Eingerückte Listen zählen mit Buchstaben (a., b.), die nächste Ebene römisch (i., ii.), danach wieder mit Zahlen. Export und Import als Markdown (`1.`).
|
||||||
|
- **Journal** im Workspace: ganz oben in der Seitenleiste; eine Notiz pro Tag mit festem Datumstitel, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach). Solange das Journal offen ist, zeigt ein Kalender mit Monats- und Jahresauswahl den Tag, bei dem man gerade ist; Tage mit Einträgen haben einen Punkt, ein Klick springt hin (und legt den Tag bei Bedarf an). „Teilen“ gilt dem gerade sichtbaren Tag. Am Handy klappt der Kalender über die Kopfzeile auf. Unter Darstellung pro Gerät abschaltbar. API: `GET /journal`, `POST /journal/{date}`.
|
||||||
|
- Benachrichtigungen: Der eigene Webhook ist jetzt ein vollwertiger Kanal neben E-Mail und Discord – eigene Spalte „Webhook“ für jedes Ereignis und die Tageszusammenfassung; gebündelte Nachrichten kommen als JSON (`event: "digest"` mit Liste der Ereignisse).
|
||||||
|
- Outliner: Neuer Slash-Befehl `/text` (sowie Aliase `/p`, `/paragraph`, `/bullet`, `/standard`), um beliebige Blöcke (Überschriften, Callouts, Aufgaben, Trennlinien) in Standard-Text umzuwandeln; Aufgaben-Optionen und Block-Aktionsblase bieten ebenfalls die Rückumwandlung an.
|
||||||
|
- Triage-Deck: Touch-freundliche Schnellaktions-Leiste unter der aktiven mobilen Karte (Wegwerfen, Liste, Notiz, Aufgabe) mit Daumenbedienung (≥ 44 px).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Schrift: MindSift liefert die Schrift Inter jetzt selbst aus (ohne externen Dienst), damit die Oberfläche auf allen Geräten gleich aussieht. Notiztitel stehen in einer feinen, dünnen Schrift und etwas größer.
|
||||||
|
- Notizen: Der Titel in der Kopfleiste erscheint erst, wenn der große Titel aus dem Bild gescrollt ist; am Handy passt die Kopfleiste in eine Zeile.
|
||||||
|
- Das Bild für `main` (`latest`) zeigt die Versionsnummer ohne `-dev`; nur `dev` und andere Zweige behalten den Zusatz.
|
||||||
|
- Outliner: Schriftgrößen für H1 (`text-xl sm:text-2xl font-bold`), H2 (`text-lg sm:text-xl font-semibold`) und H3 (`text-base font-semibold`) deutlich angehoben bei synchroner Textarea und dezentem oberen Abstand.
|
||||||
|
- Outliner: Aktions-Buttons (Zoom, Auslagern, Löschen, Text-Umwandlung) als schwebende, kontrastreiche Blase (`backdrop-blur-sm`) positioniert, wodurch der Fließtext beim Einblenden nicht mehr ruckartig umbricht.
|
||||||
|
- Outliner: Aktiver Eintrag im Slash-Befehlsmenü dezent als `bg-accent-dim text-accent` hervorgehoben statt vollflächig deckend.
|
||||||
|
- Quick Dump (Instant Capture): Platzhaltertexte gekürzt, vertikales Scrolling im einzeiligen Zustand unterbunden und Absende-Pfeil nach unten ausgerichtet.
|
||||||
|
- Zähler-Badges (`CountBadge`): Akzent-Badge über `--accent-dim` mit Rand, Muted-Badge über `--bg-panel-elevated` und `--text-muted` für hohe Lesbarkeit in allen Themes.
|
||||||
|
- Workspace: Popover-Dialoge (`FormSheet` mit Anker) öffnen zentriert unter dem Mauszeiger mit Rand-Schutz (Viewport-Clamping).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Notizen: Eingefügter Text mit Zeilenumbrüchen, der in einem einzigen Punkt landet (z. B. ein Absatz), verschwand – er wird jetzt übernommen. Leerzeilen trennen eingefügte Absätze in eigene Punkte, nummerierte Markdown-Listen bleiben nummeriert (auch mit 3 oder 4 Leerzeichen Einrückung), umbrochene Zeilen gehören zum Punkt darüber.
|
||||||
|
- Kontoeinstellungen: Der eigene Webhook ließ sich zwar eintragen, aber nicht mehr einschalten (seit dem Umbau der Benachrichtigungen in 0.8.0).
|
||||||
|
- Workspace: kein doppelter Scrollbalken mehr – die Seite selbst scrollt nicht, nur die Notiz; am Handy scrollt die Notiz nicht mehr getrennt von der Seite.
|
||||||
|
- Workspace: Ein abgebrochenes Ziehen in der Seitenleiste lässt „Auf Hauptebene verschieben“ nicht mehr stehen.
|
||||||
|
- Workspace: Der Knopf zum Ein-/Ausklappen der Ordnerleiste ist im eingeklappten Zustand dezent (nur das Symbol in Akzentfarbe).
|
||||||
|
- Outliner: Slash-Befehle werden an der tatsächlichen Cursorposition im Text erkannt (`/link`, `/diktat`); `/divider`, `/image`, `/audio` und `/code` werden nur noch in leeren Blöcken angeboten; das Menü schließt bei Klick ins Leere und Blur.
|
||||||
|
- Outliner: Aufgaben-Badges und Drei-Punkte-Menü aus dem Fließtext-Container entkoppelt, um störende Zeilenumbrüche am Zeilenende zu beheben.
|
||||||
|
- Outliner: Trennlinien (`divider`) können fokussiert und per `Backspace` oder `Entf` direkt in einen leeren Textpunkt umgewandelt werden.
|
||||||
|
- Kontoeinstellungen: Der Bereich „Profil & Anmeldedaten“ nutzt die volle Breite wie die übrigen Bereiche.
|
||||||
|
- Light-Theme: Notizen stehen auf einem weißen Blatt, die Ordnerleiste ist dezent grau abgesetzt (bisher umgekehrt). Themes können das über die neuen Variablen `--bg-editor` und `--bg-sidebar` selbst festlegen.
|
||||||
|
|
||||||
## [0.9.0] - 2026-10-30
|
## [0.9.0] - 2026-10-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -222,7 +254,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||||
|
|
||||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.9.0...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.10.0...dev
|
||||||
|
[0.10.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.9.0...v0.10.0
|
||||||
[0.9.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.8.0...v0.9.0
|
[0.9.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.8.0...v0.9.0
|
||||||
[0.8.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...v0.8.0
|
[0.8.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.7.0...v0.8.0
|
||||||
[0.7.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.7.0
|
[0.7.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.6.0...v0.7.0
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ Schreibzugriffe von Lesern auf Listen oder deren Aufgaben liefern `403` („Kein
|
|||||||
| GET/POST | `/documents/{id}/shares` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
|
| GET/POST | `/documents/{id}/shares` | Notiz-Freigaben (`userId`/`email`/`teamId`, `permission`: `read`/`write`) |
|
||||||
| DELETE | `/documents/{id}/shares/{userId}` | Notiz-Freigabe entfernen |
|
| DELETE | `/documents/{id}/shares/{userId}` | Notiz-Freigabe entfernen |
|
||||||
| POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt |
|
| POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt |
|
||||||
|
| GET | `/journal` | Tage des eigenen Journals, neueste zuerst → `{days: [{date, docId, empty}]}` (`empty`: Notiz noch ohne Text; Tage im Papierkorb fehlen) |
|
||||||
|
| POST | `/journal/{date}` | Notiz des Tages (`JJJJ-MM-TT`) öffnen; gibt es noch keine, wird sie angelegt (Titel z. B. „Freitag, 30. Oktober 2026“, Icon 📅) → Dokument, `201` bei Neuanlage. Die Journal-Notizen sind normale Notizen (Teilen, Suche, Sync) |
|
||||||
| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) |
|
| POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) |
|
||||||
| POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` |
|
| POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` |
|
||||||
| POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen |
|
| POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen |
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, beschrieben in
|
|||||||
|
|
||||||
## Benachrichtigungen
|
## Benachrichtigungen
|
||||||
|
|
||||||
`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) und eigener Discord-Webhook aus den Alarmen –, gespeichert als JSON in `users.notification_settings` (`GET /auth/me`, `PATCH /auth/settings` → `notificationSettings`). Ereignisse: `due` (Fälligkeits-Alarm, sofort über den Alarm-Ticker), `shared` (Notiz, Ordner, Aufgabenliste oder Liste geteilt; bei Gruppen alle Mitglieder), `taskDone` (Aufgabe einer geteilten Liste erledigt), `watched` (jede Änderung an einer beobachteten Aufgabe, `task_watchers`, `POST/DELETE /tasks/{id}/watch`). Alles außer `due` landet in `notification_queue`; ein Job im Minutentakt schickt je Person eine gebündelte Nachricht, sobald das älteste Ereignis `NOTIFY_DIGEST_MINUTES` (Standard 5) alt ist (E-Mail als Text + HTML, Discord als ein Embed mit höchstens 10 Zeilen), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht.
|
`internal/api/notify.go`, `internal/storage/notifications.go` (Migration 7). Jede Person wählt pro Ereignisart die Kanäle – eigene E-Mail (Alarm-Adresse, sonst Konto-Adresse) eigener Discord-Webhook und eigener Webhook (`customWebhookUrl`, JSON per POST) 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, eigener Webhook als JSON `{"event":"digest","heading","items":[{"kind","title","text"}],"link","timestamp"}`; fällige Aufgaben gehen dort als `{"event":"task_due",…}` einzeln raus), nach 3 Fehlversuchen wird verworfen. Eigene Änderungen benachrichtigen nie den Handelnden, außer bei beobachteten Aufgaben mit `includeOwnWatched`. `paused` stoppt alles, auch Alarme. **Tageszusammenfassung:** zur gewählten Uhrzeit (Server-Zeitzone, innerhalb von 30 Minuten, einmal pro Tag) überfällige, heutige und morgige offene Aufgaben samt Anzahl der folgenden 7 Tage; nichts fällig → keine Nachricht. Im Demo-Modus laufen die Jobs nicht.
|
||||||
|
|
||||||
## Link-Vorschau
|
## Link-Vorschau
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -14,6 +14,7 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j
|
|||||||
| `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` |
|
| `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` |
|
||||||
| `notification_queue` | Wartende Benachrichtigungen bis zum gebündelten Versand (Migration 7) | `user_id`, `kind` (`shared`/`taskDone`/`watched`), `payload` (JSON), `created_at`, `attempts` |
|
| `notification_queue` | Wartende Benachrichtigungen bis zum gebündelten Versand (Migration 7) | `user_id`, `kind` (`shared`/`taskDone`/`watched`), `payload` (JSON), `created_at`, `attempts` |
|
||||||
| `task_watchers` | Beobachtete Aufgaben (Migration 7) | `task_id`, `user_id`, `created_at` |
|
| `task_watchers` | Beobachtete Aufgaben (Migration 7) | `task_id`, `user_id`, `created_at` |
|
||||||
|
| `journal_days` | Journal: welche Notiz zu welchem Tag gehört (Migration 8) | `user_id`, `day` (`JJJJ-MM-TT`), `doc_id`; Primärschlüssel `(user_id, day)` |
|
||||||
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
|
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
|
||||||
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
|
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
|
||||||
| `api_keys` | Webhook-/API-Schlüssel | `name`, `key_prefix`, `key_hash` (SHA-256), `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`), `last_used_at` |
|
| `api_keys` | Webhook-/API-Schlüssel | `name`, `key_prefix`, `key_hash` (SHA-256), `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`), `last_used_at` |
|
||||||
@@ -84,7 +85,7 @@ teams 1─n team_members n─1 users
|
|||||||
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
||||||
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
||||||
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
||||||
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (Migrationen 3 bis 7: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
|
- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (Migrationen 3 bis 8: 3 „Basis-Schema v0.4.1“, 4 „Sitzungen“, 5 „Medien“, 6 „Link-Vorschauen“, 7 „Benachrichtigungen“, 8 „Journal“) genau einmal aus und vermerkt sie in `schema_migrations` (`id`, `name`, `applied_at`); `PRAGMA user_version` zeigt die höchste angewandte Nummer. Schritt 3 (`migrateBaseline`) baut das Basisschema auf und bringt ältere Datenbanken auf diesen Stand; Spalten werden nur angelegt, wenn sie fehlen. Schlägt ein Schritt fehl, bricht der Start mit der Fehlermeldung ab, statt mit halbem Schema weiterzulaufen. Neue Änderungen kommen als neuer Schritt ans Ende, alte Schritte werden nie verändert. Daten werden beim Start nie gelöscht; nur der Demo-Modus leert die Datenbank.
|
||||||
|
|
||||||
## Sicherung
|
## Sicherung
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,8 @@ 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.
|
* 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.
|
* **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`, `/extract`, `/image`, `/audio`, `/diktat`.
|
* **Journal:** eine Notiz pro Tag, alle Tage untereinander (heute oben, ältere laden beim Scrollen nach), mit Kalender zum Springen; einzelne Tage lassen sich teilen. Pro Gerät abschaltbar.
|
||||||
|
* Blocktypen und Slash-Befehle: `/todo`, `/number` (nummerierte Liste: 1., a., i.), `/link`, `/h1`–`/h3`, `/code` (mit Syntax-Hervorhebung), `/callout`, `/divider`, `/extract`, `/image`, `/audio`, `/diktat`.
|
||||||
* **Bilder & Audio in Notizen:** `/image` (Datei, Kamera, Zwischenablage oder Link), `/audio` (Sprachnotiz oder Datei-Upload) und `/diktat` (Live-Transkription).
|
* **Bilder & Audio in Notizen:** `/image` (Datei, Kamera, Zwischenablage oder Link), `/audio` (Sprachnotiz oder Datei-Upload) und `/diktat` (Live-Transkription).
|
||||||
* **Link-Vorschau:** Automatische Vorschaukarten über Punkten mit reiner Web-Adresse.
|
* **Link-Vorschau:** 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).
|
* **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).
|
||||||
|
|||||||
Generated
+12
-2
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.9.0",
|
"version": "0.10.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.9.0",
|
"version": "0.10.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
"@types/prismjs": "^1.26.6",
|
"@types/prismjs": "^1.26.6",
|
||||||
"emoji-picker-react": "4.12.0",
|
"emoji-picker-react": "4.12.0",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
@@ -1024,6 +1025,15 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@fontsource-variable/inter": {
|
||||||
|
"version": "5.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz",
|
||||||
|
"integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==",
|
||||||
|
"license": "OFL-1.1",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.9.0",
|
"version": "0.10.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
"test:run": "vitest run"
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
"@types/prismjs": "^1.26.6",
|
"@types/prismjs": "^1.26.6",
|
||||||
"emoji-picker-react": "4.12.0",
|
"emoji-picker-react": "4.12.0",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
|
|||||||
+12
-3
@@ -750,7 +750,11 @@ export const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas text-main flex flex-col font-sans selection:bg-accent/30">
|
<div
|
||||||
|
className={`bg-canvas text-main flex flex-col font-sans selection:bg-accent/30 ${
|
||||||
|
activeTab === 'workspace' ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
|
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
|
||||||
<header className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm">
|
<header className="sticky top-0 z-30 bg-canvas/85 backdrop-blur-md sm:bg-panel sm:border-b sm:border-border sm:shadow-sm">
|
||||||
{/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */}
|
{/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */}
|
||||||
@@ -1113,9 +1117,14 @@ export const App: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Container */}
|
{/* Main Container */}
|
||||||
<main className="flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6">
|
{/* The workspace fills the window exactly and scrolls only inside the note, never the page */}
|
||||||
|
<main
|
||||||
|
className={`flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6 ${
|
||||||
|
activeTab === 'workspace' ? 'min-h-0 flex flex-col' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
|
||||||
<section key={dataEpoch}>
|
<section key={dataEpoch} className={activeTab === 'workspace' ? 'flex-1 min-h-0 flex flex-col' : undefined}>
|
||||||
{activeTab === 'triage' && (
|
{activeTab === 'triage' && (
|
||||||
<TriageDeck
|
<TriageDeck
|
||||||
deck={deck}
|
deck={deck}
|
||||||
|
|||||||
@@ -36,7 +36,12 @@ export const FormSheet: React.FC<FormSheetProps> = ({
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const desktopAnchor = anchor
|
const desktopAnchor = anchor
|
||||||
? ({ '--sheet-left': `${anchor.x}px`, '--sheet-top': `${anchor.y}px` } as React.CSSProperties)
|
? ({
|
||||||
|
'--sheet-left': `${anchor.x}px`,
|
||||||
|
'--sheet-top': `${anchor.y}px`,
|
||||||
|
'--sheet-x': `clamp(12px, calc(${anchor.x}px - 8.25rem), calc(100vw - 16.5rem - 12px))`,
|
||||||
|
'--sheet-y': `calc(${anchor.y}px + 8px)`,
|
||||||
|
} as React.CSSProperties)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -54,7 +59,7 @@ export const FormSheet: React.FC<FormSheetProps> = ({
|
|||||||
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto
|
className={`fixed inset-x-0 bottom-[var(--kb-inset)] max-h-[var(--vv-max)] panel-surface rounded-t-2xl border-t border-border shadow-panel p-4 space-y-3 overflow-y-auto
|
||||||
sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${
|
sm:inset-x-auto sm:bottom-auto sm:max-h-[85vh] sm:w-full sm:rounded-card sm:border ${
|
||||||
anchor
|
anchor
|
||||||
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-left)] sm:top-[var(--sheet-top)]'
|
? 'sm:max-w-[16.5rem] sm:left-[var(--sheet-x)] sm:top-[var(--sheet-y)]'
|
||||||
: 'sm:max-w-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2'
|
: 'sm:max-w-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState, useMemo } from 'react';
|
|||||||
import {
|
import {
|
||||||
Mic,
|
Mic,
|
||||||
MicOff,
|
MicOff,
|
||||||
ArrowUp,
|
ArrowDown,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Calendar,
|
Calendar,
|
||||||
Check,
|
Check,
|
||||||
@@ -73,6 +73,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
});
|
});
|
||||||
const isListening = voice.state === 'listening';
|
const isListening = voice.state === 'listening';
|
||||||
const isTranscribing = voice.state === 'transcribing';
|
const isTranscribing = voice.state === 'transcribing';
|
||||||
|
const [isOverMaxHeight, setIsOverMaxHeight] = useState(false);
|
||||||
|
|
||||||
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
const adjustHeight = (el?: HTMLTextAreaElement | null) => {
|
||||||
const targetEl = el || inputRef.current;
|
const targetEl = el || inputRef.current;
|
||||||
@@ -82,8 +83,10 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
const scrollH = targetEl.scrollHeight;
|
const scrollH = targetEl.scrollHeight;
|
||||||
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
|
targetEl.style.height = `${Math.min(scrollH, 140)}px`;
|
||||||
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
|
setIsMultiline(targetEl.value.includes('\n') || scrollH > 28);
|
||||||
|
setIsOverMaxHeight(scrollH > 140);
|
||||||
} else {
|
} else {
|
||||||
setIsMultiline(false);
|
setIsMultiline(false);
|
||||||
|
setIsOverMaxHeight(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -267,16 +270,18 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
: isTranscribing
|
: isTranscribing
|
||||||
? 'Wird erkannt…'
|
? 'Wird erkannt…'
|
||||||
: hideTargetPicker || fixedTarget === 'inbox'
|
: hideTargetPicker || fixedTarget === 'inbox'
|
||||||
? 'Quick Dump erfassen… (Enter zum Speichern)'
|
? 'Quick Dump erfassen…'
|
||||||
: target === 'task'
|
: target === 'task'
|
||||||
? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
|
? 'Aufgabe erfassen…'
|
||||||
: target === 'list_item'
|
: target === 'list_item'
|
||||||
? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…`
|
? `Zu „${selectedList?.title || 'Liste'}“ hinzufügen…`
|
||||||
: target === 'document'
|
: target === 'document'
|
||||||
? 'Neue Workspace-Notiz direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)'
|
? 'Notiz erfassen…'
|
||||||
: 'Quick Dump: Gedanke reinrotzen… (C / Enter, Shift+Enter für Mehrzeiler)'
|
: 'Quick Dump…'
|
||||||
}
|
}
|
||||||
className="w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 overflow-y-auto"
|
className={`w-full bg-transparent text-main placeholder-faint text-xs sm:text-sm focus:outline-none resize-none leading-5 py-0.5 ${
|
||||||
|
isOverMaxHeight ? 'overflow-y-auto' : 'overflow-hidden'
|
||||||
|
}`}
|
||||||
style={{ minHeight: '22px', maxHeight: '140px' }}
|
style={{ minHeight: '22px', maxHeight: '140px' }}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
@@ -360,7 +365,7 @@ export const InstantCaptureBar: React.FC<InstantCaptureBarProps> = ({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ArrowUp className="w-3 h-3" />
|
<ArrowDown className="w-3 h-3" />
|
||||||
<span className="inline">
|
<span className="inline">
|
||||||
{hideTargetPicker || (fixedTarget || target) === 'inbox'
|
{hideTargetPicker || (fixedTarget || target) === 'inbox'
|
||||||
? 'Quick Dump'
|
? 'Quick Dump'
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { ImportCard } from './ImportCard';
|
|||||||
import { SessionsPanel } from './SessionsPanel';
|
import { SessionsPanel } from './SessionsPanel';
|
||||||
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
import { ToggleSwitch } from '../../components/ToggleSwitch';
|
||||||
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
|
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
|
||||||
|
import { isJournalEnabled, setJournalEnabled } from '../../lib/journal';
|
||||||
import { FormSheet } from '../../components/FormSheet';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
|
|
||||||
interface UserSettingsViewProps {
|
interface UserSettingsViewProps {
|
||||||
@@ -51,6 +52,7 @@ export function buildNotificationSettings(user: User): NotificationSettings {
|
|||||||
const hasLegacy = Array.isArray(user.alarmChannels);
|
const hasLegacy = Array.isArray(user.alarmChannels);
|
||||||
const dueEmailDefault = hasLegacy ? user.alarmChannels!.includes('email') : true;
|
const dueEmailDefault = hasLegacy ? user.alarmChannels!.includes('email') : true;
|
||||||
const dueDiscordDefault = hasLegacy ? user.alarmChannels!.includes('discord') : true;
|
const dueDiscordDefault = hasLegacy ? user.alarmChannels!.includes('discord') : true;
|
||||||
|
const dueWebhookDefault = hasLegacy ? user.alarmChannels!.includes('webhook') : false;
|
||||||
const s = user.notificationSettings;
|
const s = user.notificationSettings;
|
||||||
return {
|
return {
|
||||||
paused: Boolean(s?.paused),
|
paused: Boolean(s?.paused),
|
||||||
@@ -59,18 +61,22 @@ export function buildNotificationSettings(user: User): NotificationSettings {
|
|||||||
due: {
|
due: {
|
||||||
email: s?.events?.due?.email ?? dueEmailDefault,
|
email: s?.events?.due?.email ?? dueEmailDefault,
|
||||||
discord: s?.events?.due?.discord ?? dueDiscordDefault,
|
discord: s?.events?.due?.discord ?? dueDiscordDefault,
|
||||||
|
webhook: s?.events?.due?.webhook ?? dueWebhookDefault,
|
||||||
},
|
},
|
||||||
shared: {
|
shared: {
|
||||||
email: s?.events?.shared?.email ?? true,
|
email: s?.events?.shared?.email ?? true,
|
||||||
discord: s?.events?.shared?.discord ?? false,
|
discord: s?.events?.shared?.discord ?? false,
|
||||||
|
webhook: s?.events?.shared?.webhook ?? false,
|
||||||
},
|
},
|
||||||
taskDone: {
|
taskDone: {
|
||||||
email: s?.events?.taskDone?.email ?? false,
|
email: s?.events?.taskDone?.email ?? false,
|
||||||
discord: s?.events?.taskDone?.discord ?? false,
|
discord: s?.events?.taskDone?.discord ?? false,
|
||||||
|
webhook: s?.events?.taskDone?.webhook ?? false,
|
||||||
},
|
},
|
||||||
watched: {
|
watched: {
|
||||||
email: s?.events?.watched?.email ?? true,
|
email: s?.events?.watched?.email ?? true,
|
||||||
discord: s?.events?.watched?.discord ?? true,
|
discord: s?.events?.watched?.discord ?? true,
|
||||||
|
webhook: s?.events?.watched?.webhook ?? false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
dailySummary: {
|
dailySummary: {
|
||||||
@@ -78,6 +84,7 @@ export function buildNotificationSettings(user: User): NotificationSettings {
|
|||||||
time: s?.dailySummary?.time || '07:00',
|
time: s?.dailySummary?.time || '07:00',
|
||||||
email: s?.dailySummary?.email ?? true,
|
email: s?.dailySummary?.email ?? true,
|
||||||
discord: s?.dailySummary?.discord ?? false,
|
discord: s?.dailySummary?.discord ?? false,
|
||||||
|
webhook: s?.dailySummary?.webhook ?? false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -144,6 +151,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
// The public demo sends nothing and keeps the shared accounts intact (FLAW-33)
|
// The public demo sends nothing and keeps the shared accounts intact (FLAW-33)
|
||||||
const [demoMode, setDemoMode] = useState(false);
|
const [demoMode, setDemoMode] = useState(false);
|
||||||
const linkPreviewsOn = useLinkPreviewsEnabled();
|
const linkPreviewsOn = useLinkPreviewsEnabled();
|
||||||
|
const [journalOn, setJournalOn] = useState(isJournalEnabled);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api
|
api
|
||||||
.getAuthStatus()
|
.getAuthStatus()
|
||||||
@@ -250,9 +258,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// --- Multi-Channel Alarms State ---
|
// --- Multi-Channel Alarms State ---
|
||||||
const [alarmChannels, setAlarmChannels] = useState<string[]>(
|
|
||||||
Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []
|
|
||||||
);
|
|
||||||
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>(() =>
|
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>(() =>
|
||||||
buildNotificationSettings(currentUser)
|
buildNotificationSettings(currentUser)
|
||||||
);
|
);
|
||||||
@@ -270,7 +275,6 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
setDisplayName(currentUser.displayName || '');
|
setDisplayName(currentUser.displayName || '');
|
||||||
setEmail(currentUser.email || '');
|
setEmail(currentUser.email || '');
|
||||||
setAvatarUrl(currentUser.avatarUrl || '');
|
setAvatarUrl(currentUser.avatarUrl || '');
|
||||||
setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []);
|
|
||||||
setNotificationSettings(buildNotificationSettings(currentUser));
|
setNotificationSettings(buildNotificationSettings(currentUser));
|
||||||
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
|
setAlarmEmail(currentUser.alarmEmail || currentUser.email || '');
|
||||||
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
|
setDiscordWebhookUrl(currentUser.discordWebhookUrl || '');
|
||||||
@@ -278,13 +282,8 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
|
setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30);
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
const toggleAlarmChannel = (ch: string) => {
|
|
||||||
setAlarmChannels((prev) =>
|
|
||||||
prev.includes(ch) ? prev.filter((item) => item !== ch) : [...prev, ch]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord', value: boolean) => {
|
const setEventChannel = (eventKey: keyof NotificationEvents, channel: 'email' | 'discord' | 'webhook', value: boolean) => {
|
||||||
setNotificationSettings((prev) => ({
|
setNotificationSettings((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
events: {
|
events: {
|
||||||
@@ -386,7 +385,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
const nextAlarmChannels = [
|
const nextAlarmChannels = [
|
||||||
...(notificationSettings.events.due.email ? ['email'] : []),
|
...(notificationSettings.events.due.email ? ['email'] : []),
|
||||||
...(notificationSettings.events.due.discord ? ['discord'] : []),
|
...(notificationSettings.events.due.discord ? ['discord'] : []),
|
||||||
...(alarmChannels.includes('webhook') ? ['webhook'] : []),
|
...(notificationSettings.events.due.webhook ? ['webhook'] : []),
|
||||||
];
|
];
|
||||||
const updated = await api.updateSettings({
|
const updated = await api.updateSettings({
|
||||||
alarmChannels: nextAlarmChannels,
|
alarmChannels: nextAlarmChannels,
|
||||||
@@ -581,7 +580,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
{/* SECTION 1: PROFIL & SICHERHEIT */}
|
{/* SECTION 1: PROFIL & SICHERHEIT */}
|
||||||
{/* ================================================================= */}
|
{/* ================================================================= */}
|
||||||
{section === 'profile' && (
|
{section === 'profile' && (
|
||||||
<div className="space-y-5 max-w-2xl">
|
<div className="space-y-5">
|
||||||
<div className="border-b border-border pb-3">
|
<div className="border-b border-border pb-3">
|
||||||
<h2 className="text-sm font-bold text-main">Profil & Anmeldedaten</h2>
|
<h2 className="text-sm font-bold text-main">Profil & Anmeldedaten</h2>
|
||||||
<p className="text-muted mt-0.5">
|
<p className="text-muted mt-0.5">
|
||||||
@@ -857,6 +856,24 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
<ToggleSwitch checked={linkPreviewsOn} onChange={setLinkPreviewsEnabled} title="Link-Vorschauen anzeigen" />
|
<ToggleSwitch checked={linkPreviewsOn} onChange={setLinkPreviewsEnabled} title="Link-Vorschauen anzeigen" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3.5 rounded-card border border-border bg-canvas flex items-center justify-between gap-4">
|
||||||
|
<div className="text-xs">
|
||||||
|
<p className="font-semibold text-main">Journal anzeigen</p>
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Eine Notiz pro Tag, untereinander mit Kalender – ganz oben in der Notizen-Seitenleiste. Ausschalten
|
||||||
|
blendet es nur aus, die Tage bleiben als Notizen erhalten. Gilt für dieses Gerät.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={journalOn}
|
||||||
|
onChange={(v) => {
|
||||||
|
setJournalEnabled(v);
|
||||||
|
setJournalOn(v);
|
||||||
|
}}
|
||||||
|
title="Journal anzeigen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{themes.map((t) => {
|
{themes.map((t) => {
|
||||||
const isSelected = activeTheme === t.id;
|
const isSelected = activeTheme === t.id;
|
||||||
@@ -1119,13 +1136,14 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Routing Matrix: Events x (E-Mail / Discord) */}
|
{/* Routing Matrix: Events x (E-Mail / Discord / own webhook) */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const hasDiscordWebhook = Boolean(discordWebhookUrl.trim());
|
const hasDiscordWebhook = Boolean(discordWebhookUrl.trim());
|
||||||
|
const hasCustomWebhook = Boolean(customWebhookUrl.trim());
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="p-4 bg-canvas border border-border space-y-3">
|
<div className="p-4 bg-canvas border border-border space-y-3">
|
||||||
<div className="grid grid-cols-[1fr_auto_auto] items-center gap-4 pb-2 border-b border-border">
|
<div className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-2 sm:gap-4 pb-2 border-b border-border">
|
||||||
<span className="font-bold text-main">Ereignis</span>
|
<span className="font-bold text-main">Ereignis</span>
|
||||||
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
|
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
|
||||||
<Mail className="w-3.5 h-3.5 text-accent" />
|
<Mail className="w-3.5 h-3.5 text-accent" />
|
||||||
@@ -1135,6 +1153,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
<MessageSquare className="w-3.5 h-3.5 text-accent" />
|
<MessageSquare className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Discord</span>
|
<span>Discord</span>
|
||||||
</span>
|
</span>
|
||||||
|
<span className="w-16 text-center font-semibold text-muted flex items-center justify-center gap-1">
|
||||||
|
<Globe className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>Webhook</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border">
|
||||||
@@ -1143,7 +1165,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={row.key}
|
key={row.key}
|
||||||
className="grid grid-cols-[1fr_auto_auto] items-center gap-4 py-2.5"
|
className="grid grid-cols-[1fr_auto_auto_auto] items-center gap-2 sm:gap-4 py-2.5"
|
||||||
>
|
>
|
||||||
<span className="font-medium text-main">{row.label}</span>
|
<span className="font-medium text-main">{row.label}</span>
|
||||||
<div className="w-16 flex justify-center">
|
<div className="w-16 flex justify-center">
|
||||||
@@ -1165,6 +1187,18 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="w-16 flex justify-center">
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={routing.webhook}
|
||||||
|
disabled={!hasCustomWebhook}
|
||||||
|
onChange={(v) => setEventChannel(row.key, 'webhook', v)}
|
||||||
|
title={
|
||||||
|
hasCustomWebhook
|
||||||
|
? `${row.label} – Webhook`
|
||||||
|
: 'Bitte zuerst unten eine eigene Webhook URL eintragen'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1178,6 +1212,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist.
|
Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{!hasCustomWebhook && (
|
||||||
|
<p className="text-[11px] text-faint">
|
||||||
|
Webhook-Schalter werden aktiv, sobald unten eine eigene Webhook-URL eingetragen ist.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Daily Summary */}
|
{/* Daily Summary */}
|
||||||
@@ -1250,6 +1289,24 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted">Webhook</span>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={notificationSettings.dailySummary.webhook}
|
||||||
|
disabled={!hasCustomWebhook}
|
||||||
|
onChange={(v) =>
|
||||||
|
setNotificationSettings((prev) => ({
|
||||||
|
...prev,
|
||||||
|
dailySummary: { ...prev.dailySummary, webhook: v },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
hasCustomWebhook
|
||||||
|
? 'Tageszusammenfassung – Webhook'
|
||||||
|
: 'Bitte zuerst unten eine eigene Webhook URL eintragen'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1359,17 +1416,11 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
{/* Channel 3: Custom HTTP Webhook */}
|
{/* Channel 3: Custom HTTP Webhook */}
|
||||||
<div
|
<div
|
||||||
className={`p-4 border transition-colors space-y-3 bg-canvas ${
|
className={`p-4 border transition-colors space-y-3 bg-canvas ${
|
||||||
alarmChannels.includes('webhook') ? 'border-accent' : 'border-border'
|
customWebhookUrl.trim() ? 'border-accent' : 'border-border'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<label className="flex items-center gap-2.5 cursor-pointer select-none">
|
<div className="flex items-center gap-2.5">
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={alarmChannels.includes('webhook')}
|
|
||||||
onChange={() => toggleAlarmChannel('webhook')}
|
|
||||||
className="w-4 h-4 accent-blue-600"
|
|
||||||
/>
|
|
||||||
<Globe className="w-4 h-4 text-accent" />
|
<Globe className="w-4 h-4 text-accent" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-bold text-main block">
|
<span className="font-bold text-main block">
|
||||||
@@ -1377,10 +1428,10 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-muted">
|
<span className="text-[11px] text-muted">
|
||||||
Für eigene Empfänger-Skripte, Ntfy, Gotify, Slack, Mattermost oder
|
Für eigene Empfänger-Skripte, Ntfy, Gotify, Slack, Mattermost oder
|
||||||
Automatisierungen.
|
Automatisierungen. Welche Ereignisse hierher gehen, stellst du oben in der Spalte „Webhook“ ein.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -88,19 +88,53 @@ describe('UserSettingsView notification matrix', () => {
|
|||||||
paused: true,
|
paused: true,
|
||||||
includeOwnWatched: true,
|
includeOwnWatched: true,
|
||||||
events: {
|
events: {
|
||||||
due: { email: true, discord: true },
|
due: { email: true, discord: true, webhook: false },
|
||||||
shared: { email: true, discord: false },
|
shared: { email: true, discord: false, webhook: false },
|
||||||
taskDone: { email: true, discord: false },
|
taskDone: { email: true, discord: false, webhook: false },
|
||||||
watched: { email: true, discord: false },
|
watched: { email: true, discord: false, webhook: false },
|
||||||
},
|
},
|
||||||
dailySummary: {
|
dailySummary: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
time: '08:30',
|
time: '08:30',
|
||||||
email: true,
|
email: true,
|
||||||
discord: true,
|
discord: true,
|
||||||
|
webhook: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('enables the webhook column once an own webhook URL is entered and saves it as due channel', async () => {
|
||||||
|
render(
|
||||||
|
<UserSettingsView
|
||||||
|
currentUser={baseUser}
|
||||||
|
activeTheme="dark"
|
||||||
|
themes={[]}
|
||||||
|
customThemes={[]}
|
||||||
|
onThemeChange={() => {}}
|
||||||
|
onCustomThemesChange={() => {}}
|
||||||
|
onUserUpdated={() => {}}
|
||||||
|
onBack={() => {}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByText('Alarme'));
|
||||||
|
const off = screen.getAllByTitle('Bitte zuerst unten eine eigene Webhook URL eintragen');
|
||||||
|
expect(off.length).toBe(5);
|
||||||
|
expect((off[0] as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/dein-server\.local/), {
|
||||||
|
target: { value: 'https://hooks.example.com/mindsift' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTitle('Fällige Aufgaben (sofort) – Webhook'));
|
||||||
|
fireEvent.click(screen.getByTitle('Freigabe erhalten – Webhook'));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Alarm-Einstellungen speichern' }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.updateSettings).toHaveBeenCalledTimes(1));
|
||||||
|
const patch = vi.mocked(api.updateSettings).mock.calls[0][0];
|
||||||
|
expect(patch.customWebhookUrl).toBe('https://hooks.example.com/mindsift');
|
||||||
|
expect(patch.alarmChannels).toEqual(['email', 'webhook']);
|
||||||
|
expect(patch.notificationSettings?.events.due.webhook).toBe(true);
|
||||||
|
expect(patch.notificationSettings?.events.shared.webhook).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -725,7 +725,73 @@ export const TriageDeck: React.FC<TriageDeckProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Swipe + Long-Press), shown on Desktop */}
|
{/* Mobile Quick Action Buttons under the card (Touch targets >= 44px) */}
|
||||||
|
<div className="grid grid-cols-4 gap-1.5 sm:hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction('discard')}
|
||||||
|
title="Wegwerfen"
|
||||||
|
className="btn-action h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] whitespace-nowrap touch-manipulation cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-muted flex-shrink-0" />
|
||||||
|
<span>Wegwerfen</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={writableLists.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
if (singleWritableList) {
|
||||||
|
handleAction('keep', {
|
||||||
|
convertTo: 'list_item',
|
||||||
|
targetListId: singleWritableList.id,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowDateModal(false);
|
||||||
|
setShowListSelector((prev) => !prev);
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
writableLists.length === 0
|
||||||
|
? 'Keine Liste vorhanden'
|
||||||
|
: singleWritableList
|
||||||
|
? `Auf ${singleWritableList.title}`
|
||||||
|
: 'Auf eine geteilte Liste setzen'
|
||||||
|
}
|
||||||
|
className={`btn-action h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] whitespace-nowrap touch-manipulation cursor-pointer ${
|
||||||
|
writableLists.length === 0
|
||||||
|
? 'opacity-40 cursor-not-allowed'
|
||||||
|
: showListSelector
|
||||||
|
? 'border-accent text-accent bg-accent-dim'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ShoppingCart className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
|
<span className="truncate max-w-full">{singleWritableList ? singleWritableList.title : 'Liste'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction('keep', { convertTo: 'document' })}
|
||||||
|
title="In Notiz umwandeln"
|
||||||
|
className="btn-action h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] whitespace-nowrap touch-manipulation cursor-pointer"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4 text-accent flex-shrink-0" />
|
||||||
|
<span>Notiz</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction('keep', { convertTo: 'task' })}
|
||||||
|
title="Als Aufgabe übernehmen"
|
||||||
|
className="btn-primary h-11 px-1 flex flex-col items-center justify-center gap-0.5 text-[11px] font-semibold whitespace-nowrap touch-manipulation cursor-pointer"
|
||||||
|
>
|
||||||
|
<CheckSquare className="w-4 h-4 flex-shrink-0" />
|
||||||
|
<span>Aufgabe</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Primary Triage Decision Bar: Hidden on mobile (handled via Mobile Quick Actions / Swipe), shown on Desktop */}
|
||||||
<div className="hidden sm:flex items-center gap-1.5">
|
<div className="hidden sm:flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAction('discard')}
|
onClick={() => handleAction('discard')}
|
||||||
|
|||||||
@@ -160,4 +160,26 @@ describe('TriageDeck single-list shortcut', () => {
|
|||||||
expect(api.triageAction).not.toHaveBeenCalled();
|
expect(api.triageAction).not.toHaveBeenCalled();
|
||||||
expect(onDeckChange).not.toHaveBeenCalled();
|
expect(onDeckChange).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders mobile quick action buttons and triggers discard', async () => {
|
||||||
|
const onDeckChange = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<TriageDeck
|
||||||
|
deck={sampleDeck}
|
||||||
|
lists={[ownList]}
|
||||||
|
onDeckChange={onDeckChange}
|
||||||
|
onOpenDocument={vi.fn()}
|
||||||
|
onFocusCapture={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const [mobileDiscard] = screen.getAllByRole('button', { name: /^Wegwerfen$/i });
|
||||||
|
fireEvent.click(mobileDiscard);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(api.triageAction).toHaveBeenCalledWith('th_1', 'discard', expect.objectContaining({}));
|
||||||
|
expect(onDeckChange).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -61,6 +61,8 @@ export interface DocumentEditorProps {
|
|||||||
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
|
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
|
||||||
onDocCreated?: (doc: Document) => void;
|
onDocCreated?: (doc: Document) => void;
|
||||||
onDocRemoved?: (docId: string) => void;
|
onDocRemoved?: (docId: string) => void;
|
||||||
|
/** 'journal': one day inside the journal list – no own scrolling, header or backlinks, fixed title */
|
||||||
|
variant?: 'page' | 'journal';
|
||||||
}
|
}
|
||||||
|
|
||||||
import { EmojiPickerModal } from './EmojiPickerModal';
|
import { EmojiPickerModal } from './EmojiPickerModal';
|
||||||
@@ -211,7 +213,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onRequestMerge,
|
onRequestMerge,
|
||||||
onDocCreated,
|
onDocCreated,
|
||||||
onDocRemoved,
|
onDocRemoved,
|
||||||
|
variant = 'page',
|
||||||
}) => {
|
}) => {
|
||||||
|
const inJournal = variant === 'journal';
|
||||||
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
||||||
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
||||||
const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
|
const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
|
||||||
@@ -230,6 +234,9 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
|
|
||||||
|
|
||||||
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
// The header repeats the title only once the big title has scrolled out of view
|
||||||
|
const [titleScrolledAway, setTitleScrolledAway] = useState(false);
|
||||||
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
|
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
|
||||||
const docRef = useRef<Document | null>(doc);
|
const docRef = useRef<Document | null>(doc);
|
||||||
docRef.current = doc;
|
docRef.current = doc;
|
||||||
@@ -239,6 +246,22 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
|
const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
|
||||||
currentBlocksRef.current = blocks;
|
currentBlocksRef.current = blocks;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = scrollRef.current;
|
||||||
|
const el = titleInputRef.current;
|
||||||
|
if (inJournal || !root || !el || typeof IntersectionObserver === 'undefined') {
|
||||||
|
setTitleScrolledAway(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => setTitleScrolledAway(!entry.isIntersecting),
|
||||||
|
// the sticky header covers the top of the scroll area
|
||||||
|
{ root, rootMargin: '-56px 0px 0px 0px' }
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [zoomedBlockId, doc?.id, inJournal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
||||||
const focusTitle = () => {
|
const focusTitle = () => {
|
||||||
@@ -723,6 +746,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``);
|
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``);
|
||||||
} else if (b.type === 'divider') {
|
} else if (b.type === 'divider') {
|
||||||
lines.push(`${indent}- ---`);
|
lines.push(`${indent}- ---`);
|
||||||
|
} else if (b.type === 'numbered') {
|
||||||
|
lines.push(`${indent}1. ${b.content}`);
|
||||||
} else {
|
} else {
|
||||||
lines.push(`${indent}- ${b.content}`);
|
lines.push(`${indent}- ${b.content}`);
|
||||||
}
|
}
|
||||||
@@ -763,15 +788,20 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col h-full min-h-0 bg-canvas overflow-y-auto" data-sync-channel={engineState?.channel}>
|
<div
|
||||||
{/* Top Document Action Bar */}
|
ref={scrollRef}
|
||||||
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-canvas border-b border-border">
|
className={inJournal ? 'flex flex-col bg-editor' : 'flex-1 flex flex-col h-full min-h-0 bg-editor overflow-y-auto'}
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
data-sync-channel={engineState?.channel}
|
||||||
|
>
|
||||||
|
{/* Top Document Action Bar (the journal has one for all days) */}
|
||||||
|
{!inJournal && (
|
||||||
|
<div className="sticky top-0 z-20 flex items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-editor border-b border-border">
|
||||||
|
<div className="flex flex-1 items-center gap-2 min-w-0">
|
||||||
{onBackMobile && (
|
{onBackMobile && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onBackMobile}
|
onClick={onBackMobile}
|
||||||
className="lg:hidden btn-icon h-11 w-11 sm:h-7 sm:w-7"
|
className="lg:hidden btn-icon shrink-0 h-11 w-11 sm:h-7 sm:w-7"
|
||||||
title="Zurück zur Seitenleiste"
|
title="Zurück zur Seitenleiste"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-3.5 h-3.5" />
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
@@ -823,7 +853,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted">
|
<div
|
||||||
|
className={`flex min-w-0 items-center gap-1.5 text-xs text-muted transition-opacity duration-200 ${
|
||||||
|
titleScrolledAway ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||||
|
}`}
|
||||||
|
aria-hidden={!titleScrolledAway}
|
||||||
|
>
|
||||||
<FileText className="w-3.5 h-3.5 text-accent shrink-0" />
|
<FileText className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||||
<span className="truncate max-w-[180px] font-medium text-main">
|
<span className="truncate max-w-[180px] font-medium text-main">
|
||||||
{title || doc?.title || 'Dokument'}
|
{title || doc?.title || 'Dokument'}
|
||||||
@@ -868,7 +903,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Action Buttons */}
|
{/* Right Action Buttons */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
{!inTrash && (
|
{!inTrash && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -993,6 +1028,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* In the trash: read-only with a way back */}
|
{/* In the trash: read-only with a way back */}
|
||||||
{inTrash && (
|
{inTrash && (
|
||||||
@@ -1022,7 +1058,12 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
{/* Phones: almost no side margin; very deep outlines scroll sideways (rows keep a minimum width) */}
|
{/* Phones: almost no side margin; very deep outlines scroll sideways (rows keep a minimum width) */}
|
||||||
<div className="w-full pl-2 pr-1 sm:px-8 py-5 flex-1 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch]">
|
{/* shrink-0: the sideways scroll area must keep its full height, so only the editor scrolls vertically */}
|
||||||
|
<div
|
||||||
|
className={`w-full pl-2 pr-1 sm:px-8 flex-auto shrink-0 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch] ${
|
||||||
|
inJournal ? 'py-3' : 'py-5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{zoomedBlock ? (
|
{zoomedBlock ? (
|
||||||
/* =========================================================================
|
/* =========================================================================
|
||||||
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
|
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
|
||||||
@@ -1061,7 +1102,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}}
|
}}
|
||||||
placeholder="Ast-Titel…"
|
placeholder="Ast-Titel…"
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit}
|
||||||
className="w-full text-2xl sm:text-3xl font-extrabold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight"
|
className="w-full text-2xl sm:text-3xl font-light text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1102,6 +1143,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<OutlinerTree
|
<OutlinerTree
|
||||||
|
compact={inJournal}
|
||||||
docId={docId}
|
docId={docId}
|
||||||
blocks={childBlocks}
|
blocks={childBlocks}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
@@ -1137,7 +1179,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
|
onClick={() => canEdit && !inJournal && setShowEmojiPicker((prev) => !prev)}
|
||||||
className="text-2xl sm:text-3xl hover:opacity-80 transition-opacity cursor-pointer select-none leading-none bg-transparent border-0 outline-none p-0 focus:outline-none focus:ring-0 shadow-none"
|
className="text-2xl sm:text-3xl hover:opacity-80 transition-opacity cursor-pointer select-none leading-none bg-transparent border-0 outline-none p-0 focus:outline-none focus:ring-0 shadow-none"
|
||||||
title="Icon ändern"
|
title="Icon ändern"
|
||||||
>
|
>
|
||||||
@@ -1162,7 +1204,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}}
|
}}
|
||||||
rows={1}
|
rows={1}
|
||||||
value={title}
|
value={title}
|
||||||
readOnly={!canEdit}
|
readOnly={!canEdit || inJournal}
|
||||||
autoFocus={autoFocusTitle}
|
autoFocus={autoFocusTitle}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleTitleChange(e);
|
handleTitleChange(e);
|
||||||
@@ -1183,12 +1225,13 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
}}
|
}}
|
||||||
onBlur={handleTitleBlur}
|
onBlur={handleTitleBlur}
|
||||||
placeholder="Titel der Notiz…"
|
placeholder="Titel der Notiz…"
|
||||||
className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
className="flex-1 min-w-0 text-2xl sm:text-3xl font-light text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Logseq Outliner Tree */}
|
{/* Logseq Outliner Tree */}
|
||||||
<OutlinerTree
|
<OutlinerTree
|
||||||
|
compact={inJournal}
|
||||||
ref={outlinerTreeRef}
|
ref={outlinerTreeRef}
|
||||||
docId={docId}
|
docId={docId}
|
||||||
blocks={blocks}
|
blocks={blocks}
|
||||||
@@ -1214,7 +1257,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
|||||||
onRedo={handleRedo}
|
onRedo={handleRedo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{onOpenDocBlock && !inTrash && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
|
{onOpenDocBlock && !inTrash && !inJournal && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { MONTHS, monthGrid, parseISODate, todayISO } from '../../lib/journal';
|
||||||
|
|
||||||
|
interface JournalCalendarProps {
|
||||||
|
/** Day currently in view; the calendar follows its month */
|
||||||
|
activeDate: string;
|
||||||
|
/** Days with written entries (get a dot) */
|
||||||
|
entryDates: Set<string>;
|
||||||
|
onSelect: (date: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||||
|
|
||||||
|
export const JournalCalendar: React.FC<JournalCalendarProps> = ({ activeDate, entryDates, onSelect }) => {
|
||||||
|
const active = parseISODate(activeDate);
|
||||||
|
const [view, setView] = useState({ year: active.getFullYear(), month: active.getMonth() });
|
||||||
|
useEffect(() => {
|
||||||
|
const d = parseISODate(activeDate);
|
||||||
|
setView({ year: d.getFullYear(), month: d.getMonth() });
|
||||||
|
}, [activeDate]);
|
||||||
|
|
||||||
|
const today = todayISO();
|
||||||
|
const weeks = useMemo(() => monthGrid(view.year, view.month), [view]);
|
||||||
|
const years = useMemo(() => {
|
||||||
|
const now = new Date().getFullYear();
|
||||||
|
let first = now - 5;
|
||||||
|
entryDates.forEach((d) => {
|
||||||
|
first = Math.min(first, Number(d.slice(0, 4)));
|
||||||
|
});
|
||||||
|
const list: number[] = [];
|
||||||
|
for (let y = now + 1; y >= first; y--) list.push(y);
|
||||||
|
return list;
|
||||||
|
}, [entryDates]);
|
||||||
|
|
||||||
|
const shiftMonth = (delta: number) =>
|
||||||
|
setView((v) => {
|
||||||
|
const d = new Date(v.year, v.month + delta, 1);
|
||||||
|
return { year: d.getFullYear(), month: d.getMonth() };
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="select-none text-xs" aria-label="Journal-Kalender">
|
||||||
|
<div className="flex items-center gap-1 mb-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => shiftMonth(-1)}
|
||||||
|
className="btn-icon h-9 w-9 sm:h-7 sm:w-7 shrink-0"
|
||||||
|
title="Vorheriger Monat"
|
||||||
|
aria-label="Vorheriger Monat"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
value={view.month}
|
||||||
|
onChange={(e) => setView((v) => ({ ...v, month: Number(e.target.value) }))}
|
||||||
|
className="flex-1 min-w-0 h-9 sm:h-7 px-1.5 input-field text-xs font-semibold"
|
||||||
|
aria-label="Monat"
|
||||||
|
>
|
||||||
|
{MONTHS.map((m, i) => (
|
||||||
|
<option key={m} value={i}>
|
||||||
|
{m}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={view.year}
|
||||||
|
onChange={(e) => setView((v) => ({ ...v, year: Number(e.target.value) }))}
|
||||||
|
className="w-[4.5rem] h-9 sm:h-7 px-1.5 input-field text-xs font-semibold"
|
||||||
|
aria-label="Jahr"
|
||||||
|
>
|
||||||
|
{years.map((y) => (
|
||||||
|
<option key={y} value={y}>
|
||||||
|
{y}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => shiftMonth(1)}
|
||||||
|
className="btn-icon h-9 w-9 sm:h-7 sm:w-7 shrink-0"
|
||||||
|
title="Nächster Monat"
|
||||||
|
aria-label="Nächster Monat"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-0.5 text-center text-[10px] text-faint font-semibold mb-0.5">
|
||||||
|
{WEEKDAYS.map((w) => (
|
||||||
|
<span key={w}>{w}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-0.5">
|
||||||
|
{weeks.flat().map((date, i) =>
|
||||||
|
date ? (
|
||||||
|
<button
|
||||||
|
key={date}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(date)}
|
||||||
|
aria-current={date === activeDate ? 'date' : undefined}
|
||||||
|
title={parseISODate(date).toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
className={`relative h-9 sm:h-7 rounded-btn text-xs tabular-nums transition-colors cursor-pointer ${
|
||||||
|
date === activeDate
|
||||||
|
? 'bg-accent-dim text-accent font-bold'
|
||||||
|
: date === today
|
||||||
|
? 'text-accent font-semibold ring-1 ring-inset ring-accent-border hover:bg-panel-elevated'
|
||||||
|
: 'text-main hover:bg-panel-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{Number(date.slice(8))}
|
||||||
|
{entryDates.has(date) && (
|
||||||
|
<span className="absolute left-1/2 -translate-x-1/2 bottom-0.5 w-1 h-1 rounded-full bg-accent" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span key={`empty-${i}`} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { ArrowLeft, CalendarDays, ChevronDown, Users } from 'lucide-react';
|
||||||
|
import { Document, JournalDay, User } from '../../lib/types';
|
||||||
|
import { shortDateLabel, todayISO } from '../../lib/journal';
|
||||||
|
import { DocumentEditor } from './DocumentEditor';
|
||||||
|
import { JournalCalendar } from './JournalCalendar';
|
||||||
|
|
||||||
|
const PAGE = 7;
|
||||||
|
|
||||||
|
interface JournalViewProps {
|
||||||
|
days: JournalDay[];
|
||||||
|
/** Days jumped to over the calendar (shown even while still empty) */
|
||||||
|
extraDates: string[];
|
||||||
|
activeDate: string;
|
||||||
|
onActiveDateChange: (date: string) => void;
|
||||||
|
/** Set by the calendar; the view scrolls there (nonce: the same day again) */
|
||||||
|
jumpRequest: { date: string; nonce: number } | null;
|
||||||
|
/** Opens (creates on first use) the note of a day */
|
||||||
|
onOpenDay: (date: string) => Promise<Document | null>;
|
||||||
|
onJump: (date: string) => void;
|
||||||
|
getDoc: (id: string) => Document | undefined;
|
||||||
|
currentUser?: User | null;
|
||||||
|
availableFolders: string[];
|
||||||
|
onOpenShareModal: (doc: Document) => void;
|
||||||
|
onDocMetadataChange: (updated: Document) => void;
|
||||||
|
onDeleteDoc: (docId: string) => void;
|
||||||
|
onSelectTag: (tag: string) => void;
|
||||||
|
onWikiLinkClick?: (pageTitle: string) => void;
|
||||||
|
onOpenDocBlock?: (docId: string, blockId?: string) => void;
|
||||||
|
onOpenTask?: (taskId: string) => void;
|
||||||
|
onBackMobile: () => void;
|
||||||
|
/** A day was saved (its calendar dot may change) */
|
||||||
|
onDaySaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The journal: one note per day below each other, today on top, older days load while scrolling.
|
||||||
|
export const JournalView: React.FC<JournalViewProps> = ({
|
||||||
|
days,
|
||||||
|
extraDates,
|
||||||
|
activeDate,
|
||||||
|
onActiveDateChange,
|
||||||
|
jumpRequest,
|
||||||
|
onOpenDay,
|
||||||
|
onJump,
|
||||||
|
getDoc,
|
||||||
|
currentUser,
|
||||||
|
availableFolders,
|
||||||
|
onOpenShareModal,
|
||||||
|
onDocMetadataChange,
|
||||||
|
onDeleteDoc,
|
||||||
|
onSelectTag,
|
||||||
|
onWikiLinkClick,
|
||||||
|
onOpenDocBlock,
|
||||||
|
onOpenTask,
|
||||||
|
onBackMobile,
|
||||||
|
onDaySaved,
|
||||||
|
}) => {
|
||||||
|
const today = todayISO();
|
||||||
|
const docIdByDate = useMemo(() => new Map(days.map((d) => [d.date, d.docId])), [days]);
|
||||||
|
const entryDates = useMemo(() => new Set(days.filter((d) => !d.empty).map((d) => d.date)), [days]);
|
||||||
|
|
||||||
|
// Today, every day with text and the days jumped to – newest first
|
||||||
|
const dates = useMemo(() => {
|
||||||
|
const set = new Set<string>([today, ...extraDates]);
|
||||||
|
days.forEach((d) => {
|
||||||
|
if (!d.empty) set.add(d.date);
|
||||||
|
});
|
||||||
|
return Array.from(set).sort((a, b) => b.localeCompare(a));
|
||||||
|
}, [days, extraDates, today]);
|
||||||
|
|
||||||
|
const [limit, setLimit] = useState(PAGE);
|
||||||
|
const shown = dates.slice(0, limit);
|
||||||
|
const [showCalendar, setShowCalendar] = useState(false);
|
||||||
|
|
||||||
|
// Days on screen without a note yet (today, a day jumped to) get one
|
||||||
|
const opening = useRef(new Set<string>());
|
||||||
|
useEffect(() => {
|
||||||
|
shown.forEach((date) => {
|
||||||
|
if (!docIdByDate.has(date) && !opening.current.has(date)) {
|
||||||
|
opening.current.add(date);
|
||||||
|
void onOpenDay(date).finally(() => opening.current.delete(date));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [shown, docIdByDate, onOpenDay]);
|
||||||
|
|
||||||
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const sectionRefs = useRef(new Map<string, HTMLElement>());
|
||||||
|
|
||||||
|
// Jump: make sure the day is rendered, then scroll to it once its note is there
|
||||||
|
const pendingJump = useRef<string | null>(null);
|
||||||
|
const handledJump = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jumpRequest || handledJump.current === jumpRequest.nonce) return;
|
||||||
|
const idx = dates.indexOf(jumpRequest.date);
|
||||||
|
if (idx === -1) return; // the parent adds the day first
|
||||||
|
handledJump.current = jumpRequest.nonce;
|
||||||
|
if (idx >= limit) setLimit(idx + PAGE);
|
||||||
|
pendingJump.current = jumpRequest.date;
|
||||||
|
}, [jumpRequest, dates, limit]);
|
||||||
|
useEffect(() => {
|
||||||
|
const date = pendingJump.current;
|
||||||
|
if (!date) return;
|
||||||
|
const el = sectionRefs.current.get(date);
|
||||||
|
if (el && docIdByDate.has(date)) {
|
||||||
|
pendingJump.current = null;
|
||||||
|
el.scrollIntoView({ block: 'start' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The day at the top of the scroll area is the active one (calendar, share button)
|
||||||
|
const onScroll = useCallback(() => {
|
||||||
|
const root = scrollRef.current;
|
||||||
|
if (!root) return;
|
||||||
|
const top = root.getBoundingClientRect().top + 48;
|
||||||
|
let current = shown[0];
|
||||||
|
for (const date of shown) {
|
||||||
|
const el = sectionRefs.current.get(date);
|
||||||
|
if (!el) continue;
|
||||||
|
if (el.getBoundingClientRect().top <= top) current = date;
|
||||||
|
else break;
|
||||||
|
}
|
||||||
|
if (current && current !== activeDate) onActiveDateChange(current);
|
||||||
|
}, [shown, activeDate, onActiveDateChange]);
|
||||||
|
|
||||||
|
// Load older days when the end comes into view
|
||||||
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = sentinelRef.current;
|
||||||
|
const root = scrollRef.current;
|
||||||
|
if (!el || !root || typeof IntersectionObserver === 'undefined' || limit >= dates.length) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.isIntersecting) setLimit((l) => l + PAGE);
|
||||||
|
},
|
||||||
|
{ root, rootMargin: '400px 0px' }
|
||||||
|
);
|
||||||
|
observer.observe(el);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [limit, dates.length]);
|
||||||
|
|
||||||
|
const activeDocId = docIdByDate.get(activeDate);
|
||||||
|
const activeDoc = activeDocId ? getDoc(activeDocId) : undefined;
|
||||||
|
const sharedCount = activeDoc?.sharedWith?.length ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 bg-editor">
|
||||||
|
{/* One header for all days: the day in view and its sharing */}
|
||||||
|
<div className="shrink-0 flex items-center gap-2 px-4 sm:px-6 py-2 border-b border-border">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onBackMobile}
|
||||||
|
className="lg:hidden btn-icon shrink-0 h-11 w-11"
|
||||||
|
title="Zurück zur Seitenleiste"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCalendar((v) => !v)}
|
||||||
|
className="lg:pointer-events-none flex-1 min-w-0 flex items-center gap-1.5 text-xs text-main font-medium text-left cursor-pointer"
|
||||||
|
aria-expanded={showCalendar}
|
||||||
|
title="Kalender"
|
||||||
|
>
|
||||||
|
<CalendarDays className="w-3.5 h-3.5 text-accent shrink-0" />
|
||||||
|
<span className="truncate">Journal · {shortDateLabel(activeDate)}</span>
|
||||||
|
<ChevronDown className={`lg:hidden w-3.5 h-3.5 text-muted shrink-0 transition-transform ${showCalendar ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{activeDate !== today && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onJump(today)}
|
||||||
|
className="btn-action shrink-0 h-11 sm:h-7 px-2.5 text-xs"
|
||||||
|
>
|
||||||
|
Heute
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!activeDoc || !activeDoc.isOwner}
|
||||||
|
onClick={() => activeDoc && onOpenShareModal(activeDoc)}
|
||||||
|
className="btn-icon shrink-0 h-11 w-11 sm:h-7 sm:w-auto sm:px-2 gap-1 disabled:opacity-40"
|
||||||
|
title={activeDoc ? `${activeDoc.title} teilen` : 'Teilen'}
|
||||||
|
>
|
||||||
|
<Users className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden sm:inline text-xs">{sharedCount > 0 ? `Geteilt (${sharedCount})` : 'Teilen'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCalendar && (
|
||||||
|
<div className="lg:hidden shrink-0 px-4 py-3 border-b border-border bg-sidebar">
|
||||||
|
<JournalCalendar
|
||||||
|
activeDate={activeDate}
|
||||||
|
entryDates={entryDates}
|
||||||
|
onSelect={(date) => {
|
||||||
|
setShowCalendar(false);
|
||||||
|
onJump(date);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={scrollRef} onScroll={onScroll} className="flex-1 min-h-0 overflow-y-auto">
|
||||||
|
{shown.map((date) => {
|
||||||
|
const docId = docIdByDate.get(date);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={date}
|
||||||
|
data-journal-date={date}
|
||||||
|
ref={(el) => {
|
||||||
|
if (el) sectionRefs.current.set(date, el);
|
||||||
|
else sectionRefs.current.delete(date);
|
||||||
|
}}
|
||||||
|
className="border-b border-border last:border-b-0 min-h-[8rem]"
|
||||||
|
>
|
||||||
|
{docId ? (
|
||||||
|
<DocumentEditor
|
||||||
|
key={docId}
|
||||||
|
variant="journal"
|
||||||
|
docId={docId}
|
||||||
|
currentUser={currentUser}
|
||||||
|
initialDoc={getDoc(docId) || null}
|
||||||
|
availableFolders={availableFolders}
|
||||||
|
onDocMetadataChange={onDocMetadataChange}
|
||||||
|
onDeleteDoc={onDeleteDoc}
|
||||||
|
onOpenShareModal={onOpenShareModal}
|
||||||
|
onSelectTag={onSelectTag}
|
||||||
|
onWikiLinkClick={onWikiLinkClick}
|
||||||
|
onOpenDocBlock={onOpenDocBlock}
|
||||||
|
onOpenTask={onOpenTask}
|
||||||
|
onSaveStatusChange={(status) => {
|
||||||
|
if (status === 'saved') onDaySaved();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="px-4 sm:px-8 py-6 text-xs text-muted">{shortDateLabel(date)} wird geöffnet…</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{limit < dates.length && <div ref={sentinelRef} className="h-12" />}
|
||||||
|
{/* Room below the oldest day, so every day can be scrolled to the top */}
|
||||||
|
<div className="h-[60vh]" aria-hidden />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Heading2,
|
Heading2,
|
||||||
Heading3,
|
Heading3,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
|
ListOrdered,
|
||||||
Minus,
|
Minus,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
Mic,
|
Mic,
|
||||||
AudioLines,
|
AudioLines,
|
||||||
FileOutput,
|
FileOutput,
|
||||||
|
Type,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||||
import { LinkModal } from './LinkModal';
|
import { LinkModal } from './LinkModal';
|
||||||
@@ -54,7 +56,9 @@ export interface SlashCommand {
|
|||||||
|
|
||||||
const SLASH_COMMANDS: SlashCommand[] = [
|
const SLASH_COMMANDS: SlashCommand[] = [
|
||||||
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
|
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
|
||||||
|
{ type: 'numbered', label: '/number', desc: 'Nummerierte Liste (1., a., i.)', icon: <ListOrdered className="w-3.5 h-3.5" />, aliases: ['nummer', 'liste', 'ol'] },
|
||||||
{ type: 'link', label: '/link', desc: 'Notiz oder Weblink', 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: 'bullet', label: '/text', desc: 'In Standardtext umwandeln', icon: <Type className="w-3.5 h-3.5" />, aliases: ['p', 'paragraph', 'bullet', 'standard'] },
|
||||||
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 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: '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: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: <Heading3 className="w-3.5 h-3.5" /> },
|
||||||
@@ -98,6 +102,8 @@ export interface OutlinerBlockProps {
|
|||||||
isTargeted?: boolean;
|
isTargeted?: boolean;
|
||||||
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
||||||
onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
|
onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void;
|
||||||
|
/** Number or letter of a numbered-list item ("1.", "b.", "iii.") */
|
||||||
|
listMarker?: string;
|
||||||
lockedBy?: string; // someone else is editing this block
|
lockedBy?: string; // someone else is editing this block
|
||||||
canAppendBelow?: boolean; // the user may edit the note, so Enter in a locked block still adds one below
|
canAppendBelow?: boolean; // the user may edit the note, so Enter in a locked block still adds one below
|
||||||
onResolveConflict?: (blockId: string, accept: boolean) => void;
|
onResolveConflict?: (blockId: string, accept: boolean) => void;
|
||||||
@@ -145,6 +151,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
isTargeted,
|
isTargeted,
|
||||||
registerTextareaRef,
|
registerTextareaRef,
|
||||||
onPasteMultiLines,
|
onPasteMultiLines,
|
||||||
|
listMarker,
|
||||||
lockedBy,
|
lockedBy,
|
||||||
canAppendBelow,
|
canAppendBelow,
|
||||||
onResolveConflict,
|
onResolveConflict,
|
||||||
@@ -156,7 +163,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
const activeFocus = isFocused || isLocallyFocused;
|
const activeFocus = isFocused || isLocallyFocused;
|
||||||
// A block someone else is editing can still get the cursor (read-only), e.g. to press Enter at its end
|
// 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 canFocus = canEdit || (!!lockedBy && !!canAppendBelow);
|
||||||
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
const [slashMatchInfo, setSlashMatchInfo] = useState<{ query: string; slashStart: number; cursor: number } | null>(null);
|
||||||
|
const slashSearch = slashMatchInfo ? slashMatchInfo.query : null;
|
||||||
|
const linkBeforeRef = useRef<string>('');
|
||||||
|
const linkAfterRef = useRef<string>('');
|
||||||
|
const dictationBeforeRef = useRef<string>('');
|
||||||
|
const dictationAfterRef = useRef<string>('');
|
||||||
|
const slashMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const todoMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const blurSlashTimerRef = useRef<number | null>(null);
|
||||||
// Inline picker an empty bullet turns into after /image (local only, nothing synced until done)
|
// Inline picker an empty bullet turns into after /image (local only, nothing synced until done)
|
||||||
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
|
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
|
||||||
// Card above bullets whose entire content is a plain https address (lib/linkPreview.ts)
|
// Card above bullets whose entire content is a plain https address (lib/linkPreview.ts)
|
||||||
@@ -272,9 +287,48 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
window.clearTimeout(debounceTimerRef.current);
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
debounceTimerRef.current = null;
|
debounceTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (blurSlashTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(blurSlashTimerRef.current);
|
||||||
|
blurSlashTimerRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Close slash command menu on click outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!slashMatchInfo) return;
|
||||||
|
const handleOutsideClick = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node | null;
|
||||||
|
if (
|
||||||
|
slashMenuRef.current &&
|
||||||
|
!slashMenuRef.current.contains(target) &&
|
||||||
|
textareaRef.current &&
|
||||||
|
!textareaRef.current.contains(target)
|
||||||
|
) {
|
||||||
|
setSlashMatchInfo(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleOutsideClick);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleOutsideClick);
|
||||||
|
};
|
||||||
|
}, [slashMatchInfo]);
|
||||||
|
|
||||||
|
// Close todo menu on click outside
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showTodoMenu) return;
|
||||||
|
const handleOutsideClick = (e: MouseEvent) => {
|
||||||
|
const target = e.target as Node | null;
|
||||||
|
if (todoMenuRef.current && !todoMenuRef.current.contains(target)) {
|
||||||
|
setShowTodoMenu(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleOutsideClick);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleOutsideClick);
|
||||||
|
};
|
||||||
|
}, [showTodoMenu]);
|
||||||
|
|
||||||
const renderedTextRef = useRef<HTMLSpanElement | null>(null);
|
const renderedTextRef = useRef<HTMLSpanElement | null>(null);
|
||||||
|
|
||||||
// Focus the textarea and place the caret where the user clicked in the rendered (markdown) text layer
|
// Focus the textarea and place the caret where the user clicked in the rendered (markdown) text layer
|
||||||
@@ -358,22 +412,55 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}, [adjustHeight, block.type]);
|
}, [adjustHeight, block.type]);
|
||||||
|
|
||||||
const filteredSlashCommands = useMemo(() => {
|
const filteredSlashCommands = useMemo(() => {
|
||||||
if (slashSearch === null) return [];
|
if (!slashMatchInfo) return [];
|
||||||
const q = slashSearch.toLowerCase();
|
const q = slashMatchInfo.query.toLowerCase();
|
||||||
const cmdQuery = q.startsWith('code ') ? 'code' : q;
|
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() === '';
|
const textBeforeSlash = localContent.slice(0, slashMatchInfo.slashStart);
|
||||||
return SLASH_COMMANDS.filter(
|
const textAfterCursor = localContent.slice(slashMatchInfo.cursor);
|
||||||
(c) =>
|
const restOfBlock = (textBeforeSlash + textAfterCursor).trim();
|
||||||
(!c.media || emptyBullet) &&
|
const isRestEmpty = restOfBlock === '';
|
||||||
(c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) &&
|
const isAtStartOrEnd = textBeforeSlash.trim() === '' || textAfterCursor.trim() === '';
|
||||||
// The bullet's text becomes the title of the new note
|
|
||||||
(c.type !== 'extract' || (!emptyBullet && !!onExtractBlock)) &&
|
return SLASH_COMMANDS.filter((c) => {
|
||||||
(c.label.toLowerCase().includes(cmdQuery) ||
|
const matchesQuery =
|
||||||
|
c.label.toLowerCase().includes(cmdQuery) ||
|
||||||
c.desc.toLowerCase().includes(cmdQuery) ||
|
c.desc.toLowerCase().includes(cmdQuery) ||
|
||||||
(c.aliases || []).some((a) => a.includes(cmdQuery)))
|
(c.aliases || []).some((a) => a.includes(cmdQuery));
|
||||||
);
|
if (!matchesQuery) return false;
|
||||||
}, [slashSearch, localContent, onExtractBlock]);
|
|
||||||
|
// /link and /dictation everywhere
|
||||||
|
if (c.type === 'link') return true;
|
||||||
|
if (c.type === 'dictation') {
|
||||||
|
return ['server', 'browser'].includes(currentVoiceConfig().mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// /divider, /image, /audio, /code only when rest of block is empty
|
||||||
|
if (c.type === 'divider' || c.type === 'image' || c.type === 'audio' || c.type === 'code') {
|
||||||
|
return isRestEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /extract only when note has content and is at start/end
|
||||||
|
if (c.type === 'extract') {
|
||||||
|
return !isRestEmpty && isAtStartOrEnd && !!onExtractBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
// /h1, /h2, /h3, /todo, /callout, /text (type 'bullet') at start or at end
|
||||||
|
if (
|
||||||
|
c.type === 'h1' ||
|
||||||
|
c.type === 'h2' ||
|
||||||
|
c.type === 'h3' ||
|
||||||
|
c.type === 'todo' ||
|
||||||
|
c.type === 'numbered' ||
|
||||||
|
c.type === 'callout' ||
|
||||||
|
c.type === 'bullet'
|
||||||
|
) {
|
||||||
|
return isAtStartOrEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [slashMatchInfo, localContent, onExtractBlock]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (slashSearch === null) return;
|
if (slashSearch === null) return;
|
||||||
@@ -381,15 +468,37 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}, [slashIndex, slashSearch]);
|
}, [slashIndex, slashSearch]);
|
||||||
|
|
||||||
const applySlashCommand = (cmd: SlashCommand) => {
|
const applySlashCommand = (cmd: SlashCommand) => {
|
||||||
setSlashSearch(null);
|
if (blurSlashTimerRef.current !== null) {
|
||||||
const codeLangMatch = localContent.match(/(^|\s)\/code\s+([a-z0-9_+#.-]+)$/i);
|
window.clearTimeout(blurSlashTimerRef.current);
|
||||||
const clean = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim();
|
blurSlashTimerRef.current = null;
|
||||||
|
}
|
||||||
|
const slashStart = slashMatchInfo ? slashMatchInfo.slashStart : 0;
|
||||||
|
const slashEnd = slashMatchInfo ? slashMatchInfo.cursor : localContent.length;
|
||||||
|
setSlashMatchInfo(null);
|
||||||
|
|
||||||
|
const before = localContent.slice(0, slashStart);
|
||||||
|
const after = localContent.slice(slashEnd);
|
||||||
|
const clean = `${before}${after}`.replace(/\s{2,}/g, ' ').trim();
|
||||||
|
|
||||||
if (cmd.type === 'link') {
|
if (cmd.type === 'link') {
|
||||||
|
linkBeforeRef.current = before;
|
||||||
|
linkAfterRef.current = after;
|
||||||
setLocalContent(clean);
|
setLocalContent(clean);
|
||||||
localContentRef.current = clean;
|
localContentRef.current = clean;
|
||||||
setShowLinkModal(true);
|
setShowLinkModal(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cmd.type === 'dictation') {
|
||||||
|
dictationBeforeRef.current = before;
|
||||||
|
dictationAfterRef.current = after;
|
||||||
|
setLocalContent(clean);
|
||||||
|
localContentRef.current = clean;
|
||||||
|
flushDebounce(clean);
|
||||||
|
setMediaPicker('dictation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (cmd.type === 'extract') {
|
if (cmd.type === 'extract') {
|
||||||
setLocalContent(clean);
|
setLocalContent(clean);
|
||||||
localContentRef.current = clean;
|
localContentRef.current = clean;
|
||||||
@@ -397,31 +506,38 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onExtractBlock?.(block.id);
|
onExtractBlock?.(block.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmd.media) {
|
if (cmd.media) {
|
||||||
// The typed "/image" must not stay in the synced text; the block type changes only after the upload
|
// The typed "/image" must not stay in the synced text; the block type changes only after the upload
|
||||||
setLocalContent('');
|
setLocalContent('');
|
||||||
localContentRef.current = '';
|
localContentRef.current = '';
|
||||||
flushDebounce('');
|
flushDebounce('');
|
||||||
setMediaPicker(cmd.type as 'image' | 'audio' | 'dictation');
|
setMediaPicker(cmd.type as 'image' | 'audio');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const codeLangMatch = slashMatchInfo?.query.match(/^code\s+([a-z0-9_+#.-]+)$/i);
|
||||||
const langOverride =
|
const langOverride =
|
||||||
cmd.type === 'code' && codeLangMatch
|
cmd.type === 'code' && codeLangMatch
|
||||||
? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase()
|
? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
setLocalContent(clean);
|
setLocalContent(clean);
|
||||||
localContentRef.current = clean;
|
localContentRef.current = clean;
|
||||||
if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true;
|
if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true;
|
||||||
flushDebounce(clean, {
|
flushDebounce(clean, {
|
||||||
type: cmd.type as BlockType, // media commands returned above
|
type: cmd.type as BlockType,
|
||||||
...(langOverride !== undefined ? { lang: langOverride } : {}),
|
...(langOverride !== undefined ? { lang: langOverride } : {}),
|
||||||
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
|
checked: cmd.type === 'todo' ? Boolean(block.checked) : false,
|
||||||
|
...(cmd.type === 'bullet' ? { taskId: '', checked: false } : {}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInsertLink = (linkMarkdown: string) => {
|
const handleInsertLink = (linkMarkdown: string) => {
|
||||||
setShowLinkModal(false);
|
setShowLinkModal(false);
|
||||||
if (localContent.trim() === '') {
|
const b = linkBeforeRef.current ?? '';
|
||||||
|
const a = linkAfterRef.current ?? '';
|
||||||
|
if (b.trim() === '' && a.trim() === '') {
|
||||||
const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/);
|
const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/);
|
||||||
if (webMatch) {
|
if (webMatch) {
|
||||||
const bareUrl = webMatch[1];
|
const bareUrl = webMatch[1];
|
||||||
@@ -431,10 +547,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const cursor = textareaRef.current?.selectionStart ?? localContent.length;
|
const spaceBefore = b && !b.endsWith(' ') ? ' ' : '';
|
||||||
const before = localContent.slice(0, cursor);
|
const spaceAfter = a && !a.startsWith(' ') && a.trim() !== '' ? ' ' : '';
|
||||||
const after = localContent.slice(cursor);
|
const newContent = `${b}${spaceBefore}${linkMarkdown}${spaceAfter}${a}`.trim();
|
||||||
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
|
|
||||||
setLocalContent(newContent);
|
setLocalContent(newContent);
|
||||||
localContentRef.current = newContent;
|
localContentRef.current = newContent;
|
||||||
flushDebounce(newContent);
|
flushDebounce(newContent);
|
||||||
@@ -507,6 +622,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
val = val.replace(/^\[[xX]\]\s/, '');
|
val = val.replace(/^\[[xX]\]\s/, '');
|
||||||
typeOverride = 'todo';
|
typeOverride = 'todo';
|
||||||
checkedOverride = true;
|
checkedOverride = true;
|
||||||
|
} else if ((val === '1. ' || val === '1) ') && block.type !== 'numbered') {
|
||||||
|
// "1. " typed into an empty bullet starts a numbered list
|
||||||
|
val = '';
|
||||||
|
typeOverride = 'numbered';
|
||||||
} else if (val.startsWith('# ') && block.type !== 'h1') {
|
} else if (val.startsWith('# ') && block.type !== 'h1') {
|
||||||
val = val.slice(2);
|
val = val.slice(2);
|
||||||
typeOverride = 'h1';
|
typeOverride = 'h1';
|
||||||
@@ -516,18 +635,32 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
} else if (val.startsWith('### ') && block.type !== 'h3') {
|
} else if (val.startsWith('### ') && block.type !== 'h3') {
|
||||||
val = val.slice(4);
|
val = val.slice(4);
|
||||||
typeOverride = 'h3';
|
typeOverride = 'h3';
|
||||||
|
} else if (
|
||||||
|
(val.startsWith('/text ') ||
|
||||||
|
val.startsWith('/p ') ||
|
||||||
|
val.startsWith('/paragraph ') ||
|
||||||
|
val.startsWith('/bullet ') ||
|
||||||
|
val.startsWith('/standard ')) &&
|
||||||
|
block.type !== 'bullet'
|
||||||
|
) {
|
||||||
|
val = val.replace(/^\/(text|p|paragraph|bullet|standard)\s+/, '');
|
||||||
|
typeOverride = 'bullet';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for slash command invocation
|
// Check for slash command invocation at cursor position
|
||||||
const slashMatch = !typeOverride ? val.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
|
const sel = e.currentTarget.selectionStart;
|
||||||
|
const cursor = typeof sel === 'number' && sel > 0 ? sel : val.length;
|
||||||
|
const textBefore = val.slice(0, cursor);
|
||||||
|
const slashMatch = !typeOverride ? textBefore.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null;
|
||||||
if (slashMatch) {
|
if (slashMatch) {
|
||||||
setSlashSearch(slashMatch[2]);
|
const slashStart = slashMatch.index! + slashMatch[1].length;
|
||||||
|
setSlashMatchInfo({ query: slashMatch[2], slashStart, cursor });
|
||||||
setSlashIndex(0);
|
setSlashIndex(0);
|
||||||
} else {
|
} else {
|
||||||
setSlashSearch(null);
|
setSlashMatchInfo(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setSlashSearch(null);
|
setSlashMatchInfo(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
setLocalContent(val);
|
setLocalContent(val);
|
||||||
@@ -543,6 +676,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
type: typeOverride,
|
type: typeOverride,
|
||||||
...(langOverride !== undefined ? { lang: langOverride } : {}),
|
...(langOverride !== undefined ? { lang: langOverride } : {}),
|
||||||
checked: checkedOverride ?? latestBlockRef.current.checked,
|
checked: checkedOverride ?? latestBlockRef.current.checked,
|
||||||
|
...(typeOverride === 'bullet' ? { taskId: '', checked: false } : {}),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -557,6 +691,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
|
if (blurSlashTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(blurSlashTimerRef.current);
|
||||||
|
blurSlashTimerRef.current = null;
|
||||||
|
}
|
||||||
isFocusedRef.current = true;
|
isFocusedRef.current = true;
|
||||||
setIsLocallyFocused(true);
|
setIsLocallyFocused(true);
|
||||||
onFocusBlock?.(block.id);
|
onFocusBlock?.(block.id);
|
||||||
@@ -567,6 +705,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
isFocusedRef.current = false;
|
isFocusedRef.current = false;
|
||||||
setIsLocallyFocused(false);
|
setIsLocallyFocused(false);
|
||||||
onBlurBlock?.(block.id);
|
onBlurBlock?.(block.id);
|
||||||
|
if (blurSlashTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(blurSlashTimerRef.current);
|
||||||
|
}
|
||||||
|
blurSlashTimerRef.current = window.setTimeout(() => {
|
||||||
|
setSlashMatchInfo(null);
|
||||||
|
}, 150);
|
||||||
if (isPastingRef.current) {
|
if (isPastingRef.current) {
|
||||||
isPastingRef.current = false;
|
isPastingRef.current = false;
|
||||||
return;
|
return;
|
||||||
@@ -600,6 +744,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
isPastingRef.current = true;
|
isPastingRef.current = true;
|
||||||
|
// Pasted text that stays in this block arrives as a prop change while the field has focus: take it
|
||||||
|
acceptRemoteUntilRef.current = Date.now() + 1000;
|
||||||
onPasteMultiLinesRef.current?.(block.id, textBefore, textAfter, rawLines);
|
onPasteMultiLinesRef.current?.(block.id, textBefore, textAfter, rawLines);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -749,7 +895,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
if (slashSearch !== null) {
|
if (slashSearch !== null) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSlashSearch(null);
|
setSlashMatchInfo(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (filteredSlashCommands.length > 0) {
|
if (filteredSlashCommands.length > 0) {
|
||||||
@@ -789,7 +935,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
|
if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSlashSearch(null);
|
setSlashMatchInfo(null);
|
||||||
|
|
||||||
const fenceMatch = localContent.match(/^```([a-z0-9_+#.-]*)$/i);
|
const fenceMatch = localContent.match(/^```([a-z0-9_+#.-]*)$/i);
|
||||||
if (fenceMatch) {
|
if (fenceMatch) {
|
||||||
@@ -813,6 +959,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onIndentBlockRef.current(block.id, -1);
|
onIndentBlockRef.current(block.id, -1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Enter in an empty numbered item at the top ends the list
|
||||||
|
if (block.type === 'numbered') {
|
||||||
|
flushDebounce('', { type: 'text' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const start = e.currentTarget.selectionStart ?? localContent.length;
|
const start = e.currentTarget.selectionStart ?? localContent.length;
|
||||||
@@ -844,7 +995,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
if (e.key === 'Backspace' && localContent === '') {
|
if (e.key === 'Backspace' && localContent === '') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSlashSearch(null);
|
setSlashMatchInfo(null);
|
||||||
if (debounceTimerRef.current !== null) {
|
if (debounceTimerRef.current !== null) {
|
||||||
window.clearTimeout(debounceTimerRef.current);
|
window.clearTimeout(debounceTimerRef.current);
|
||||||
debounceTimerRef.current = null;
|
debounceTimerRef.current = null;
|
||||||
@@ -870,7 +1021,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSlashSearch(null);
|
setSlashMatchInfo(null);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -920,6 +1071,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group relative flex items-start gap-1 py-1 px-0.5 sm: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 ${
|
||||||
|
block.type === 'h1'
|
||||||
|
? 'mt-2.5 sm:mt-3'
|
||||||
|
: block.type === 'h2'
|
||||||
|
? 'mt-1.5 sm:mt-2'
|
||||||
|
: ''
|
||||||
|
} ${
|
||||||
isDragTarget
|
isDragTarget
|
||||||
? 'border-t-2 border-accent bg-accent/10'
|
? 'border-t-2 border-accent bg-accent/10'
|
||||||
: isTargeted
|
: isTargeted
|
||||||
@@ -970,7 +1127,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
draggable={canEdit}
|
draggable={canEdit}
|
||||||
onDragStart={() => canEdit && onDragStartBlock?.(index)}
|
onDragStart={() => canEdit && onDragStartBlock?.(index)}
|
||||||
onClick={() => onZoomBlock?.(block.id)}
|
onClick={() => onZoomBlock?.(block.id)}
|
||||||
className={`w-4 h-4 flex items-center justify-center rounded-btn group/bullet transition-transform ${
|
className={`${
|
||||||
|
block.type === 'numbered' && listMarker ? 'w-4 h-4 justify-end overflow-visible' : 'w-4 h-4 justify-center'
|
||||||
|
} flex items-center rounded-btn group/bullet transition-transform ${
|
||||||
canEdit ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
|
canEdit ? 'cursor-grab active:cursor-grabbing' : 'cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
@@ -979,6 +1138,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
: 'Klick: In diesen Ast hineinzoomen (Fokus-Modus)'
|
: 'Klick: In diesen Ast hineinzoomen (Fokus-Modus)'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{block.type === 'numbered' && listMarker ? (
|
||||||
|
<span
|
||||||
|
className={`text-xs tabular-nums font-medium leading-none whitespace-nowrap transition-colors ${
|
||||||
|
activeFocus ? 'text-main' : 'text-muted group-hover/bullet:text-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{listMarker}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
<span
|
<span
|
||||||
className={`w-1.5 h-1.5 rounded-full transition-all duration-150 ${
|
className={`w-1.5 h-1.5 rounded-full transition-all duration-150 ${
|
||||||
block.collapsed && hasChildren
|
block.collapsed && hasChildren
|
||||||
@@ -992,6 +1160,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
: 'opacity-100'
|
: 'opacity-100'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* If To-Do block: checkbox alongside bullet */}
|
{/* If To-Do block: checkbox alongside bullet */}
|
||||||
@@ -1047,14 +1216,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
<DictationBlock
|
<DictationBlock
|
||||||
onDone={(text) => {
|
onDone={(text) => {
|
||||||
setMediaPicker(null);
|
setMediaPicker(null);
|
||||||
setLocalContent(text);
|
const b = dictationBeforeRef.current ?? '';
|
||||||
localContentRef.current = text;
|
const a = dictationAfterRef.current ?? '';
|
||||||
flushDebounce(text);
|
const spaceBefore = b && !b.endsWith(' ') ? ' ' : '';
|
||||||
|
const spaceAfter = a && !a.startsWith(' ') && a.trim() !== '' ? ' ' : '';
|
||||||
|
const newContent = `${b}${spaceBefore}${text}${spaceAfter}${a}`.trim();
|
||||||
|
setLocalContent(newContent);
|
||||||
|
localContentRef.current = newContent;
|
||||||
|
flushDebounce(newContent);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const ta = textareaRef.current;
|
const ta = textareaRef.current;
|
||||||
if (!ta) return;
|
if (!ta) return;
|
||||||
ta.focus();
|
ta.focus();
|
||||||
ta.setSelectionRange(ta.value.length, ta.value.length);
|
const pos = (b + spaceBefore + text).length;
|
||||||
|
ta.setSelectionRange(pos, pos);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
@@ -1104,8 +1279,43 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : block.type === 'divider' ? (
|
) : block.type === 'divider' ? (
|
||||||
<div className="py-2">
|
<div
|
||||||
<hr className="border-t border-border" />
|
className="relative py-2 group/divider cursor-pointer w-full"
|
||||||
|
onClick={() => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<hr
|
||||||
|
className={`border-t transition-colors ${
|
||||||
|
activeFocus ? 'border-accent ring-1 ring-accent/30' : 'border-border'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
ref={(el) => {
|
||||||
|
textareaRef.current = el;
|
||||||
|
registerTextareaRef(block.id, el);
|
||||||
|
}}
|
||||||
|
rows={1}
|
||||||
|
value=""
|
||||||
|
readOnly={!canEdit}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onChange={() => {}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||||
|
e.preventDefault();
|
||||||
|
refocusAfterTypeChangeRef.current = true;
|
||||||
|
flushDebounce('', { type: 'bullet' });
|
||||||
|
setLocalContent('');
|
||||||
|
localContentRef.current = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleKeyDown(e);
|
||||||
|
}}
|
||||||
|
className="sr-only"
|
||||||
|
aria-label="Trennlinie"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : block.type === 'code' ? (
|
) : block.type === 'code' ? (
|
||||||
<CodeBlockEditor
|
<CodeBlockEditor
|
||||||
@@ -1145,7 +1355,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative w-full min-h-[1.5rem]">
|
<div className="relative w-full min-h-[1.5rem]">
|
||||||
{/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */}
|
{/* View Layer with Inline Markdown */}
|
||||||
<div
|
<div
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!activeFocus && canFocus && textareaRef.current) {
|
if (!activeFocus && canFocus && textareaRef.current) {
|
||||||
@@ -1158,11 +1368,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
activeFocus ? 'absolute inset-0 pointer-events-none' : 'relative cursor-text'
|
activeFocus ? 'absolute inset-0 pointer-events-none' : 'relative cursor-text'
|
||||||
} ${
|
} ${
|
||||||
block.type === 'h1'
|
block.type === 'h1'
|
||||||
? 'text-lg font-bold text-main tracking-tight'
|
? 'text-xl sm:text-2xl font-bold text-main tracking-tight'
|
||||||
: block.type === 'h2'
|
: block.type === 'h2'
|
||||||
? 'text-base font-semibold text-main'
|
? 'text-lg sm:text-xl font-semibold text-main'
|
||||||
: block.type === 'h3'
|
: block.type === 'h3'
|
||||||
? 'text-sm font-semibold text-main'
|
? 'text-base font-semibold text-main'
|
||||||
: block.type === 'todo' && block.checked
|
: block.type === 'todo' && block.checked
|
||||||
? 'text-sm text-faint line-through'
|
? 'text-sm text-faint line-through'
|
||||||
: 'text-sm text-main'
|
: 'text-sm text-main'
|
||||||
@@ -1172,104 +1382,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
|
<span ref={renderedTextRef} className={activeFocus ? 'invisible' : ''}>
|
||||||
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
|
{localContent ? renderInlineMarkdown(localContent, { onTagClick }) : '\u200b'}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Inline Badge and 3-Dots directly behind the text */}
|
|
||||||
{block.type === 'todo' && (
|
|
||||||
<span className="inline-flex items-center align-middle ml-1.5 gap-1 shrink-0 pointer-events-auto z-10 relative select-none">
|
|
||||||
{block.taskId && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpenTask?.(block.taskId as string);
|
|
||||||
}}
|
|
||||||
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.2 rounded font-medium shrink-0 cursor-pointer hover:opacity-80"
|
|
||||||
title="Erweiterte Aufgabe öffnen"
|
|
||||||
>
|
|
||||||
<CheckSquare className="w-2.5 h-2.5" />
|
|
||||||
<span>Erweitert</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canEdit && (
|
|
||||||
<span className="relative inline-flex items-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setShowTodoMenu((prev) => !prev);
|
|
||||||
}}
|
|
||||||
className="p-0.5 text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
|
|
||||||
title="Aufgaben-Optionen"
|
|
||||||
>
|
|
||||||
<MoreHorizontal className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
{showTodoMenu && (
|
|
||||||
<div
|
|
||||||
className="absolute left-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{!block.taskId ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
setShowTodoMenu(false);
|
|
||||||
try {
|
|
||||||
const targetDocId = docId || block.documentId || block.docId || '';
|
|
||||||
const thought = await api.createEnhancedTask(targetDocId, block.id, {
|
|
||||||
content: localContent || 'Aufgabe',
|
|
||||||
checked: Boolean(block.checked),
|
|
||||||
});
|
|
||||||
const updatedBlock: DocumentBlock = {
|
|
||||||
...latestBlockRef.current,
|
|
||||||
content: localContent,
|
|
||||||
taskId: thought.id,
|
|
||||||
type: 'todo',
|
|
||||||
};
|
|
||||||
onUpdateBlockRef.current(updatedBlock, true);
|
|
||||||
onOpenTask?.(thought.id);
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message || 'Fehler beim Erstellen der erweiterten Aufgabe');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer"
|
|
||||||
>
|
|
||||||
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
|
||||||
<span>In erweiterte Aufgabe umwandeln</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
setShowTodoMenu(false);
|
|
||||||
try {
|
|
||||||
if (block.taskId) {
|
|
||||||
await api.unlinkEnhancedTask(block.taskId).catch(() => {});
|
|
||||||
}
|
|
||||||
const updatedBlock: DocumentBlock = {
|
|
||||||
...latestBlockRef.current,
|
|
||||||
content: localContent,
|
|
||||||
taskId: '',
|
|
||||||
type: 'todo',
|
|
||||||
};
|
|
||||||
onUpdateBlockRef.current(updatedBlock, true);
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.message || 'Fehler beim Aufheben der Verknüpfung');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-status-warn flex items-center gap-1.5 text-main font-medium cursor-pointer"
|
|
||||||
>
|
|
||||||
<X className="w-3.5 h-3.5 text-status-warn" />
|
|
||||||
<span>Verknüpfung aufheben</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
@@ -1285,15 +1397,47 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onChange={handleContentChange}
|
onChange={handleContentChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
|
onClick={() => {
|
||||||
|
if (slashMatchInfo) {
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (ta) {
|
||||||
|
const cursor = ta.selectionStart;
|
||||||
|
const textBefore = ta.value.slice(0, cursor);
|
||||||
|
const match = textBefore.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i);
|
||||||
|
if (match) {
|
||||||
|
const slashStart = match.index! + match[1].length;
|
||||||
|
setSlashMatchInfo({ query: match[2], slashStart, cursor });
|
||||||
|
} else {
|
||||||
|
setSlashMatchInfo(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyUp={(e) => {
|
||||||
|
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key) && slashMatchInfo) {
|
||||||
|
const ta = textareaRef.current;
|
||||||
|
if (ta) {
|
||||||
|
const cursor = ta.selectionStart;
|
||||||
|
const textBefore = ta.value.slice(0, cursor);
|
||||||
|
const match = textBefore.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i);
|
||||||
|
if (match) {
|
||||||
|
const slashStart = match.index! + match[1].length;
|
||||||
|
setSlashMatchInfo({ query: match[2], slashStart, cursor });
|
||||||
|
} else {
|
||||||
|
setSlashMatchInfo(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={`w-full min-h-[1.5rem] resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
|
className={`w-full min-h-[1.5rem] resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${
|
||||||
activeFocus ? 'relative z-0 text-main' : 'absolute inset-0 text-transparent selection:text-transparent pointer-events-none'
|
activeFocus ? 'relative z-0 text-main' : 'absolute inset-0 text-transparent selection:text-transparent pointer-events-none'
|
||||||
} ${
|
} ${
|
||||||
block.type === 'h1'
|
block.type === 'h1'
|
||||||
? 'text-lg font-bold text-main tracking-tight'
|
? 'text-xl sm:text-2xl font-bold text-main tracking-tight'
|
||||||
: block.type === 'h2'
|
: block.type === 'h2'
|
||||||
? 'text-base font-semibold text-main'
|
? 'text-lg sm:text-xl font-semibold text-main'
|
||||||
: block.type === 'h3'
|
: block.type === 'h3'
|
||||||
? 'text-sm font-semibold text-main'
|
? 'text-base font-semibold text-main'
|
||||||
: block.type === 'todo' && block.checked
|
: block.type === 'todo' && block.checked
|
||||||
? 'text-sm text-faint line-through'
|
? 'text-sm text-faint line-through'
|
||||||
: 'text-sm text-main'
|
: 'text-sm text-main'
|
||||||
@@ -1302,6 +1446,25 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* If To-Do is an enhanced task, render badge stably outside text layer */}
|
||||||
|
{block.type === 'todo' && block.taskId && (
|
||||||
|
<div className="flex items-center gap-1 mt-0.5 select-none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenTask?.(block.taskId as string);
|
||||||
|
}}
|
||||||
|
className="badge-accent text-[9px] inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded font-medium shrink-0 cursor-pointer hover:opacity-80"
|
||||||
|
title="Erweiterte Aufgabe öffnen"
|
||||||
|
>
|
||||||
|
<CheckSquare className="w-2.5 h-2.5" />
|
||||||
|
<span>Erweitert</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Conflict copy: text that lost against someone else's change */}
|
{/* Conflict copy: text that lost against someone else's change */}
|
||||||
{block.conflictOf && (
|
{block.conflictOf && (
|
||||||
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px] text-status-warn select-none">
|
<div className="flex flex-wrap items-center gap-1.5 mt-1 text-[11px] text-status-warn select-none">
|
||||||
@@ -1393,7 +1556,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
|
|
||||||
{/* Floating Slash Commands Menu */}
|
{/* Floating Slash Commands Menu */}
|
||||||
{slashSearch !== null && canEdit && (
|
{slashSearch !== null && canEdit && (
|
||||||
<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
|
||||||
|
ref={slashMenuRef}
|
||||||
|
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">
|
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-faint border-b border-border mb-1">
|
||||||
Befehl auswählen
|
Befehl auswählen
|
||||||
</div>
|
</div>
|
||||||
@@ -1411,7 +1577,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
}}
|
}}
|
||||||
className={`w-full text-left px-2 py-1 rounded-btn text-xs flex items-center justify-between transition-colors ${
|
className={`w-full text-left px-2 py-1 rounded-btn text-xs flex items-center justify-between transition-colors ${
|
||||||
i === slashIndex
|
i === slashIndex
|
||||||
? 'bg-accent text-on-accent'
|
? 'bg-accent-dim text-accent font-semibold'
|
||||||
: 'text-main hover:bg-panel-elevated'
|
: 'text-main hover:bg-panel-elevated'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -1432,29 +1598,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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)}
|
|
||||||
// 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" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Someone else is editing this block */}
|
{/* Someone else is editing this block */}
|
||||||
{lockedBy && (
|
{lockedBy && (
|
||||||
<span
|
<span
|
||||||
@@ -1466,7 +1609,145 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete button on hover (Trash2 icon); on phones only in the row being edited */}
|
{/* Floating Actions Overlay Bubble (Zoom, Auslagern, Löschen, TODO-Menü, Text-Umwandlung) */}
|
||||||
|
<div
|
||||||
|
className={`absolute right-1 sm:right-2 top-0.5 sm:top-1 z-20 bg-panel/90 backdrop-blur-sm border border-border shadow-xs rounded-pill px-1 sm:px-1.5 py-0.5 flex items-center gap-0.5 sm:gap-1 transition-opacity ${
|
||||||
|
activeFocus
|
||||||
|
? 'opacity-100 pointer-events-auto'
|
||||||
|
: 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-events-none group-hover:pointer-events-auto focus-within:pointer-events-auto'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* Convert back to bullet for headings, callouts, divider */}
|
||||||
|
{canEdit && ['callout', 'h1', 'h2', 'h3', 'divider'].includes(block.type) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
refocusAfterTypeChangeRef.current = true;
|
||||||
|
flushDebounce(localContent, { type: 'bullet', checked: false });
|
||||||
|
}}
|
||||||
|
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
|
||||||
|
title="In normalen Text umwandeln"
|
||||||
|
>
|
||||||
|
<Type className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* To-Do 3-dots Menu */}
|
||||||
|
{block.type === 'todo' && canEdit && (
|
||||||
|
<div className="relative inline-flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowTodoMenu((prev) => !prev);
|
||||||
|
}}
|
||||||
|
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
|
||||||
|
title="Aufgaben-Optionen"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
{showTodoMenu && (
|
||||||
|
<div
|
||||||
|
ref={todoMenuRef}
|
||||||
|
className="absolute right-0 top-full mt-1 bg-panel border border-border shadow-lg rounded-card p-1 z-30 w-52 text-xs"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{!block.taskId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
setShowTodoMenu(false);
|
||||||
|
try {
|
||||||
|
const targetDocId = docId || block.documentId || block.docId || '';
|
||||||
|
const thought = await api.createEnhancedTask(targetDocId, block.id, {
|
||||||
|
content: localContent || 'Aufgabe',
|
||||||
|
checked: Boolean(block.checked),
|
||||||
|
});
|
||||||
|
const updatedBlock: DocumentBlock = {
|
||||||
|
...latestBlockRef.current,
|
||||||
|
content: localContent,
|
||||||
|
taskId: thought.id,
|
||||||
|
type: 'todo',
|
||||||
|
};
|
||||||
|
onUpdateBlockRef.current(updatedBlock, true);
|
||||||
|
onOpenTask?.(thought.id);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Fehler beim Erstellen der erweiterten Aufgabe');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
<CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>In erweiterte Aufgabe umwandeln</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
setShowTodoMenu(false);
|
||||||
|
try {
|
||||||
|
if (block.taskId) {
|
||||||
|
await api.unlinkEnhancedTask(block.taskId).catch(() => {});
|
||||||
|
}
|
||||||
|
const updatedBlock: DocumentBlock = {
|
||||||
|
...latestBlockRef.current,
|
||||||
|
content: localContent,
|
||||||
|
taskId: '',
|
||||||
|
type: 'todo',
|
||||||
|
};
|
||||||
|
onUpdateBlockRef.current(updatedBlock, true);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Fehler beim Aufheben der Verknüpfung');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-status-warn flex items-center gap-1.5 text-main font-medium cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5 text-status-warn" />
|
||||||
|
<span>Verknüpfung aufheben</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowTodoMenu(false);
|
||||||
|
refocusAfterTypeChangeRef.current = true;
|
||||||
|
flushDebounce(localContent, { type: 'bullet', checked: false, taskId: '' });
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-2 py-1.5 rounded-btn hover:bg-panel-elevated hover:text-accent flex items-center gap-1.5 text-main font-medium cursor-pointer border-t border-border mt-0.5 pt-1.5"
|
||||||
|
>
|
||||||
|
<Type className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span>In normalen Text umwandeln</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Branch into its own note */}
|
||||||
|
{canEdit && onExtractBlock && localContent.trim() !== '' && !mediaPicker && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onExtractBlock(block.id)}
|
||||||
|
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
|
||||||
|
title="In eigene Notiz umwandeln (mit allen Unterpunkten)"
|
||||||
|
>
|
||||||
|
<FileOutput className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Zoom / Focus button */}
|
||||||
|
{onZoomBlock && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onZoomBlock(block.id)}
|
||||||
|
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-accent rounded-btn transition-colors cursor-pointer"
|
||||||
|
title="In diesen Ast hineinzoomen (Fokus-Modus)"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete button */}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1479,16 +1760,13 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
|||||||
onDeleteBlockRef.current(block.id, false);
|
onDeleteBlockRef.current(block.id, false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={
|
className="p-1 sm:p-0.5 min-w-[28px] min-h-[28px] sm:min-w-0 sm:min-h-0 flex items-center justify-center text-faint hover:text-status-err rounded-btn transition-colors cursor-pointer"
|
||||||
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"
|
title="Block löschen"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Link Insertion Modal */}
|
{/* Link Insertion Modal */}
|
||||||
{showLinkModal && (
|
{showLinkModal && (
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
|
||||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||||
import { OutlinerBlock } from './OutlinerBlock';
|
import { OutlinerBlock } from './OutlinerBlock';
|
||||||
|
import { parsePastedLines } from '../../lib/pasteOutline';
|
||||||
|
import { numberedLabels } from '../../lib/outlineList';
|
||||||
|
|
||||||
export interface OutlinerTreeHandle {
|
export interface OutlinerTreeHandle {
|
||||||
focusFirstBlock: () => void;
|
focusFirstBlock: () => void;
|
||||||
@@ -9,6 +11,8 @@ export interface OutlinerTreeHandle {
|
|||||||
|
|
||||||
export interface OutlinerTreeProps {
|
export interface OutlinerTreeProps {
|
||||||
docId: string;
|
docId: string;
|
||||||
|
/** Smaller click area below the last bullet (journal days) */
|
||||||
|
compact?: boolean;
|
||||||
blocks: DocumentBlock[];
|
blocks: DocumentBlock[];
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
levelOffset?: number;
|
levelOffset?: number;
|
||||||
@@ -34,6 +38,7 @@ export interface OutlinerTreeProps {
|
|||||||
|
|
||||||
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
||||||
docId,
|
docId,
|
||||||
|
compact = false,
|
||||||
blocks,
|
blocks,
|
||||||
canEdit,
|
canEdit,
|
||||||
levelOffset = 0,
|
levelOffset = 0,
|
||||||
@@ -235,13 +240,16 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
return result;
|
return result;
|
||||||
}, [blocks, levelOffset]);
|
}, [blocks, levelOffset]);
|
||||||
|
|
||||||
|
const listLabels = useMemo(() => numberedLabels(blocks), [blocks]);
|
||||||
|
|
||||||
const handleCreateBlockBelow = useCallback(
|
const handleCreateBlockBelow = useCallback(
|
||||||
(afterBlockId: string, newContent: string = '') => {
|
(afterBlockId: string, newContent: string = '') => {
|
||||||
if (!canEdit) return;
|
if (!canEdit) return;
|
||||||
const idx = blocks.findIndex((b) => b.id === afterBlockId);
|
const idx = blocks.findIndex((b) => b.id === afterBlockId);
|
||||||
const currentBlock = idx >= 0 ? blocks[idx] : null;
|
const currentBlock = idx >= 0 ? blocks[idx] : null;
|
||||||
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
|
const level = currentBlock ? Math.max(0, currentBlock.level || 0) : 0;
|
||||||
const inheritedType: BlockType = 'text';
|
// Numbered lists continue on Enter (an empty item ends the list, see OutlinerBlock)
|
||||||
|
const inheritedType: BlockType = currentBlock?.type === 'numbered' ? 'numbered' : 'text';
|
||||||
|
|
||||||
const newBlock: DocumentBlock = {
|
const newBlock: DocumentBlock = {
|
||||||
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||||||
@@ -270,178 +278,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
const currentBlock = blocks[idx];
|
const currentBlock = blocks[idx];
|
||||||
const baseLevel = Math.max(0, currentBlock.level || 0);
|
const baseLevel = Math.max(0, currentBlock.level || 0);
|
||||||
|
|
||||||
// Detect indentation style (2-space vs 4-space tabs)
|
const parsed = parsePastedLines(rawLines);
|
||||||
let detectedSpaceStep = 2;
|
|
||||||
const leadingSpacesList: number[] = [];
|
|
||||||
for (const line of rawLines) {
|
|
||||||
if (!line.trim()) continue;
|
|
||||||
let sp = 0;
|
|
||||||
while (sp < line.length && line[sp] === ' ') sp++;
|
|
||||||
if (sp > 0) leadingSpacesList.push(sp);
|
|
||||||
}
|
|
||||||
if (leadingSpacesList.length > 0 && leadingSpacesList.every((s) => s % 4 === 0)) {
|
|
||||||
detectedSpaceStep = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse lines into structured blocks
|
|
||||||
interface ParsedPasteItem {
|
|
||||||
type: BlockType;
|
|
||||||
checked?: boolean;
|
|
||||||
content: string;
|
|
||||||
indent: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed: ParsedPasteItem[] = [];
|
|
||||||
let currentItem: ParsedPasteItem | null = null;
|
|
||||||
let inCodeBlock = false;
|
|
||||||
let afterHeading = false;
|
|
||||||
|
|
||||||
const pushCurrentItem = () => {
|
|
||||||
if (currentItem) {
|
|
||||||
currentItem.content = currentItem.content.trimEnd();
|
|
||||||
parsed.push(currentItem);
|
|
||||||
currentItem = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (let i = 0; i < rawLines.length; i++) {
|
|
||||||
const rawLine = rawLines[i];
|
|
||||||
|
|
||||||
// 1. Inside a fenced code block
|
|
||||||
if (inCodeBlock) {
|
|
||||||
if (currentItem) {
|
|
||||||
currentItem.content += '\n' + rawLine;
|
|
||||||
}
|
|
||||||
if (rawLine.trim().startsWith('```')) {
|
|
||||||
inCodeBlock = false;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Starts a code fence
|
|
||||||
if (rawLine.trim().startsWith('```')) {
|
|
||||||
inCodeBlock = true;
|
|
||||||
if (afterHeading) {
|
|
||||||
afterHeading = false;
|
|
||||||
pushCurrentItem();
|
|
||||||
currentItem = {
|
|
||||||
type: 'text',
|
|
||||||
content: rawLine,
|
|
||||||
indent: 0,
|
|
||||||
};
|
|
||||||
} else if (currentItem) {
|
|
||||||
currentItem.content += (currentItem.content.endsWith('\n') ? '' : '\n') + rawLine;
|
|
||||||
} else {
|
|
||||||
currentItem = {
|
|
||||||
type: 'text',
|
|
||||||
content: rawLine,
|
|
||||||
indent: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Measure leading indent
|
|
||||||
let tabs = 0;
|
|
||||||
let spaces = 0;
|
|
||||||
let charIdx = 0;
|
|
||||||
while (charIdx < rawLine.length) {
|
|
||||||
if (rawLine[charIdx] === '\t') {
|
|
||||||
tabs++;
|
|
||||||
charIdx++;
|
|
||||||
} else if (rawLine[charIdx] === ' ') {
|
|
||||||
spaces++;
|
|
||||||
charIdx++;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const strippedLine = rawLine.slice(charIdx);
|
|
||||||
const lineIndent = tabs + Math.floor(spaces / detectedSpaceStep);
|
|
||||||
|
|
||||||
// Blank lines
|
|
||||||
if (strippedLine.trim() === '') {
|
|
||||||
if (currentItem) {
|
|
||||||
currentItem.content += '\n';
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Headings: #, ##, ###
|
|
||||||
const h3Match = strippedLine.match(/^###\s+(.*)$/);
|
|
||||||
const h2Match = strippedLine.match(/^##\s+(.*)$/);
|
|
||||||
const h1Match = strippedLine.match(/^#\s+(.*)$/);
|
|
||||||
if (h1Match || h2Match || h3Match) {
|
|
||||||
pushCurrentItem();
|
|
||||||
afterHeading = true;
|
|
||||||
const type: BlockType = h1Match ? 'h1' : h2Match ? 'h2' : 'h3';
|
|
||||||
const content = (h1Match || h2Match || h3Match)![1];
|
|
||||||
parsed.push({ type, content, indent: lineIndent });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dividers: ---, ***, ___
|
|
||||||
if (/^(?:---|---|\*\*\*|___)\s*$/.test(strippedLine.trim())) {
|
|
||||||
pushCurrentItem();
|
|
||||||
afterHeading = false;
|
|
||||||
parsed.push({ type: 'divider', content: '---', indent: lineIndent });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Todo items: - [ ], - [x], * [ ], * [x]
|
|
||||||
const todoMatch = strippedLine.match(/^[-*]\s*\[([ xX])\]\s*(.*)$/);
|
|
||||||
if (todoMatch) {
|
|
||||||
pushCurrentItem();
|
|
||||||
afterHeading = false;
|
|
||||||
parsed.push({
|
|
||||||
type: 'todo',
|
|
||||||
checked: todoMatch[1].toLowerCase() === 'x',
|
|
||||||
content: todoMatch[2],
|
|
||||||
indent: lineIndent,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explicit bullet or numbered list: - item, * item, • item, 1. item, 1) item
|
|
||||||
const bulletMatch = strippedLine.match(/^[-*•]\s+(.*)$/);
|
|
||||||
const numMatch = strippedLine.match(/^\d+[.)]\s+(.*)$/);
|
|
||||||
if (bulletMatch || numMatch) {
|
|
||||||
pushCurrentItem();
|
|
||||||
afterHeading = false;
|
|
||||||
parsed.push({
|
|
||||||
type: 'text',
|
|
||||||
content: (bulletMatch ? bulletMatch[1] : numMatch![1]),
|
|
||||||
indent: lineIndent,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular text line
|
|
||||||
if (afterHeading) {
|
|
||||||
// This starts the single bullet point after the heading
|
|
||||||
afterHeading = false;
|
|
||||||
pushCurrentItem();
|
|
||||||
currentItem = {
|
|
||||||
type: 'text',
|
|
||||||
content: strippedLine,
|
|
||||||
indent: lineIndent,
|
|
||||||
};
|
|
||||||
} else if (currentItem) {
|
|
||||||
if (currentItem.content.endsWith('\n')) {
|
|
||||||
currentItem.content += strippedLine;
|
|
||||||
} else {
|
|
||||||
currentItem.content += '\n' + strippedLine;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
currentItem = {
|
|
||||||
type: 'text',
|
|
||||||
content: strippedLine,
|
|
||||||
indent: lineIndent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pushCurrentItem();
|
|
||||||
|
|
||||||
if (parsed.length === 0) return;
|
if (parsed.length === 0) return;
|
||||||
|
|
||||||
@@ -789,6 +626,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
onUpdateBlock={onUpdateBlock}
|
onUpdateBlock={onUpdateBlock}
|
||||||
onCreateBlockBelow={handleCreateBlockBelow}
|
onCreateBlockBelow={handleCreateBlockBelow}
|
||||||
onPasteMultiLines={handlePasteMultiLines}
|
onPasteMultiLines={handlePasteMultiLines}
|
||||||
|
listMarker={listLabels.get(block.id)}
|
||||||
onDeleteBlock={handleDeleteBlock}
|
onDeleteBlock={handleDeleteBlock}
|
||||||
onMergeWithPrevious={handleMergeWithPrevious}
|
onMergeWithPrevious={handleMergeWithPrevious}
|
||||||
onIndentBlock={handleIndentBlock}
|
onIndentBlock={handleIndentBlock}
|
||||||
@@ -810,7 +648,7 @@ export const OutlinerTree = forwardRef<OutlinerTreeHandle, OutlinerTreeProps>(({
|
|||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<div
|
<div
|
||||||
className={`min-h-[14rem] pt-2 pl-2 transition-colors cursor-text ${
|
className={`${compact ? 'min-h-[2.5rem]' : 'min-h-[14rem]'} pt-2 pl-2 transition-colors cursor-text ${
|
||||||
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
|
dragOverIndex === blocks.length ? 'border-t-2 border-accent bg-accent/10 rounded' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
CalendarDays,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Document,
|
Document,
|
||||||
@@ -36,6 +37,7 @@ import {
|
|||||||
TrashedDocument,
|
TrashedDocument,
|
||||||
UnifiedShare,
|
UnifiedShare,
|
||||||
User,
|
User,
|
||||||
|
JournalDay,
|
||||||
} from '../../lib/types';
|
} from '../../lib/types';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
@@ -49,6 +51,9 @@ import { useMobileActions } from '../../context/MobileActionContext';
|
|||||||
import { DocumentEditor, DocSaveStatus, DocHeaderStatus } from './DocumentEditor';
|
import { DocumentEditor, DocSaveStatus, DocHeaderStatus } from './DocumentEditor';
|
||||||
import { PresenceAvatars } from './PresenceAvatars';
|
import { PresenceAvatars } from './PresenceAvatars';
|
||||||
import { TrashView } from './TrashView';
|
import { TrashView } from './TrashView';
|
||||||
|
import { JournalView } from './JournalView';
|
||||||
|
import { JournalCalendar } from './JournalCalendar';
|
||||||
|
import { isJournalEnabled, todayISO } from '../../lib/journal';
|
||||||
import { MergeDialog } from './MergeDialog';
|
import { MergeDialog } from './MergeDialog';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
import { extractHashtags } from '../../lib/hashtags';
|
import { extractHashtags } from '../../lib/hashtags';
|
||||||
@@ -63,7 +68,7 @@ interface WorkspaceViewProps {
|
|||||||
onOpenSearch?: () => void;
|
onOpenSearch?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash';
|
type WorkspaceSubView = 'editor' | 'tag_detail' | 'todos' | 'tags' | 'trash' | 'journal';
|
||||||
|
|
||||||
interface FolderTreeNode {
|
interface FolderTreeNode {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -327,6 +332,33 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reloadTrash();
|
reloadTrash();
|
||||||
}, [reloadTrash]);
|
}, [reloadTrash]);
|
||||||
|
|
||||||
|
// Journal: one note per day, listed separately from the folder tree (can be switched off per device)
|
||||||
|
const [journalEnabled] = useState(isJournalEnabled);
|
||||||
|
const [journalDays, setJournalDays] = useState<JournalDay[]>([]);
|
||||||
|
const [journalExtra, setJournalExtra] = useState<string[]>([]);
|
||||||
|
const [journalActive, setJournalActive] = useState(todayISO);
|
||||||
|
const [journalJump, setJournalJump] = useState<{ date: string; nonce: number } | null>(null);
|
||||||
|
const reloadJournal = useCallback(() => {
|
||||||
|
if (!journalEnabled) return;
|
||||||
|
api.getJournal().then(setJournalDays).catch(() => {});
|
||||||
|
}, [journalEnabled]);
|
||||||
|
useEffect(() => {
|
||||||
|
reloadJournal();
|
||||||
|
}, [reloadJournal]);
|
||||||
|
// After typing, refresh which days have text (calendar dots) – at most every few seconds
|
||||||
|
const journalReloadTimer = useRef<number | null>(null);
|
||||||
|
const reloadJournalSoon = useCallback(() => {
|
||||||
|
if (journalReloadTimer.current !== null) return;
|
||||||
|
journalReloadTimer.current = window.setTimeout(() => {
|
||||||
|
journalReloadTimer.current = null;
|
||||||
|
reloadJournal();
|
||||||
|
}, 3000);
|
||||||
|
}, [reloadJournal]);
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (journalReloadTimer.current !== null) window.clearTimeout(journalReloadTimer.current);
|
||||||
|
}, []);
|
||||||
|
const journalDocIds = useMemo(() => new Set(journalDays.map((d) => d.docId)), [journalDays]);
|
||||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||||
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
|
const [tagDetails, setTagDetails] = useState<TagDetails | null>(null);
|
||||||
const [tagNoteDraft, setTagNoteDraft] = useState<string>('');
|
const [tagNoteDraft, setTagNoteDraft] = useState<string>('');
|
||||||
@@ -408,6 +440,17 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}, []);
|
}, []);
|
||||||
const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
|
const [draggingFolderPath, setDraggingFolderPath] = useState<string | null>(null);
|
||||||
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
||||||
|
// A drag that ends without a drop (released elsewhere, Escape) fires only dragend: clear every drag state
|
||||||
|
useEffect(() => {
|
||||||
|
const clear = () => {
|
||||||
|
setDraggingDocId(null);
|
||||||
|
setDraggingFolderPath(null);
|
||||||
|
setDragOverFolder(null);
|
||||||
|
setMergeHoverId(null);
|
||||||
|
};
|
||||||
|
document.addEventListener('dragend', clear);
|
||||||
|
return () => document.removeEventListener('dragend', clear);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Rollup drilldown & View Mode
|
// Rollup drilldown & View Mode
|
||||||
const [todoFolderDrilldown, setTodoFolderDrilldown] = useState<string | null>(null);
|
const [todoFolderDrilldown, setTodoFolderDrilldown] = useState<string | null>(null);
|
||||||
@@ -549,6 +592,34 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
setMobileShowDetail(true);
|
setMobileShowDetail(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openJournalDay = useCallback(async (date: string): Promise<Document | null> => {
|
||||||
|
try {
|
||||||
|
const doc = await api.openJournalDay(date);
|
||||||
|
setDocs((prev) => (prev.some((d) => d.id === doc.id) ? prev : [doc, ...prev]));
|
||||||
|
setJournalDays((prev) =>
|
||||||
|
[
|
||||||
|
...prev.filter((d) => d.date !== date),
|
||||||
|
{ date, docId: doc.id, empty: !(doc.content || '').trim() },
|
||||||
|
].sort((a, b) => b.date.localeCompare(a.date))
|
||||||
|
);
|
||||||
|
return doc;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const jumpToJournalDay = useCallback((date: string) => {
|
||||||
|
setJournalExtra((prev) => (prev.includes(date) ? prev : [...prev, date]));
|
||||||
|
setJournalActive(date);
|
||||||
|
setJournalJump({ date, nonce: Date.now() });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openJournal = useCallback(() => {
|
||||||
|
reloadJournal();
|
||||||
|
setSubView('journal');
|
||||||
|
setMobileShowDetail(true);
|
||||||
|
}, [reloadJournal]);
|
||||||
|
|
||||||
const openTrash = useCallback(() => {
|
const openTrash = useCallback(() => {
|
||||||
reloadTrash();
|
reloadTrash();
|
||||||
setSubView('trash');
|
setSubView('trash');
|
||||||
@@ -928,6 +999,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Own journal notes appear under "Journal", not in the folder tree
|
||||||
|
const treeDocs = useMemo(
|
||||||
|
() => (journalDocIds.size ? docs.filter((d) => !(d.isOwner && journalDocIds.has(d.id))) : docs),
|
||||||
|
[docs, journalDocIds]
|
||||||
|
);
|
||||||
|
|
||||||
const allFolderPaths = useMemo(() => {
|
const allFolderPaths = useMemo(() => {
|
||||||
const set = new Set<string>(folders);
|
const set = new Set<string>(folders);
|
||||||
for (const d of docs) {
|
for (const d of docs) {
|
||||||
@@ -966,7 +1043,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
const sharedFolderObj = sharedFolders.find(
|
const sharedFolderObj = sharedFolders.find(
|
||||||
(sf) => sf.mountPath === p || sf.name === p
|
(sf) => sf.mountPath === p || sf.name === p
|
||||||
);
|
);
|
||||||
const folderDocs = docs.filter((d) => d.folderPath === p);
|
const folderDocs = treeDocs.filter((d) => d.folderPath === p);
|
||||||
|
|
||||||
const node: FolderTreeNode = {
|
const node: FolderTreeNode = {
|
||||||
name: leafName,
|
name: leafName,
|
||||||
@@ -991,16 +1068,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return rootNodes;
|
return rootNodes;
|
||||||
}, [allFolderPaths, docs, sharedFolders]);
|
}, [allFolderPaths, treeDocs, sharedFolders]);
|
||||||
|
|
||||||
const pinnedDocs = useMemo(
|
const pinnedDocs = useMemo(
|
||||||
() => docs.filter((d) => d.isPinned),
|
() => treeDocs.filter((d) => d.isPinned),
|
||||||
[docs]
|
[treeDocs]
|
||||||
);
|
);
|
||||||
|
|
||||||
const rootDocs = useMemo(
|
const rootDocs = useMemo(
|
||||||
() => docs.filter((d) => !d.folderPath && !d.isPinned),
|
() => treeDocs.filter((d) => !d.folderPath && !d.isPinned),
|
||||||
[docs]
|
[treeDocs]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Global To-Dos rollup collections
|
// Global To-Dos rollup collections
|
||||||
@@ -1218,7 +1295,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}, [tagsSummary, tagSearchQuery, tagSortBy]);
|
}, [tagsSummary, tagSearchQuery, tagSortBy]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-3">
|
<div className="w-full h-full min-h-0 flex flex-col gap-3">
|
||||||
{/* Top Header: Workspace Tabs & Actions */}
|
{/* Top Header: Workspace Tabs & Actions */}
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2">
|
||||||
{/* Workspace selector tabs & left sidebar toggle; on phones the three tabs share the full width */}
|
{/* Workspace selector tabs & left sidebar toggle; on phones the three tabs share the full width */}
|
||||||
@@ -1231,8 +1308,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
subView !== 'editor'
|
subView !== 'editor'
|
||||||
? 'opacity-35 bg-canvas border-border text-faint cursor-not-allowed pointer-events-none'
|
? 'opacity-35 bg-canvas border-border text-faint cursor-not-allowed pointer-events-none'
|
||||||
: isSidebarCollapsed
|
: isSidebarCollapsed
|
||||||
? 'bg-panel border-accent text-accent cursor-pointer'
|
? 'bg-canvas border-border text-accent hover:bg-panel cursor-pointer'
|
||||||
: 'bg-canvas border-border text-muted hover:text-main cursor-pointer'
|
: 'bg-canvas border-border text-muted hover:text-main hover:bg-panel cursor-pointer'
|
||||||
}`}
|
}`}
|
||||||
title={
|
title={
|
||||||
subView !== 'editor'
|
subView !== 'editor'
|
||||||
@@ -1436,16 +1513,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Workspace Split Pane */}
|
{/* Main Workspace Split Pane */}
|
||||||
<div className="flex h-[calc(100vh-7.5rem)] bg-canvas rounded-card border border-border overflow-hidden">
|
<div className="flex flex-1 min-h-0 bg-editor rounded-card border border-border overflow-hidden">
|
||||||
{/* LEFT MASTER SIDEBAR */}
|
{/* LEFT MASTER SIDEBAR */}
|
||||||
<aside
|
<aside
|
||||||
className={`${
|
className={`${
|
||||||
(subView !== 'editor' && subView !== 'trash') || isSidebarCollapsed
|
(subView !== 'editor' && subView !== 'trash' && subView !== 'journal') || isSidebarCollapsed
|
||||||
? 'hidden'
|
? 'hidden'
|
||||||
: mobileShowDetail
|
: mobileShowDetail
|
||||||
? 'hidden lg:flex'
|
? 'hidden lg:flex'
|
||||||
: 'flex'
|
: 'flex'
|
||||||
} w-full lg:w-72 xl:w-80 shrink-0 flex-col bg-panel border-r border-border transition-all duration-200`}
|
} w-full lg:w-72 xl:w-80 shrink-0 flex-col bg-sidebar border-r border-border transition-all duration-200`}
|
||||||
>
|
>
|
||||||
{/* Sidebar Header & Actions */}
|
{/* Sidebar Header & Actions */}
|
||||||
<div className="p-3 border-b border-border space-y-2">
|
<div className="p-3 border-b border-border space-y-2">
|
||||||
@@ -1492,6 +1569,37 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
{/* Scrollable Navigation Tree */}
|
{/* Scrollable Navigation Tree */}
|
||||||
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
||||||
|
{/* Journal on top; while open, its calendar stays below it */}
|
||||||
|
{journalEnabled && (
|
||||||
|
<div className="border-b border-border pb-1.5 -mb-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openJournal}
|
||||||
|
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
|
||||||
|
subView === 'journal'
|
||||||
|
? 'bg-panel-elevated text-main font-semibold border-border/80'
|
||||||
|
: 'border-transparent text-main hover:bg-panel-elevated'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CalendarDays className="w-3.5 h-3.5 text-accent" />
|
||||||
|
<span className="flex-1 text-left">Journal</span>
|
||||||
|
{subView === 'journal' ? (
|
||||||
|
<ChevronDown className="w-3.5 h-3.5 text-muted" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3.5 h-3.5 text-muted" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{subView === 'journal' && (
|
||||||
|
<div className="hidden lg:block px-1 pt-2 pb-1">
|
||||||
|
<JournalCalendar
|
||||||
|
activeDate={journalActive}
|
||||||
|
entryDates={new Set(journalDays.filter((d) => !d.empty).map((d) => d.date))}
|
||||||
|
onSelect={jumpToJournalDay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? (
|
{pinnedDocs.length === 0 && folderTree.length === 0 && rootDocs.length === 0 ? (
|
||||||
<div className="px-2 py-2 text-xs text-muted">Noch keine Notizen</div>
|
<div className="px-2 py-2 text-xs text-muted">Noch keine Notizen</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -1667,9 +1775,31 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
<main
|
<main
|
||||||
className={`${
|
className={`${
|
||||||
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex'
|
subView !== 'editor' || mobileShowDetail || isSidebarCollapsed ? 'flex' : 'hidden lg:flex'
|
||||||
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-canvas`}
|
} flex-1 flex-col min-w-0 h-full overflow-hidden bg-editor`}
|
||||||
>
|
>
|
||||||
{subView === 'trash' ? (
|
{subView === 'journal' && journalEnabled ? (
|
||||||
|
<JournalView
|
||||||
|
days={journalDays}
|
||||||
|
extraDates={journalExtra}
|
||||||
|
activeDate={journalActive}
|
||||||
|
onActiveDateChange={setJournalActive}
|
||||||
|
jumpRequest={journalJump}
|
||||||
|
onOpenDay={openJournalDay}
|
||||||
|
onJump={jumpToJournalDay}
|
||||||
|
getDoc={(id) => docs.find((d) => d.id === id)}
|
||||||
|
currentUser={currentUser}
|
||||||
|
availableFolders={allFolderPaths}
|
||||||
|
onOpenShareModal={openDocShareModal}
|
||||||
|
onDocMetadataChange={handleDocMetadataChange}
|
||||||
|
onDeleteDoc={handleDeleteDocument}
|
||||||
|
onSelectTag={handleSelectTag}
|
||||||
|
onWikiLinkClick={handleWikiLinkClick}
|
||||||
|
onOpenDocBlock={handleJumpToDocBlock}
|
||||||
|
onOpenTask={onOpenTask}
|
||||||
|
onBackMobile={() => setMobileShowDetail(false)}
|
||||||
|
onDaySaved={reloadJournalSoon}
|
||||||
|
/>
|
||||||
|
) : subView === 'trash' ? (
|
||||||
<TrashView
|
<TrashView
|
||||||
items={trash}
|
items={trash}
|
||||||
onOpen={handleOpenTrashedDoc}
|
onOpen={handleOpenTrashedDoc}
|
||||||
@@ -2386,7 +2516,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-canvas">
|
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center bg-editor">
|
||||||
<FileText className="w-10 h-10 text-faint mb-2" />
|
<FileText className="w-10 h-10 text-faint mb-2" />
|
||||||
<h3 className="text-sm font-semibold text-main mb-1">
|
<h3 className="text-sm font-semibold text-main mb-1">
|
||||||
Keine Notiz ausgewählt
|
Keine Notiz ausgewählt
|
||||||
|
|||||||
@@ -129,9 +129,9 @@ describe('slash command menu and outliner fixes', () => {
|
|||||||
fireEvent.change(ta, { target: { value: 'https://a.de/b/' } });
|
fireEvent.change(ta, { target: { value: 'https://a.de/b/' } });
|
||||||
expect(queryByText('Befehl auswählen')).toBeNull();
|
expect(queryByText('Befehl auswählen')).toBeNull();
|
||||||
|
|
||||||
fireEvent.change(ta, { target: { value: 'Text /co' } });
|
fireEvent.change(ta, { target: { value: 'Text /ca' } });
|
||||||
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||||
expect(queryByText('/code')).toBeTruthy();
|
expect(queryByText('/callout')).toBeTruthy();
|
||||||
|
|
||||||
// Removing the slash closes the menu
|
// Removing the slash closes the menu
|
||||||
fireEvent.change(ta, { target: { value: 'Text ' } });
|
fireEvent.change(ta, { target: { value: 'Text ' } });
|
||||||
@@ -375,3 +375,106 @@ describe('formatting shortcuts (Teil C)', () => {
|
|||||||
expect(readerTa.value).toBe('text');
|
expect(readerTa.value).toBe('text');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('outliner polish (Teil A)', () => {
|
||||||
|
it('opens slash menu at cursor position in the middle of text and inserts link at cursor', () => {
|
||||||
|
const updates: DocumentBlock[] = [];
|
||||||
|
const block = { id: 'b1', type: 'bullet', content: 'Vorher Nachher', level: 0 } as DocumentBlock;
|
||||||
|
const { container, queryByText, getByText, getByPlaceholderText, getByRole } = renderBlock(block, {
|
||||||
|
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
|
||||||
|
});
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
ta.focus();
|
||||||
|
|
||||||
|
// Type "/link" in the middle of the text (selectionStart at 12, right after "/link")
|
||||||
|
fireEvent.change(ta, { target: { value: 'Vorher /link Nachher', selectionStart: 12, selectionEnd: 12 } });
|
||||||
|
|
||||||
|
// Menu should be open with /link, but without /divider or /h1
|
||||||
|
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||||
|
expect(queryByText('/link')).toBeTruthy();
|
||||||
|
expect(queryByText('/divider')).toBeNull();
|
||||||
|
expect(queryByText('/h1')).toBeNull();
|
||||||
|
|
||||||
|
// Select /link by pressing Enter
|
||||||
|
fireEvent.keyDown(ta, { key: 'Enter' });
|
||||||
|
|
||||||
|
// Link modal opens
|
||||||
|
expect(queryByText('Web-Link')).toBeTruthy();
|
||||||
|
fireEvent.click(getByText('Web-Link'));
|
||||||
|
fireEvent.change(getByPlaceholderText('z. B. MindSift Dokumentation'), { target: { value: 'Doku' } });
|
||||||
|
fireEvent.change(getByPlaceholderText('https://example.com'), { target: { value: 'https://example.com/test' } });
|
||||||
|
fireEvent.click(getByRole('button', { name: 'Link einfügen' }));
|
||||||
|
|
||||||
|
// Result has link inserted in the middle
|
||||||
|
expect(updates[updates.length - 1]?.content).toBe('Vorher [Doku](https://example.com/test) Nachher');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer /divider at end of text, but offers it in empty block', () => {
|
||||||
|
const block = { id: 'b1', type: 'bullet', content: 'Ein Text', level: 0 } as DocumentBlock;
|
||||||
|
const { container, queryByText } = renderBlock(block);
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
ta.focus();
|
||||||
|
|
||||||
|
// At end of non-empty text
|
||||||
|
fireEvent.change(ta, { target: { value: 'Ein Text /div' } });
|
||||||
|
|
||||||
|
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||||
|
expect(queryByText('/divider')).toBeNull();
|
||||||
|
|
||||||
|
// In an empty block
|
||||||
|
fireEvent.change(ta, { target: { value: '/div' } });
|
||||||
|
|
||||||
|
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||||
|
expect(queryByText('/divider')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts H1 block back to bullet via /text slash command', () => {
|
||||||
|
const updates: DocumentBlock[] = [];
|
||||||
|
const block = { id: 'b1', type: 'h1', content: 'Meine Überschrift', level: 0 } as DocumentBlock;
|
||||||
|
const { container, queryByText } = renderBlock(block, {
|
||||||
|
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
|
||||||
|
});
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
ta.focus();
|
||||||
|
|
||||||
|
fireEvent.change(ta, { target: { value: 'Meine Überschrift /text' } });
|
||||||
|
|
||||||
|
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||||
|
expect(queryByText('/text')).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.keyDown(ta, { key: 'Enter' });
|
||||||
|
|
||||||
|
const last = updates[updates.length - 1];
|
||||||
|
expect(last?.type).toBe('bullet');
|
||||||
|
expect(last?.content).toBe('Meine Überschrift');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes slash menu on mousedown outside', () => {
|
||||||
|
const block = { id: 'b1', type: 'bullet', content: '', level: 0 } as DocumentBlock;
|
||||||
|
const { container, queryByText } = renderBlock(block);
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
ta.focus();
|
||||||
|
|
||||||
|
fireEvent.change(ta, { target: { value: '/' } });
|
||||||
|
expect(queryByText('Befehl auswählen')).toBeTruthy();
|
||||||
|
|
||||||
|
// Click outside on document body
|
||||||
|
fireEvent.mouseDown(document.body);
|
||||||
|
expect(queryByText('Befehl auswählen')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts focused divider to empty bullet on Backspace', () => {
|
||||||
|
const updates: DocumentBlock[] = [];
|
||||||
|
const block = { id: 'd1', type: 'divider', content: '', level: 0 } as DocumentBlock;
|
||||||
|
const { container } = renderBlock(block, {
|
||||||
|
onUpdateBlock: (u: DocumentBlock) => updates.push(u),
|
||||||
|
});
|
||||||
|
const ta = container.querySelector('textarea') as HTMLTextAreaElement;
|
||||||
|
ta.focus();
|
||||||
|
|
||||||
|
fireEvent.keyDown(ta, { key: 'Backspace' });
|
||||||
|
const last = updates[updates.length - 1];
|
||||||
|
expect(last?.type).toBe('bullet');
|
||||||
|
expect(last?.content).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
+14
-5
@@ -28,7 +28,7 @@
|
|||||||
body {
|
body {
|
||||||
background-color: var(--bg-app);
|
background-color: var(--bg-app);
|
||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
font-family: 'Inter Variable', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
transition: background-color 140ms ease, color 140ms ease;
|
transition: background-color 140ms ease, color 140ms ease;
|
||||||
}
|
}
|
||||||
@@ -318,12 +318,14 @@ body {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.count-badge-accent {
|
.count-badge-accent {
|
||||||
background-color: var(--accent-primary);
|
background: var(--accent-dim);
|
||||||
color: var(--text-on-accent);
|
color: var(--accent-primary);
|
||||||
|
border: 1px solid var(--accent-border);
|
||||||
}
|
}
|
||||||
.count-badge-muted {
|
.count-badge-muted {
|
||||||
background-color: var(--text-muted);
|
background-color: var(--bg-panel-elevated);
|
||||||
color: var(--bg-panel);
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
.count-badge-corner {
|
.count-badge-corner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -550,6 +552,13 @@ fieldset:disabled :is(button, input, select, textarea) {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Workspace surfaces: the note editor and the folder sidebar. Themes may override them (the light theme
|
||||||
|
uses a white sheet next to a grey sidebar); by default they follow the page and panel colours. */
|
||||||
|
:root {
|
||||||
|
--bg-editor: var(--bg-app);
|
||||||
|
--bg-sidebar: var(--bg-panel);
|
||||||
|
}
|
||||||
|
|
||||||
/* Outliner: indent per level and the width a row keeps for its text. Phones indent less and let very
|
/* Outliner: indent per level and the width a row keeps for its text. Phones indent less and let very
|
||||||
deep rows run wider than the screen (the note then scrolls sideways) instead of squeezing the text. */
|
deep rows run wider than the screen (the note then scrolls sideways) instead of squeezing the text. */
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { monthGrid, toISODate, parseISODate } from '../journal';
|
||||||
|
|
||||||
|
describe('journal dates', () => {
|
||||||
|
it('builds a Monday-first month grid', () => {
|
||||||
|
// October 2026 starts on a Thursday
|
||||||
|
const weeks = monthGrid(2026, 9);
|
||||||
|
expect(weeks[0]).toEqual([null, null, null, '2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04']);
|
||||||
|
expect(weeks.flat().filter(Boolean)).toHaveLength(31);
|
||||||
|
expect(weeks.every((w) => w.length === 7)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips local dates', () => {
|
||||||
|
expect(toISODate(parseISODate('2026-03-01'))).toBe('2026-03-01');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { listLabel, numberedLabels } from '../outlineList';
|
||||||
|
import type { DocumentBlock } from '../types';
|
||||||
|
|
||||||
|
const blk = (id: string, level: number, type: DocumentBlock['type'] = 'numbered'): DocumentBlock => ({
|
||||||
|
id, documentId: 'd', type, content: id, level, position: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('numbered lists', () => {
|
||||||
|
it('formats numbers, letters and roman numerals by depth', () => {
|
||||||
|
expect(listLabel(3, 0)).toBe('3.');
|
||||||
|
expect(listLabel(2, 1)).toBe('b.');
|
||||||
|
expect(listLabel(27, 1)).toBe('aa.');
|
||||||
|
expect(listLabel(4, 2)).toBe('iv.');
|
||||||
|
expect(listLabel(1, 3)).toBe('1.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts siblings, nests and restarts after other blocks', () => {
|
||||||
|
const labels = numberedLabels([
|
||||||
|
blk('a', 0), blk('a1', 1), blk('a2', 1), blk('a2i', 2), blk('b', 0),
|
||||||
|
blk('note', 0, 'text'), blk('c', 0), blk('plain', 1, 'text'), blk('c1', 2),
|
||||||
|
]);
|
||||||
|
expect(Object.fromEntries(labels)).toEqual({
|
||||||
|
a: '1.', a1: 'a.', a2: 'b.', a2i: 'i.', b: '2.', c: '1.', c1: 'a.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { parsePastedLines } from '../pasteOutline';
|
||||||
|
|
||||||
|
const parse = (text: string) => parsePastedLines(text.split('\n'));
|
||||||
|
|
||||||
|
describe('parsePastedLines', () => {
|
||||||
|
it('splits paragraphs at blank lines and keeps wrapped lines together', () => {
|
||||||
|
expect(parse('Erster Absatz\nnoch erster\n\nZweiter Absatz')).toEqual([
|
||||||
|
{ type: 'text', content: 'Erster Absatz\nnoch erster', indent: 0 },
|
||||||
|
{ type: 'text', content: 'Zweiter Absatz', indent: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps numbered lists with nesting (3-space indent)', () => {
|
||||||
|
expect(parse('1. Eins\n 1. Unterpunkt\n 1. Tiefer\n2. Zwei')).toEqual([
|
||||||
|
{ type: 'numbered', content: 'Eins', indent: 0 },
|
||||||
|
{ type: 'numbered', content: 'Unterpunkt', indent: 1 },
|
||||||
|
{ type: 'numbered', content: 'Tiefer', indent: 2 },
|
||||||
|
{ type: 'numbered', content: 'Zwei', indent: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads headings, todos, bullets and dividers', () => {
|
||||||
|
expect(parse('# Titel\nText danach\n- [x] erledigt\n- Punkt\n---')).toEqual([
|
||||||
|
{ type: 'h1', content: 'Titel', indent: 0 },
|
||||||
|
{ type: 'text', content: 'Text danach', indent: 0 },
|
||||||
|
{ type: 'todo', checked: true, content: 'erledigt', indent: 0 },
|
||||||
|
{ type: 'text', content: 'Punkt', indent: 0 },
|
||||||
|
{ type: 'divider', content: '---', indent: 0 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps fenced code as one block', () => {
|
||||||
|
const items = parse('```go\nfunc main() {}\n\n```\nDanach');
|
||||||
|
expect(items[0].content).toBe('```go\nfunc main() {}\n\n```');
|
||||||
|
expect(items[1]).toEqual({ type: 'text', content: 'Danach', indent: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
DashboardStats,
|
DashboardStats,
|
||||||
Document,
|
Document,
|
||||||
TrashedDocument,
|
TrashedDocument,
|
||||||
|
JournalDay,
|
||||||
BackupFile,
|
BackupFile,
|
||||||
DataSection,
|
DataSection,
|
||||||
ApiKeyScope,
|
ApiKeyScope,
|
||||||
@@ -723,6 +724,17 @@ export const api = {
|
|||||||
return request<TrashedDocument[]>('/api/v1/documents/trash');
|
return request<TrashedDocument[]>('/api/v1/documents/trash');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Days of the own journal (newest first) with their notes. */
|
||||||
|
async getJournal(): Promise<JournalDay[]> {
|
||||||
|
const res = await request<{ days: JournalDay[] }>('/api/v1/journal');
|
||||||
|
return res.days || [];
|
||||||
|
},
|
||||||
|
|
||||||
|
/** The note of a journal day (YYYY-MM-DD); created on first use. */
|
||||||
|
async openJournalDay(date: string): Promise<Document> {
|
||||||
|
return request<Document>(`/api/v1/journal/${date}`, { method: 'POST' });
|
||||||
|
},
|
||||||
|
|
||||||
/** Creates a note from a bullet's children; the editor then replaces the branch by `link` itself. */
|
/** Creates a note from a bullet's children; the editor then replaces the branch by `link` itself. */
|
||||||
async extractBranch(docId: string, blockId: string, clientId: string): Promise<{ document: Document; link: string }> {
|
async extractBranch(docId: string, blockId: string, clientId: string): Promise<{ document: Document; link: string }> {
|
||||||
return request<{ document: Document; link: string }>(`/api/v1/documents/${docId}/extract`, {
|
return request<{ document: Document; link: string }>(`/api/v1/documents/${docId}/extract`, {
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Journal helpers: dates are local calendar days as YYYY-MM-DD (the server stores them as given).
|
||||||
|
|
||||||
|
export const JOURNAL_ENABLED_KEY = 'mindsift_journal_enabled';
|
||||||
|
|
||||||
|
export function isJournalEnabled(): boolean {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(JOURNAL_ENABLED_KEY) !== 'false';
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setJournalEnabled(on: boolean) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(JOURNAL_ENABLED_KEY, on ? 'true' : 'false');
|
||||||
|
} catch {
|
||||||
|
// storage blocked: the setting simply does not stick
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toISODate(d: Date): string {
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${m}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function todayISO(): string {
|
||||||
|
return toISODate(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseISODate(date: string): Date {
|
||||||
|
const [y, m, d] = date.split('-').map(Number);
|
||||||
|
return new Date(y, (m || 1) - 1, d || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MONTHS = [
|
||||||
|
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||||
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** "Fr, 30. Okt. 2026" for compact labels */
|
||||||
|
export function shortDateLabel(date: string): string {
|
||||||
|
return parseISODate(date).toLocaleDateString('de-DE', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weeks of a month for a Monday-first grid; days outside the month are null. */
|
||||||
|
export function monthGrid(year: number, month: number): (string | null)[][] {
|
||||||
|
const first = new Date(year, month, 1);
|
||||||
|
const offset = (first.getDay() + 6) % 7; // Monday = 0
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const cells: (string | null)[] = Array(offset).fill(null);
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) cells.push(toISODate(new Date(year, month, d)));
|
||||||
|
while (cells.length % 7 !== 0) cells.push(null);
|
||||||
|
const weeks: (string | null)[][] = [];
|
||||||
|
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
|
||||||
|
return weeks;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { DocumentBlock } from './types';
|
||||||
|
|
||||||
|
// Numbered lists in the outliner: consecutive 'numbered' siblings count 1, 2, 3 …; a numbered list
|
||||||
|
// inside another one uses a, b, c, the next level i, ii, iii, then numbers again.
|
||||||
|
|
||||||
|
function alpha(n: number): string {
|
||||||
|
let s = '';
|
||||||
|
while (n > 0) {
|
||||||
|
n--;
|
||||||
|
s = String.fromCharCode(97 + (n % 26)) + s;
|
||||||
|
n = Math.floor(n / 26);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROMAN: [number, string][] = [
|
||||||
|
[1000, 'm'], [900, 'cm'], [500, 'd'], [400, 'cd'], [100, 'c'], [90, 'xc'],
|
||||||
|
[50, 'l'], [40, 'xl'], [10, 'x'], [9, 'ix'], [5, 'v'], [4, 'iv'], [1, 'i'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function roman(n: number): string {
|
||||||
|
let s = '';
|
||||||
|
for (const [v, r] of ROMAN) {
|
||||||
|
while (n >= v) {
|
||||||
|
s += r;
|
||||||
|
n -= v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listLabel(n: number, depth: number): string {
|
||||||
|
switch (depth % 3) {
|
||||||
|
case 1:
|
||||||
|
return `${alpha(n)}.`;
|
||||||
|
case 2:
|
||||||
|
return `${roman(n)}.`;
|
||||||
|
default:
|
||||||
|
return `${n}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label ("1.", "b.", "iii.") for every numbered block, in document order. */
|
||||||
|
export function numberedLabels(blocks: DocumentBlock[]): Map<string, string> {
|
||||||
|
const labels = new Map<string, string>();
|
||||||
|
const runs = new Map<number, number>(); // level -> count of the current run
|
||||||
|
const chain: DocumentBlock[] = []; // last block seen per level = ancestors of the next one
|
||||||
|
for (const b of blocks) {
|
||||||
|
const level = Math.max(0, b.level || 0);
|
||||||
|
for (const l of Array.from(runs.keys())) {
|
||||||
|
if (l > level) runs.delete(l);
|
||||||
|
}
|
||||||
|
chain.length = Math.min(chain.length, level);
|
||||||
|
if (b.type === 'numbered') {
|
||||||
|
const n = (runs.get(level) ?? 0) + 1;
|
||||||
|
runs.set(level, n);
|
||||||
|
const depth = chain.filter((a) => a && a.type === 'numbered').length;
|
||||||
|
labels.set(b.id, listLabel(n, depth));
|
||||||
|
} else {
|
||||||
|
runs.delete(level);
|
||||||
|
}
|
||||||
|
chain[level] = b;
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import type { BlockType } from './types';
|
||||||
|
|
||||||
|
// Turns pasted (Markdown) text into outline items: headings, todos, bullets, numbered items, dividers,
|
||||||
|
// fenced code and paragraphs. A blank line ends a paragraph; indentation becomes the nesting level.
|
||||||
|
|
||||||
|
export interface PastedItem {
|
||||||
|
type: BlockType;
|
||||||
|
checked?: boolean;
|
||||||
|
content: string;
|
||||||
|
indent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function leadingWidth(line: string): { width: number; rest: string } {
|
||||||
|
let width = 0;
|
||||||
|
let i = 0;
|
||||||
|
while (i < line.length && (line[i] === ' ' || line[i] === '\t')) {
|
||||||
|
width += line[i] === '\t' ? 4 : 1;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return { width, rest: line.slice(i) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePastedLines(rawLines: string[]): PastedItem[] {
|
||||||
|
// Indentation steps differ (2, 3 or 4 spaces, tabs): the distinct widths in use are the levels
|
||||||
|
const widths = Array.from(
|
||||||
|
new Set(rawLines.filter((l) => l.trim() !== '').map((l) => leadingWidth(l).width))
|
||||||
|
).sort((a, b) => a - b);
|
||||||
|
const levelOf = (w: number) => Math.max(0, widths.indexOf(w));
|
||||||
|
|
||||||
|
const items: PastedItem[] = [];
|
||||||
|
let current: PastedItem | null = null;
|
||||||
|
let inCode = false;
|
||||||
|
let afterHeading = false;
|
||||||
|
|
||||||
|
const flush = () => {
|
||||||
|
if (current) {
|
||||||
|
current.content = current.content.trimEnd();
|
||||||
|
items.push(current);
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of rawLines) {
|
||||||
|
if (inCode) {
|
||||||
|
if (current) (current as PastedItem).content += '\n' + rawLine;
|
||||||
|
if (rawLine.trim().startsWith('```')) {
|
||||||
|
inCode = false;
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rawLine.trim().startsWith('```')) {
|
||||||
|
inCode = true;
|
||||||
|
flush();
|
||||||
|
current = { type: 'text', content: rawLine, indent: 0 };
|
||||||
|
afterHeading = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width, rest } = leadingWidth(rawLine);
|
||||||
|
const indent = levelOf(width);
|
||||||
|
const line = rest.trimEnd();
|
||||||
|
|
||||||
|
// A blank line ends the paragraph or list item
|
||||||
|
if (line === '') {
|
||||||
|
flush();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const heading = line.match(/^(#{1,3})\s+(.*)$/);
|
||||||
|
if (heading) {
|
||||||
|
flush();
|
||||||
|
afterHeading = true;
|
||||||
|
items.push({ type: (['h1', 'h2', 'h3'] as const)[heading[1].length - 1], content: heading[2], indent });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(line)) {
|
||||||
|
flush();
|
||||||
|
afterHeading = false;
|
||||||
|
items.push({ type: 'divider', content: '---', indent });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const todo = line.match(/^[-*+]\s*\[([ xX])\]\s*(.*)$/);
|
||||||
|
const bullet = line.match(/^[-*+•]\s+(.*)$/);
|
||||||
|
const numbered = line.match(/^\d+[.)]\s+(.*)$/);
|
||||||
|
if (todo || bullet || numbered) {
|
||||||
|
flush();
|
||||||
|
afterHeading = false;
|
||||||
|
current = todo
|
||||||
|
? { type: 'todo', checked: todo[1].toLowerCase() === 'x', content: todo[2], indent }
|
||||||
|
: numbered
|
||||||
|
? { type: 'numbered', content: numbered[1], indent }
|
||||||
|
: { type: 'text', content: bullet![1], indent };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain text: continues the paragraph or list item above (a wrapped line), else starts a paragraph
|
||||||
|
if (current && !afterHeading) {
|
||||||
|
(current as PastedItem).content += '\n' + line;
|
||||||
|
} else {
|
||||||
|
flush();
|
||||||
|
current = { type: 'text', content: line, indent };
|
||||||
|
}
|
||||||
|
afterHeading = false;
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return items;
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ export type SharingMode = 'private' | 'organizations' | 'open';
|
|||||||
export interface NotificationChannelRouting {
|
export interface NotificationChannelRouting {
|
||||||
email: boolean;
|
email: boolean;
|
||||||
discord: boolean;
|
discord: boolean;
|
||||||
|
webhook: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationEvents {
|
export interface NotificationEvents {
|
||||||
@@ -63,6 +64,7 @@ export interface DailySummarySettings {
|
|||||||
time: string;
|
time: string;
|
||||||
email: boolean;
|
email: boolean;
|
||||||
discord: boolean;
|
discord: boolean;
|
||||||
|
webhook: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NotificationSettings {
|
export interface NotificationSettings {
|
||||||
@@ -259,7 +261,7 @@ export const DATA_SECTIONS: { id: DataSection; label: string }[] = [
|
|||||||
{ id: 'themes', label: 'Themes' },
|
{ id: 'themes', label: 'Themes' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'code' | 'callout' | 'divider' | 'table' | 'progress' | 'image' | 'audio';
|
export type BlockType = 'h1' | 'h2' | 'h3' | 'paragraph' | 'text' | 'todo' | 'bullet' | 'numbered' | 'code' | 'callout' | 'divider' | 'table' | 'progress' | 'image' | 'audio';
|
||||||
|
|
||||||
export interface DocumentBlock {
|
export interface DocumentBlock {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -320,6 +322,13 @@ export interface Backlink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A note in the trash (GET /documents/trash). */
|
/** A note in the trash (GET /documents/trash). */
|
||||||
|
/** A day of the own journal; `empty` while its note has no text yet. */
|
||||||
|
export interface JournalDay {
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
docId: string;
|
||||||
|
empty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrashedDocument {
|
export interface TrashedDocument {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React from 'react';
|
|||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import { MobileActionProvider } from './context/MobileActionContext';
|
import { MobileActionProvider } from './context/MobileActionContext';
|
||||||
|
// Inter ships with the app (no external font service); the variable font covers all weights
|
||||||
|
import '@fontsource-variable/inter';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
--bg-input: #ffffff;
|
--bg-input: #ffffff;
|
||||||
--bg-overlay: rgba(15, 23, 42, 0.45);
|
--bg-overlay: rgba(15, 23, 42, 0.45);
|
||||||
--bg-code: #e2e8f0;
|
--bg-code: #e2e8f0;
|
||||||
|
--bg-editor: #ffffff;
|
||||||
|
--bg-sidebar: #f3f4f6;
|
||||||
|
|
||||||
/* --- 3. TEXT & KONTRASTE --- */
|
/* --- 3. TEXT & KONTRASTE --- */
|
||||||
--text-main: #111827;
|
--text-main: #111827;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
--bg-input: #121316; /* Textfelder, Selects, Textareas */
|
--bg-input: #121316; /* Textfelder, Selects, Textareas */
|
||||||
--bg-overlay: rgba(0, 0, 0, 0.72); /* Hintergrund bei Modals/Dialogen */
|
--bg-overlay: rgba(0, 0, 0, 0.72); /* Hintergrund bei Modals/Dialogen */
|
||||||
--bg-code: #0d0e11; /* Code-Blöcke & Terminal-Boxen */
|
--bg-code: #0d0e11; /* Code-Blöcke & Terminal-Boxen */
|
||||||
|
/* Optional: --bg-editor (Notiz-Editor, Standard --bg-app) und --bg-sidebar (Ordnerleiste, Standard --bg-panel) */
|
||||||
|
|
||||||
/* --- 3. TEXT & KONTRASTE --- */
|
/* --- 3. TEXT & KONTRASTE --- */
|
||||||
--text-main: #f4f4f5; /* Haupttext & Überschriften */
|
--text-main: #f4f4f5; /* Haupttext & Überschriften */
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export default {
|
|||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
canvas: v('--bg-app'),
|
canvas: v('--bg-app'),
|
||||||
|
editor: v('--bg-editor'),
|
||||||
|
sidebar: v('--bg-sidebar'),
|
||||||
panel: {
|
panel: {
|
||||||
DEFAULT: v('--bg-panel'),
|
DEFAULT: v('--bg-panel'),
|
||||||
elevated: v('--bg-panel-elevated'),
|
elevated: v('--bg-panel-elevated'),
|
||||||
@@ -83,6 +85,7 @@ export default {
|
|||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: [
|
sans: [
|
||||||
|
'Inter Variable',
|
||||||
'Inter',
|
'Inter',
|
||||||
'system-ui',
|
'system-ui',
|
||||||
'-apple-system',
|
'-apple-system',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite';
|
|||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
// Shown in the header. Release builds get the git tag (APP_VERSION build argument, e.g. "v0.6.0");
|
// Shown in the header. Tag and main builds get APP_VERSION as build argument (e.g. "v0.6.0");
|
||||||
// other builds show the package version with a "-dev" suffix (Semantic Versioning, see CHANGELOG.md).
|
// other builds show the package version with a "-dev" suffix (Semantic Versioning, see CHANGELOG.md).
|
||||||
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
const pkg = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
|
||||||
const appVersion = process.env.APP_VERSION || `v${pkg.version}-dev`;
|
const appVersion = process.env.APP_VERSION || `v${pkg.version}-dev`;
|
||||||
|
|||||||
@@ -140,11 +140,9 @@ func (s *Server) startAlarmTicker() {
|
|||||||
if set.Events.Due.Discord {
|
if set.Events.Due.Discord {
|
||||||
channels = append(channels, "discord")
|
channels = append(channels, "discord")
|
||||||
}
|
}
|
||||||
for _, ch := range a.AlarmChannels {
|
if set.Events.Due.Webhook {
|
||||||
if ch == "webhook" {
|
|
||||||
channels = append(channels, "webhook")
|
channels = append(channels, "webhook")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if a.AlarmEmail == "" {
|
if a.AlarmEmail == "" {
|
||||||
a.AlarmEmail = a.UserEmail
|
a.AlarmEmail = a.UserEmail
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/sandrodach/mindsift/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Journal: one ordinary note per day, opened (and created on first use) by date. The web app shows the
|
||||||
|
// days below each other with a calendar; the notes can be shared and searched like any other.
|
||||||
|
|
||||||
|
var journalMu sync.Mutex // one creation at a time, so a day never gets two notes
|
||||||
|
|
||||||
|
var (
|
||||||
|
journalWeekdays = []string{"Sonntag", "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag"}
|
||||||
|
journalMonths = []string{"Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// journalTitle: "Freitag, 30. Oktober 2026"
|
||||||
|
func journalTitle(day time.Time) string {
|
||||||
|
return fmt.Sprintf("%s, %d. %s %d", journalWeekdays[day.Weekday()], day.Day(), journalMonths[day.Month()-1], day.Year())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetJournal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
days, err := s.store.JournalDays(auth.GetUserID(r.Context()))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Journal konnte nicht geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"days": days})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOpenJournalDay returns the note of a day and creates it when there is none yet.
|
||||||
|
func (s *Server) handleOpenJournalDay(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID := auth.GetUserID(r.Context())
|
||||||
|
date := chi.URLParam(r, "date")
|
||||||
|
day, err := time.Parse("2006-01-02", date)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "Ungültiges Datum (erwartet JJJJ-MM-TT)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
journalMu.Lock()
|
||||||
|
defer journalMu.Unlock()
|
||||||
|
docID, err := s.store.JournalDocID(userID, date)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Journal konnte nicht geladen werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if docID != "" {
|
||||||
|
if doc, err := s.store.GetDocumentByID(userID, docID); err == nil {
|
||||||
|
writeJSON(w, http.StatusOK, doc)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doc, err := s.store.CreateDocument(userID, "", journalTitle(day), "📅", "", nil, nil, false)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Journal-Tag konnte nicht angelegt werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.store.SetJournalDay(userID, date, doc.ID); err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, "Journal-Tag konnte nicht angelegt werden")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.broadcastDocToAuthorizedUsers(doc.ID, "DOC_CREATED")
|
||||||
|
writeJSON(w, http.StatusCreated, doc)
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJournalDayIsCreatedOnceAndListed(t *testing.T) {
|
||||||
|
srv, store, _ := setupTestServer(t)
|
||||||
|
router := srv.Router()
|
||||||
|
me, token := newTestUser(t, store, "journal@example.com")
|
||||||
|
|
||||||
|
rec := sessionCall(router, "POST", "/api/v1/journal/2026-10-30", token, "", "")
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var doc struct {
|
||||||
|
ID, Title, Icon string
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &doc)
|
||||||
|
if doc.Title != "Freitag, 30. Oktober 2026" || doc.Icon != "📅" {
|
||||||
|
t.Fatalf("unexpected note: %+v", doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening the same day again returns the same note
|
||||||
|
rec = sessionCall(router, "POST", "/api/v1/journal/2026-10-30", token, "", "")
|
||||||
|
var again struct{ ID string }
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &again)
|
||||||
|
if rec.Code != http.StatusOK || again.ID != doc.ID {
|
||||||
|
t.Fatalf("second open: %d id %s want %s", rec.Code, again.ID, doc.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = sessionCall(router, "GET", "/api/v1/journal", token, "", "")
|
||||||
|
var list struct {
|
||||||
|
Days []struct {
|
||||||
|
Date, DocID string
|
||||||
|
Empty bool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &list)
|
||||||
|
if len(list.Days) != 1 || list.Days[0].Date != "2026-10-30" || list.Days[0].DocID != doc.ID || !list.Days[0].Empty {
|
||||||
|
t.Fatalf("list: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Other people have their own journal
|
||||||
|
_, otherToken := newTestUser(t, store, "andere@example.com")
|
||||||
|
rec = sessionCall(router, "GET", "/api/v1/journal", otherToken, "", "")
|
||||||
|
if rec.Code != http.StatusOK || rec.Body.String() == "" {
|
||||||
|
t.Fatalf("other list: %d", rec.Code)
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &list)
|
||||||
|
if len(list.Days) != 0 {
|
||||||
|
t.Fatalf("other user must not see my journal: %+v", list.Days)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A day whose note went to the trash gets a fresh note
|
||||||
|
if err := store.DeleteDocument(me.ID, doc.ID); err != nil {
|
||||||
|
t.Fatalf("trash: %v", err)
|
||||||
|
}
|
||||||
|
rec = sessionCall(router, "POST", "/api/v1/journal/2026-10-30", token, "", "")
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &again)
|
||||||
|
if rec.Code != http.StatusCreated || again.ID == doc.ID {
|
||||||
|
t.Fatalf("after trash: %d id %s", rec.Code, again.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rec := sessionCall(router, "POST", "/api/v1/journal/30.10.2026", token, "", ""); rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("bad date: want 400, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJournalTitle(t *testing.T) {
|
||||||
|
day := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
if got := journalTitle(day); got != "Sonntag, 1. März 2026" {
|
||||||
|
t.Fatalf("got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
-22
@@ -18,7 +18,7 @@ import (
|
|||||||
|
|
||||||
// Notifications besides task alarms: events go into a queue per recipient and are sent bundled, one
|
// Notifications besides task alarms: events go into a queue per recipient and are sent bundled, one
|
||||||
// message per recipient and channel once the oldest event is NOTIFY_DIGEST_MINUTES old. Each user picks
|
// message per recipient and channel once the oldest event is NOTIFY_DIGEST_MINUTES old. Each user picks
|
||||||
// the channels per event kind (own mail address, own Discord webhook from the alarm settings), can
|
// the channels per event kind (own mail address, own Discord webhook, own webhook with a JSON body), can
|
||||||
// pause everything and gets a daily summary of due tasks at a chosen time. Own changes never notify the
|
// pause everything and gets a daily summary of due tasks at a chosen time. Own changes never notify the
|
||||||
// one who made them, except on watched tasks when the user asked for it. The demo sends nothing.
|
// one who made them, except on watched tasks when the user asked for it. The demo sends nothing.
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ func (s *Server) notify(kind, actorID string, recipients []string, payload map[s
|
|||||||
if uid == actorID && !(kind == "watched" && set.IncludeOwnWatched) {
|
if uid == actorID && !(kind == "watched" && set.IncludeOwnWatched) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ch := set.Channels(kind); !ch.Email && !ch.Discord {
|
if !set.Channels(kind).Any() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := s.store.QueueNotification(uid, kind, payload); err != nil {
|
if err := s.store.QueueNotification(uid, kind, payload); err != nil {
|
||||||
@@ -161,7 +161,7 @@ func (s *Server) flushNotifications(now time.Time) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
set := s.store.NotificationSettingsOf(uid)
|
set := s.store.NotificationSettingsOf(uid)
|
||||||
var emailItems, discordItems []notifyItem
|
var out channelItems
|
||||||
ids := make([]int64, 0, len(queued))
|
ids := make([]int64, 0, len(queued))
|
||||||
maxAttempts := 0
|
maxAttempts := 0
|
||||||
for _, q := range queued {
|
for _, q := range queued {
|
||||||
@@ -173,15 +173,9 @@ func (s *Server) flushNotifications(now time.Time) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
item := notifyItem{kind: q.Kind, title: q.Payload["title"], text: q.Payload["text"]}
|
item := notifyItem{kind: q.Kind, title: q.Payload["title"], text: q.Payload["text"]}
|
||||||
ch := set.Channels(q.Kind)
|
out.add(set.Channels(q.Kind), item)
|
||||||
if ch.Email {
|
|
||||||
emailItems = append(emailItems, item)
|
|
||||||
}
|
}
|
||||||
if ch.Discord {
|
if err := s.deliver(uid, "Neuigkeiten", out); err != nil {
|
||||||
discordItems = append(discordItems, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.deliver(uid, "Neuigkeiten", emailItems, discordItems); err != nil {
|
|
||||||
if maxAttempts+1 >= notifyMaxAttempts {
|
if maxAttempts+1 >= notifyMaxAttempts {
|
||||||
slog.Warn("Benachrichtigungen nach mehreren Fehlversuchen verworfen", "user_id", uid, "count", len(ids), "error", err)
|
slog.Warn("Benachrichtigungen nach mehreren Fehlversuchen verworfen", "user_id", uid, "count", len(ids), "error", err)
|
||||||
_ = s.store.DeleteQueuedNotifications(ids)
|
_ = s.store.DeleteQueuedNotifications(ids)
|
||||||
@@ -194,8 +188,26 @@ func (s *Server) flushNotifications(now time.Time) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// deliver sends the items to the user's mail address and/or Discord webhook.
|
// channelItems collects what goes out on each channel.
|
||||||
func (s *Server) deliver(userID, heading string, emailItems, discordItems []notifyItem) error {
|
type channelItems struct {
|
||||||
|
email, discord, webhook []notifyItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *channelItems) add(ch domain.ChannelChoice, items ...notifyItem) {
|
||||||
|
if ch.Email {
|
||||||
|
c.email = append(c.email, items...)
|
||||||
|
}
|
||||||
|
if ch.Discord {
|
||||||
|
c.discord = append(c.discord, items...)
|
||||||
|
}
|
||||||
|
if ch.Webhook {
|
||||||
|
c.webhook = append(c.webhook, items...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliver sends the items to the user's mail address, Discord webhook and/or own webhook.
|
||||||
|
func (s *Server) deliver(userID, heading string, out channelItems) error {
|
||||||
|
emailItems, discordItems := out.email, out.discord
|
||||||
u, err := s.store.GetUserByID(userID)
|
u, err := s.store.GetUserByID(userID)
|
||||||
if err != nil || u == nil || !u.IsActive {
|
if err != nil || u == nil || !u.IsActive {
|
||||||
return nil // account gone: nothing to deliver
|
return nil // account gone: nothing to deliver
|
||||||
@@ -219,6 +231,11 @@ func (s *Server) deliver(userID, heading string, emailItems, discordItems []noti
|
|||||||
firstErr = err
|
firstErr = err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(out.webhook) > 0 && u.CustomWebhookURL != "" {
|
||||||
|
if err := s.sendNotifyWebhook(u.CustomWebhookURL, heading, out.webhook); err != nil && firstErr == nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
return firstErr
|
return firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,6 +254,44 @@ func (s *Server) sendNotifyDiscord(webhookURL, heading string, items []notifyIte
|
|||||||
return s.sendDiscordDigest(webhookURL, heading, items)
|
return s.sendDiscordDigest(webhookURL, heading, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) sendNotifyWebhook(webhookURL, heading string, items []notifyItem) error {
|
||||||
|
if s.notifyWebhookHook != nil {
|
||||||
|
return s.notifyWebhookHook(webhookURL, heading, items)
|
||||||
|
}
|
||||||
|
return s.sendWebhookDigest(webhookURL, heading, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendWebhookDigest posts the bundle as JSON: {"event":"digest","heading":…,"items":[{kind,title,text}],…}.
|
||||||
|
func (s *Server) sendWebhookDigest(webhookURL, heading string, items []notifyItem) error {
|
||||||
|
if s.cfg.DemoMode {
|
||||||
|
return errDemoOutgoing
|
||||||
|
}
|
||||||
|
allowPrivate := s.allowPrivateWebhooks()
|
||||||
|
if err := validateWebhookURL(webhookURL, false, allowPrivate); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
list := make([]map[string]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
list = append(list, map[string]string{"kind": it.kind, "title": it.title, "text": it.text})
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"event": "digest",
|
||||||
|
"heading": heading,
|
||||||
|
"items": list,
|
||||||
|
"link": s.notifyLink(),
|
||||||
|
"timestamp": domain.NowISO(),
|
||||||
|
})
|
||||||
|
resp, err := guardedClient(allowPrivate).Post(webhookURL, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
return fmt.Errorf("HTTP Status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) notifyLink() string {
|
func (s *Server) notifyLink() string {
|
||||||
if !s.cfg.BaseURLSet {
|
if !s.cfg.BaseURLSet {
|
||||||
return ""
|
return ""
|
||||||
@@ -322,7 +377,7 @@ func (s *Server) sendDailySummaries(now time.Time) {
|
|||||||
for _, uid := range s.store.ActiveUserIDs() {
|
for _, uid := range s.store.ActiveUserIDs() {
|
||||||
set := s.store.NotificationSettingsOf(uid)
|
set := s.store.NotificationSettingsOf(uid)
|
||||||
ds := set.DailySummary
|
ds := set.DailySummary
|
||||||
if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord) {
|
if set.Paused || !ds.Enabled || (!ds.Email && !ds.Discord && !ds.Webhook) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
at, err := time.ParseInLocation("2006-01-02 15:04", day+" "+ds.Time, now.Location())
|
at, err := time.ParseInLocation("2006-01-02 15:04", day+" "+ds.Time, now.Location())
|
||||||
@@ -333,14 +388,9 @@ func (s *Server) sendDailySummaries(now time.Time) {
|
|||||||
if len(items) == 0 || !s.store.ClaimDailySummary(uid, day) {
|
if len(items) == 0 || !s.store.ClaimDailySummary(uid, day) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var mail, discord []notifyItem
|
var out channelItems
|
||||||
if ds.Email {
|
out.add(domain.ChannelChoice{Email: ds.Email, Discord: ds.Discord, Webhook: ds.Webhook}, items...)
|
||||||
mail = items
|
if err := s.deliver(uid, "Deine Aufgaben für heute", out); err != nil {
|
||||||
}
|
|
||||||
if ds.Discord {
|
|
||||||
discord = items
|
|
||||||
}
|
|
||||||
if err := s.deliver(uid, "Deine Aufgaben für heute", mail, discord); err != nil {
|
|
||||||
slog.Warn("Tageszusammenfassung konnte nicht verschickt werden", "user_id", uid, "error", err)
|
slog.Warn("Tageszusammenfassung konnte nicht verschickt werden", "user_id", uid, "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ func notifyServer(t *testing.T) (*Server, *[]sentNote) {
|
|||||||
sent = append(sent, sentNote{channel: "discord", to: url, text: strings.Join(lines, "\n")})
|
sent = append(sent, sentNote{channel: "discord", to: url, text: strings.Join(lines, "\n")})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
srv.notifyWebhookHook = func(url, heading string, items []notifyItem) error {
|
||||||
|
lines := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
lines = append(lines, it.text)
|
||||||
|
}
|
||||||
|
sent = append(sent, sentNote{channel: "webhook", to: url, text: strings.Join(lines, "\n")})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return srv, &sent
|
return srv, &sent
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +110,18 @@ func TestOwnChangesPauseAndChannels(t *testing.T) {
|
|||||||
t.Fatalf("expected one Discord message, got %+v", *sent)
|
t.Fatalf("expected one Discord message, got %+v", *sent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Own webhook as a channel of its own
|
||||||
|
*sent = nil
|
||||||
|
set.Events.Watched = domain.ChannelChoice{Webhook: true}
|
||||||
|
_ = store.SetNotificationSettings(me.ID, set)
|
||||||
|
own := "https://hooks.example.com/mindsift"
|
||||||
|
_, _ = store.UpdateUserFull(me.ID, nil, nil, nil, nil, nil, nil, nil, &own, nil, nil)
|
||||||
|
sessionCall(router, "PATCH", "/api/v1/thoughts/"+task.ID, myToken, `{"priority":"normal"}`, "")
|
||||||
|
srv.flushNotifications(time.Now().Add(10 * time.Minute))
|
||||||
|
if len(*sent) != 1 || (*sent)[0].channel != "webhook" || (*sent)[0].to != own {
|
||||||
|
t.Fatalf("expected one webhook message, got %+v", *sent)
|
||||||
|
}
|
||||||
|
|
||||||
// Paused: nothing at all
|
// Paused: nothing at all
|
||||||
*sent = nil
|
*sent = nil
|
||||||
set.Paused = true
|
set.Paused = true
|
||||||
@@ -114,7 +134,7 @@ func TestOwnChangesPauseAndChannels(t *testing.T) {
|
|||||||
|
|
||||||
// Settings round trip over the profile API
|
// Settings round trip over the profile API
|
||||||
rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", myToken, `{"notificationSettings":{"paused":false,"events":{"shared":{"email":false,"discord":true}},"dailySummary":{"enabled":true,"time":"6:5"}}}`, "")
|
rec := sessionCall(router, "PATCH", "/api/v1/auth/settings", myToken, `{"notificationSettings":{"paused":false,"events":{"shared":{"email":false,"discord":true}},"dailySummary":{"enabled":true,"time":"6:5"}}}`, "")
|
||||||
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"time":"07:00"`) || !strings.Contains(rec.Body.String(), `"shared":{"email":false,"discord":true}`) {
|
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"time":"07:00"`) || !strings.Contains(rec.Body.String(), `"shared":{"email":false,"discord":true,"webhook":false}`) {
|
||||||
t.Fatalf("settings: %d %s", rec.Code, rec.Body.String())
|
t.Fatalf("settings: %d %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ type Server struct {
|
|||||||
// Replace the delivery of bundled notifications in tests (notify.go)
|
// Replace the delivery of bundled notifications in tests (notify.go)
|
||||||
notifyEmailHook func(to, subject, text, html string) error
|
notifyEmailHook func(to, subject, text, html string) error
|
||||||
notifyDiscordHook func(webhookURL, heading string, items []notifyItem) error
|
notifyDiscordHook func(webhookURL, heading string, items []notifyItem) error
|
||||||
|
notifyWebhookHook func(webhookURL, heading string, items []notifyItem) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
|
func NewServer(cfg *config.Config, store *storage.Store, wsHub *ws.Hub, staticFS fs.FS) *Server {
|
||||||
@@ -216,6 +217,8 @@ func (s *Server) Router() http.Handler {
|
|||||||
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
|
r.Get("/api/v1/workspace/tags/{tag}", s.handleGetTagDetails)
|
||||||
r.Put("/api/v1/workspace/tags/{tag}/note", s.handleUpdateTagPageNote)
|
r.Put("/api/v1/workspace/tags/{tag}/note", s.handleUpdateTagPageNote)
|
||||||
|
|
||||||
|
r.Get("/api/v1/journal", s.handleGetJournal)
|
||||||
|
r.Post("/api/v1/journal/{date}", s.handleOpenJournalDay)
|
||||||
r.Get("/api/v1/documents", s.handleGetDocuments)
|
r.Get("/api/v1/documents", s.handleGetDocuments)
|
||||||
r.Post("/api/v1/documents", s.handleCreateDocument)
|
r.Post("/api/v1/documents", s.handleCreateDocument)
|
||||||
r.Get("/api/v1/documents/{id}", s.handleGetDocument)
|
r.Get("/api/v1/documents/{id}", s.handleGetDocument)
|
||||||
|
|||||||
@@ -157,13 +157,18 @@ type NotificationEvents struct {
|
|||||||
type ChannelChoice struct {
|
type ChannelChoice struct {
|
||||||
Email bool `json:"email"`
|
Email bool `json:"email"`
|
||||||
Discord bool `json:"discord"`
|
Discord bool `json:"discord"`
|
||||||
|
Webhook bool `json:"webhook"` // own webhook (customWebhookUrl), JSON payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Any reports whether at least one channel is chosen.
|
||||||
|
func (c ChannelChoice) Any() bool { return c.Email || c.Discord || c.Webhook }
|
||||||
|
|
||||||
type DailySummary struct {
|
type DailySummary struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Time string `json:"time"` // HH:MM, server time zone
|
Time string `json:"time"` // HH:MM, server time zone
|
||||||
Email bool `json:"email"`
|
Email bool `json:"email"`
|
||||||
Discord bool `json:"discord"`
|
Discord bool `json:"discord"`
|
||||||
|
Webhook bool `json:"webhook"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultNotificationSettings for users who never saved any; task alarms follow the old alarm channels.
|
// DefaultNotificationSettings for users who never saved any; task alarms follow the old alarm channels.
|
||||||
@@ -172,6 +177,7 @@ func DefaultNotificationSettings(alarmChannels []string) NotificationSettings {
|
|||||||
for _, c := range alarmChannels {
|
for _, c := range alarmChannels {
|
||||||
due.Email = due.Email || c == "email"
|
due.Email = due.Email || c == "email"
|
||||||
due.Discord = due.Discord || c == "discord"
|
due.Discord = due.Discord || c == "discord"
|
||||||
|
due.Webhook = due.Webhook || c == "webhook"
|
||||||
}
|
}
|
||||||
return NotificationSettings{
|
return NotificationSettings{
|
||||||
Events: NotificationEvents{
|
Events: NotificationEvents{
|
||||||
@@ -435,6 +441,8 @@ func CompileBlocksToMarkdown(blocks []DocumentBlock) string {
|
|||||||
sb.WriteString(indent + "- ```" + b.Lang + "\n" + b.Content + "\n" + indent + " ```\n")
|
sb.WriteString(indent + "- ```" + b.Lang + "\n" + b.Content + "\n" + indent + " ```\n")
|
||||||
case "divider":
|
case "divider":
|
||||||
sb.WriteString(indent + "- ---\n")
|
sb.WriteString(indent + "- ---\n")
|
||||||
|
case "numbered":
|
||||||
|
sb.WriteString(indent + "1. " + b.Content + "\n")
|
||||||
default:
|
default:
|
||||||
sb.WriteString(indent + "- " + b.Content + "\n")
|
sb.WriteString(indent + "- " + b.Content + "\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,6 +258,9 @@ func blocksToVaultMarkdown(blocks []domain.DocumentBlock, attachment func(id str
|
|||||||
sb.WriteString(indent + "- ---\n")
|
sb.WriteString(indent + "- ---\n")
|
||||||
case "callout":
|
case "callout":
|
||||||
sb.WriteString(indent + "- > " + cont(content, " > ") + "\n")
|
sb.WriteString(indent + "- > " + cont(content, " > ") + "\n")
|
||||||
|
case "numbered":
|
||||||
|
// Markdown renumbers the items itself
|
||||||
|
sb.WriteString(indent + "1. " + cont(content, " ") + "\n")
|
||||||
default:
|
default:
|
||||||
if strings.TrimSpace(content) == "" {
|
if strings.TrimSpace(content) == "" {
|
||||||
sb.WriteString(indent + "-\n")
|
sb.WriteString(indent + "-\n")
|
||||||
|
|||||||
@@ -824,6 +824,9 @@ func MarkdownToBlocks(md string) []domain.DocumentBlock {
|
|||||||
level := listLevel(indentWidth(m[1]))
|
level := listLevel(indentWidth(m[1]))
|
||||||
content := m[2]
|
content := m[2]
|
||||||
typ := "bullet"
|
typ := "bullet"
|
||||||
|
if t := strings.TrimLeft(line, " \t"); t != "" && t[0] >= '0' && t[0] <= '9' {
|
||||||
|
typ = "numbered"
|
||||||
|
}
|
||||||
checked := false
|
checked := false
|
||||||
if c := checkboxRe.FindStringSubmatch(content); c != nil {
|
if c := checkboxRe.FindStringSubmatch(content); c != nil {
|
||||||
typ, checked, content = "todo", c[1] != " ", c[2]
|
typ, checked, content = "todo", c[1] != " ", c[2]
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Journal: one note per person and day. The notes are ordinary documents (sharing, search, sync work as
|
||||||
|
// usual); this table only remembers which note belongs to which day.
|
||||||
|
|
||||||
|
type JournalDay struct {
|
||||||
|
Date string `json:"date"` // YYYY-MM-DD
|
||||||
|
DocID string `json:"docId"`
|
||||||
|
Empty bool `json:"empty"` // no text yet (the journal still shows today and days jumped to)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) migrateJournal() error {
|
||||||
|
return s.execInTx(
|
||||||
|
stmt(`CREATE TABLE IF NOT EXISTS journal_days (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
day TEXT NOT NULL,
|
||||||
|
doc_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, day)
|
||||||
|
)`),
|
||||||
|
stmt(`CREATE INDEX IF NOT EXISTS idx_journal_days_doc ON journal_days(doc_id)`),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// JournalDays lists the user's days whose note still exists (not in the trash), newest first.
|
||||||
|
func (s *Store) JournalDays(userID string) ([]JournalDay, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT j.day, j.doc_id, TRIM(d.content) = ''
|
||||||
|
FROM journal_days j JOIN documents d ON d.id = j.doc_id
|
||||||
|
WHERE j.user_id = ? AND d.is_archived = 0
|
||||||
|
ORDER BY j.day DESC`, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
days := []JournalDay{}
|
||||||
|
for rows.Next() {
|
||||||
|
var d JournalDay
|
||||||
|
if err := rows.Scan(&d.Date, &d.DocID, &d.Empty); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
days = append(days, d)
|
||||||
|
}
|
||||||
|
return days, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// JournalDocID returns the note of a day; "" when there is none or it was moved to the trash.
|
||||||
|
func (s *Store) JournalDocID(userID, day string) (string, error) {
|
||||||
|
var docID string
|
||||||
|
err := s.db.QueryRow(`SELECT j.doc_id FROM journal_days j JOIN documents d ON d.id = j.doc_id
|
||||||
|
WHERE j.user_id = ? AND j.day = ? AND d.is_archived = 0`, userID, day).Scan(&docID)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return docID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) SetJournalDay(userID, day, docID string) error {
|
||||||
|
_, err := s.db.Exec(`INSERT INTO journal_days (user_id, day, doc_id) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, day) DO UPDATE SET doc_id = excluded.doc_id`, userID, day, docID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ var migrations = []migration{
|
|||||||
{5, "Medien", (*Store).migrateMedia},
|
{5, "Medien", (*Store).migrateMedia},
|
||||||
{6, "Link-Vorschauen", (*Store).migrateLinkPreviews},
|
{6, "Link-Vorschauen", (*Store).migrateLinkPreviews},
|
||||||
{7, "Benachrichtigungen", (*Store).migrateNotifications},
|
{7, "Benachrichtigungen", (*Store).migrateNotifications},
|
||||||
|
{8, "Journal", (*Store).migrateJournal},
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) migrate() error {
|
func (s *Store) migrate() error {
|
||||||
|
|||||||
Reference in new issue
Block a user