diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg index fec3035..57e8419 100644 --- a/frontend/public/icon.svg +++ b/frontend/public/icon.svg @@ -1,5 +1,14 @@ - - - + + + + + + + + + + + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fb1374a..4c3c0e2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -127,6 +127,7 @@ export const App: React.FC = () => { const [activeTab, setActiveTab] = useState('triage'); const [previousTab, setPreviousTab] = useState('triage'); const [selectedDocId, setSelectedDocId] = useState(null); + const [selectedBlockId, setSelectedBlockId] = useState(null); const [captureFocusCount, setCaptureFocusCount] = useState(0); const [refreshRevision, setRefreshRevision] = useState(0); @@ -143,6 +144,7 @@ export const App: React.FC = () => { const [mobileVoiceActive, setMobileVoiceActive] = useState(false); const [isVoiceCaptured, setIsVoiceCaptured] = useState(false); const mobileRecognitionRef = useRef(null); + const mobileBaseVoiceRef = useRef(''); const toggleMobileVoice = () => { const SpeechRecognition = @@ -157,6 +159,7 @@ export const App: React.FC = () => { return; } try { + mobileBaseVoiceRef.current = mobileTriageContent.replace(/\s*\[…\]$/, '').trim(); const recognition = new SpeechRecognition(); recognition.lang = 'de-DE'; recognition.continuous = false; @@ -170,10 +173,8 @@ export const App: React.FC = () => { for (let i = 0; i < event.results.length; i++) { transcript += event.results[i][0].transcript; } - setMobileTriageContent((prev) => { - const base = prev.replace(/\s*\[…\]$/, '').trim(); - return base ? `${base} ${transcript}` : transcript; - }); + const base = mobileBaseVoiceRef.current; + setMobileTriageContent(base ? `${base} ${transcript}` : transcript); setIsVoiceCaptured(true); }; recognition.onerror = () => setMobileVoiceActive(false); @@ -549,8 +550,9 @@ export const App: React.FC = () => { refreshAll(); }; - const handleOpenDocument = (docId: string) => { + const handleOpenDocument = (docId: string, blockId?: string) => { setSelectedDocId(docId); + setSelectedBlockId(blockId || null); setActiveTab('workspace'); }; @@ -636,9 +638,12 @@ export const App: React.FC = () => {
- - {authStatus.instanceName || 'MindSift'} - +
+ + {authStatus.instanceName || 'MindSift'} + + v0.1 +
{activeTabLabelMap[activeTab]} @@ -698,9 +703,12 @@ export const App: React.FC = () => {
- - {authStatus.instanceName || 'MindSift'} - +
+ + {authStatus.instanceName || 'MindSift'} + + v0.1 +
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */} @@ -961,6 +969,7 @@ export const App: React.FC = () => { )} diff --git a/frontend/src/features/capture/InstantCaptureBar.tsx b/frontend/src/features/capture/InstantCaptureBar.tsx index c7b333d..5bf73ef 100644 --- a/frontend/src/features/capture/InstantCaptureBar.tsx +++ b/frontend/src/features/capture/InstantCaptureBar.tsx @@ -52,6 +52,8 @@ export const InstantCaptureBar: React.FC = ({ const inputRef = useRef(null); const containerRef = useRef(null); const recognitionRef = useRef(null); + const baseVoiceTextRef = useRef(''); + const hasVoiceInputRef = useRef(false); const adjustHeight = (el?: HTMLTextAreaElement | null) => { const targetEl = el || inputRef.current; @@ -131,6 +133,9 @@ export const InstantCaptureBar: React.FC = ({ } try { + baseVoiceTextRef.current = content.replace(/\s*\[…\]$/, '').trim(); + hasVoiceInputRef.current = true; + const recognition = new SpeechRecognition(); recognition.lang = 'de-DE'; recognition.continuous = false; @@ -146,10 +151,8 @@ export const InstantCaptureBar: React.FC = ({ for (let i = 0; i < event.results.length; i++) { transcript += event.results[i][0].transcript; } - setContent((prev) => { - const base = prev.replace(/\s*\[…\]$/, '').trim(); - return base ? `${base} ${transcript}` : transcript; - }); + const base = baseVoiceTextRef.current; + setContent(base ? `${base} ${transcript}` : transcript); }; recognition.onerror = () => { @@ -180,7 +183,8 @@ export const InstantCaptureBar: React.FC = ({ overrideListId || targetListId || (lists.length > 0 ? lists[0].id : ''); const capturedPriority = priority; const capturedDueDate = dueDate; - const capturedSource = isListening ? 'voice' : 'manual'; + const capturedSource = hasVoiceInputRef.current || isListening ? 'voice' : 'manual'; + hasVoiceInputRef.current = false; // Optimistically clear input immediately so the UI feels instant (<10ms) in all browsers setContent(''); diff --git a/frontend/src/features/lists/SharedListsView.tsx b/frontend/src/features/lists/SharedListsView.tsx index da5bb75..b06384c 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -85,6 +85,8 @@ export const SharedListsView: React.FC = ({ onRefreshGloba const [newItemText, setNewItemText] = useState(''); const [newItemQty, setNewItemQty] = useState(''); const [isSubmittingItem, setIsSubmittingItem] = useState(false); + const [editingQtyItemId, setEditingQtyItemId] = useState(null); + const [editingQtyValue, setEditingQtyValue] = useState(''); const [wsConnected, setWsConnected] = useState(false); const [realtimeTransport, setRealtimeTransport] = useState('none'); @@ -174,7 +176,7 @@ export const SharedListsView: React.FC = ({ onRefreshGloba return { ...l, items: deduplicateItems([newItem, ...l.items]) }; }) ); - } else if (msg.event === 'ITEM_TOGGLED' && msgListId) { + } else if ((msg.event === 'ITEM_TOGGLED' || msg.event === 'ITEM_UPDATED') && msgListId) { const updatedItem: ListItem = msg.payload; setLists((prev) => prev.map((l) => { @@ -273,6 +275,26 @@ export const SharedListsView: React.FC = ({ onRefreshGloba } }; + const handleSaveQuantity = async (itemId: string, quantity: string) => { + setEditingQtyItemId(null); + if (!activeList) return; + const trimmed = quantity.trim(); + try { + const updated = await api.updateListItem(activeList.id, itemId, { quantity: trimmed }); + setLists((prev) => + prev.map((l) => { + if (l.id !== activeList.id) return l; + return { + ...l, + items: l.items.map((i) => (i.id === itemId ? { ...i, quantity: updated.quantity } : i)), + }; + }) + ); + } catch (err: any) { + console.error('Fehler beim Aktualisieren der Menge', err); + } + }; + const handleToggleItem = async (item: ListItem) => { if (!activeList) return; triggerHaptic(10); @@ -1085,8 +1107,8 @@ export const SharedListsView: React.FC = ({ onRefreshGloba setNewItemQty(e.target.value)} - placeholder="Menge (z.B. 2x)" + onChange={(e) => setNewItemQty(e.target.value.replace(/[^0-9.,xX]/g, ''))} + placeholder="Menge (z.B. 2)" className="flex-1 sm:flex-none sm:w-28 h-9 sm:h-7 px-2.5 sm:px-2 input-field text-xs" /> )} {cat && !group.label && ( @@ -1274,8 +1331,45 @@ export const SharedListsView: React.FC = ({ onRefreshGloba {displayContent} - {item.quantity && ( - ({item.quantity}) + {editingQtyItemId === item.id ? ( +
e.stopPropagation()}> + setEditingQtyValue(e.target.value.replace(/[^0-9.,xX]/g, ''))} + onKeyDown={async (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + await handleSaveQuantity(item.id, editingQtyValue); + } else if (e.key === 'Escape') { + setEditingQtyItemId(null); + } + }} + onBlur={async () => { + await handleSaveQuantity(item.id, editingQtyValue); + }} + className="w-16 h-6 px-1.5 text-xs input-field font-semibold text-accent text-center" + placeholder="Menge" + /> +
+ ) : ( + )} diff --git a/frontend/src/features/triage/TaskDetailModal.tsx b/frontend/src/features/triage/TaskDetailModal.tsx index 7e1b3ef..b176250 100644 --- a/frontend/src/features/triage/TaskDetailModal.tsx +++ b/frontend/src/features/triage/TaskDetailModal.tsx @@ -18,6 +18,7 @@ import { } from 'lucide-react'; import { Thought, SubTask, TaskList, KanbanColumn } from '../../lib/types'; import { triggerHaptic } from '../../lib/api'; +import { MarkdownViewer } from '../../lib/markdown'; interface TaskDetailModalProps { task: Thought | null; @@ -36,6 +37,7 @@ interface TaskDetailModalProps { onAddSubtask: (task: Thought, title: string) => Promise; onDeleteSubtask: (task: Thought, subtaskId: string) => Promise; onConvertToDoc: (task: Thought) => void; + onOpenDocument?: (docId: string, blockId?: string) => void; onDeleteTask: (taskId: string) => void; } @@ -56,6 +58,7 @@ export const TaskDetailModal: React.FC = ({ onAddSubtask, onDeleteSubtask, onConvertToDoc, + onOpenDocument, onDeleteTask, }) => { if (!isOpen || !task) return null; @@ -202,18 +205,35 @@ export const TaskDetailModal: React.FC = ({
- + {(task.convertedType === 'document_block' || task.docId) ? ( + + ) : ( + + )}
) : ( + )} + {/* Kanban status selector pill (Desktop only - on mobile done via TaskDetailModal) */} setEmojiSearch(e.target.value)} + placeholder="Icon suchen oder Emoji eingeben…" + className="input-field w-full pl-7 pr-2 py-1 text-xs" + /> + + {emojiSearch.trim() && ( - ))} + )} +
+ {filteredEmojis.map((item) => ( + + ))} + {filteredEmojis.length === 0 && ( +
+ Kein passendes Standard-Icon +
+ )} +
)} - { + titleInputRef.current = el; + if (el) { + el.style.height = 'auto'; + el.style.height = `${el.scrollHeight}px`; + } + }} + rows={1} value={title} readOnly={!canEdit} autoFocus={autoFocusTitle} - onChange={handleTitleChange} + onChange={(e) => { + handleTitleChange(e); + e.target.style.height = 'auto'; + e.target.style.height = `${e.target.scrollHeight}px`; + }} onBlur={handleTitleBlur} placeholder="Titel der Notiz…" - className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight" + className="flex-1 min-w-0 text-xl sm:text-2xl font-bold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]" /> diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index 4351002..160c256 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -3,7 +3,10 @@ import { ChevronRight, ChevronDown, Check, + CheckSquare, X, + Trash2, + MoreHorizontal, Code, Heading1, Heading2, @@ -19,6 +22,8 @@ import { } from 'lucide-react'; import { DocumentBlock, BlockType } from '../../lib/types'; import { LinkModal } from './LinkModal'; +import { renderInlineMarkdown } from '../../lib/markdown'; +import { api } from '../../lib/api'; export interface SlashCommand { type: BlockType | 'link'; @@ -50,7 +55,7 @@ export interface OutlinerBlockProps { onZoomBlock?: (blockId: string) => void; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void; - onDeleteBlock: (blockId: string) => void; + onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void; onIndentBlock: (blockId: string, delta: 1 | -1) => void; onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void; onTagClick?: (tag: string) => void; @@ -91,6 +96,7 @@ export const OutlinerBlock: React.FC = ({ const [slashSearch, setSlashSearch] = useState(null); const [slashIndex, setSlashIndex] = useState(0); const [showLinkModal, setShowLinkModal] = useState(false); + const [showTodoMenu, setShowTodoMenu] = useState(false); const textareaRef = useRef(null); const debounceTimerRef = useRef(null); @@ -475,7 +481,34 @@ export const OutlinerBlock: React.FC = ({ )} - {block.type === 'todo' ? ( + {/* Bullet handle for dragging and zoom focus */} + + + {/* If To-Do block: checkbox alongside bullet */} + {block.type === 'todo' && ( - ) : ( - + + Erweitert + )} @@ -571,6 +589,23 @@ export const OutlinerBlock: React.FC = ({ ) : (
+ {!isFocused && localContent && ( +
+ {renderInlineMarkdown(localContent)} +
+ )}