import React, { useState, useEffect, useMemo, useRef } from 'react'; import { Link as LinkIcon, FileText, Globe, X, Search, ExternalLink, Loader2, Plus, ArrowLeft, ListTree, } from 'lucide-react'; import { api } from '../../lib/api'; import { Document, DocumentBlock } from '../../lib/types'; import { buildBlockLink } from '../../lib/wikiLink'; import { SnippetRenderer } from '../../components/SnippetRenderer'; export interface LinkModalProps { isOpen: boolean; onClose: () => void; onInsertLink: (markdown: string) => void; availableDocs?: { id: string; title: string; icon?: string; folderPath?: string }[]; } export const LinkModal: React.FC = ({ isOpen, onClose, onInsertLink, availableDocs, }) => { const [tab, setTab] = useState<'note' | 'web'>('note'); const [search, setSearch] = useState(''); const [docs, setDocs] = useState([]); const [loading, setLoading] = useState(false); const [webTitle, setWebTitle] = useState(''); const [webUrl, setWebUrl] = useState(''); const searchInputRef = useRef(null); const webTitleInputRef = useRef(null); // Load documents when modal opens useEffect(() => { if (!isOpen) return; if (availableDocs && availableDocs.length > 0) { setDocs(availableDocs as Document[]); return; } let isCancelled = false; setLoading(true); api .getDocuments() .then((fetched) => { if (!isCancelled) { setDocs(fetched); setLoading(false); } }) .catch(() => { if (!isCancelled) { setLoading(false); } }); return () => { isCancelled = true; }; }, [isOpen, availableDocs]); // Focus input on mount or tab change useEffect(() => { if (!isOpen) return; const timer = setTimeout(() => { if (tab === 'note') { searchInputRef.current?.focus(); } else { webTitleInputRef.current?.focus(); } }, 50); return () => clearTimeout(timer); }, [isOpen, tab]); // Close on Escape useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); onClose(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [isOpen, onClose]); const filteredDocs = useMemo(() => { if (!search.trim()) return docs; const q = search.toLowerCase(); return docs.filter( (d) => d.title.toLowerCase().includes(q) || (d.folderPath && d.folderPath.toLowerCase().includes(q)) ); }, [docs, search]); // Picking a single bullet of a note (link to that bullet) const [pickDoc, setPickDoc] = useState(null); const [pickBlocks, setPickBlocks] = useState(null); const [blockSearch, setBlockSearch] = useState(''); useEffect(() => { if (!isOpen) { setPickDoc(null); setPickBlocks(null); setBlockSearch(''); } }, [isOpen]); const openBlockPicker = (doc: Document) => { setPickDoc(doc); setBlockSearch(''); if (doc.blocks && doc.blocks.length > 0) { setPickBlocks(doc.blocks); return; } setPickBlocks(null); api .getDocument(doc.id) .then((full) => setPickBlocks(full.blocks || [])) .catch(() => setPickBlocks([])); }; const pickableBlocks = useMemo(() => { const q = blockSearch.trim().toLowerCase(); return (pickBlocks || []).filter( (b) => b.content.trim() !== '' && b.type !== 'divider' && (!q || b.content.toLowerCase().includes(q)) ); }, [pickBlocks, blockSearch]); // Bullets of all notes matching the search (full-text index, like the global search), from 3 letters type BlockHit = { docId: string; blockId: string; title: string; icon?: string; snippet: string; content: string }; const [blockHits, setBlockHits] = useState([]); const [blockHitsLoading, setBlockHitsLoading] = useState(false); useEffect(() => { const q = search.trim(); if (!isOpen || tab !== 'note' || pickDoc || q.length < 3) { setBlockHits([]); setBlockHitsLoading(false); return; } let cancelled = false; setBlockHitsLoading(true); const timer = window.setTimeout(() => { api .searchAll(q) .then((res) => { if (cancelled) return; const docsById = new Map((res.documents || []).map((d) => [d.id, d])); const hits = (res.hits || []) .filter((h) => h.type === 'document' && h.blockId && h.docId) .slice(0, 8) .map((h) => { const block = docsById.get(h.docId!)?.blocks?.find((b) => b.id === h.blockId); return { docId: h.docId!, blockId: h.blockId!, title: h.title, icon: h.icon, snippet: h.snippet, content: block?.content ?? h.snippet.replace(/<\/?mark>/g, ''), }; }); setBlockHits(hits); }) .catch(() => !cancelled && setBlockHits([])) .finally(() => !cancelled && setBlockHitsLoading(false)); }, 250); return () => { cancelled = true; window.clearTimeout(timer); }; }, [search, isOpen, tab, pickDoc]); const handleSelectNote = (doc: { title: string }) => { const noteTitle = doc.title.trim() || 'Notiz'; onInsertLink(`[[${noteTitle}]]`); }; const handleWebLinkSubmit = (e: React.FormEvent) => { e.preventDefault(); let url = webUrl.trim(); if (!url) return; if (!/^https?:\/\//i.test(url) && !url.startsWith('#') && !url.startsWith('/')) { url = `https://${url}`; } const label = webTitle.trim() || url; onInsertLink(`[${label}](${url})`); }; if (!isOpen) return null; return (
{ if (e.target === e.currentTarget) onClose(); }} >
{/* Header - identical to UnifiedShareModal */}

Link einfügen

Notiz intern verknüpfen oder externen Web-Link einbetten

{/* Control Box - matching UnifiedShareModal form header */}
Link-Typ:
{tab === 'note' ? (
setSearch(e.target.value)} placeholder="Notiz suchen (Titel oder Ordner)…" className="input-field bg-panel h-8 pl-8 pr-2.5 text-xs w-full text-main placeholder:text-muted" />
) : (
setWebTitle(e.target.value)} placeholder="z. B. MindSift Dokumentation" className="input-field bg-panel h-8 px-2.5 text-xs w-full text-main placeholder:text-muted" />
setWebUrl(e.target.value)} placeholder="https://example.com" className="input-field bg-panel h-8 px-2.5 text-xs w-full text-main placeholder:text-muted" />
)}
{/* Bullets of one note: link to a single bullet */} {tab === 'note' && pickDoc && (
Punkt in „{pickDoc.title || 'Unbenannte Notiz'}“ wählen
setBlockSearch(e.target.value)} placeholder="Punkte filtern…" className="input-field w-full h-11 sm:h-8 px-2.5 text-xs" aria-label="Punkte filtern" />
{pickBlocks === null ? (
Punkte werden geladen…
) : pickableBlocks.length === 0 ? (
Keine passenden Punkte.
) : ( pickableBlocks.map((b) => ( )) )}
)} {/* Note List Section (Matching Active Shares list in UnifiedShareModal) */} {tab === 'note' && !pickDoc && (
Verfügbare Notizen {filteredDocs.length} {filteredDocs.length === 1 ? 'Notiz' : 'Notizen'}
{loading ? (
Notizen werden geladen…
) : filteredDocs.length === 0 ? (
{search ? `Keine Notiz für "${search}" gefunden.` : 'Keine Notizen im Workspace vorhanden.'}
) : ( filteredDocs.map((d) => (
handleSelectNote(d)} className="flex items-center justify-between p-2.5 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors group cursor-pointer" >
{d.icon && {d.icon}}
{d.title || 'Unbenannte Notiz'}
{d.folderPath && (
📁 {d.folderPath}
)}
)) )}
{/* Matching bullets of all notes: a click links that bullet */} {search.trim().length >= 3 && (blockHitsLoading || blockHits.length > 0) && ( <>
Punkte {blockHitsLoading && }
{blockHits.map((h) => ( ))}
)}
)}
); };