From 1df32ece9289a0ad27937bb123c7e4a6c96bf917 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 06:43:50 +0100 Subject: [PATCH 1/3] feat(backup): download the full backup as ZIP GET /export?format=zip streams the JSON backup compressed (plain JSON stays the API default). The MindSift import recognises the backup ZIP next to the plain JSON and the Markdown archive. Co-Authored-By: Claude Opus 5.5 --- Doku/api/rest.md | 2 +- Doku/modules/workspace.md | 2 +- .../features/settings/UserSettingsView.tsx | 25 ++++---- frontend/src/lib/api.ts | 21 ++++--- internal/api/server.go | 16 +++-- internal/storage/import_markdown.go | 59 +++++++++++++++++++ internal/storage/import_markdown_test.go | 22 +++++++ 7 files changed, 122 insertions(+), 25 deletions(-) diff --git a/Doku/api/rest.md b/Doku/api/rest.md index 30eb221..240b4a4 100644 --- a/Doku/api/rest.md +++ b/Doku/api/rest.md @@ -30,7 +30,7 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}` | 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=` | Globale FTS5-Suche in zugänglichen Notizen (Titel & Blöcke), Aufgaben und Listen → `{thoughts, documents, listItems, hits}` | -| GET | `/export` | Datenexport als JSON | +| GET | `/export` | Datenexport als JSON; mit `?format=zip` dieselben Daten als `mindsift-backup.json` in einer ZIP (so lädt die Oberfläche das Backup) | | 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 | diff --git a/Doku/modules/workspace.md b/Doku/modules/workspace.md index 1bebd17..fd9a34d 100644 --- a/Doku/modules/workspace.md +++ b/Doku/modules/workspace.md @@ -68,7 +68,7 @@ Kontoeinstellungen → Private Webhooks & Export → „Daten importieren“, mi | Weg | Endpunkt | Dateien | Ergebnis | |---|---|---|---| -| MindSift-Daten | `POST /import/mindsift` | JSON-Backup (`/export`) oder Markdown-Archiv (`/export/markdown`) | Notizen in ihre Ordner, Aufgabenlisten mit Unteraufgaben, Fälligkeit, Priorität und Notizen, Listen mit Menge, Kategorie und Erledigt-Status, beim JSON-Backup auch die Gedanken der Triage. Fremde, nur mit dem Exporteur geteilte Inhalte im JSON werden übersprungen. | +| MindSift-Daten | `POST /import/mindsift` | Backup-ZIP oder JSON (`/export`) oder Markdown-Archiv (`/export/markdown`), das Format wird erkannt | Notizen in ihre Ordner, Aufgabenlisten mit Unteraufgaben, Fälligkeit, Priorität und Notizen, Listen mit Menge, Kategorie und Erledigt-Status, beim JSON-Backup auch die Gedanken der Triage. Fremde, nur mit dem Exporteur geteilte Inhalte im JSON werden übersprungen. | | Obsidian-Vault | `POST /import/obsidian` | ZIP eines Vaults | Alles in einen neuen Ordner mit dem Namen des Vaults, Unterordner bleiben. Checkboxen bleiben Checkboxen, Frontmatter-Tags werden zu `#Hashtags`, Callouts zu Hinweisboxen, Wikilinks bleiben `[[…]]`. Versteckte Ordner (`.obsidian`, `.trash`) werden ignoriert, Anhänge übersprungen. | - **Vorschau:** Die Oberfläche lädt die Datei zuerst mit `?preview=1` hoch. Der Server liest und prüft alles und meldet, was angelegt würde (inkl. übersprungener Dateien mit Grund), schreibt aber nichts. Erst „Jetzt importieren“ legt an. diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index 2409ef9..6a150fa 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -375,12 +375,16 @@ export const UserSettingsView: React.FC = ({ window.setTimeout(() => URL.revokeObjectURL(url), 10000); }; + const [exportingBackup, setExportingBackup] = useState(false); const handleExportData = async () => { - const data = await api.exportAll(); - const blob = new Blob([JSON.stringify(data, null, 2)], { - type: 'application/json;charset=utf-8', - }); - saveBlob(blob, `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`); + setExportingBackup(true); + try { + saveBlob(await api.exportBackupZip(), `mindsift-backup-${new Date().toISOString().slice(0, 10)}.zip`); + } catch (err: any) { + setVaultError(err.message || 'Backup fehlgeschlagen'); + } finally { + setExportingBackup(false); + } }; const [exportingVault, setExportingVault] = useState(false); @@ -1238,18 +1242,19 @@ export const UserSettingsView: React.FC = ({ {/* Full Data Backup */}
-

Vollständiger Daten-Export (JSON Backup)

+

Vollständiges Backup (.zip)

- Lädt alle deine Gedanken, Aufgaben, Markdown-Dokumente und Listen als portable - JSON-Datei herunter. + Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, gepackt in eine ZIP-Datei. Über + „MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.

diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index af6da26..e7b6234 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -135,6 +135,14 @@ export const setCurrentSharingMode = (mode: SharingMode) => { }; export const isSharingDisabled = () => currentSharingMode === 'private'; +// Authenticated binary download (archives); no JSON parsing and no request timeout +async function downloadBlob(path: string): Promise { + const token = getToken(); + const res = await fetch(path, { headers: token ? { Authorization: `Bearer ${token}` } : {}, cache: 'no-store' }); + if (!res.ok) throw new Error(`Download fehlgeschlagen (HTTP ${res.status})`); + return res.blob(); +} + export const api = { // --- AUTH, FIRST-BOOT SETUP & SETTINGS --- async getAuthStatus(): Promise { @@ -1093,15 +1101,14 @@ export const api = { return data as ImportSummary; }, + // Full backup (all data as JSON) packed into a ZIP; restorable via importData('mindsift', …) + async exportBackupZip(): Promise { + return downloadBlob('/api/v1/export?format=zip'); + }, + // Obsidian-compatible ZIP of the workspace; binary and possibly large, so no JSON helper and no timeout async exportMarkdownVault(): Promise { - const token = getToken(); - const res = await fetch('/api/v1/export/markdown', { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - cache: 'no-store', - }); - if (!res.ok) throw new Error(`Export fehlgeschlagen (HTTP ${res.status})`); - return res.blob(); + return downloadBlob('/api/v1/export/markdown'); }, }; diff --git a/internal/api/server.go b/internal/api/server.go index 739254f..8377176 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -2159,12 +2159,7 @@ func (s *Server) handleImportMindSift(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, err.Error()) return } - if strings.HasSuffix(strings.ToLower(name), ".json") || (len(data) > 0 && data[0] == '{') { - plan, err := storage.PlanMindSiftJSONImport(data) - s.runImport(w, r, plan, err) - return - } - plan, err := storage.PlanMindSiftArchiveImport(bytes.NewReader(data), int64(len(data))) + plan, err := storage.PlanMindSiftImport(data, name) s.runImport(w, r, plan, err) } @@ -2197,6 +2192,15 @@ func (s *Server) handleExport(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err.Error()) return } + // ?format=zip: the same JSON compressed (backups grow quickly with notes); plain JSON stays the API default + if r.URL.Query().Get("format") == "zip" { + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="mindsift-backup-%s.zip"`, time.Now().Format("2006-01-02"))) + if err := storage.WriteBackupZip(exportData, w); err != nil { + slog.Error("Backup-Export fehlgeschlagen", "user_id", userID, "error", err) + } + return + } w.Header().Set("Content-Disposition", "attachment; filename=mindsift-export.json") writeJSON(w, http.StatusOK, exportData) } diff --git a/internal/storage/import_markdown.go b/internal/storage/import_markdown.go index d84c774..8c692aa 100644 --- a/internal/storage/import_markdown.go +++ b/internal/storage/import_markdown.go @@ -2,6 +2,7 @@ package storage import ( "archive/zip" + "bytes" "encoding/json" "errors" "fmt" @@ -10,6 +11,7 @@ import ( "regexp" "sort" "strings" + "time" "unicode/utf8" "github.com/sandrodach/mindsift/internal/domain" @@ -300,6 +302,63 @@ func PlanMindSiftArchiveImport(r io.ReaderAt, size int64) (*ImportPlan, error) { return plan, nil } +// BackupFileName is the JSON inside the backup ZIP. +const BackupFileName = "mindsift-backup.json" + +// WriteBackupZip streams the JSON backup compressed into a ZIP with a single file. +func WriteBackupZip(exp *domain.FullExport, w io.Writer) error { + zw := zip.NewWriter(w) + f, err := zw.CreateHeader(&zip.FileHeader{Name: BackupFileName, Method: zip.Deflate, Modified: parseISO(exp.ExportedAt, time.Now())}) + if err != nil { + return err + } + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + if err := enc.Encode(exp); err != nil { + return err + } + return zw.Close() +} + +// PlanMindSiftImport picks the format of MindSift's own data: plain JSON backup, the backup ZIP +// (one JSON inside) or the Markdown archive. +func PlanMindSiftImport(data []byte, fileName string) (*ImportPlan, error) { + if strings.HasSuffix(strings.ToLower(fileName), ".json") || (len(data) > 0 && data[0] == '{') { + return PlanMindSiftJSONImport(data) + } + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, errors.New("Die Datei ist weder ein MindSift-Backup (.json/.zip) noch ein MindSift-Markdown-Archiv") + } + var backup *zip.File + for _, f := range zr.File { + name := cleanArchivePath(f.Name) + if strings.EqualFold(path.Ext(name), ".md") { + backup = nil + break + } + if strings.EqualFold(path.Ext(name), ".json") && !hiddenPath(name) { + backup = f + } + } + if backup == nil { + return PlanMindSiftArchiveImport(bytes.NewReader(data), int64(len(data))) + } + if backup.UncompressedSize64 > maxImportUnpackedBytes { + return nil, errors.New("Backup ist entpackt zu groß (max. 500 MB)") + } + rc, err := backup.Open() + if err != nil { + return nil, errors.New("Backup im Archiv ist nicht lesbar") + } + defer rc.Close() + raw, err := io.ReadAll(io.LimitReader(rc, maxImportUnpackedBytes+1)) + if err != nil || len(raw) > maxImportUnpackedBytes { + return nil, errors.New("Backup im Archiv ist nicht lesbar oder zu groß") + } + return PlanMindSiftJSONImport(raw) +} + // PlanMindSiftJSONImport reads the JSON backup ("Backup herunterladen"). Notes shared with the // exporting user belong to someone else and are skipped; discarded thoughts are not restored. func PlanMindSiftJSONImport(data []byte) (*ImportPlan, error) { diff --git a/internal/storage/import_markdown_test.go b/internal/storage/import_markdown_test.go index 4042f66..821a26a 100644 --- a/internal/storage/import_markdown_test.go +++ b/internal/storage/import_markdown_test.go @@ -197,3 +197,25 @@ func TestImportObsidianVault(t *testing.T) { t.Errorf("invalid archive must be rejected") } } + +func TestImportBackupZip(t *testing.T) { + store, anna, ben := newImportStore(t) + seedAnna(t, store, anna) + exp, _ := store.ExportUserData(anna.ID) + var buf bytes.Buffer + if err := WriteBackupZip(exp, &buf); err != nil { + t.Fatalf("WriteBackupZip: %v", err) + } + if buf.Len() == 0 { + t.Fatalf("empty archive") + } + plan, err := PlanMindSiftImport(buf.Bytes(), "mindsift-backup-2026-10-29.zip") + if err != nil { + t.Fatalf("plan: %v", err) + } + if _, err := store.ApplyImport(ben.ID, plan); err != nil { + t.Fatalf("apply: %v", err) + } + checkRestored(t, store, ben) +} + From 108d166990bdd938c230aef3fa05995174140c7e Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 06:43:51 +0100 Subject: [PATCH 2/3] chore(db): stop wiping databases with an older schema version All installations are past schema version 2, so the one-time reset is gone; data is never deleted on start (demo mode still resets). user_version is kept for future migrations. Legal test uses an in-memory DB (cleanup race). Co-Authored-By: Claude Opus 5.5 --- Doku/database.md | 2 +- Doku/flaws.md | 2 +- internal/api/server_test.go | 3 ++- internal/storage/sqlite.go | 21 +++------------------ internal/storage/visibility_test.go | 9 +++++---- 5 files changed, 12 insertions(+), 25 deletions(-) diff --git a/Doku/database.md b/Doku/database.md index 5c0f51f..d991666 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -80,7 +80,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. -- **Schema-Version:** `PRAGMA user_version` hält `schemaVersion` (derzeit 2). Ist die Datenbank älter, werden vor allem anderen **alle Tabellen gelöscht** und neu angelegt. Bewusst so, solange MindSift nicht produktiv läuft. +- **Schema-Version:** `PRAGMA user_version` hält `schemaVersion` (derzeit 2), damit spätere Versionen erkennen, welche Migrationen eine Datenbank schon hat. Daten werden beim Start nie gelöscht (das einmalige Zurücksetzen auf Version 2 ist entfernt, alle Installationen sind darüber hinweg). Nur der Demo-Modus leert die Datenbank bei jedem Start. ## Sicherung diff --git a/Doku/flaws.md b/Doku/flaws.md index eaace45..d6fc9d2 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -115,7 +115,7 @@ Schweregrade: **Hoch** (Datenabfluss oder Rechteumgehung möglich), **Mittel** ( - **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen. ### FLAW-13: Migrationen -- **Teilweise behoben:** Das Schema hat eine Version (`PRAGMA user_version`, `schemaVersion` in `sqlite.go`). Ist eine Datenbank älter, wird sie beim Start **komplett gelöscht** und neu angelegt (vor dem produktiven Einsatz bewusst so gewählt). Die übrigen Punkte unten gelten weiter. +- **Teilweise behoben:** Das Schema hat eine Version (`PRAGMA user_version`, `schemaVersion` in `sqlite.go`), auf die künftige Migrationen aufsetzen können. Die übrigen Punkte unten gelten weiter. - **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql). - **Probleme:** - Es gibt keine Versionstabelle. Alle `ALTER TABLE`-Anweisungen laufen bei jedem Start erneut; Fehler werden pauschal ignoriert (`_, _ = s.db.Exec(q)`), echte Fehler fallen nicht auf. diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 9c4df1b..92788e4 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1369,7 +1369,8 @@ func TestFirstBootSetupWithSampleData(t *testing.T) { // Impressum and Datenschutz must be reachable without login; unset texts stay hidden. func TestLegalTextsArePublic(t *testing.T) { - dbPath := filepath.Join(t.TempDir(), "legal.db") + // In memory: no files the cleanup could race with + dbPath := ":memory:" store, err := storage.NewStore(dbPath) if err != nil { t.Fatalf("NewStore: %v", err) diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go index 42e8140..e482122 100644 --- a/internal/storage/sqlite.go +++ b/internal/storage/sqlite.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "log/slog" "os" "path/filepath" "regexp" @@ -58,22 +57,11 @@ func (s *Store) Close() error { return s.db.Close() } -// schemaVersion is stored in PRAGMA user_version. Databases from an older version are wiped and rebuilt -// on start: MindSift is not in production yet, so breaking changes need no migration path. +// schemaVersion is stored in PRAGMA user_version so later releases can tell which migrations a database +// already has. (Version 2 once wiped older databases on start; all installations are past that now.) const schemaVersion = 2 -func (s *Store) resetOutdatedSchema() error { - var version int - if err := s.db.QueryRow(`PRAGMA user_version`).Scan(&version); err != nil { - return err - } - if version >= schemaVersion { - return nil - } - slog.Warn("Veraltetes Datenbankschema: alle Daten werden gelöscht und neu angelegt", "version", version, "neu", schemaVersion) - return s.dropAllTables() -} - +// dropAllTables removes every table; used by the demo mode reset. func (s *Store) dropAllTables() error { conn, err := s.db.Conn(context.Background()) if err != nil { @@ -107,9 +95,6 @@ func (s *Store) dropAllTables() error { } func (s *Store) migrate() error { - if err := s.resetOutdatedSchema(); err != nil { - return fmt.Errorf("reset outdated schema: %w", err) - } schema := ` PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; diff --git a/internal/storage/visibility_test.go b/internal/storage/visibility_test.go index cd9666f..801d03c 100644 --- a/internal/storage/visibility_test.go +++ b/internal/storage/visibility_test.go @@ -144,8 +144,9 @@ func TestVisibilityFollowsOrganizations(t *testing.T) { } } -func TestOutdatedSchemaIsWiped(t *testing.T) { - path := filepath.Join(t.TempDir(), "old.db") +// Existing data must survive a restart, whatever user_version the database carries. +func TestDataSurvivesRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "keep.db") store, err := NewStore(path) if err != nil { t.Fatalf("NewStore failed: %v", err) @@ -159,8 +160,8 @@ func TestOutdatedSchemaIsWiped(t *testing.T) { t.Fatalf("reopen failed: %v", err) } defer store.Close() - if n, _ := store.CountUsers(); n != 0 { - t.Fatalf("outdated database should start empty, found %d users", n) + if n, _ := store.CountUsers(); n != 1 { + t.Fatalf("data must be kept on restart, found %d users", n) } var v int _ = store.db.QueryRow(`PRAGMA user_version`).Scan(&v) From eaabe469d5dab74ef1ae62aaccdb263ca997ab78 Mon Sep 17 00:00:00 2001 From: Sandro Date: Thu, 29 Oct 2026 06:43:51 +0100 Subject: [PATCH 3/3] fix(mobile): sheets above the keyboard, emoji picker, cleaner header - FormSheet: create dialogs (task list, shopping list, folder) as bottom sheet right above the on-screen keyboard (visualViewport), large fields; centred or anchored dialog on desktop - Emoji picker: category icons keep their sprite size (no doubled icons, round highlight), one 'Zuletzt verwendet' (library category, German names), sheet and picker height follow the keyboard - Mobile header no longer repeats the current section next to the logo Co-Authored-By: Claude Opus 5.5 --- Doku/ui-patterns.md | 8 ++ frontend/src/App.tsx | 3 - frontend/src/components/FormSheet.tsx | 73 ++++++++++++++++ .../src/features/lists/SharedListsView.tsx | 83 +++++++------------ frontend/src/features/triage/TasksView.tsx | 82 +++++++----------- .../features/workspace/EmojiPickerModal.tsx | 82 ++++++------------ .../src/features/workspace/WorkspaceView.tsx | 66 ++++----------- frontend/src/index.css | 7 +- .../lib/__tests__/useKeyboardInset.test.tsx | 35 ++++++++ frontend/src/lib/useKeyboardInset.ts | 27 ++++++ 10 files changed, 249 insertions(+), 217 deletions(-) create mode 100644 frontend/src/components/FormSheet.tsx create mode 100644 frontend/src/lib/__tests__/useKeyboardInset.test.tsx create mode 100644 frontend/src/lib/useKeyboardInset.ts diff --git a/Doku/ui-patterns.md b/Doku/ui-patterns.md index 6fe68b2..c40bea5 100644 --- a/Doku/ui-patterns.md +++ b/Doku/ui-patterns.md @@ -39,6 +39,14 @@ Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx) - Ein/Aus-Einstellungen sind immer Schalter, keine Checkbox-Haken (Einrichtung, Admin-Bereich). - Text links (Titel, darunter eine kurze Erklärung in `text-muted`), Schalter rechts. +## Kleine Dialoge (Anlegen, Umbenennen) + +Komponente: [FormSheet.tsx](../frontend/src/components/FormSheet.tsx), Tastatur-Abstand über [useKeyboardInset.ts](../frontend/src/lib/useKeyboardInset.ts) + +- **Handy:** Bottom-Sheet über die volle Breite, das direkt über der Bildschirmtastatur sitzt (der Abstand kommt aus `visualViewport`), Griff oben, Felder und Buttons mindestens 44 px hoch, Eingaben in 16 px (sonst zoomt iOS), Abbrechen/Erstellen nebeneinander über die ganze Breite. +- **Desktop:** zentriertes Fenster, oder mit `anchor` neben der Klickposition (z. B. „Neuer Ordner“ im Explorer). +- Escape und Klick auf den Hintergrund schließen. Genutzt für neue Aufgabenliste, neue Einkaufsliste und neuen Ordner; das Emoji-Sheet nutzt denselben Tastatur-Abstand. + ## Zähler Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Klassen `count-badge*` in [index.css](../frontend/src/index.css). Es gibt keine anderen Zähler-Stile mehr. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9124344..5715ba2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -716,9 +716,6 @@ export const App: React.FC = () => { {APP_VERSION} - - {activeTabLabelMap[activeTab]} - void; + onSubmit: (e: React.FormEvent) => void; + children: React.ReactNode; // the fields + submitLabel?: string; + // Desktop only: open next to the click instead of centred + anchor?: { x: number; y: number } | null; +} + +// Small create/rename dialogs. Phone: bottom sheet directly above the keyboard, full width, large +// fields. Desktop: centred dialog (or next to the click). Escape and the backdrop close it. +export const FormSheet: React.FC = ({ title, icon, onClose, onSubmit, children, submitLabel = 'Erstellen', anchor }) => { + const { inset, visibleHeight } = useKeyboardInset(); + useEffect(() => { + const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose(); + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + const desktopAnchor = anchor + ? ({ '--sheet-left': `${anchor.x}px`, '--sheet-top': `${anchor.y}px` } as React.CSSProperties) + : undefined; + + return ( +
+
e.stopPropagation()} + style={ + { + ...desktopAnchor, + '--kb-inset': `${inset}px`, + '--vv-max': visibleHeight ? `${visibleHeight - 16}px` : '85vh', + } as React.CSSProperties + } + 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-sm sm:left-1/2 sm:top-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2' + }`} + > +
{showNewListModal && ( -
-
-
-

- - Neue Einkaufsliste erstellen -

- + } + onClose={() => setShowNewListModal(false)} + onSubmit={handleCreateList} + > +
+ +
+ setNewListIcon(e.target.value)} + maxLength={2} + className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm" + title="Icon (Emoji)" + /> + setNewListTitle(e.target.value)} + placeholder="z.B. Supermarkt, Baumarkt, Drogerie" + autoFocus + required + className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs" + /> +
- -
-
- -
- setNewListIcon(e.target.value)} - maxLength={2} - className="w-10 h-8 text-center input-field text-sm" - title="Icon (Emoji)" - /> - setNewListTitle(e.target.value)} - placeholder="z.B. Supermarkt, Baumarkt, Drogerie" - autoFocus - required - className="flex-1 h-8 input-field px-2.5 text-xs" - /> -
-
- -
- - -
-
-
-
+ )} {/* Active Shared List View */} diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 320d3a9..786ec43 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -33,6 +33,7 @@ import { TaskDetailModal } from './TaskDetailModal'; import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { CountBadge } from '../../components/CountBadge'; +import { FormSheet } from '../../components/FormSheet'; import { useMobileActions } from '../../context/MobileActionContext'; interface TasksViewProps { @@ -2016,60 +2017,35 @@ export const TasksView: React.FC = ({ {/* NEW LIST MODAL */} {showNewListModal && ( -
-
-
-

- - Neue Aufgabenliste erstellen -

- + } + onClose={() => setShowNewListModal(false)} + onSubmit={handleCreateTaskList} + > +
+ +
+ setNewListIcon(e.target.value)} + maxLength={2} + className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm" + title="Icon (Emoji)" + /> + setNewListTitle(e.target.value)} + placeholder="z.B. Homelab, Einkäufe, Steuern" + autoFocus + required + className="flex-1 min-w-0 h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs" + /> +
- -
-
- -
- setNewListIcon(e.target.value)} - maxLength={2} - className="w-10 h-8 text-center input-field text-sm" - title="Icon (Emoji)" - /> - setNewListTitle(e.target.value)} - placeholder="z.B. Homelab, Einkäufe, Steuern" - autoFocus - required - className="flex-1 h-8 input-field px-2.5 text-xs" - /> -
-
- -
- - -
-
-
-
+ )} {/* UNIFIED SHARE LIST MODAL */} diff --git a/frontend/src/features/workspace/EmojiPickerModal.tsx b/frontend/src/features/workspace/EmojiPickerModal.tsx index 9646fbc..84b315b 100644 --- a/frontend/src/features/workspace/EmojiPickerModal.tsx +++ b/frontend/src/features/workspace/EmojiPickerModal.tsx @@ -1,37 +1,25 @@ import React, { Suspense, useEffect, useRef, useState } from 'react'; -import { X, Trash2, Loader2, Clock } from 'lucide-react'; +import { X, Trash2, Loader2 } from 'lucide-react'; import type { EmojiClickData } from 'emoji-picker-react'; -import { EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react'; +import { Categories, EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react'; +import { useKeyboardInset } from '../../lib/useKeyboardInset'; // 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 - } -} +// The library's own "recently used" category (stored by it) is the only recent list; a second bar +// would cost height on phones. Category names in German. +const CATEGORIES = [ + { category: Categories.SUGGESTED, name: 'Zuletzt verwendet' }, + { category: Categories.SMILEYS_PEOPLE, name: 'Smileys & Personen' }, + { category: Categories.ANIMALS_NATURE, name: 'Tiere & Natur' }, + { category: Categories.FOOD_DRINK, name: 'Essen & Trinken' }, + { category: Categories.TRAVEL_PLACES, name: 'Reisen & Orte' }, + { category: Categories.ACTIVITIES, name: 'Aktivitäten' }, + { category: Categories.OBJECTS, name: 'Objekte' }, + { category: Categories.SYMBOLS, name: 'Symbole' }, + { category: Categories.FLAGS, name: 'Flaggen' }, +]; interface EmojiPickerModalProps { currentIcon?: string; @@ -44,7 +32,10 @@ export const EmojiPickerModal: React.FC = ({ onSelectEmoji, onClose, }) => { - const [recentEmojis, setRecentEmojis] = useState(getStoredRecentEmojis); + const { inset, visibleHeight } = useKeyboardInset(); + const isPhone = typeof window !== 'undefined' && window.innerWidth < 640; + // On phones the picker shrinks with the keyboard so search field and results stay visible + const pickerHeight = isPhone ? Math.max(200, Math.min(350, visibleHeight - 110)) : 350; const [isDark, setIsDark] = useState(() => typeof document !== 'undefined' ? document.documentElement.classList.contains('dark') @@ -92,8 +83,6 @@ export const EmojiPickerModal: React.FC = ({ }, [onClose]); const handlePick = (emoji: string) => { - saveRecentEmoji(emoji); - setRecentEmojis(getStoredRecentEmojis()); onSelectEmoji(emoji); }; @@ -114,7 +103,7 @@ export const EmojiPickerModal: React.FC = ({
= ({ '--epr-focus-bg-color': 'var(--bg-panel-elevated)', '--epr-highlight-color': 'var(--accent-primary)', '--epr-category-icon-active-color': 'var(--accent-primary)', + '--kb-inset': `${inset}px`, + '--vv-max': `${Math.round(visibleHeight * 0.9)}px`, } as React.CSSProperties } > @@ -170,30 +161,8 @@ export const EmojiPickerModal: React.FC = ({
- {/* Recent Emojis Bar */} - {recentEmojis.length > 0 && ( -
-
- - Zuletzt verwendet: -
-
- {recentEmojis.map((emoji) => ( - - ))} -
-
- )} - {/* Full Emoji Catalog via emoji-picker-react */} -
+
@@ -211,7 +180,8 @@ export const EmojiPickerModal: React.FC = ({ searchPlaceholder="Emoji suchen…" searchPlaceHolder="Emoji suchen…" width="100%" - height="350px" + height={`${pickerHeight}px`} + categories={CATEGORIES as any} previewConfig={{ showPreview: false, }} diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index ad4ff2e..d469d02 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -49,6 +49,7 @@ import { RealtimeTransport, } from '../../lib/api'; import { UnifiedShareModal } from '../../components/UnifiedShareModal'; +import { FormSheet } from '../../components/FormSheet'; import { sendDocOps } from '../../lib/docSync/oneoff'; import { useMobileActions } from '../../context/MobileActionContext'; import { DocumentEditor, DocSaveStatus } from './DocumentEditor'; @@ -340,7 +341,7 @@ export const WorkspaceView: React.FC = ({ if (e) { setNewFolderCoords({ x: Math.min(e.clientX, window.innerWidth - 270), - y: Math.min(e.clientY, window.innerHeight - 130), + y: Math.min(e.clientY, window.innerHeight - 200), }); } else { setNewFolderCoords({ x: 100, y: 150 }); @@ -2196,55 +2197,22 @@ export const WorkspaceView: React.FC = ({ {/* New Folder Popover (small & positioned near click, asks only for name & OK) */} {showNewFolderModal && ( -
setShowNewFolderModal(false)} + } + onClose={() => setShowNewFolderModal(false)} + onSubmit={handleCreateFolderSubmit} + anchor={newFolderCoords} > -
e.stopPropagation()} - style={ - newFolderCoords - ? { top: `${newFolderCoords.y}px`, left: `${newFolderCoords.x}px` } - : undefined - } - className="fixed w-64 bg-panel border border-border rounded-card p-3 shadow-lg space-y-2.5 z-50 text-xs" - > -
- {newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'} - -
- setNewFolderName(e.target.value)} - placeholder="Ordnername" - className="input-field w-full px-2 py-1 text-xs" - /> -
- - -
-
-
+ setNewFolderName(e.target.value)} + placeholder="Ordnername" + className="input-field w-full h-11 sm:h-8 px-2.5 text-base sm:text-xs" + /> + )} {/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */} diff --git a/frontend/src/index.css b/frontend/src/index.css index e55e25e..b2b17b5 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -484,7 +484,10 @@ body { min-height: 44px !important; } - .mindsift-emoji-picker .EmojiPickerReact .epr-category-nav button { - min-height: 44px !important; + /* Category icons are sprite cut-outs: they must keep the library's square size (stretching them showed + two icons and an oval highlight). More breathing room comes from the nav row instead. */ + .mindsift-emoji-picker .EmojiPickerReact .epr-category-nav { + padding-top: 6px !important; + padding-bottom: 6px !important; } } diff --git a/frontend/src/lib/__tests__/useKeyboardInset.test.tsx b/frontend/src/lib/__tests__/useKeyboardInset.test.tsx new file mode 100644 index 0000000..b29de15 --- /dev/null +++ b/frontend/src/lib/__tests__/useKeyboardInset.test.tsx @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { useKeyboardInset } from '../useKeyboardInset'; + +// Simulates iOS: the layout viewport keeps its height, the visual viewport shrinks by the keyboard +function mockVisualViewport(height: number) { + const listeners: Record void)[]> = {}; + const vv = { + height, + offsetTop: 0, + addEventListener: (t: string, fn: () => void) => (listeners[t] = [...(listeners[t] || []), fn]), + removeEventListener: (t: string, fn: () => void) => (listeners[t] = (listeners[t] || []).filter((f) => f !== fn)), + }; + Object.defineProperty(window, 'visualViewport', { value: vv, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: 844, configurable: true }); + return { + open(keyboard: number) { + vv.height = 844 - keyboard; + (listeners.resize || []).forEach((fn) => fn()); + }, + }; +} + +describe('useKeyboardInset', () => { + it('reports the keyboard height so sheets can sit above it', () => { + const vv = mockVisualViewport(844); + const { result } = renderHook(() => useKeyboardInset()); + expect(result.current.inset).toBe(0); + act(() => vv.open(336)); + expect(result.current.inset).toBe(336); + expect(result.current.visibleHeight).toBe(508); + act(() => vv.open(0)); + expect(result.current.inset).toBe(0); + }); +}); diff --git a/frontend/src/lib/useKeyboardInset.ts b/frontend/src/lib/useKeyboardInset.ts new file mode 100644 index 0000000..074f5f6 --- /dev/null +++ b/frontend/src/lib/useKeyboardInset.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; + +// Space the on-screen keyboard takes at the bottom (0 without keyboard) and the height that stays +// visible. Fixed bottom sheets use it to sit right above the keyboard instead of behind it (iOS keeps +// fixed elements on the layout viewport; on Android the inset is usually 0 because the page shrinks). +export function useKeyboardInset(): { inset: number; visibleHeight: number } { + const read = () => { + const vv = typeof window !== 'undefined' ? window.visualViewport : null; + if (!vv) return { inset: 0, visibleHeight: typeof window !== 'undefined' ? window.innerHeight : 0 }; + return { inset: Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)), visibleHeight: Math.round(vv.height) }; + }; + const [state, setState] = useState(read); + + useEffect(() => { + const vv = window.visualViewport; + if (!vv) return; + const update = () => setState(read()); + vv.addEventListener('resize', update); + vv.addEventListener('scroll', update); + update(); + return () => { + vv.removeEventListener('resize', update); + vv.removeEventListener('scroll', update); + }; + }, []); + return state; +}