feat: kanban touch drag, swipe undo, dashboard task deep-link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.20.22] - 2026-04-20
|
||||
|
||||
### Added
|
||||
- Tasks: Kanban board now supports touch drag-and-drop on mobile — a ghost card follows the finger and drops into the target column on release
|
||||
- Tasks: swipe-left to mark done/open now shows a 5-second undo toast that reverts the status change
|
||||
- Tasks: opening a task card from the Dashboard now navigates to `/tasks` and immediately opens the edit modal for that task (deep-link via `?open=<id>`)
|
||||
|
||||
### Fixed
|
||||
- Router: query parameters (e.g. `?open=123`) are now stripped before route matching, so parameterised URLs resolve correctly without falling back to the home page
|
||||
|
||||
## [0.20.21] - 2026-04-20
|
||||
|
||||
### Changed
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "oikos",
|
||||
"version": "0.20.20",
|
||||
"version": "0.20.22",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "oikos",
|
||||
"version": "0.20.20",
|
||||
"version": "0.20.22",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oikos",
|
||||
"version": "0.20.21",
|
||||
"version": "0.20.22",
|
||||
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
||||
"main": "server/index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -152,7 +152,9 @@
|
||||
"kanbanMoveToOpen": "Erneut öffnen",
|
||||
"recurring": "Wiederkehrend",
|
||||
"listView": "Listenansicht",
|
||||
"kanbanView": "Kanban-Ansicht"
|
||||
"kanbanView": "Kanban-Ansicht",
|
||||
"swipedDoneToast": "Als erledigt markiert.",
|
||||
"swipedOpenToast": "Als offen markiert."
|
||||
},
|
||||
"shopping": {
|
||||
"title": "Einkauf",
|
||||
|
||||
@@ -197,7 +197,7 @@ function renderUrgentTasks(tasks) {
|
||||
const items = tasks.map((t) => {
|
||||
const due = formatDueDate(t.due_date);
|
||||
return `
|
||||
<div class="task-item" data-route="/tasks" role="button" tabindex="0">
|
||||
<div class="task-item" data-route="/tasks?open=${t.id}" role="button" tabindex="0">
|
||||
${t.priority !== 'none' ? `<div class="task-item__priority task-item__priority--${t.priority}" aria-hidden="true"></div>` : ''}
|
||||
<span class="sr-only">${PRIORITY_LABELS()[t.priority] ?? t.priority}</span>
|
||||
<div class="task-item__content">
|
||||
|
||||
+138
-1
@@ -645,6 +645,7 @@ function renderKanban(container) {
|
||||
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
wireKanbanDrag(container);
|
||||
wireKanbanTouch(container);
|
||||
updateOverdueBadge();
|
||||
}
|
||||
|
||||
@@ -752,6 +753,115 @@ function wireKanbanDrag(container) {
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Kanban-Touch-Drag (Mobile)
|
||||
// --------------------------------------------------------
|
||||
|
||||
function wireKanbanTouch(container) {
|
||||
const board = container.querySelector('.kanban-board');
|
||||
if (!board) return;
|
||||
|
||||
let dragging = null;
|
||||
let ghost = null;
|
||||
let taskId = null;
|
||||
let originX = 0, originY = 0;
|
||||
let originLeft = 0, originTop = 0;
|
||||
let activeZone = null;
|
||||
let started = false;
|
||||
|
||||
function cleanup() {
|
||||
ghost?.remove();
|
||||
ghost = null;
|
||||
if (dragging) {
|
||||
dragging.classList.remove('kanban-card--dragging');
|
||||
dragging = null;
|
||||
}
|
||||
board.querySelectorAll('.kanban-col__body--over').forEach((el) =>
|
||||
el.classList.remove('kanban-col__body--over')
|
||||
);
|
||||
activeZone = null;
|
||||
started = false;
|
||||
taskId = null;
|
||||
}
|
||||
|
||||
board.addEventListener('touchstart', (e) => {
|
||||
const card = e.target.closest('.kanban-card[data-task-id]');
|
||||
if (!card || e.target.closest('[data-next-status]')) return;
|
||||
dragging = card;
|
||||
taskId = card.dataset.taskId;
|
||||
const touch = e.touches[0];
|
||||
originX = touch.clientX;
|
||||
originY = touch.clientY;
|
||||
const rect = card.getBoundingClientRect();
|
||||
originLeft = rect.left;
|
||||
originTop = rect.top;
|
||||
started = false;
|
||||
}, { passive: true });
|
||||
|
||||
board.addEventListener('touchmove', (e) => {
|
||||
if (!dragging) return;
|
||||
const touch = e.touches[0];
|
||||
const dx = touch.clientX - originX;
|
||||
const dy = touch.clientY - originY;
|
||||
|
||||
if (!started && Math.sqrt(dx * dx + dy * dy) < 8) return;
|
||||
|
||||
if (!started) {
|
||||
started = true;
|
||||
ghost = dragging.cloneNode(true);
|
||||
ghost.className = 'kanban-card kanban-card--ghost';
|
||||
ghost.style.width = dragging.getBoundingClientRect().width + 'px';
|
||||
ghost.style.left = originLeft + 'px';
|
||||
ghost.style.top = originTop + 'px';
|
||||
document.body.appendChild(ghost);
|
||||
dragging.classList.add('kanban-card--dragging');
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
ghost.style.left = (originLeft + dx) + 'px';
|
||||
ghost.style.top = (originTop + dy) + 'px';
|
||||
|
||||
ghost.style.visibility = 'hidden';
|
||||
const el = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||
ghost.style.visibility = '';
|
||||
|
||||
const zone = el?.closest('[data-drop-zone]');
|
||||
board.querySelectorAll('.kanban-col__body--over').forEach((z) =>
|
||||
z.classList.remove('kanban-col__body--over')
|
||||
);
|
||||
if (zone) {
|
||||
zone.classList.add('kanban-col__body--over');
|
||||
activeZone = zone;
|
||||
} else {
|
||||
activeZone = null;
|
||||
}
|
||||
}, { passive: false });
|
||||
|
||||
board.addEventListener('touchend', async () => {
|
||||
if (!dragging) return;
|
||||
const zone = activeZone;
|
||||
const tid = taskId;
|
||||
const task = state.tasks.find((tk) => String(tk.id) === String(tid));
|
||||
cleanup();
|
||||
|
||||
if (!zone || !task) return;
|
||||
const newStatus = zone.dataset.dropZone;
|
||||
if (task.status === newStatus) return;
|
||||
|
||||
task.status = newStatus;
|
||||
renderKanban(container);
|
||||
try {
|
||||
await api.patch(`/tasks/${tid}/status`, { status: newStatus });
|
||||
await loadTasks(container);
|
||||
} catch (err) {
|
||||
window.oikos.showToast(err.message, 'danger');
|
||||
await loadTasks(container);
|
||||
}
|
||||
}, { passive: true });
|
||||
|
||||
board.addEventListener('touchcancel', cleanup, { passive: true });
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Partielle DOM-Updates
|
||||
// --------------------------------------------------------
|
||||
@@ -952,11 +1062,26 @@ function wireSwipeGestures(container) {
|
||||
card.style.transition = 'transform 0.2s ease';
|
||||
card.style.transform = 'translateX(-110%)';
|
||||
vibrate(40);
|
||||
const capturedStatus = status;
|
||||
const nextStatus = capturedStatus === 'done' ? 'open' : 'done';
|
||||
setTimeout(async () => {
|
||||
resetCard(false);
|
||||
try {
|
||||
await toggleTaskStatus(taskId, status);
|
||||
await toggleTaskStatus(taskId, capturedStatus);
|
||||
await loadTasks(container);
|
||||
window.oikos.showToast(
|
||||
t(nextStatus === 'done' ? 'tasks.swipedDoneToast' : 'tasks.swipedOpenToast'),
|
||||
'default',
|
||||
5000,
|
||||
async () => {
|
||||
try {
|
||||
await toggleTaskStatus(taskId, nextStatus);
|
||||
await loadTasks(container);
|
||||
} catch (err) {
|
||||
window.oikos.showToast(err.message, 'danger');
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
window.oikos.showToast(err.message, 'danger');
|
||||
await loadTasks(container);
|
||||
@@ -1212,4 +1337,16 @@ export async function render(container, { user }) {
|
||||
wireTaskList(container);
|
||||
renderFilters(container);
|
||||
renderTaskList(container);
|
||||
|
||||
// Deep-Link: ?open=<id> öffnet direkt das Edit-Modal
|
||||
const openId = new URLSearchParams(window.location.search).get('open');
|
||||
if (openId) {
|
||||
try {
|
||||
const [task, reminder] = await Promise.all([
|
||||
loadTaskForEdit(openId),
|
||||
loadReminderForTask(openId),
|
||||
]);
|
||||
openTaskModal({ task, users: state.users, reminder }, container);
|
||||
} catch { /* Task existiert nicht oder kein Zugriff */ }
|
||||
}
|
||||
}
|
||||
|
||||
+4
-3
@@ -155,9 +155,10 @@ async function navigate(path, userOrPushState = true, pushState = true) {
|
||||
|
||||
// Alten Pfad merken, bevor currentPath aktualisiert wird - für Richtungsberechnung
|
||||
const previousPath = currentPath;
|
||||
currentPath = path;
|
||||
const basePath = path.split('?')[0];
|
||||
currentPath = basePath;
|
||||
|
||||
const route = ROUTES.find((r) => r.path === path) ?? ROUTES.find((r) => r.path === '/');
|
||||
const route = ROUTES.find((r) => r.path === basePath) ?? ROUTES.find((r) => r.path === '/');
|
||||
|
||||
// Auth-Guard
|
||||
if (route.requiresAuth && !currentUser) {
|
||||
@@ -188,7 +189,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
|
||||
document.documentElement.style.setProperty('--active-module-accent', accent);
|
||||
|
||||
await renderPage(route, previousPath);
|
||||
updateNav(path);
|
||||
updateNav(basePath);
|
||||
updateThemeColorForRoute(route);
|
||||
} finally {
|
||||
isNavigating = false;
|
||||
|
||||
@@ -557,6 +557,16 @@
|
||||
transform: rotate(1.5deg);
|
||||
}
|
||||
|
||||
.kanban-card--ghost {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
opacity: 0.88;
|
||||
box-shadow: var(--shadow-xl);
|
||||
transform: rotate(2deg) scale(1.03);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.kanban-card--done {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user