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]
|
## [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
|
## [0.20.21] - 2026-04-20
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.20.20",
|
"version": "0.20.22",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "oikos",
|
"name": "oikos",
|
||||||
"version": "0.20.20",
|
"version": "0.20.22",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "oikos",
|
"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.",
|
"description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -152,7 +152,9 @@
|
|||||||
"kanbanMoveToOpen": "Erneut öffnen",
|
"kanbanMoveToOpen": "Erneut öffnen",
|
||||||
"recurring": "Wiederkehrend",
|
"recurring": "Wiederkehrend",
|
||||||
"listView": "Listenansicht",
|
"listView": "Listenansicht",
|
||||||
"kanbanView": "Kanban-Ansicht"
|
"kanbanView": "Kanban-Ansicht",
|
||||||
|
"swipedDoneToast": "Als erledigt markiert.",
|
||||||
|
"swipedOpenToast": "Als offen markiert."
|
||||||
},
|
},
|
||||||
"shopping": {
|
"shopping": {
|
||||||
"title": "Einkauf",
|
"title": "Einkauf",
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ function renderUrgentTasks(tasks) {
|
|||||||
const items = tasks.map((t) => {
|
const items = tasks.map((t) => {
|
||||||
const due = formatDueDate(t.due_date);
|
const due = formatDueDate(t.due_date);
|
||||||
return `
|
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>` : ''}
|
${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>
|
<span class="sr-only">${PRIORITY_LABELS()[t.priority] ?? t.priority}</span>
|
||||||
<div class="task-item__content">
|
<div class="task-item__content">
|
||||||
|
|||||||
+138
-1
@@ -645,6 +645,7 @@ function renderKanban(container) {
|
|||||||
|
|
||||||
if (window.lucide) window.lucide.createIcons();
|
if (window.lucide) window.lucide.createIcons();
|
||||||
wireKanbanDrag(container);
|
wireKanbanDrag(container);
|
||||||
|
wireKanbanTouch(container);
|
||||||
updateOverdueBadge();
|
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
|
// Partielle DOM-Updates
|
||||||
// --------------------------------------------------------
|
// --------------------------------------------------------
|
||||||
@@ -952,11 +1062,26 @@ function wireSwipeGestures(container) {
|
|||||||
card.style.transition = 'transform 0.2s ease';
|
card.style.transition = 'transform 0.2s ease';
|
||||||
card.style.transform = 'translateX(-110%)';
|
card.style.transform = 'translateX(-110%)';
|
||||||
vibrate(40);
|
vibrate(40);
|
||||||
|
const capturedStatus = status;
|
||||||
|
const nextStatus = capturedStatus === 'done' ? 'open' : 'done';
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
resetCard(false);
|
resetCard(false);
|
||||||
try {
|
try {
|
||||||
await toggleTaskStatus(taskId, status);
|
await toggleTaskStatus(taskId, capturedStatus);
|
||||||
await loadTasks(container);
|
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) {
|
} catch (err) {
|
||||||
window.oikos.showToast(err.message, 'danger');
|
window.oikos.showToast(err.message, 'danger');
|
||||||
await loadTasks(container);
|
await loadTasks(container);
|
||||||
@@ -1212,4 +1337,16 @@ export async function render(container, { user }) {
|
|||||||
wireTaskList(container);
|
wireTaskList(container);
|
||||||
renderFilters(container);
|
renderFilters(container);
|
||||||
renderTaskList(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
|
// Alten Pfad merken, bevor currentPath aktualisiert wird - für Richtungsberechnung
|
||||||
const previousPath = currentPath;
|
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
|
// Auth-Guard
|
||||||
if (route.requiresAuth && !currentUser) {
|
if (route.requiresAuth && !currentUser) {
|
||||||
@@ -188,7 +189,7 @@ async function navigate(path, userOrPushState = true, pushState = true) {
|
|||||||
document.documentElement.style.setProperty('--active-module-accent', accent);
|
document.documentElement.style.setProperty('--active-module-accent', accent);
|
||||||
|
|
||||||
await renderPage(route, previousPath);
|
await renderPage(route, previousPath);
|
||||||
updateNav(path);
|
updateNav(basePath);
|
||||||
updateThemeColorForRoute(route);
|
updateThemeColorForRoute(route);
|
||||||
} finally {
|
} finally {
|
||||||
isNavigating = false;
|
isNavigating = false;
|
||||||
|
|||||||
@@ -557,6 +557,16 @@
|
|||||||
transform: rotate(1.5deg);
|
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 {
|
.kanban-card--done {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user