diff --git a/.gitea/workflows/build-and-publish.yaml b/.gitea/workflows/build-and-publish.yaml index d62768a..0943af5 100644 --- a/.gitea/workflows/build-and-publish.yaml +++ b/.gitea/workflows/build-and-publish.yaml @@ -51,10 +51,12 @@ jobs: echo "tag=${TAGS%% *}" >> "$GITHUB_OUTPUT" echo "tags=${TAGS}" >> "$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="" case "${GITHUB_REF:-}" in refs/tags/*) APP_VERSION="${REF_NAME}" ;; + refs/heads/main) APP_VERSION="v$(sed -nE 's/^ "version": "([^"]+)".*/\1/p' frontend/package.json | head -n1)" ;; esac echo "app_version=${APP_VERSION}" >> "$GITHUB_OUTPUT" echo "Target Docker image: ${IMAGE_NAME} tags: ${TAGS} (Registry: ${REGISTRY_HOST}, Actor: ${ACTOR_NAME})" diff --git a/CHANGELOG.md b/CHANGELOG.md index 37cc979..20f7969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,38 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [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 ### 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. - 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.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 diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 2e839f8..074558a 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -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`) | | DELETE | `/documents/{id}/shares/{userId}` | Notiz-Freigabe entfernen | | POST | `/documents/{id}/extract` | `{blockId, clientId}`: legt aus den Unterpunkten des Punkts eine eigene Notiz an (Titel = Text ohne Hashtags, gleicher Ordner, Freigaben übernommen, fremder Besitzer bekommt Schreibrecht) → `{document, link}`. Die Ursprungsnotiz ändert der Server nicht; der Editor ersetzt den Ast selbst durch `link` (normale eigene Änderung, Strg+Z möglich). Schreibrecht nötig; `409`, wenn jemand anderes im Ast schreibt | +| GET | `/journal` | Tage des eigenen Journals, neueste zuerst → `{days: [{date, docId, empty}]}` (`empty`: Notiz noch ohne Text; Tage im Papierkorb fehlen) | +| POST | `/journal/{date}` | Notiz des Tages (`JJJJ-MM-TT`) öffnen; gibt es noch keine, wird sie angelegt (Titel z. B. „Freitag, 30. Oktober 2026“, Icon 📅) → Dokument, `201` bei Neuanlage. Die Journal-Notizen sind normale Notizen (Teilen, Suche, Sync) | | POST | `/documents/{id}/extract/undo` | Legt die ausgelagerte Notiz `{id}` wieder in den Papierkorb (nur ihr Ersteller) | | POST | `/documents/{id}/merge` | `{sourceId}`: hängt die (eigene) Quellnotiz als Überschrift mit Unterpunkten an diese Notiz an, Quelle in den Papierkorb → `{targetId, sourceId, sourceTitle, headingId}` | | POST | `/documents/{id}/merge/undo` | `{sourceId, headingId}`: Abschnitt wieder entfernen, Quelle wiederherstellen | diff --git a/Doku/architecture.md b/Doku/architecture.md index fb266ce..46b60f2 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -106,7 +106,7 @@ Bilder und Audio liegen in `$DATA_DIR/media//.`, beschrieben in ## 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 diff --git a/Doku/database.md b/Doku/database.md index 78dd183..9a98c4f 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -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` | | `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` | +| `journal_days` | Journal: welche Notiz zu welchem Tag gehört (Migration 8) | `user_id`, `day` (`JJJJ-MM-TT`), `doc_id`; Primärschlüssel `(user_id, day)` | | `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) | | `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` | | `api_keys` | Webhook-/API-Schlüssel | `name`, `key_prefix`, `key_hash` (SHA-256), `scopes` (`webhook:triage`, `webhook:task`, `webhook:list`), `last_used_at` | @@ -84,7 +85,7 @@ teams 1─n team_members n─1 users - `users.username` wird aus dem E-Mail-Präfix aufgefüllt. - Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt. - Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer. -- **Migrationen:** [migrations.go](../internal/storage/migrations.go) führt nummerierte Schritte (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 diff --git a/README.md b/README.md index 5d10d77..629ac9c 100644 --- a/README.md +++ b/README.md @@ -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. * **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). * **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). diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 50b1f35..e3c486a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,14 +1,15 @@ { "name": "mindsift-frontend", - "version": "0.9.0", + "version": "0.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.9.0", + "version": "0.10.0", "license": "Unlicense", "dependencies": { + "@fontsource-variable/inter": "5.3.0", "@types/prismjs": "^1.26.6", "emoji-picker-react": "4.12.0", "framer-motion": "^11.15.0", @@ -1024,6 +1025,15 @@ "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": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index d2277c8..f1974ef 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.9.0", + "version": "0.10.0", "license": "Unlicense", "type": "module", "scripts": { @@ -13,6 +13,7 @@ "test:run": "vitest run" }, "dependencies": { + "@fontsource-variable/inter": "5.3.0", "@types/prismjs": "^1.26.6", "emoji-picker-react": "4.12.0", "framer-motion": "^11.15.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 28a7672..5b36801 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -750,7 +750,11 @@ export const App: React.FC = () => { }; return ( -
+
{/* Top Header Bar: Floating on mobile, solid panel on desktop */}
{/* Mobile Header: Floating Bar with Logo Action Dropdown Left & Profile Avatar Right */} @@ -1113,9 +1117,14 @@ export const App: React.FC = () => { )} {/* Main Container */} -
+ {/* The workspace fills the window exactly and scrolls only inside the note, never the page */} +
setActiveTab('triage')}> -
+
{activeTab === 'triage' && ( = ({ }, [onClose]); 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; return ( @@ -54,7 +59,7 @@ export const FormSheet: React.FC = ({ 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 ${ 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' }`} > diff --git a/frontend/src/features/capture/InstantCaptureBar.tsx b/frontend/src/features/capture/InstantCaptureBar.tsx index f99b7b1..3ba4752 100644 --- a/frontend/src/features/capture/InstantCaptureBar.tsx +++ b/frontend/src/features/capture/InstantCaptureBar.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState, useMemo } from 'react'; import { Mic, MicOff, - ArrowUp, + ArrowDown, Sparkles, Calendar, Check, @@ -73,6 +73,7 @@ export const InstantCaptureBar: React.FC = ({ }); const isListening = voice.state === 'listening'; const isTranscribing = voice.state === 'transcribing'; + const [isOverMaxHeight, setIsOverMaxHeight] = useState(false); const adjustHeight = (el?: HTMLTextAreaElement | null) => { const targetEl = el || inputRef.current; @@ -82,8 +83,10 @@ export const InstantCaptureBar: React.FC = ({ const scrollH = targetEl.scrollHeight; targetEl.style.height = `${Math.min(scrollH, 140)}px`; setIsMultiline(targetEl.value.includes('\n') || scrollH > 28); + setIsOverMaxHeight(scrollH > 140); } else { setIsMultiline(false); + setIsOverMaxHeight(false); } }; @@ -267,16 +270,18 @@ export const InstantCaptureBar: React.FC = ({ : isTranscribing ? 'Wird erkannt…' : hideTargetPicker || fixedTarget === 'inbox' - ? 'Quick Dump erfassen… (Enter zum Speichern)' + ? 'Quick Dump erfassen…' : target === 'task' - ? 'Neue Aufgabe direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)' + ? 'Aufgabe erfassen…' : target === 'list_item' - ? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…` + ? `Zu „${selectedList?.title || 'Liste'}“ hinzufügen…` : target === 'document' - ? 'Neue Workspace-Notiz direkt anlegen… (Enter / Shift+Enter für Mehrzeiler)' - : 'Quick Dump: Gedanke reinrotzen… (C / Enter, Shift+Enter für Mehrzeiler)' + ? 'Notiz erfassen…' + : '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' }} autoComplete="off" /> @@ -360,7 +365,7 @@ export const InstantCaptureBar: React.FC = ({ ) : ( <> - + {hideTargetPicker || (fixedTarget || target) === 'inbox' ? 'Quick Dump' diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index c11cae9..293d513 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -32,6 +32,7 @@ import { ImportCard } from './ImportCard'; import { SessionsPanel } from './SessionsPanel'; import { ToggleSwitch } from '../../components/ToggleSwitch'; import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview'; +import { isJournalEnabled, setJournalEnabled } from '../../lib/journal'; import { FormSheet } from '../../components/FormSheet'; interface UserSettingsViewProps { @@ -51,6 +52,7 @@ export function buildNotificationSettings(user: User): NotificationSettings { const hasLegacy = Array.isArray(user.alarmChannels); const dueEmailDefault = hasLegacy ? user.alarmChannels!.includes('email') : true; const dueDiscordDefault = hasLegacy ? user.alarmChannels!.includes('discord') : true; + const dueWebhookDefault = hasLegacy ? user.alarmChannels!.includes('webhook') : false; const s = user.notificationSettings; return { paused: Boolean(s?.paused), @@ -59,18 +61,22 @@ export function buildNotificationSettings(user: User): NotificationSettings { due: { email: s?.events?.due?.email ?? dueEmailDefault, discord: s?.events?.due?.discord ?? dueDiscordDefault, + webhook: s?.events?.due?.webhook ?? dueWebhookDefault, }, shared: { email: s?.events?.shared?.email ?? true, discord: s?.events?.shared?.discord ?? false, + webhook: s?.events?.shared?.webhook ?? false, }, taskDone: { email: s?.events?.taskDone?.email ?? false, discord: s?.events?.taskDone?.discord ?? false, + webhook: s?.events?.taskDone?.webhook ?? false, }, watched: { email: s?.events?.watched?.email ?? true, discord: s?.events?.watched?.discord ?? true, + webhook: s?.events?.watched?.webhook ?? false, }, }, dailySummary: { @@ -78,6 +84,7 @@ export function buildNotificationSettings(user: User): NotificationSettings { time: s?.dailySummary?.time || '07:00', email: s?.dailySummary?.email ?? true, discord: s?.dailySummary?.discord ?? false, + webhook: s?.dailySummary?.webhook ?? false, }, }; } @@ -144,6 +151,7 @@ export const UserSettingsView: React.FC = ({ // The public demo sends nothing and keeps the shared accounts intact (FLAW-33) const [demoMode, setDemoMode] = useState(false); const linkPreviewsOn = useLinkPreviewsEnabled(); + const [journalOn, setJournalOn] = useState(isJournalEnabled); useEffect(() => { api .getAuthStatus() @@ -250,9 +258,6 @@ export const UserSettingsView: React.FC = ({ const fileInputRef = useRef(null); // --- Multi-Channel Alarms State --- - const [alarmChannels, setAlarmChannels] = useState( - Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : [] - ); const [notificationSettings, setNotificationSettings] = useState(() => buildNotificationSettings(currentUser) ); @@ -270,7 +275,6 @@ export const UserSettingsView: React.FC = ({ setDisplayName(currentUser.displayName || ''); setEmail(currentUser.email || ''); setAvatarUrl(currentUser.avatarUrl || ''); - setAlarmChannels(Array.isArray(currentUser.alarmChannels) ? currentUser.alarmChannels : []); setNotificationSettings(buildNotificationSettings(currentUser)); setAlarmEmail(currentUser.alarmEmail || currentUser.email || ''); setDiscordWebhookUrl(currentUser.discordWebhookUrl || ''); @@ -278,13 +282,8 @@ export const UserSettingsView: React.FC = ({ setNotifyBeforeMinutes(currentUser.notifyBeforeMinutes ?? 30); }, [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) => ({ ...prev, events: { @@ -386,7 +385,7 @@ export const UserSettingsView: React.FC = ({ const nextAlarmChannels = [ ...(notificationSettings.events.due.email ? ['email'] : []), ...(notificationSettings.events.due.discord ? ['discord'] : []), - ...(alarmChannels.includes('webhook') ? ['webhook'] : []), + ...(notificationSettings.events.due.webhook ? ['webhook'] : []), ]; const updated = await api.updateSettings({ alarmChannels: nextAlarmChannels, @@ -581,7 +580,7 @@ export const UserSettingsView: React.FC = ({ {/* SECTION 1: PROFIL & SICHERHEIT */} {/* ================================================================= */} {section === 'profile' && ( -
+

Profil & Anmeldedaten

@@ -857,6 +856,24 @@ export const UserSettingsView: React.FC = ({

+
+
+

Journal anzeigen

+

+ 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. +

+
+ { + setJournalEnabled(v); + setJournalOn(v); + }} + title="Journal anzeigen" + /> +
+
{themes.map((t) => { const isSelected = activeTheme === t.id; @@ -1119,13 +1136,14 @@ export const UserSettingsView: React.FC = ({
- {/* Routing Matrix: Events x (E-Mail / Discord) */} + {/* Routing Matrix: Events x (E-Mail / Discord / own webhook) */} {(() => { const hasDiscordWebhook = Boolean(discordWebhookUrl.trim()); + const hasCustomWebhook = Boolean(customWebhookUrl.trim()); return ( <>
-
+
Ereignis @@ -1135,6 +1153,10 @@ export const UserSettingsView: React.FC = ({ Discord + + + Webhook +
@@ -1143,7 +1165,7 @@ export const UserSettingsView: React.FC = ({ return (
{row.label}
@@ -1165,6 +1187,18 @@ export const UserSettingsView: React.FC = ({ } />
+
+ setEventChannel(row.key, 'webhook', v)} + title={ + hasCustomWebhook + ? `${row.label} – Webhook` + : 'Bitte zuerst unten eine eigene Webhook URL eintragen' + } + /> +
); })} @@ -1178,6 +1212,11 @@ export const UserSettingsView: React.FC = ({ Discord-Schalter sind deaktiviert, solange unten kein Discord-Webhook eingetragen ist.

)} + {!hasCustomWebhook && ( +

+ Webhook-Schalter werden aktiv, sobald unten eine eigene Webhook-URL eingetragen ist. +

+ )}
{/* Daily Summary */} @@ -1250,6 +1289,24 @@ export const UserSettingsView: React.FC = ({ } />
+
+ Webhook + + setNotificationSettings((prev) => ({ + ...prev, + dailySummary: { ...prev.dailySummary, webhook: v }, + })) + } + title={ + hasCustomWebhook + ? 'Tageszusammenfassung – Webhook' + : 'Bitte zuerst unten eine eigene Webhook URL eintragen' + } + /> +
@@ -1359,17 +1416,11 @@ export const UserSettingsView: React.FC = ({ {/* Channel 3: Custom HTTP Webhook */}
-
)} - {/* 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) */} +
+ + + + + + + +
+ + {/* Primary Triage Decision Bar: Hidden on mobile (handled via Mobile Quick Actions / Swipe), shown on Desktop */}
+ )} {/* In the trash: read-only with a way back */} {inTrash && ( @@ -1022,7 +1058,12 @@ export const DocumentEditor: React.FC = ({ {/* Main Content Area */} {/* Phones: almost no side margin; very deep outlines scroll sideways (rows keep a minimum width) */} -
+ {/* shrink-0: the sideways scroll area must keep its full height, so only the editor scrolls vertically */} +
{zoomedBlock ? ( /* ========================================================================= FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW) @@ -1061,7 +1102,7 @@ export const DocumentEditor: React.FC = ({ }} placeholder="Ast-Titel…" 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" />
@@ -1102,6 +1143,7 @@ export const DocumentEditor: React.FC = ({
) : ( = ({
{/* Logseq Outliner Tree */} = ({ onRedo={handleRedo} /> - {onOpenDocBlock && !inTrash && } + {onOpenDocBlock && !inTrash && !inJournal && } )} diff --git a/frontend/src/features/workspace/JournalCalendar.tsx b/frontend/src/features/workspace/JournalCalendar.tsx new file mode 100644 index 0000000..8529b3c --- /dev/null +++ b/frontend/src/features/workspace/JournalCalendar.tsx @@ -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; + onSelect: (date: string) => void; +} + +const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; + +export const JournalCalendar: React.FC = ({ 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 ( +
+
+ + + + +
+ +
+ {WEEKDAYS.map((w) => ( + {w} + ))} +
+
+ {weeks.flat().map((date, i) => + date ? ( + + ) : ( + + ) + )} +
+
+ ); +}; diff --git a/frontend/src/features/workspace/JournalView.tsx b/frontend/src/features/workspace/JournalView.tsx new file mode 100644 index 0000000..83bbd62 --- /dev/null +++ b/frontend/src/features/workspace/JournalView.tsx @@ -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; + 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 = ({ + 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([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()); + 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(null); + const sectionRefs = useRef(new Map()); + + // Jump: make sure the day is rendered, then scroll to it once its note is there + const pendingJump = useRef(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(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 ( +
+ {/* One header for all days: the day in view and its sharing */} +
+ + + {activeDate !== today && ( + + )} + +
+ + {showCalendar && ( +
+ { + setShowCalendar(false); + onJump(date); + }} + /> +
+ )} + +
+ {shown.map((date) => { + const docId = docIdByDate.get(date); + return ( +
{ + 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 ? ( + { + if (status === 'saved') onDaySaved(); + }} + /> + ) : ( +
{shortDateLabel(date)} wird geöffnet…
+ )} +
+ ); + })} + {limit < dates.length &&
} + {/* Room below the oldest day, so every day can be scrolled to the top */} +
+
+
+ ); +}; diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 9a22cd0..a705828 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -12,6 +12,7 @@ import { Heading2, Heading3, ListTodo, + ListOrdered, Minus, MessageSquare, ExternalLink, @@ -24,6 +25,7 @@ import { Mic, AudioLines, FileOutput, + Type, } from 'lucide-react'; import { DocumentBlock, BlockType } from '../../lib/types'; import { LinkModal } from './LinkModal'; @@ -54,7 +56,9 @@ export interface SlashCommand { const SLASH_COMMANDS: SlashCommand[] = [ { type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: }, + { type: 'numbered', label: '/number', desc: 'Nummerierte Liste (1., a., i.)', icon: , aliases: ['nummer', 'liste', 'ol'] }, { type: 'link', label: '/link', desc: 'Notiz oder Weblink', icon: }, + { type: 'bullet', label: '/text', desc: 'In Standardtext umwandeln', icon: , aliases: ['p', 'paragraph', 'bullet', 'standard'] }, { type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: }, { type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: }, { type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: }, @@ -98,6 +102,8 @@ export interface OutlinerBlockProps { isTargeted?: boolean; registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => 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 canAppendBelow?: boolean; // the user may edit the note, so Enter in a locked block still adds one below onResolveConflict?: (blockId: string, accept: boolean) => void; @@ -145,6 +151,7 @@ export const OutlinerBlock: React.FC = ({ isTargeted, registerTextareaRef, onPasteMultiLines, + listMarker, lockedBy, canAppendBelow, onResolveConflict, @@ -156,7 +163,15 @@ export const OutlinerBlock: React.FC = ({ 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 const canFocus = canEdit || (!!lockedBy && !!canAppendBelow); - const [slashSearch, setSlashSearch] = useState(null); + const [slashMatchInfo, setSlashMatchInfo] = useState<{ query: string; slashStart: number; cursor: number } | null>(null); + const slashSearch = slashMatchInfo ? slashMatchInfo.query : null; + const linkBeforeRef = useRef(''); + const linkAfterRef = useRef(''); + const dictationBeforeRef = useRef(''); + const dictationAfterRef = useRef(''); + const slashMenuRef = useRef(null); + const todoMenuRef = useRef(null); + const blurSlashTimerRef = useRef(null); // Inline picker an empty bullet turns into after /image (local only, nothing synced until done) const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null); // Card above bullets whose entire content is a plain https address (lib/linkPreview.ts) @@ -272,9 +287,48 @@ export const OutlinerBlock: React.FC = ({ window.clearTimeout(debounceTimerRef.current); 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(null); // 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 = ({ }, [adjustHeight, block.type]); const filteredSlashCommands = useMemo(() => { - if (slashSearch === null) return []; - const q = slashSearch.toLowerCase(); + if (!slashMatchInfo) return []; + const q = slashMatchInfo.query.toLowerCase(); const cmdQuery = q.startsWith('code ') ? 'code' : q; - // Media only in an otherwise empty bullet - const emptyBullet = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim() === ''; - return SLASH_COMMANDS.filter( - (c) => - (!c.media || emptyBullet) && - (c.type !== 'dictation' || ['server', 'browser'].includes(currentVoiceConfig().mode)) && - // The bullet's text becomes the title of the new note - (c.type !== 'extract' || (!emptyBullet && !!onExtractBlock)) && - (c.label.toLowerCase().includes(cmdQuery) || - c.desc.toLowerCase().includes(cmdQuery) || - (c.aliases || []).some((a) => a.includes(cmdQuery))) - ); - }, [slashSearch, localContent, onExtractBlock]); + + const textBeforeSlash = localContent.slice(0, slashMatchInfo.slashStart); + const textAfterCursor = localContent.slice(slashMatchInfo.cursor); + const restOfBlock = (textBeforeSlash + textAfterCursor).trim(); + const isRestEmpty = restOfBlock === ''; + const isAtStartOrEnd = textBeforeSlash.trim() === '' || textAfterCursor.trim() === ''; + + return SLASH_COMMANDS.filter((c) => { + const matchesQuery = + c.label.toLowerCase().includes(cmdQuery) || + c.desc.toLowerCase().includes(cmdQuery) || + (c.aliases || []).some((a) => a.includes(cmdQuery)); + if (!matchesQuery) return false; + + // /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(() => { if (slashSearch === null) return; @@ -381,15 +468,37 @@ export const OutlinerBlock: React.FC = ({ }, [slashIndex, slashSearch]); const applySlashCommand = (cmd: SlashCommand) => { - setSlashSearch(null); - const codeLangMatch = localContent.match(/(^|\s)\/code\s+([a-z0-9_+#.-]+)$/i); - const clean = localContent.replace(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i, '').trim(); + if (blurSlashTimerRef.current !== null) { + window.clearTimeout(blurSlashTimerRef.current); + 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') { + linkBeforeRef.current = before; + linkAfterRef.current = after; setLocalContent(clean); localContentRef.current = clean; setShowLinkModal(true); 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') { setLocalContent(clean); localContentRef.current = clean; @@ -397,31 +506,38 @@ export const OutlinerBlock: React.FC = ({ onExtractBlock?.(block.id); return; } + if (cmd.media) { // The typed "/image" must not stay in the synced text; the block type changes only after the upload setLocalContent(''); localContentRef.current = ''; flushDebounce(''); - setMediaPicker(cmd.type as 'image' | 'audio' | 'dictation'); + setMediaPicker(cmd.type as 'image' | 'audio'); return; } + + const codeLangMatch = slashMatchInfo?.query.match(/^code\s+([a-z0-9_+#.-]+)$/i); const langOverride = cmd.type === 'code' && codeLangMatch - ? resolveLanguageId(codeLangMatch[2]) || codeLangMatch[2].toLowerCase() + ? resolveLanguageId(codeLangMatch[1]) || codeLangMatch[1].toLowerCase() : undefined; + setLocalContent(clean); localContentRef.current = clean; if (cmd.type !== block.type) refocusAfterTypeChangeRef.current = true; flushDebounce(clean, { - type: cmd.type as BlockType, // media commands returned above + type: cmd.type as BlockType, ...(langOverride !== undefined ? { lang: langOverride } : {}), checked: cmd.type === 'todo' ? Boolean(block.checked) : false, + ...(cmd.type === 'bullet' ? { taskId: '', checked: false } : {}), }); }; const handleInsertLink = (linkMarkdown: string) => { setShowLinkModal(false); - if (localContent.trim() === '') { + const b = linkBeforeRef.current ?? ''; + const a = linkAfterRef.current ?? ''; + if (b.trim() === '' && a.trim() === '') { const webMatch = linkMarkdown.match(/^\[.*\]\((https?:\/\/[^\s)]+)\)$/); if (webMatch) { const bareUrl = webMatch[1]; @@ -431,10 +547,9 @@ export const OutlinerBlock: React.FC = ({ return; } } - const cursor = textareaRef.current?.selectionStart ?? localContent.length; - const before = localContent.slice(0, cursor); - const after = localContent.slice(cursor); - const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim(); + const spaceBefore = b && !b.endsWith(' ') ? ' ' : ''; + const spaceAfter = a && !a.startsWith(' ') && a.trim() !== '' ? ' ' : ''; + const newContent = `${b}${spaceBefore}${linkMarkdown}${spaceAfter}${a}`.trim(); setLocalContent(newContent); localContentRef.current = newContent; flushDebounce(newContent); @@ -507,6 +622,10 @@ export const OutlinerBlock: React.FC = ({ val = val.replace(/^\[[xX]\]\s/, ''); typeOverride = 'todo'; 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') { val = val.slice(2); typeOverride = 'h1'; @@ -516,18 +635,32 @@ export const OutlinerBlock: React.FC = ({ } else if (val.startsWith('### ') && block.type !== 'h3') { val = val.slice(4); 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 - const slashMatch = !typeOverride ? val.match(/(^|\s)\/(code\s+[a-z0-9_+#.-]*|[a-z0-9]*)$/i) : null; + // Check for slash command invocation at cursor position + 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) { - setSlashSearch(slashMatch[2]); + const slashStart = slashMatch.index! + slashMatch[1].length; + setSlashMatchInfo({ query: slashMatch[2], slashStart, cursor }); setSlashIndex(0); } else { - setSlashSearch(null); + setSlashMatchInfo(null); } } else { - setSlashSearch(null); + setSlashMatchInfo(null); } setLocalContent(val); @@ -543,6 +676,7 @@ export const OutlinerBlock: React.FC = ({ type: typeOverride, ...(langOverride !== undefined ? { lang: langOverride } : {}), checked: checkedOverride ?? latestBlockRef.current.checked, + ...(typeOverride === 'bullet' ? { taskId: '', checked: false } : {}), }); return; } @@ -557,6 +691,10 @@ export const OutlinerBlock: React.FC = ({ }; const handleFocus = () => { + if (blurSlashTimerRef.current !== null) { + window.clearTimeout(blurSlashTimerRef.current); + blurSlashTimerRef.current = null; + } isFocusedRef.current = true; setIsLocallyFocused(true); onFocusBlock?.(block.id); @@ -567,6 +705,12 @@ export const OutlinerBlock: React.FC = ({ isFocusedRef.current = false; setIsLocallyFocused(false); onBlurBlock?.(block.id); + if (blurSlashTimerRef.current !== null) { + window.clearTimeout(blurSlashTimerRef.current); + } + blurSlashTimerRef.current = window.setTimeout(() => { + setSlashMatchInfo(null); + }, 150); if (isPastingRef.current) { isPastingRef.current = false; return; @@ -600,6 +744,8 @@ export const OutlinerBlock: React.FC = ({ } 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); }; @@ -749,7 +895,7 @@ export const OutlinerBlock: React.FC = ({ if (slashSearch !== null) { if (e.key === 'Escape') { e.preventDefault(); - setSlashSearch(null); + setSlashMatchInfo(null); return; } if (filteredSlashCommands.length > 0) { @@ -789,7 +935,7 @@ export const OutlinerBlock: React.FC = ({ if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') { e.preventDefault(); e.stopPropagation(); - setSlashSearch(null); + setSlashMatchInfo(null); const fenceMatch = localContent.match(/^```([a-z0-9_+#.-]*)$/i); if (fenceMatch) { @@ -813,6 +959,11 @@ export const OutlinerBlock: React.FC = ({ onIndentBlockRef.current(block.id, -1); 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; @@ -844,7 +995,7 @@ export const OutlinerBlock: React.FC = ({ if (e.key === 'Backspace' && localContent === '') { e.preventDefault(); e.stopPropagation(); - setSlashSearch(null); + setSlashMatchInfo(null); if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; @@ -870,7 +1021,7 @@ export const OutlinerBlock: React.FC = ({ } e.preventDefault(); e.stopPropagation(); - setSlashSearch(null); + setSlashMatchInfo(null); } return; } @@ -920,6 +1071,12 @@ export const OutlinerBlock: React.FC = ({ return (
= ({ draggable={canEdit} onDragStart={() => canEdit && onDragStartBlock?.(index)} 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' }`} title={ @@ -979,6 +1138,15 @@ export const OutlinerBlock: React.FC = ({ : 'Klick: In diesen Ast hineinzoomen (Fokus-Modus)' } > + {block.type === 'numbered' && listMarker ? ( + + {listMarker} + + ) : ( = ({ : 'opacity-100' }`} /> + )} {/* If To-Do block: checkbox alongside bullet */} @@ -1047,14 +1216,20 @@ export const OutlinerBlock: React.FC = ({ { setMediaPicker(null); - setLocalContent(text); - localContentRef.current = text; - flushDebounce(text); + const b = dictationBeforeRef.current ?? ''; + const a = dictationAfterRef.current ?? ''; + 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(() => { const ta = textareaRef.current; if (!ta) return; ta.focus(); - ta.setSelectionRange(ta.value.length, ta.value.length); + const pos = (b + spaceBefore + text).length; + ta.setSelectionRange(pos, pos); }); }} onCancel={() => { @@ -1104,8 +1279,43 @@ export const OutlinerBlock: React.FC = ({ }} /> ) : block.type === 'divider' ? ( -
-
+
{ + textareaRef.current?.focus(); + }} + > +
+