Merge dev into main (v0.62.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 30s

This commit is contained in:
sandrodach committed 2026-11-02 20:48:45 +01:00
commit 40f38e36a2
5 files changed
+87 -16

No files matched your search

+8 -1
View File
@@ -6,6 +6,12 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [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 ## [0.61.0] - 2026-11-02
### Added ### 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. - 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. - 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.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.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 [0.59.3]: https://git.dsit.me/sandrodach/MindSift/compare/v0.59.2...v0.59.3
+1 -1
View File
@@ -6,7 +6,7 @@
[![Self-Hosted](https://img.shields.io/badge/100%25-Self--Hosted-2563eb?style=flat-square)](#) [![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) [![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) [![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. **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.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.61.0", "version": "0.62.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.61.0", "version": "0.62.0",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@fontsource-variable/inter": "5.3.0", "@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"private": true, "private": true,
"version": "0.61.0", "version": "0.62.0",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"type": "module", "type": "module",
"scripts": { "scripts": {
+75 -11
View File
@@ -41,6 +41,7 @@ import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './Ma
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
import { CountBadge } from '../../components/CountBadge'; import { CountBadge } from '../../components/CountBadge';
import { ListSwitcher } from '../../components/ListSwitcher'; import { ListSwitcher } from '../../components/ListSwitcher';
import { isPhoneWidth } from '../../lib/breakpoints';
import { LivePill } from '../../components/LivePill'; import { LivePill } from '../../components/LivePill';
import { FormSheet } from '../../components/FormSheet'; import { FormSheet } from '../../components/FormSheet';
import { EmojiIconButton } from '../../components/EmojiIconButton'; import { EmojiIconButton } from '../../components/EmojiIconButton';
@@ -74,6 +75,8 @@ interface TasksViewProps {
} }
const KANBAN_WIDE_KEY = 'mindsift:kanban_wide'; 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 // Wide Kanban: a column grows to at most this width, the board stays centred
const KANBAN_COL_MAX = 380; const KANBAN_COL_MAX = 380;
const KANBAN_GAP = 12; // gap-3 const KANBAN_GAP = 12; // gap-3
@@ -538,38 +541,99 @@ export const TasksView: React.FC<TasksViewProps> = ({
[ownColumnOf, boardIdOf] [ownColumnOf, boardIdOf]
); );
// Desktop Kanban in one row: the columns fill the screen height and scroll on their own, so the page // Desktop Kanban: every column, in every row, is as high as the room from the board's top to the bottom of
// itself does not scroll as well (like the workspace). Several rows of columns keep the page scroll. // 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<HTMLDivElement>(null); const kanbanBoardRef = useRef<HTMLDivElement>(null);
const [kanbanColHeight, setKanbanColHeight] = useState<number | null>(null); const [kanbanColHeight, setKanbanColHeight] = useState<number | null>(null);
const kanbanOneRow = viewMode === 'kanban' && (isKanbanWide || boardColumns.length <= 4); const [kanbanMultiRow, setKanbanMultiRow] = useState(false);
const isKanban = viewMode === 'kanban';
useEffect(() => { useEffect(() => {
if (!kanbanOneRow) { if (!isKanban) {
setKanbanColHeight(null); setKanbanColHeight(null);
setKanbanMultiRow(false);
return; return;
} }
const fit = () => { const fit = () => {
const el = kanbanBoardRef.current; const el = kanbanBoardRef.current;
if (!el || window.innerWidth < 1024) { if (!el || isPhoneWidth()) {
setKanbanColHeight(null); setKanbanColHeight(null);
setKanbanMultiRow(false);
return; 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<HTMLElement>('[data-app-scroll]'); const area = el.closest<HTMLElement>('[data-app-scroll]');
const areaTop = area ? area.getBoundingClientRect().top - area.scrollTop : -window.scrollY; const areaTop = area ? area.getBoundingClientRect().top - area.scrollTop : -window.scrollY;
const areaHeight = area ? area.clientHeight : window.innerHeight; const areaHeight = area ? area.clientHeight : window.innerHeight;
const top = el.getBoundingClientRect().top - areaTop; const top = el.getBoundingClientRect().top - areaTop;
setKanbanColHeight(Math.max(320, areaHeight - top - 16)); setKanbanColHeight(Math.max(KANBAN_MIN_COL_HEIGHT, areaHeight - top - 16));
requestAnimationFrame(() => { requestAnimationFrame(() => {
const tops = new Set(
Array.from(el.querySelectorAll<HTMLElement>('[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; 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(); fit();
window.addEventListener('resize', fit); window.addEventListener('resize', fit);
return () => window.removeEventListener('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<HTMLElement>('[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<number>([0]);
board.querySelectorAll<HTMLElement>('[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<HTMLElement>('[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(() => { const safeTasks = useMemo(() => {
if (!Array.isArray(tasks)) return []; if (!Array.isArray(tasks)) return [];
@@ -2782,7 +2846,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div> </div>
{/* Tasks List in Column */} {/* Tasks List in Column */}
<div className={`p-2 space-y-2 flex-1 min-h-0 overflow-y-auto ${kanbanColHeight ? '' : 'max-h-[70vh]'}`}> <div data-kanban-list className={`p-2 space-y-2 flex-1 min-h-0 overflow-y-auto ${kanbanColHeight ? '' : 'max-h-[70vh]'}`}>
{colTasks.map((task) => { {colTasks.map((task) => {
const isDragging = draggingTaskId === task.id; const isDragging = draggingTaskId === task.id;
const readOnly = isTaskReadOnly(task); const readOnly = isTaskReadOnly(task);