diff --git a/Doku/database.md b/Doku/database.md index c2478c1..3447799 100644 --- a/Doku/database.md +++ b/Doku/database.md @@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS users ( avatar_url TEXT NOT NULL DEFAULT '', -- Profilbild URL oder leer role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member' is_active INTEGER NOT NULL DEFAULT 1, -- 1 = aktiv, 0 = gesperrt + token_version INTEGER NOT NULL DEFAULT 1, -- Erhöht bei Passwortwechsel/Rechteentzug zur Token-Invalidierung alarm_channels TEXT NOT NULL DEFAULT 'discord', -- Kommagetrennt: 'email', 'discord', 'webhook' alarm_email TEXT NOT NULL DEFAULT '', -- Ziel-E-Mail für Alarme discord_webhook_url TEXT NOT NULL DEFAULT '', -- Discord Webhook URL @@ -111,6 +112,19 @@ CREATE TABLE IF NOT EXISTS api_keys ( ); ``` +#### `revoked_tokens` +Sperrliste für explizit abgemeldete JWTs (Blacklist bis zum regulären Token-Ablaufdatum). +```sql +CREATE TABLE IF NOT EXISTS revoked_tokens ( + token_hash TEXT PRIMARY KEY, -- SHA-256 Hash des Tokens + user_id TEXT NOT NULL, -- Benutzer-ID + expires_at TEXT NOT NULL, -- ISO 8601 Ablaufdatum + revoked_at TEXT NOT NULL -- ISO 8601 Sperrzeitpunkt +); +CREATE INDEX IF NOT EXISTS idx_revoked_tokens_expires ON revoked_tokens(expires_at); +CREATE INDEX IF NOT EXISTS idx_revoked_tokens_user ON revoked_tokens(user_id); +``` + --- ### 2.2 Gruppen & Organisations-Teams diff --git a/Doku/flaws.md b/Doku/flaws.md index f3eaf7b..3f9c99a 100644 --- a/Doku/flaws.md +++ b/Doku/flaws.md @@ -15,7 +15,7 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle | **FLAW-05** | Frontend DOM | Fehlende Virtualisierung im Outliner-Baum | **Mittel** | Frame-Drops und Lags bei Dokumenten mit >500 Blöcken auf Mobilgeräten | | **FLAW-06** | SQLite Concurrency | Single-Writer-Bottleneck bei hohem parallelem Schreibaufkommen | **Mittel** | `SQLITE_BUSY` Fehler bei vielen gleichzeitigen Team-Nutzern | | **FLAW-07** | Benachrichtigungen | Fehlende Dead-Letter-Queue für Webhook- & E-Mail-Alarme | **Niedrig** | Verlorene Benachrichtigungen bei temporärem Ausfall von Drittsystemen | -| **FLAW-08** | Sicherheit / Auth | Statische JWT-Tokens ohne rotierende Refresh-Tokens & Blacklisting | **Niedrig** | Keine sofortige Sitzungsinvalidierung bei Rechteentzug | +| **FLAW-08** | Sicherheit / Auth | Statische JWT-Tokens ohne Blacklisting & Token-Versionierung | **Gelöst (v2.2)** | Behoben durch `token_version`-Spalte in `users`, `revoked_tokens`-Sperrliste und sofortige Sitzungsinvalidierung bei Passwortänderung/Logout/Rechteentzug | --- @@ -112,13 +112,15 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle --- -### FLAW-08: Statische JWT-Tokens ohne Blacklisting -- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), `internal/api/auth.go` -- **Ursache:** Nach dem Login wird ein JWT ausgestellt, das clientseitig im `localStorage` gespeichert wird. Der Server prüft lediglich die kryptografische Signatur des Tokens, führt aber keine serverseitige Sitzungsliste. -- **Folgen:** Wird einem Benutzer die Berechtigung entzogen oder ändert er sein Passwort, bleibt ein bereits ausgestellter Token bis zu seinem Ablaufdatum (z. B. 7 Tage) gültig. -- **Empfohlene Lösung:** - 1. Einführung einer `revoked_tokens`-Tabelle oder einer `token_version`-Spalte in der `users`-Tabelle. - 2. Implementierung von kurzlebigen Access-Tokens (15 Min.) in Verbindung mit rotierenden Refresh-Tokens in `httpOnly`-Cookies. +### FLAW-08: [GELÖST / IMPLEMENTIERT] JWT-Sicherheit, Token-Blacklisting & Token-Versionierung +- **Status:** **Gelöst via `token_version`-Spalte in `users`, `revoked_tokens`-Blacklist und serverseitige Invalidierungs-Endpunkte** +- **Betroffene Komponenten:** [`architecture.md`](./architecture.md), [`database.md`](./database.md), `internal/api/server.go`, `internal/auth/auth.go`, `internal/storage/sqlite.go`, `frontend/src/lib/api.ts` +- **Ursache (Historisch):** Nach dem Login wurde ein statisches JWT ausgestellt, das lediglich kryptografisch signiert war. Bei Passwortänderungen, Rechteänderungen oder Konto-Deaktivierung blieben bereits ausgestellte Tokens bis zum Ablaufdatum gültig. +- **Implementierte Lösung:** + 1. **`token_version`-Spalte in `users`:** Jedes ausgestellte JWT führt die `tokenVersion` des Benutzers im Claim. Bei einer Passwortänderung (`UpdateUserPassword`), Rechterückstufung oder Deaktivierung wird `token_version = token_version + 1` atomar hochgezählt. Alle alten Tokens auf beliebigen Geräten werden sofort mit `401 Unauthorized` abgewiesen. + 2. **Tabelle `revoked_tokens` für Einzelsitzungen:** Beim Klick auf Logout (`POST /api/v1/auth/logout`) wird das spezifische JWT via SHA-256-Hash in die Sperrliste eingetragen, bis sein reguläres Ablaufdatum erreicht ist. + 3. **"Alle Sitzungen beenden" (`POST /api/v1/auth/revoke-all`):** Ermöglicht es dem Benutzer in den Einstellungen ("Profil & Sicherheit") oder einem Administrator, mit einem Klick alle aktiven Sitzungen auf sämtlichen Browsern und Mobilgeräten zu terminieren. + 4. **Konsistente Prüfung bei WebSockets & SSE:** Auch bei Echtzeit-Verbindungen (`/api/v1/ws`, `/api/v1/ws/docs/:id`, `/api/v1/events`) wird der Benutzerstatus, die Sperrliste und die aktuelle `token_version` überprüft. Outdated oder gesperrte Clients werden sofort abgewiesen. --- diff --git a/cmd/server/main.go b/cmd/server/main.go index 9f034c9..6ef54bd 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,6 +5,7 @@ import ( "embed" "io/fs" "log/slog" + "net" "net/http" "os" "os/signal" @@ -58,9 +59,15 @@ func main() { srv := api.NewServer(cfg, store, wsHub, staticFS) + serverCtx, serverCancel := context.WithCancel(context.Background()) + defer serverCancel() + httpServer := &http.Server{ - Addr: ":" + cfg.Port, - Handler: srv.Router(), + Addr: ":" + cfg.Port, + Handler: srv.Router(), + BaseContext: func(l net.Listener) context.Context { + return serverCtx + }, ReadTimeout: 15 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, @@ -79,7 +86,12 @@ func main() { <-stop slog.Info("Shutting down MindSift server gracefully...") - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + serverCancel() + wsHub.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() - _ = httpServer.Shutdown(ctx) + if err := httpServer.Shutdown(ctx); err != nil { + slog.Warn("HTTP server shutdown finished with error", "error", err) + } } 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 0cc26a1..0a2d73f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -127,6 +127,8 @@ 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 [selectedTaskId, setSelectedTaskId] = useState(null); const [captureFocusCount, setCaptureFocusCount] = useState(0); const [refreshRevision, setRefreshRevision] = useState(0); @@ -143,6 +145,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 +160,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 +174,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); @@ -249,13 +251,6 @@ export const App: React.FC = () => { id: 'triage-nav', title: 'Bereiche wechseln', items: [ - { - id: 'goto-tasks', - label: 'Aufgaben & Kanban', - icon: , - badge: stats.tasksOpenCount, - onClick: () => setActiveTab('tasks'), - }, { id: 'goto-workspace', label: 'Notizen & Workspace', @@ -263,6 +258,13 @@ export const App: React.FC = () => { badge: stats.docsCount, onClick: () => setActiveTab('workspace'), }, + { + id: 'goto-tasks', + label: 'Aufgaben & Kanban', + icon: , + badge: stats.tasksOpenCount, + onClick: () => setActiveTab('tasks'), + }, { id: 'goto-lists', label: 'Einkaufslisten', @@ -297,18 +299,18 @@ export const App: React.FC = () => { variant: 'accent', onClick: () => setActiveTab('triage'), }, - { - id: 'goto-tasks', - label: 'Zu den Aufgaben', - icon: , - onClick: () => setActiveTab('tasks'), - }, { id: 'goto-workspace', label: 'Zum Workspace', icon: , onClick: () => setActiveTab('workspace'), }, + { + id: 'goto-tasks', + label: 'Zu den Aufgaben', + icon: , + onClick: () => setActiveTab('tasks'), + }, { id: 'goto-lists', label: 'Zu den Listen', @@ -549,11 +551,17 @@ export const App: React.FC = () => { refreshAll(); }; - const handleOpenDocument = (docId: string) => { + const handleOpenDocument = (docId: string, blockId?: string) => { setSelectedDocId(docId); + setSelectedBlockId(blockId || null); setActiveTab('workspace'); }; + const handleOpenTask = useCallback((taskId: string) => { + setSelectedTaskId(taskId); + setActiveTab('tasks'); + }, []); + const handleTriggerAddButton = () => { triggerHaptic(20); if (window.innerWidth < 640) { @@ -567,7 +575,11 @@ export const App: React.FC = () => { const handleLogout = async () => { triggerHaptic(20); disconnectWebSocket(); - clearToken(); + try { + await api.logout(); + } catch { + clearToken(); + } setUser(null); setShowProfileMenu(false); setActiveTab('triage'); @@ -632,9 +644,12 @@ export const App: React.FC = () => {
- - {authStatus.instanceName || 'MindSift'} - +
+ + {authStatus.instanceName || 'MindSift'} + + v0.1 +
{activeTabLabelMap[activeTab]} @@ -694,9 +709,12 @@ export const App: React.FC = () => {
- - {authStatus.instanceName || 'MindSift'} - +
+ + {authStatus.instanceName || 'MindSift'} + + v0.1 +
{/* Quick-Capture Input Bubble directly in Header (above Tabs) */} @@ -862,24 +880,6 @@ export const App: React.FC = () => { - - + + - {/* Col 2: Aufgaben */} + {/* Col 2: Workspace */} {/* Col 3 (EXACT CENTER): + Quick-Add FAB */} @@ -1066,27 +1087,27 @@ export const App: React.FC = () => { - {/* Col 4: Workspace */} + {/* Col 4: Aufgaben */} {/* Col 5: Listen */} diff --git a/frontend/src/components/UnifiedShareModal.tsx b/frontend/src/components/UnifiedShareModal.tsx index 77d8dba..cef88c8 100644 --- a/frontend/src/components/UnifiedShareModal.tsx +++ b/frontend/src/components/UnifiedShareModal.tsx @@ -24,6 +24,7 @@ export interface UnifiedShareModalProps { teams: Team[]; collaborators: User[]; allowRoleSelection?: boolean; // Default true (editor vs viewer) + fixedPermission?: 'editor' | 'viewer'; // When allowRoleSelection is false, force fixedPermission (default 'viewer' for documents, 'editor' for tasks/lists) onAddShare: (targetType: 'team' | 'user', targetId: string, permission: 'editor' | 'viewer') => Promise; onRemoveShare: (shareId: string) => Promise; } @@ -40,13 +41,17 @@ export const UnifiedShareModal: React.FC = ({ teams, collaborators, allowRoleSelection = true, + fixedPermission, onAddShare, onRemoveShare, }) => { + const effectiveFixedPerm = fixedPermission || 'viewer'; const [shareType, setShareType] = useState<'team' | 'user'>('team'); const [selectedTeamId, setSelectedTeamId] = useState(''); const [selectedUser, setSelectedUser] = useState(null); - const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>('editor'); + const [selectedPermission, setSelectedPermission] = useState<'editor' | 'viewer'>( + allowRoleSelection ? 'editor' : effectiveFixedPerm + ); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -63,7 +68,8 @@ export const UnifiedShareModal: React.FC = ({ setLoading(true); try { - await onAddShare(shareType, targetId, selectedPermission); + const permToUse = allowRoleSelection ? selectedPermission : effectiveFixedPerm; + await onAddShare(shareType, targetId, permToUse); setSelectedTeamId(''); setSelectedUser(null); } catch (err: any) { @@ -196,7 +202,7 @@ export const UnifiedShareModal: React.FC = ({ ))} - {allowRoleSelection && ( + {allowRoleSelection ? ( + ) : ( + + {effectiveFixedPerm === 'editor' ? ( + <> + + Bearbeiten + + ) : ( + <> + + Nur Lesen + + )} + )} ) : ( @@ -220,7 +240,7 @@ export const UnifiedShareModal: React.FC = ({ placeholder="Benutzer suchen (Name oder @Benutzername)…" /> - {allowRoleSelection && ( + {allowRoleSelection ? ( + ) : ( + + {effectiveFixedPerm === 'editor' ? ( + <> + + Bearbeiten + + ) : ( + <> + + Nur Lesen + + )} + )} )} 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 49f9484..b06384c 100644 --- a/frontend/src/features/lists/SharedListsView.tsx +++ b/frontend/src/features/lists/SharedListsView.tsx @@ -8,6 +8,7 @@ import { Sparkles, X, Hash, + ShoppingCart, } from 'lucide-react'; import { ListItem, SharedList, ListShare, Team, User, WSMessage } from '../../lib/types'; import { api, buildWebSocketURL, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api'; @@ -84,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'); @@ -173,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) => { @@ -272,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); @@ -953,41 +976,61 @@ export const SharedListsView: React.FC = ({ onRefreshGloba {showNewListModal && ( -
-
- {['🛒', '🏔️', '🍕', '🎉', '🧰', '🎁'].map((ic) => ( +
+
+
+

+ + Neue Einkaufsliste erstellen +

- ))} +
+ + +
+ +
+ setNewListIcon(e.target.value)} + maxLength={2} + className="w-10 h-8 text-center input-field text-sm" + title="Icon (Emoji)" + /> + setNewListTitle(e.target.value)} + placeholder="z.B. Supermarkt, Baumarkt, Drogerie" + autoFocus + required + className="flex-1 h-8 input-field px-2.5 text-xs" + /> +
+
+ +
+ + +
+
- setNewListTitle(e.target.value)} - placeholder="Titel der Liste (z. B. Baumarkt, Drogerie, Camping)…" - className="flex-1 h-9 px-3 input-field text-sm" - autoFocus - /> - - +
)} {/* Active Shared List View */} @@ -1064,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 && ( @@ -1253,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/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx index ec8e15b..4c2bbf0 100644 --- a/frontend/src/features/settings/UserSettingsView.tsx +++ b/frontend/src/features/settings/UserSettingsView.tsx @@ -49,6 +49,7 @@ export const UserSettingsView: React.FC = ({ const [newPassword, setNewPassword] = useState(''); const [profileMsg, setProfileMsg] = useState<{ type: 'ok' | 'err'; text: string } | null>(null); const [savingProfile, setSavingProfile] = useState(false); + const [revokingSessions, setRevokingSessions] = useState(false); const [isProcessingAvatar, setIsProcessingAvatar] = useState(false); const fileInputRef = useRef(null); @@ -155,6 +156,21 @@ export const UserSettingsView: React.FC = ({ } }; + const handleRevokeAllSessions = async () => { + if (!window.confirm('Möchtest du wirklich alle aktiven Sitzungen auf allen Geräten beenden? Du wirst dabei auch auf diesem Gerät abgemeldet.')) { + return; + } + setRevokingSessions(true); + try { + await api.revokeAllSessions(); + await api.logout(); + window.location.reload(); + } catch (err: any) { + setProfileMsg({ type: 'err', text: err.message || 'Fehler beim Beenden der Sitzungen' }); + setRevokingSessions(false); + } + }; + // --- Handlers: Alarms --- const handleSaveAlarms = async (e: React.FormEvent) => { e.preventDefault(); @@ -530,6 +546,32 @@ export const UserSettingsView: React.FC = ({ + + {/* Sessions & Security */} +
+
+

Aktive Sitzungen & Sicherheit

+

+ Sitzungen werden durch kryptografische JWTs geschützt. Bei einer Passwortänderung werden alle alten Sitzungen automatisch ungültig. +

+
+
+
+

Alle Sitzungen sofort beenden

+

+ Invalidiert alle ausgestellten Tokens auf allen Geräten und Browsern. +

+
+ +
+
)} 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" + /> + + {doc?.icon && ( - ))} + )} + {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`; + }} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + outlinerTreeRef.current?.focusFirstBlock(); + } + }} 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]" /> {/* Logseq Outliner Tree */} void; levelOffset?: number; onZoomBlock?: (blockId: string) => void; - onFocusBlock: (blockId: string) => void; onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void; - onCreateBlockBelow: (afterBlockId: string, currentContent: string) => void; - onDeleteBlock: (blockId: string) => void; + onCreateBlockBelow: (afterBlockId: string, newContent?: string) => void; + onDeleteBlock: (blockId: string, deleteSubtree?: boolean) => void; onIndentBlock: (blockId: string, delta: 1 | -1) => void; onNavigateFocus: (blockId: string, direction: 'up' | 'down') => void; + onFocusBlock?: (blockId: string) => void; + onOpenTask?: (taskId: string) => void; onTagClick?: (tag: string) => void; onWikiLinkClick?: (pageTitle: string) => void; onDragStartBlock?: (index: number) => void; @@ -65,24 +69,27 @@ export interface OutlinerBlockProps { isDragTarget?: boolean; isTargeted?: boolean; registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void; + onPasteMultiLines?: (blockId: string, textBefore: string, textAfter: string, rawLines: string[]) => void; } export const OutlinerBlock: React.FC = ({ + docId, block, index, + isLast, + maxAllowedLevel, hasChildren, isFocused, canEdit, - activeRemoteCursors = [], - sendCursorMoved, levelOffset = 0, onZoomBlock, - onFocusBlock, onUpdateBlock, onCreateBlockBelow, onDeleteBlock, onIndentBlock, onNavigateFocus, + onFocusBlock, + onOpenTask, onTagClick, onWikiLinkClick, onDragStartBlock, @@ -91,11 +98,15 @@ export const OutlinerBlock: React.FC = ({ isDragTarget, isTargeted, registerTextareaRef, + onPasteMultiLines, }) => { const [localContent, setLocalContent] = useState(block.content || ''); + const [isLocallyFocused, setIsLocallyFocused] = useState(false); + const activeFocus = isFocused || isLocallyFocused; 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); @@ -107,18 +118,17 @@ export const OutlinerBlock: React.FC = ({ // Callback refs to avoid stale closures in setTimeout, debounce flush, and event listeners const onUpdateBlockRef = useRef(onUpdateBlock); onUpdateBlockRef.current = onUpdateBlock; - const onFocusBlockRef = useRef(onFocusBlock); - onFocusBlockRef.current = onFocusBlock; const onCreateBlockBelowRef = useRef(onCreateBlockBelow); onCreateBlockBelowRef.current = onCreateBlockBelow; + const onPasteMultiLinesRef = useRef(onPasteMultiLines); + onPasteMultiLinesRef.current = onPasteMultiLines; const onDeleteBlockRef = useRef(onDeleteBlock); onDeleteBlockRef.current = onDeleteBlock; const onIndentBlockRef = useRef(onIndentBlock); onIndentBlockRef.current = onIndentBlock; const onNavigateFocusRef = useRef(onNavigateFocus); onNavigateFocusRef.current = onNavigateFocus; - const sendCursorMovedRef = useRef(sendCursorMoved); - sendCursorMovedRef.current = sendCursorMoved; + const isPastingRef = useRef(false); // Track if this input currently has actual focus in an active browser window const isFocusedRef = useRef(false); @@ -166,7 +176,6 @@ export const OutlinerBlock: React.FC = ({ flushDebounce(localContentRef.current); } isFocusedRef.current = false; - sendCursorMovedRef.current?.(null); }; const handleWindowFocus = () => { @@ -203,7 +212,7 @@ export const OutlinerBlock: React.FC = ({ useLayoutEffect(() => { adjustHeight(); - }, [localContent, block.type, block.level, adjustHeight]); + }, [localContent, block.type, block.level, activeFocus, adjustHeight]); useEffect(() => { window.addEventListener('resize', adjustHeight); @@ -279,7 +288,6 @@ export const OutlinerBlock: React.FC = ({ } setLocalContent(val); - sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd); if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); @@ -302,14 +310,20 @@ export const OutlinerBlock: React.FC = ({ }, 150); }; - const handleFocus = (e: React.FocusEvent) => { + const handleFocus = () => { isFocusedRef.current = true; - onFocusBlockRef.current(block.id); - sendCursorMovedRef.current?.(block.id, e.target.selectionStart, e.target.selectionEnd); + setIsLocallyFocused(true); + onFocusBlock?.(block.id); + setTimeout(adjustHeight, 0); }; const handleBlur = () => { isFocusedRef.current = false; + setIsLocallyFocused(false); + if (isPastingRef.current) { + isPastingRef.current = false; + return; + } if (debounceTimerRef.current !== null) { window.clearTimeout(debounceTimerRef.current); debounceTimerRef.current = null; @@ -317,23 +331,30 @@ export const OutlinerBlock: React.FC = ({ } else if (localContentRef.current !== latestBlockRef.current.content) { flushDebounce(localContentRef.current); } - sendCursorMovedRef.current?.(null); }; - const handleClick = (e: React.MouseEvent) => { - sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd); - }; + const handlePaste = (e: React.ClipboardEvent) => { + if (!canEdit || block.type === 'code') return; + const pastedText = e.clipboardData.getData('text'); + if (!pastedText || !pastedText.includes('\n')) return; - const handleKeyUp = (e: React.KeyboardEvent) => { - sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd); - }; + e.preventDefault(); + const rawLines = pastedText.split(/\r?\n/); + if (rawLines.length <= 1) return; - const handleSelect = (e: React.SyntheticEvent) => { - const target = e.currentTarget; - sendCursorMovedRef.current?.(block.id, target.selectionStart, target.selectionEnd); - }; + const start = e.currentTarget.selectionStart ?? localContent.length; + const end = e.currentTarget.selectionEnd ?? start; + const textBefore = localContent.slice(0, start); + const textAfter = localContent.slice(end); - const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null; + if (debounceTimerRef.current !== null) { + window.clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + + isPastingRef.current = true; + onPasteMultiLinesRef.current?.(block.id, textBefore, textAfter, rawLines); + }; const handleKeyDown = (e: React.KeyboardEvent) => { if (!canEdit) return; @@ -374,13 +395,49 @@ export const OutlinerBlock: React.FC = ({ return; } - // 2. Enter (without Shift) -> Create new block on same level + // 2. Enter (without Shift) -> If block is completely empty: + // - If level > minLevel, outdent (Shift+Tab behaviour) + // - If already at minLevel and is the last block, do NOT create another empty block below + // - Otherwise split paragraph at cursor and create new block below if (e.key === 'Enter' && !e.shiftKey && block.type !== 'code') { e.preventDefault(); e.stopPropagation(); setSlashSearch(null); - flushDebounce(localContent); - onCreateBlockBelowRef.current(block.id, localContent); + + const minLevel = levelOffset || 0; + if (localContent === '') { + if (Math.max(0, block.level || 0) > minLevel) { + flushDebounce(localContent); + onIndentBlockRef.current(block.id, -1); + return; + } + if (isLast) { + // Bereits auf oberster Ebene und der letzte Block: kein weiterer leerer Block + return; + } + } + + const start = e.currentTarget.selectionStart ?? localContent.length; + const end = e.currentTarget.selectionEnd ?? start; + let beforeText = localContent.slice(0, start); + let afterText = localContent.slice(end); + + // Clean linebreaks at the split boundary if present + if (beforeText.endsWith('\r\n')) { + beforeText = beforeText.slice(0, -2); + } else if (beforeText.endsWith('\n')) { + beforeText = beforeText.slice(0, -1); + } + if (afterText.startsWith('\r\n')) { + afterText = afterText.slice(2); + } else if (afterText.startsWith('\n')) { + afterText = afterText.slice(1); + } + + setLocalContent(beforeText); + localContentRef.current = beforeText; + flushDebounce(beforeText); + onCreateBlockBelowRef.current(block.id, afterText); return; } @@ -422,6 +479,7 @@ export const OutlinerBlock: React.FC = ({ const level = Math.max(0, block.level || 0); const visualLevel = Math.max(0, level - (levelOffset || 0)); + const isEmpty = (!localContent || localContent.trim() === '') && !hasChildren; // Parse rich tokens (WikiLinks [[Page]], Markdown Links [Title](url), raw URLs, and #Tags) const tokens = useMemo(() => { @@ -441,39 +499,29 @@ export const OutlinerBlock: React.FC = ({ return (
onDragOverBlock?.(e, index)} onDrop={() => onDropBlock?.(index)} + onClick={(e) => { + if ((e.target as HTMLElement).closest('button, a, input, select')) return; + if (!activeFocus && canEdit && textareaRef.current) { + textareaRef.current.focus(); + const len = textareaRef.current.value.length; + textareaRef.current.setSelectionRange(len, len); + } + }} > - {/* Remote Cursors Floating Name Badges on Block */} - {activeRemoteCursors.length > 0 && ( -
- {activeRemoteCursors.map((c) => ( - - {c.userName} - - ))} -
- )} - {/* Indentation Guide Line */} {visualLevel > 0 && Array.from({ length: visualLevel }).map((_, lvlIdx) => ( @@ -484,20 +532,8 @@ export const OutlinerBlock: React.FC = ({ /> ))} - {/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */} + {/* Bullet Controls: Collapse Toggle & Bullet (Drag-Handle & Zoom) / Checkbox */}
- {canEdit && ( - - )} - {hasChildren ? ( + + {/* If To-Do block: checkbox alongside bullet */} + {block.type === 'todo' && ( - ) : ( - )}
{/* Main Block Content */} -
+
{block.type === 'divider' ? (

@@ -581,9 +631,6 @@ export const OutlinerBlock: React.FC = ({ readOnly={!canEdit} onFocus={handleFocus} onBlur={handleBlur} - onClick={handleClick} - onKeyUp={handleKeyUp} - onSelect={handleSelect} onChange={handleContentChange} onKeyDown={handleKeyDown} className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed" @@ -601,16 +648,133 @@ export const OutlinerBlock: React.FC = ({ readOnly={!canEdit} onFocus={handleFocus} onBlur={handleBlur} - onClick={handleClick} - onKeyUp={handleKeyUp} - onSelect={handleSelect} onChange={handleContentChange} onKeyDown={handleKeyDown} className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic" />
) : ( -
+
+ {/* View Layer with Inline Markdown & Actions (3-dots or Erweitert Badge) directly behind text */} +
{ + if (!activeFocus && canEdit && textareaRef.current) { + textareaRef.current.focus(); + const len = textareaRef.current.value.length; + textareaRef.current.setSelectionRange(len, len); + } + }} + className={`w-full min-h-[1.5rem] leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] ${ + activeFocus ? 'absolute inset-0 pointer-events-none' : 'relative cursor-text' + } ${ + block.type === 'h1' + ? 'text-lg font-bold text-main tracking-tight' + : block.type === 'h2' + ? 'text-base font-semibold text-main' + : block.type === 'h3' + ? 'text-sm font-semibold text-main' + : block.type === 'todo' && block.checked + ? 'text-sm text-faint line-through' + : 'text-sm text-main' + }`} + > + {/* When actively focused, ghost text is invisible so textarea renders text/caret directly */} + + {localContent ? renderInlineMarkdown(localContent) : '\u200b'} + + + {/* Inline Badge and 3-Dots directly behind the text */} + {block.type === 'todo' && ( + + {block.taskId && ( + + + Erweitert + + )} + + {canEdit && ( + + + {showTodoMenu && ( +
e.stopPropagation()} + > + {!block.taskId ? ( + + ) : ( + + )} +
+ )} +
+ )} +
+ )} +
+