From 84e0b6a4bd39499b4a064596a58793c2b263f560 Mon Sep 17 00:00:00 2001 From: Sandro Date: Sun, 25 Oct 2026 02:23:46 +0200 Subject: [PATCH] feat: implement live-sync for tasks, workspace note sharing, and document locking system --- frontend/src/App.tsx | 40 +- frontend/src/features/triage/TasksView.tsx | 65 ++ .../src/features/workspace/WorkspaceView.tsx | 789 ++++++++++++++++-- frontend/src/lib/api.ts | 152 +++- frontend/src/lib/types.ts | 28 +- internal/api/server.go | 161 ++++ internal/domain/models.go | 24 + internal/storage/sqlite.go | 249 +++++- internal/storage/sqlite_test.go | 53 ++ internal/ws/hub.go | 322 ++++++- 10 files changed, 1773 insertions(+), 110 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 06182ee..842adf9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,7 +14,14 @@ import { HelpCircle, } from 'lucide-react'; import { AuthStatus, DashboardStats, SharedList, Thought, User } from './lib/types'; -import { api, clearToken, getToken, triggerHaptic } from './lib/api'; +import { + api, + clearToken, + getToken, + triggerHaptic, + connectWebSocket, + disconnectWebSocket, +} from './lib/api'; import { AVAILABLE_THEMES, initThemeOnBoot, @@ -193,6 +200,35 @@ export const App: React.FC = () => { }; }, []); + // WebSocket Connection Management + useEffect(() => { + if (user && getToken()) { + connectWebSocket(); + } else { + disconnectWebSocket(); + } + }, [user]); + + // Refresh stats on task/doc real-time events + useEffect(() => { + const handleWs = (e: any) => { + const msg = e.detail; + if (!msg) return; + if ( + typeof msg.event === 'string' && + (msg.event.startsWith('TASK_') || msg.event.startsWith('DOC_')) + ) { + api.getMe().then((res) => { + setStats(res.stats); + }).catch(() => {}); + } + }; + window.addEventListener('mindsift:ws', handleWs); + return () => { + window.removeEventListener('mindsift:ws', handleWs); + }; + }, []); + // Close profile dropdown on outside click useEffect(() => { const handleClickOutside = (e: MouseEvent) => { @@ -239,6 +275,7 @@ export const App: React.FC = () => { const handleLogout = async () => { triggerHaptic(20); + disconnectWebSocket(); clearToken(); setUser(null); setShowProfileMenu(false); @@ -551,6 +588,7 @@ export const App: React.FC = () => { {activeTab === 'workspace' && ( diff --git a/frontend/src/features/triage/TasksView.tsx b/frontend/src/features/triage/TasksView.tsx index 8201d0f..6d6516f 100644 --- a/frontend/src/features/triage/TasksView.tsx +++ b/frontend/src/features/triage/TasksView.tsx @@ -96,6 +96,71 @@ export const TasksView: React.FC = ({ loadData(); }, [refreshTrigger, selectedListId]); + // Live-Sync WebSocket listener for tasks + useEffect(() => { + const handleWs = (e: any) => { + const msg = e.detail; + if (!msg || !msg.event) return; + + if (msg.event === 'TASK_CREATED') { + const created = msg.payload as Thought; + if ( + created && + (created.status === 'converted' || + created.kanbanStatus || + created.taskListId || + created.isCompleted !== undefined) + ) { + setTasks((prev) => { + if (prev.some((t) => t.id === created.id)) return prev; + if (selectedListId !== 'all' && created.taskListId !== selectedListId) return prev; + return [created, ...prev]; + }); + onRefreshStats(); + } + } else if (msg.event === 'TASK_UPDATED') { + const updated = msg.payload as Thought; + if (updated && updated.id) { + setTasks((prev) => { + const exists = prev.some((t) => t.id === updated.id); + if (!exists) { + if ( + (updated.status === 'converted' || + updated.kanbanStatus || + updated.taskListId || + updated.isCompleted !== undefined) && + (selectedListId === 'all' || updated.taskListId === selectedListId) + ) { + return [updated, ...prev]; + } + return prev; + } + if (selectedListId !== 'all' && updated.taskListId !== selectedListId) { + return prev.filter((t) => t.id !== updated.id); + } + return prev.map((t) => { + if (t.id !== updated.id) return t; + return { + ...updated, + notes: activeNotesTaskId === updated.id ? t.notes : updated.notes, + }; + }); + }); + onRefreshStats(); + } + } else if (msg.event === 'TASK_DELETED') { + const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id; + if (deletedId) { + setTasks((prev) => prev.filter((t) => t.id !== deletedId)); + onRefreshStats(); + } + } + }; + + window.addEventListener('mindsift:ws', handleWs); + return () => window.removeEventListener('mindsift:ws', handleWs); + }, [selectedListId, activeNotesTaskId, onRefreshStats]); + const handleToggleDone = async (task: Thought) => { triggerHaptic(15); const nextState = !task.isCompleted; diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 6335247..28b55e5 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -39,11 +39,17 @@ import { FolderTree, Plus, HelpCircle, + Users, + Lock, + Unlock, + Eye, + Share2, } from 'lucide-react'; -import { BlockType, Document, DocumentBlock } from '../../lib/types'; +import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, User } from '../../lib/types'; import { api, triggerHaptic } from '../../lib/api'; interface WorkspaceViewProps { + currentUser?: User | null; initialDocId?: string | null; onStatsChange: () => void; } @@ -79,6 +85,7 @@ const SLASH_COMMANDS: SlashCommandItem[] = [ ]; export const WorkspaceView: React.FC = ({ + currentUser, initialDocId, onStatsChange, }) => { @@ -129,8 +136,102 @@ export const WorkspaceView: React.FC = ({ const [todoFolderDrilldown, setTodoFolderDrilldown] = useState(null); const [collapsedTodoFolders, setCollapsedTodoFolders] = useState>(new Set()); + // --- DOCUMENT SHARING STATE --- + const [showShareModal, setShowShareModal] = useState(false); + const [docShares, setDocShares] = useState([]); + const [collaborators, setCollaborators] = useState([]); + const [shareTargetEmailOrId, setShareTargetEmailOrId] = useState(''); + const [sharePermission, setSharePermission] = useState<'read' | 'write'>('read'); + const [sharingLoading, setSharingLoading] = useState(false); + const [shareError, setShareError] = useState(null); + + // --- DOCUMENT LOCKING STATE --- + const [activeLock, setActiveLock] = useState(null); + const [holdsLock, setHoldsLock] = useState(false); + const holdsLockRef = useRef(false); + const heartbeatTimerRef = useRef(null); + const idleUnlockTimerRef = useRef(null); + const currentDocIdRef = useRef(null); + const blockInputRefs = useRef>({}); + const isLockedByOther = !!(activeLock && activeLock.userId !== currentUser?.id); + const isReadOnly = selectedDoc?.permission === 'read' || isLockedByOther; + + const stopHeartbeat = () => { + if (heartbeatTimerRef.current) { + clearInterval(heartbeatTimerRef.current); + heartbeatTimerRef.current = null; + } + }; + + const startHeartbeat = (docId: string) => { + stopHeartbeat(); + heartbeatTimerRef.current = setInterval(async () => { + if (!holdsLockRef.current) { + stopHeartbeat(); + return; + } + const res = await api.heartbeatDocLock(docId); + if (!res.extended) { + holdsLockRef.current = false; + setHoldsLock(false); + stopHeartbeat(); + } + }, 10000); + }; + + const releaseMyLock = async (docId?: string) => { + const targetDocId = docId || currentDocIdRef.current; + if (!targetDocId || !holdsLockRef.current) return; + stopHeartbeat(); + if (idleUnlockTimerRef.current) { + clearTimeout(idleUnlockTimerRef.current); + idleUnlockTimerRef.current = null; + } + holdsLockRef.current = false; + setHoldsLock(false); + try { + await api.unlockDocument(targetDocId); + } catch {} + }; + + const resetIdleTimer = () => { + if (idleUnlockTimerRef.current) { + clearTimeout(idleUnlockTimerRef.current); + } + idleUnlockTimerRef.current = setTimeout(() => { + releaseMyLock(); + }, 25000); + }; + + const ensureLock = async (): Promise => { + if (!selectedDoc || selectedDoc.permission === 'read') return false; + if (holdsLockRef.current) { + resetIdleTimer(); + return true; + } + try { + const res = await api.lockDocument(selectedDoc.id); + if (res.acquired) { + holdsLockRef.current = true; + setHoldsLock(true); + setActiveLock(null); + startHeartbeat(selectedDoc.id); + resetIdleTimer(); + return true; + } else if (res.lock) { + setActiveLock(res.lock); + holdsLockRef.current = false; + setHoldsLock(false); + return false; + } + } catch (e) { + console.error('Failed to acquire lock', e); + } + return false; + }; + const loadDocs = async (selectId?: string | null) => { const list = await api.getDocuments(); setDocs(list); @@ -153,12 +254,216 @@ export const WorkspaceView: React.FC = ({ loadDocs(initialDocId); }, [initialDocId]); + // Handle document switch and lock status checks + useEffect(() => { + const prevId = currentDocIdRef.current; + if (prevId && prevId !== selectedDoc?.id) { + releaseMyLock(prevId); + } + currentDocIdRef.current = selectedDoc?.id || null; + + if (selectedDoc) { + api + .getDocLock(selectedDoc.id) + .then((res) => { + if (res.locked && res.lock && res.lock.userId !== currentUser?.id) { + setActiveLock(res.lock); + } else { + setActiveLock(null); + } + }) + .catch(() => {}); + } else { + setActiveLock(null); + } + + return () => { + if (currentDocIdRef.current) { + releaseMyLock(currentDocIdRef.current); + } + }; + }, [selectedDoc?.id]); + + // Real-time WebSocket listener for Document & Lock events + useEffect(() => { + const handleWs = (e: any) => { + const msg = e.detail; + if (!msg || !msg.event) return; + + if (msg.event === 'DOC_LOCKED') { + const payload = msg.payload as DocLockInfo; + if (payload && payload.docId === selectedDoc?.id) { + if (payload.userId !== currentUser?.id) { + setActiveLock(payload); + } + } + } else if (msg.event === 'DOC_UNLOCKED') { + const payload = msg.payload as { doc_id: string }; + if (payload && payload.doc_id === selectedDoc?.id) { + setActiveLock(null); + } + } else if (msg.event === 'DOC_CREATED') { + const created = msg.payload as Document; + if (created && created.id) { + setDocs((prev) => (prev.some((d) => d.id === created.id) ? prev : [created, ...prev])); + onStatsChange(); + } + } else if (msg.event === 'DOC_UPDATED') { + const updated = msg.payload as Document; + if (updated && updated.id) { + setDocs((prev) => + prev.map((d) => (d.id === updated.id ? { ...d, ...updated } : d)) + ); + if (selectedDoc?.id === updated.id) { + if (!holdsLockRef.current) { + setSelectedDoc((prev) => (prev ? { ...prev, ...updated } : updated)); + } + } + onStatsChange(); + } + } else if (msg.event === 'DOC_DELETED') { + const deletedId = typeof msg.payload === 'string' ? msg.payload : msg.payload?.id; + if (deletedId) { + setDocs((prev) => prev.filter((d) => d.id !== deletedId)); + if (selectedDoc?.id === deletedId) { + setSelectedDoc(null); + } + onStatsChange(); + } + } + }; + + window.addEventListener('mindsift:ws', handleWs); + return () => window.removeEventListener('mindsift:ws', handleWs); + }, [selectedDoc?.id, currentUser?.id, onStatsChange]); + + const openShareModal = async () => { + if (!selectedDoc) return; + setShowShareModal(true); + setShareError(null); + setSharingLoading(true); + try { + const [sharesList, usersList] = await Promise.all([ + api.getDocShares(selectedDoc.id).catch(() => []), + api.getCollaborators().catch(() => []), + ]); + setDocShares(sharesList); + setCollaborators(usersList); + } finally { + setSharingLoading(false); + } + }; + + const handleAddShare = async (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedDoc) return; + const target = shareTargetEmailOrId.trim(); + if (!target) return; + setSharingLoading(true); + setShareError(null); + try { + const isId = collaborators.some((u) => u.id === target); + const newShare = await api.createDocShare(selectedDoc.id, { + userId: isId ? target : undefined, + email: !isId ? target : undefined, + permission: sharePermission, + }); + setDocShares((prev) => [...prev.filter((s) => s.userId !== newShare.userId), newShare]); + setShareTargetEmailOrId(''); + setSelectedDoc((prev) => + prev + ? { + ...prev, + sharedWith: [ + ...(prev.sharedWith || []).filter((s) => s.userId !== newShare.userId), + newShare, + ], + } + : prev + ); + } catch (err: any) { + setShareError(err.message || 'Freigabe konnte nicht erstellt werden'); + } finally { + setSharingLoading(false); + } + }; + + const handleUpdateSharePerm = async (targetUserId: string, newPerm: 'read' | 'write') => { + if (!selectedDoc) return; + setSharingLoading(true); + try { + const updated = await api.createDocShare(selectedDoc.id, { + userId: targetUserId, + permission: newPerm, + }); + setDocShares((prev) => prev.map((s) => (s.userId === targetUserId ? updated : s))); + setSelectedDoc((prev) => + prev + ? { + ...prev, + sharedWith: (prev.sharedWith || []).map((s) => + s.userId === targetUserId ? updated : s + ), + } + : prev + ); + } catch (err: any) { + setShareError(err.message || 'Berechtigung konnte nicht geändert werden'); + } finally { + setSharingLoading(false); + } + }; + + const handleRemoveShare = async (targetUserId: string) => { + if (!selectedDoc) return; + if (!window.confirm('Freigabe für diesen Benutzer wirklich aufheben?')) return; + setSharingLoading(true); + try { + await api.deleteDocShare(selectedDoc.id, targetUserId); + setDocShares((prev) => prev.filter((s) => s.userId !== targetUserId)); + setSelectedDoc((prev) => + prev + ? { + ...prev, + sharedWith: (prev.sharedWith || []).filter((s) => s.userId !== targetUserId), + } + : prev + ); + } catch (err: any) { + setShareError(err.message || 'Freigabe konnte nicht entfernt werden'); + } finally { + setSharingLoading(false); + } + }; + + const handleLeaveShare = async () => { + if (!selectedDoc || !currentUser) return; + if ( + !window.confirm( + 'Möchtest du diese freigegebene Notiz wirklich aus deinem Workspace entfernen?' + ) + ) + return; + try { + await api.deleteDocument(selectedDoc.id); + setShowShareModal(false); + const remaining = docs.filter((d) => d.id !== selectedDoc.id); + setDocs(remaining); + setSelectedDoc(remaining[0] || null); + onStatsChange(); + } catch (err: any) { + alert(err.message || 'Konnte Freigabe nicht verlassen'); + } + }; + const persistDocument = async (updatedDoc: Document) => { + if (isReadOnly) return; + await ensureLock(); setSelectedDoc(updatedDoc); setDocs((prev) => prev.map((d) => (d.id === updatedDoc.id ? updatedDoc : d))); setSaveState('saving'); try { - await api.updateDocument(updatedDoc.id, { + const saved = await api.updateDocument(updatedDoc.id, { title: updatedDoc.title, icon: updatedDoc.icon, folderPath: updatedDoc.folderPath, @@ -166,6 +471,24 @@ export const WorkspaceView: React.FC = ({ tags: updatedDoc.tags, isPinned: updatedDoc.isPinned, }); + setSelectedDoc((prev) => + prev + ? { + ...prev, + ...saved, + permission: prev.permission, + isOwner: prev.isOwner, + ownerName: prev.ownerName, + sharedWith: prev.sharedWith, + } + : saved + ); + } catch (e: any) { + if (e.message && e.message.includes('423')) { + api.getDocLock(updatedDoc.id).then((res) => { + if (res.lock) setActiveLock(res.lock); + }); + } } finally { setSaveState('saved'); } @@ -336,7 +659,7 @@ export const WorkspaceView: React.FC = ({ // Insert a new bullet immediately after index, inheriting level const insertNewBulletAfter = (afterIndex: number, level: number, type: BlockType = 'bullet') => { - if (!selectedDoc) return; + if (isReadOnly || !selectedDoc) return; const newBlock: DocumentBlock = { id: `blk_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, type, @@ -361,7 +684,7 @@ export const WorkspaceView: React.FC = ({ // Indent block (Tab): increase level by 1 const indentBlock = (index: number) => { - if (!selectedDoc || index === 0) return; + if (isReadOnly || !selectedDoc || index === 0) return; const block = selectedDoc.blocks[index]; const prevBlock = selectedDoc.blocks[index - 1]; const currentLevel = block.level || 0; @@ -373,7 +696,7 @@ export const WorkspaceView: React.FC = ({ // Outdent block (Shift+Tab): decrease level by 1 const outdentBlock = (blockId: string) => { - if (!selectedDoc) return; + if (isReadOnly || !selectedDoc) return; const block = selectedDoc.blocks.find((b) => b.id === blockId); if (!block) return; const currentLevel = block.level || 0; @@ -384,7 +707,7 @@ export const WorkspaceView: React.FC = ({ // Delete block and focus the preceding one const deleteBlockAndFocusPrev = (index: number) => { - if (!selectedDoc || selectedDoc.blocks.length <= 1) return; + if (isReadOnly || !selectedDoc || selectedDoc.blocks.length <= 1) return; const prevBlock = selectedDoc.blocks[index - 1] || selectedDoc.blocks[index + 1]; const targetId = selectedDoc.blocks[index].id; removeBlock(targetId); @@ -404,7 +727,7 @@ export const WorkspaceView: React.FC = ({ }; const updateBlock = (blockId: string, patch: Partial) => { - if (!selectedDoc) return; + if (isReadOnly || !selectedDoc) return; const nextBlocks = selectedDoc.blocks.map((b) => b.id === blockId ? { ...b, ...patch } : b ); @@ -412,7 +735,7 @@ export const WorkspaceView: React.FC = ({ }; const removeBlock = (blockId: string) => { - if (!selectedDoc || selectedDoc.blocks.length <= 1) return; + if (isReadOnly || !selectedDoc || selectedDoc.blocks.length <= 1) return; const nextBlocks = selectedDoc.blocks.filter((b) => b.id !== blockId); persistDocument({ ...selectedDoc, blocks: nextBlocks }); }; @@ -431,6 +754,9 @@ export const WorkspaceView: React.FC = ({ block: DocumentBlock, index: number ) => { + if (isReadOnly) return; + ensureLock(); + if (slashBlockId === block.id) { if (e.key === 'ArrowDown') { e.preventDefault(); @@ -481,6 +807,8 @@ export const WorkspaceView: React.FC = ({ }; const handleContentChange = (block: DocumentBlock, newContent: string) => { + if (isReadOnly) return; + ensureLock(); updateBlock(block.id, { content: newContent }); // Check if user just typed '/' @@ -507,7 +835,7 @@ export const WorkspaceView: React.FC = ({ }, [slashSearch]); const applySlashCommand = (blockId: string, type: BlockType) => { - if (!selectedDoc) return; + if (isReadOnly || !selectedDoc) return; const block = selectedDoc.blocks.find((b) => b.id === blockId); if (!block) return; const cleanContent = block.content.replace(/\/([a-z0-9]*)$/i, '').trim(); @@ -876,6 +1204,14 @@ export const WorkspaceView: React.FC = ({ {doc.icon || '📄'} {doc.title || 'Unbenannt'} {doc.isPinned && } + {doc.isOwner === false && ( + + + + )} ); @@ -1287,9 +1623,47 @@ export const WorkspaceView: React.FC = ({ • {selectedDoc.blocks.length} Bullets + + {selectedDoc.isOwner === false && ( + + {selectedDoc.permission === 'read' ? 'Nur Lesezugriff' : 'Schreibzugriff'} + {selectedDoc.ownerName ? ` (von ${selectedDoc.ownerName})` : ' (Geteilt)'} + + )} + + {selectedDoc.sharedWith && selectedDoc.sharedWith.length > 0 && selectedDoc.isOwner !== false && ( + + + Geteilt ({selectedDoc.sharedWith.length}) + + )}
+ + - + {selectedDoc.isOwner !== false ? ( + + ) : ( + + )}
+ {/* LOCK WARNING BANNER */} + {isLockedByOther && ( +
+ +
+
Schreibgeschützt
+
+ Wird aktuell bearbeitet von {activeLock?.userName || 'einem anderen Benutzer'}. Eingaben sind vorübergehend gesperrt. +
+
+
+ )} + + {!isLockedByOther && selectedDoc.permission === 'read' && ( +
+ + Du hast Nur-Lesezugriff auf diese Notiz. Änderungen können nicht vorgenommen werden. +
+ )} + {/* ZOOM BREADCRUMB BAR (Hoisting Focus Mode) */} {zoomedBlock && (
@@ -1355,14 +1760,17 @@ export const WorkspaceView: React.FC = ({
- {showIconPicker && ( + {showIconPicker && !isReadOnly && (
{ICONS.map((ic) => (
)} @@ -1478,12 +1898,16 @@ export const WorkspaceView: React.FC = ({ {/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder!) */}
handleDragStart(e, block.id)} + draggable={!isReadOnly} + onDragStart={(e) => !isReadOnly && handleDragStart(e, block.id)} onDragOver={(e) => e.preventDefault()} - onDrop={(e) => handleDropOnBlock(e, block.id)} + onDrop={(e) => !isReadOnly && handleDropOnBlock(e, block.id)} onClick={() => setZoomedBlockId(block.id)} - className="w-4 h-4 mt-1 rounded-pill hover:bg-accent/20 flex items-center justify-center cursor-pointer text-muted hover:text-accent transition-colors flex-shrink-0" + className={`w-4 h-4 mt-1 rounded-pill flex items-center justify-center transition-colors flex-shrink-0 ${ + isReadOnly + ? 'cursor-pointer text-muted' + : 'hover:bg-accent/20 cursor-pointer text-muted hover:text-accent' + }`} title="Klick: In diesen Ast zoomen · Ziehen: Verschieben" > = ({
- updateBlock(block.id, { checked: e.target.checked }) + !isReadOnly && updateBlock(block.id, { checked: e.target.checked }) } - className="w-4 h-4 checkbox-square cursor-pointer flex-shrink-0" + className={`w-4 h-4 checkbox-square flex-shrink-0 ${ + isReadOnly ? 'cursor-not-allowed opacity-60' : 'cursor-pointer' + }`} /> (blockInputRefs.current[block.id] = el)} type="text" + readOnly={isReadOnly} value={block.content} + onFocus={() => { + if (!isReadOnly) ensureLock(); + }} + onBlur={() => resetIdleTimer()} onChange={(e) => handleContentChange(block, e.target.value)} onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} - placeholder="Aufgabe tippen… (/ für Befehle)" + placeholder={isReadOnly ? '' : 'Aufgabe tippen… (/ für Befehle)'} className={`w-full bg-transparent text-sm focus:outline-none placeholder:text-faint ${ block.checked ? 'line-through text-faint' : 'text-main' - }`} + } ${isReadOnly ? 'cursor-default' : ''}`} /> - + {!isReadOnly && ( + + )}
{/* Progress bar for child todos if any exist */} @@ -1569,11 +2003,18 @@ export const WorkspaceView: React.FC = ({ (blockInputRefs.current[block.id] = el)} type="text" + readOnly={isReadOnly} value={block.content} + onFocus={() => { + if (!isReadOnly) ensureLock(); + }} + onBlur={() => resetIdleTimer()} onChange={(e) => handleContentChange(block, e.target.value)} onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} - placeholder="Überschrift 1… (/ für Befehle)" - className="w-full bg-transparent text-lg font-bold text-main focus:outline-none placeholder:text-faint" + placeholder={isReadOnly ? '' : 'Überschrift 1… (/ für Befehle)'} + className={`w-full bg-transparent text-lg font-bold text-main focus:outline-none placeholder:text-faint ${ + isReadOnly ? 'cursor-default' : '' + }`} /> )} @@ -1582,11 +2023,18 @@ export const WorkspaceView: React.FC = ({ (blockInputRefs.current[block.id] = el)} type="text" + readOnly={isReadOnly} value={block.content} + onFocus={() => { + if (!isReadOnly) ensureLock(); + }} + onBlur={() => resetIdleTimer()} onChange={(e) => handleContentChange(block, e.target.value)} onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} - placeholder="Abschnitts-Überschrift… (/ für Befehle)" - className="w-full bg-transparent text-base font-semibold text-accent focus:outline-none placeholder:text-faint" + placeholder={isReadOnly ? '' : 'Abschnitts-Überschrift… (/ für Befehle)'} + className={`w-full bg-transparent text-base font-semibold text-accent focus:outline-none placeholder:text-faint ${ + isReadOnly ? 'cursor-default' : '' + }`} /> )} @@ -1595,11 +2043,18 @@ export const WorkspaceView: React.FC = ({ (blockInputRefs.current[block.id] = el)} type="text" + readOnly={isReadOnly} value={block.content} + onFocus={() => { + if (!isReadOnly) ensureLock(); + }} + onBlur={() => resetIdleTimer()} onChange={(e) => handleContentChange(block, e.target.value)} onKeyDown={(e) => handleKeyDown(e, block, originalIndex)} - placeholder="Unterabschnitt… (/ für Befehle)" - className="w-full bg-transparent text-sm font-semibold text-main focus:outline-none placeholder:text-faint" + placeholder={isReadOnly ? '' : 'Unterabschnitt… (/ für Befehle)'} + className={`w-full bg-transparent text-sm font-semibold text-main focus:outline-none placeholder:text-faint ${ + isReadOnly ? 'cursor-default' : '' + }`} /> )} @@ -1609,12 +2064,19 @@ export const WorkspaceView: React.FC = ({