refactor(collab): remove live document collaboration and enforce strictly read-only sharing
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m2s
This commit is contained in:
1 parent
aea05e868a
commit
04fa774d10
16 files changed
+179
-3556
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -17,10 +17,8 @@ import {
|
||||
Maximize2,
|
||||
Link as LinkIcon,
|
||||
FileText,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { DocumentBlock, BlockType } from '../../lib/types';
|
||||
import { RemoteCursor } from '../../lib/useDocumentSync';
|
||||
import { LinkModal } from './LinkModal';
|
||||
|
||||
export interface SlashCommand {
|
||||
@@ -48,11 +46,8 @@ export interface OutlinerBlockProps {
|
||||
hasChildren: boolean;
|
||||
isFocused: boolean;
|
||||
canEdit: boolean;
|
||||
activeRemoteCursors?: RemoteCursor[];
|
||||
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => 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;
|
||||
@@ -66,23 +61,17 @@ export interface OutlinerBlockProps {
|
||||
isDragTarget?: boolean;
|
||||
isTargeted?: boolean;
|
||||
registerTextareaRef: (blockId: string, el: HTMLTextAreaElement | null) => void;
|
||||
isLockedByOther?: boolean;
|
||||
lockedByName?: string;
|
||||
lockedByColor?: string;
|
||||
onBlurBlock?: (blockId: string) => void;
|
||||
}
|
||||
|
||||
export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
block,
|
||||
index,
|
||||
maxAllowedLevel,
|
||||
hasChildren,
|
||||
isFocused,
|
||||
canEdit,
|
||||
activeRemoteCursors = [],
|
||||
sendCursorMoved,
|
||||
levelOffset = 0,
|
||||
onZoomBlock,
|
||||
onFocusBlock,
|
||||
onUpdateBlock,
|
||||
onCreateBlockBelow,
|
||||
onDeleteBlock,
|
||||
@@ -96,10 +85,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
isDragTarget,
|
||||
isTargeted,
|
||||
registerTextareaRef,
|
||||
isLockedByOther,
|
||||
lockedByName,
|
||||
lockedByColor,
|
||||
onBlurBlock,
|
||||
}) => {
|
||||
const [localContent, setLocalContent] = useState<string>(block.content || '');
|
||||
const [slashSearch, setSlashSearch] = useState<string | null>(null);
|
||||
@@ -116,10 +101,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
// 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 onBlurBlockRef = useRef(onBlurBlock);
|
||||
onBlurBlockRef.current = onBlurBlock;
|
||||
const onCreateBlockBelowRef = useRef(onCreateBlockBelow);
|
||||
onCreateBlockBelowRef.current = onCreateBlockBelow;
|
||||
const onDeleteBlockRef = useRef(onDeleteBlock);
|
||||
@@ -128,8 +109,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
onIndentBlockRef.current = onIndentBlock;
|
||||
const onNavigateFocusRef = useRef(onNavigateFocus);
|
||||
onNavigateFocusRef.current = onNavigateFocus;
|
||||
const sendCursorMovedRef = useRef(sendCursorMoved);
|
||||
sendCursorMovedRef.current = sendCursorMoved;
|
||||
|
||||
// Track if this input currently has actual focus in an active browser window
|
||||
const isFocusedRef = useRef<boolean>(false);
|
||||
@@ -177,7 +156,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
flushDebounce(localContentRef.current);
|
||||
}
|
||||
isFocusedRef.current = false;
|
||||
sendCursorMovedRef.current?.(null);
|
||||
};
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
@@ -290,7 +268,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}
|
||||
|
||||
setLocalContent(val);
|
||||
sendCursorMoved?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
||||
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
@@ -313,16 +290,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
if (isLockedByOther) return;
|
||||
const handleFocus = () => {
|
||||
isFocusedRef.current = true;
|
||||
onFocusBlockRef.current(block.id);
|
||||
sendCursorMovedRef.current?.(block.id, e.target.selectionStart, e.target.selectionEnd);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
isFocusedRef.current = false;
|
||||
if (isLockedByOther) return;
|
||||
if (debounceTimerRef.current !== null) {
|
||||
window.clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
@@ -330,27 +303,10 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
} else if (localContentRef.current !== latestBlockRef.current.content) {
|
||||
flushDebounce(localContentRef.current);
|
||||
}
|
||||
sendCursorMovedRef.current?.(null);
|
||||
onBlurBlockRef.current?.(block.id);
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLTextAreaElement>) => {
|
||||
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
sendCursorMovedRef.current?.(block.id, e.currentTarget.selectionStart, e.currentTarget.selectionEnd);
|
||||
};
|
||||
|
||||
const handleSelect = (e: React.SyntheticEvent<HTMLTextAreaElement>) => {
|
||||
const target = e.currentTarget;
|
||||
sendCursorMovedRef.current?.(block.id, target.selectionStart, target.selectionEnd);
|
||||
};
|
||||
|
||||
const primaryRemoteCursor = activeRemoteCursors.length > 0 ? activeRemoteCursors[0] : null;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!canEdit || isLockedByOther) return;
|
||||
if (!canEdit) return;
|
||||
|
||||
// Slash command navigation
|
||||
if (slashSearch !== null && filteredSlashCommands.length > 0) {
|
||||
@@ -460,50 +416,16 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
? 'border-t-2 border-accent bg-accent/10'
|
||||
: isTargeted
|
||||
? 'bg-accent/15 ring-2 ring-accent'
|
||||
: isLockedByOther
|
||||
? 'bg-panel-elevated/80 border border-border/60 shadow-xs'
|
||||
: isFocused
|
||||
? 'bg-panel-elevated'
|
||||
: 'hover:bg-panel'
|
||||
}`}
|
||||
style={{
|
||||
paddingLeft: `${visualLevel * 22 + 4}px`,
|
||||
...(isLockedByOther && lockedByColor
|
||||
? { borderLeft: `3px solid ${lockedByColor}` }
|
||||
: primaryRemoteCursor
|
||||
? { borderLeft: `2px solid ${primaryRemoteCursor.userColor}` }
|
||||
: {}),
|
||||
}}
|
||||
onDragOver={(e) => onDragOverBlock?.(e, index)}
|
||||
onDrop={() => onDropBlock?.(index)}
|
||||
>
|
||||
{/* Right-aligned Lock Indicator (Zero vertical height impact, keeps bullet & line aligned) */}
|
||||
{isLockedByOther && (
|
||||
<div
|
||||
className="absolute right-2 top-1.5 z-10 flex items-center gap-1 px-2 py-0.5 rounded-pill text-[10px] font-semibold text-white shadow-xs select-none pointer-events-none"
|
||||
style={{ backgroundColor: lockedByColor || '#64748b' }}
|
||||
title={`Wird gerade bearbeitet von ${lockedByName || 'anderer Nutzer'}`}
|
||||
>
|
||||
<Lock className="w-2.5 h-2.5 shrink-0" />
|
||||
<span className="truncate max-w-[120px]">{lockedByName || 'Gesperrt'}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Remote Cursors Floating Name Badges on Block */}
|
||||
{activeRemoteCursors.length > 0 && (
|
||||
<div className="absolute -top-2.5 right-2 z-20 flex items-center gap-1 select-none pointer-events-none">
|
||||
{activeRemoteCursors.map((c) => (
|
||||
<span
|
||||
key={c.clientId}
|
||||
className="text-[9px] font-bold px-1.5 py-0.5 rounded-btn text-white shadow-sm flex items-center gap-0.5"
|
||||
style={{ backgroundColor: c.userColor }}
|
||||
>
|
||||
{c.userName}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indentation Guide Line */}
|
||||
{visualLevel > 0 &&
|
||||
Array.from({ length: visualLevel }).map((_, lvlIdx) => (
|
||||
@@ -516,7 +438,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
|
||||
{/* Bullet Controls: Drag Handle, Collapse Toggle, Bullet / Checkbox */}
|
||||
<div className="flex items-center gap-0.5 pt-1 select-none shrink-0">
|
||||
{canEdit && !isLockedByOther && (
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
@@ -550,7 +472,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
{block.type === 'todo' ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canEdit || isLockedByOther}
|
||||
disabled={!canEdit}
|
||||
onClick={() =>
|
||||
flushDebounce(localContent, { checked: !block.checked })
|
||||
}
|
||||
@@ -608,17 +530,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}}
|
||||
rows={1}
|
||||
value={localContent}
|
||||
readOnly={!canEdit || isLockedByOther}
|
||||
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 ${
|
||||
isLockedByOther ? 'pr-28 cursor-not-allowed opacity-90' : ''
|
||||
}`}
|
||||
className="w-full resize-none font-mono text-xs bg-transparent text-status-ok border-0 focus:outline-none focus:ring-0 leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
) : block.type === 'callout' ? (
|
||||
@@ -630,17 +547,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}}
|
||||
rows={1}
|
||||
value={localContent}
|
||||
readOnly={!canEdit || isLockedByOther}
|
||||
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 ${
|
||||
isLockedByOther ? 'pr-28 cursor-not-allowed opacity-90' : ''
|
||||
}`}
|
||||
className="w-full resize-none bg-transparent text-sm text-main border-0 focus:outline-none focus:ring-0 leading-relaxed italic"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -652,12 +564,9 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
}}
|
||||
rows={1}
|
||||
value={localContent}
|
||||
readOnly={!canEdit || isLockedByOther}
|
||||
readOnly={!canEdit}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onClick={handleClick}
|
||||
onKeyUp={handleKeyUp}
|
||||
onSelect={handleSelect}
|
||||
onChange={handleContentChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
@@ -666,8 +575,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
: ''
|
||||
}
|
||||
className={`w-full resize-none overflow-hidden bg-transparent border-0 focus:outline-none focus:ring-0 leading-relaxed whitespace-pre-wrap break-words ${
|
||||
isLockedByOther ? 'pr-28 cursor-not-allowed opacity-90' : ''
|
||||
} ${
|
||||
block.type === 'h1'
|
||||
? 'text-lg font-bold text-main tracking-tight'
|
||||
: block.type === 'h2'
|
||||
@@ -679,45 +586,6 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
|
||||
: 'text-sm text-main'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Remote Caret & Floating Name Tag Overlay */}
|
||||
{primaryRemoteCursor &&
|
||||
primaryRemoteCursor.selectionStart !== undefined &&
|
||||
primaryRemoteCursor.selectionStart !== null && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute inset-0 overflow-hidden leading-relaxed whitespace-pre-wrap break-words select-none z-10 ${
|
||||
block.type === 'h1'
|
||||
? 'text-lg font-bold tracking-tight'
|
||||
: block.type === 'h2'
|
||||
? 'text-base font-semibold'
|
||||
: block.type === 'h3'
|
||||
? 'text-sm font-semibold'
|
||||
: block.type === 'todo' && block.checked
|
||||
? 'text-sm text-faint line-through'
|
||||
: 'text-sm'
|
||||
}`}
|
||||
>
|
||||
<span className="opacity-0">
|
||||
{localContent.slice(
|
||||
0,
|
||||
Math.min(primaryRemoteCursor.selectionStart, localContent.length)
|
||||
)}
|
||||
</span>
|
||||
<span className="relative inline-block align-baseline">
|
||||
<span
|
||||
className="inline-block w-[2px] h-[1.15em] -mb-0.5 animate-pulse"
|
||||
style={{ backgroundColor: primaryRemoteCursor.userColor }}
|
||||
/>
|
||||
<span
|
||||
className="absolute -top-3.5 left-0 text-[8px] font-bold px-1 py-0 rounded-btn text-white whitespace-nowrap shadow-sm"
|
||||
style={{ backgroundColor: primaryRemoteCursor.userColor }}
|
||||
>
|
||||
{primaryRemoteCursor.userName}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { DocumentBlock, BlockLockInfo } from '../../lib/types';
|
||||
import { RemoteCursor } from '../../lib/useDocumentSync';
|
||||
import { DocumentBlock } from '../../lib/types';
|
||||
import { OutlinerBlock } from './OutlinerBlock';
|
||||
|
||||
export interface OutlinerTreeProps {
|
||||
@@ -10,8 +9,6 @@ export interface OutlinerTreeProps {
|
||||
canEdit: boolean;
|
||||
levelOffset?: number;
|
||||
onZoomBlock?: (blockId: string) => void;
|
||||
remoteCursors?: Record<string, RemoteCursor>;
|
||||
sendCursorMoved?: (blockId: string | null, selectionStart?: number, selectionEnd?: number) => void;
|
||||
onUpdateBlock: (updated: DocumentBlock, immediate?: boolean) => void;
|
||||
onUpdateBlocks?: (updated: DocumentBlock[]) => void;
|
||||
onCreateBlock: (newBlock: DocumentBlock, afterBlockId?: string) => void;
|
||||
@@ -20,10 +17,6 @@ export interface OutlinerTreeProps {
|
||||
onTagClick?: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
initialFocusedBlockId?: string | null;
|
||||
activeBlockLocks?: Record<string, BlockLockInfo>;
|
||||
onFocusBlock?: (blockId: string) => void;
|
||||
onBlurBlock?: (blockId: string) => void;
|
||||
currentUserId?: string;
|
||||
}
|
||||
|
||||
export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
@@ -32,8 +25,6 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
canEdit,
|
||||
levelOffset = 0,
|
||||
onZoomBlock,
|
||||
remoteCursors,
|
||||
sendCursorMoved,
|
||||
onUpdateBlock,
|
||||
onUpdateBlocks,
|
||||
onCreateBlock,
|
||||
@@ -42,10 +33,6 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
onTagClick,
|
||||
onWikiLinkClick,
|
||||
initialFocusedBlockId,
|
||||
activeBlockLocks,
|
||||
onFocusBlock,
|
||||
onBlurBlock,
|
||||
currentUserId,
|
||||
}) => {
|
||||
const [focusedBlockId, setFocusedBlockId] = useState<string | null>(null);
|
||||
const [highlightedBlockId, setHighlightedBlockId] = useState<string | null>(
|
||||
@@ -371,24 +358,9 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
return () => window.removeEventListener('dragend', clearDrag);
|
||||
}, []);
|
||||
|
||||
const cursorsByBlock = useMemo(() => {
|
||||
const map: Record<string, RemoteCursor[]> = {};
|
||||
if (!remoteCursors) return map;
|
||||
for (const c of Object.values(remoteCursors)) {
|
||||
if (c.blockId) {
|
||||
if (!map[c.blockId]) map[c.blockId] = [];
|
||||
map[c.blockId].push(c);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [remoteCursors]);
|
||||
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{visibleEntries.map(({ block, index, hasChildren, maxAllowedLevel }) => {
|
||||
const lockInfo = activeBlockLocks?.[block.id];
|
||||
const isLockedByOther = Boolean(lockInfo && currentUserId && lockInfo.userId !== currentUserId);
|
||||
|
||||
return (
|
||||
<OutlinerBlock
|
||||
key={block.id}
|
||||
@@ -400,15 +372,6 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
canEdit={canEdit}
|
||||
levelOffset={levelOffset}
|
||||
onZoomBlock={onZoomBlock}
|
||||
activeRemoteCursors={cursorsByBlock[block.id] || []}
|
||||
sendCursorMoved={sendCursorMoved}
|
||||
onFocusBlock={(bId) => {
|
||||
setFocusedBlockId(bId);
|
||||
onFocusBlock?.(bId);
|
||||
}}
|
||||
onBlurBlock={(bId) => {
|
||||
onBlurBlock?.(bId);
|
||||
}}
|
||||
onUpdateBlock={onUpdateBlock}
|
||||
onCreateBlockBelow={handleCreateBlockBelow}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
@@ -422,9 +385,6 @@ export const OutlinerTree: React.FC<OutlinerTreeProps> = ({
|
||||
isDragTarget={dragOverIndex === index && draggedIndex !== index}
|
||||
isTargeted={highlightedBlockId === block.id}
|
||||
registerTextareaRef={registerTextareaRef}
|
||||
isLockedByOther={isLockedByOther}
|
||||
lockedByName={lockInfo?.userName}
|
||||
lockedByColor={lockInfo?.userColor}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -48,10 +48,9 @@ import {
|
||||
getRealtimeTransport,
|
||||
RealtimeTransport,
|
||||
} from '../../lib/api';
|
||||
import { DocSyncStatus } from '../../lib/useDocumentSync';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
import { DocumentEditor, DocSaveStatus, DocLiveState } from './DocumentEditor';
|
||||
import { DocumentEditor, DocSaveStatus } from './DocumentEditor';
|
||||
|
||||
interface WorkspaceViewProps {
|
||||
currentUser?: User | null;
|
||||
@@ -328,13 +327,8 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
// Realtime connection status for top header indicator (identical to SharedListsView)
|
||||
const [realtimeConnected, setRealtimeConnected] = useState<boolean>(() => isRealtimeConnected());
|
||||
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>(() => getRealtimeTransport());
|
||||
const [activeDocSyncStatus, setActiveDocSyncStatus] = useState<DocSyncStatus>('connected');
|
||||
const [docSaveStatus, setDocSaveStatus] = useState<DocSaveStatus>('saved');
|
||||
const [docSaveError, setDocSaveError] = useState<string | null>(null);
|
||||
const [docLiveState, setDocLiveState] = useState<DocLiveState>('idle');
|
||||
const [docLiveInitiator, setDocLiveInitiator] = useState<string | null>(null);
|
||||
const [showWsBlockedModal, setShowWsBlockedModal] = useState<boolean>(false);
|
||||
const liveToggleRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = onRealtimeStatus((connected, transport) => {
|
||||
@@ -1168,136 +1162,52 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
{/* Right Header Section: Save Status & Live Collaboration Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
{subView === 'editor' && selectedDocId ? (
|
||||
<>
|
||||
{/* Save Status Pill */}
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${
|
||||
docSaveStatus === 'saved'
|
||||
? 'bg-canvas border-border text-muted'
|
||||
: docSaveStatus === 'saving'
|
||||
? 'bg-panel border-accent/40 text-accent animate-pulse'
|
||||
: docSaveStatus === 'unsaved'
|
||||
? 'bg-status-warn/10 border-status-warn/30 text-status-warn'
|
||||
: 'bg-status-err/10 border-status-err/30 text-status-err'
|
||||
}`}
|
||||
title={
|
||||
docSaveStatus === 'saved'
|
||||
? 'Alle Änderungen per HTTPS gesichert'
|
||||
: docSaveStatus === 'saving'
|
||||
? 'Änderungen werden per HTTPS gespeichert…'
|
||||
: docSaveStatus === 'unsaved'
|
||||
? 'Ungespeicherte Änderungen (Auto-Save aktiv)'
|
||||
: docSaveError || 'Fehler beim Speichern'
|
||||
}
|
||||
>
|
||||
{docSaveStatus === 'saved' && (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5 text-status-ok" />
|
||||
<span>Gespeichert</span>
|
||||
</>
|
||||
)}
|
||||
{docSaveStatus === 'saving' && (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
|
||||
<span>Speichert…</span>
|
||||
</>
|
||||
)}
|
||||
{docSaveStatus === 'unsaved' && (
|
||||
<>
|
||||
<Clock className="w-3.5 h-3.5 text-status-warn" />
|
||||
<span>Ungespeichert</span>
|
||||
</>
|
||||
)}
|
||||
{docSaveStatus === 'error' && (
|
||||
<>
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
|
||||
<span>Fehler</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Interactive Live Collaboration Button (replaces passive radio icon) */}
|
||||
{docLiveState === 'locked_by_other' ? (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<div
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-status-warn/15 border border-status-warn/40 text-status-warn select-none"
|
||||
title={`Live-Zusammenarbeit aktiv durch ${docLiveInitiator || 'anderer Nutzer'}`}
|
||||
>
|
||||
<Lock className="w-3.5 h-3.5 shrink-0" />
|
||||
<span className="truncate max-w-[120px]">Live: {docLiveInitiator || 'Gesperrt'}</span>
|
||||
</div>
|
||||
{(selectedDocObj?.isOwner || selectedDocObj?.permission === 'owner') && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => liveToggleRef.current?.()}
|
||||
className="px-2 py-1 rounded-btn text-xs font-semibold bg-status-warn/20 border border-status-warn/40 text-status-warn hover:bg-status-warn/30 transition-colors cursor-pointer"
|
||||
title="Als Notiz-Besitzer Live-Sitzung beenden und Dokument freigeben"
|
||||
>
|
||||
Freigeben
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : docLiveState === 'blocked' ? (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWsBlockedModal(true)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-status-err/15 border border-status-err/40 text-status-err hover:bg-status-err/25 transition-colors cursor-pointer"
|
||||
title="WebSockets im Firmennetzwerk blockiert – Hier klicken für Infos"
|
||||
>
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-status-err animate-pulse shrink-0" />
|
||||
<span>Live blockiert</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => liveToggleRef.current?.()}
|
||||
className="px-2 py-1 rounded-btn text-xs font-medium bg-canvas border border-border text-muted hover:text-status-err hover:border-status-err/50 transition-colors cursor-pointer"
|
||||
title="Live-Sitzung beenden"
|
||||
>
|
||||
Beenden
|
||||
</button>
|
||||
</div>
|
||||
) : docLiveState === 'connecting' ? (
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-amber-500/15 border border-amber-500/40 text-amber-500 opacity-80 cursor-wait"
|
||||
>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-amber-500 shrink-0" />
|
||||
<span>Verbinde…</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => liveToggleRef.current?.()}
|
||||
className="px-2 py-1 rounded-btn text-xs font-medium bg-canvas border border-border text-muted hover:text-status-err hover:border-status-err/50 transition-colors cursor-pointer"
|
||||
title="Verbindungsaufbau abbrechen"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
) : docLiveState === 'active' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => liveToggleRef.current?.()}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-semibold bg-emerald-500/15 border border-emerald-500/40 text-emerald-500 hover:bg-emerald-500/25 transition-colors cursor-pointer"
|
||||
title="Live-Zusammenarbeit beenden (Tastendruck / WebSocket)"
|
||||
>
|
||||
<Radio className="w-3.5 h-3.5 text-emerald-500 animate-pulse shrink-0" />
|
||||
<span>Live aktiv</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => liveToggleRef.current?.()}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium bg-canvas border border-border text-muted hover:text-main hover:border-accent transition-colors cursor-pointer"
|
||||
title="Live-Zusammenarbeit aktivieren (WebSockets)"
|
||||
>
|
||||
<Radio className="w-3.5 h-3.5 text-muted hover:text-accent shrink-0" />
|
||||
<span>Live</span>
|
||||
</button>
|
||||
/* Save Status Pill */
|
||||
<div
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-btn text-xs font-medium border transition-colors ${
|
||||
docSaveStatus === 'saved'
|
||||
? 'bg-canvas border-border text-muted'
|
||||
: docSaveStatus === 'saving'
|
||||
? 'bg-panel border-accent/40 text-accent animate-pulse'
|
||||
: docSaveStatus === 'unsaved'
|
||||
? 'bg-status-warn/10 border-status-warn/30 text-status-warn'
|
||||
: 'bg-status-err/10 border-status-err/30 text-status-err'
|
||||
}`}
|
||||
title={
|
||||
docSaveStatus === 'saved'
|
||||
? 'Alle Änderungen per HTTPS gesichert'
|
||||
: docSaveStatus === 'saving'
|
||||
? 'Änderungen werden per HTTPS gespeichert…'
|
||||
: docSaveStatus === 'unsaved'
|
||||
? 'Ungespeicherte Änderungen (Auto-Save aktiv)'
|
||||
: docSaveError || 'Fehler beim Speichern'
|
||||
}
|
||||
>
|
||||
{docSaveStatus === 'saved' && (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5 text-status-ok" />
|
||||
<span>Gespeichert</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
{docSaveStatus === 'saving' && (
|
||||
<>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
|
||||
<span>Speichert…</span>
|
||||
</>
|
||||
)}
|
||||
{docSaveStatus === 'unsaved' && (
|
||||
<>
|
||||
<Clock className="w-3.5 h-3.5 text-status-warn" />
|
||||
<span>Ungespeichert</span>
|
||||
</>
|
||||
)}
|
||||
{docSaveStatus === 'error' && (
|
||||
<>
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
|
||||
<span>Fehler</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Standalone Realtime Status Indicator for Todos / Hashtags */
|
||||
(() => {
|
||||
@@ -1568,18 +1478,10 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
onSelectTag={handleSelectTag}
|
||||
onWikiLinkClick={handleWikiLinkClick}
|
||||
onBackMobile={() => setMobileShowDetail(false)}
|
||||
onSyncStatusChange={setActiveDocSyncStatus}
|
||||
onSaveStatusChange={(status, err) => {
|
||||
setDocSaveStatus(status);
|
||||
setDocSaveError(err || null);
|
||||
}}
|
||||
onLiveStateChange={(state, initiator) => {
|
||||
setDocLiveState(state);
|
||||
setDocLiveInitiator(initiator || null);
|
||||
}}
|
||||
registerLiveToggle={(handler) => {
|
||||
liveToggleRef.current = handler;
|
||||
}}
|
||||
/>
|
||||
) : subView === 'tags' ? (
|
||||
/* =========================================================================
|
||||
@@ -2535,14 +2437,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
shares={unifiedShares}
|
||||
teams={teams}
|
||||
collaborators={collaborators}
|
||||
allowRoleSelection={!shareModalDoc}
|
||||
onAddShare={async (targetType, targetId, permission) => {
|
||||
const perm = permission === 'editor' ? 'write' : 'read';
|
||||
const perm = (!shareModalDoc && permission === 'editor') ? 'write' : 'read';
|
||||
if (shareModalDoc) {
|
||||
await api.createDocShare(
|
||||
shareModalDoc.id,
|
||||
targetType === 'team'
|
||||
? { teamId: targetId, permission: perm }
|
||||
: { userId: targetId, permission: perm }
|
||||
? { teamId: targetId, permission: 'read' }
|
||||
: { userId: targetId, permission: 'read' }
|
||||
);
|
||||
const updatedShares = await api.getDocShares(shareModalDoc.id);
|
||||
setActiveDocShares(updatedShares);
|
||||
@@ -2569,60 +2472,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* WebSocket Blocked Modal */}
|
||||
{showWsBlockedModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-150">
|
||||
<div className="w-full max-w-md bg-canvas border border-border rounded-card shadow-modal p-6 space-y-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-status-err/15 flex items-center justify-center shrink-0">
|
||||
<AlertTriangle className="w-5 h-5 text-status-err" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-bold text-main">
|
||||
WebSockets in diesem Netzwerk blockiert
|
||||
</h3>
|
||||
<p className="text-xs text-muted mt-1 leading-relaxed">
|
||||
Deine Firewall oder dein Firmen-Proxy blockiert WebSocket-Verbindungen (<code className="text-accent bg-panel px-1 py-0.5 rounded">ws://</code> / <code className="text-accent bg-panel px-1 py-0.5 rounded">wss://</code>).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-subtle border border-border rounded-btn p-3 text-xs space-y-2 text-main">
|
||||
<div className="flex items-center gap-2 text-status-ok font-semibold">
|
||||
<Check className="w-4 h-4 shrink-0" />
|
||||
<span>Kein Datenverlust: Auto-Save per HTTPS ist aktiv!</span>
|
||||
</div>
|
||||
<p className="text-muted leading-relaxed">
|
||||
Jede getippte Zeile und alle Änderungen werden zuverlässig über Standard-HTTPS-Anfragen im Hintergrund gespeichert.
|
||||
</p>
|
||||
<p className="text-muted text-[11px] leading-relaxed">
|
||||
Lediglich die gleichzeitige Tastendruck-Echtzeitanzeige mit anderen Nutzern im selben Moment ist ohne WebSockets eingeschränkt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
liveToggleRef.current?.();
|
||||
setShowWsBlockedModal(false);
|
||||
}}
|
||||
className="px-3 py-1.5 text-xs font-medium bg-status-err/15 border border-status-err/40 text-status-err hover:bg-status-err/25 rounded-btn transition-colors cursor-pointer"
|
||||
>
|
||||
Live-Sitzung beenden
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWsBlockedModal(false)}
|
||||
className="btn-action px-4 py-1.5 text-xs font-semibold bg-accent text-on-accent hover:opacity-90 transition-opacity cursor-pointer"
|
||||
>
|
||||
Verstanden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user