Merge dev into main
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 7s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 7s
Backup as ZIP, no more schema wipe on start, mobile sheets above the keyboard, emoji picker fixes, cleaner mobile header. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
7daa7aaae1
22 files changed
+383
-267
No files matched your search
+1
-1
@@ -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 | `/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 | `/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 | `/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/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.md` |
|
| 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/<Liste>.md` im Format des Tasks-Plugins (`📅`, `⏫`/`🔺`), `Listen/<Liste>.md` |
|
||||||
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
|
| GET/POST | `/themes` | Eigene CSS-Themes; anlegen oder mit `id` ändern (`name`, `css`, max. 200 KB) |
|
||||||
| DELETE | `/themes/{id}` | Eigenes Theme löschen |
|
| DELETE | `/themes/{id}` | Eigenes Theme löschen |
|
||||||
|
|||||||
+1
-1
@@ -80,7 +80,7 @@ teams 1─n team_members n─1 users
|
|||||||
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
- `users.username` wird aus dem E-Mail-Präfix aufgefüllt.
|
||||||
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
- Notiz-Synchronisation (`migrateDocSync`): neue Spalten an `blocks` (inkl. `deleted_at`; vorhandene Grabsteine erhalten den Migrationszeitpunkt) und `documents` (`head_seq`, `title_version`, `log_floor`), Tabellen `doc_ops` und `doc_op_results`. Blöcke im alten flachen Format (nur `level`/`position`) werden einmalig in den Baum umgewandelt.
|
||||||
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
- Standardwerte in `system_settings` (`INSERT OR IGNORE`): `sharing_mode=organizations`, `allow_registration=false`, `instance_name=MindSift`, SMTP leer.
|
||||||
- **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
|
## Sicherung
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.
|
- **Maßnahme:** Fälligkeit mit Uhrzeit speichern, `alarm_sent` bei Änderung von `due_date` zurücksetzen, Wiederholungen begrenzen.
|
||||||
|
|
||||||
### FLAW-13: Migrationen
|
### 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).
|
- **Fundstellen:** `Store.migrate` in [`internal/storage/sqlite.go`](../internal/storage/sqlite.go), [`migrations/001_init.sql`](../migrations/001_init.sql).
|
||||||
- **Probleme:**
|
- **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.
|
- 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.
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ Kontoeinstellungen → Private Webhooks & Export → „Daten importieren“, mi
|
|||||||
|
|
||||||
| Weg | Endpunkt | Dateien | Ergebnis |
|
| 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. |
|
| 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.
|
- **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.
|
||||||
|
|||||||
@@ -39,6 +39,14 @@ Komponente: [ToggleSwitch.tsx](../frontend/src/components/ToggleSwitch.tsx)
|
|||||||
- Ein/Aus-Einstellungen sind immer Schalter, keine Checkbox-Haken (Einrichtung, Admin-Bereich).
|
- 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.
|
- 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
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -716,9 +716,6 @@ export const App: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
<span className="text-[9px] text-muted font-mono leading-tight mt-0.5">{APP_VERSION}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] font-semibold text-accent bg-accent/15 px-1.5 py-0.5 rounded-md">
|
|
||||||
{activeTabLabelMap[activeTab]}
|
|
||||||
</span>
|
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
className={`w-4 h-4 text-muted transition-transform duration-150 ${
|
className={`w-4 h-4 text-muted transition-transform duration-150 ${
|
||||||
isMenuOpen ? 'rotate-180 text-accent' : ''
|
isMenuOpen ? 'rotate-180 text-accent' : ''
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { useKeyboardInset } from '../lib/useKeyboardInset';
|
||||||
|
|
||||||
|
interface FormSheetProps {
|
||||||
|
title: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
onClose: () => 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<FormSheetProps> = ({ 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 (
|
||||||
|
<div className="fixed inset-0 !m-0 z-50 modal-overlay" onClick={onClose}>
|
||||||
|
<form
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
onClick={(e) => 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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-10 h-1 bg-border rounded-full mx-auto sm:hidden" aria-hidden="true" />
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5 min-w-0">
|
||||||
|
{icon}
|
||||||
|
<span className="truncate">{title}</span>
|
||||||
|
</h3>
|
||||||
|
<button type="button" onClick={onClose} className="w-11 h-11 sm:w-7 sm:h-7 -mr-2 sm:mr-0 flex items-center justify-center text-muted hover:text-main" title="Schließen">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
<div className="flex gap-2 pt-1 sm:justify-end">
|
||||||
|
<button type="button" onClick={onClose} className="btn-action flex-1 sm:flex-none h-11 sm:h-8 px-3 text-sm sm:text-xs">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn-primary flex-1 sm:flex-none h-11 sm:h-8 px-4 text-sm sm:text-xs font-semibold">
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* keeps the buttons clear of the home indicator when no keyboard is open */}
|
||||||
|
<div className="sm:hidden" style={{ height: inset ? 0 : 'env(safe-area-inset-bottom, 0px)' }} />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -15,6 +15,7 @@ import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, Realti
|
|||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
|
|
||||||
interface SharedListsViewProps {
|
interface SharedListsViewProps {
|
||||||
@@ -1017,61 +1018,35 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showNewListModal && (
|
{showNewListModal && (
|
||||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4">
|
<FormSheet
|
||||||
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
|
title="Neue Einkaufsliste erstellen"
|
||||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
icon={<ShoppingCart className="w-4 h-4 text-accent" />}
|
||||||
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
|
onClose={() => setShowNewListModal(false)}
|
||||||
<ShoppingCart className="w-4 h-4 text-accent" />
|
onSubmit={handleCreateList}
|
||||||
<span>Neue Einkaufsliste erstellen</span>
|
>
|
||||||
</h3>
|
<div>
|
||||||
<button
|
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||||
type="button"
|
<div className="flex items-center gap-1.5">
|
||||||
onClick={() => setShowNewListModal(false)}
|
<input
|
||||||
className="text-muted hover:text-main"
|
type="text"
|
||||||
>
|
value={newListIcon}
|
||||||
<X className="w-4 h-4" />
|
onChange={(e) => setNewListIcon(e.target.value)}
|
||||||
</button>
|
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)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newListTitle}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</FormSheet>
|
||||||
<form onSubmit={handleCreateList} className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newListIcon}
|
|
||||||
onChange={(e) => setNewListIcon(e.target.value)}
|
|
||||||
maxLength={2}
|
|
||||||
className="w-10 h-8 text-center input-field text-sm"
|
|
||||||
title="Icon (Emoji)"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newListTitle}
|
|
||||||
onChange={(e) => setNewListTitle(e.target.value)}
|
|
||||||
placeholder="z.B. Supermarkt, Baumarkt, Drogerie"
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
className="flex-1 h-8 input-field px-2.5 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewListModal(false)}
|
|
||||||
className="btn-action h-7 px-3 text-xs"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn-primary h-7 px-3 text-xs">
|
|
||||||
Erstellen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active Shared List View */}
|
{/* Active Shared List View */}
|
||||||
|
|||||||
@@ -375,12 +375,16 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
|
window.setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const [exportingBackup, setExportingBackup] = useState(false);
|
||||||
const handleExportData = async () => {
|
const handleExportData = async () => {
|
||||||
const data = await api.exportAll();
|
setExportingBackup(true);
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
try {
|
||||||
type: 'application/json;charset=utf-8',
|
saveBlob(await api.exportBackupZip(), `mindsift-backup-${new Date().toISOString().slice(0, 10)}.zip`);
|
||||||
});
|
} catch (err: any) {
|
||||||
saveBlob(blob, `mindsift-backup-${new Date().toISOString().slice(0, 10)}.json`);
|
setVaultError(err.message || 'Backup fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setExportingBackup(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [exportingVault, setExportingVault] = useState(false);
|
const [exportingVault, setExportingVault] = useState(false);
|
||||||
@@ -1238,18 +1242,19 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
|
|||||||
{/* Full Data Backup */}
|
{/* Full Data Backup */}
|
||||||
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
<div className="pt-4 border-t border-border flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-main">Vollständiger Daten-Export (JSON Backup)</h3>
|
<h3 className="font-bold text-main">Vollständiges Backup (.zip)</h3>
|
||||||
<p className="text-muted text-[11px]">
|
<p className="text-muted text-[11px]">
|
||||||
Lädt alle deine Gedanken, Aufgaben, Markdown-Dokumente und Listen als portable
|
Alle deine Gedanken, Aufgaben, Notizen und Listen als JSON, gepackt in eine ZIP-Datei. Über
|
||||||
JSON-Datei herunter.
|
„MindSift-Daten importieren“ lässt es sich vollständig zurückspielen.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleExportData}
|
onClick={handleExportData}
|
||||||
className="btn-action h-9 px-4 text-xs flex items-center gap-2 flex-shrink-0"
|
disabled={exportingBackup}
|
||||||
|
className="btn-action h-11 sm:h-9 px-4 text-xs flex items-center justify-center gap-2 flex-shrink-0 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5 text-accent" />
|
<Download className="w-3.5 h-3.5 text-accent" />
|
||||||
<span>Backup herunterladen</span>
|
<span>{exportingBackup ? 'Wird erstellt…' : 'Backup herunterladen'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { TaskDetailModal } from './TaskDetailModal';
|
|||||||
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS } from './ManageColumnsModal';
|
||||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
|
|
||||||
interface TasksViewProps {
|
interface TasksViewProps {
|
||||||
@@ -2016,60 +2017,35 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
|
|
||||||
{/* NEW LIST MODAL */}
|
{/* NEW LIST MODAL */}
|
||||||
{showNewListModal && (
|
{showNewListModal && (
|
||||||
<div className="fixed inset-0 !m-0 modal-overlay z-50 flex items-center justify-center p-4">
|
<FormSheet
|
||||||
<div className="w-full max-w-sm panel-surface shadow-panel p-4 space-y-3">
|
title="Neue Aufgabenliste erstellen"
|
||||||
<div className="flex items-center justify-between border-b border-border pb-2">
|
icon={<Layers className="w-4 h-4 text-accent" />}
|
||||||
<h3 className="text-sm font-semibold text-main flex items-center gap-1.5">
|
onClose={() => setShowNewListModal(false)}
|
||||||
<Layers className="w-4 h-4 text-accent" />
|
onSubmit={handleCreateTaskList}
|
||||||
<span>Neue Aufgabenliste erstellen</span>
|
>
|
||||||
</h3>
|
<div>
|
||||||
<button
|
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||||
onClick={() => setShowNewListModal(false)}
|
<div className="flex items-center gap-1.5">
|
||||||
className="text-muted hover:text-main"
|
<input
|
||||||
>
|
type="text"
|
||||||
<X className="w-4 h-4" />
|
value={newListIcon}
|
||||||
</button>
|
onChange={(e) => 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)"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newListTitle}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</FormSheet>
|
||||||
<form onSubmit={handleCreateTaskList} className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newListIcon}
|
|
||||||
onChange={(e) => setNewListIcon(e.target.value)}
|
|
||||||
maxLength={2}
|
|
||||||
className="w-10 h-8 text-center input-field text-sm"
|
|
||||||
title="Icon (Emoji)"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newListTitle}
|
|
||||||
onChange={(e) => 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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewListModal(false)}
|
|
||||||
className="btn-action h-7 px-3 text-xs"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button type="submit" className="btn-primary h-7 px-3 text-xs">
|
|
||||||
Erstellen
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* UNIFIED SHARE LIST MODAL */}
|
{/* UNIFIED SHARE LIST MODAL */}
|
||||||
|
|||||||
@@ -1,37 +1,25 @@
|
|||||||
import React, { Suspense, useEffect, useRef, useState } from 'react';
|
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 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
|
// Lazy-load emoji-picker-react so the main bundle size stays small
|
||||||
const EmojiPicker = React.lazy(() => import('emoji-picker-react'));
|
const EmojiPicker = React.lazy(() => import('emoji-picker-react'));
|
||||||
|
|
||||||
const RECENT_EMOJIS_STORAGE_KEY = 'mindsift_recent_emojis';
|
// The library's own "recently used" category (stored by it) is the only recent list; a second bar
|
||||||
const MAX_RECENT_EMOJIS = 10;
|
// would cost height on phones. Category names in German.
|
||||||
|
const CATEGORIES = [
|
||||||
function getStoredRecentEmojis(): string[] {
|
{ category: Categories.SUGGESTED, name: 'Zuletzt verwendet' },
|
||||||
try {
|
{ category: Categories.SMILEYS_PEOPLE, name: 'Smileys & Personen' },
|
||||||
const raw = localStorage.getItem(RECENT_EMOJIS_STORAGE_KEY);
|
{ category: Categories.ANIMALS_NATURE, name: 'Tiere & Natur' },
|
||||||
if (!raw) return ['📝', '💡', '🚀', '⭐', '🔥', '🎯', '📚', '💻'];
|
{ category: Categories.FOOD_DRINK, name: 'Essen & Trinken' },
|
||||||
const parsed = JSON.parse(raw);
|
{ category: Categories.TRAVEL_PLACES, name: 'Reisen & Orte' },
|
||||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
{ category: Categories.ACTIVITIES, name: 'Aktivitäten' },
|
||||||
return parsed.slice(0, MAX_RECENT_EMOJIS);
|
{ category: Categories.OBJECTS, name: 'Objekte' },
|
||||||
}
|
{ category: Categories.SYMBOLS, name: 'Symbole' },
|
||||||
} catch {
|
{ category: Categories.FLAGS, name: 'Flaggen' },
|
||||||
// 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 {
|
interface EmojiPickerModalProps {
|
||||||
currentIcon?: string;
|
currentIcon?: string;
|
||||||
@@ -44,7 +32,10 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
onSelectEmoji,
|
onSelectEmoji,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const [recentEmojis, setRecentEmojis] = useState<string[]>(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<boolean>(() =>
|
const [isDark, setIsDark] = useState<boolean>(() =>
|
||||||
typeof document !== 'undefined'
|
typeof document !== 'undefined'
|
||||||
? document.documentElement.classList.contains('dark')
|
? document.documentElement.classList.contains('dark')
|
||||||
@@ -92,8 +83,6 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
const handlePick = (emoji: string) => {
|
const handlePick = (emoji: string) => {
|
||||||
saveRecentEmoji(emoji);
|
|
||||||
setRecentEmojis(getStoredRecentEmojis());
|
|
||||||
onSelectEmoji(emoji);
|
onSelectEmoji(emoji);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -114,7 +103,7 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="
|
className="
|
||||||
fixed inset-x-0 bottom-0 z-50 rounded-t-2xl max-h-[85vh] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3
|
fixed inset-x-0 bottom-[var(--kb-inset)] z-50 rounded-t-2xl max-h-[var(--vv-max)] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3
|
||||||
sm:absolute sm:inset-auto sm:left-0 sm:top-full sm:mt-1 sm:z-40 sm:w-[360px] sm:max-h-[520px] sm:rounded-card sm:border sm:border-border sm:shadow-xl
|
sm:absolute sm:inset-auto sm:left-0 sm:top-full sm:mt-1 sm:z-40 sm:w-[360px] sm:max-h-[520px] sm:rounded-card sm:border sm:border-border sm:shadow-xl
|
||||||
"
|
"
|
||||||
style={
|
style={
|
||||||
@@ -130,6 +119,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
'--epr-focus-bg-color': 'var(--bg-panel-elevated)',
|
'--epr-focus-bg-color': 'var(--bg-panel-elevated)',
|
||||||
'--epr-highlight-color': 'var(--accent-primary)',
|
'--epr-highlight-color': 'var(--accent-primary)',
|
||||||
'--epr-category-icon-active-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
|
} as React.CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -170,30 +161,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Emojis Bar */}
|
|
||||||
{recentEmojis.length > 0 && (
|
|
||||||
<div className="shrink-0 space-y-1.5">
|
|
||||||
<div className="flex items-center gap-1 text-[11px] text-muted">
|
|
||||||
<Clock className="w-3 h-3 text-accent" />
|
|
||||||
<span>Zuletzt verwendet:</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 no-scrollbar">
|
|
||||||
{recentEmojis.map((emoji) => (
|
|
||||||
<button
|
|
||||||
key={emoji}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePick(emoji)}
|
|
||||||
className="min-w-[44px] min-h-[44px] sm:min-w-[32px] sm:min-h-[32px] rounded-btn bg-panel-elevated hover:bg-canvas border border-border flex items-center justify-center text-xl sm:text-base cursor-pointer transition-transform active:scale-95 shrink-0"
|
|
||||||
>
|
|
||||||
{emoji}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Full Emoji Catalog via emoji-picker-react */}
|
{/* Full Emoji Catalog via emoji-picker-react */}
|
||||||
<div className="flex-1 min-h-[300px] overflow-hidden rounded mindsift-emoji-picker">
|
<div className="flex-1 min-h-0 overflow-hidden rounded mindsift-emoji-picker">
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="h-64 flex flex-col items-center justify-center gap-2 text-xs text-muted">
|
<div className="h-64 flex flex-col items-center justify-center gap-2 text-xs text-muted">
|
||||||
@@ -211,7 +180,8 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
searchPlaceholder="Emoji suchen…"
|
searchPlaceholder="Emoji suchen…"
|
||||||
searchPlaceHolder="Emoji suchen…"
|
searchPlaceHolder="Emoji suchen…"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="350px"
|
height={`${pickerHeight}px`}
|
||||||
|
categories={CATEGORIES as any}
|
||||||
previewConfig={{
|
previewConfig={{
|
||||||
showPreview: false,
|
showPreview: false,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
RealtimeTransport,
|
RealtimeTransport,
|
||||||
} from '../../lib/api';
|
} from '../../lib/api';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
import { sendDocOps } from '../../lib/docSync/oneoff';
|
import { sendDocOps } from '../../lib/docSync/oneoff';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
||||||
@@ -340,7 +341,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
if (e) {
|
if (e) {
|
||||||
setNewFolderCoords({
|
setNewFolderCoords({
|
||||||
x: Math.min(e.clientX, window.innerWidth - 270),
|
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 {
|
} else {
|
||||||
setNewFolderCoords({ x: 100, y: 150 });
|
setNewFolderCoords({ x: 100, y: 150 });
|
||||||
@@ -2196,55 +2197,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
|
|
||||||
{/* New Folder Popover (small & positioned near click, asks only for name & OK) */}
|
{/* New Folder Popover (small & positioned near click, asks only for name & OK) */}
|
||||||
{showNewFolderModal && (
|
{showNewFolderModal && (
|
||||||
<div
|
<FormSheet
|
||||||
className="fixed inset-0 z-50 bg-black/20"
|
title={newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'}
|
||||||
onClick={() => setShowNewFolderModal(false)}
|
icon={<FolderPlus className="w-4 h-4 text-accent" />}
|
||||||
|
onClose={() => setShowNewFolderModal(false)}
|
||||||
|
onSubmit={handleCreateFolderSubmit}
|
||||||
|
anchor={newFolderCoords}
|
||||||
>
|
>
|
||||||
<form
|
<input
|
||||||
onSubmit={handleCreateFolderSubmit}
|
type="text"
|
||||||
onClick={(e) => e.stopPropagation()}
|
autoFocus
|
||||||
style={
|
value={newFolderName}
|
||||||
newFolderCoords
|
onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
? { top: `${newFolderCoords.y}px`, left: `${newFolderCoords.x}px` }
|
placeholder="Ordnername"
|
||||||
: undefined
|
className="input-field w-full h-11 sm:h-8 px-2.5 text-base sm:text-xs"
|
||||||
}
|
/>
|
||||||
className="fixed w-64 bg-panel border border-border rounded-card p-3 shadow-lg space-y-2.5 z-50 text-xs"
|
</FormSheet>
|
||||||
>
|
|
||||||
<div className="font-semibold text-main text-xs flex items-center justify-between">
|
|
||||||
<span className="truncate">{newFolderParentPath ? `Unterordner in "${newFolderParentPath}"` : 'Neuer Ordner'}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewFolderModal(false)}
|
|
||||||
className="text-faint hover:text-main cursor-pointer"
|
|
||||||
>
|
|
||||||
<X className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
autoFocus
|
|
||||||
value={newFolderName}
|
|
||||||
onChange={(e) => setNewFolderName(e.target.value)}
|
|
||||||
placeholder="Ordnername"
|
|
||||||
className="input-field w-full px-2 py-1 text-xs"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end gap-1.5 pt-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewFolderModal(false)}
|
|
||||||
className="btn-action px-2.5 py-1 text-xs"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="btn-primary px-3 py-1 text-xs font-semibold"
|
|
||||||
>
|
|
||||||
OK
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
|
{/* Unified Sharing Modal (Strictly Read-Only for Docs and Folders) */}
|
||||||
|
|||||||
@@ -484,7 +484,10 @@ body {
|
|||||||
min-height: 44px !important;
|
min-height: 44px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mindsift-emoji-picker .EmojiPickerReact .epr-category-nav button {
|
/* Category icons are sprite cut-outs: they must keep the library's square size (stretching them showed
|
||||||
min-height: 44px !important;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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<string, (() => 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+14
-7
@@ -135,6 +135,14 @@ export const setCurrentSharingMode = (mode: SharingMode) => {
|
|||||||
};
|
};
|
||||||
export const isSharingDisabled = () => currentSharingMode === 'private';
|
export const isSharingDisabled = () => currentSharingMode === 'private';
|
||||||
|
|
||||||
|
// Authenticated binary download (archives); no JSON parsing and no request timeout
|
||||||
|
async function downloadBlob(path: string): Promise<Blob> {
|
||||||
|
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 = {
|
export const api = {
|
||||||
// --- AUTH, FIRST-BOOT SETUP & SETTINGS ---
|
// --- AUTH, FIRST-BOOT SETUP & SETTINGS ---
|
||||||
async getAuthStatus(): Promise<AuthStatus> {
|
async getAuthStatus(): Promise<AuthStatus> {
|
||||||
@@ -1093,15 +1101,14 @@ export const api = {
|
|||||||
return data as ImportSummary;
|
return data as ImportSummary;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Full backup (all data as JSON) packed into a ZIP; restorable via importData('mindsift', …)
|
||||||
|
async exportBackupZip(): Promise<Blob> {
|
||||||
|
return downloadBlob('/api/v1/export?format=zip');
|
||||||
|
},
|
||||||
|
|
||||||
// Obsidian-compatible ZIP of the workspace; binary and possibly large, so no JSON helper and no timeout
|
// Obsidian-compatible ZIP of the workspace; binary and possibly large, so no JSON helper and no timeout
|
||||||
async exportMarkdownVault(): Promise<Blob> {
|
async exportMarkdownVault(): Promise<Blob> {
|
||||||
const token = getToken();
|
return downloadBlob('/api/v1/export/markdown');
|
||||||
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();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+10
-6
@@ -2159,12 +2159,7 @@ func (s *Server) handleImportMindSift(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.HasSuffix(strings.ToLower(name), ".json") || (len(data) > 0 && data[0] == '{') {
|
plan, err := storage.PlanMindSiftImport(data, name)
|
||||||
plan, err := storage.PlanMindSiftJSONImport(data)
|
|
||||||
s.runImport(w, r, plan, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
plan, err := storage.PlanMindSiftArchiveImport(bytes.NewReader(data), int64(len(data)))
|
|
||||||
s.runImport(w, r, plan, err)
|
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())
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
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")
|
w.Header().Set("Content-Disposition", "attachment; filename=mindsift-export.json")
|
||||||
writeJSON(w, http.StatusOK, exportData)
|
writeJSON(w, http.StatusOK, exportData)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1369,7 +1369,8 @@ func TestFirstBootSetupWithSampleData(t *testing.T) {
|
|||||||
|
|
||||||
// Impressum and Datenschutz must be reachable without login; unset texts stay hidden.
|
// Impressum and Datenschutz must be reachable without login; unset texts stay hidden.
|
||||||
func TestLegalTextsArePublic(t *testing.T) {
|
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)
|
store, err := storage.NewStore(dbPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewStore: %v", err)
|
t.Fatalf("NewStore: %v", err)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,6 +11,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/sandrodach/mindsift/internal/domain"
|
"github.com/sandrodach/mindsift/internal/domain"
|
||||||
@@ -300,6 +302,63 @@ func PlanMindSiftArchiveImport(r io.ReaderAt, size int64) (*ImportPlan, error) {
|
|||||||
return plan, nil
|
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
|
// 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.
|
// exporting user belong to someone else and are skipped; discarded thoughts are not restored.
|
||||||
func PlanMindSiftJSONImport(data []byte) (*ImportPlan, error) {
|
func PlanMindSiftJSONImport(data []byte) (*ImportPlan, error) {
|
||||||
|
|||||||
@@ -197,3 +197,25 @@ func TestImportObsidianVault(t *testing.T) {
|
|||||||
t.Errorf("invalid archive must be rejected")
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -58,22 +57,11 @@ func (s *Store) Close() error {
|
|||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// schemaVersion is stored in PRAGMA user_version. Databases from an older version are wiped and rebuilt
|
// schemaVersion is stored in PRAGMA user_version so later releases can tell which migrations a database
|
||||||
// on start: MindSift is not in production yet, so breaking changes need no migration path.
|
// already has. (Version 2 once wiped older databases on start; all installations are past that now.)
|
||||||
const schemaVersion = 2
|
const schemaVersion = 2
|
||||||
|
|
||||||
func (s *Store) resetOutdatedSchema() error {
|
// dropAllTables removes every table; used by the demo mode reset.
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) dropAllTables() error {
|
func (s *Store) dropAllTables() error {
|
||||||
conn, err := s.db.Conn(context.Background())
|
conn, err := s.db.Conn(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -107,9 +95,6 @@ func (s *Store) dropAllTables() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) migrate() error {
|
func (s *Store) migrate() error {
|
||||||
if err := s.resetOutdatedSchema(); err != nil {
|
|
||||||
return fmt.Errorf("reset outdated schema: %w", err)
|
|
||||||
}
|
|
||||||
schema := `
|
schema := `
|
||||||
PRAGMA journal_mode = WAL;
|
PRAGMA journal_mode = WAL;
|
||||||
PRAGMA synchronous = NORMAL;
|
PRAGMA synchronous = NORMAL;
|
||||||
|
|||||||
@@ -144,8 +144,9 @@ func TestVisibilityFollowsOrganizations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOutdatedSchemaIsWiped(t *testing.T) {
|
// Existing data must survive a restart, whatever user_version the database carries.
|
||||||
path := filepath.Join(t.TempDir(), "old.db")
|
func TestDataSurvivesRestart(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "keep.db")
|
||||||
store, err := NewStore(path)
|
store, err := NewStore(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewStore failed: %v", err)
|
t.Fatalf("NewStore failed: %v", err)
|
||||||
@@ -159,8 +160,8 @@ func TestOutdatedSchemaIsWiped(t *testing.T) {
|
|||||||
t.Fatalf("reopen failed: %v", err)
|
t.Fatalf("reopen failed: %v", err)
|
||||||
}
|
}
|
||||||
defer store.Close()
|
defer store.Close()
|
||||||
if n, _ := store.CountUsers(); n != 0 {
|
if n, _ := store.CountUsers(); n != 1 {
|
||||||
t.Fatalf("outdated database should start empty, found %d users", n)
|
t.Fatalf("data must be kept on restart, found %d users", n)
|
||||||
}
|
}
|
||||||
var v int
|
var v int
|
||||||
_ = store.db.QueryRow(`PRAGMA user_version`).Scan(&v)
|
_ = store.db.QueryRow(`PRAGMA user_version`).Scan(&v)
|
||||||
|
|||||||
Reference in new issue
Block a user