feat: Kanban touch drag, admins delete backups, bigger share message
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m5s

- Kanban on phones/tablets: hold a card, drag it onto a card or column, release; the page scrolls at the edges
- Admin settings: database backups can be deleted (DELETE /api/v1/admin/backups/{name})
- Share message: large Liste/Aufgabe buttons, close button, tap outside closes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-31 11:54:17 +01:00
1 parent da258fdce2
commit f76b0f34ff
14 files changed
+371 -57

No files matched your search

+36 -1
View File
@@ -31,6 +31,7 @@ import {
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS } from '../../lib/types';
import { formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
import { readViewSession, writeViewSession } from '../../lib/viewSession';
import { useKanbanTouchDrag } from './useKanbanTouchDrag';
import { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { TaskDetailModal } from './TaskDetailModal';
@@ -807,7 +808,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
await moveTaskNextTo(sourceId, targetTask, position, newKanbanCol);
};
// Shared by mouse drag & drop and the touch drag on the Kanban board
const moveTaskNextTo = async (sourceId: string | null, targetTask: Thought, position: 'before' | 'after', newKanbanCol?: string) => {
if (!sourceId || sourceId === targetTask.id) return;
triggerHaptic(15);
@@ -864,7 +869,10 @@ export const TasksView: React.FC<TasksViewProps> = ({
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
await moveTaskToColumn(sourceId, colId);
};
const moveTaskToColumn = async (sourceId: string | null, colId: string) => {
if (!sourceId) return;
triggerHaptic(15);
@@ -905,6 +913,28 @@ export const TasksView: React.FC<TasksViewProps> = ({
}
};
// Kanban with a finger: hold a card, drag it, let go (mouse and pen use HTML5 drag & drop)
const clearDragState = () => {
setDraggingTaskId(null);
setDragOverTaskTarget(null);
setDragOverKanbanCol(null);
};
const kanbanTouch = useKanbanTouchDrag({
enabled: !canDragTasks && viewMode === 'kanban',
onStart: (taskId) => setDraggingTaskId(taskId),
onHover: (colId, target) => {
setDragOverKanbanCol(colId);
setDragOverTaskTarget(target);
},
onDrop: (taskId, colId, target) => {
clearDragState();
const targetTask = target ? tasks.find((t) => t.id === target.taskId) : undefined;
if (targetTask && target) void moveTaskNextTo(taskId, targetTask, target.position, colId ?? undefined);
else if (colId) void moveTaskToColumn(taskId, colId);
},
onCancel: clearDragState,
});
const handleCreateTaskList = async (e: React.FormEvent) => {
e.preventDefault();
const title = newListTitle.trim();
@@ -2224,6 +2254,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
{viewMode === 'kanban' ? (
/* DYNAMIC KANBAN BOARD */
<div className="space-y-2">
{kanbanTouch.ghost}
<div
className={`grid grid-cols-1 md:grid-cols-2 gap-3 pt-1 lg:w-[var(--kanban-w)] lg:ml-[var(--kanban-ml)] transition-[width,margin] duration-300 ease-out ${
isKanbanWide ? 'lg:grid-cols-[repeat(var(--kanban-cols),minmax(0,1fr))]' : 'lg:grid-cols-4'
@@ -2251,6 +2282,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
if (dragOverKanbanCol === col.id) setDragOverKanbanCol(null);
}}
onDrop={(e) => handleDropOnKanbanCol(e, col.id)}
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' : ''
}`}
@@ -2295,11 +2327,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
onDragOver={(e) => handleTaskDragOver(e, task.id)}
onDragLeave={(e) => handleTaskDragLeave(e, task.id)}
onDrop={(e) => handleDropOnTask(e, task, col.id)}
data-kanban-task={task.id}
{...kanbanTouch.cardProps(task.id, task.content, !readOnly)}
onClick={() => {
if (kanbanTouch.consumeClick()) return;
setDetailTaskId(task.id);
setShowDetailModal(true);
}}
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative ${
className={`panel-surface p-2.5 space-y-2 hover:border-strong transition-all shadow-sm group relative select-none ${
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
} ${
isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
@@ -0,0 +1,169 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
// Drag & drop on the Kanban board with a finger (browsers offer HTML5 drag & drop only for mouse and
// pen): hold a card for a moment, it lifts, drag it onto another card or column, let go. A short tap
// still opens the task and a swipe still scrolls, because the drag starts only after the hold.
// Cards carry data-kanban-task="<id>", columns data-kanban-col="<id>".
const HOLD_MS = 350;
const MOVE_TOLERANCE = 8; // px a finger may wander during the hold before it counts as scrolling
const EDGE = 90; // px near the top/bottom edge that scroll the page while dragging
export type KanbanDropTarget = { taskId: string; position: 'before' | 'after' } | null;
interface Options {
enabled: boolean;
onStart: (taskId: string) => void;
onHover: (colId: string | null, target: KanbanDropTarget) => void;
onDrop: (taskId: string, colId: string | null, target: KanbanDropTarget) => void;
onCancel: () => void;
}
interface Drag {
taskId: string;
label: string;
x: number;
y: number;
offsetX: number;
offsetY: number;
width: number;
}
export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel }: Options) {
const [drag, setDrag] = useState<Drag | null>(null);
const holdRef = useRef<{ timer: number; x: number; y: number } | null>(null);
const dragRef = useRef<Drag | null>(null);
const hoverRef = useRef<{ col: string | null; target: KanbanDropTarget }>({ col: null, target: null });
const suppressClickRef = useRef(false);
const callbacks = useRef({ onStart, onHover, onDrop, onCancel });
callbacks.current = { onStart, onHover, onDrop, onCancel };
const cancelHold = () => {
if (holdRef.current) window.clearTimeout(holdRef.current.timer);
holdRef.current = null;
};
const hoverAt = (x: number, y: number) => {
const el = document.elementFromPoint(x, y) as HTMLElement | null;
const card = el?.closest('[data-kanban-task]') as HTMLElement | null;
const col = (el?.closest('[data-kanban-col]') as HTMLElement | null)?.dataset.kanbanCol ?? null;
let target: KanbanDropTarget = null;
const cardId = card?.dataset.kanbanTask;
if (card && cardId && cardId !== dragRef.current?.taskId) {
const r = card.getBoundingClientRect();
target = { taskId: cardId, position: y - r.top < r.height / 2 ? 'before' : 'after' };
}
const prev = hoverRef.current;
if (prev.col !== col || prev.target?.taskId !== target?.taskId || prev.target?.position !== target?.position) {
hoverRef.current = { col, target };
callbacks.current.onHover(col, target);
}
};
// While dragging: the page must not scroll under the finger (non-passive listener), it scrolls
// only near the edges
useEffect(() => {
if (!drag) return;
let frame = 0;
const scroll = () => {
const d = dragRef.current;
if (d) {
if (d.y < EDGE) window.scrollBy(0, -Math.ceil((EDGE - d.y) / 6));
else if (d.y > window.innerHeight - EDGE) window.scrollBy(0, Math.ceil((d.y - (window.innerHeight - EDGE)) / 6));
hoverAt(d.x, d.y);
}
frame = requestAnimationFrame(scroll);
};
frame = requestAnimationFrame(scroll);
const move = (e: TouchEvent) => {
e.preventDefault();
const t = e.touches[0];
if (!t || !dragRef.current) return;
dragRef.current = { ...dragRef.current, x: t.clientX, y: t.clientY };
setDrag(dragRef.current);
};
const end = (e: TouchEvent) => {
const d = dragRef.current;
dragRef.current = null;
setDrag(null);
suppressClickRef.current = true;
window.setTimeout(() => (suppressClickRef.current = false), 400);
if (!d) return;
if (e.type === 'touchcancel') callbacks.current.onCancel();
else callbacks.current.onDrop(d.taskId, hoverRef.current.col, hoverRef.current.target);
hoverRef.current = { col: null, target: null };
};
document.addEventListener('touchmove', move, { passive: false });
document.addEventListener('touchend', end);
document.addEventListener('touchcancel', end);
return () => {
cancelAnimationFrame(frame);
document.removeEventListener('touchmove', move);
document.removeEventListener('touchend', end);
document.removeEventListener('touchcancel', end);
};
// Listeners live as long as one drag; positions are read from refs
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [Boolean(drag)]);
/** Props for a card; label is what the lifted card shows */
const cardProps = useCallback(
(taskId: string, label: string, draggable: boolean) => {
if (!enabled || !draggable) return {};
return {
onTouchStart: (e: React.TouchEvent<HTMLElement>) => {
if (e.touches.length !== 1) return;
const t = e.touches[0];
const el = e.currentTarget;
cancelHold();
holdRef.current = {
x: t.clientX,
y: t.clientY,
timer: window.setTimeout(() => {
const hold = holdRef.current;
holdRef.current = null;
if (!hold) return;
const r = el.getBoundingClientRect();
navigator.vibrate?.(15);
const d: Drag = { taskId, label, x: hold.x, y: hold.y, offsetX: hold.x - r.left, offsetY: hold.y - r.top, width: r.width };
dragRef.current = d;
hoverRef.current = { col: null, target: null };
setDrag(d);
callbacks.current.onStart(taskId);
}, HOLD_MS),
};
},
onTouchMove: (e: React.TouchEvent<HTMLElement>) => {
const h = holdRef.current;
const t = e.touches[0];
if (h && t && Math.hypot(t.clientX - h.x, t.clientY - h.y) > MOVE_TOLERANCE) cancelHold(); // scrolling
},
onTouchEnd: cancelHold,
onTouchCancel: cancelHold,
// The long press must not open the browser's context menu or select text
onContextMenu: (e: React.MouseEvent) => e.preventDefault(),
style: { WebkitTouchCallout: 'none' } as React.CSSProperties,
};
},
[enabled]
);
/** A tap right after a drag must not open the task */
const consumeClick = () => suppressClickRef.current;
const ghost = drag
? createPortal(
<div
className="fixed z-[80] pointer-events-none panel-surface shadow-panel px-3 py-2.5 text-xs font-medium text-main rotate-1 opacity-95 border-accent"
style={{ left: drag.x - drag.offsetX, top: drag.y - drag.offsetY, width: drag.width }}
>
{drag.label}
</div>,
document.body
)
: null;
return { cardProps, ghost, consumeClick, dragging: Boolean(drag) };
}