feat(share): after sharing, send the entry on as task or list item with one tap
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m30s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-31 11:27:06 +01:00
1 parent 3d367ce633
commit da258fdce2
7 files changed
+200 -9

No files matched your search

+7 -1
View File
@@ -6,6 +6,11 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [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 ## [0.20.0] - 2026-10-31
### Changed ### 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. - 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. - 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.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.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 [0.18.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.18.0...v0.18.1
+2 -2
View File
@@ -6,7 +6,7 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![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) [![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) [![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. **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). * 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. * 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. * **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. * Papierkorb mit Wiederherstellen.
<p align="center"> <p align="center">
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.20.0", "version": "0.21.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.20.0", "version": "0.21.0",
"license": "Unlicense", "license": "Unlicense",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "5.3.0", "@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"private": true, "private": true,
"version": "0.20.0", "version": "0.21.0",
"license": "Unlicense", "license": "Unlicense",
"type": "module", "type": "module",
"scripts": { "scripts": {
+27 -3
View File
@@ -56,6 +56,7 @@ import { WorkspaceView } from './features/workspace/WorkspaceView';
import { SharedListsView } from './features/lists/SharedListsView'; import { SharedListsView } from './features/lists/SharedListsView';
import { SetupAndAuthView } from './features/auth/SetupAndAuthView'; import { SetupAndAuthView } from './features/auth/SetupAndAuthView';
import { OfflineBanner } from './components/OfflineBanner'; import { OfflineBanner } from './components/OfflineBanner';
import { SharedImportToast } from './components/SharedImportToast';
import { outboxCount, clearOfflineData } from './lib/offline'; import { outboxCount, clearOfflineData } from './lib/offline';
import { DemoCountdown } from './components/DemoCountdown'; import { DemoCountdown } from './components/DemoCountdown';
import { UserSettingsView } from './features/settings/UserSettingsView'; import { UserSettingsView } from './features/settings/UserSettingsView';
@@ -136,6 +137,9 @@ export const App: React.FC = () => {
const [demoDeadline, setDemoDeadline] = useState<number | null>(null); const [demoDeadline, setDemoDeadline] = useState<number | null>(null);
const [dataEpoch, setDataEpoch] = useState(0); const [dataEpoch, setDataEpoch] = useState(0);
const [demoNotice, setDemoNotice] = useState<string | null>(null); const [demoNotice, setDemoNotice] = useState<string | null>(null);
// Just shared from another app: these Quick Dump entries can still be sent on as task / list item
const [sharedImportIds, setSharedImportIds] = useState<string[] | null>(null);
const closeSharedImport = useCallback(() => setSharedImportIds(null), []);
const lastDemoResetRef = useRef(0); const lastDemoResetRef = useRef(0);
const handleDemoResetRef = useRef<() => void>(() => {}); const handleDemoResetRef = useRef<() => void>(() => {});
// Link from the password reset mail (?resetToken=…); removed from the address bar right away // 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; if (shares.length === 0) return;
let importedCount = 0; let importedCount = 0;
const ids: string[] = [];
for (const share of shares) { for (const share of shares) {
const content = formatSharedThought(share); const content = formatSharedThought(share);
if (content) { if (content) {
await api.captureThought(content, 'normal', 'manual', '', { target: 'inbox' }); const created = await api.captureThought(content, 'normal', 'manual', '', { target: 'inbox' });
importedCount++; importedCount++;
if (created?.id && !created.id.startsWith('th_offline_')) ids.push(created.id);
} }
} }
if (importedCount > 0) { if (importedCount > 0) {
triggerHaptic(15); triggerHaptic(15);
setDemoNotice('Erfolgreich im Quick Dump abgelegt'); if (ids.length === importedCount) {
setTimeout(() => setDemoNotice(null), 4000); setSharedImportIds(ids);
} else {
// Offline: kept for later, nothing to convert yet
setDemoNotice('Im Quick Dump abgelegt');
setTimeout(() => setDemoNotice(null), 4000);
}
refreshAll(); refreshAll();
} }
} catch (err) { } catch (err) {
@@ -1184,6 +1195,19 @@ export const App: React.FC = () => {
<MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} /> <MobileHeaderMenu activeTabLabel={activeTabLabelMap[activeTab]} />
<OfflineBanner /> <OfflineBanner />
{sharedImportIds && (
<SharedImportToast
thoughtIds={sharedImportIds}
lists={lists}
onClose={closeSharedImport}
onConverted={(message) => {
setSharedImportIds(null);
setDemoNotice(message);
setTimeout(() => setDemoNotice(null), 3000);
refreshAll();
}}
/>
)}
{demoNotice && ( {demoNotice && (
<div <div
role="status" role="status"
@@ -0,0 +1,100 @@
import React, { useEffect, useState } from 'react';
import { ArrowRight, CheckSquare, Inbox, ShoppingCart, X } from 'lucide-react';
import { api, triggerHaptic } from '../lib/api';
import type { SharedList } from '../lib/types';
// After sharing from another app: the items are already in the Quick Dump (nothing to tap), and for a
// few seconds they can be sent on as a task or onto a list instead – one tap, the flow stays as fast.
const AUTO_HIDE_MS = 8000;
interface SharedImportToastProps {
thoughtIds: string[]; // created on the server (offline captures have no id yet and get no buttons)
lists: SharedList[];
onClose: () => void;
onConverted: (message: string) => void;
}
export const SharedImportToast: React.FC<SharedImportToastProps> = ({ 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 (
<div
role="status"
className="fixed left-1/2 -translate-x-1/2 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] sm:bottom-4 z-[60] w-[calc(100vw-1.5rem)] max-w-sm panel-surface shadow-panel overflow-hidden p-3 space-y-2.5 animate-in fade-in slide-in-from-bottom-2 duration-200"
>
<div className="flex items-center gap-2 text-sm text-main">
<Inbox className="w-4 h-4 text-accent shrink-0" />
<span className="flex-1 font-medium">
{count === 1 ? 'Im Quick Dump abgelegt' : `${count} Einträge im Quick Dump abgelegt`}
</span>
<button type="button" onClick={onClose} aria-label="Schließen" className="h-7 w-7 -mr-1 flex items-center justify-center text-faint">
<X className="w-4 h-4" />
</button>
</div>
{failed && <p className="text-xs text-status-err">Umwandeln hat nicht geklappt – der Eintrag bleibt im Quick Dump.</p>}
{!choosingList ? (
<div className="flex items-center gap-2">
<span className="text-xs text-muted">Stattdessen</span>
<button type="button" className={chip} disabled={busy} onClick={() => convert('task')}>
<ArrowRight className="w-3.5 h-3.5 text-faint" />
<CheckSquare className="w-3.5 h-3.5 text-accent" />
Aufgabe
</button>
{writableLists.length > 0 && (
<button type="button" className={chip} disabled={busy} onClick={toList}>
<ArrowRight className="w-3.5 h-3.5 text-faint" />
<ShoppingCart className="w-3.5 h-3.5 text-accent" />
Liste
</button>
)}
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
{writableLists.map((l) => (
<button key={l.id} type="button" className={chip} disabled={busy} onClick={() => convert('list_item', l)}>
<span>{l.icon}</span>
{l.title}
</button>
))}
</div>
)}
</div>
);
};
@@ -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(<SharedImportToast thoughtIds={['th_1', 'th_2']} lists={[]} onClose={() => {}} 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(
<SharedImportToast
thoughtIds={['th_1']}
lists={[list('l1', 'Wocheneinkauf', 'owner', true), list('l2', 'WG-Archiv', 'viewer')]}
onClose={() => {}}
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(
<SharedImportToast
thoughtIds={['th_1']}
lists={[list('l1', 'Wocheneinkauf', 'owner', true), list('l2', 'Baumarkt', 'editor'), list('l3', 'WG-Archiv', 'viewer')]}
onClose={() => {}}
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' }));
});
});