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

This commit is contained in:
sandrodach committed 2026-11-02 12:40:11 +01:00
commit 6799a8506f
12 files changed
+55 -25

No files matched your search

+1 -1
View File
@@ -85,7 +85,7 @@ Fremde Branches (z. B. von anderen Agenten) werden vor dem Mergen geprüft.
## Oberfläche ## Oberfläche
- **Einheitliche Bedienelemente:** Personen-Suche, Profilbild-Stapel, Zähler und weitere Muster stehen in [Doku/ui-patterns.md](Doku/ui-patterns.md). Vorhandene Komponenten verwenden statt neuer Varianten; neue gemeinsame Muster dort ergänzen. - **Einheitliche Bedienelemente:** Personen-Suche, Profilbild-Stapel, Zähler und weitere Muster stehen in [Doku/ui-patterns.md](Doku/ui-patterns.md). Vorhandene Komponenten verwenden statt neuer Varianten; neue gemeinsame Muster dort ergänzen.
- **Handy und Desktop:** Unter `sm` ist Handy-Layout, ab `lg` die volle Desktop-Ansicht (z. B. Seitenleiste neben der Notiz). Wird nur das Handy geändert, bleiben die Werte ab `sm`/`lg` exakt wie vorher. - **Handy und Desktop:** Unter `sm` ist Handy-Layout, ab `lg` die volle Desktop-Ansicht (z. B. Seitenleiste neben der Notiz). `sm` liegt bei **768 px**, nicht bei Tailwinds 640 px (Handys quer und kleine Tablets bekommen das Handy-Layout); `md` ist gleich `sm`. Im Code dafür `isPhoneWidth()` aus `lib/breakpoints.ts` verwenden, keine feste Pixelzahl. Wird nur das Handy geändert, bleiben die Werte ab `sm`/`lg` exakt wie vorher.
- **Antippen am Handy:** Ziele mindestens 44 px hoch (`h-11 sm:h-…`). Auf reinen Touch-Geräten gibt es kein HTML5-Drag-&-Drop; Kanban nutzt dafür `useKanbanTouchDrag`, die Notizen die Leiste über der Tastatur (`MobileOutlineBar`). - **Antippen am Handy:** Ziele mindestens 44 px hoch (`h-11 sm:h-…`). Auf reinen Touch-Geräten gibt es kein HTML5-Drag-&-Drop; Kanban nutzt dafür `useKanbanTouchDrag`, die Notizen die Leiste über der Tastatur (`MobileOutlineBar`).
- **Themes:** Farben nur über die Theme-Variablen bzw. Klassen (`bg-panel`, `bg-canvas`, `text-main`, `text-muted`, `border-border`, `bg-accent` …), keine festen Farben. Rahmen mit `bg-panel`/`bg-canvas` bekommen die Ecken des Themes automatisch; liegt darin ein Kopfbereich mit eigenem Hintergrund, braucht der Rahmen `overflow-hidden`, sonst ragen bei runden Ecken die Kanten heraus. - **Themes:** Farben nur über die Theme-Variablen bzw. Klassen (`bg-panel`, `bg-canvas`, `text-main`, `text-muted`, `border-border`, `bg-accent` …), keine festen Farben. Rahmen mit `bg-panel`/`bg-canvas` bekommen die Ecken des Themes automatisch; liegt darin ein Kopfbereich mit eigenem Hintergrund, braucht der Rahmen `overflow-hidden`, sonst ragen bei runden Ecken die Kanten heraus.
- **Installierte App (PWA):** Manifest-Änderungen (Farben, Verknüpfungen) übernimmt Android erst, wenn es die App aktualisiert (bis etwa einen Tag) oder nach Neuinstallation; normaler App-Code kommt beim nächsten Start. Ändert sich der Cache-Inhalt des Service Workers, `VERSION` in `frontend/public/sw.js` erhöhen. - **Installierte App (PWA):** Manifest-Änderungen (Farben, Verknüpfungen) übernimmt Android erst, wenn es die App aktualisiert (bis etwa einen Tag) oder nach Neuinstallation; normaler App-Code kommt beim nächsten Start. Ändert sich der Cache-Inhalt des Service Workers, `VERSION` in `frontend/public/sw.js` erhöhen.
+10 -1
View File
@@ -6,6 +6,14 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
## [0.57.0] - 2026-11-02
### Changed
- Die Handy-Ansicht gilt jetzt bis 767 px Breite (bisher bis 639 px). Handys im Querformat, kleine Tablets im Hochformat (z. B. iPad mini) und schmale Fenster bekommen damit die Ansicht mit großen Tippzielen und unterer Navigation statt einer gedrängten Desktop-Ansicht.
### Fixed
- Desktop: Die Quick-Dump-Leiste steht genau mittig im Kopf und bleibt stehen, wenn der Verbindungsstatus wechselt (z. B. von „online“ zu „eingeschränkt“) oder der Name lang ist.
## [0.56.0] - 2026-11-02 ## [0.56.0] - 2026-11-02
### Added ### Added
@@ -828,7 +836,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.56.0...dev [Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.57.0...dev
[0.57.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.56.0...v0.57.0
[0.56.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.55.0...v0.56.0 [0.56.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.55.0...v0.56.0
[0.55.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.54.0...v0.55.0 [0.55.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.54.0...v0.55.0
[0.54.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.53.0...v0.54.0 [0.54.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.53.0...v0.54.0
+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.56.0-16a34a?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/Version-v0.57.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.56.0", "version": "0.57.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.56.0", "version": "0.57.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.56.0", "version": "0.57.0",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"type": "module", "type": "module",
"scripts": { "scripts": {
+12 -9
View File
@@ -82,6 +82,7 @@ import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
import { APP_VERSION } from './version'; import { APP_VERSION } from './version';
import { readViewSession, writeViewSession } from './lib/viewSession'; import { readViewSession, writeViewSession } from './lib/viewSession';
import { setVoicePreference } from './lib/voice'; import { setVoicePreference } from './lib/voice';
import { isPhoneWidth } from './lib/breakpoints';
// Connection status at the profile (useConnectionState); full class names for Tailwind // Connection status at the profile (useConnectionState); full class names for Tailwind
const CONNECTION_STATUS = { const CONNECTION_STATUS = {
@@ -302,7 +303,7 @@ export const App: React.FC = () => {
// pendingShortcut above re-runs this effect and must not cancel the timer) // pendingShortcut above re-runs this effect and must not cancel the timer)
window.setTimeout(() => { window.setTimeout(() => {
if (target === 'triage') handleTriggerAddButton(); if (target === 'triage') handleTriggerAddButton();
else if (window.innerWidth < 640 || target === 'lists') primaryActionRef.current?.(); else if (isPhoneWidth() || target === 'lists') primaryActionRef.current?.();
}, 400); }, 400);
}, [pendingShortcut, user?.id, activeTab]); }, [pendingShortcut, user?.id, activeTab]);
@@ -465,7 +466,7 @@ export const App: React.FC = () => {
// Immediately offer Web-App installation on mobile if not yet running as installed standalone app // Immediately offer Web-App installation on mobile if not yet running as installed standalone app
useEffect(() => { useEffect(() => {
if (!user || isStandaloneApp) return; if (!user || isStandaloneApp) return;
if (window.innerWidth < 640 && !sessionStorage.getItem('mindsift_pwa_dismissed')) { if (isPhoneWidth() && !sessionStorage.getItem('mindsift_pwa_dismissed')) {
setShowPwaInstallModal(true); setShowPwaInstallModal(true);
} }
}, [user?.id, isStandaloneApp]); }, [user?.id, isStandaloneApp]);
@@ -828,7 +829,7 @@ export const App: React.FC = () => {
const handleTriggerAddButton = () => { const handleTriggerAddButton = () => {
triggerHaptic(20); triggerHaptic(20);
if (window.innerWidth < 640) { if (isPhoneWidth()) {
openMobileTriageCapture(); openMobileTriageCapture();
} else { } else {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
@@ -1008,15 +1009,16 @@ export const App: React.FC = () => {
</div> </div>
</div> </div>
{/* Desktop Header: 3-column Layout (Logo, InstantCaptureBar, Profile Pill) */} {/* Desktop Header: 3-column grid (Logo, InstantCaptureBar, Profile Pill) with equal outer columns, so
<div className="hidden sm:flex max-w-6xl mx-auto px-4 py-2 items-start justify-between gap-6"> the capture bar stays exactly centred whatever name or status stands on the right */}
<div className="hidden sm:grid grid-cols-[minmax(12rem,1fr)_minmax(0,36rem)_minmax(12rem,1fr)] max-w-6xl mx-auto px-4 py-2 items-start gap-6">
{/* Logo & Headline */} {/* Logo & Headline */}
<div <div
onClick={() => { onClick={() => {
setActiveTab('triage'); setActiveTab('triage');
handleTriggerAddButton(); handleTriggerAddButton();
}} }}
className="h-9 flex items-center gap-2.5 cursor-pointer group select-none flex-shrink-0" className="h-9 flex items-center gap-2.5 cursor-pointer group select-none flex-shrink-0 justify-self-start"
> >
<div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center"> <div className="w-7 h-7 bg-accent text-on-accent rounded-btn flex items-center justify-center">
<Brain className="w-4 h-4" /> <Brain className="w-4 h-4" />
@@ -1039,7 +1041,7 @@ export const App: React.FC = () => {
</div> </div>
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */} {/* Quick-Capture Input Bubble directly in Header (above Tabs) */}
<div className="flex-1 max-w-xl mx-auto flex items-center gap-2"> <div className="w-full max-w-xl mx-auto flex items-center gap-2">
<div className="flex-1"> <div className="flex-1">
<InstantCaptureBar <InstantCaptureBar
onCaptured={handleCaptured} onCaptured={handleCaptured}
@@ -1059,7 +1061,7 @@ export const App: React.FC = () => {
</div> </div>
{/* User Profile Pill & Dropdown Menu */} {/* User Profile Pill & Dropdown Menu */}
<div className="relative h-9 flex items-center gap-2 flex-shrink-0" ref={dropdownRef}> <div className="relative h-9 flex items-center gap-2 flex-shrink-0 justify-self-end" ref={dropdownRef}>
<button <button
onClick={() => { onClick={() => {
triggerHaptic(10); triggerHaptic(10);
@@ -1069,7 +1071,8 @@ export const App: React.FC = () => {
title="Benutzermenü öffnen" title="Benutzermenü öffnen"
> >
<UserAvatar user={user} size="sm" /> <UserAvatar user={user} size="sm" />
<span className="flex flex-col items-start leading-tight"> {/* Wide enough for the longest status ("eingeschränkt"), so switching status moves nothing */}
<span className="flex flex-col items-start leading-tight min-w-[5.5rem]">
<span className="max-w-[130px] truncate hidden sm:inline text-main font-medium"> <span className="max-w-[130px] truncate hidden sm:inline text-main font-medium">
{user.displayName} {user.displayName}
</span> </span>
@@ -18,6 +18,7 @@ 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 { LivePill } from '../../components/LivePill'; import { LivePill } from '../../components/LivePill';
import { isPhoneWidth } from '../../lib/breakpoints';
import { OriginBadge } from '../../components/OriginBadge'; import { OriginBadge } from '../../components/OriginBadge';
import { VoiceInputButton } from '../../components/VoiceInputButton'; import { VoiceInputButton } from '../../components/VoiceInputButton';
import { FormSheet } from '../../components/FormSheet'; import { FormSheet } from '../../components/FormSheet';
@@ -126,10 +127,10 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
// Phones: the add form lives in a sheet opened by "+" instead of taking room above the list // Phones: the add form lives in a sheet opened by "+" instead of taking room above the list
const [isPhone, setIsPhone] = useState(() => typeof window !== 'undefined' && window.innerWidth < 640); const [isPhone, setIsPhone] = useState(isPhoneWidth);
const [showAddSheet, setShowAddSheet] = useState(false); const [showAddSheet, setShowAddSheet] = useState(false);
useEffect(() => { useEffect(() => {
const onResize = () => setIsPhone(window.innerWidth < 640); const onResize = () => setIsPhone(isPhoneWidth());
window.addEventListener('resize', onResize); window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize);
}, []); }, []);
@@ -838,7 +839,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
}, [openItems]); }, [openItems]);
const focusNewItemInput = () => { const focusNewItemInput = () => {
if (window.innerWidth < 640) { if (isPhoneWidth()) {
setShowAddSheet(true); setShowAddSheet(true);
return; return;
} }
@@ -4,6 +4,7 @@ import { X, Trash2, Loader2 } from 'lucide-react';
import type { EmojiClickData } from 'emoji-picker-react'; import type { EmojiClickData } from 'emoji-picker-react';
import { Categories, EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react'; import { Categories, EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
import { useKeyboardInset } from '../../lib/useKeyboardInset'; import { useKeyboardInset } from '../../lib/useKeyboardInset';
import { isPhoneWidth } from '../../lib/breakpoints';
// Lazy-load emoji-picker-react so the main bundle size stays small // Lazy-load emoji-picker-react so the main bundle size stays small
const EmojiPicker = React.lazy(() => import('emoji-picker-react')); const EmojiPicker = React.lazy(() => import('emoji-picker-react'));
@@ -38,7 +39,7 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
anchor, anchor,
}) => { }) => {
const { inset, visibleHeight } = useKeyboardInset(); const { inset, visibleHeight } = useKeyboardInset();
const isPhone = typeof window !== 'undefined' && window.innerWidth < 640; const isPhone = isPhoneWidth();
// On phones the picker shrinks with the keyboard so search field and results stay visible // On phones the picker shrinks with the keyboard so search field and results stay visible
const pickerHeight = isPhone ? Math.max(200, Math.min(350, visibleHeight - 110)) : 300; const pickerHeight = isPhone ? Math.max(200, Math.min(350, visibleHeight - 110)) : 300;
const [isDark, setIsDark] = useState<boolean>(() => const [isDark, setIsDark] = useState<boolean>(() =>
@@ -105,14 +106,14 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
const picker = ( const picker = (
<> <>
{/* Mobile Backdrop (< 640px) */} {/* Mobile Backdrop (below sm) */}
<div <div
className={`fixed inset-0 bg-black/60 sm:hidden backdrop-blur-xs ${anchor ? 'z-[60]' : 'z-40'}`} className={`fixed inset-0 bg-black/60 sm:hidden backdrop-blur-xs ${anchor ? 'z-[60]' : 'z-40'}`}
onClick={onClose} onClick={onClose}
aria-hidden="true" aria-hidden="true"
/> />
{/* Main Container: Mobile Bottom-Sheet (<640px) & Desktop Popover (>=640px) */} {/* Main Container: Mobile Bottom-Sheet (below sm) & Desktop Popover (from sm) */}
<div <div
ref={containerRef} ref={containerRef}
className={` className={`
@@ -60,6 +60,7 @@ import { CountBadge } from '../../components/CountBadge';
import { EmojiIconButton } from '../../components/EmojiIconButton'; import { EmojiIconButton } from '../../components/EmojiIconButton';
import { LazyMount } from '../../components/LazyMount'; import { LazyMount } from '../../components/LazyMount';
import { extractHashtags } from '../../lib/hashtags'; import { extractHashtags } from '../../lib/hashtags';
import { isPhoneWidth } from '../../lib/breakpoints';
interface WorkspaceViewProps { interface WorkspaceViewProps {
currentUser?: User | null; currentUser?: User | null;
@@ -624,7 +625,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [newNoteIcon, setNewNoteIcon] = useState('🗒️'); const [newNoteIcon, setNewNoteIcon] = useState('🗒️');
const openNewNote = useCallback( const openNewNote = useCallback(
(folderPath = '') => { (folderPath = '') => {
if (window.innerWidth >= 640) { if (!isPhoneWidth()) {
void handleCreateDocument(folderPath); void handleCreateDocument(folderPath);
return; return;
} }
+3 -3
View File
@@ -563,7 +563,7 @@ body {
--epr-dark-category-icon-active-color: var(--accent-primary); --epr-dark-category-icon-active-color: var(--accent-primary);
} }
@media (max-width: 639px) { @media (max-width: 767px) {
/* Prevent iOS auto-zoom on input focus */ /* Prevent iOS auto-zoom on input focus */
.mindsift-emoji-picker .EmojiPickerReact input.epr-search-input { .mindsift-emoji-picker .EmojiPickerReact input.epr-search-input {
font-size: 1rem !important; font-size: 1rem !important;
@@ -584,7 +584,7 @@ body {
} }
} }
@media (min-width: 640px) { @media (min-width: 768px) {
/* Desktop: compact like a context menu, in step with the app's small type (the sprite of the category /* Desktop: compact like a context menu, in step with the app's small type (the sprite of the category
icons scales with its size variable) */ icons scales with its size variable) */
.mindsift-emoji-picker .EmojiPickerReact { .mindsift-emoji-picker .EmojiPickerReact {
@@ -630,7 +630,7 @@ fieldset:disabled :is(button, input, select, textarea) {
--outline-indent: 14px; --outline-indent: 14px;
--outline-min-text: 14rem; --outline-min-text: 14rem;
} }
@media (min-width: 640px) { @media (min-width: 768px) {
:root { :root {
--outline-indent: 22px; --outline-indent: 22px;
--outline-min-text: 0px; --outline-min-text: 0px;
+5
View File
@@ -0,0 +1,5 @@
// Width below which the app uses its phone layout; the same value as Tailwind's sm (tailwind.config.js)
// and the media queries in index.css.
export const PHONE_MAX_WIDTH = 768;
export const isPhoneWidth = (): boolean => typeof window !== 'undefined' && window.innerWidth < PHONE_MAX_WIDTH;
+10
View File
@@ -12,6 +12,16 @@ export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
darkMode: 'class', darkMode: 'class',
theme: { theme: {
// Phone layout below sm, desktop from sm (see AGENTS.md). sm is 768 px, not Tailwind's 640, so phones in
// landscape and small tablets (iPad mini upright) get the touch layout too; keep PHONE_MAX_WIDTH in
// src/lib/breakpoints.ts and the media queries in index.css in step.
screens: {
sm: '768px',
md: '768px',
lg: '1024px',
xl: '1280px',
'2xl': '1536px',
},
extend: { extend: {
colors: { colors: {
canvas: v('--bg-app'), canvas: v('--bg-app'),