Merge dev into main (v0.63.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 30s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 30s
This commit is contained in:
commit
91803bab87
7 files changed
+168
-24
No files matched your search
+11
-1
@@ -6,6 +6,15 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.63.0] - 2026-11-02
|
||||
|
||||
### Added
|
||||
- Kanban am Handy: Jede Spalte füllt den Bildschirm zwischen Kopfleiste und unterer Navigation, ein Wisch bringt genau eine Spalte weiter und rastet ein. Beim Ziehen einer Karte springt die Seite am oberen bzw. unteren Rand zur vorigen bzw. nächsten Spalte, sodass sich Karten in jede Spalte legen lassen.
|
||||
- Desktop: Triage, Workspace, Aufgaben und Listen behalten beim Wechsel zwischen den Seiten ihre Scrollposition.
|
||||
|
||||
### Changed
|
||||
- Kanban am Desktop mit mehreren Reihen: Beim ersten Scrollen verschwindet nur die Leiste über dem Board, die erste Reihe wächst dabei mit, bis sie den ganzen Bildschirm füllt.
|
||||
|
||||
## [0.62.0] - 2026-11-02
|
||||
|
||||
### Changed
|
||||
@@ -893,7 +902,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.62.0...dev
|
||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.63.0...dev
|
||||
[0.63.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.62.0...v0.63.0
|
||||
[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
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](#)
|
||||
[](#-schnellstart)
|
||||
[](#-deine-daten-gehören-dir)
|
||||
[](CHANGELOG.md)
|
||||
[](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.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.62.0",
|
||||
"version": "0.63.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.62.0",
|
||||
"version": "0.63.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.62.0",
|
||||
"version": "0.63.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+41
-4
@@ -208,10 +208,47 @@ export const App: React.FC = () => {
|
||||
|
||||
// Desktop: the window does not scroll; this area under the header does (phones keep the window scroll)
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
// A new page starts at its top (the desktop scroll area would otherwise keep the old page's position)
|
||||
// Each page keeps its scroll position when switching pages. The new page is often still loading and
|
||||
// shorter than before: the position comes back as soon as the content is high enough (or the user
|
||||
// scrolls first, or after 3 s). Scroll events meanwhile do not count as the page's position.
|
||||
const scrollPositionsRef = useRef<Partial<Record<ActiveTab, number>>>({});
|
||||
useEffect(() => {
|
||||
scrollAreaRef.current?.scrollTo?.({ top: 0 });
|
||||
}, [activeTab]);
|
||||
const area = scrollAreaRef.current;
|
||||
if (!area) return;
|
||||
const target = scrollPositionsRef.current[activeTab] ?? 0;
|
||||
let restoring = true;
|
||||
let observer: ResizeObserver | null = null;
|
||||
const finish = () => {
|
||||
if (!restoring) return;
|
||||
restoring = false;
|
||||
observer?.disconnect();
|
||||
window.clearTimeout(timeout);
|
||||
area.removeEventListener('wheel', finish);
|
||||
area.removeEventListener('pointerdown', finish);
|
||||
area.removeEventListener('keydown', finish);
|
||||
};
|
||||
const tryRestore = () => {
|
||||
area.scrollTop = target;
|
||||
if (Math.abs(area.scrollTop - target) < 2) finish();
|
||||
};
|
||||
const timeout = window.setTimeout(finish, 3000);
|
||||
area.addEventListener('wheel', finish, { passive: true });
|
||||
area.addEventListener('pointerdown', finish);
|
||||
area.addEventListener('keydown', finish);
|
||||
tryRestore();
|
||||
if (restoring && typeof ResizeObserver !== 'undefined' && area.firstElementChild) {
|
||||
observer = new ResizeObserver(tryRestore);
|
||||
observer.observe(area.firstElementChild);
|
||||
}
|
||||
const onScroll = () => {
|
||||
if (!restoring) scrollPositionsRef.current[activeTab] = area.scrollTop;
|
||||
};
|
||||
area.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
finish();
|
||||
area.removeEventListener('scroll', onScroll);
|
||||
};
|
||||
}, [activeTab, user?.id]);
|
||||
const [showAbout, setShowAbout] = useState(false);
|
||||
const [showTriageTrash, setShowTriageTrash] = useState(false);
|
||||
const { update: availableUpdate, dismiss: dismissUpdate } = useUpdateCheck();
|
||||
@@ -1413,7 +1450,7 @@ export const App: React.FC = () => {
|
||||
/>
|
||||
|
||||
{/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
|
||||
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
|
||||
<nav data-mobile-nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
|
||||
{/* Col 1: Triage */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -77,6 +77,10 @@ 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;
|
||||
const KANBAN_MIN_PHONE_COL_HEIGHT = 280;
|
||||
// First row of a Kanban board with several rows: the full height minus what is still visible of the bar
|
||||
// above the board (--kb-top at the start, shrinking with the scroll position --kb-scroll)
|
||||
const KANBAN_FIRST_ROW_HEIGHT = `max(${KANBAN_MIN_COL_HEIGHT}px, calc(var(--kb-full) - max(0px, calc(var(--kb-top) - var(--kb-scroll, 0px)))))`;
|
||||
// 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
|
||||
@@ -544,36 +548,63 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
// 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
|
||||
// One row: the page itself does not scroll (like the workspace). Several rows: the first row grows while
|
||||
// the bar above the board scrolls away, so a row always fills the screen; the wheel moves row by row
|
||||
// (below).
|
||||
const kanbanBoardRef = useRef<HTMLDivElement>(null);
|
||||
const [kanbanColHeight, setKanbanColHeight] = useState<number | null>(null);
|
||||
const [kanbanMultiRow, setKanbanMultiRow] = useState(false);
|
||||
const [kanbanPerRow, setKanbanPerRow] = useState(0);
|
||||
// Phones: one column per screen (between header and bottom bar) and the page snaps column by column;
|
||||
// kanbanPhoneTop is where a snapped column starts (below the header)
|
||||
const [kanbanPhoneSnap, setKanbanPhoneSnap] = useState(false);
|
||||
const [kanbanPhoneTop, setKanbanPhoneTop] = useState(0);
|
||||
// The first column is lower by the bar above the board, so it shows completely at the page top (its
|
||||
// snap point); a swipe then goes on to the second one
|
||||
const [kanbanPhoneFirstHeight, setKanbanPhoneFirstHeight] = useState(0);
|
||||
const isKanban = viewMode === 'kanban';
|
||||
useEffect(() => {
|
||||
if (!isKanban) {
|
||||
setKanbanColHeight(null);
|
||||
setKanbanMultiRow(false);
|
||||
setKanbanPhoneSnap(false);
|
||||
return;
|
||||
}
|
||||
const fit = () => {
|
||||
const el = kanbanBoardRef.current;
|
||||
if (!el || isPhoneWidth()) {
|
||||
if (!el) {
|
||||
setKanbanColHeight(null);
|
||||
setKanbanMultiRow(false);
|
||||
setKanbanPhoneSnap(false);
|
||||
return;
|
||||
}
|
||||
if (isPhoneWidth()) {
|
||||
const headerBottom = document.querySelector('header')?.getBoundingClientRect().bottom ?? 0;
|
||||
const navTop = document.querySelector('[data-mobile-nav]')?.getBoundingClientRect().top ?? window.innerHeight;
|
||||
setKanbanColHeight(Math.max(KANBAN_MIN_PHONE_COL_HEIGHT, navTop - headerBottom - 24));
|
||||
setKanbanPhoneTop(Math.round(headerBottom + 12));
|
||||
const boardTop = el.getBoundingClientRect().top + window.scrollY;
|
||||
setKanbanPhoneFirstHeight(Math.max(KANBAN_MIN_PHONE_COL_HEIGHT, Math.round(navTop - boardTop - 12)));
|
||||
setKanbanMultiRow(false);
|
||||
setKanbanPhoneSnap(true);
|
||||
return;
|
||||
}
|
||||
setKanbanPhoneSnap(false);
|
||||
const area = el.closest<HTMLElement>('[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(KANBAN_MIN_COL_HEIGHT, areaHeight - top - 16));
|
||||
// Several rows: a row fills the whole area (--kb-full); the first one grows into the room the bar
|
||||
// above the board frees while scrolling (--kb-top - --kb-scroll, see the scroll effect)
|
||||
el.style.setProperty('--kb-full', `${Math.max(KANBAN_MIN_COL_HEIGHT, areaHeight - 24)}px`);
|
||||
el.style.setProperty('--kb-top', `${Math.max(0, top - 12)}px`);
|
||||
requestAnimationFrame(() => {
|
||||
const tops = new Set(
|
||||
Array.from(el.querySelectorAll<HTMLElement>('[data-kanban-col]')).map((c) => c.offsetTop)
|
||||
);
|
||||
const colTops = Array.from(el.querySelectorAll<HTMLElement>('[data-kanban-col]')).map((c) => c.offsetTop);
|
||||
const tops = new Set(colTops);
|
||||
const multi = tops.size > 1;
|
||||
setKanbanMultiRow(multi);
|
||||
setKanbanPerRow(colTops.filter((t) => t === Math.min(...colTops)).length);
|
||||
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;
|
||||
@@ -631,8 +662,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
lockedUntil = Date.now() + 450;
|
||||
area.scrollTo({ top: next, behavior: 'smooth' });
|
||||
};
|
||||
// The first row grows while the bar above the board scrolls away
|
||||
const onScroll = () => board.style.setProperty('--kb-scroll', `${area.scrollTop}px`);
|
||||
onScroll();
|
||||
area.addEventListener('wheel', onWheel, { passive: false });
|
||||
return () => area.removeEventListener('wheel', onWheel);
|
||||
area.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
area.removeEventListener('wheel', onWheel);
|
||||
area.removeEventListener('scroll', onScroll);
|
||||
};
|
||||
}, [kanbanMultiRow]);
|
||||
|
||||
const safeTasks = useMemo(() => {
|
||||
@@ -1421,6 +1459,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
setDragOverTaskTarget(null);
|
||||
setDragOverKanbanCol(null);
|
||||
};
|
||||
// Page positions of the phone board's snap points: the page top (bar and first column) and the start of
|
||||
// every further column
|
||||
const phoneSnapPoints = useCallback(() => {
|
||||
const points = new Set<number>([0]); // page top: bar and first column
|
||||
kanbanBoardRef.current?.querySelectorAll<HTMLElement>('[data-kanban-col]').forEach((c, i) => {
|
||||
if (i > 0) points.add(Math.max(0, Math.round(c.getBoundingClientRect().top + window.scrollY - kanbanPhoneTop)));
|
||||
});
|
||||
return Array.from(points).sort((a, b) => a - b);
|
||||
}, [kanbanPhoneTop]);
|
||||
const kanbanTouch = useKanbanTouchDrag({
|
||||
enabled: !canDragTasks && viewMode === 'kanban',
|
||||
onStart: (taskId) => setDraggingTaskId(taskId),
|
||||
@@ -1438,8 +1485,22 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
else void moveTaskToColumn(taskId, ownColId);
|
||||
},
|
||||
onCancel: clearDragState,
|
||||
snapPoints: kanbanPhoneSnap ? phoneSnapPoints : undefined,
|
||||
});
|
||||
|
||||
// Phones: the page snaps to its top and to each column; while a card is dragged it does not (the drag
|
||||
// moves the page from column to column itself, see useKanbanTouchDrag)
|
||||
useEffect(() => {
|
||||
if (!kanbanPhoneSnap) return;
|
||||
const root = document.documentElement;
|
||||
root.style.scrollPaddingTop = `${kanbanPhoneTop}px`;
|
||||
root.style.scrollSnapType = kanbanTouch.dragging ? 'none' : 'y mandatory';
|
||||
return () => {
|
||||
root.style.scrollSnapType = '';
|
||||
root.style.scrollPaddingTop = '';
|
||||
};
|
||||
}, [kanbanPhoneSnap, kanbanPhoneTop, kanbanTouch.dragging]);
|
||||
|
||||
const handleCreateTaskList = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const title = newListTitle.trim();
|
||||
@@ -1970,7 +2031,8 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3">
|
||||
// Phone Kanban: the page top is the first snap point (bar and first column)
|
||||
<div className={`w-full space-y-3 ${kanbanPhoneSnap ? 'snap-start' : ''}`}>
|
||||
{/* Top Header: Task Lists Tabs & View Mode Switcher (Desktop) */}
|
||||
<div className="hidden sm:flex flex-wrap items-center justify-between gap-2 border-b border-border pb-2 !mt-0">
|
||||
{/* Task Lists selector tabs */}
|
||||
@@ -2813,7 +2875,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}`}
|
||||
style={kanbanBoardStyle(isKanbanWide, boardColumns.length)}
|
||||
>
|
||||
{boardColumns.map((col) => {
|
||||
{boardColumns.map((col, colIndex) => {
|
||||
const colTasks = kanbanTasks.filter((t) => inBoardColumn(t, col));
|
||||
// While dragging: a column the card's list does not have is greyed out and takes nothing
|
||||
const blockedForDrag =
|
||||
@@ -2830,8 +2892,20 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onDragLeave={(e) => handleKanbanColDragLeave(e, col.id)}
|
||||
onDrop={(e) => handleKanbanDrop(e, col.id, colTasks)}
|
||||
data-kanban-col={col.id}
|
||||
style={kanbanColHeight ? { height: kanbanColHeight } : undefined}
|
||||
className={`bg-canvas border border-border flex flex-col overflow-hidden transition-all ${
|
||||
style={
|
||||
!kanbanColHeight
|
||||
? undefined
|
||||
: kanbanPhoneSnap && colIndex === 0
|
||||
? { height: kanbanPhoneFirstHeight }
|
||||
: !kanbanMultiRow
|
||||
? { height: kanbanColHeight }
|
||||
: colIndex < kanbanPerRow
|
||||
? { height: KANBAN_FIRST_ROW_HEIGHT }
|
||||
: { height: 'var(--kb-full)' }
|
||||
}
|
||||
className={`bg-canvas border border-border flex flex-col overflow-hidden transition-[opacity,background-color,box-shadow] ${
|
||||
kanbanPhoneSnap && colIndex > 0 ? 'snap-start snap-always' : ''
|
||||
} ${
|
||||
kanbanColHeight ? '' : 'min-h-[420px]'
|
||||
} ${dragOverKanbanCol === col.id ? 'ring-2 ring-accent/60 bg-accent/5' : ''} ${blockedForDrag ? 'opacity-40' : ''}`}
|
||||
title={blockedForDrag ? 'Diese Spalte gibt es in der Liste der Aufgabe nicht' : undefined}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createPortal } from 'react-dom';
|
||||
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
|
||||
const EDGE_DWELL_MS = 450; // with snap points: how long a card waits at the edge before the page moves on
|
||||
|
||||
export type KanbanDropTarget = { taskId: string; position: 'before' | 'after' } | null;
|
||||
|
||||
@@ -18,6 +19,9 @@ interface Options {
|
||||
onHover: (colId: string | null, target: KanbanDropTarget) => void;
|
||||
onDrop: (taskId: string, colId: string | null, target: KanbanDropTarget) => void;
|
||||
onCancel: () => void;
|
||||
// Page positions the board snaps to (one column per screen on phones). With them, holding a card at
|
||||
// the top/bottom edge moves the page to the previous/next one instead of scrolling slowly.
|
||||
snapPoints?: () => number[];
|
||||
}
|
||||
|
||||
interface Drag {
|
||||
@@ -30,14 +34,14 @@ interface Drag {
|
||||
width: number;
|
||||
}
|
||||
|
||||
export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel }: Options) {
|
||||
export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel, snapPoints }: 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 callbacks = useRef({ onStart, onHover, onDrop, onCancel, snapPoints });
|
||||
callbacks.current = { onStart, onHover, onDrop, onCancel, snapPoints };
|
||||
|
||||
const cancelHold = () => {
|
||||
if (holdRef.current) window.clearTimeout(holdRef.current.timer);
|
||||
@@ -66,11 +70,30 @@ export function useKanbanTouchDrag({ enabled, onStart, onHover, onDrop, onCancel
|
||||
useEffect(() => {
|
||||
if (!drag) return;
|
||||
let frame = 0;
|
||||
let edgeSince = 0;
|
||||
let movingUntil = 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));
|
||||
const up = d.y < EDGE;
|
||||
const down = d.y > window.innerHeight - EDGE;
|
||||
const points = callbacks.current.snapPoints?.();
|
||||
if (points && (up || down)) {
|
||||
// Snapping board: after a short wait at the edge, on to the previous/next column (again after
|
||||
// another wait while the card stays there)
|
||||
const now = Date.now();
|
||||
if (!edgeSince) edgeSince = now;
|
||||
if (now - edgeSince > EDGE_DWELL_MS && now > movingUntil) {
|
||||
const cur = window.scrollY;
|
||||
const next = down ? points.find((p) => p > cur + 2) : [...points].reverse().find((p) => p < cur - 2);
|
||||
if (next !== undefined) window.scrollTo({ top: next, behavior: 'smooth' });
|
||||
movingUntil = now + 1000;
|
||||
edgeSince = now;
|
||||
}
|
||||
} else if (points) {
|
||||
edgeSince = 0;
|
||||
} else if (up) window.scrollBy(0, -Math.ceil((EDGE - d.y) / 6));
|
||||
else if (down) window.scrollBy(0, Math.ceil((d.y - (window.innerHeight - EDGE)) / 6));
|
||||
hoverAt(d.x, d.y);
|
||||
}
|
||||
frame = requestAnimationFrame(scroll);
|
||||
|
||||
Reference in new issue
Block a user