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

This commit is contained in:
sandrodach committed 2026-11-02 13:58:38 +01:00
commit 14f63f0b3a
14 files changed
+175 -75

No files matched your search

+14 -1
View File
@@ -6,6 +6,18 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
## [Unreleased] ## [Unreleased]
## [0.58.0] - 2026-11-02
### Added
- Aufgaben: „Alle Aufgaben“ ist standardmäßig nach Liste gruppiert. Aufgaben ohne Liste stehen als „Ohne Liste“ ganz oben, danach die Listen in der Reihenfolge der Reiter. Unter „Gruppe“ lässt sich wie bisher anders gruppieren; einzelne Listen bleiben ungruppiert.
### Changed
- Gewählte Aufgabenliste, Listen-/Kanban-Ansicht und Gruppierung sowie die gewählte Einkaufsliste merkt sich MindSift jetzt pro Gerät, auch über einen Neustart der installierten App oder einen neuen Tab hinweg.
- Zähler an Reitern und in der Navigation stehen direkt hinter dem Wort und hängen immer nur 2 px über den letzten Buchstaben, auch bei zwei- und dreistelligen Zahlen. Sie sind überall gefüllt (am Handy schien vorher das Icon durch) und haben keinen dunklen Ring mehr.
### Fixed
- Handy: In den Aufgaben-Details waren Aufgabenliste, Personen-Suche, Hashtag und Unteraufgabe deutlich kleiner als die übrigen Felder; jetzt sind alle gleich groß (auch beim Teilen und in Gruppen), und iOS zoomt beim Antippen nicht mehr hinein. Der Knopf zur verknüpften Notiz heißt dort kurz „Notiz“, damit der Kopf einzeilig bleibt.
## [0.57.0] - 2026-11-02 ## [0.57.0] - 2026-11-02
### Changed ### Changed
@@ -836,7 +848,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.57.0...dev [Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.58.0...dev
[0.58.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.57.0...v0.58.0
[0.57.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.56.0...v0.57.0 [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
+5 -3
View File
@@ -14,6 +14,7 @@ Eingesetzt beim Teilen (`UnifiedShareModal`), beim Hinzufügen zu Gruppen (`Grou
- Bereits gewählte oder freigegebene Personen werden über `excludeUserIds` ausgeblendet. - Bereits gewählte oder freigegebene Personen werden über `excludeUserIds` ausgeblendet.
- **Einzelauswahl** (Teilen, Gruppen): `selectedUser` setzen; das Feld zeigt dann die gewählte Person mit ×. - **Einzelauswahl** (Teilen, Gruppen): `selectedUser` setzen; das Feld zeigt dann die gewählte Person mit ×.
- **Mehrfachauswahl** (Aufgaben zuweisen): `selectedUser={null}` lassen und in `onSelectUser` zur Liste hinzufügen. Die Gewählten stehen als Chips (Avatar, Name, ×) über dem Feld, das Feld bleibt für die nächste Suche frei. - **Mehrfachauswahl** (Aufgaben zuweisen): `selectedUser={null}` lassen und in `onSelectUser` zur Liste hinzufügen. Die Gewählten stehen als Chips (Avatar, Name, ×) über dem Feld, das Feld bleibt für die nächste Suche frei.
- Am Handy sind Feld, gewählte Person und Trefferzeilen 44 px hoch (Schrift 16 px, kein Zoom unter iOS), ab `sm` wie bisher 32 px.
## Profilbilder als Stapel ## Profilbilder als Stapel
@@ -58,13 +59,14 @@ Komponente: [FormSheet.tsx](../frontend/src/components/FormSheet.tsx), Tastatur-
Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Klassen `count-badge*` in [index.css](../frontend/src/index.css). Es gibt keine anderen Zähler-Stile mehr. Komponente: [CountBadge.tsx](../frontend/src/components/CountBadge.tsx), CSS-Klassen `count-badge*` in [index.css](../frontend/src/index.css). Es gibt keine anderen Zähler-Stile mehr.
- **Form:** runder Kreis, mindestens 16 px, ab 100 als „99+“ (der Screenreader liest die echte Zahl). Bei 0 ausgeblendet, außer mit `showZero` (z. B. Mitgliederzahl, mobiles Menü). - **Form:** runder, gefüllter Kreis mit dünnem Rand, ohne Schatten oder Ring, mindestens 16 px, ab 100 als „99+“ (der Screenreader liest die echte Zahl). Bei 0 ausgeblendet, außer mit `showZero` (z. B. Mitgliederzahl, mobiles Menü).
- **Farbe (`variant`):** - **Farbe (`variant`):**
- `accent`: Hier wartet etwas auf dich. Navigationsleiste: Triage (Inbox), Aufgaben (offene Aufgaben), Listen (offene Einträge aller eigenen und geteilten Listen). Workspace hat keinen Zähler. - `accent`: Hier wartet etwas auf dich. Navigationsleiste: Triage (Inbox), Aufgaben (offene Aufgaben), Listen (offene Einträge aller eigenen und geteilten Listen). Workspace hat keinen Zähler.
- `muted` (Standard): reine Anzahl. Tabs der Aufgabenlisten und Listen, Organisationen, mobiles Menü. - `muted` (Standard): reine Anzahl. Tabs der Aufgabenlisten und Listen, Organisationen, mobiles Menü.
- Beide kommen aus den Theme-Variablen (`--accent-primary`/`--text-on-accent` bzw. `--text-muted`/`--bg-panel`) und passen sich damit jedem Theme an. - Beide kommen aus den Theme-Variablen (`--accent-primary`/`--text-on-accent` bzw. `--text-muted`/`--bg-panel`) und passen sich damit jedem Theme an. Der Akzent liegt auf einer deckenden Panel-Fläche, damit am Handy das Icon darunter nicht durchscheint.
- **Position (`position`):** - **Position (`position`):**
- `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement, mit Ring in der Seitenfarbe. In der Navigation und an den Tabs sitzt er **auf dem Text**: der Text steht in einem `relative inline-flex`-Span (mit `pr-3`, solange ein Zähler da ist), der Kreis bekommt `-top-0.5 -right-2`, damit er die obere Tab-Linie nicht anschneidet und nicht am Text klebt. In der mobilen Leiste sitzt er am Icon (`-top-1.5 -right-2.5`). - `text`: an Beschriftungen (Navigation, Reiter der Aufgabenlisten und Listen, Workspace-Kopf). Der Zähler steht im Textfluss direkt hinter dem Wort, leicht angehoben, und hängt immer 2 px über den letzten Buchstaben, egal wie viele Stellen die Zahl hat; breitere Zahlen machen den Reiter breiter. Der Text steht dafür in einem `inline-flex items-center`-Span.
- `corner`: Benachrichtigungs-Badge oben rechts am nächsten `relative` Elternelement; für Icons, z. B. in der mobilen Leiste (`-top-1.5 -right-2.5`).
- `inline`: im Textfluss. - `inline`: im Textfluss.
- Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken. - Zahlen im mobilen Menü (`MobileActionContext`, `badge: number`) werden automatisch als `CountBadge` gezeigt; Text-Badges wie „2 aktiv“ bleiben Textmarken.
- **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben. - **Wichtig für neue Klassen:** Tailwind entfernt Klassen aus `@layer components`, die nicht wörtlich im Code stehen. Klassennamen deshalb nie zusammensetzen (`count-badge-${variant}`), sondern ausschreiben.
+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.57.0-16a34a?style=flat-square)](CHANGELOG.md) [![Version](https://img.shields.io/badge/Version-v0.58.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.57.0", "version": "0.58.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mindsift-frontend", "name": "mindsift-frontend",
"version": "0.57.0", "version": "0.58.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.57.0", "version": "0.58.0",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"type": "module", "type": "module",
"scripts": { "scripts": {
+6 -6
View File
@@ -1198,9 +1198,9 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`} className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`}
> >
<Layers className="w-4 h-4" /> <Layers className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.inboxCount > 0 ? 'pr-3' : ''}`}> <span className="inline-flex items-center">
<span>Triage</span> <span>Triage</span>
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-0.5 -right-2" /> <CountBadge count={stats.inboxCount} variant="accent" position="text" />
</span> </span>
</button> </button>
@@ -1223,9 +1223,9 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`} className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
> >
<CheckSquare className="w-4 h-4" /> <CheckSquare className="w-4 h-4" />
<span className={`relative inline-flex items-center ${stats.tasksOpenCount > 0 ? 'pr-3' : ''}`}> <span className="inline-flex items-center">
<span>Aufgaben</span> <span>Aufgaben</span>
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" /> <CountBadge count={stats.tasksOpenCount} variant="accent" position="text" />
</span> </span>
</button> </button>
@@ -1237,9 +1237,9 @@ export const App: React.FC = () => {
className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`} className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`}
> >
<ShoppingCart className="w-4 h-4" /> <ShoppingCart className="w-4 h-4" />
<span className={`relative inline-flex items-center ${(stats.listItemsOpenCount ?? 0) > 0 ? 'pr-3' : ''}`}> <span className="inline-flex items-center">
<span>Listen</span> <span>Listen</span>
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" /> <CountBadge count={stats.listItemsOpenCount} variant="accent" position="text" />
</span> </span>
</button> </button>
</div> </div>
+5 -3
View File
@@ -4,14 +4,16 @@ interface CountBadgeProps {
count: number | undefined | null; count: number | undefined | null;
// accent: something waits for you (nav bar); muted: plain amount (tabs, lists) // accent: something waits for you (nav bar); muted: plain amount (tabs, lists)
variant?: 'accent' | 'muted'; variant?: 'accent' | 'muted';
// corner: notification badge on the top right of the nearest `relative` parent // corner: notification badge on the top right of the nearest `relative` parent (icons);
position?: 'inline' | 'corner'; // text: right after a label, raised and hanging a little over its last letter (tabs, nav bar)
position?: 'inline' | 'corner' | 'text';
showZero?: boolean; showZero?: boolean;
className?: string; // e.g. to fine-tune the corner offset className?: string; // e.g. to fine-tune the corner offset
} }
// Full class names: Tailwind drops component classes it cannot find literally in the source // Full class names: Tailwind drops component classes it cannot find literally in the source
const VARIANT_CLASS = { accent: 'count-badge-accent', muted: 'count-badge-muted' }; const VARIANT_CLASS = { accent: 'count-badge-accent', muted: 'count-badge-muted' };
const POSITION_CLASS = { inline: '', corner: 'count-badge-corner', text: 'count-badge-text' };
// The one counter of the app (Doku/ui-patterns.md); hides itself at 0 unless showZero // The one counter of the app (Doku/ui-patterns.md); hides itself at 0 unless showZero
export const CountBadge: React.FC<CountBadgeProps> = ({ export const CountBadge: React.FC<CountBadgeProps> = ({
@@ -25,7 +27,7 @@ export const CountBadge: React.FC<CountBadgeProps> = ({
const label = count > 99 ? '99+' : String(count); const label = count > 99 ? '99+' : String(count);
return ( return (
<span <span
className={`count-badge ${VARIANT_CLASS[variant]} ${position === 'corner' ? 'count-badge-corner' : ''} ${className}`} className={`count-badge ${VARIANT_CLASS[variant]} ${POSITION_CLASS[position]} ${className}`}
aria-label={String(count)} aria-label={String(count)}
> >
{label} {label}
+3 -3
View File
@@ -103,7 +103,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
return ( return (
<div ref={containerRef} className={`relative ${className}`}> <div ref={containerRef} className={`relative ${className}`}>
{selectedUser ? ( {selectedUser ? (
<div className="h-8 px-2.5 bg-panel-elevated border border-accent/40 rounded-btn flex items-center justify-between gap-2 text-xs"> <div className="h-11 sm:h-8 px-2.5 bg-panel-elevated border border-accent/40 rounded-btn flex items-center justify-between gap-2 text-xs">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<UserAvatar user={selectedUser} size="xs" /> <UserAvatar user={selectedUser} size="xs" />
<span className="font-semibold text-main truncate"> <span className="font-semibold text-main truncate">
@@ -148,7 +148,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
onFocus={() => setIsOpen(true)} onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={placeholder} placeholder={placeholder}
className="w-full h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-xs placeholder:text-muted transition-colors disabled:opacity-50" className="w-full h-11 sm:h-8 pl-8 pr-2.5 bg-input text-main border border-border rounded-btn focus:outline-none focus:border-accent text-base sm:text-xs placeholder:text-muted transition-colors disabled:opacity-50"
/> />
{showResults && ( {showResults && (
@@ -166,7 +166,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
type="button" type="button"
onClick={() => handleSelect(u)} onClick={() => handleSelect(u)}
onMouseEnter={() => setHighlightedIndex(idx)} onMouseEnter={() => setHighlightedIndex(idx)}
className={`w-full px-2.5 py-1.5 flex items-center justify-between gap-2 text-left text-xs transition-colors cursor-pointer ${ className={`w-full min-h-11 sm:min-h-0 px-2.5 py-1.5 flex items-center justify-between gap-2 text-left text-xs transition-colors cursor-pointer ${
isHighlighted isHighlighted
? 'bg-accent/15 text-main font-medium' ? 'bg-accent/15 text-main font-medium'
: 'hover:bg-panel-elevated text-main' : 'hover:bg-panel-elevated text-main'
@@ -11,7 +11,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types'; import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
import { api, triggerHaptic, createRealtimeConnection } from '../../lib/api'; import { api, triggerHaptic, createRealtimeConnection } from '../../lib/api';
import { readViewSession, writeViewSession } from '../../lib/viewSession'; import { readViewMemory, writeViewMemory } from '../../lib/viewSession';
import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline'; import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline';
import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { UnifiedShareModal } from '../../components/UnifiedShareModal';
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack'; import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
@@ -91,12 +91,12 @@ export function deduplicateItems(items: ListItem[]): ListItem[] {
export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => { export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGlobal, currentUser, initialListId }) => {
const { setGroups, setPrimaryAction } = useMobileActions(); const { setGroups, setPrimaryAction } = useMobileActions();
const [lists, setLists] = useState<SharedList[]>([]); const [lists, setLists] = useState<SharedList[]>([]);
// The open list survives switching to other tabs (like the workspace's open note) // The open list is remembered on this device (other tabs of the app, new tab, restart)
const [activeListId, setActiveListId] = useState<string>( const [activeListId, setActiveListId] = useState<string>(
() => initialListId || readViewSession<{ listId: string }>('lists').listId || '' () => initialListId || readViewMemory<{ listId: string }>('lists').listId || ''
); );
useEffect(() => { useEffect(() => {
if (activeListId) writeViewSession('lists', { listId: activeListId }); if (activeListId) writeViewMemory('lists', { listId: activeListId });
}, [activeListId]); }, [activeListId]);
useEffect(() => { useEffect(() => {
@@ -1063,9 +1063,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
> >
<span>{lst.icon}</span> <span>{lst.icon}</span>
{/* Counter on the label, like in the nav bar */} {/* Counter on the label, like in the nav bar */}
<span className={`relative inline-flex items-center ${openCount > 0 ? 'pr-3' : ''}`}> <span className="inline-flex items-center">
{lst.title} {lst.title}
<CountBadge count={openCount} position="corner" className="-top-0.5 -right-2" /> <CountBadge count={openCount} position="text" />
</span> </span>
<AvatarStack <AvatarStack
people={otherMembers(lst)} people={otherMembers(lst)}
@@ -287,7 +287,9 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
className="btn-primary min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 font-semibold" className="btn-primary min-h-[38px] sm:min-h-0 h-9 sm:h-7 px-2.5 sm:px-2 text-xs flex items-center gap-1.5 font-semibold"
> >
<FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5" /> <FileText className="w-4 h-4 sm:w-3.5 sm:h-3.5" />
<span>In Notiz öffnen</span> {/* Short on phones, so the header buttons stay on one line */}
<span className="sm:hidden">Notiz</span>
<span className="hidden sm:inline">In Notiz öffnen</span>
</button> </button>
) : !readOnly && ( ) : !readOnly && (
<button <button
@@ -611,7 +613,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
<select <select
value={task.taskListId || ''} value={task.taskListId || ''}
onChange={(e) => onUpdateTaskList(task, e.target.value)} onChange={(e) => onUpdateTaskList(task, e.target.value)}
className="w-full h-8 input-field px-2.5 text-xs bg-panel" className="w-full h-11 sm:h-8 input-field px-2.5 text-base sm:text-xs bg-panel"
> >
<option value="">-- Keine Liste (Allgemein) --</option> <option value="">-- Keine Liste (Allgemein) --</option>
{taskLists.map((tl) => ( {taskLists.map((tl) => (
@@ -714,7 +716,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
value={newTagDraft} value={newTagDraft}
onChange={(e) => setNewTagDraft(e.target.value)} onChange={(e) => setNewTagDraft(e.target.value)}
placeholder="+ #hashtag" placeholder="+ #hashtag"
className="h-6 w-24 px-1.5 text-xs input-field" className="h-11 sm:h-6 w-32 sm:w-24 px-2 sm:px-1.5 text-base sm:text-xs input-field"
/> />
</form> </form>
)} )}
@@ -802,13 +804,13 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
type="text" type="text"
value={newSubtaskTitle} value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)} onChange={(e) => setNewSubtaskTitle(e.target.value)}
placeholder="Neue Unteraufgabe hinzufügen…" placeholder="Neue Unteraufgabe…"
className="flex-1 h-7 px-2 text-xs input-field" className="flex-1 min-w-0 h-11 sm:h-7 px-2 text-base sm:text-xs input-field"
/> />
<button <button
type="submit" type="submit"
disabled={!newSubtaskTitle.trim()} disabled={!newSubtaskTitle.trim()}
className="btn-action h-7 px-2.5 text-xs disabled:opacity-40" className="btn-action h-11 sm:h-7 px-2.5 text-xs disabled:opacity-40"
> >
<Plus className="w-3 h-3" /> <Plus className="w-3 h-3" />
<span>Hinzufügen</span> <span>Hinzufügen</span>
+86 -36
View File
@@ -30,7 +30,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS, TrashedTask } from '../../lib/types'; import { TaskList, Thought, SubTask, Team, User, TaskListMember, KanbanColumn, PriorityLevel, Recurrence, RECURRENCE_LABELS, TrashedTask } from '../../lib/types';
import { formatDeletedAt, formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due'; import { formatDeletedAt, formatDue, isDueToday, isOverdue, localDay, splitDue } from '../../lib/due';
import { readViewSession, writeViewSession } from '../../lib/viewSession'; import { readViewMemory, writeViewMemory } from '../../lib/viewSession';
import { useKanbanTouchDrag } from './useKanbanTouchDrag'; import { useKanbanTouchDrag } from './useKanbanTouchDrag';
import { api, triggerHaptic } from '../../lib/api'; import { api, triggerHaptic } from '../../lib/api';
import { UnifiedShareModal } from '../../components/UnifiedShareModal'; import { UnifiedShareModal } from '../../components/UnifiedShareModal';
@@ -91,7 +91,28 @@ const kanbanBoardStyle = (wide: boolean, columns: number): React.CSSProperties =
} as React.CSSProperties; } as React.CSSProperties;
}; };
type TasksSession = { listId: string; viewMode: 'list' | 'kanban' }; type GroupBy = 'none' | 'list' | 'tag' | 'status' | 'priority' | 'due';
type TasksSession = { listId: string; viewMode: 'list' | 'kanban'; groupAll: GroupBy; groupList: GroupBy };
// "Alle Aufgaben" is grouped by task list unless the user picks something else; a single list has no
// grouping by default (grouping it by list would give one group)
const DEFAULT_GROUP_ALL: GroupBy = 'list';
const DEFAULT_GROUP_LIST: GroupBy = 'none';
const GROUP_LABELS: Record<GroupBy, string> = {
none: 'Keine',
list: 'Nach Liste',
tag: 'Nach Hashtag',
status: 'Nach Status',
priority: 'Nach Priorität',
due: 'Nach Fälligkeit',
};
const SORT_LABELS: Record<string, string> = {
custom: 'Manuell',
created: 'Erstelldatum',
due: 'Fälligkeitsdatum',
priority: 'Priorität',
alpha: 'Alphabetisch',
};
export const TasksView: React.FC<TasksViewProps> = ({ export const TasksView: React.FC<TasksViewProps> = ({
currentUser, currentUser,
@@ -102,9 +123,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
onInitialTaskShown, onInitialTaskShown,
openTasksCount, openTasksCount,
}) => { }) => {
// Open list and view survive switching to other tabs (like the workspace's open note) // Open list, view and grouping are remembered on this device (new tab, restart of the installed app)
const remembered = useMemo(() => readViewMemory<TasksSession>('tasks'), []);
const [viewMode, setViewMode] = useState<'list' | 'kanban'>(() => const [viewMode, setViewMode] = useState<'list' | 'kanban'>(() =>
readViewSession<TasksSession>('tasks').viewMode === 'kanban' ? 'kanban' : 'list' remembered.viewMode === 'kanban' ? 'kanban' : 'list'
);
const [groupAll, setGroupAll] = useState<GroupBy>(() =>
remembered.groupAll && remembered.groupAll in GROUP_LABELS ? remembered.groupAll : DEFAULT_GROUP_ALL
);
const [groupList, setGroupList] = useState<GroupBy>(() =>
remembered.groupList && remembered.groupList in GROUP_LABELS && remembered.groupList !== 'list'
? remembered.groupList
: DEFAULT_GROUP_LIST
); );
// Wide Kanban: the board takes the whole window and shows every column in one row // Wide Kanban: the board takes the whole window and shows every column in one row
const [kanbanWide, setKanbanWide] = useState(() => { const [kanbanWide, setKanbanWide] = useState(() => {
@@ -128,11 +158,19 @@ export const TasksView: React.FC<TasksViewProps> = ({
const [tasks, setTasks] = useState<Thought[]>([]); const [tasks, setTasks] = useState<Thought[]>([]);
const [taskLists, setTaskLists] = useState<TaskList[]>([]); const [taskLists, setTaskLists] = useState<TaskList[]>([]);
const [selectedListId, setSelectedListId] = useState<string>( const [selectedListId, setSelectedListId] = useState<string>(
() => readViewSession<TasksSession>('tasks').listId || 'all' () => remembered.listId || 'all'
); );
const isAllTasks = selectedListId === 'all';
const groupBy: GroupBy = isAllTasks ? groupAll : groupList;
const setGroupBy = (g: GroupBy) => (isAllTasks ? setGroupAll(g) : setGroupList(g));
const defaultGroupBy = isAllTasks ? DEFAULT_GROUP_ALL : DEFAULT_GROUP_LIST;
// "Nach Liste" only where several lists show up
const groupOptions = (Object.keys(GROUP_LABELS) as GroupBy[])
.filter((id) => isAllTasks || id !== 'list')
.map((id) => ({ id, label: GROUP_LABELS[id] }));
useEffect(() => { useEffect(() => {
writeViewSession<TasksSession>('tasks', { listId: selectedListId, viewMode }); writeViewMemory<TasksSession>('tasks', { listId: selectedListId, viewMode, groupAll, groupList });
}, [selectedListId, viewMode]); }, [selectedListId, viewMode, groupAll, groupList]);
const [teams, setTeams] = useState<Team[]>([]); const [teams, setTeams] = useState<Team[]>([]);
const [collaborators, setCollaborators] = useState<User[]>([]); const [collaborators, setCollaborators] = useState<User[]>([]);
// Task trash of the selected list (or all): deleted tasks and cleared or cleaned-up completed ones // Task trash of the selected list (or all): deleted tasks and cleared or cleaned-up completed ones
@@ -216,7 +254,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
const tagRowRef = useRef<HTMLDivElement>(null); const tagRowRef = useRef<HTMLDivElement>(null);
const [tagsExpanded, setTagsExpanded] = useState(false); const [tagsExpanded, setTagsExpanded] = useState(false);
const [tagsOverflow, setTagsOverflow] = useState(false); const [tagsOverflow, setTagsOverflow] = useState(false);
const [groupBy, setGroupBy] = useState<'none' | 'tag' | 'status' | 'priority' | 'due'>('none');
const [sortBy, setSortBy] = useState<'custom' | 'created' | 'due' | 'priority' | 'alpha'>('custom'); const [sortBy, setSortBy] = useState<'custom' | 'created' | 'due' | 'priority' | 'alpha'>('custom');
// Reloads happen after almost every action and on realtime events. Only the first load of a list shows // Reloads happen after almost every action and on realtime events. Only the first load of a list shows
@@ -571,7 +608,25 @@ export const TasksView: React.FC<TasksViewProps> = ({
if (groupBy === 'none') return null; if (groupBy === 'none') return null;
const groups: { key: string; title: string; count: number; tasks: Thought[] }[] = []; const groups: { key: string; title: string; count: number; tasks: Thought[] }[] = [];
if (groupBy === 'tag') { if (groupBy === 'list') {
// Tasks without a list first, then the lists in the order of the tabs
const byList = new Map<string, Thought[]>();
for (const t of filteredTasks) {
const key = t.taskListId || '';
if (!byList.has(key)) byList.set(key, []);
byList.get(key)!.push(t);
}
const without = byList.get('') || [];
if (without.length > 0) groups.push({ key: 'list:none', title: 'Ohne Liste', count: without.length, tasks: without });
for (const tl of safeTaskLists) {
const list = byList.get(tl.id);
if (!list || list.length === 0) continue;
groups.push({ key: `list:${tl.id}`, title: [tl.icon, tl.title].filter(Boolean).join(' '), count: list.length, tasks: list });
byList.delete(tl.id);
}
byList.delete('');
byList.forEach((list, id) => groups.push({ key: `list:${id}`, title: 'Andere Liste', count: list.length, tasks: list }));
} else if (groupBy === 'tag') {
const map = new Map<string, Thought[]>(); const map = new Map<string, Thought[]>();
const withoutTag: Thought[] = []; const withoutTag: Thought[] = [];
for (const t of filteredTasks) { for (const t of filteredTasks) {
@@ -616,7 +671,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
if (noDue.length > 0) groups.push({ key: 'due:none', title: 'Ohne Fälligkeitsdatum', count: noDue.length, tasks: noDue }); if (noDue.length > 0) groups.push({ key: 'due:none', title: 'Ohne Fälligkeitsdatum', count: noDue.length, tasks: noDue });
} }
return groups; return groups;
}, [filteredTasks, groupBy, boardColumns, inBoardColumn]); }, [filteredTasks, groupBy, boardColumns, inBoardColumn, safeTaskLists]);
// --- TASK ACTIONS --- // --- TASK ACTIONS ---
@@ -1341,7 +1396,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
]; ];
const filterSortActiveCount = const filterSortActiveCount =
(selectedTag ? 1 : 0) + (groupBy !== 'none' ? 1 : 0) + (sortBy !== 'custom' ? 1 : 0); (selectedTag ? 1 : 0) + (groupBy !== defaultGroupBy ? 1 : 0) + (sortBy !== 'custom' ? 1 : 0);
const filterItems: any[] = [ const filterItems: any[] = [
{ {
@@ -1482,6 +1537,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
selectedListId, selectedListId,
selectedTag, selectedTag,
groupBy, groupBy,
defaultGroupBy,
sortBy, sortBy,
openTasks.length, openTasks.length,
doneTasks.length, doneTasks.length,
@@ -1796,9 +1852,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
}`} }`}
> >
{/* Counter on the label, like in the nav bar */} {/* Counter on the label, like in the nav bar */}
<span className={`relative inline-flex items-center ${openTasksCount ? 'pr-3' : ''}`}> <span className="inline-flex items-center">
Alle Aufgaben Alle Aufgaben
<CountBadge count={openTasksCount} position="corner" className="-top-0.5 -right-2" /> <CountBadge count={openTasksCount} position="text" />
</span> </span>
</button> </button>
@@ -1825,9 +1881,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn" className="h-full px-3 text-xs font-medium flex items-center gap-1.5 rounded-btn"
> >
<span>{tl.icon}</span> <span>{tl.icon}</span>
<span className={`relative inline-flex items-center ${tl.tasksCount ? 'pr-3' : ''}`}> <span className="inline-flex items-center">
{tl.title} {tl.title}
<CountBadge count={tl.tasksCount} position="corner" className="-top-0.5 -right-2" /> <CountBadge count={tl.tasksCount} position="text" />
</span> </span>
{(() => { {(() => {
@@ -2051,7 +2107,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
)} )}
{/* Mobile Active Filter Badge Banner (minimal and clean) */} {/* Mobile Active Filter Badge Banner (minimal and clean) */}
{(selectedTag || groupBy !== 'none' || sortBy !== 'custom') && ( {(selectedTag || groupBy !== defaultGroupBy || sortBy !== 'custom') && (
<div className="flex sm:hidden items-center justify-between gap-2 p-2 rounded-btn bg-accent/10 border border-accent/20 text-xs"> <div className="flex sm:hidden items-center justify-between gap-2 p-2 rounded-btn bg-accent/10 border border-accent/20 text-xs">
<div className="flex items-center gap-1.5 flex-wrap min-w-0"> <div className="flex items-center gap-1.5 flex-wrap min-w-0">
<Filter className="w-3.5 h-3.5 text-accent flex-shrink-0" /> <Filter className="w-3.5 h-3.5 text-accent flex-shrink-0" />
@@ -2067,14 +2123,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
</button> </button>
</span> </span>
)} )}
{groupBy !== 'none' && ( {groupBy !== defaultGroupBy && (
<span className="text-muted text-[11px]"> <span className="text-muted text-[11px]">
Gruppe: <strong className="text-main">{groupBy}</strong> Gruppe: <strong className="text-main">{GROUP_LABELS[groupBy]}</strong>
</span> </span>
)} )}
{sortBy !== 'custom' && ( {sortBy !== 'custom' && (
<span className="text-muted text-[11px]"> <span className="text-muted text-[11px]">
Sortiert: <strong className="text-main">{sortBy}</strong> Sortiert: <strong className="text-main">{SORT_LABELS[sortBy]}</strong>
</span> </span>
)} )}
</div> </div>
@@ -2082,7 +2138,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedTag(null); setSelectedTag(null);
setGroupBy('none'); setGroupBy(defaultGroupBy);
setSortBy('custom'); setSortBy('custom');
}} }}
className="text-accent text-[11px] font-semibold flex-shrink-0 hover:underline cursor-pointer" className="text-accent text-[11px] font-semibold flex-shrink-0 hover:underline cursor-pointer"
@@ -2397,17 +2453,11 @@ export const TasksView: React.FC<TasksViewProps> = ({
Gruppierung Gruppierung
</span> </span>
<div className="grid grid-cols-2 gap-1.5"> <div className="grid grid-cols-2 gap-1.5">
{[ {groupOptions.map((g) => (
{ id: 'none', label: 'Keine' },
{ id: 'tag', label: 'Nach Hashtag' },
{ id: 'status', label: 'Nach Status' },
{ id: 'priority', label: 'Nach Priorität' },
{ id: 'due', label: 'Nach Fälligkeit' },
].map((g) => (
<button <button
key={g.id} key={g.id}
type="button" type="button"
onClick={() => setGroupBy(g.id as any)} onClick={() => setGroupBy(g.id)}
className={`min-h-[36px] px-3 text-xs rounded-btn border text-left transition-colors cursor-pointer ${ className={`min-h-[36px] px-3 text-xs rounded-btn border text-left transition-colors cursor-pointer ${
groupBy === g.id groupBy === g.id
? 'bg-accent-dim border-accent text-accent font-semibold' ? 'bg-accent-dim border-accent text-accent font-semibold'
@@ -2457,7 +2507,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedTag(null); setSelectedTag(null);
setGroupBy('none'); setGroupBy(defaultGroupBy);
setSortBy('custom'); setSortBy('custom');
}} }}
className="btn-ghost h-9 px-3 text-xs text-muted hover:text-main cursor-pointer" className="btn-ghost h-9 px-3 text-xs text-muted hover:text-main cursor-pointer"
@@ -2918,14 +2968,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
<span className="text-[11px]">Gruppe:</span> <span className="text-[11px]">Gruppe:</span>
<select <select
value={groupBy} value={groupBy}
onChange={(e) => setGroupBy(e.target.value as any)} onChange={(e) => setGroupBy(e.target.value as GroupBy)}
className="h-8 px-2 input-field text-xs bg-panel" className="h-8 px-2 input-field text-xs bg-panel"
> >
<option value="none">Keine</option> {groupOptions.map((g) => (
<option value="tag">Nach Hashtag</option> <option key={g.id} value={g.id}>
<option value="status">Nach Status</option> {g.label}
<option value="priority">Nach Priorität</option> </option>
<option value="due">Nach Fälligkeit</option> ))}
</select> </select>
</div> </div>
<div className="flex items-center gap-1 text-xs text-muted"> <div className="flex items-center gap-1 text-xs text-muted">
@@ -3014,7 +3064,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{groupedTasks ? ( {groupedTasks && groupedTasks.length > 0 ? (
/* GROUPED LIST */ /* GROUPED LIST */
groupedTasks.map((grp) => ( groupedTasks.map((grp) => (
<div key={grp.key} className="panel-surface overflow-hidden"> <div key={grp.key} className="panel-surface overflow-hidden">
@@ -1623,9 +1623,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}`} }`}
> >
<Check className="w-3.5 h-3.5 text-status-ok" /> <Check className="w-3.5 h-3.5 text-status-ok" />
<span className={`relative inline-flex items-center ${totalOpenTodos > 0 ? 'pr-3' : ''}`}> <span className="inline-flex items-center">
To-Dos To-Dos
<CountBadge count={totalOpenTodos} position="corner" className="-top-0.5 -right-2" /> <CountBadge count={totalOpenTodos} position="text" />
</span> </span>
</button> </button>
@@ -1642,9 +1642,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}`} }`}
> >
<Hash className="w-3.5 h-3.5 text-accent" /> <Hash className="w-3.5 h-3.5 text-accent" />
<span className={`relative inline-flex items-center ${tagsSummary.length > 0 ? 'pr-3' : ''}`}> <span className="inline-flex items-center">
Hashtags Hashtags
<CountBadge count={tagsSummary.length} position="corner" className="-top-0.5 -right-2" /> <CountBadge count={tagsSummary.length} position="text" />
</span> </span>
</button> </button>
</div> </div>
+10 -3
View File
@@ -322,7 +322,8 @@ body {
pointer-events: none; pointer-events: none;
} }
.count-badge-accent { .count-badge-accent {
background: var(--accent-dim); /* The tint on an opaque panel: the badge stays solid on top of icons (phone nav bar) */
background: linear-gradient(var(--accent-dim), var(--accent-dim)), var(--bg-panel);
color: var(--accent-primary); color: var(--accent-primary);
border: 1px solid var(--accent-border); border: 1px solid var(--accent-border);
} }
@@ -336,8 +337,14 @@ body {
top: -0.5rem; top: -0.5rem;
right: -0.5rem; right: -0.5rem;
z-index: 1; z-index: 1;
/* ring in the page colour keeps the badge readable on top of borders */ }
box-shadow: 0 0 0 2px var(--bg-app); /* In the text flow right after a label: hangs 2 px over the last letter, however many digits; wider
numbers make the tab wider instead of covering more of the word */
.count-badge-text {
position: relative;
top: -0.375rem;
margin-left: -2px;
z-index: 1;
} }
.badge-ok { .badge-ok {
+24
View File
@@ -18,3 +18,27 @@ export function writeViewSession<T extends object>(view: string, state: T) {
// storage blocked: the view simply starts fresh // storage blocked: the view simply starts fresh
} }
} }
// Remembered per device as well (localStorage): a new tab or a restart of the installed app opens the
// same list again. This tab's own state wins, so two tabs can show different lists.
const DEVICE_PREFIX = 'mindsift_view_device:';
export function readViewMemory<T extends object>(view: string): Partial<T> {
let device: Partial<T> = {};
try {
const raw = JSON.parse(localStorage.getItem(DEVICE_PREFIX + view) || '{}');
if (raw && typeof raw === 'object') device = raw;
} catch {
// storage blocked or broken: the tab state alone
}
return { ...device, ...readViewSession<T>(view) };
}
export function writeViewMemory<T extends object>(view: string, state: T) {
writeViewSession(view, state);
try {
localStorage.setItem(DEVICE_PREFIX + view, JSON.stringify(state));
} catch {
// only a convenience
}
}