Merge dev into main (v0.38.1)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 29s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 29s
This commit is contained in:
commit
4f8bac0565
5 files changed
+129
-46
No files matched your search
+10
-1
@@ -6,6 +6,14 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
||||
|
||||
## [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
|
||||
|
||||
### 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.
|
||||
- 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.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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#-deine-daten-gehören-dir)
|
||||
[](CHANGELOG.md)
|
||||
[](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.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.38.0",
|
||||
"version": "0.38.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.38.0",
|
||||
"version": "0.38.1",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.38.0",
|
||||
"version": "0.38.1",
|
||||
"license": "Unlicense",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
Check,
|
||||
Plus,
|
||||
@@ -134,6 +135,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
|
||||
const [dragOverTaskTarget, setDragOverTaskTarget] = useState<{ taskId: string; position: 'before' | 'after' } | 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)
|
||||
const [dragOverListTab, setDragOverListTab] = useState<string | null>(null);
|
||||
|
||||
@@ -241,6 +248,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
|
||||
const onRefreshStatsRef = useRef(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)
|
||||
useEffect(() => {
|
||||
@@ -290,7 +300,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onRefreshStatsRef.current();
|
||||
}
|
||||
} 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') {
|
||||
// Payload only carries the list ID; reload so permissions reflect this user's view
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const sourceId =
|
||||
e.dataTransfer.getData('application/mindsift-task-id') ||
|
||||
e.dataTransfer.getData('text/plain') ||
|
||||
draggingTaskId;
|
||||
|
||||
const resetKanbanDrag = () => {
|
||||
setDraggingTaskId(null);
|
||||
setDragOverTaskTarget(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) => {
|
||||
if (!sourceId) return;
|
||||
triggerHaptic(15);
|
||||
@@ -2347,17 +2425,16 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
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 (
|
||||
<div
|
||||
key={col.id}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
if (draggingTaskId) setDragOverKanbanCol(col.id);
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (dragOverKanbanCol === col.id) setDragOverKanbanCol(null);
|
||||
}}
|
||||
onDrop={(e) => handleDropOnKanbanCol(e, col.id)}
|
||||
onDragOver={(e) => handleKanbanColDragOver(e, col.id)}
|
||||
onDragLeave={(e) => handleKanbanColDragLeave(e, col.id)}
|
||||
onDrop={(e) => handleKanbanDrop(e, col.id, colTasks)}
|
||||
data-kanban-col={col.id}
|
||||
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' : ''
|
||||
@@ -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]">
|
||||
{colTasks.map((task) => {
|
||||
const isDragging = draggingTaskId === task.id;
|
||||
const isTarget = dragOverTaskTarget?.taskId === task.id;
|
||||
const readOnly = isTaskReadOnly(task);
|
||||
const visibleIdx = visibleIds.indexOf(task.id);
|
||||
|
||||
return (
|
||||
<React.Fragment key={task.id}>
|
||||
<AnimatePresence initial={false}>
|
||||
{slotHere >= 0 && slotHere === visibleIdx && renderDropSlot(col.id, visibleIdx)}
|
||||
</AnimatePresence>
|
||||
<div
|
||||
key={task.id}
|
||||
draggable={canDragTasks && !readOnly}
|
||||
onDragStart={(e) => handleTaskDragStart(e, task)}
|
||||
onDragEnd={() => {
|
||||
setDraggingTaskId(null);
|
||||
setDragOverTaskTarget(null);
|
||||
setDragOverKanbanCol(null);
|
||||
setDragOverListTab(null);
|
||||
onDragStart={(e) => {
|
||||
handleTaskDragStart(e, task);
|
||||
dragHeightRef.current = e.currentTarget.getBoundingClientRect().height;
|
||||
// Hidden a tick later: hiding the source right away would cancel the drag
|
||||
window.setTimeout(() => setKanbanHiddenId(task.id), 0);
|
||||
}}
|
||||
onDragOver={(e) => handleTaskDragOver(e, task.id)}
|
||||
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
|
||||
onDrop={(e) => handleDropOnTask(e, task, col.id)}
|
||||
onDragEnd={resetKanbanDrag}
|
||||
data-kanban-task={task.id}
|
||||
{...kanbanTouch.cardProps(task.id, task.content, !readOnly)}
|
||||
onClick={() => {
|
||||
@@ -2400,19 +2477,12 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setDetailTaskId(task.id);
|
||||
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'
|
||||
} ${
|
||||
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">
|
||||
<p
|
||||
className={`text-xs font-medium break-words flex-1 leading-snug ${
|
||||
@@ -2556,10 +2626,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</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">
|
||||
Keine Aufgaben in dieser Spalte
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user