From 7860609201e9b078357a720b77f68153277a2a62 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 1 Nov 2026 14:20:34 +0100 Subject: [PATCH] feat: emoji picker in create dialogs; due date and priority right under a deepened task; no flicker on kanban drop Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++++++- README.md | 2 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/components/EmojiIconButton.tsx | 41 +++++++++++++++++++ .../src/features/lists/SharedListsView.tsx | 10 +---- frontend/src/features/triage/TasksView.tsx | 34 +++++++-------- .../features/workspace/EmojiPickerModal.tsx | 35 ++++++++++++---- .../src/features/workspace/WorkspaceView.tsx | 10 +---- internal/api/alarms.go | 16 +------- internal/domain/due.go | 13 ++++++ internal/storage/deepen_test.go | 4 ++ internal/storage/thoughts.go | 31 +++++++------- 13 files changed, 143 insertions(+), 74 deletions(-) create mode 100644 frontend/src/components/EmojiIconButton.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index eff416e..0af0a4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V ## [Unreleased] +## [0.39.0] - 2026-11-01 + +### Added +- Icon beim Anlegen von Aufgabenlisten, Einkaufslisten und Notizen: Statt eines Textfelds öffnet ein Klick auf das Symbol denselben Emoji-Auswähler wie bei einer geöffneten Notiz (mit Suche und zuletzt verwendeten). Escape schließt nur den Auswähler, nicht das Fenster. + +### Changed +- „Als Notiz vertiefen“: Fälligkeit und Priorität stehen in der neuen Notiz direkt unter der Aufgabe, vor der Beschreibung und den Unteraufgaben statt ganz unten. Die Priorität steht auf Deutsch da („Wichtig“, „Dringend“, „Niedrig“). + +### Fixed +- Kanban: Beim Ablegen flackerte die Karte kurz, bevor sie an der neuen Stelle erschien. Jetzt nimmt sie direkt den Platz der Lücke ein. +- Beispiel-Konfiguration für den Whisper-Server (`docker-compose.yml`): Das Modell bleibt mit `WHISPER__TTL=-1` geladen; das bisher angegebene `STT_MODEL_TTL` kennt speaches 0.8.3 nicht, das Modell wurde deshalb nach 5 Minuten entladen. + ## [0.38.1] - 2026-11-01 ### Changed @@ -647,7 +659,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.38.1...dev +[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.39.0...dev +[0.39.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.38.1...v0.39.0 [0.38.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.38.0...v0.38.1 [0.38.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.2...v0.38.0 [0.37.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.1...v0.37.2 diff --git a/README.md b/README.md index a3e282b..08d3994 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![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) [![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.38.1-16a34a?style=flat-square)](CHANGELOG.md) +[![Version](https://img.shields.io/badge/Version-v0.39.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. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e76b273..c8f7476 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mindsift-frontend", - "version": "0.38.1", + "version": "0.39.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mindsift-frontend", - "version": "0.38.1", + "version": "0.39.0", "license": "Unlicense", "dependencies": { "@fontsource-variable/inter": "5.3.0", diff --git a/frontend/package.json b/frontend/package.json index b90a769..67dc765 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mindsift-frontend", "private": true, - "version": "0.38.1", + "version": "0.39.0", "license": "Unlicense", "type": "module", "scripts": { diff --git a/frontend/src/components/EmojiIconButton.tsx b/frontend/src/components/EmojiIconButton.tsx new file mode 100644 index 0000000..9174f26 --- /dev/null +++ b/frontend/src/components/EmojiIconButton.tsx @@ -0,0 +1,41 @@ +import React, { useRef, useState } from 'react'; +import { EmojiPickerModal } from '../features/workspace/EmojiPickerModal'; + +interface EmojiIconButtonProps { + value: string; + onChange: (emoji: string) => void; + // Default icon; removing the icon in the picker goes back to it + fallback: string; +} + +// Icon field of the create dialogs (note, task list, list): the emoji as a button that opens the same +// picker as the icon of an open note (Doku/ui-patterns.md). +export const EmojiIconButton: React.FC = ({ value, onChange, fallback }) => { + const buttonRef = useRef(null); + const [anchor, setAnchor] = useState(null); + return ( + <> + + {anchor && ( + { + onChange(emoji || fallback); + setAnchor(null); + }} + onClose={() => setAnchor(null)} + /> + )} + + ); +}; diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index dea2f4a..46ec633 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -19,6 +19,7 @@ import { CountBadge } from '../../components/CountBadge'; import { OriginBadge } from '../../components/OriginBadge'; import { VoiceInputButton } from '../../components/VoiceInputButton'; import { FormSheet } from '../../components/FormSheet'; +import { EmojiIconButton } from '../../components/EmojiIconButton'; import { useMobileActions } from '../../context/MobileActionContext'; interface SharedListsViewProps { @@ -1203,14 +1204,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba
- setNewListIcon(e.target.value)} - maxLength={2} - className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm" - title="Icon (Emoji)" - /> + = ({ const renderDropSlot = (colId: string, index: number) => ( ({ height: 0, opacity: 0, transition: { duration: dropped ? 0 : 0.2 } }), + }} + initial="closed" + animate="open" + exit="gone" transition={{ duration: 0.2, ease: 'easeOut' }} className="rounded-card border-2 border-dashed border-accent/50 bg-accent/5 pointer-events-none" aria-hidden="true" @@ -967,6 +974,9 @@ export const TasksView: React.FC = ({ const slot = kanbanSlot; resetKanbanDrag(); if (!sourceId) return; + // Set with the move in one render: the card shows up marked in its new place, not a moment later + setJustDroppedId(sourceId); + window.setTimeout(() => setJustDroppedId((cur) => (cur === sourceId ? null : cur)), 900); const others = colTasks.filter((t) => t.id !== sourceId); if (others.length === 0 || !slot || slot.col !== colId) { await moveTaskToColumn(sourceId, colId); @@ -975,8 +985,6 @@ export const TasksView: React.FC = ({ } else { await moveTaskNextTo(sourceId, others[others.length - 1], 'after', colId); } - setJustDroppedId(sourceId); - window.setTimeout(() => setJustDroppedId((cur) => (cur === sourceId ? null : cur)), 900); }; // A drag that ends outside any column (Escape, dropped elsewhere) leaves nothing behind @@ -2285,14 +2293,7 @@ export const TasksView: React.FC = ({
- setNewListIcon(e.target.value)} - maxLength={2} - className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm" - title="Icon (Emoji)" - /> + = ({ return ( - + {slotHere >= 0 && slotHere === visibleIdx && renderDropSlot(col.id, visibleIdx)}
{ handleTaskDragStart(e, task); + setJustDroppedId(null); dragHeightRef.current = e.currentTarget.getBoundingClientRect().height; // Hidden a tick later: hiding the source right away would cancel the drag window.setTimeout(() => setKanbanHiddenId(task.id), 0); @@ -2481,7 +2483,7 @@ export const TasksView: React.FC = ({ readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing' } ${ kanbanHiddenId === task.id ? 'hidden' : isDragging ? 'opacity-30 border border-dashed border-accent/40' : '' - } ${justDroppedId === task.id ? 'ring-2 ring-accent/60 animate-in fade-in zoom-in-95 duration-300' : ''}`} + } ${justDroppedId === task.id ? 'ring-2 ring-accent/60' : ''}`} >

= ({ ); })} - + {slotHere >= 0 && slotHere === visibleIds.length && renderDropSlot(col.id, visibleIds.length)} diff --git a/frontend/src/features/workspace/EmojiPickerModal.tsx b/frontend/src/features/workspace/EmojiPickerModal.tsx index 84b315b..664ab4c 100644 --- a/frontend/src/features/workspace/EmojiPickerModal.tsx +++ b/frontend/src/features/workspace/EmojiPickerModal.tsx @@ -1,4 +1,5 @@ import React, { Suspense, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { X, Trash2, Loader2 } from 'lucide-react'; import type { EmojiClickData } from 'emoji-picker-react'; import { Categories, EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react'; @@ -25,12 +26,16 @@ interface EmojiPickerModalProps { currentIcon?: string; onSelectEmoji: (emoji: string) => void; onClose: () => void; + // Inside a dialog (which clips and transforms its content): the picker is put on the page instead and + // opens below this button on desktop + anchor?: DOMRect | null; } export const EmojiPickerModal: React.FC = ({ currentIcon, onSelectEmoji, onClose, + anchor, }) => { const { inset, visibleHeight } = useKeyboardInset(); const isPhone = typeof window !== 'undefined' && window.innerWidth < 640; @@ -61,6 +66,7 @@ export const EmojiPickerModal: React.FC = ({ const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); + e.stopPropagation(); // only the picker closes, not the dialog it was opened from onClose(); } }; @@ -74,10 +80,10 @@ export const EmojiPickerModal: React.FC = ({ } }; - window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keydown', handleKeyDown, true); document.addEventListener('mousedown', handleClickOutside); return () => { - window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keydown', handleKeyDown, true); document.removeEventListener('mousedown', handleClickOutside); }; }, [onClose]); @@ -90,11 +96,18 @@ export const EmojiPickerModal: React.FC = ({ handlePick(emojiData.emoji); }; - return ( + const anchorStyle = anchor + ? ({ + '--pick-left': `${Math.max(12, Math.min(anchor.left, window.innerWidth - 372))}px`, + '--pick-top': `${Math.min(anchor.bottom + 4, window.innerHeight - 532)}px`, + } as React.CSSProperties) + : undefined; + + const picker = ( <> {/* Mobile Backdrop (< 640px) */}