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

This commit is contained in:
sandrodach committed 2026-11-01 13:42:50 +01:00
commit 4f8bac0565
5 files changed
+129 -46

No files matched your search

+10 -1
View File
@@ -6,6 +6,14 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
## [0.38.1] - 2026-11-01
### Changed
- Kanban: Beim Ziehen einer Karte öffnet sich an der Zielstelle sanft eine Lücke in Kartengröße, die übrigen Karten gleiten nach, und die Karte verlässt ihren alten Platz. Nach dem Ablegen leuchtet sie kurz an der neuen Stelle auf. Die blaue Linie, die beim Ziehen über Text und Symbole der Karten flackerte und die Ansicht zittern ließ, gibt es nicht mehr.
### Fixed
- Nach dem Umsortieren von Aufgaben (Kanban oder Liste) zeigte eine gewählte Aufgabenliste plötzlich auch Aufgaben aus anderen Listen, bis man sie neu öffnete.
## [0.38.0] - 2026-11-01 ## [0.38.0] - 2026-11-01
### Added ### Added
@@ -639,7 +647,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.38.0...dev [Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.38.1...dev
[0.38.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.38.0...v0.38.1
[0.38.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.2...v0.38.0 [0.38.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.2...v0.38.0
[0.37.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.1...v0.37.2 [0.37.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.1...v0.37.2
[0.37.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.0...v0.37.1 [0.37.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.0...v0.37.1
+1 -1
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.38.0-16a34a?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/Version-v0.38.1-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.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.38.0", "version": "0.38.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.38.0", "version": "0.38.1",
"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.38.0", "version": "0.38.1",
"license": "Unlicense", "license": "Unlicense",
"type": "module", "type": "module",
"scripts": { "scripts": {
+115 -41
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useState, useRef, useMemo } from 'react'; import React, { useEffect, useState, useRef, useMemo } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { import {
Check, Check,
Plus, Plus,
@@ -134,6 +135,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null); const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null); const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | null>(null);
const [dragOverKanbanCol, setDragOverKanbanCol] = useState<string | null>(null); const [dragOverKanbanCol, setDragOverKanbanCol] = useState<string | null>(null);
// Kanban drag: the column works out the slot from the pointer; a gap of the card's height opens there
// (no per-card enter/leave flicker), the dragged card leaves its old place, and lights up where it lands
const [kanbanSlot, setKanbanSlot] = useState<{ col: string; index: number } | null>(null);
const [kanbanHiddenId, setKanbanHiddenId] = useState<string | null>(null);
const [justDroppedId, setJustDroppedId] = useState<string | null>(null);
const dragHeightRef = useRef(64);
// List tab a dragged task hovers over (dropping moves it into that list) // List tab a dragged task hovers over (dropping moves it into that list)
const [dragOverListTab, setDragOverListTab] = useState<string | null>(null); const [dragOverListTab, setDragOverListTab] = useState<string | null>(null);
@@ -241,6 +248,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
const onRefreshStatsRef = useRef(onRefreshStats); const onRefreshStatsRef = useRef(onRefreshStats);
onRefreshStatsRef.current = onRefreshStats; onRefreshStatsRef.current = onRefreshStats;
// The realtime handler is registered once; it must reload with the current list, not the first one
const loadDataRef = useRef(loadData);
loadDataRef.current = loadData;
// Live-Sync WebSocket listener for tasks (registered once on mount) // Live-Sync WebSocket listener for tasks (registered once on mount)
useEffect(() => { useEffect(() => {
@@ -290,7 +300,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
onRefreshStatsRef.current(); onRefreshStatsRef.current();
} }
} else if (msg.event === 'TASK_REORDERED') { } else if (msg.event === 'TASK_REORDERED') {
loadData(); loadDataRef.current();
} else if (msg.event === 'TASK_LIST_CREATED' || msg.event === 'TASK_LIST_UPDATED' || msg.event === 'TASK_LIST_DELETED') { } else if (msg.event === 'TASK_LIST_CREATED' || msg.event === 'TASK_LIST_UPDATED' || msg.event === 'TASK_LIST_DELETED') {
// Payload only carries the list ID; reload so permissions reflect this user's view // Payload only carries the list ID; reload so permissions reflect this user's view
api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {}); api.getTaskLists().then((lists) => setTaskLists(Array.isArray(lists) ? lists : [])).catch(() => {});
@@ -901,21 +911,89 @@ export const TasksView: React.FC<TasksViewProps> = ({
} }
}; };
const handleDropOnKanbanCol = async (e: React.DragEvent, colId: string) => { const resetKanbanDrag = () => {
e.preventDefault();
e.stopPropagation();
const sourceId =
e.dataTransfer.getData('application/mindsift-task-id') ||
e.dataTransfer.getData('text/plain') ||
draggingTaskId;
setDraggingTaskId(null); setDraggingTaskId(null);
setDragOverTaskTarget(null); setDragOverTaskTarget(null);
setDragOverKanbanCol(null); setDragOverKanbanCol(null);
await moveTaskToColumn(sourceId, colId); setDragOverListTab(null);
setKanbanSlot(null);
setKanbanHiddenId(null);
}; };
// The gap where the dragged card would land; it opens and closes smoothly, the cards below slide along
const renderDropSlot = (colId: string, index: number) => (
<motion.div
key={`slot-${colId}-${index}`}
initial={{ height: 0, opacity: 0 }}
animate={{ height: dragHeightRef.current, opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="rounded-card border-2 border-dashed border-accent/50 bg-accent/5 pointer-events-none"
aria-hidden="true"
/>
);
const handleKanbanColDragOver = (e: React.DragEvent<HTMLDivElement>, colId: string) => {
e.preventDefault();
if (!draggingTaskId) return;
if (dragOverKanbanCol !== colId) setDragOverKanbanCol(colId);
// Slot = number of cards (without the dragged one) whose middle lies above the pointer
const cards = Array.from(e.currentTarget.querySelectorAll<HTMLElement>('[data-kanban-task]')).filter(
(el) => el.dataset.kanbanTask !== draggingTaskId
);
let index = cards.length;
for (let i = 0; i < cards.length; i++) {
const r = cards[i].getBoundingClientRect();
if (e.clientY < r.top + r.height / 2) {
index = i;
break;
}
}
if (!kanbanSlot || kanbanSlot.col !== colId || kanbanSlot.index !== index) setKanbanSlot({ col: colId, index });
};
const handleKanbanColDragLeave = (e: React.DragEvent<HTMLDivElement>, colId: string) => {
// Moving onto a child of the column is no leaving
if (e.currentTarget.contains(e.relatedTarget as Node)) return;
if (dragOverKanbanCol === colId) setDragOverKanbanCol(null);
setKanbanSlot((cur) => (cur?.col === colId ? null : cur));
};
const handleKanbanDrop = async (e: React.DragEvent, colId: string, colTasks: Thought[]) => {
e.preventDefault();
e.stopPropagation();
const sourceId =
e.dataTransfer.getData('application/mindsift-task-id') || e.dataTransfer.getData('text/plain') || draggingTaskId;
const slot = kanbanSlot;
resetKanbanDrag();
if (!sourceId) return;
const others = colTasks.filter((t) => t.id !== sourceId);
if (others.length === 0 || !slot || slot.col !== colId) {
await moveTaskToColumn(sourceId, colId);
} else if (slot.index < others.length) {
await moveTaskNextTo(sourceId, others[slot.index], 'before', colId);
} else {
await moveTaskNextTo(sourceId, others[others.length - 1], 'after', colId);
}
setJustDroppedId(sourceId);
window.setTimeout(() => setJustDroppedId((cur) => (cur === sourceId ? null : cur)), 900);
};
// A drag that ends outside any column (Escape, dropped elsewhere) leaves nothing behind
useEffect(() => {
if (!draggingTaskId) return;
const end = () => {
setKanbanSlot(null);
setKanbanHiddenId(null);
};
window.addEventListener('dragend', end);
window.addEventListener('drop', end);
return () => {
window.removeEventListener('dragend', end);
window.removeEventListener('drop', end);
};
}, [draggingTaskId]);
const moveTaskToColumn = async (sourceId: string | null, colId: string) => { const moveTaskToColumn = async (sourceId: string | null, colId: string) => {
if (!sourceId) return; if (!sourceId) return;
triggerHaptic(15); triggerHaptic(15);
@@ -2347,17 +2425,16 @@ export const TasksView: React.FC<TasksViewProps> = ({
return col.id === 'todo'; return col.id === 'todo';
}); });
// Cards that take part in the slot count (the dragged one has left its place)
const visibleIds = colTasks.filter((t) => t.id !== kanbanHiddenId).map((t) => t.id);
const slotHere = kanbanSlot && kanbanSlot.col === col.id ? kanbanSlot.index : -1;
return ( return (
<div <div
key={col.id} key={col.id}
onDragOver={(e) => { onDragOver={(e) => handleKanbanColDragOver(e, col.id)}
e.preventDefault(); onDragLeave={(e) => handleKanbanColDragLeave(e, col.id)}
if (draggingTaskId) setDragOverKanbanCol(col.id); onDrop={(e) => handleKanbanDrop(e, col.id, colTasks)}
}}
onDragLeave={() => {
if (dragOverKanbanCol === col.id) setDragOverKanbanCol(null);
}}
onDrop={(e) => handleDropOnKanbanCol(e, col.id)}
data-kanban-col={col.id} data-kanban-col={col.id}
className={`bg-canvas border border-border flex flex-col min-h-[420px] overflow-hidden transition-all ${ className={`bg-canvas border border-border flex flex-col min-h-[420px] overflow-hidden transition-all ${
dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : '' dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''
@@ -2376,23 +2453,23 @@ export const TasksView: React.FC<TasksViewProps> = ({
<div className="p-2 space-y-2 flex-1 overflow-y-auto max-h-[70vh]"> <div className="p-2 space-y-2 flex-1 overflow-y-auto max-h-[70vh]">
{colTasks.map((task) => { {colTasks.map((task) => {
const isDragging = draggingTaskId === task.id; const isDragging = draggingTaskId === task.id;
const isTarget = dragOverTaskTarget?.taskId === task.id;
const readOnly = isTaskReadOnly(task); const readOnly = isTaskReadOnly(task);
const visibleIdx = visibleIds.indexOf(task.id);
return ( return (
<React.Fragment key={task.id}>
<AnimatePresence initial={false}>
{slotHere >= 0 && slotHere === visibleIdx && renderDropSlot(col.id, visibleIdx)}
</AnimatePresence>
<div <div
key={task.id}
draggable={canDragTasks && !readOnly} draggable={canDragTasks && !readOnly}
onDragStart={(e) => handleTaskDragStart(e, task)} onDragStart={(e) => {
onDragEnd={() => { handleTaskDragStart(e, task);
setDraggingTaskId(null); dragHeightRef.current = e.currentTarget.getBoundingClientRect().height;
setDragOverTaskTarget(null); // Hidden a tick later: hiding the source right away would cancel the drag
setDragOverKanbanCol(null); window.setTimeout(() => setKanbanHiddenId(task.id), 0);
setDragOverListTab(null);
}} }}
onDragOver={(e) => handleTaskDragOver(e, task.id)} onDragEnd={resetKanbanDrag}
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
onDrop={(e) => handleDropOnTask(e, task, col.id)}
data-kanban-task={task.id} data-kanban-task={task.id}
{...kanbanTouch.cardProps(task.id, task.content, !readOnly)} {...kanbanTouch.cardProps(task.id, task.content, !readOnly)}
onClick={() => { onClick={() => {
@@ -2400,19 +2477,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
setDetailTaskId(task.id); setDetailTaskId(task.id);
setShowDetailModal(true); setShowDetailModal(true);
}} }}
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative select-none ${ className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-colors shadow-sm group relative select-none ${
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing' readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
} ${ } ${
isDragging ? 'opacity-30 border border-dashed border-accent/40' : '' kanbanHiddenId === task.id ? 'hidden' : isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
}`} } ${justDroppedId === task.id ? 'ring-2 ring-accent/60 animate-in fade-in zoom-in-95 duration-300' : ''}`}
> >
{/* Drop insertion line indicator */}
{isTarget && dragOverTaskTarget.position === 'before' && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
)}
{isTarget && dragOverTaskTarget.position === 'after' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-accent z-20 pointer-events-none rounded-full shadow-[0_0_8px_rgba(var(--accent-rgb),0.6)]" />
)}
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<p <p
className={`text-xs font-medium break-words flex-1 leading-snug ${ className={`text-xs font-medium break-words flex-1 leading-snug ${
@@ -2556,10 +2626,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div> </div>
)} )}
</div> </div>
</React.Fragment>
); );
})} })}
<AnimatePresence initial={false}>
{slotHere >= 0 && slotHere === visibleIds.length && renderDropSlot(col.id, visibleIds.length)}
</AnimatePresence>
{colTasks.length === 0 && ( {colTasks.length === 0 && slotHere < 0 && (
<div className="p-4 text-center text-faint text-xs italic"> <div className="p-4 text-center text-faint text-xs italic">
Keine Aufgaben in dieser Spalte Keine Aufgaben in dieser Spalte
</div> </div>