Merge dev into main (v0.58.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 31s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 31s
This commit is contained in:
commit
14f63f0b3a
14 files changed
+175
-75
No files matched your search
+14
-1
@@ -6,6 +6,18 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
||||
|
||||
## [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
|
||||
|
||||
### 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.
|
||||
- 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.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
|
||||
|
||||
+5
-3
@@ -14,6 +14,7 @@ Eingesetzt beim Teilen (`UnifiedShareModal`), beim Hinzufügen zu Gruppen (`Grou
|
||||
- Bereits gewählte oder freigegebene Personen werden über `excludeUserIds` ausgeblendet.
|
||||
- **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.
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
- **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`):**
|
||||
- `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ü.
|
||||
- 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`):**
|
||||
- `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.
|
||||
- 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.
|
||||
|
||||
@@ -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.57.0",
|
||||
"version": "0.58.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mindsift-frontend",
|
||||
"version": "0.57.0",
|
||||
"version": "0.58.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mindsift-frontend",
|
||||
"private": true,
|
||||
"version": "0.57.0",
|
||||
"version": "0.58.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1198,9 +1198,9 @@ export const App: React.FC = () => {
|
||||
className={`tab-btn ${activeTab === 'triage' ? 'tab-btn-active' : ''}`}
|
||||
>
|
||||
<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>
|
||||
<CountBadge count={stats.inboxCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
|
||||
<CountBadge count={stats.inboxCount} variant="accent" position="text" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1223,9 +1223,9 @@ export const App: React.FC = () => {
|
||||
className={`tab-btn ${activeTab === 'tasks' ? 'tab-btn-active' : ''}`}
|
||||
>
|
||||
<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>
|
||||
<CountBadge count={stats.tasksOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
|
||||
<CountBadge count={stats.tasksOpenCount} variant="accent" position="text" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1237,9 +1237,9 @@ export const App: React.FC = () => {
|
||||
className={`tab-btn ${activeTab === 'lists' ? 'tab-btn-active' : ''}`}
|
||||
>
|
||||
<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>
|
||||
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="corner" className="-top-0.5 -right-2" />
|
||||
<CountBadge count={stats.listItemsOpenCount} variant="accent" position="text" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -4,14 +4,16 @@ interface CountBadgeProps {
|
||||
count: number | undefined | null;
|
||||
// accent: something waits for you (nav bar); muted: plain amount (tabs, lists)
|
||||
variant?: 'accent' | 'muted';
|
||||
// corner: notification badge on the top right of the nearest `relative` parent
|
||||
position?: 'inline' | 'corner';
|
||||
// corner: notification badge on the top right of the nearest `relative` parent (icons);
|
||||
// text: right after a label, raised and hanging a little over its last letter (tabs, nav bar)
|
||||
position?: 'inline' | 'corner' | 'text';
|
||||
showZero?: boolean;
|
||||
className?: string; // e.g. to fine-tune the corner offset
|
||||
}
|
||||
|
||||
// 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 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
|
||||
export const CountBadge: React.FC<CountBadgeProps> = ({
|
||||
@@ -25,7 +27,7 @@ export const CountBadge: React.FC<CountBadgeProps> = ({
|
||||
const label = count > 99 ? '99+' : String(count);
|
||||
return (
|
||||
<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)}
|
||||
>
|
||||
{label}
|
||||
|
||||
@@ -103,7 +103,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
return (
|
||||
<div ref={containerRef} className={`relative ${className}`}>
|
||||
{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">
|
||||
<UserAvatar user={selectedUser} size="xs" />
|
||||
<span className="font-semibold text-main truncate">
|
||||
@@ -148,7 +148,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
onFocus={() => setIsOpen(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
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 && (
|
||||
@@ -166,7 +166,7 @@ export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({
|
||||
type="button"
|
||||
onClick={() => handleSelect(u)}
|
||||
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
|
||||
? 'bg-accent/15 text-main font-medium'
|
||||
: 'hover:bg-panel-elevated text-main'
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
|
||||
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 { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
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 }) => {
|
||||
const { setGroups, setPrimaryAction } = useMobileActions();
|
||||
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>(
|
||||
() => initialListId || readViewSession<{ listId: string }>('lists').listId || ''
|
||||
() => initialListId || readViewMemory<{ listId: string }>('lists').listId || ''
|
||||
);
|
||||
useEffect(() => {
|
||||
if (activeListId) writeViewSession('lists', { listId: activeListId });
|
||||
if (activeListId) writeViewMemory('lists', { listId: activeListId });
|
||||
}, [activeListId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1063,9 +1063,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
||||
>
|
||||
<span>{lst.icon}</span>
|
||||
{/* 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}
|
||||
<CountBadge count={openCount} position="corner" className="-top-0.5 -right-2" />
|
||||
<CountBadge count={openCount} position="text" />
|
||||
</span>
|
||||
<AvatarStack
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
) : !readOnly && (
|
||||
<button
|
||||
@@ -611,7 +613,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
||||
<select
|
||||
value={task.taskListId || ''}
|
||||
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>
|
||||
{taskLists.map((tl) => (
|
||||
@@ -714,7 +716,7 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
||||
value={newTagDraft}
|
||||
onChange={(e) => setNewTagDraft(e.target.value)}
|
||||
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>
|
||||
)}
|
||||
@@ -802,13 +804,13 @@ const TaskDetailModalContent: React.FC<TaskDetailModalProps & { task: Thought }>
|
||||
type="text"
|
||||
value={newSubtaskTitle}
|
||||
onChange={(e) => setNewSubtaskTitle(e.target.value)}
|
||||
placeholder="Neue Unteraufgabe hinzufügen…"
|
||||
className="flex-1 h-7 px-2 text-xs input-field"
|
||||
placeholder="Neue Unteraufgabe…"
|
||||
className="flex-1 min-w-0 h-11 sm:h-7 px-2 text-base sm:text-xs input-field"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
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" />
|
||||
<span>Hinzufügen</span>
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
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 { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||||
import { readViewMemory, writeViewMemory } from '../../lib/viewSession';
|
||||
import { useKanbanTouchDrag } from './useKanbanTouchDrag';
|
||||
import { api, triggerHaptic } from '../../lib/api';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
@@ -91,7 +91,28 @@ const kanbanBoardStyle = (wide: boolean, columns: number): 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> = ({
|
||||
currentUser,
|
||||
@@ -102,9 +123,18 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
onInitialTaskShown,
|
||||
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'>(() =>
|
||||
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
|
||||
const [kanbanWide, setKanbanWide] = useState(() => {
|
||||
@@ -128,11 +158,19 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
const [tasks, setTasks] = useState<Thought[]>([]);
|
||||
const [taskLists, setTaskLists] = useState<TaskList[]>([]);
|
||||
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(() => {
|
||||
writeViewSession<TasksSession>('tasks', { listId: selectedListId, viewMode });
|
||||
}, [selectedListId, viewMode]);
|
||||
writeViewMemory<TasksSession>('tasks', { listId: selectedListId, viewMode, groupAll, groupList });
|
||||
}, [selectedListId, viewMode, groupAll, groupList]);
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const [collaborators, setCollaborators] = useState<User[]>([]);
|
||||
// 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 [tagsExpanded, setTagsExpanded] = 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');
|
||||
|
||||
// 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;
|
||||
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 withoutTag: Thought[] = [];
|
||||
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 });
|
||||
}
|
||||
return groups;
|
||||
}, [filteredTasks, groupBy, boardColumns, inBoardColumn]);
|
||||
}, [filteredTasks, groupBy, boardColumns, inBoardColumn, safeTaskLists]);
|
||||
|
||||
// --- TASK ACTIONS ---
|
||||
|
||||
@@ -1341,7 +1396,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
];
|
||||
|
||||
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[] = [
|
||||
{
|
||||
@@ -1482,6 +1537,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
selectedListId,
|
||||
selectedTag,
|
||||
groupBy,
|
||||
defaultGroupBy,
|
||||
sortBy,
|
||||
openTasks.length,
|
||||
doneTasks.length,
|
||||
@@ -1796,9 +1852,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
}`}
|
||||
>
|
||||
{/* 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
|
||||
<CountBadge count={openTasksCount} position="corner" className="-top-0.5 -right-2" />
|
||||
<CountBadge count={openTasksCount} position="text" />
|
||||
</span>
|
||||
</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"
|
||||
>
|
||||
<span>{tl.icon}</span>
|
||||
<span className={`relative inline-flex items-center ${tl.tasksCount ? 'pr-3' : ''}`}>
|
||||
<span className="inline-flex items-center">
|
||||
{tl.title}
|
||||
<CountBadge count={tl.tasksCount} position="corner" className="-top-0.5 -right-2" />
|
||||
<CountBadge count={tl.tasksCount} position="text" />
|
||||
</span>
|
||||
|
||||
{(() => {
|
||||
@@ -2051,7 +2107,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
)}
|
||||
|
||||
{/* 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 items-center gap-1.5 flex-wrap min-w-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>
|
||||
</span>
|
||||
)}
|
||||
{groupBy !== 'none' && (
|
||||
{groupBy !== defaultGroupBy && (
|
||||
<span className="text-muted text-[11px]">
|
||||
Gruppe: <strong className="text-main">{groupBy}</strong>
|
||||
Gruppe: <strong className="text-main">{GROUP_LABELS[groupBy]}</strong>
|
||||
</span>
|
||||
)}
|
||||
{sortBy !== 'custom' && (
|
||||
<span className="text-muted text-[11px]">
|
||||
Sortiert: <strong className="text-main">{sortBy}</strong>
|
||||
Sortiert: <strong className="text-main">{SORT_LABELS[sortBy]}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -2082,7 +2138,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedTag(null);
|
||||
setGroupBy('none');
|
||||
setGroupBy(defaultGroupBy);
|
||||
setSortBy('custom');
|
||||
}}
|
||||
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
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{[
|
||||
{ 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) => (
|
||||
{groupOptions.map((g) => (
|
||||
<button
|
||||
key={g.id}
|
||||
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 ${
|
||||
groupBy === g.id
|
||||
? 'bg-accent-dim border-accent text-accent font-semibold'
|
||||
@@ -2457,7 +2507,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedTag(null);
|
||||
setGroupBy('none');
|
||||
setGroupBy(defaultGroupBy);
|
||||
setSortBy('custom');
|
||||
}}
|
||||
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>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="none">Keine</option>
|
||||
<option value="tag">Nach Hashtag</option>
|
||||
<option value="status">Nach Status</option>
|
||||
<option value="priority">Nach Priorität</option>
|
||||
<option value="due">Nach Fälligkeit</option>
|
||||
{groupOptions.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-xs text-muted">
|
||||
@@ -3014,7 +3064,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{groupedTasks ? (
|
||||
{groupedTasks && groupedTasks.length > 0 ? (
|
||||
/* GROUPED LIST */
|
||||
groupedTasks.map((grp) => (
|
||||
<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" />
|
||||
<span className={`relative inline-flex items-center ${totalOpenTodos > 0 ? 'pr-3' : ''}`}>
|
||||
<span className="inline-flex items-center">
|
||||
To-Dos
|
||||
<CountBadge count={totalOpenTodos} position="corner" className="-top-0.5 -right-2" />
|
||||
<CountBadge count={totalOpenTodos} position="text" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1642,9 +1642,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}`}
|
||||
>
|
||||
<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
|
||||
<CountBadge count={tagsSummary.length} position="corner" className="-top-0.5 -right-2" />
|
||||
<CountBadge count={tagsSummary.length} position="text" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+10
-3
@@ -322,7 +322,8 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
.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);
|
||||
border: 1px solid var(--accent-border);
|
||||
}
|
||||
@@ -336,8 +337,14 @@ body {
|
||||
top: -0.5rem;
|
||||
right: -0.5rem;
|
||||
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 {
|
||||
|
||||
@@ -18,3 +18,27 @@ export function writeViewSession<T extends object>(view: string, state: T) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user