diff --git a/CHANGELOG.md b/CHANGELOG.md index b41b573..fef9d43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.21.0] - 2026-10-31 + +### Added +- Teilen aus anderen Apps: Geteiltes landet wie bisher sofort im Quick Dump. Die Meldung danach bietet ein paar Sekunden lang **→ Aufgabe** und **→ Liste** an, um es mit einem Tipp stattdessen dort abzulegen (bei nur einer beschreibbaren Liste direkt, sonst mit kurzer Listenauswahl). Wer nichts tippt, behält den gewohnten Ablauf. + ## [0.20.0] - 2026-10-31 ### Changed @@ -445,7 +450,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V - Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken. - Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung. -[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.20.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.21.0...dev +[0.21.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.20.0...v0.21.0 [0.20.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.19.0...v0.20.0 [0.19.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.18.1...v0.19.0 [0.18.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.18.0...v0.18.1 diff --git a/README.md b/README.md index b3401e6..c0eb84d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![Docker](https://img.shields.io/badge/Deployment-Single%20Container-2496ed?style=flat-square&logo=docker&logoColor=white)](#-schnellstart) [![SQLite](https://img.shields.io/badge/Daten-SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](#-deine-daten-gehören-dir) -[![Version](https://img.shields.io/badge/Version-v0.20.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.21.0-16a34a?style=flat-square)](CHANGELOG.md) **MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server. @@ -39,7 +39,7 @@ Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment: Wenn * Eingabeleiste im Kopf der App, jederzeit mit `C` oder `/` fokussierbar; auch per Spracheingabe: über den Browser oder datenschutzfreundlich über einen eigenen Whisper-Container (optional in `docker-compose.yml`, siehe [Architektur](Doku/architecture.md#spracherkennung)); auf Smartphones auch per Gedrückthalten des `+`-Knopfs (Hold-to-Record mit Pegel-Wellen). * Ziel beim Erfassen wählbar: Posteingang, Aufgabe, Listeneintrag oder Notiz. * **Triage-Deck:** `H`/`←` verwerfen, `L`/`→` zur Aufgabe, `T` Aufgabe mit Termin, `D` in Notiz verwandeln, Leertaste = später. Auf dem Smartphone per Wischgeste oder über große Knöpfe unter der Karte; die Warteschlange klappt bei Bedarf auf. -* **Teilen aus anderen Apps:** Als installierte App erscheint MindSift im Teilen-Menü des Smartphones; geteilte Texte und Links landen im Posteingang. +* **Teilen aus anderen Apps:** Als installierte App erscheint MindSift im Teilen-Menü des Smartphones; geteilte Texte und Links landen im Posteingang und lassen sich direkt danach mit einem Tipp zur Aufgabe oder auf eine Liste schicken. * Papierkorb mit Wiederherstellen.

diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cee0a00..cb52abb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.20.0", + "version": "0.21.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.20.0", + "version": "0.21.0", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index c49a278..c118634 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.20.0", + "version": "0.21.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1a0f95a..358a61c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -56,6 +56,7 @@ import { WorkspaceView } from './features/workspace/WorkspaceView'; import { SharedListsView } from './features/lists/SharedListsView'; import { SetupAndAuthView } from './features/auth/SetupAndAuthView'; import { OfflineBanner } from './components/OfflineBanner'; +import { SharedImportToast } from './components/SharedImportToast'; import { outboxCount, clearOfflineData } from './lib/offline'; import { DemoCountdown } from './components/DemoCountdown'; import { UserSettingsView } from './features/settings/UserSettingsView'; @@ -136,6 +137,9 @@ export const App: React.FC = () => { const [demoDeadline, setDemoDeadline] = useState(null); const [dataEpoch, setDataEpoch] = useState(0); const [demoNotice, setDemoNotice] = useState(null); + // Just shared from another app: these Quick Dump entries can still be sent on as task / list item + const [sharedImportIds, setSharedImportIds] = useState(null); + const closeSharedImport = useCallback(() => setSharedImportIds(null), []); const lastDemoResetRef = useRef(0); const handleDemoResetRef = useRef<() => void>(() => {}); // Link from the password reset mail (?resetToken=…); removed from the address bar right away @@ -601,18 +605,25 @@ export const App: React.FC = () => { if (shares.length === 0) return; let importedCount = 0; + const ids: string[] = []; for (const share of shares) { const content = formatSharedThought(share); if (content) { - await api.captureThought(content, 'normal', 'manual', '', { target: 'inbox' }); + const created = await api.captureThought(content, 'normal', 'manual', '', { target: 'inbox' }); importedCount++; + if (created?.id && !created.id.startsWith('th_offline_')) ids.push(created.id); } } if (importedCount > 0) { triggerHaptic(15); - setDemoNotice('Erfolgreich im Quick Dump abgelegt'); - setTimeout(() => setDemoNotice(null), 4000); + if (ids.length === importedCount) { + setSharedImportIds(ids); + } else { + // Offline: kept for later, nothing to convert yet + setDemoNotice('Im Quick Dump abgelegt'); + setTimeout(() => setDemoNotice(null), 4000); + } refreshAll(); } } catch (err) { @@ -1184,6 +1195,19 @@ export const App: React.FC = () => { + {sharedImportIds && ( + { + setSharedImportIds(null); + setDemoNotice(message); + setTimeout(() => setDemoNotice(null), 3000); + refreshAll(); + }} + /> + )} {demoNotice && (

void; + onConverted: (message: string) => void; +} + +export const SharedImportToast: React.FC = ({ thoughtIds, lists, onClose, onConverted }) => { + const [choosingList, setChoosingList] = useState(false); + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState(false); + const writableLists = lists.filter((l) => l.isOwner || l.role !== 'viewer'); + const count = thoughtIds.length; + + // Disappears on its own unless a list is being picked or a conversion runs + useEffect(() => { + if (choosingList || busy) return; + const timer = window.setTimeout(onClose, AUTO_HIDE_MS); + return () => window.clearTimeout(timer); + }, [choosingList, busy, onClose]); + + const convert = async (convertTo: 'task' | 'list_item', list?: SharedList) => { + triggerHaptic(15); + setBusy(true); + setFailed(false); + try { + for (const id of thoughtIds) { + await api.triageAction(id, 'keep', { convertTo, targetListId: list?.id }); + } + const what = count === 1 ? 'Eintrag' : `${count} Einträge`; + onConverted(convertTo === 'task' ? `${count === 1 ? 'Als Aufgabe' : `${count} Aufgaben`} angelegt` : `${what} auf „${list?.title}“`); + } catch { + setFailed(true); + setBusy(false); + } + }; + + const toList = () => { + if (writableLists.length === 1) convert('list_item', writableLists[0]); + else setChoosingList(true); + }; + + const chip = + 'h-9 px-3 inline-flex items-center gap-1.5 rounded-btn border border-border bg-canvas text-main text-xs font-medium active:bg-panel-elevated disabled:opacity-50 touch-manipulation'; + + return ( +
+
+ + + {count === 1 ? 'Im Quick Dump abgelegt' : `${count} Einträge im Quick Dump abgelegt`} + + +
+ + {failed &&

Umwandeln hat nicht geklappt – der Eintrag bleibt im Quick Dump.

} + + {!choosingList ? ( +
+ Stattdessen + + {writableLists.length > 0 && ( + + )} +
+ ) : ( +
+ {writableLists.map((l) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/__tests__/SharedImportToast.test.tsx b/frontend/src/components/__tests__/SharedImportToast.test.tsx new file mode 100644 index 0000000..78a58f3 --- /dev/null +++ b/frontend/src/components/__tests__/SharedImportToast.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { SharedImportToast } from '../SharedImportToast'; +import { api } from '../../lib/api'; +import { SharedList } from '../../lib/types'; + +const list = (id: string, title: string, role: string, isOwner = false): SharedList => ({ + id, title, icon: '🛒', listType: 'shopping', isOwner, role, items: [], + createdAt: '2026-10-31T10:00:00Z', updatedAt: '2026-10-31T10:00:00Z', +}); + +describe('SharedImportToast (after sharing into MindSift)', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('sends every shared entry on as a task', async () => { + const spy = vi.spyOn(api, 'triageAction').mockResolvedValue({} as any); + const onConverted = vi.fn(); + render( {}} onConverted={onConverted} />); + expect(screen.getByText('2 Einträge im Quick Dump abgelegt')).toBeTruthy(); + expect(screen.queryByText('Liste')).toBeNull(); // no writable list, no list button + fireEvent.click(screen.getByText('Aufgabe')); + await waitFor(() => expect(onConverted).toHaveBeenCalledWith('2 Aufgaben angelegt')); + expect(spy).toHaveBeenCalledWith('th_1', 'keep', { convertTo: 'task', targetListId: undefined }); + expect(spy).toHaveBeenCalledWith('th_2', 'keep', { convertTo: 'task', targetListId: undefined }); + }); + + it('puts it straight onto the only writable list', async () => { + const spy = vi.spyOn(api, 'triageAction').mockResolvedValue({} as any); + const onConverted = vi.fn(); + render( + {}} + onConverted={onConverted} + /> + ); + fireEvent.click(screen.getByText('Liste')); + await waitFor(() => expect(onConverted).toHaveBeenCalledWith('Eintrag auf „Wocheneinkauf“')); + expect(spy).toHaveBeenCalledWith('th_1', 'keep', { convertTo: 'list_item', targetListId: 'l1' }); + }); + + it('lets you pick among several lists, read-only ones left out', async () => { + const spy = vi.spyOn(api, 'triageAction').mockResolvedValue({} as any); + render( + {}} + onConverted={() => {}} + /> + ); + fireEvent.click(screen.getByText('Liste')); + expect(screen.queryByText('WG-Archiv')).toBeNull(); + fireEvent.click(screen.getByText('Baumarkt')); + await waitFor(() => expect(spy).toHaveBeenCalledWith('th_1', 'keep', { convertTo: 'list_item', targetListId: 'l2' })); + }); +});