feat(workspace): tabula-rasa outliner architecture, SQLite blocks/block_tags schema & echo-protected live room sync
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m12s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m12s
This commit is contained in:
1 parent
f3b3f07e04
commit
86867b0d6f
17 files changed
+3409
-7178
No files matched your search
@@ -0,0 +1,664 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Pin,
|
||||
Share2,
|
||||
Trash2,
|
||||
Download,
|
||||
Folder as FolderIcon,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Loader2,
|
||||
ArrowLeft,
|
||||
Hash,
|
||||
Lock,
|
||||
} from 'lucide-react';
|
||||
import { Document, DocumentBlock } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
import { useDocumentSync, DocSyncEvent } from '../../lib/useDocumentSync';
|
||||
import { OutlinerTree } from './OutlinerTree';
|
||||
|
||||
export interface DocumentEditorProps {
|
||||
docId: string;
|
||||
initialDoc?: Document | null;
|
||||
availableFolders: string[];
|
||||
onDocMetadataChange: (updated: Document) => void;
|
||||
onDeleteDoc: (docId: string) => void;
|
||||
onOpenShareModal: (doc: Document) => void;
|
||||
onSelectTag: (tag: string) => void;
|
||||
onBackMobile?: () => void;
|
||||
}
|
||||
|
||||
const EMOJI_PALETTE = ['📝', '📄', '💡', '🚀', '📌', '📂', '🧠', '⚡', '🎯', '🛠️', '📊', '🔒'];
|
||||
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
docId,
|
||||
initialDoc,
|
||||
availableFolders,
|
||||
onDocMetadataChange,
|
||||
onDeleteDoc,
|
||||
onOpenShareModal,
|
||||
onSelectTag,
|
||||
onBackMobile,
|
||||
}) => {
|
||||
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 [showFolderMenu, setShowFolderMenu] = useState<boolean>(false);
|
||||
|
||||
const titleInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const titleDebounceRef = useRef<number | null>(null);
|
||||
const docRef = useRef<Document | null>(doc);
|
||||
docRef.current = doc;
|
||||
|
||||
const canEdit = useMemo(() => {
|
||||
if (!doc) return true;
|
||||
return doc.isOwner !== false && doc.permission !== 'read';
|
||||
}, [doc]);
|
||||
|
||||
// Handle incoming WebSocket events for THIS document room
|
||||
const handleRemoteEvent = useCallback(
|
||||
(evt: DocSyncEvent) => {
|
||||
if (evt.docId && evt.docId !== docId) return;
|
||||
|
||||
switch (evt.type) {
|
||||
case 'doc_state': {
|
||||
if (evt.title !== undefined) {
|
||||
const isTitleFocused =
|
||||
titleInputRef.current !== null &&
|
||||
document.activeElement === titleInputRef.current;
|
||||
if (!isTitleFocused) {
|
||||
setTitle(evt.title);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(evt.blocks) && evt.blocks.length > 0) {
|
||||
setBlocks(ensureNonEmptyBlocks(docId, evt.blocks));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'title_updated': {
|
||||
if (evt.title !== undefined) {
|
||||
const isTitleFocused =
|
||||
titleInputRef.current !== null &&
|
||||
document.activeElement === titleInputRef.current;
|
||||
if (!isTitleFocused) {
|
||||
setTitle(evt.title);
|
||||
}
|
||||
if (docRef.current) {
|
||||
const updated = { ...docRef.current, title: evt.title };
|
||||
setDoc(updated);
|
||||
onDocMetadataChange(updated);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block_created': {
|
||||
if (!evt.block) return;
|
||||
const incoming = evt.block;
|
||||
setBlocks((prev) => {
|
||||
if (prev.some((b) => b.id === incoming.id)) {
|
||||
return prev.map((b) => (b.id === incoming.id ? incoming : b));
|
||||
}
|
||||
const next = [...prev];
|
||||
if (evt.afterBlockId) {
|
||||
const idx = next.findIndex((b) => b.id === evt.afterBlockId);
|
||||
if (idx >= 0) {
|
||||
next.splice(idx + 1, 0, incoming);
|
||||
return ensureNonEmptyBlocks(docId, next);
|
||||
}
|
||||
}
|
||||
next.push(incoming);
|
||||
return ensureNonEmptyBlocks(docId, next);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block_updated': {
|
||||
const targetId = evt.blockId || evt.block?.id;
|
||||
if (!targetId) return;
|
||||
setBlocks((prev) =>
|
||||
prev.map((b) => {
|
||||
if (b.id !== targetId) return b;
|
||||
if (evt.block) {
|
||||
return { ...b, ...evt.block };
|
||||
}
|
||||
return {
|
||||
...b,
|
||||
content: evt.content !== undefined ? evt.content : b.content,
|
||||
level: evt.level !== undefined ? evt.level : b.level,
|
||||
checked: evt.checked !== undefined ? evt.checked : b.checked,
|
||||
collapsed: evt.collapsed !== undefined ? evt.collapsed : b.collapsed,
|
||||
};
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block_deleted': {
|
||||
if (!evt.blockId) return;
|
||||
setBlocks((prev) =>
|
||||
ensureNonEmptyBlocks(
|
||||
docId,
|
||||
prev.filter((b) => b.id !== evt.blockId)
|
||||
)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'block_moved': {
|
||||
if (!evt.blockId || evt.newIndex === undefined) return;
|
||||
const targetIndex = evt.newIndex;
|
||||
setBlocks((prev) => {
|
||||
const fromIdx = prev.findIndex((b) => b.id === evt.blockId);
|
||||
if (fromIdx === -1) return prev;
|
||||
const copy = [...prev];
|
||||
const [moving] = copy.splice(fromIdx, 1);
|
||||
const updatedMoving: DocumentBlock = {
|
||||
...moving,
|
||||
level: evt.level !== undefined ? evt.level : moving.level,
|
||||
};
|
||||
const boundedIdx = Math.max(0, Math.min(targetIndex, copy.length));
|
||||
copy.splice(boundedIdx, 0, updatedMoving);
|
||||
return ensureNonEmptyBlocks(docId, copy);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'blocks_replaced': {
|
||||
if (Array.isArray(evt.blocks)) {
|
||||
setBlocks(ensureNonEmptyBlocks(docId, evt.blocks));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[docId, onDocMetadataChange]
|
||||
);
|
||||
|
||||
const {
|
||||
status: syncStatus,
|
||||
sendBlockCreated,
|
||||
sendBlockUpdated,
|
||||
sendBlockDeleted,
|
||||
sendBlockMoved,
|
||||
sendTitleUpdated,
|
||||
} = useDocumentSync(docId, handleRemoteEvent, canEdit);
|
||||
|
||||
// Fetch isolated document state on mount
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
api
|
||||
.getDocument(docId)
|
||||
.then((fetched) => {
|
||||
if (isCancelled) return;
|
||||
setDoc(fetched);
|
||||
setTitle(fetched.title || 'Unbenannte Notiz');
|
||||
setBlocks(ensureNonEmptyBlocks(docId, fetched.blocks));
|
||||
setLoading(false);
|
||||
onDocMetadataChange(fetched);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (isCancelled) return;
|
||||
setError(err?.message || 'Dokument konnte nicht geladen werden');
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
if (titleDebounceRef.current !== null) {
|
||||
window.clearTimeout(titleDebounceRef.current);
|
||||
titleDebounceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [docId]);
|
||||
|
||||
// Extract all #tags present across the current blocks
|
||||
const extractedTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
for (const b of blocks) {
|
||||
const matches = b.content?.match(/#([a-zA-Z0-9äöüÄÖÜß_-]+)/g);
|
||||
if (matches) {
|
||||
for (const m of matches) {
|
||||
tagSet.add(m.slice(1).toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(tagSet);
|
||||
}, [blocks]);
|
||||
|
||||
// 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>) => {
|
||||
if (!canEdit) return;
|
||||
const nextTitle = e.target.value;
|
||||
setTitle(nextTitle);
|
||||
|
||||
if (titleDebounceRef.current !== null) {
|
||||
window.clearTimeout(titleDebounceRef.current);
|
||||
}
|
||||
titleDebounceRef.current = window.setTimeout(() => {
|
||||
titleDebounceRef.current = null;
|
||||
sendTitleUpdated(nextTitle);
|
||||
}, 250);
|
||||
};
|
||||
|
||||
const handleUpdateBlock = useCallback(
|
||||
(updatedBlock: DocumentBlock) => {
|
||||
setBlocks((prev) =>
|
||||
prev.map((b) => (b.id === updatedBlock.id ? updatedBlock : b))
|
||||
);
|
||||
sendBlockUpdated(updatedBlock);
|
||||
},
|
||||
[sendBlockUpdated]
|
||||
);
|
||||
|
||||
const handleCreateBlock = useCallback(
|
||||
(newBlock: DocumentBlock, afterBlockId?: string) => {
|
||||
setBlocks((prev) => {
|
||||
const next = [...prev];
|
||||
if (afterBlockId) {
|
||||
const idx = next.findIndex((b) => b.id === afterBlockId);
|
||||
if (idx >= 0) {
|
||||
next.splice(idx + 1, 0, newBlock);
|
||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
||||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
next.push(newBlock);
|
||||
const normalized = ensureNonEmptyBlocks(docId, next);
|
||||
sendBlockCreated(newBlock, afterBlockId, normalized);
|
||||
return normalized;
|
||||
});
|
||||
},
|
||||
[docId, sendBlockCreated]
|
||||
);
|
||||
|
||||
const handleDeleteBlock = useCallback(
|
||||
(blockId: string) => {
|
||||
setBlocks((prev) => {
|
||||
const filtered = prev.filter((b) => b.id !== blockId);
|
||||
const normalized = ensureNonEmptyBlocks(docId, filtered);
|
||||
sendBlockDeleted(blockId, normalized);
|
||||
return normalized;
|
||||
});
|
||||
},
|
||||
[docId, sendBlockDeleted]
|
||||
);
|
||||
|
||||
const handleMoveBlock = useCallback(
|
||||
(blockId: string, newIndex: number, level?: number) => {
|
||||
setBlocks((prev) => {
|
||||
const fromIdx = prev.findIndex((b) => b.id === blockId);
|
||||
if (fromIdx === -1) return prev;
|
||||
const copy = [...prev];
|
||||
const [moving] = copy.splice(fromIdx, 1);
|
||||
const updatedMoving: DocumentBlock = {
|
||||
...moving,
|
||||
level: level !== undefined ? level : moving.level,
|
||||
};
|
||||
const boundedIdx = Math.max(0, Math.min(newIndex, copy.length));
|
||||
copy.splice(boundedIdx, 0, updatedMoving);
|
||||
const normalized = ensureNonEmptyBlocks(docId, copy);
|
||||
sendBlockMoved(blockId, boundedIdx, updatedMoving.level, normalized);
|
||||
return normalized;
|
||||
});
|
||||
},
|
||||
[docId, sendBlockMoved]
|
||||
);
|
||||
|
||||
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 handleMoveToFolder = async (folderPath: string) => {
|
||||
setShowFolderMenu(false);
|
||||
if (!doc || !canEdit) return;
|
||||
try {
|
||||
const updated = await api.moveDocument(doc.id, folderPath);
|
||||
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 {
|
||||
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-slate-400">
|
||||
<Loader2 className="w-6 h-6 animate-spin mr-2 text-indigo-500" />
|
||||
<span className="text-sm">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-sm text-rose-500 font-medium mb-2">{error}</p>
|
||||
{onBackMobile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackMobile}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
Zurück zur Übersicht
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full min-h-0 bg-white dark:bg-slate-900 overflow-y-auto">
|
||||
{/* Top Document Action Bar */}
|
||||
<div className="sticky top-0 z-20 flex flex-wrap items-center justify-between gap-2 px-4 sm:px-8 py-3 bg-white/90 dark:bg-slate-900/90 backdrop-blur-md border-b border-slate-200/70 dark:border-slate-800/80">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{onBackMobile && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBackMobile}
|
||||
className="lg:hidden p-1.5 rounded-lg text-slate-500 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||
title="Zurück zur Seitenleiste"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Folder Selector */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => canEdit && setShowFolderMenu((prev) => !prev)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-slate-200/70 dark:hover:bg-slate-700/70 transition-colors"
|
||||
title="Ordner wechseln"
|
||||
>
|
||||
<FolderIcon className="w-3.5 h-3.5 text-indigo-500" />
|
||||
<span className="truncate max-w-[160px]">
|
||||
{doc?.folderPath ? doc.folderPath : 'Hauptebene'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{showFolderMenu && canEdit && (
|
||||
<div className="absolute left-0 mt-1 w-52 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl shadow-xl py-1 z-30 max-h-60 overflow-y-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMoveToFolder('')}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-slate-100 dark:hover:bg-slate-800 ${
|
||||
!doc?.folderPath
|
||||
? 'font-semibold text-indigo-600 dark:text-indigo-400'
|
||||
: 'text-slate-700 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
/ (Hauptebene)
|
||||
</button>
|
||||
{availableFolders.map((folder) => (
|
||||
<button
|
||||
key={folder}
|
||||
type="button"
|
||||
onClick={() => handleMoveToFolder(folder)}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs hover:bg-slate-100 dark:hover:bg-slate-800 truncate ${
|
||||
doc?.folderPath === folder
|
||||
? 'font-semibold text-indigo-600 dark:text-indigo-400'
|
||||
: 'text-slate-700 dark:text-slate-300'
|
||||
}`}
|
||||
>
|
||||
📁 {folder}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Read-only indicator if shared with read permission */}
|
||||
{!canEdit && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium bg-amber-50 dark:bg-amber-950/60 text-amber-700 dark:text-amber-300">
|
||||
<Lock className="w-3 h-3" />
|
||||
Nur Lesen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Status & Action Buttons */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Sync Status Pill */}
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-[11px] font-medium ${
|
||||
syncStatus === 'connected'
|
||||
? 'bg-emerald-50 dark:bg-emerald-950/50 text-emerald-600 dark:text-emerald-400'
|
||||
: syncStatus === 'connecting' || syncStatus === 'reconnecting'
|
||||
? 'bg-amber-50 dark:bg-amber-950/50 text-amber-600 dark:text-amber-400'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-500 dark:text-slate-400'
|
||||
}`}
|
||||
title={
|
||||
syncStatus === 'connected'
|
||||
? 'Echtzeit-Synchronisation aktiv (1.5s Auto-Flush in SQLite)'
|
||||
: 'Änderungen werden automatisch per REST gespeichert'
|
||||
}
|
||||
>
|
||||
{syncStatus === 'connected' ? (
|
||||
<>
|
||||
<Wifi className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">Live</span>
|
||||
</>
|
||||
) : syncStatus === 'connecting' || syncStatus === 'reconnecting' ? (
|
||||
<>
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
<span className="hidden sm:inline">Sync…</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="w-3 h-3" />
|
||||
<span className="hidden sm:inline">REST-Sync</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePin}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
doc?.isPinned
|
||||
? 'bg-amber-50 dark:bg-amber-950/50 text-amber-500'
|
||||
: 'text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800'
|
||||
}`}
|
||||
title={doc?.isPinned ? 'Anheftung lösen' : 'Notiz anheften'}
|
||||
>
|
||||
<Pin className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{doc && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenShareModal(doc)}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-indigo-600 dark:hover:text-indigo-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
title="Notiz teilen / Berechtigungen"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExportMarkdown}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
title="Als Markdown (.md) exportieren"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{canEdit && doc?.isOwner !== false && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeleteDoc(docId)}
|
||||
className="p-1.5 rounded-lg text-slate-400 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 transition-colors"
|
||||
title="Notiz löschen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Outliner Content Container */}
|
||||
<div className="max-w-4xl w-full mx-auto px-4 sm:px-10 py-6 sm:py-8 flex-1">
|
||||
{/* 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="w-11 h-11 rounded-2xl bg-slate-100 dark:bg-slate-800/90 hover:bg-slate-200/70 dark:hover:bg-slate-700 flex items-center justify-center text-2xl transition-colors"
|
||||
title="Icon ändern"
|
||||
>
|
||||
{doc?.icon || '📝'}
|
||||
</button>
|
||||
{showEmojiPicker && canEdit && (
|
||||
<div className="absolute left-0 mt-2 p-2 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-xl grid grid-cols-6 gap-1 z-30 w-56">
|
||||
{EMOJI_PALETTE.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => handleSelectIcon(emoji)}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 text-lg flex items-center justify-center"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={titleInputRef}
|
||||
type="text"
|
||||
value={title}
|
||||
readOnly={!canEdit}
|
||||
onChange={handleTitleChange}
|
||||
placeholder="Titel der Notiz…"
|
||||
className="flex-1 min-w-0 text-2xl sm:text-3xl font-bold text-slate-900 dark:text-white bg-transparent border-0 focus:outline-none focus:ring-0 tracking-tight"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Document #Tags Summary Bar */}
|
||||
{extractedTags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mb-6 pb-4 border-b border-slate-100 dark:border-slate-800/80">
|
||||
<Hash className="w-3.5 h-3.5 text-slate-400 mr-0.5" />
|
||||
{extractedTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
onClick={() => onSelectTag(tag)}
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-50 dark:bg-indigo-950/60 text-indigo-600 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/80 transition-colors"
|
||||
>
|
||||
#{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logseq Hierarchical Outliner Tree */}
|
||||
<OutlinerTree
|
||||
docId={docId}
|
||||
blocks={blocks}
|
||||
canEdit={canEdit}
|
||||
onUpdateBlock={handleUpdateBlock}
|
||||
onCreateBlock={handleCreateBlock}
|
||||
onDeleteBlock={handleDeleteBlock}
|
||||
onMoveBlock={handleMoveBlock}
|
||||
onTagClick={onSelectTag}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user