feat(workspace): move status pills to tab bar and add undo/redo and actions menu to note header

This commit is contained in:
sandrodach committed 2026-10-30 09:41:02 +01:00
1 parent fc7b2b3a2c
commit 5f27e7ca9c
4 files changed
+456 -149

No files matched your search

+169 -103
View File
@@ -18,16 +18,26 @@ import {
CloudOff,
AlertTriangle,
GitMerge,
Undo2,
Redo2,
MoreHorizontal,
} from 'lucide-react';
import { Document, DocumentBlock, User } from '../../lib/types';
import { api } from '../../lib/api';
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
import { Presence } from '../../lib/docSync/transport';
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
import { PresenceAvatars } from './PresenceAvatars';
import { extractHashtags } from '../../lib/hashtags';
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
export interface DocHeaderStatus {
showLive: boolean;
channel: 'ws' | 'sse' | 'longpoll' | 'interval';
otherPeople: Presence[];
avatarsById: Map<string, string>;
}
export interface DocumentEditorProps {
docId: string;
currentUser?: User | null;
@@ -45,6 +55,7 @@ export interface DocumentEditorProps {
onBackMobile?: () => void;
onOpenTask?: (taskId: string) => void;
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
onHeaderStatusChange?: (status: DocHeaderStatus | null) => void;
/** Opens the dialog to merge this note into another one (own notes only) */
onRequestMerge?: (doc: Document) => void;
/** A note was created from this one (extract) or removed again (undo); keeps the sidebar current */
@@ -196,6 +207,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
onBackMobile,
onOpenTask,
onSaveStatusChange,
onHeaderStatusChange,
onRequestMerge,
onDocCreated,
onDocRemoved,
@@ -209,6 +221,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
const [error, setError] = useState<string | null>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
const [showActionsMenu, setShowActionsMenu] = useState<boolean>(false);
const actionsMenuRef = useRef<HTMLDivElement | null>(null);
// Sync engine (operation log): stores every change locally and syncs it with the server
const engineRef = useRef<DocSyncEngine | null>(null);
@@ -384,6 +398,48 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
.catch(() => {});
}, [otherPeople.length]);
useEffect(() => {
onHeaderStatusChange?.({
showLive: Boolean(engineState && (engineState.shared || engineState.sessions > 1)),
channel: engineState?.channel || 'ws',
otherPeople,
avatarsById,
});
}, [
engineState?.shared,
engineState?.sessions,
engineState?.channel,
otherPeople,
avatarsById,
onHeaderStatusChange,
]);
useEffect(() => {
return () => {
onHeaderStatusChange?.(null);
};
}, [onHeaderStatusChange]);
useEffect(() => {
if (!showActionsMenu) return;
const handleMouseDown = (e: MouseEvent) => {
if (actionsMenuRef.current && !actionsMenuRef.current.contains(e.target as Node)) {
setShowActionsMenu(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setShowActionsMenu(false);
}
};
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('keydown', handleKeyDown);
};
}, [showActionsMenu]);
// Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action
const [notice, setNotice] = useState<string | null>(null);
const [noticeActions, setNoticeActions] = useState<{ label: string; run: () => void }[]>([]);
@@ -715,7 +771,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
<button
type="button"
onClick={onBackMobile}
className="lg:hidden btn-icon"
className="lg:hidden btn-icon h-11 w-11 sm:h-7 sm:w-7"
title="Zurück zur Seitenleiste"
>
<ArrowLeft className="w-3.5 h-3.5" />
@@ -813,118 +869,128 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
{/* Right Action Buttons */}
<div className="flex items-center gap-1.5">
{/* How changes of others arrive (only relevant for shared notes) */}
{engineState && (engineState.shared || engineState.sessions > 1) && (
<span
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] text-muted bg-panel border border-border"
title={
engineState.channel === 'ws'
? 'Live-Verbindung über WebSocket'
: engineState.channel === 'sse'
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
: engineState.channel === 'longpoll'
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
: 'Keine Live-Verbindung möglich – Änderungen werden alle 3 Sekunden abgefragt'
}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
engineState.channel === 'interval' ? 'bg-status-warn' : 'bg-status-ok'
}`}
/>
{engineState.channel === 'longpoll' ? 'Live (Long-Polling)' : engineState.channel === 'interval' ? 'Abgleich alle 3 s' : 'Live'}
</span>
{!inTrash && (
<>
<button
type="button"
onClick={handleUndo}
disabled={!canEdit || !engineState?.canUndo}
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
title="Rückgängig (Strg+Z)"
aria-label="Rückgängig (Strg+Z)"
>
<Undo2 className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={handleRedo}
disabled={!canEdit || !engineState?.canRedo}
className="btn-icon h-11 w-11 sm:h-7 sm:w-7 disabled:opacity-40 disabled:cursor-not-allowed"
title="Wiederholen (Strg+Y)"
aria-label="Wiederholen (Strg+Y)"
>
<Redo2 className="w-3.5 h-3.5" />
</button>
</>
)}
{/* Who else has this note open */}
<PresenceAvatars people={otherPeople} avatars={avatarsById} />
{/* Sharing Section */}
{doc && !inTrash && (() => {
const isOwner = doc.isOwner !== false && (doc.userId === currentUser?.id || !doc.userId || currentUser?.role === 'admin');
const sharesCount = doc.sharedWith?.length || 0;
const isShared = sharesCount > 0 || !isOwner || Boolean(doc.sharedFolderId);
const ownerDisplayName = doc.ownerName || 'Eigentümer';
{doc && !inTrash && (
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
title="Notiz teilen und Berechtigungen"
aria-label="Notiz teilen und Berechtigungen"
>
<Users className="w-3.5 h-3.5" />
</button>
)}
return (
<div className="flex items-center gap-1.5">
{isShared && (
isOwner ? (
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="px-2 py-0.5 rounded text-[10px] font-semibold bg-accent/15 text-accent border border-accent/30 flex items-center gap-1 hover:bg-accent/25 hover:border-accent/50 transition-colors cursor-pointer"
title="Teilen-Menü öffnen: Geteilt mit Nutzern/Gruppen anzeigen & verwalten"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt{sharesCount > 0 ? ` (${sharesCount})` : ''}</span>
</button>
) : (
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="px-2 py-0.5 rounded text-[10px] font-medium border border-border text-muted flex items-center gap-1 bg-surface-subtle hover:bg-panel-elevated hover:text-main transition-colors cursor-pointer"
title="Teilen & Berechtigungen öffnen"
>
<Users className="w-2.5 h-2.5" />
<span>Geteilt von {ownerDisplayName}</span>
</button>
)
<div className="relative" ref={actionsMenuRef}>
<button
type="button"
onClick={() => setShowActionsMenu((prev) => !prev)}
aria-expanded={showActionsMenu}
aria-haspopup="menu"
aria-label="Weitere Aktionen"
title="Weitere Aktionen"
className="btn-icon h-11 w-11 sm:h-7 sm:w-7"
>
<MoreHorizontal className="w-3.5 h-3.5" />
</button>
{showActionsMenu && (
<div
role="menu"
className="absolute right-0 top-full mt-1.5 w-60 rounded-card bg-panel border border-border shadow-lg p-1 z-30 text-xs"
>
{!inTrash && (
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
void handleTogglePin();
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
<Pin
className={`w-3.5 h-3.5 shrink-0 ${
doc?.isPinned ? 'text-status-warn' : 'text-accent'
}`}
/>
<span>{doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}</span>
</button>
)}
{onRequestMerge && doc?.isOwner && !inTrash && (
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
onRequestMerge(doc);
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
<GitMerge className="w-3.5 h-3.5 text-accent shrink-0" />
<span>Mit anderer Notiz zusammenführen…</span>
</button>
)}
<button
type="button"
onClick={() => onOpenShareModal(doc)}
className="btn-icon"
title="Notiz teilen und Berechtigungen"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
handleExportMarkdown();
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-panel-elevated text-left text-main font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
<Users className="w-3.5 h-3.5" />
<Download className="w-3.5 h-3.5 text-accent shrink-0" />
<span>Als Markdown (.md) exportieren</span>
</button>
{onRequestMerge && doc.isOwner && !inTrash && (
<button
type="button"
onClick={() => onRequestMerge(doc)}
className="btn-icon"
title="Mit anderer Notiz zusammenführen…"
>
<GitMerge className="w-3.5 h-3.5" />
</button>
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
<>
<div className="my-1 border-t border-border" />
<button
type="button"
role="menuitem"
onClick={() => {
setShowActionsMenu(false);
onDeleteDoc(docId);
}}
className="w-full min-h-[44px] sm:min-h-0 px-2.5 py-2 sm:py-1.5 rounded-btn hover:bg-status-err/10 text-left text-status-err font-medium flex items-center gap-2 transition-colors cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5 text-status-err shrink-0" />
<span>Notiz löschen</span>
</button>
</>
)}
</div>
);
})()}
{!inTrash && <button
type="button"
onClick={handleTogglePin}
className={`btn-icon ${
doc?.isPinned
? 'text-status-warn bg-status-warn/10 border-status-warn/30'
: ''
}`}
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
>
<Pin className="w-3.5 h-3.5" />
</button>}
<button
type="button"
onClick={handleExportMarkdown}
className="btn-icon"
title="Als Markdown (.md) exportieren"
>
<Download className="w-3.5 h-3.5" />
</button>
{canEdit && (doc?.isOwner || doc?.permission === 'owner') && (
<button
type="button"
onClick={() => onDeleteDoc(docId)}
className="btn-icon-danger"
title="Notiz löschen"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
)}
</div>
</div>
</div>