diff --git a/.env.example b/.env.example index 029e508..7ad4c2a 100644 --- a/.env.example +++ b/.env.example @@ -19,3 +19,9 @@ SMTP_FROM=noreply@mindsift.local SMTP_TLS=true # Only for internal mail servers with self-signed certificates SMTP_SKIP_VERIFY=false + +# Optional legal notices: external link and/or Markdown (inline or as file path) +LEGAL_IMPRINT_URL= +LEGAL_PRIVACY_URL= +LEGAL_IMPRINT_CONTENT_FILE= +LEGAL_PRIVACY_CONTENT_FILE= diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 835a51d..30b8207 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -7,7 +7,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | Methode | Pfad | Zweck | |---|---|---| | GET | `/health` | Lebenszeichen | -| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode` | +| GET | `/auth/status` | `needsSetup`, `allowRegistration`, `instanceName`, `demoMode`, `sharingMode`, `legal` (`imprintUrl`, `hasImprintContent`, `privacyUrl`, `hasPrivacyContent`) | +| GET | `/legal/{imprint\|privacy}` | Hinterlegter Markdown-Text (`content`), ohne Anmeldung; `404`, wenn keiner gesetzt ist | | POST | `/auth/setup` | Erstes Administratorkonto (`email`, `password`, `displayName`, `instanceName`, `allowRegistration`, `sharingMode`, `seedSampleData`); nur solange es keine Konten gibt | | POST | `/auth/register` | Registrierung (`email`, `password`, `displayName`, `inviteCode`); nur mit Code oder wenn freigegeben | | POST | `/auth/login` | Anmeldung (`login`/`email`/`username`, `password`) → `{token, user}` | @@ -28,12 +29,38 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | POST | `/auth/alarms/test` | Test-Alarm (`channel`: `email`/`discord`/`webhook`, optional `target`) | | GET | `/users/directory`, `/collaborators` | Personen, die man sehen und mit denen man teilen kann (je nach `sharingMode`, [sharing-and-permissions.md](../sharing-and-permissions.md)) | | GET | `/stats` | Zähler fürs Dashboard | -| GET | `/search?q=` | Suche in eigenen Gedanken und Notizen | +| GET | `/search?q=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits}` | | GET | `/export` | Datenexport als JSON | +| GET | `/export/markdown` | Obsidian-kompatibles ZIP (`mindsift-vault-DATUM.zip`), direkt gestreamt: Notizen als `.md` mit YAML-Frontmatter in der Ordnerstruktur des Nutzers (inkl. geteilter), `Aufgaben/.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/.md` | | GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) | | DELETE | `/themes/{id}` | Eigenes Theme löschen | | GET/POST/DELETE | `/keys`, `/keys/{id}` | API-Keys verwalten (Klartext nur bei Erstellung) | +### Globale Suche (`GET /api/v1/search?q=`) + +Führt eine Volltextsuche über SQLite FTS5 mit BM25-Relevanz-Ranking aus. +- Durchsucht Aufgaben/Gedanken (Inhalt, Notizen, Unteraufgaben), Notizen (Titel sowie Inhalte aktiver Blöcke) und Listeneinträge. +- Beachtet strikt alle Zugriffsregeln: Nur eigene Gedanken/Aufgaben und solche in zugänglichen Aufgabenlisten; nur eigene und geteilte Notizen (direkt, über Gruppen oder freigegebene Ordner); nur eigene und geteilte Listen. +- Titel-Treffer werden höher gewichtet als Treffer im Fließtext. +- Liefert rückwärtskompatible Collections (`thoughts`, `documents`, `listItems`) sowie strukturierte `hits` mit Snippet und ``-Hervorhebung: + ```json + { + "thoughts": [...], + "documents": [...], + "listItems": [...], + "hits": [ + { + "type": "document", + "id": "blk_123", + "title": "Projektplanung", + "snippet": "... wichtige Notiz zum Release ...", + "docId": "doc_abc", + "blockId": "blk_123" + } + ] + } + ``` + ## Gedanken, Triage, Aufgaben | Methode | Pfad | Zweck | diff --git a/Doku/architecture.md b/Doku/architecture.md index 01ddc03..77464ac 100644 --- a/Doku/architecture.md +++ b/Doku/architecture.md @@ -65,6 +65,8 @@ Das Frontend liegt per `go:embed` im Binary ([cmd/server/main.go](../cmd/server/ | `MINDA_DEMO` (auch `MINDSIFT_DEMO`, `DEMO_MODE`) | `false` | Demo-Konten anlegen | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer / `587` / `true` | Mailversand, auch im Admin-UI einstellbar | | `SMTP_SKIP_VERIFY` | `false` | Zertifikat des Mailservers nicht prüfen (nur interne Server mit selbstsigniertem Zertifikat) | +| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) | +| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. | Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in `DATA_DIR` genutzt wird; Postgres ist nicht implementiert. diff --git a/Doku/flaws.md b/Doku/flaws.md index 9edf419..eaace45 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -27,7 +27,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( | FLAW-15 | Realtime | ~~`WriteTimeout` von 30 s beendet SSE-Streams~~ | **Behoben** | | FLAW-16 | Setup | `/auth/setup` ist bis zum ersten Konto offen; Demo-Modus legt `admin/admin` an | Mittel | | FLAW-17 | Workspace | ~~Bearbeiter-Freigaben für Notizen nicht wählbar~~ | **Behoben** | -| FLAW-18 | Suche | Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS | Niedrig | +| FLAW-18 | Suche | ~~Nur eigene Gedanken und Dokumente, `LIKE` ohne Escaping, keine FTS~~ | **Behoben** | | FLAW-19 | Qualität | Frontend-Tests nur für den Outliner, `lint` ist ein Platzhalter | Niedrig | | FLAW-20 | Qualität | Sehr große Dateien, duplizierte Zugriffslogik | Niedrig | | FLAW-21 | Repo | `LICENSE` fehlt, obwohl README MIT angibt | Niedrig | @@ -147,9 +147,8 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** Entweder Schreibfreigaben mit Konfliktbehandlung ergänzen oder den toten Code und das Schema bereinigen. ### FLAW-18: Suche -- **Fundstelle:** `Store.SearchAll`. -- **Probleme:** Durchsucht nur eigene Gedanken (30) und eigene Dokumente (20); geteilte Dokumente, Aufgaben aus geteilten Listen, Einkaufslisten und Blöcke bleiben außen vor. `LIKE '%…%'` ohne Escaping von `%`/`_`, keine Indizes, keine Relevanz. -- **Maßnahme:** FTS5-Index über Blöcke/Gedanken; Berechtigungen mitberücksichtigen. +- **Status: Behoben.** SQLite FTS5-Volltexttabellen mit BM25-Ranking (`thoughts_fts`, `documents_fts`, `list_items_fts`), Trigger zur automatischen Synchronisation und initialem Backfill beim Start. Sicheres Query-Escaping, Präfixsuche. Strikte Berechtigungsprüfung über `accessibleTaskListIDsQuery`, `DocRole` (eigene, geteilte Notizen, über Ordner gemountete Notizen) und zugängliche Listen. Rückwärtskompatible API-Antwort mit Snippets (``) und Frontend-Integration über `CommandPalette` (`Cmd+K` / `Ctrl+K` / `/`). +- **Fundstellen:** `internal/storage/search.go`, `internal/api/server.go`, `frontend/src/components/CommandPalette.tsx`. ### FLAW-19: Geringe Frontend-Testabdeckung - **Stand:** Vitest mit jsdom und Testing Library ist eingerichtet (`npm run test:run`) und läuft beim Image-Build. Getestet sind bisher nur Outliner-Funktionen (Backspace-Zusammenführen, Cursor-Position beim Klicken). diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index 6127a75..e72ee6a 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -32,6 +32,7 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts - **Konfliktkopien** sind farbig markiert („Konflikt – Version von …“) mit „Übernehmen“ (Text ersetzt den ursprünglichen Punkt bzw. den Titel, die Kopie verschwindet) und „Verwerfen“. - **Rückgängig:** `Strg+Z`/`Cmd+Z` nimmt die eigene letzte Änderung zurück, `Strg+Umschalt+Z` oder `Strg+Y` wiederholt (auch im Titel und über das Kontextmenü/mobile Tastaturen). Tippen im selben Punkt bis 1 s Pause ist ein Schritt; bis zu 200 Schritte je Tab. Hat jemand anderes den Punkt inzwischen geändert oder hält ihn gesperrt, wird der Schritt mit Hinweis übersprungen. - `PUT /documents/{id}` ändert nur noch Symbol, Anheften und Ordner (Titel wird ins Log übernommen); Inhalte laufen ausschließlich über die Engine. Das To-Do-Rollup hakt per einzelner Operation über denselben Sync-Endpunkt ab. +- **Notiz-Icon (Emoji-Picker):** Vollständiger Emoji-Katalog mit Kategoriereitern, Suche, Verlauf zuletzt verwendeter Symbole (`localStorage`) und dynamischer Theme-Anpassung. Auf Mobilgeräten (< 640 px) als Bottom-Sheet mit touch-freundlichen Zielen (≥ 44 px) und `text-base`-Suchfeld (verhindert iOS-Zoom). ## Explorer, Tags, To-Dos @@ -50,4 +51,15 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts - Keine gleichzeitige Mehrbenutzerbearbeitung, keine Cursor-Anzeige, keine Versionshistorie. - Keine Virtualisierung langer Dokumente; sehr große Notizen können auf Mobilgeräten träge werden *(nicht gemessen)*. -- Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen ([FLAW-18](../flaws.md)). +- ~~Suche (`/search`) erfasst nur Titel und Inhalt eigener Notizen~~: Mit der globalen FTS5-Suche (`Cmd+K` / `Ctrl+K` / `/`) werden sowohl Titel als auch Block-Inhalte aller zugänglichen Notizen (eigene und freigegebene) durchsucht ([FLAW-18](../flaws.md)). + +## Export als Markdown-Archiv + +Kontoeinstellungen → Private Webhooks & Export → „Markdown-Archiv herunterladen“ (`GET /export/markdown`, Code: [export_markdown.go](../../internal/storage/export_markdown.go)). + +- Ordner und Notizen so, wie der Nutzer sie sieht (eigene Ordner, eingehängte geteilte Ordner, mit ihm geteilte Notizen); leere Ordner bleiben erhalten. +- Jede Notiz: YAML-Frontmatter (`title`, `icon`, `tags`, `created_at`, `updated_at`) und Markdown, das Obsidian direkt darstellt: Überschriften, Trenner, Hinweisboxen (`> [!note]`) und Codeblöcke der obersten Ebene als echte Blöcke, ihre Kinder rücken eine Ebene nach vorn; alles andere als Liste mit einem Tab pro Ebene, To-dos als `- [ ]`. +- Dateinamen werden für alle Betriebssysteme bereinigt (keine `/ \ : * ? " < > |`, nie `.`/`..`); doppelte Titel bekommen „ (2)“. +- Das ZIP wird beim Lesen der Daten direkt in die Antwort geschrieben, ohne temporäre Dateien. +- Ein Import aus ZIP gibt es noch nicht. + diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md index 0c2e1f5..6fe68b2 100644 --- a/Doku/ui-patterns.md +++ b/Doku/ui-patterns.md @@ -49,7 +49,7 @@ Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Kla - `muted` (Standard): reine Anzahl. Tabs der Aufgabenlisten und Listen, Organisationen, mobiles Menü. - Beide kommen aus den Theme-Variablen (`--accent-primary`/`--text-on-accent` bzw. `--text-muted`/`--bg-panel`) und passen sich damit jedem Theme an. - **Position (`position`):** - - `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. So in der Navigation und an den Tabs. Zeilen mit solchen Tabs brauchen etwas Abstand (`gap-x-3`, `pt-2`), damit der Kreis nicht in den Nachbar-Tab ragt. + - `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. In der Navigation und an den Tabs sitzt er **auf dem Text**: der Text steht in einem `relative inline-flex`-Span (mit `pr-2`, solange ein Zähler da ist), der Kreis bekommt `-top-1.5 -right-1`. - `inline`: im Textfluss. - Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken. - **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben. diff --git a/README.md b/README.md index 7d85945..852c948 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,8 @@ Rufe `http://localhost:8080` auf. Beim ersten Start führt ein Assistent durch d | `LOG_LEVEL` / `DEBUG` | `info` / `false` | Logging | | `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_TLS` | leer | E-Mail-Alarme (auch im Admin-UI) | | `SMTP_SKIP_VERIFY` | `false` | Zertifikatsprüfung für interne Mailserver abschalten | +| `LEGAL_IMPRINT_URL`, `LEGAL_PRIVACY_URL` | leer | Impressum/Datenschutz als externer Link (nur `http(s)`) | +| `LEGAL_IMPRINT_CONTENT`, `LEGAL_PRIVACY_CONTENT` (oder `…_CONTENT_FILE` mit Dateipfad) | leer | Impressum/Datenschutz als Markdown, angezeigt in einem Lesefenster. Ohne URL und Text bleibt der Link unsichtbar. | | `MINDA_DEMO` | `false` | Legt Demo-Konten `admin/admin` (mit Beispieldaten) und `user/user` an; eine Notiz und zwei Listen sind zwischen beiden geteilt, um das gemeinsame Bearbeiten auszuprobieren. **Nie auf öffentlichen Servern!** | * **Reverse-Proxy:** Am besten WebSocket-Upgrade und ungepufferte SSE-Streams durchreichen. Blockiert ein Proxy beides, funktioniert MindSift trotzdem über Long-Polling bzw. regelmäßige Abfragen, nur etwas träger. Beispiele für Nginx, Traefik, Caddy und Cloudflare: [Doku/reverse-proxy.md](Doku/reverse-proxy.md). diff --git a/cmd/server/main.go b/cmd/server/main.go index 9333166..e04bf9d 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -37,18 +37,18 @@ func main() { } defer store.Close() - // Seed SMTP defaults from environment variables into DB if not yet configured in DB - store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS, cfg.SMTPSkipVerify) - if cfg.DemoMode { - slog.Info("🧪 Demo-Modus aktiv (MINDA_DEMO=true): Richte Demo-Benutzer admin/admin und user/user ein...") - if err := store.EnsureDemoAccounts(); err != nil { - slog.Warn("⚠️ Fehler beim Anlegen der Demo-Accounts", "error", err) + slog.Info("🧪 Demo-Modus aktiv: Setze Datenbank zurück und richte Demo-Accounts ein...") + if err := store.ResetAndSeedDemo(); err != nil { + slog.Warn("⚠️ Fehler beim Einrichten des Demo-Modus", "error", err) } else { slog.Info("✅ Demo-Accounts bereit: admin / admin (Admin-Rolle) und user / user (Standard-Rolle)") } } + // Seed SMTP defaults from environment variables into DB if not yet configured in DB + store.SeedSMTPDefaultsFromEnv(cfg.SMTPHost, cfg.SMTPPort, cfg.SMTPUser, cfg.SMTPPass, cfg.SMTPFrom, cfg.SMTPTLS, cfg.SMTPSkipVerify) + wsHub := ws.NewHub() staticFS, err := fs.Sub(embeddedDist, "dist") diff --git a/compose.yaml b/compose.yaml index 8c45312..ff56f61 100644 --- a/compose.yaml +++ b/compose.yaml @@ -27,3 +27,8 @@ services: - SMTP_FROM=${SMTP_FROM:-} - SMTP_TLS=${SMTP_TLS:-true} - SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false} + # Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden) + - LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-} + - LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-} + - LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-} + - LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-} diff --git a/docker-compose.yml b/docker-compose.yml index 8c45312..ff56f61 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,3 +27,8 @@ services: - SMTP_FROM=${SMTP_FROM:-} - SMTP_TLS=${SMTP_TLS:-true} - SMTP_SKIP_VERIFY=${SMTP_SKIP_VERIFY:-false} + # Optional: Impressum / Datenschutz (URL oder Markdown-Datei, z. B. per Volume eingebunden) + - LEGAL_IMPRINT_URL=${LEGAL_IMPRINT_URL:-} + - LEGAL_PRIVACY_URL=${LEGAL_PRIVACY_URL:-} + - LEGAL_IMPRINT_CONTENT_FILE=${LEGAL_IMPRINT_CONTENT_FILE:-} + - LEGAL_PRIVACY_CONTENT_FILE=${LEGAL_PRIVACY_CONTENT_FILE:-} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4154551..c0f45f8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.2.0", "dependencies": { "@types/prismjs": "^1.26.6", + "emoji-picker-react": "^4.12.0", "framer-motion": "^11.15.0", "lucide-react": "^0.468.0", "prismjs": "^1.30.0", @@ -2101,6 +2102,21 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-picker-react": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/emoji-picker-react/-/emoji-picker-react-4.12.0.tgz", + "integrity": "sha512-q2c8UcZH0eRIMj41bj0k1akTjk69tsu+E7EzkW7giN66iltF6H9LQvQvw6ugscsxdC+1lmt3WZpQkkY65J95tg==", + "license": "MIT", + "dependencies": { + "flairup": "1.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -2302,6 +2318,12 @@ "node": ">=8" } }, + "node_modules/flairup": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/flairup/-/flairup-1.0.0.tgz", + "integrity": "sha512-IKlE+pNvL2R+kVL1kEhUYqRxVqeFnjiIvHWDMLFXNaqyUdFXQM2wte44EfMYJNHkW16X991t2Zg8apKkhv7OBA==", + "license": "MIT" + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 935837f..2d22bc8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@types/prismjs": "^1.26.6", + "emoji-picker-react": "4.12.0", "framer-motion": "^11.15.0", "lucide-react": "^0.468.0", "prismjs": "^1.30.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fc2a4f3..9124344 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,6 +23,7 @@ import { Flame, Mic, MicOff, + Search, } from 'lucide-react'; import { AuthStatus, DashboardStats, PriorityLevel, SharedList, Thought, User, UserTheme } from './lib/types'; import { @@ -56,6 +57,7 @@ import { GroupsView } from './features/teams/GroupsView'; import { useMobileActions } from './context/MobileActionContext'; import { MobileHeaderMenu } from './components/MobileHeaderMenu'; import { UserAvatar } from './components/UserAvatar'; +import { CommandPalette } from './components/CommandPalette'; import { APP_VERSION } from './version'; type ActiveTab = @@ -136,6 +138,8 @@ export const App: React.FC = () => { const [selectedDocId, setSelectedDocId] = useState(null); const [selectedBlockId, setSelectedBlockId] = useState(null); const [selectedTaskId, setSelectedTaskId] = useState(null); + const [selectedListId, setSelectedListId] = useState(null); + const [showCommandPalette, setShowCommandPalette] = useState(false); const [captureFocusCount, setCaptureFocusCount] = useState(0); const [refreshRevision, setRefreshRevision] = useState(0); @@ -594,6 +598,36 @@ export const App: React.FC = () => { setActiveTab('tasks'); }, []); + const handleOpenList = (listId: string) => { + setSelectedListId(listId); + setActiveTab('lists'); + }; + + // Global Command Palette shortcuts: Cmd+K / Ctrl+K, and '/' when not in input + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const el = e.target as HTMLElement; + const isEditing = + el.tagName === 'INPUT' || + el.tagName === 'TEXTAREA' || + el.isContentEditable; + + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + e.preventDefault(); + setShowCommandPalette((prev) => !prev); + return; + } + + if (!isEditing && e.key === '/') { + e.preventDefault(); + setShowCommandPalette(true); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, []); + const handleTriggerAddButton = () => { triggerHaptic(20); if (window.innerWidth < 640) { @@ -710,6 +744,19 @@ export const App: React.FC = () => { )} + + {/* User Profile Pill & Dropdown Menu */} @@ -1010,7 +1068,11 @@ export const App: React.FC = () => { )} {activeTab === 'lists' && ( - + )} {activeTab === 'groups' && ( @@ -1533,6 +1595,15 @@ export const App: React.FC = () => { )} + + {/* Global Command Palette (Cmd+K / Ctrl+K / '/') */} + setShowCommandPalette(false)} + onOpenDocument={handleOpenDocument} + onOpenTask={handleOpenTask} + onOpenList={handleOpenList} + /> ); }; diff --git a/frontend/src/components/AboutModal.tsx b/frontend/src/components/AboutModal.tsx new file mode 100644 index 0000000..f1b24d5 --- /dev/null +++ b/frontend/src/components/AboutModal.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Brain, ExternalLink } from 'lucide-react'; +import { APP_VERSION } from '../version'; +import { ReadingModal } from './ReadingModal'; + +// Project facts shown in "Über MindSift"; change here when the repository or license changes +export const PROJECT_REPO_URL = 'https://git.dsit.me/sandrodach/MindSift'; +export const PROJECT_LICENSE = 'Noch nicht festgelegt (keine LICENSE-Datei im Repository)'; + +export const AboutModal: React.FC<{ onClose: () => void }> = ({ onClose }) => ( + +
+
+
+ +
+
+

MindSift

+

Der Kopf frei in drei Sekunden.

+
+
+ +

+ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment. MindSift trennt deshalb{' '} + Erfassen und Organisieren: Gedanken + landen sofort im Quick Dump und werden später mit einem Tastendruck zu Aufgabe, Notiz oder Listeneintrag. Alles läuft + selbst gehostet in einem Container mit einer SQLite-Datei – deine Daten bleiben bei dir. +

+ +
+
Version
+
{APP_VERSION}
+
Lizenz
+
{PROJECT_LICENSE}
+
Quellcode
+
+ + {PROJECT_REPO_URL.replace(/^https:\/\//, '')} + + +
+
+ +

+ Impressum und Datenschutz dieser Instanz verantwortet ihr Betreiber; die Links stehen in den Kontoeinstellungen und auf der Anmeldeseite. +

+
+
+); diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx new file mode 100644 index 0000000..951d04c --- /dev/null +++ b/frontend/src/components/CommandPalette.tsx @@ -0,0 +1,327 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + Search, + FileText, + CheckSquare, + ShoppingCart, + X, + Loader2, + ArrowRight, + CornerDownLeft, +} from 'lucide-react'; +import { SearchHit } from '../lib/types'; +import { api } from '../lib/api'; + +interface CommandPaletteProps { + isOpen: boolean; + onClose: () => void; + onOpenDocument: (docId: string, blockId?: string) => void; + onOpenTask: (taskId: string) => void; + onOpenList: (listId: string) => void; +} + +// Safely render snippet containing ... without dangerouslySetInnerHTML +const SnippetRenderer: React.FC<{ snippet: string }> = ({ snippet }) => { + const parts = useMemo(() => { + if (!snippet) return []; + return snippet.split(/([\s\S]*?<\/mark>)/g); + }, [snippet]); + + return ( + + {parts.map((part, i) => { + if (part.startsWith('') && part.endsWith('')) { + const text = part.slice(6, -7); + return ( + + {text} + + ); + } + return {part}; + })} + + ); +}; + +export const CommandPalette: React.FC = ({ + isOpen, + onClose, + onOpenDocument, + onOpenTask, + onOpenList, +}) => { + const [query, setQuery] = useState(''); + const [hits, setHits] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + const [loading, setLoading] = useState(false); + + const inputRef = useRef(null); + const debounceTimerRef = useRef(null); + + // Group hits by category: Notizen, Aufgaben, Listen + const groupedHits = useMemo(() => { + const docs: SearchHit[] = []; + const tasks: SearchHit[] = []; + const lists: SearchHit[] = []; + + for (const h of hits) { + if (h.type === 'document') { + docs.push(h); + } else if (h.type === 'thought') { + tasks.push(h); + } else if (h.type === 'list_item') { + lists.push(h); + } + } + + return [ + { key: 'documents', label: 'Notizen', icon: FileText, items: docs }, + { key: 'tasks', label: 'Aufgaben', icon: CheckSquare, items: tasks }, + { key: 'lists', label: 'Listen', icon: ShoppingCart, items: lists }, + ].filter((g) => g.items.length > 0); + }, [hits]); + + // Flattened array for simple up/down index navigation + const flatHits = useMemo(() => { + return groupedHits.flatMap((g) => g.items); + }, [groupedHits]); + + // Focus input and reset on open + useEffect(() => { + if (isOpen) { + setQuery(''); + setHits([]); + setSelectedIndex(0); + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [isOpen]); + + // 150ms debounce search + useEffect(() => { + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + } + + const trimmed = query.trim(); + if (!trimmed) { + setHits([]); + setLoading(false); + setSelectedIndex(0); + return; + } + + setLoading(true); + debounceTimerRef.current = window.setTimeout(async () => { + try { + const res = await api.searchAll(trimmed); + setHits(res.hits || []); + setSelectedIndex(0); + } catch { + setHits([]); + } finally { + setLoading(false); + } + }, 150); + + return () => { + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + } + }; + }, [query]); + + // Keyboard navigation: ↑, ↓, Enter, Escape + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + return; + } + + if (flatHits.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => (prev + 1) % flatHits.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => (prev - 1 + flatHits.length) % flatHits.length); + } else if (e.key === 'Enter') { + e.preventDefault(); + const selected = flatHits[selectedIndex]; + if (selected) { + executeSelection(selected); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, flatHits, selectedIndex]); + + const executeSelection = (hit: SearchHit) => { + onClose(); + if (hit.type === 'document') { + onOpenDocument(hit.docId || hit.id, hit.blockId); + } else if (hit.type === 'thought') { + onOpenTask(hit.id); + } else if (hit.type === 'list_item') { + if (hit.listId) { + onOpenList(hit.listId); + } + } + }; + + if (!isOpen) return null; + + return ( +
+ {/* Search Modal Container */} +
e.stopPropagation()} + > + {/* Top Input Bar */} +
+ + setQuery(e.target.value)} + placeholder="Globale Suche in Notizen, Aufgaben und Listen…" + className="flex-1 bg-transparent border-0 outline-none text-main placeholder-muted text-base sm:text-sm focus:ring-0 p-0" + /> + {loading && } + {query && !loading && ( + + )} + +
+ + {/* Results Container */} +
+ {query.trim() === '' ? ( +
+

MindSift Globale Suche

+

Tippe ein Stichwort ein, um alle Inhalte in Sekundenschnelle zu durchsuchen.

+
+ ) : flatHits.length === 0 && !loading ? ( +
+

Keine Treffer gefunden

+

Keine Notizen, Aufgaben oder Listen-Einträge stimmen mit „{query}“ überein.

+
+ ) : ( + groupedHits.map((group) => { + const GroupIcon = group.icon; + return ( +
+
+ + {group.label} +
+ +
+ {group.items.map((item) => { + const overallIndex = flatHits.indexOf(item); + const isSelected = overallIndex === selectedIndex; + return ( +
executeSelection(item)} + onMouseEnter={() => setSelectedIndex(overallIndex)} + className={` + px-3 py-2.5 rounded-btn flex items-start gap-2.5 transition-colors cursor-pointer border + ${ + isSelected + ? 'bg-panel-elevated border-accent text-main' + : 'bg-canvas/50 border-transparent text-main hover:bg-panel-elevated' + } + `} + > +
+ {item.icon || (item.type === 'document' ? '📝' : item.type === 'thought' ? '✅' : '🛒')} +
+ +
+
+ + {item.title || 'Unbenannt'} + + {isSelected && ( + + Öffnen + + + )} +
+ + {item.snippet && ( +
+ +
+ )} +
+
+ ); + })} +
+
+ ); + }) + )} +
+ + {/* Footer Shortcut Bar (Desktop) */} +
+
+ + ↑{' '} + ↓ Navigieren + + + ↵ Auswählen + + + Esc Schließen + +
+ +
+ {flatHits.length} Treffer +
+
+
+
+ ); +}; diff --git a/frontend/src/components/LegalLinks.tsx b/frontend/src/components/LegalLinks.tsx new file mode 100644 index 0000000..f464fac --- /dev/null +++ b/frontend/src/components/LegalLinks.tsx @@ -0,0 +1,63 @@ +import React, { useState } from 'react'; +import { AuthStatus } from '../lib/types'; +import { api } from '../lib/api'; +import { MarkdownViewer } from '../lib/markdown'; +import { ReadingModal } from './ReadingModal'; + +type LegalType = 'imprint' | 'privacy'; +const TITLES: Record = { imprint: 'Impressum', privacy: 'Datenschutz' }; + +interface LegalLinksProps { + legal?: AuthStatus['legal']; + className?: string; + children?: React.ReactNode; // further footer links, e.g. "Über MindSift" +} + +// Impressum / Datenschutz links: an external URL opens in a new tab, Markdown in a reading modal. +// Links without URL or text are not shown at all. +export const LegalLinks: React.FC = ({ legal, className = '', children }) => { + const [open, setOpen] = useState<{ type: LegalType; content: string } | null>(null); + const [error, setError] = useState(null); + + const entries = (['imprint', 'privacy'] as LegalType[]) + .map((type) => ({ + type, + url: type === 'imprint' ? legal?.imprintUrl : legal?.privacyUrl, + hasContent: type === 'imprint' ? legal?.hasImprintContent : legal?.hasPrivacyContent, + })) + .filter((e) => e.url || e.hasContent); + + if (entries.length === 0 && !children) return null; + + const openText = async (type: LegalType) => { + setError(null); + try { + setOpen({ type, content: await api.getLegalText(type) }); + } catch (err: any) { + setError(err.message || 'Text konnte nicht geladen werden'); + } + }; + + return ( +
+ {entries.map((e) => + e.url ? ( + + {TITLES[e.type]} + + ) : ( + + ) + )} + {children} + {error && {error}} + {open && ( + setOpen(null)}> + + + )} +
+ ); +}; diff --git a/frontend/src/components/ReadingModal.tsx b/frontend/src/components/ReadingModal.tsx new file mode 100644 index 0000000..fd57830 --- /dev/null +++ b/frontend/src/components/ReadingModal.tsx @@ -0,0 +1,37 @@ +import React, { useEffect } from 'react'; +import { X } from 'lucide-react'; + +interface ReadingModalProps { + title: string; + onClose: () => void; + children: React.ReactNode; +} + +// Slim modal for reading text (legal notices, about); Escape or backdrop click closes it +export const ReadingModal: React.FC = ({ title, onClose, children }) => { + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + return ( +
e.target === e.currentTarget && onClose()} + role="dialog" + aria-modal="true" + aria-label={title} + > +
+
+

{title}

+ +
+
{children}
+
+
+ ); +}; diff --git a/frontend/src/features/auth/SetupAndAuthView.tsx b/frontend/src/features/auth/SetupAndAuthView.tsx index 07f28b8..ed54997 100644 --- a/frontend/src/features/auth/SetupAndAuthView.tsx +++ b/frontend/src/features/auth/SetupAndAuthView.tsx @@ -14,6 +14,7 @@ import { import { AuthStatus, SharingMode, User } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; import { ToggleSwitch } from '../../components/ToggleSwitch'; +import { LegalLinks } from '../../components/LegalLinks'; interface SetupAndAuthViewProps { authStatus: AuthStatus; @@ -330,6 +331,7 @@ export const SetupAndAuthView: React.FC = ({ + ); } @@ -574,6 +576,7 @@ export const SetupAndAuthView: React.FC = ({ )} + ); }; diff --git a/frontend/src/features/capture/InstantCaptureBar.tsx b/frontend/src/features/capture/InstantCaptureBar.tsx index 5bf73ef..cffc170 100644 --- a/frontend/src/features/capture/InstantCaptureBar.tsx +++ b/frontend/src/features/capture/InstantCaptureBar.tsx @@ -103,7 +103,7 @@ export const InstantCaptureBar: React.FC = ({ el.tagName === 'TEXTAREA' || el.isContentEditable; - if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) { + if (!isEditing && (e.key === 'c' || e.key === 'C')) { e.preventDefault(); inputRef.current?.focus(); } else if (e.key === 'Escape' && document.activeElement === inputRef.current) { diff --git a/frontend/src/features/help/HelpView.tsx b/frontend/src/features/help/HelpView.tsx index 31d92f4..2ea0e3f 100644 --- a/frontend/src/features/help/HelpView.tsx +++ b/frontend/src/features/help/HelpView.tsx @@ -28,6 +28,7 @@ import { Flame, Check, } from 'lucide-react'; +import { AboutModal } from '../../components/AboutModal'; interface HelpViewProps { onBack?: () => void; @@ -55,6 +56,7 @@ interface DocCard { export const HelpView: React.FC = ({ onBack }) => { const [activeCategory, setActiveCategory] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); + const [showAbout, setShowAbout] = useState(false); // Documentation sections & cards data const cards: DocCard[] = useMemo( @@ -341,6 +343,42 @@ export const HelpView: React.FC = ({ onBack }) => { ), }, + { + id: 'workspace-search-cmdk', + category: 'workspace', + title: 'Globale Suche & Command Palette (Cmd+K)', + subtitle: 'FTS5-Volltextsuche in Notizen, Blöcken, Aufgaben und Listen', + icon: Command, + keywords: [ + 'suche', + 'search', + 'command', + 'palette', + 'cmdk', + 'find', + 'fts', + 'volltext', + 'tastatur', + ], + content: ( +
+

+ Drücke Cmd + K / Strg + K oder / (wenn kein Textfeld aktiv ist), um die globale Suche zu öffnen: +

+
    +
  • + Volltextsuche (FTS5): Durchsucht Notiz-Titel und Blöcke, Aufgaben und Listeneinträge blitzschnell mit Relevanz-Ranking (BM25) und Präfixsuche. +
  • +
  • + Sicherheit: Zeigt nur Inhalte, auf die du tatsächlich Zugriff hast (eigene sowie freigegebene Notizen, Aufgabenlisten und Listen). +
  • +
  • + Tastaturnavigation: Mit ↑ und ↓ blättern, mit Enter Treffer direkt öffnen (Notizen direkt am gefundenen Block). +
  • +
+
+ ), + }, // TRIAGE & QUICK DUMP { @@ -656,7 +694,8 @@ export const HelpView: React.FC = ({ onBack }) => { // Keyboard shortcuts table data const shortcuts = useMemo( () => [ - { key: 'C oder /', scope: 'Global', desc: 'Fokus sofort in die Schnellerfassungsleiste setzen' }, + { key: 'Cmd + K / Strg + K oder /', scope: 'Global', desc: 'Globale Volltextsuche (Command Palette) öffnen' }, + { key: 'C', scope: 'Global', desc: 'Fokus sofort in die Schnellerfassungsleiste setzen' }, { key: 'Enter', scope: 'Schnellerfassung', desc: 'Gedanken blitzschnell speichern' }, { key: 'Esc', scope: 'Schnellerfassung', desc: 'Eingabe leeren & Optionen schließen' }, { key: 'Enter', scope: 'Workspace Outliner', desc: 'Neuen Bullet-Point anlegen (oder leeren Block ausrücken)' }, @@ -876,6 +915,13 @@ export const HelpView: React.FC = ({ onBack }) => { )} + +
+ +
+ {showAbout && setShowAbout(false)} />} ); }; diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index 0e0a28f..68d7e14 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -20,6 +20,7 @@ import { useMobileActions } from '../../context/MobileActionContext'; interface SharedListsViewProps { onRefreshGlobal: () => void; currentUser?: User | null; + initialListId?: string | null; } export function parseListItemInput(rawText: string, defaultQty?: string): { @@ -79,10 +80,16 @@ export function deduplicateItems(items: ListItem[]): ListItem[] { return result; } -export const SharedListsView: React.FC = ({ onRefreshGlobal, currentUser }) => { +export const SharedListsView: React.FC = ({ onRefreshGlobal, currentUser, initialListId }) => { const { setGroups, setPrimaryAction } = useMobileActions(); const [lists, setLists] = useState([]); - const [activeListId, setActiveListId] = useState(''); + const [activeListId, setActiveListId] = useState(initialListId || ''); + + useEffect(() => { + if (initialListId) { + setActiveListId(initialListId); + } + }, [initialListId]); const [newItemText, setNewItemText] = useState(''); const [newItemQty, setNewItemQty] = useState(''); @@ -128,7 +135,9 @@ export const SharedListsView: React.FC = ({ onRefreshGloba items: deduplicateItems(l.items || []), })); setLists(deduplicated); - if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) { + if (initialListId && deduplicated.some((l) => l.id === initialListId)) { + setActiveListId(initialListId); + } else if (deduplicated.length > 0 && (!activeListId || !deduplicated.some((l) => l.id === activeListId))) { setActiveListId(deduplicated[0].id); } } catch { @@ -843,8 +852,8 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
{/* Top Header: List Tabs & Actions (Desktop) */}
- {/* List Tabs (counters sit on the top right corner, hence the extra gap) */} -
+ {/* List Tabs */} +
{lists.map((lst) => { const openCount = deduplicateItems(lst.items || []).filter((i) => !i.isCompleted).length; const isSelected = activeList?.id === lst.id; @@ -860,7 +869,11 @@ export const SharedListsView: React.FC = ({ onRefreshGloba className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn" > {lst.icon} - {lst.title} + {/* Counter on the label, like in the nav bar */} + 0 ? 'pr-2' : ''}`}> + {lst.title} + + = ({ onRefreshGloba className="ml-0.5" /> - {isSelected && lists.length > 1 && (
+ + {/* Obsidian-compatible Markdown vault */} +
+
+

Markdown-Archiv für Obsidian (.zip)

+

+ Alle Notizen als .md-Dateien in deiner Ordnerstruktur (mit Titel, Tags und + Datum im Kopf), Aufgaben je Liste unter Aufgaben/ im Format des + Obsidian-Tasks-Plugins, Listen unter Listen/. Mit dir geteilte Notizen sind + dabei. +

+ {vaultError &&

{vaultError}

} +
+ +
)}
+ + {/* Footer: legal notices of this instance and project info */} + + + + {showAbout && setShowAbout(false)} />} ); }; diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 552c698..320d3a9 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -1373,18 +1373,21 @@ export const TasksView: React.FC = ({
{/* Top Header: Task Lists Tabs & View Mode Switcher (Desktop) */}
- {/* Task Lists selector tabs (counters sit on the top right corner, hence the extra gap) */} -
+ {/* Task Lists selector tabs */} +
{safeTaskLists.map((tl) => ( @@ -1399,7 +1402,10 @@ export const TasksView: React.FC = ({ className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn" > {tl.icon} - {tl.title} + + {tl.title} + + {(() => { const isShared = (tl.shares && tl.shares.length > 0) || (tl.members && tl.members.length > 1); @@ -1422,7 +1428,6 @@ export const TasksView: React.FC = ({ ); })()} - {selectedListId === tl.id && ( {showEmojiPicker && canEdit && ( -
e.stopPropagation()} - > -
- - setEmojiSearch(e.target.value)} - placeholder="Icon suchen oder Emoji eingeben…" - className="input-field w-full pl-7 pr-2 py-1 text-xs" - /> -
- {doc?.icon && ( - - )} - {emojiSearch.trim() && ( - - )} -
- {filteredEmojis.map((item) => ( - - ))} - {filteredEmojis.length === 0 && ( -
- Kein passendes Standard-Icon -
- )} -
-
+ setShowEmojiPicker(false)} + /> )}
diff --git a/frontend/src/features/workspace/EmojiPickerModal.tsx b/frontend/src/features/workspace/EmojiPickerModal.tsx new file mode 100644 index 0000000..9646fbc --- /dev/null +++ b/frontend/src/features/workspace/EmojiPickerModal.tsx @@ -0,0 +1,226 @@ +import React, { Suspense, useEffect, useRef, useState } from 'react'; +import { X, Trash2, Loader2, Clock } from 'lucide-react'; +import type { EmojiClickData } from 'emoji-picker-react'; +import { EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react'; + +// Lazy-load emoji-picker-react so the main bundle size stays small +const EmojiPicker = React.lazy(() => import('emoji-picker-react')); + +const RECENT_EMOJIS_STORAGE_KEY = 'mindsift_recent_emojis'; +const MAX_RECENT_EMOJIS = 10; + +function getStoredRecentEmojis(): string[] { + try { + const raw = localStorage.getItem(RECENT_EMOJIS_STORAGE_KEY); + if (!raw) return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻']; + const parsed = JSON.parse(raw); + if (Array.isArray(parsed) && parsed.length > 0) { + return parsed.slice(0, MAX_RECENT_EMOJIS); + } + } catch { + // fallback + } + return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻']; +} + +function saveRecentEmoji(emoji: string): void { + try { + const current = getStoredRecentEmojis(); + const updated = [emoji, ...current.filter((e) => e !== emoji)].slice(0, MAX_RECENT_EMOJIS); + localStorage.setItem(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(updated)); + } catch { + // ignore + } +} + +interface EmojiPickerModalProps { + currentIcon?: string; + onSelectEmoji: (emoji: string) => void; + onClose: () => void; +} + +export const EmojiPickerModal: React.FC = ({ + currentIcon, + onSelectEmoji, + onClose, +}) => { + const [recentEmojis, setRecentEmojis] = useState(getStoredRecentEmojis); + const [isDark, setIsDark] = useState(() => + typeof document !== 'undefined' + ? document.documentElement.classList.contains('dark') + : true + ); + + const containerRef = useRef(null); + + // Sync theme changes + useEffect(() => { + const observer = new MutationObserver(() => { + setIsDark(document.documentElement.classList.contains('dark')); + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class', 'data-theme'], + }); + return () => observer.disconnect(); + }, []); + + // Close on Escape or click outside (desktop popover) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onClose(); + } + }; + + const handleClickOutside = (e: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(e.target as Node) + ) { + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + document.addEventListener('mousedown', handleClickOutside); + return () => { + window.removeEventListener('keydown', handleKeyDown); + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [onClose]); + + const handlePick = (emoji: string) => { + saveRecentEmoji(emoji); + setRecentEmojis(getStoredRecentEmojis()); + onSelectEmoji(emoji); + }; + + const handlePickerEmojiClick = (emojiData: EmojiClickData) => { + handlePick(emojiData.emoji); + }; + + return ( + <> + {/* Mobile Backdrop (< 640px) */} +