diff --git a/CHANGELOG.md b/CHANGELOG.md index b92a6c4..4f830ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.62.0] - 2026-11-02 + +### Changed +- Kanban am Desktop: Alle Spalten sind in jeder Reihe gleich hoch, so hoch wie der sichtbare Bereich unter der Leiste (mindestens Platz für etwa drei Karten). Bricht eine Spalte in eine zweite Reihe um, wird die erste Reihe nicht mehr höher. +- Kanban mit mehreren Reihen: Ein Mausrad-Schritt springt zur nächsten bzw. vorigen Reihe. Spalten mit vielen Karten scrollen zuerst in sich; Strg + Mausrad über dem Board scrollt frei. + ## [0.61.0] - 2026-11-02 ### Added @@ -887,7 +893,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.61.0...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.62.0...dev +[0.62.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.61.0...v0.62.0 [0.61.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.60.0...v0.61.0 [0.60.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.59.3...v0.60.0 [0.59.3]: https://git.dsit.me/sandrodach/MindSift/compare/v0.59.2...v0.59.3 diff --git a/README.md b/README.md index 161d0d7..e1cf19f 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![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) [![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.61.0-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.62.0-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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 867e475..eac15bc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.61.0", + "version": "0.62.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.61.0", + "version": "0.62.0", "license": "AGPL-3.0-only", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index 5e77fe5..d65e2ec 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.61.0", + "version": "0.62.0", "license": "AGPL-3.0-only", "type": "module", "scripts": { diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 82f1022..b47f2a4 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -41,6 +41,7 @@ import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './Ma import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { CountBadge } from '../../components/CountBadge'; import { ListSwitcher } from '../../components/ListSwitcher'; +import { isPhoneWidth } from '../../lib/breakpoints'; import { LivePill } from '../../components/LivePill'; import { FormSheet } from '../../components/FormSheet'; import { EmojiIconButton } from '../../components/EmojiIconButton'; @@ -74,6 +75,8 @@ interface TasksViewProps { } const KANBAN_WIDE_KEY = 'mindsift:kanban_wide'; +// Desktop Kanban columns never get lower than this: room for the header and about three cards +const KANBAN_MIN_COL_HEIGHT = 340; // Wide Kanban: a column grows to at most this width, the board stays centred const KANBAN_COL_MAX = 380; const KANBAN_GAP = 12; // gap-3 @@ -538,38 +541,99 @@ export const TasksView: React.FC = ({ [ownColumnOf, boardIdOf] ); - // Desktop Kanban in one row: the columns fill the screen height and scroll on their own, so the page - // itself does not scroll as well (like the workspace). Several rows of columns keep the page scroll. + // Desktop Kanban: every column, in every row, is as high as the room from the board's top to the bottom of + // the scroll area, so the first row fits completely on opening and each further row fills one screen; + // more cards scroll inside the column. At least room for about three cards (the page scrolls below that). + // One row: the page itself does not scroll (like the workspace). Several rows: the wheel moves row by row + // (below). const kanbanBoardRef = useRef(null); const [kanbanColHeight, setKanbanColHeight] = useState(null); - const kanbanOneRow = viewMode === 'kanban' && (isKanbanWide || boardColumns.length <= 4); + const [kanbanMultiRow, setKanbanMultiRow] = useState(false); + const isKanban = viewMode === 'kanban'; useEffect(() => { - if (!kanbanOneRow) { + if (!isKanban) { setKanbanColHeight(null); + setKanbanMultiRow(false); return; } const fit = () => { const el = kanbanBoardRef.current; - if (!el || window.innerWidth < 1024) { + if (!el || isPhoneWidth()) { setKanbanColHeight(null); + setKanbanMultiRow(false); return; } - // From the board's top to the bottom of the app's scroll area (the desktop window itself does not - // scroll); then take back what still overflows (paddings) const area = el.closest('[data-app-scroll]'); const areaTop = area ? area.getBoundingClientRect().top - area.scrollTop : -window.scrollY; const areaHeight = area ? area.clientHeight : window.innerHeight; const top = el.getBoundingClientRect().top - areaTop; - setKanbanColHeight(Math.max(320, areaHeight - top - 16)); + setKanbanColHeight(Math.max(KANBAN_MIN_COL_HEIGHT, areaHeight - top - 16)); requestAnimationFrame(() => { + const tops = new Set( + Array.from(el.querySelectorAll('[data-kanban-col]')).map((c) => c.offsetTop) + ); + const multi = tops.size > 1; + setKanbanMultiRow(multi); + if (multi) return; + // One row: take back what still overflows (paddings), so the page does not scroll const over = area ? area.scrollHeight - area.clientHeight : document.documentElement.scrollHeight - window.innerHeight; - if (over > 0) setKanbanColHeight((h) => (h ? Math.max(320, h - over) : h)); + if (over > 0) setKanbanColHeight((h) => (h ? Math.max(KANBAN_MIN_COL_HEIGHT, h - over) : h)); }); }; fit(); window.addEventListener('resize', fit); return () => window.removeEventListener('resize', fit); - }, [kanbanOneRow, selectedListId, loading]); + }, [isKanban, isKanbanWide, boardColumns.length, selectedListId, loading]); + + // Several rows: one wheel step moves to the next row (or back up to the previous row / the page top), + // smoothly. The browser's own snapping goes back to the nearest point, so a wheel step shorter than a row + // never got past the first one. A column whose cards can still scroll that way scrolls first; Ctrl + + // wheel over the board scrolls freely (and does not zoom there); scrollbar and keys scroll freely. + useEffect(() => { + const board = kanbanBoardRef.current; + const area = board?.closest('[data-app-scroll]'); + if (!kanbanMultiRow || !board || !area) return; + let pending = 0; // trackpads send many small steps: move only once they add up + let lockedUntil = 0; + const snapPoints = () => { + const areaTop = area.getBoundingClientRect().top - area.scrollTop; + const rows = new Set([0]); + board.querySelectorAll('[data-kanban-col]').forEach((c) => { + rows.add(Math.max(0, Math.round(c.getBoundingClientRect().top - areaTop - 12))); + }); + return Array.from(rows).sort((a, b) => a - b); + }; + const onWheel = (e: WheelEvent) => { + const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaY; + if (e.ctrlKey) { + if (!board.contains(e.target as Node)) return; // zooming elsewhere stays as it is + e.preventDefault(); + area.scrollBy({ top: dy }); + return; + } + if (dy === 0 || e.shiftKey) return; + const list = (e.target as HTMLElement).closest('[data-kanban-list]'); + if (list && (dy > 0 ? list.scrollTop + list.clientHeight < list.scrollHeight - 1 : list.scrollTop > 0)) return; + e.preventDefault(); + if (Date.now() < lockedUntil) return; + pending += dy; + if (Math.abs(pending) < 40) return; + const down = pending > 0; + pending = 0; + const cur = area.scrollTop; + const points = snapPoints(); + const next = down ? points.find((p) => p > cur + 2) : [...points].reverse().find((p) => p < cur - 2); + if (next === undefined) { + // Past the last row (e.g. a very low window): plain scrolling + area.scrollBy({ top: dy }); + return; + } + lockedUntil = Date.now() + 450; + area.scrollTo({ top: next, behavior: 'smooth' }); + }; + area.addEventListener('wheel', onWheel, { passive: false }); + return () => area.removeEventListener('wheel', onWheel); + }, [kanbanMultiRow]); const safeTasks = useMemo(() => { if (!Array.isArray(tasks)) return []; @@ -2782,7 +2846,7 @@ export const TasksView: React.FC = ({ {/* Tasks List in Column */} -
+
{colTasks.map((task) => { const isDragging = draggingTaskId === task.id; const readOnly = isTaskReadOnly(task);