Merge dev into main (v0.37.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 27s

This commit is contained in:
sandrodach committed 2026-11-01 12:40:06 +01:00
commit 34b5b4432f
6 files changed
+205 -26

No files matched your search

+8 -1
View File
@@ -6,6 +6,12 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased]
## [0.37.0] - 2026-11-01
### Added
- Workspace: Mehrere Notizen auf einmal verschieben oder löschen. Am PC markierst du mit Strg/Cmd+Klick einzelne Notizen und mit Shift+Klick einen Bereich; ziehst du eine markierte Notiz in einen Ordner oder auf den Papierkorb, kommen alle markierten mit. Escape hebt die Markierung auf.
- Der Papierkorb unten in der Seitenleiste nimmt gezogene Notizen an, auch einzeln und am Handy (lange drücken, dann ziehen). Der Hinweis danach bietet „Rückgängig“.
## [0.36.0] - 2026-11-01
### Added
@@ -611,7 +617,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.36.0...dev
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.0...dev
[0.37.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.36.0...v0.37.0
[0.36.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.35.0...v0.36.0
[0.35.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.34.0...v0.35.0
[0.34.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.33.0...v0.34.0
+2
View File
@@ -41,6 +41,8 @@ Code: [WorkspaceView.tsx](../../frontend/src/features/workspace/WorkspaceView.ts
## Explorer, Tags, To-Dos
- Ordnerbaum mit Favoriten (`doc_pins`), Verschieben per Drag & Drop (`/documents/{id}/move`, `/folders/move`).
- **Mehrere Notizen** (PC): Strg/Cmd+Klick markiert einzeln, Shift+Klick einen Bereich in der angezeigten Reihenfolge; ein normaler Klick, Escape oder ein Klick ins Leere hebt die Markierung auf. Wer eine markierte Notiz zieht, nimmt alle markierten mit, in einen Ordner oder auf den Papierkorb (Zusammenführen gibt es dabei nicht).
- **Auf den Papierkorb ziehen** (auch eine einzelne Notiz, am Handy per langem Drücken wie beim Verschieben in Ordner) verschiebt in den Papierkorb; der Hinweis bietet „Rückgängig“.
- **Tags:** Alle `#Hashtags` werden aggregiert (`/workspace/tags`). Eine Tag-Seite zeigt alle Fundstellen gruppiert nach Notiz und kann eine eigene Freitext-Notiz tragen (`tag_pages`).
- **To-Do-Rollup:** Die Ansicht „To-Dos“ sammelt alle `todo`-Blöcke aller Notizen, filterbar nach Ordner. Abhaken im Rollup ändert den Block in der Notiz.
- **Enhanced Tasks:** Ein `todo`-Block lässt sich als verknüpfte Aufgabe führen (`/documents/{id}/blocks/{blockId}/enhanced-task`); Erledigt-Status bleibt zwischen Notiz und Aufgabenansicht synchron.
+1 -1
View File
@@ -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.36.0-16a34a?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/Version-v0.37.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.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.36.0",
"version": "0.37.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.36.0",
"version": "0.37.0",
"license": "Unlicense",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.36.0",
"version": "0.37.0",
"license": "Unlicense",
"type": "module",
"scripts": {
+191 -21
View File
@@ -99,6 +99,9 @@ interface FolderTreeItemProps {
// Dropping a note onto another one offers to merge them
docDropProps: (targetId: string) => React.HTMLAttributes<HTMLDivElement>;
mergeHoverId: string | null;
// Several notes marked with Ctrl/Cmd or Shift; a plain click opens the note
markedDocIds: Set<string>;
onDocRowClick: (e: React.MouseEvent, id: string, open: () => void) => void;
}
const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
@@ -120,6 +123,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
onSelectDoc,
docDropProps,
mergeHoverId,
markedDocIds,
onDocRowClick,
}) => {
const hasContent = node.subfolders.length > 0 || node.docs.length > 0;
const isExpanded = Boolean(hasContent && expandedFolders[node.fullPath]);
@@ -250,6 +255,8 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
onSelectDoc={onSelectDoc}
docDropProps={docDropProps}
mergeHoverId={mergeHoverId}
markedDocIds={markedDocIds}
onDocRowClick={onDocRowClick}
/>
))}
@@ -258,13 +265,17 @@ const FolderTreeItem: React.FC<FolderTreeItemProps> = ({
<div
key={d.id}
draggable
data-doc-row={d.id}
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => onSelectDoc(d.id)}
onMouseDown={(e) => e.shiftKey && e.preventDefault()}
onClick={(e) => onDocRowClick(e, d.id, () => onSelectDoc(d.id))}
style={{ paddingLeft: `${(depth + 1) * 12 + 16}px` }}
className={`w-full flex items-center gap-2 pr-2 py-1.5 min-h-11 sm:min-h-0 rounded-btn text-left text-sm sm:text-xs cursor-pointer border transition-colors ${
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: markedDocIds.has(d.id)
? 'border-accent/40 bg-accent/10 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-transparent'
: 'border-transparent text-main hover:bg-panel-elevated'
@@ -438,6 +449,12 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
// Drag and Drop state
const [draggingDocId, setDraggingDocId] = useState<string | null>(null);
// Marked notes (Ctrl/Cmd+click, Shift+click) are moved or trashed together by dragging one of them
const [markedDocIds, setMarkedDocIds] = useState<Set<string>>(() => new Set());
const [draggingDocIds, setDraggingDocIds] = useState<string[]>([]);
const [trashDragOver, setTrashDragOver] = useState(false);
const lastMarkedRef = useRef<string | null>(null);
const treeRef = useRef<HTMLDivElement>(null);
// Merging notes: dialog (picker/confirmation) and a toast with undo afterwards
const [mergeDialog, setMergeDialog] = useState<{ sourceId: string; targetId?: string } | null>(null);
@@ -773,13 +790,116 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
};
// HTML5 Drag & Drop handlers for moving documents into folders
// Ctrl/Cmd+click toggles a note, Shift+click marks the range in the order shown (from the last marked
// or the open note); a plain click clears the marks and opens the note
const handleDocRowClick = (e: React.MouseEvent, id: string, open: () => void) => {
if (e.metaKey || e.ctrlKey) {
setMarkedDocIds((prev) => {
const next = new Set(prev);
if (next.size === 0 && selectedDocId && selectedDocId !== id) next.add(selectedDocId);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
lastMarkedRef.current = id;
return;
}
if (e.shiftKey) {
const order: string[] = [];
treeRef.current?.querySelectorAll<HTMLElement>('[data-doc-row]').forEach((el) => {
const rowId = el.dataset.docRow;
if (rowId && !order.includes(rowId)) order.push(rowId);
});
const anchor = lastMarkedRef.current || selectedDocId || id;
const a = order.indexOf(anchor);
const b = order.indexOf(id);
const range = a < 0 || b < 0 ? [id] : order.slice(Math.min(a, b), Math.max(a, b) + 1);
setMarkedDocIds((prev) => new Set([...prev, ...range]));
return;
}
setMarkedDocIds(new Set());
lastMarkedRef.current = id;
open();
};
useEffect(() => {
if (markedDocIds.size === 0) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setMarkedDocIds(new Set());
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [markedDocIds.size]);
// HTML5 Drag & Drop handlers for moving documents into folders (a marked note takes the others along)
const handleDocDragStart = (e: React.DragEvent, docId: string) => {
e.stopPropagation();
const ids = markedDocIds.has(docId) && markedDocIds.size > 1 ? Array.from(markedDocIds) : [docId];
e.dataTransfer.setData('application/mindsift-doc-id', docId);
e.dataTransfer.setData('application/mindsift-doc-ids', JSON.stringify(ids));
e.dataTransfer.setData('text/plain', docId);
e.dataTransfer.effectAllowed = 'move';
setDraggingDocId(docId);
setDraggingDocIds(ids);
};
const draggedDocIds = (e: React.DragEvent): string[] => {
try {
const raw = e.dataTransfer.getData('application/mindsift-doc-ids');
const ids = raw ? (JSON.parse(raw) as string[]) : [];
if (ids.length > 0) return ids;
} catch {
// fall back below
}
const single = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
return draggingDocIds.length > 0 ? draggingDocIds : single ? [single] : [];
};
// Dropping notes on the trash: they can be restored (toast or the trash itself)
const handleDropOnTrash = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setTrashDragOver(false);
const ids = draggedDocIds(e);
setDraggingDocId(null);
setDraggingDocIds([]);
setMergeHoverId(null);
if (ids.length === 0) return;
triggerHaptic(20);
const trashed: string[] = [];
for (const id of ids) {
try {
await api.deleteDocument(id);
trashed.push(id);
} catch {
// e.g. a note shared with me: only its owner can delete it
}
}
if (trashed.length === 0) {
showToast('Nur eigene Notizen lassen sich löschen');
return;
}
setDocs((prev) => prev.filter((d) => !trashed.includes(d.id)));
if (selectedDocId && trashed.includes(selectedDocId)) {
setSelectedDocId(null);
setMobileShowDetail(false);
}
setMarkedDocIds(new Set());
onStatsChange();
reloadTrash();
showToast(trashed.length === 1 ? 'Notiz im Papierkorb' : `${trashed.length} Notizen im Papierkorb`, [
{
label: 'Rückgängig',
run: async () => {
for (const id of trashed) {
await api.restoreDocument(id).catch(() => {});
}
await loadWorkspaceData();
reloadTrash();
onStatsChange();
},
},
]);
};
const requestMerge = (sourceId: string, targetId?: string) => {
@@ -794,7 +914,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
// Dropping a dragged note onto another note (instead of a folder) offers to merge them
const docDropProps = (targetId: string): React.HTMLAttributes<HTMLDivElement> => ({
onDragOver: (e) => {
if (!draggingDocId || draggingDocId === targetId) return;
if (!draggingDocId || draggingDocId === targetId || draggingDocIds.length > 1) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
@@ -803,7 +923,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
onDragLeave: () => setMergeHoverId((cur) => (cur === targetId ? null : cur)),
onDrop: (e) => {
const sourceId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
if (!sourceId || sourceId === targetId) return;
if (!sourceId || sourceId === targetId || draggedDocIds(e).length > 1) return;
e.preventDefault();
e.stopPropagation();
setDraggingDocId(null);
@@ -855,19 +975,24 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
e.stopPropagation();
setDragOverFolder(null);
const docId = e.dataTransfer.getData('application/mindsift-doc-id') || draggingDocId;
const docIds = draggedDocIds(e);
const docId = docIds[0];
const folderPath = e.dataTransfer.getData('application/mindsift-folder-path') || draggingFolderPath;
setDraggingDocId(null);
setDraggingDocIds([]);
setDraggingFolderPath(null);
if (docId) {
triggerHaptic(15);
try {
const updatedDoc = await api.moveDocument(docId, targetFolder);
setDocs((prev) =>
prev.map((d) => (d.id === docId ? { ...d, folderPath: updatedDoc.folderPath } : d))
);
for (const id of docIds) {
const updatedDoc = await api.moveDocument(id, targetFolder).catch(() => null);
if (updatedDoc) {
setDocs((prev) => prev.map((d) => (d.id === id ? { ...d, folderPath: updatedDoc.folderPath } : d)));
}
}
if (docIds.length > 1) setMarkedDocIds(new Set());
if (targetFolder) {
const parts = targetFolder.split('/');
let acc = '';
@@ -1758,7 +1883,27 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</div>
{/* Scrollable Navigation Tree */}
<div className="flex-1 overflow-y-auto p-2 space-y-3">
<div
ref={treeRef}
className="flex-1 overflow-y-auto p-2 space-y-3"
onClick={(e) => {
if (e.target === e.currentTarget && markedDocIds.size > 0) setMarkedDocIds(new Set());
}}
>
{markedDocIds.size > 0 && (
<div className="sticky top-0 z-10 flex items-center gap-2 px-2 py-1.5 rounded-btn bg-accent/10 border border-accent/30 text-[11px] text-main">
<span className="flex-1">
{markedDocIds.size} markiert · in einen Ordner oder auf den Papierkorb ziehen
</span>
<button
type="button"
onClick={() => setMarkedDocIds(new Set())}
className="text-accent hover:underline shrink-0"
>
Aufheben
</button>
</div>
)}
{/* Journal on top; open, it becomes a panel with the calendar (click the heading to close).
Panel and calendar animate open (grid rows 0fr → 1fr) instead of jumping. Only from lg on:
below, the journal is a view of its own and the button stays a plain entry like the folders
@@ -1825,16 +1970,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<div
key={`pin-${d.id}`}
draggable
data-doc-row={d.id}
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
onMouseDown={(e) => e.shiftKey && e.preventDefault()}
onClick={(e) =>
handleDocRowClick(e, d.id, () => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
})
}
className={`w-full flex items-center gap-2 px-2 py-1.5 min-h-11 sm:min-h-0 rounded-btn text-left text-sm sm:text-xs cursor-pointer border transition-colors ${
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: markedDocIds.has(d.id)
? 'border-accent/40 bg-accent/10 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-transparent'
: 'border-transparent text-main hover:bg-panel-elevated'
@@ -1881,6 +2032,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}}
docDropProps={docDropProps}
mergeHoverId={mergeHoverId}
markedDocIds={markedDocIds}
onDocRowClick={handleDocRowClick}
/>
))}
@@ -1888,16 +2041,22 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<div
key={d.id}
draggable
data-doc-row={d.id}
onDragStart={(e) => handleDocDragStart(e, d.id)}
{...docDropProps(d.id)}
onClick={() => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
}}
onMouseDown={(e) => e.shiftKey && e.preventDefault()}
onClick={(e) =>
handleDocRowClick(e, d.id, () => {
setSelectedDocId(d.id);
setSubView('editor');
setMobileShowDetail(true);
})
}
className={`w-full flex items-center gap-2 px-2 py-1.5 min-h-11 sm:min-h-0 rounded-btn text-left text-sm sm:text-xs cursor-pointer border transition-colors ${
mergeHoverId === d.id
? 'border-accent bg-accent/15 ring-2 ring-accent/30 text-main'
: markedDocIds.has(d.id)
? 'border-accent/40 bg-accent/10 text-main'
: selectedDocId === d.id && subView === 'editor'
? 'bg-panel-elevated text-main font-semibold border-transparent'
: 'border-transparent text-main hover:bg-panel-elevated'
@@ -1966,8 +2125,19 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<button
type="button"
onClick={openTrash}
onDragOver={(e) => {
if (!draggingDocId) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (!trashDragOver) setTrashDragOver(true);
}}
onDragLeave={() => setTrashDragOver(false)}
onDrop={handleDropOnTrash}
title="Papierkorb – Notizen hierher ziehen, um sie zu löschen"
className={`w-full flex items-center gap-2 px-2 h-11 sm:h-8 rounded-btn text-xs border transition-colors cursor-pointer ${
subView === 'trash'
trashDragOver
? 'border-status-err bg-status-err/10 text-status-err ring-2 ring-status-err/30'
: subView === 'trash'
? 'bg-panel-elevated text-main font-semibold border-transparent'
: 'border-transparent text-muted hover:text-main hover:bg-panel-elevated'
}`}