MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m56s
- Extract: button on the bullet (desktop) or /extract (/notiz). The bullet becomes the title (hashtags stay at the link), its children the content of a new note in the same folder with the source's shares (a foreign source owner gets write access); the branch becomes [[title]] through the operation log, so open editors follow. - Merge: drop a note onto another in the sidebar or use "Mit anderer Notiz zusammenführen…" in the note header. The own source is appended as an h2 section with its bullets below and goes to the trash; the toast offers a complete undo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1147 lines
41 KiB
TypeScript
1147 lines
41 KiB
TypeScript
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||
import {
|
||
Pin,
|
||
Users,
|
||
Trash2,
|
||
RotateCcw,
|
||
Download,
|
||
FileText,
|
||
Loader2,
|
||
ArrowLeft,
|
||
Lock,
|
||
Home,
|
||
ChevronRight,
|
||
Minimize2,
|
||
Sparkles,
|
||
Plus,
|
||
Check,
|
||
CloudOff,
|
||
AlertTriangle,
|
||
GitMerge,
|
||
} from 'lucide-react';
|
||
import { Document, DocumentBlock, User } from '../../lib/types';
|
||
import { api } from '../../lib/api';
|
||
import { DocSyncEngine, EngineState } from '../../lib/docSync/engine';
|
||
import { OutlinerTree, OutlinerTreeHandle } from './OutlinerTree';
|
||
import { PresenceAvatars } from './PresenceAvatars';
|
||
import { extractHashtags } from '../../lib/hashtags';
|
||
|
||
export type DocSaveStatus = 'saved' | 'saving' | 'unsaved' | 'error';
|
||
|
||
export interface DocumentEditorProps {
|
||
docId: string;
|
||
currentUser?: User | null;
|
||
initialDoc?: Document | null;
|
||
availableFolders: string[];
|
||
autoFocusTitle?: boolean;
|
||
initialFocusedBlockId?: string | null;
|
||
onDocMetadataChange: (updated: Document) => void;
|
||
onDeleteDoc: (docId: string) => void;
|
||
onRestoreDoc?: (docId: string) => void;
|
||
onOpenShareModal: (doc: Document) => void;
|
||
onSelectTag: (tag: string) => void;
|
||
onWikiLinkClick?: (pageTitle: string) => void;
|
||
onOpenDocBlock?: (docId: string, blockId?: string) => void;
|
||
onBackMobile?: () => void;
|
||
onOpenTask?: (taskId: string) => void;
|
||
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
||
/** Opens the dialog to merge this note into another one (own notes only) */
|
||
onRequestMerge?: (doc: Document) => void;
|
||
}
|
||
|
||
import { EmojiPickerModal } from './EmojiPickerModal';
|
||
import { BacklinksSection } from './BacklinksSection';
|
||
|
||
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
|
||
if (!blocks || blocks.length === 0) {
|
||
return [
|
||
{
|
||
id: `${docId}-b0`,
|
||
documentId: docId,
|
||
type: 'text',
|
||
content: '',
|
||
level: 0,
|
||
position: 1000,
|
||
},
|
||
];
|
||
}
|
||
return blocks.map((b, idx) => ({
|
||
...b,
|
||
id: b.id || `${docId}-b${idx}`,
|
||
documentId: docId,
|
||
type: b.type || 'text',
|
||
level: Math.max(0, b.level || 0),
|
||
position: b.position || (idx + 1) * 1000,
|
||
}));
|
||
}
|
||
|
||
// True if both lists show the same blocks; avoids re-rendering when the sync engine echoes a local change.
|
||
function sameBlocks(a: DocumentBlock[], b: DocumentBlock[]): boolean {
|
||
if (a.length !== b.length) return false;
|
||
for (let i = 0; i < a.length; i++) {
|
||
const x = a[i];
|
||
const y = b[i];
|
||
if (
|
||
x.id !== y.id ||
|
||
x.content !== y.content ||
|
||
(x.level || 0) !== (y.level || 0) ||
|
||
x.type !== y.type ||
|
||
!!x.checked !== !!y.checked ||
|
||
!!x.collapsed !== !!y.collapsed ||
|
||
(x.lang || '') !== (y.lang || '') ||
|
||
(x.taskId || '') !== (y.taskId || '') ||
|
||
(x.conflictOf || '') !== (y.conflictOf || '')
|
||
) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
export interface BreadcrumbNode {
|
||
id: string | null;
|
||
title: string;
|
||
}
|
||
|
||
export function computeBreadcrumbs(
|
||
docTitle: string,
|
||
blocks: DocumentBlock[],
|
||
zoomedBlockId: string | null
|
||
): BreadcrumbNode[] {
|
||
if (!zoomedBlockId) return [];
|
||
|
||
const zoomedIdx = blocks.findIndex((b) => b.id === zoomedBlockId);
|
||
if (zoomedIdx === -1) return [];
|
||
|
||
const trail: BreadcrumbNode[] = [];
|
||
const zoomedBlock = blocks[zoomedIdx];
|
||
|
||
let currentLevel = zoomedBlock.level;
|
||
for (let i = zoomedIdx - 1; i >= 0; i--) {
|
||
if (blocks[i].level < currentLevel) {
|
||
trail.unshift({
|
||
id: blocks[i].id,
|
||
title: blocks[i].content?.trim() || 'Punkt',
|
||
});
|
||
currentLevel = blocks[i].level;
|
||
if (currentLevel === 0) break;
|
||
}
|
||
}
|
||
|
||
trail.unshift({
|
||
id: null,
|
||
title: docTitle || 'Dokument',
|
||
});
|
||
|
||
trail.push({
|
||
id: zoomedBlock.id,
|
||
title: zoomedBlock.content?.trim() || 'Punkt',
|
||
});
|
||
|
||
return trail;
|
||
}
|
||
|
||
export function getZoomedSubtree(
|
||
blocks: DocumentBlock[],
|
||
zoomedBlockId: string | null
|
||
): {
|
||
zoomedBlock: DocumentBlock | null;
|
||
childBlocks: DocumentBlock[];
|
||
levelOffset: number;
|
||
} {
|
||
if (!zoomedBlockId) {
|
||
return { zoomedBlock: null, childBlocks: blocks, levelOffset: 0 };
|
||
}
|
||
|
||
const idx = blocks.findIndex((b) => b.id === zoomedBlockId);
|
||
if (idx === -1) {
|
||
return { zoomedBlock: null, childBlocks: blocks, levelOffset: 0 };
|
||
}
|
||
|
||
const zoomedBlock = blocks[idx];
|
||
const zLevel = zoomedBlock.level;
|
||
const childBlocks: DocumentBlock[] = [];
|
||
|
||
for (let i = idx + 1; i < blocks.length; i++) {
|
||
if (blocks[i].level > zLevel) {
|
||
childBlocks.push(blocks[i]);
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
return {
|
||
zoomedBlock,
|
||
childBlocks,
|
||
levelOffset: zLevel + 1,
|
||
};
|
||
}
|
||
|
||
export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||
docId,
|
||
currentUser,
|
||
initialDoc,
|
||
autoFocusTitle,
|
||
initialFocusedBlockId,
|
||
onDocMetadataChange,
|
||
onDeleteDoc,
|
||
onRestoreDoc,
|
||
onOpenDocBlock,
|
||
onOpenShareModal,
|
||
onSelectTag,
|
||
onWikiLinkClick,
|
||
onBackMobile,
|
||
onOpenTask,
|
||
onSaveStatusChange,
|
||
onRequestMerge,
|
||
}) => {
|
||
const [doc, setDoc] = useState<Document | null>(initialDoc || null);
|
||
const [title, setTitle] = useState<string>(initialDoc?.title || '');
|
||
const [blocks, setBlocks] = useState<DocumentBlock[]>(() =>
|
||
ensureNonEmptyBlocks(docId, initialDoc?.blocks)
|
||
);
|
||
const [loading, setLoading] = useState<boolean>(!initialDoc);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [showEmojiPicker, setShowEmojiPicker] = useState<boolean>(false);
|
||
const [zoomedBlockId, setZoomedBlockId] = useState<string | null>(null);
|
||
|
||
// Sync engine (operation log): stores every change locally and syncs it with the server
|
||
const engineRef = useRef<DocSyncEngine | null>(null);
|
||
const [engineState, setEngineState] = useState<EngineState | null>(null);
|
||
|
||
|
||
const titleInputRef = useRef<HTMLTextAreaElement | HTMLInputElement | null>(null);
|
||
const outlinerTreeRef = useRef<OutlinerTreeHandle | null>(null);
|
||
const docRef = useRef<Document | null>(doc);
|
||
docRef.current = doc;
|
||
|
||
const currentTitleRef = useRef<string>(title);
|
||
currentTitleRef.current = title;
|
||
const currentBlocksRef = useRef<DocumentBlock[]>(blocks);
|
||
currentBlocksRef.current = blocks;
|
||
|
||
useEffect(() => {
|
||
if (!loading && (autoFocusTitle || (doc?.title === 'Neue Notiz' && (!doc?.blocks || doc?.blocks.length <= 1)))) {
|
||
const focusTitle = () => {
|
||
if (titleInputRef.current) {
|
||
titleInputRef.current.focus();
|
||
titleInputRef.current.select();
|
||
}
|
||
};
|
||
focusTitle();
|
||
const t1 = setTimeout(focusTitle, 50);
|
||
const t2 = setTimeout(focusTitle, 150);
|
||
return () => {
|
||
clearTimeout(t1);
|
||
clearTimeout(t2);
|
||
};
|
||
}
|
||
}, [loading, autoFocusTitle, doc?.id]);
|
||
|
||
useEffect(() => {
|
||
if (zoomedBlockId && !blocks.some((b) => b.id === zoomedBlockId)) {
|
||
setZoomedBlockId(null);
|
||
}
|
||
}, [blocks, zoomedBlockId]);
|
||
|
||
const { zoomedBlock, childBlocks, levelOffset } = useMemo(() => {
|
||
return getZoomedSubtree(blocks, zoomedBlockId);
|
||
}, [blocks, zoomedBlockId]);
|
||
|
||
const breadcrumbs = useMemo(() => {
|
||
return computeBreadcrumbs(title || doc?.title || 'Dokument', blocks, zoomedBlockId);
|
||
}, [title, doc?.title, blocks, zoomedBlockId]);
|
||
|
||
// Owner and editors may edit; the role comes from the server via the sync engine
|
||
const inTrash = Boolean(doc?.isArchived);
|
||
const canEdit = useMemo(() => {
|
||
if (doc?.isArchived) return false;
|
||
if (engineState?.role) return engineState.role === 'owner' || engineState.role === 'editor';
|
||
if (!doc) return false;
|
||
return Boolean(doc.isOwner || doc.permission === 'owner' || doc.permission === 'write');
|
||
}, [doc, engineState?.role]);
|
||
|
||
// Extract all #tags present across the current blocks
|
||
const extractedTags = useMemo(() => {
|
||
const tagSet = new Set<string>();
|
||
for (const b of blocks) {
|
||
for (const tag of extractHashtags(b.content)) tagSet.add(tag);
|
||
}
|
||
return Array.from(tagSet);
|
||
}, [blocks]);
|
||
|
||
|
||
// Fetch document on mount
|
||
useEffect(() => {
|
||
let isCancelled = false;
|
||
if (!initialDoc) {
|
||
setLoading(true);
|
||
}
|
||
setError(null);
|
||
|
||
api
|
||
.getDocument(docId)
|
||
.then((fetched) => {
|
||
if (isCancelled) return;
|
||
const normalized = ensureNonEmptyBlocks(docId, fetched.blocks);
|
||
setDoc(fetched);
|
||
if (!engineRef.current?.getState().ready) {
|
||
setTitle(fetched.title || 'Unbenannte Notiz');
|
||
currentBlocksRef.current = normalized;
|
||
setBlocks(normalized);
|
||
}
|
||
const links = new Map<string, string>();
|
||
for (const b of fetched.blocks || []) {
|
||
if (b.taskId) links.set(b.id, b.taskId);
|
||
}
|
||
engineRef.current?.setTaskLinks(links);
|
||
setLoading(false);
|
||
onDocMetadataChange(fetched);
|
||
|
||
})
|
||
.catch((err: any) => {
|
||
if (isCancelled) return;
|
||
setError(err?.message || 'Dokument konnte nicht geladen werden');
|
||
setLoading(false);
|
||
});
|
||
|
||
return () => {
|
||
isCancelled = true;
|
||
};
|
||
}, [docId]);
|
||
|
||
// Start the sync engine for this note: shows stored state immediately (also offline), then syncs
|
||
useEffect(() => {
|
||
const engine = new DocSyncEngine(docId);
|
||
engineRef.current = engine;
|
||
const unsubscribe = engine.subscribe((state) => {
|
||
setEngineState(state);
|
||
if (!state.ready) return;
|
||
const next = ensureNonEmptyBlocks(docId, state.blocks);
|
||
if (!sameBlocks(currentBlocksRef.current, next)) {
|
||
currentBlocksRef.current = next;
|
||
setBlocks(next);
|
||
}
|
||
if (state.title && state.title !== currentTitleRef.current) {
|
||
setTitle(state.title);
|
||
}
|
||
});
|
||
void engine.start();
|
||
return () => {
|
||
unsubscribe();
|
||
engine.stop();
|
||
if (engineRef.current === engine) engineRef.current = null;
|
||
};
|
||
}, [docId]);
|
||
|
||
useEffect(() => {
|
||
if (!engineState) return;
|
||
switch (engineState.status) {
|
||
case 'saving':
|
||
onSaveStatusChange?.('saving');
|
||
break;
|
||
case 'offline':
|
||
onSaveStatusChange?.('unsaved', 'Offline – Änderungen sind lokal gesichert und werden gesendet, sobald die Verbindung zurück ist');
|
||
break;
|
||
case 'error':
|
||
onSaveStatusChange?.('error', engineState.errorMessage);
|
||
break;
|
||
default:
|
||
onSaveStatusChange?.('saved');
|
||
}
|
||
}, [engineState?.status, engineState?.errorMessage]);
|
||
|
||
// Blocks other users are editing right now (blockId -> name)
|
||
const lockedBlocks = useMemo(() => {
|
||
const m = new Map<string, string>();
|
||
for (const l of engineState?.lockedByOthers || []) m.set(l.blockId, l.name || 'Jemand');
|
||
return m;
|
||
}, [engineState?.lockedByOthers]);
|
||
|
||
const otherPeople = useMemo(
|
||
() => (engineState?.presence || []).filter((p) => p.userId !== currentUser?.id),
|
||
[engineState?.presence, currentUser?.id]
|
||
);
|
||
|
||
// Presence only carries names; profile pictures come from the user directory, loaded once
|
||
// the first time someone else shows up (avatars can be data URLs, too big for every sync)
|
||
const [avatarsById, setAvatarsById] = useState<Map<string, string>>(new Map());
|
||
const avatarsRequestedRef = useRef(false);
|
||
useEffect(() => {
|
||
if (otherPeople.length === 0 || avatarsRequestedRef.current) return;
|
||
avatarsRequestedRef.current = true;
|
||
api.getUsersDirectory()
|
||
.then((users) => {
|
||
const m = new Map<string, string>();
|
||
for (const u of users) if (u.avatarUrl) m.set(u.id, u.avatarUrl);
|
||
setAvatarsById(m);
|
||
})
|
||
.catch(() => {});
|
||
}, [otherPeople.length]);
|
||
|
||
// Short hint at the bottom of the editor (blocked actions, skipped undo), optionally with one action
|
||
const [notice, setNotice] = useState<string | null>(null);
|
||
const [noticeAction, setNoticeAction] = useState<{ label: string; run: () => void } | null>(null);
|
||
const noticeTimerRef = useRef<number | null>(null);
|
||
const showNotice = useCallback((message: string, action?: { label: string; run: () => void }) => {
|
||
setNotice(message);
|
||
setNoticeAction(action || null);
|
||
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
|
||
noticeTimerRef.current = window.setTimeout(() => {
|
||
setNotice(null);
|
||
setNoticeAction(null);
|
||
}, action ? 7000 : 3500);
|
||
}, []);
|
||
|
||
// A bullet with its children becomes a note of its own; the server swaps the branch for a [[link]]
|
||
// and this editor receives that change through the normal sync
|
||
const handleExtractBlock = useCallback(
|
||
async (blockId: string) => {
|
||
const engine = engineRef.current;
|
||
try {
|
||
await engine?.syncNow(); // the branch as typed so far
|
||
const created = await api.extractBranch(docId, blockId, engine?.clientId || '');
|
||
await engine?.syncNow();
|
||
showNotice(`In eigene Notiz „${created.title}“ umgewandelt`, {
|
||
label: 'Öffnen',
|
||
run: () => onOpenDocBlock?.(created.id),
|
||
});
|
||
} catch (err: any) {
|
||
showNotice(err?.message || 'Umwandeln fehlgeschlagen');
|
||
}
|
||
},
|
||
[docId, onOpenDocBlock, showNotice]
|
||
);
|
||
useEffect(() => () => {
|
||
if (noticeTimerRef.current !== null) window.clearTimeout(noticeTimerRef.current);
|
||
}, []);
|
||
|
||
const handleLockedAction = useCallback(
|
||
(holder: string) => showNotice(`${holder} bearbeitet diesen Punkt gerade – bitte kurz warten`),
|
||
[showNotice]
|
||
);
|
||
const handleUndo = useCallback(() => {
|
||
const res = engineRef.current?.undo();
|
||
if (res?.message) showNotice(res.message);
|
||
}, [showNotice]);
|
||
const handleRedo = useCallback(() => {
|
||
const res = engineRef.current?.redo();
|
||
if (res?.message) showNotice(res.message);
|
||
}, [showNotice]);
|
||
const handleResolveConflict = useCallback(
|
||
(blockId: string, accept: boolean) => {
|
||
const res = engineRef.current?.resolveConflict(blockId, accept);
|
||
if (res?.message) showNotice(res.message);
|
||
},
|
||
[showNotice]
|
||
);
|
||
|
||
// Every local block change goes to the engine, which turns it into operations
|
||
const applyLocalBlocks = useCallback(
|
||
(update: DocumentBlock[] | ((prev: DocumentBlock[]) => DocumentBlock[])) => {
|
||
const prev = currentBlocksRef.current;
|
||
const next = typeof update === 'function' ? update(prev) : update;
|
||
if (next === prev) return;
|
||
currentBlocksRef.current = next;
|
||
setBlocks(next);
|
||
engineRef.current?.applyFlat(next);
|
||
},
|
||
[]
|
||
);
|
||
|
||
// Keep parent sidebar metadata in sync when blocks/tags change
|
||
useEffect(() => {
|
||
if (!docRef.current) return;
|
||
const updatedDoc: Document = {
|
||
...docRef.current,
|
||
title,
|
||
blocks,
|
||
tags: extractedTags,
|
||
updatedAt: new Date().toISOString(),
|
||
};
|
||
docRef.current = updatedDoc;
|
||
onDocMetadataChange(updatedDoc);
|
||
}, [blocks, extractedTags, title]);
|
||
|
||
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||
if (!canEdit) return;
|
||
setTitle(e.target.value);
|
||
engineRef.current?.setTitle(e.target.value);
|
||
};
|
||
|
||
const handleTitleBlur = () => {
|
||
void engineRef.current?.syncNow();
|
||
};
|
||
|
||
const handleUpdateBlock = useCallback(
|
||
(updatedBlock: DocumentBlock) => {
|
||
if (!canEdit) return;
|
||
applyLocalBlocks((prev) => prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b)));
|
||
},
|
||
[canEdit, applyLocalBlocks]
|
||
);
|
||
|
||
const handleUpdateBlocks = useCallback(
|
||
(updatedBlocks: DocumentBlock[]) => {
|
||
if (!canEdit || updatedBlocks.length === 0) return;
|
||
const map = new Map<string, DocumentBlock>();
|
||
for (const b of updatedBlocks) {
|
||
map.set(b.id, b);
|
||
}
|
||
applyLocalBlocks((prev) => prev.map((b) => map.get(b.id) || b));
|
||
},
|
||
[canEdit, applyLocalBlocks]
|
||
);
|
||
|
||
const handleCreateBlock = useCallback(
|
||
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
||
if (!canEdit) return;
|
||
applyLocalBlocks((prev) => {
|
||
const next = [...prev];
|
||
if (afterBlockId) {
|
||
const idx = next.findIndex((b) => b.id === afterBlockId);
|
||
if (idx >= 0) {
|
||
next.splice(idx + 1, 0, newBlock);
|
||
return ensureNonEmptyBlocks(docId, next);
|
||
}
|
||
}
|
||
next.push(newBlock);
|
||
return ensureNonEmptyBlocks(docId, next);
|
||
});
|
||
},
|
||
[docId, canEdit, applyLocalBlocks]
|
||
);
|
||
|
||
const handleInsertBlocks = useCallback(
|
||
(afterBlockId: string, updatedCurrentBlock: DocumentBlock, newBlocks: DocumentBlock[]) => {
|
||
if (!canEdit) return;
|
||
applyLocalBlocks((prev) => {
|
||
const next = [...prev];
|
||
const idx = next.findIndex((b) => b.id === afterBlockId);
|
||
if (idx >= 0) {
|
||
next[idx] = updatedCurrentBlock;
|
||
next.splice(idx + 1, 0, ...newBlocks);
|
||
} else {
|
||
next.push(updatedCurrentBlock, ...newBlocks);
|
||
}
|
||
return ensureNonEmptyBlocks(docId, next);
|
||
});
|
||
},
|
||
[docId, canEdit, applyLocalBlocks]
|
||
);
|
||
|
||
const handleDeleteBlock = useCallback(
|
||
(blockId: string) => {
|
||
if (!canEdit) return;
|
||
applyLocalBlocks((prev) => ensureNonEmptyBlocks(docId, prev.filter((b) => b.id !== blockId)));
|
||
},
|
||
[docId, canEdit, applyLocalBlocks]
|
||
);
|
||
|
||
const handleMoveBlock = useCallback(
|
||
(blockId: string, targetIndex: number, newLevel?: number) => {
|
||
if (!canEdit) return;
|
||
applyLocalBlocks((prev) => {
|
||
const fromIdx = prev.findIndex((b) => b.id === blockId);
|
||
if (fromIdx === -1) return prev;
|
||
|
||
const parentBlock = prev[fromIdx];
|
||
const parentLevel = Math.max(0, parentBlock.level || 0);
|
||
|
||
// Calculate full subtree count (dragged block + all contiguous descendants)
|
||
let count = 1;
|
||
for (let i = fromIdx + 1; i < prev.length; i++) {
|
||
if (Math.max(0, prev[i].level || 0) > parentLevel) {
|
||
count++;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Prevent dropping inside its own subtree
|
||
if (targetIndex >= fromIdx && targetIndex < fromIdx + count) {
|
||
return prev;
|
||
}
|
||
|
||
const copy = [...prev];
|
||
const subtree = copy.splice(fromIdx, count);
|
||
|
||
// Adjust insertion index because removing elements from before shifts target index
|
||
let insertIndex = targetIndex;
|
||
if (fromIdx < targetIndex) {
|
||
insertIndex = targetIndex - count;
|
||
}
|
||
insertIndex = Math.max(0, Math.min(insertIndex, copy.length));
|
||
|
||
// Calculate level adjustments if moving to a constrained position
|
||
const prevBlockInPosition = insertIndex > 0 ? copy[insertIndex - 1] : null;
|
||
const maxAllowed = prevBlockInPosition
|
||
? Math.max(0, prevBlockInPosition.level || 0) + 1
|
||
: 0;
|
||
|
||
const targetLevel = newLevel !== undefined ? newLevel : parentLevel;
|
||
const clampedLevel = Math.min(targetLevel, maxAllowed);
|
||
const levelDelta = clampedLevel - parentLevel;
|
||
|
||
const updatedSubtree = subtree.map((b) => ({
|
||
...b,
|
||
level: Math.max(0, (b.level || 0) + levelDelta),
|
||
}));
|
||
|
||
copy.splice(insertIndex, 0, ...updatedSubtree);
|
||
return ensureNonEmptyBlocks(docId, copy);
|
||
});
|
||
},
|
||
[docId, canEdit, applyLocalBlocks]
|
||
);
|
||
|
||
const handleTogglePin = async () => {
|
||
if (!doc) return;
|
||
try {
|
||
const updated = await api.updateDocument(doc.id, { isPinned: !doc.isPinned });
|
||
setDoc(updated);
|
||
onDocMetadataChange(updated);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
};
|
||
|
||
const handleSelectIcon = async (icon: string) => {
|
||
setShowEmojiPicker(false);
|
||
if (!doc || !canEdit) return;
|
||
try {
|
||
const updated = await api.updateDocument(doc.id, { icon });
|
||
setDoc(updated);
|
||
onDocMetadataChange(updated);
|
||
} catch {
|
||
// ignore
|
||
}
|
||
};
|
||
|
||
const handleExportMarkdown = () => {
|
||
const lines: string[] = [`# ${title || 'Unbenannte Notiz'}`, ''];
|
||
for (const b of blocks) {
|
||
const indent = ' '.repeat(Math.max(0, b.level || 0));
|
||
if (b.type === 'todo') {
|
||
lines.push(`${indent}- [${b.checked ? 'x' : ' '}] ${b.content}`);
|
||
} else if (b.type === 'h1') {
|
||
lines.push(`${indent}- # ${b.content}`);
|
||
} else if (b.type === 'h2') {
|
||
lines.push(`${indent}- ## ${b.content}`);
|
||
} else if (b.type === 'h3') {
|
||
lines.push(`${indent}- ### ${b.content}`);
|
||
} else if (b.type === 'code') {
|
||
lines.push(`${indent}- \`\`\`${b.lang || ''}\n${b.content}\n${indent} \`\`\``);
|
||
} else if (b.type === 'divider') {
|
||
lines.push(`${indent}- ---`);
|
||
} else {
|
||
lines.push(`${indent}- ${b.content}`);
|
||
}
|
||
}
|
||
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${(title || 'notiz').toLowerCase().replace(/[^a-z0-9äöüß_-]+/gi, '-')}.md`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex-1 flex items-center justify-center p-12 text-muted">
|
||
<Loader2 className="w-5 h-5 animate-spin mr-2 text-accent" />
|
||
<span className="text-xs">Notiz wird geladen…</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center">
|
||
<p className="text-xs text-status-err font-medium mb-2">{error}</p>
|
||
{onBackMobile && (
|
||
<button
|
||
type="button"
|
||
onClick={onBackMobile}
|
||
className="btn-action px-3 py-1.5 text-xs text-main"
|
||
>
|
||
Zurück zur Übersicht
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="flex-1 flex flex-col h-full min-h-0 bg-canvas overflow-y-auto" data-sync-channel={engineState?.channel}>
|
||
{/* Top Document Action Bar */}
|
||
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-2 px-4 sm:px-6 py-2 bg-canvas border-b border-border">
|
||
<div className="flex items-center gap-2 min-w-0">
|
||
{onBackMobile && (
|
||
<button
|
||
type="button"
|
||
onClick={onBackMobile}
|
||
className="lg:hidden btn-icon"
|
||
title="Zurück zur Seitenleiste"
|
||
>
|
||
<ArrowLeft className="w-3.5 h-3.5" />
|
||
</button>
|
||
)}
|
||
|
||
{/* Breadcrumbs Navigation (Fokus-Modus) or Doc Indicator */}
|
||
{zoomedBlock && breadcrumbs.length > 0 ? (
|
||
<nav
|
||
aria-label="Breadcrumb Navigation"
|
||
className="flex items-center flex-wrap gap-1 text-xs text-muted"
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => setZoomedBlockId(null)}
|
||
className="hover:text-accent inline-flex items-center gap-1 font-medium transition-colors cursor-pointer"
|
||
title="Zurück zum Hauptdokument"
|
||
>
|
||
<Home className="w-3.5 h-3.5 text-accent shrink-0" />
|
||
<span className="truncate max-w-[130px]">
|
||
{title || doc?.title || 'Dokument'}
|
||
</span>
|
||
</button>
|
||
|
||
{breadcrumbs.slice(1).map((crumb, idx) => {
|
||
const isCurrent = idx === breadcrumbs.length - 2;
|
||
return (
|
||
<React.Fragment key={crumb.id || idx}>
|
||
<ChevronRight className="w-3 h-3 text-faint shrink-0" />
|
||
{isCurrent ? (
|
||
<span
|
||
className="font-bold text-accent truncate max-w-[160px]"
|
||
title={crumb.title}
|
||
>
|
||
{crumb.title || 'Punkt'}
|
||
</span>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => setZoomedBlockId(crumb.id)}
|
||
className="hover:text-main truncate max-w-[120px] transition-colors cursor-pointer"
|
||
title={`Zoom zu "${crumb.title}"`}
|
||
>
|
||
{crumb.title || 'Punkt'}
|
||
</button>
|
||
)}
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
</nav>
|
||
) : (
|
||
<div className="flex items-center gap-1.5 text-xs text-muted">
|
||
<FileText className="w-3.5 h-3.5 text-accent shrink-0" />
|
||
<span className="truncate max-w-[180px] font-medium text-main">
|
||
{title || doc?.title || 'Dokument'}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Save status on phones (the desktop shows it above the workspace) */}
|
||
{engineState && (
|
||
<span
|
||
className="sm:hidden inline-flex items-center shrink-0"
|
||
title={
|
||
engineState.status === 'saving'
|
||
? 'Speichert…'
|
||
: engineState.status === 'offline'
|
||
? 'Offline – Änderungen sind lokal gesichert'
|
||
: engineState.status === 'error'
|
||
? engineState.errorMessage || 'Fehler beim Speichern'
|
||
: 'Gespeichert'
|
||
}
|
||
aria-label="Speicherstatus"
|
||
>
|
||
{engineState.status === 'saving' ? (
|
||
<Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />
|
||
) : engineState.status === 'offline' ? (
|
||
<CloudOff className="w-3.5 h-3.5 text-status-warn" />
|
||
) : engineState.status === 'error' ? (
|
||
<AlertTriangle className="w-3.5 h-3.5 text-status-err" />
|
||
) : (
|
||
<Check className="w-3.5 h-3.5 text-status-ok" />
|
||
)}
|
||
</span>
|
||
)}
|
||
|
||
{/* Read-only indicator */}
|
||
{!canEdit && (
|
||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-btn text-[11px] font-medium bg-status-warn/10 text-status-warn border border-status-warn/30">
|
||
<Lock className="w-3 h-3" />
|
||
Nur Lesen
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* 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>
|
||
)}
|
||
|
||
{/* 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';
|
||
|
||
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>
|
||
)
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => onOpenShareModal(doc)}
|
||
className="btn-icon"
|
||
title="Notiz teilen und Berechtigungen"
|
||
>
|
||
<Users className="w-3.5 h-3.5" />
|
||
</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>
|
||
)}
|
||
</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>
|
||
|
||
{/* In the trash: read-only with a way back */}
|
||
{inTrash && (
|
||
<div className="flex flex-wrap items-center gap-2 px-4 sm:px-6 py-2 bg-status-err/10 border-b border-status-err/25 text-status-err text-xs">
|
||
<Trash2 className="w-3.5 h-3.5 shrink-0" />
|
||
<span className="flex-1 min-w-0">Diese Notiz liegt im Papierkorb und ist schreibgeschützt.</span>
|
||
{onRestoreDoc && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onRestoreDoc(docId)}
|
||
className="btn-action h-11 sm:h-7 px-3 text-xs text-main inline-flex items-center gap-1.5"
|
||
>
|
||
<RotateCcw className="w-3.5 h-3.5 text-accent" />
|
||
Wiederherstellen
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Read-only notification banner */}
|
||
{!canEdit && !inTrash && (
|
||
<div className="flex items-center gap-2 px-4 sm:px-6 py-2 bg-status-warn/10 border-b border-status-warn/25 text-status-warn text-xs">
|
||
<Lock className="w-3.5 h-3.5 shrink-0" />
|
||
<span>Dieses Dokument wurde schreibgeschützt mit dir geteilt (Nur Lesen).</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Main Content Area */}
|
||
{/* Phones: almost no side margin; very deep outlines scroll sideways (rows keep a minimum width) */}
|
||
<div className="w-full pl-2 pr-1 sm:px-8 py-5 flex-1 overflow-x-auto sm:overflow-x-visible [-webkit-overflow-scrolling:touch]">
|
||
{zoomedBlock ? (
|
||
/* =========================================================================
|
||
FOKUS-MODUS & ZOOM-IN (HOISTED SUBTREE VIEW)
|
||
========================================================================= */
|
||
<div className="space-y-3">
|
||
<div className="flex items-start justify-between gap-3 pb-4 mb-2 border-b border-border">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-center gap-2 mb-1.5">
|
||
<span className="inline-flex items-center gap-1 text-[10px] font-bold uppercase tracking-wider text-accent bg-accent-dim px-2 py-0.5 rounded-btn border border-accent/30">
|
||
<Sparkles className="w-2.5 h-2.5 text-accent animate-pulse" />
|
||
Fokus-Modus
|
||
</span>
|
||
<span className="text-[11px] text-faint hidden sm:inline">
|
||
Isolierter Ast • Zoom aktiv
|
||
</span>
|
||
</div>
|
||
<input
|
||
type="text"
|
||
value={zoomedBlock.content}
|
||
onChange={(e) => {
|
||
handleUpdateBlock({ ...zoomedBlock, content: e.target.value });
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
const firstChild: DocumentBlock = {
|
||
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||
documentId: docId,
|
||
type: 'text',
|
||
content: '',
|
||
level: levelOffset,
|
||
position: (zoomedBlock.position || 1000) + 10,
|
||
};
|
||
handleCreateBlock(firstChild, zoomedBlock.id);
|
||
}
|
||
}}
|
||
placeholder="Ast-Titel…"
|
||
readOnly={!canEdit}
|
||
className="w-full text-2xl sm:text-3xl font-extrabold text-main bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight leading-tight"
|
||
/>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setZoomedBlockId(null)}
|
||
className="btn-action inline-flex items-center gap-1.5 px-2.5 py-1 text-xs text-muted hover:text-main shrink-0 mt-1"
|
||
title="Fokus-Modus beenden"
|
||
>
|
||
<Minimize2 className="w-3.5 h-3.5" />
|
||
<span className="hidden sm:inline">Fokus beenden</span>
|
||
</button>
|
||
</div>
|
||
|
||
{childBlocks.length === 0 ? (
|
||
<div className="py-8 text-center text-xs text-faint border border-dashed border-border rounded-card my-2">
|
||
<p className="mb-2">Dieser Ast hat noch keine Unterpunkte.</p>
|
||
{canEdit && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const firstChild: DocumentBlock = {
|
||
id: `blk_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
||
documentId: docId,
|
||
type: 'text',
|
||
content: '',
|
||
level: levelOffset,
|
||
position: (zoomedBlock.position || 1000) + 10,
|
||
};
|
||
handleCreateBlock(firstChild, zoomedBlock.id);
|
||
}}
|
||
className="btn-action inline-flex items-center gap-1.5 px-3 py-1.5 text-xs text-accent font-medium hover:bg-accent/10"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
<span>Ersten Unterpunkt anlegen (oder Enter drücken)</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<OutlinerTree
|
||
docId={docId}
|
||
blocks={childBlocks}
|
||
canEdit={canEdit}
|
||
levelOffset={levelOffset}
|
||
onZoomBlock={setZoomedBlockId}
|
||
onExtractBlock={canEdit ? handleExtractBlock : undefined}
|
||
onUpdateBlock={handleUpdateBlock}
|
||
onUpdateBlocks={handleUpdateBlocks}
|
||
onCreateBlock={handleCreateBlock}
|
||
onInsertBlocks={handleInsertBlocks}
|
||
onDeleteBlock={handleDeleteBlock}
|
||
onMoveBlock={handleMoveBlock}
|
||
onOpenTask={onOpenTask}
|
||
onTagClick={onSelectTag}
|
||
onWikiLinkClick={onWikiLinkClick}
|
||
initialFocusedBlockId={initialFocusedBlockId}
|
||
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
||
lockedBlocks={lockedBlocks}
|
||
onLockedAction={handleLockedAction}
|
||
onResolveConflict={handleResolveConflict}
|
||
onUndo={handleUndo}
|
||
onRedo={handleRedo}
|
||
/>
|
||
)}
|
||
</div>
|
||
) : (
|
||
/* =========================================================================
|
||
STANDARD GESAMTDOKUMENT-ANSICHT
|
||
========================================================================= */
|
||
<>
|
||
{/* Document Icon + Title Header */}
|
||
<div className="flex items-center gap-3 mb-4 relative">
|
||
<div className="relative">
|
||
<button
|
||
type="button"
|
||
onClick={() => canEdit && setShowEmojiPicker((prev) => !prev)}
|
||
className="text-2xl sm:text-3xl hover:opacity-80 transition-opacity cursor-pointer select-none leading-none bg-transparent border-0 outline-none p-0 focus:outline-none focus:ring-0 shadow-none"
|
||
title="Icon ändern"
|
||
>
|
||
{doc?.icon || '📝'}
|
||
</button>
|
||
{showEmojiPicker && canEdit && (
|
||
<EmojiPickerModal
|
||
currentIcon={doc?.icon}
|
||
onSelectEmoji={handleSelectIcon}
|
||
onClose={() => setShowEmojiPicker(false)}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
<textarea
|
||
ref={(el) => {
|
||
titleInputRef.current = el;
|
||
if (el) {
|
||
el.style.height = 'auto';
|
||
el.style.height = `${el.scrollHeight}px`;
|
||
}
|
||
}}
|
||
rows={1}
|
||
value={title}
|
||
readOnly={!canEdit}
|
||
autoFocus={autoFocusTitle}
|
||
onChange={(e) => {
|
||
handleTitleChange(e);
|
||
e.target.style.height = 'auto';
|
||
e.target.style.height = `${e.target.scrollHeight}px`;
|
||
}}
|
||
onKeyDown={(e) => {
|
||
if ((e.ctrlKey || e.metaKey) && !e.altKey && ['z', 'y'].includes(e.key.toLowerCase())) {
|
||
e.preventDefault();
|
||
if (e.key.toLowerCase() === 'y' || e.shiftKey) handleRedo();
|
||
else handleUndo();
|
||
return;
|
||
}
|
||
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 resize-none leading-snug whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
||
/>
|
||
</div>
|
||
|
||
{/* Logseq Outliner Tree */}
|
||
<OutlinerTree
|
||
ref={outlinerTreeRef}
|
||
docId={docId}
|
||
blocks={blocks}
|
||
canEdit={canEdit}
|
||
levelOffset={0}
|
||
onZoomBlock={setZoomedBlockId}
|
||
onExtractBlock={canEdit ? handleExtractBlock : undefined}
|
||
onUpdateBlock={handleUpdateBlock}
|
||
onUpdateBlocks={handleUpdateBlocks}
|
||
onCreateBlock={handleCreateBlock}
|
||
onInsertBlocks={handleInsertBlocks}
|
||
onDeleteBlock={handleDeleteBlock}
|
||
onMoveBlock={handleMoveBlock}
|
||
onOpenTask={onOpenTask}
|
||
onTagClick={onSelectTag}
|
||
onWikiLinkClick={onWikiLinkClick}
|
||
initialFocusedBlockId={initialFocusedBlockId}
|
||
onFocusedBlockChange={(id) => engineRef.current?.focusBlock(id)}
|
||
lockedBlocks={lockedBlocks}
|
||
onLockedAction={handleLockedAction}
|
||
onResolveConflict={handleResolveConflict}
|
||
onUndo={handleUndo}
|
||
onRedo={handleRedo}
|
||
/>
|
||
|
||
{onOpenDocBlock && !inTrash && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{notice && (
|
||
<div
|
||
role="status"
|
||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 max-w-[90vw] px-3 py-2 rounded-card text-xs text-main bg-panel-elevated border border-border shadow-panel"
|
||
>
|
||
<span>{notice}</span>
|
||
{noticeAction && (
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
noticeAction.run();
|
||
setNotice(null);
|
||
setNoticeAction(null);
|
||
}}
|
||
className="ml-3 font-semibold text-accent hover:underline min-h-[44px] sm:min-h-0"
|
||
>
|
||
{noticeAction.label}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|