diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bb3aca4..3aa0cc0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,6 +39,7 @@ export const App: React.FC = () => { const [activeTab, setActiveTab] = useState('triage'); const [selectedDocId, setSelectedDocId] = useState(null); const [captureFocusCount, setCaptureFocusCount] = useState(0); + const [refreshRevision, setRefreshRevision] = useState(0); // Profile & Auth Modal State (aligned with Template Screen 2) const [showProfileModal, setShowProfileModal] = useState(false); @@ -101,9 +102,18 @@ export const App: React.FC = () => { return () => window.removeEventListener('online', onOnline); }, []); - const handleCaptured = (thought: Thought) => { - setDeck((prev) => [thought, ...prev]); - setStats((prev) => ({ ...prev, inboxCount: prev.inboxCount + 1 })); + const handleCaptured = ( + thought: Thought, + target: 'inbox' | 'task' | 'list_item' | 'document' = 'inbox' + ) => { + if (target === 'inbox' && thought.status === 'inbox') { + setDeck((prev) => [thought, ...prev]); + setStats((prev) => ({ ...prev, inboxCount: prev.inboxCount + 1 })); + } else if (target === 'document' && thought.convertedRefId) { + setSelectedDocId(thought.convertedRefId); + setActiveTab('workspace'); + } + setRefreshRevision((r) => r + 1); refreshAll(); }; @@ -177,6 +187,7 @@ export const App: React.FC = () => {
@@ -275,9 +286,6 @@ export const App: React.FC = () => { {/* Main Container */}
- {/* Tab View Content */} - - {/* Tab View Content */}
{activeTab === 'triage' && ( @@ -294,6 +302,7 @@ export const App: React.FC = () => { )} diff --git a/frontend/src/features/capture/InstantCaptureBar.tsx b/frontend/src/features/capture/InstantCaptureBar.tsx index 067256a..afc89cd 100644 --- a/frontend/src/features/capture/InstantCaptureBar.tsx +++ b/frontend/src/features/capture/InstantCaptureBar.tsx @@ -1,40 +1,76 @@ import React, { useEffect, useRef, useState } from 'react'; -import { Mic, MicOff, ArrowUp, Sparkles, Calendar, Check } from 'lucide-react'; -import { PriorityLevel, Thought } from '../../lib/types'; +import { + Mic, + MicOff, + ArrowUp, + Sparkles, + Calendar, + Check, + CheckSquare, + ShoppingCart, + FileText, + Layers, + ChevronDown, +} from 'lucide-react'; +import { PriorityLevel, SharedList, Thought } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; +export type CaptureTarget = 'inbox' | 'task' | 'list_item' | 'document'; + interface InstantCaptureBarProps { - onCaptured: (thought: Thought) => void; + onCaptured: (thought: Thought, target?: CaptureTarget) => void; + lists?: SharedList[]; autoFocusTrigger?: number; } export const InstantCaptureBar: React.FC = ({ onCaptured, + lists = [], autoFocusTrigger = 0, }) => { const [content, setContent] = useState(''); const [priority, setPriority] = useState('normal'); const [dueDate, setDueDate] = useState(''); + const [target, setTarget] = useState('inbox'); + const [targetListId, setTargetListId] = useState(''); const [showOptions, setShowOptions] = useState(false); const [isListening, setIsListening] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - const [justSaved, setJustSaved] = useState(false); + const [justSavedLabel, setJustSavedLabel] = useState(null); const inputRef = useRef(null); + const containerRef = useRef(null); const recognitionRef = useRef(null); useEffect(() => { inputRef.current?.focus(); }, [autoFocusTrigger]); + useEffect(() => { + if (!targetListId && lists.length > 0) { + setTargetListId(lists[0].id); + } + }, [lists, targetListId]); + + // Close dropdown on outside click + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setShowOptions(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + // Global keyboard shortcut: 'c' or '/' focuses the Instant Capture input immediately useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - const target = e.target as HTMLElement; + const el = e.target as HTMLElement; const isEditing = - target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.isContentEditable; + el.tagName === 'INPUT' || + el.tagName === 'TEXTAREA' || + el.isContentEditable; if (!isEditing && (e.key === 'c' || e.key === 'C' || e.key === '/')) { e.preventDefault(); @@ -102,11 +138,17 @@ export const InstantCaptureBar: React.FC = ({ } }; - const handleSubmit = async (e?: React.FormEvent) => { - if (e) e.preventDefault(); + const executeCapture = async ( + overrideTarget?: CaptureTarget, + overrideListId?: string + ) => { const trimmed = content.trim(); if (!trimmed || isSubmitting) return; + const effectiveTarget = overrideTarget || target; + const effectiveListId = + overrideListId || targetListId || (lists.length > 0 ? lists[0].id : ''); + setIsSubmitting(true); triggerHaptic(15); @@ -115,25 +157,54 @@ export const InstantCaptureBar: React.FC = ({ trimmed, priority, isListening ? 'voice' : 'manual', - dueDate + dueDate, + { + target: effectiveTarget, + targetListId: effectiveTarget === 'list_item' ? effectiveListId : '', + } ); setContent(''); setPriority('normal'); setDueDate(''); + setTarget('inbox'); setShowOptions(false); - setJustSaved(true); - setTimeout(() => setJustSaved(false), 1600); - onCaptured(created); + + const savedMsg = + effectiveTarget === 'task' + ? 'Als Aufgabe!' + : effectiveTarget === 'list_item' + ? 'Auf Liste!' + : effectiveTarget === 'document' + ? 'Als Notiz!' + : 'Im Dump!'; + setJustSavedLabel(savedMsg); + setTimeout(() => setJustSavedLabel(null), 1600); + + onCaptured(created, effectiveTarget); inputRef.current?.focus(); } finally { setIsSubmitting(false); } }; + const handleSubmit = async (e?: React.FormEvent) => { + if (e) e.preventDefault(); + await executeCapture(); + }; + + const selectedList = lists.find((l) => l.id === targetListId) || lists[0]; + + const getTargetLabel = () => { + if (target === 'task') return 'Direkt-Aufgabe'; + if (target === 'list_item') return selectedList ? `${selectedList.icon} ${selectedList.title}` : 'Auf Liste'; + if (target === 'document') return 'Direkt-Notiz'; + return 'Quick Dump'; + }; + return (
-
- {/* Capture Input Bar */} + + {/* Round Quick-Capture Input Bar */}
= ({ placeholder={ isListening ? 'Ich höre zu…' - : 'Schnell-Notiz: Gedanke oder Zutat erfassen… (Taste C oder /)' + : target === 'task' + ? 'Neue Aufgabe direkt anlegen… (Enter)' + : target === 'list_item' + ? `Eintrag direkt zu "${selectedList?.title || 'Liste'}" hinzufügen…` + : target === 'document' + ? 'Neue Workspace-Notiz direkt anlegen…' + : 'Quick Dump: Gedanke einfach reinrotzen & später swipen… (C / Enter)' } className="w-full bg-transparent text-white placeholder-zinc-500 text-xs sm:text-sm focus:outline-none" autoComplete="off" /> -
- {/* Options Toggle Pill (Due Date / Priority) */} +
+ {/* Target & Options Toggle Pill */} {/* Voice Dictation Button */} @@ -201,59 +288,197 @@ export const InstantCaptureBar: React.FC = ({ : 'bg-panel-elevated text-zinc-500 cursor-not-allowed' }`} > - {justSaved ? ( + {justSavedLabel ? ( <> - Gespeichert + {justSavedLabel} ) : ( <> - Sichern + + {target === 'task' + ? 'Als Aufgabe' + : target === 'list_item' + ? 'Auf Liste' + : target === 'document' + ? 'Als Notiz' + : 'Dump'} + )}
- {/* Expandable Quick Metadata (Floating Dropdown) */} + {/* Expandable Target & Metadata Panel (Square Monochromatic Dropdown) */} {showOptions && ( -
-
- Priorität: - {(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => { - const labels: Record = { - low: 'Niedrig', - normal: 'Normal', - high: 'Hoch', - urgent: 'Dringend', - }; - const active = priority === p; - return ( - - ); - })} +
+ {/* Row 1: Where should this entry go? */} +
+
+ + Wohin damit? {content.trim() ? '(Klick speichert sofort oder wählt Ziel)' : '(Ziel für Eingabe wählen)'} + + + Standard: Quick Dump für späteres Swipen + +
+ +
+ {/* Option 1: Quick Dump (Triage) */} + + + {/* Option 2: Direct Task */} + + + {/* Option 3: Direct List Item */} + + + {/* Option 4: Direct Workspace Note */} + +
-
- Fällig am: - setDueDate(e.target.value)} - className="input-field px-2 py-0.5 text-xs" - /> + {/* Optional Sub-Row: Shared List Picker (always shown if lists exist so 1-click list assignment is instant) */} + {lists.length > 0 && ( +
+ + + Liste: + + {lists.map((lst) => { + const isSelected = target === 'list_item' && targetListId === lst.id; + return ( + + ); + })} +
+ )} + + {/* Row 2: Priority & Due Date */} +
+
+ Priorität: + {(['low', 'normal', 'high', 'urgent'] as PriorityLevel[]).map((p) => { + const labels: Record = { + low: 'Niedrig', + normal: 'Normal', + high: 'Hoch', + urgent: 'Dringend', + }; + const active = priority === p; + return ( + + ); + })} +
+ +
+ + + Fällig am: + + setDueDate(e.target.value)} + className="input-field px-2 py-0.5 text-xs" + /> +
)} diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 1585818..274a0b4 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -16,9 +16,14 @@ import { api, triggerHaptic } from '../../lib/api'; interface TasksViewProps { onRefreshStats: () => void; onOpenDocument: (docId: string) => void; + refreshTrigger?: number; } -export const TasksView: React.FC = ({ onRefreshStats, onOpenDocument }) => { +export const TasksView: React.FC = ({ + onRefreshStats, + onOpenDocument, + refreshTrigger = 0, +}) => { const [filter, setFilter] = useState<'open' | 'done' | 'discarded'>('open'); const [tasks, setTasks] = useState([]); const [discarded, setDiscarded] = useState([]); @@ -40,7 +45,7 @@ export const TasksView: React.FC = ({ onRefreshStats, onOpenDocu useEffect(() => { loadData(); - }, []); + }, [refreshTrigger]); const handleToggleDone = async (task: Thought) => { triggerHaptic(15); diff --git a/frontend/src/features/triage/TriageDeck.tsx b/frontend/src/features/triage/TriageDeck.tsx index e6c5b35..d7b914e 100644 --- a/frontend/src/features/triage/TriageDeck.tsx +++ b/frontend/src/features/triage/TriageDeck.tsx @@ -3,6 +3,7 @@ import { motion, useMotionValue, useTransform, AnimatePresence } from 'framer-mo import { X, Check, + CheckSquare, Calendar, FileText, ShoppingCart, @@ -13,6 +14,9 @@ import { Keyboard, Tag, Clock, + SkipForward, + Edit3, + Layers, } from 'lucide-react'; import { SharedList, Thought } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; @@ -41,6 +45,10 @@ export const TriageDeck: React.FC = ({ }); const [showListSelector, setShowListSelector] = useState(false); + // Inline edit state for cleaning up a hasty Quick Dump note before converting + const [isEditingCard, setIsEditingCard] = useState(false); + const [editDraft, setEditDraft] = useState(''); + useEffect(() => { setCards(deck); }, [deck]); @@ -48,14 +56,19 @@ export const TriageDeck: React.FC = ({ const activeCard = cards[0]; const nextCard = cards[1]; + useEffect(() => { + setIsEditingCard(false); + setEditDraft(activeCard?.content || ''); + }, [activeCard?.id]); + const x = useMotionValue(0); - const rotate = useTransform(x, [-220, 0, 220], [-10, 0, 10]); - const keepOpacity = useTransform(x, [25, 120], [0, 1]); - const discardOpacity = useTransform(x, [-120, -25], [1, 0]); + const rotate = useTransform(x, [-220, 0, 220], [-8, 0, 8]); + const keepOpacity = useTransform(x, [25, 110], [0, 1]); + const discardOpacity = useTransform(x, [-110, -25], [1, 0]); const cardBorderColor = useTransform( x, [-140, 0, 140], - ['rgba(239, 68, 68, 0.7)', 'rgba(255, 255, 255, 0.08)', 'rgba(34, 197, 94, 0.7)'] + ['rgba(239, 68, 68, 0.7)', 'rgba(255, 255, 255, 0.08)', 'rgba(29, 140, 248, 0.8)'] ); const handleAction = async ( @@ -64,22 +77,28 @@ export const TriageDeck: React.FC = ({ convertTo?: 'task' | 'document' | 'list_item'; targetListId?: string; dueDate?: string; + thoughtOverride?: Thought; } ) => { - if (!activeCard) return; + const targetCard = options?.thoughtOverride || activeCard; + if (!targetCard) return; - const current = activeCard; triggerHaptic(action === 'keep' ? [10, 20] : 15); - // Optimistic removal from deck - setCards((prev) => prev.slice(1)); - setLastTriaged(current); + // Optimistic removal from local deck + setCards((prev) => prev.filter((c) => c.id !== targetCard.id)); + setLastTriaged(targetCard); setShowDateModal(false); setShowListSelector(false); + setIsEditingCard(false); x.set(0); try { - const updated = await api.triageAction(current.id, action, options); + const updated = await api.triageAction(targetCard.id, action, { + convertTo: options?.convertTo, + targetListId: options?.targetListId, + dueDate: options?.dueDate, + }); onDeckChange(); if (options?.convertTo === 'document' && updated.convertedRefId) { onOpenDocument(updated.convertedRefId); @@ -89,6 +108,42 @@ export const TriageDeck: React.FC = ({ } }; + const handleSkipCard = () => { + if (cards.length <= 1) return; + triggerHaptic(10); + setShowDateModal(false); + setShowListSelector(false); + setIsEditingCard(false); + x.set(0); + setCards((prev) => [...prev.slice(1), prev[0]]); + }; + + const handleBringToFront = (thoughtId: string) => { + triggerHaptic(10); + setCards((prev) => { + const chosen = prev.find((c) => c.id === thoughtId); + if (!chosen) return prev; + return [chosen, ...prev.filter((c) => c.id !== thoughtId)]; + }); + }; + + const handleSaveInlineEdit = async () => { + if (!activeCard) return; + const trimmed = editDraft.trim(); + if (!trimmed || trimmed === activeCard.content) { + setIsEditingCard(false); + return; + } + try { + const updated = await api.updateThought(activeCard.id, { content: trimmed }); + setCards((prev) => prev.map((c) => (c.id === activeCard.id ? updated : c))); + setIsEditingCard(false); + onDeckChange(); + } catch { + setIsEditingCard(false); + } + }; + const handleUndo = async () => { if (!lastTriaged) return; triggerHaptic(15); @@ -116,38 +171,42 @@ export const TriageDeck: React.FC = ({ handleAction('discard'); } else if (e.key === 'ArrowRight' || e.key === 'l' || e.key === 'L') { e.preventDefault(); - handleAction('keep'); + handleAction('keep', { convertTo: 'task' }); } else if (e.key === 't' || e.key === 'T') { e.preventDefault(); + setShowListSelector(false); setShowDateModal((prev) => !prev); } else if (e.key === 'd' || e.key === 'D') { e.preventDefault(); handleAction('keep', { convertTo: 'document' }); + } else if (e.key === ' ') { + e.preventDefault(); + handleSkipCard(); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); - }, [activeCard]); + }, [activeCard, cards.length]); const priorityBadge = (priority: string) => { switch (priority) { case 'urgent': return ( - + Dringend ); case 'high': return ( - + Wichtig ); default: return ( - - Gedanke + + Quick Dump ); } @@ -156,15 +215,15 @@ export const TriageDeck: React.FC = ({ const sourceBadge = (source: string) => { if (source === 'voice') { return ( - + Diktat ); } if (source === 'webhook') { return ( - - Webhook + + Webhook ); } @@ -173,30 +232,30 @@ export const TriageDeck: React.FC = ({ if (!activeCard) { return ( -
-
- +
+
+
-

Inbox Zero

-

- Alle Gedanken sind gesichtet. Dein Kopf ist wieder frei. +

Triage-Deck ist leer

+

+ Alle Quick Dump Notizen sind einsortiert oder verworfen. Tippe oben in die Leiste (Taste C), um neue Gedanken reinzurotzen.

-
+
{lastTriaged && ( )}
@@ -205,16 +264,16 @@ export const TriageDeck: React.FC = ({ } return ( -
+
{/* Top Deck Status & Undo Bar */} -
+
- - {cards.length} {cards.length === 1 ? 'Gedanke' : 'Gedanken'} verbleibend + + {cards.length} {cards.length === 1 ? 'Quick Dump' : 'Quick Dumps'} offen • - H / L oder ← / → + ← Wegwerfen • → Aufgabe • D Notiz • Leertaste Später
@@ -230,16 +289,16 @@ export const TriageDeck: React.FC = ({
{/* Card Stack Container */} -
+
{/* Background Preview Card */} {nextCard && (
{priorityBadge(nextCard.priority)}
-

{nextCard.content}

-
Nächste Karte…
+

{nextCard.content}

+
Nächster Quick Dump…
)} @@ -248,80 +307,121 @@ export const TriageDeck: React.FC = ({ { - if (info.offset.x > 110 || info.velocity.x > 550) { - handleAction('keep'); - } else if (info.offset.x < -110 || info.velocity.x < -550) { + if (info.offset.x > 105 || info.velocity.x > 520) { + handleAction('keep', { convertTo: 'task' }); + } else if (info.offset.x < -105 || info.velocity.x < -520) { handleAction('discard'); } }} - initial={{ scale: 0.96, opacity: 0, y: 12 }} + initial={{ scale: 0.97, opacity: 0, y: 10 }} animate={{ scale: 1, opacity: 1, y: 0 }} - exit={{ scale: 0.92, opacity: 0 }} + exit={{ scale: 0.93, opacity: 0 }} className="relative w-full h-full bg-panel border border-border p-4 sm:p-5 cursor-grab active:cursor-grabbing flex flex-col justify-between overflow-hidden" > {/* Dynamic Swipe Overlays */} - Behalten 👉 + Als Aufgabe 👉 - 👈 Weg damit + 👈 Wegwerfen {/* Card Header */}
-
+
{priorityBadge(activeCard.priority)} {sourceBadge(activeCard.source)}
- - - {new Date(activeCard.createdAt).toLocaleString('de-DE', { - day: '2-digit', - month: '2-digit', - hour: '2-digit', - minute: '2-digit', - })} - +
+ + + + {new Date(activeCard.createdAt).toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit', + })} + +
{/* Card Body */} -
-

- {activeCard.content} -

+
+ {isEditingCard ? ( +
+