Dev #7

Merged
sandrodach merged 33 commits from dev into main 2026-10-27 11:42:12 +01:00
2 changed files with 345 additions and 5 deletions
Showing only changes of commit 06f5f7043c - Show all commits

No files matched your search

@@ -0,0 +1,271 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { Link as LinkIcon, FileText, Globe, X, Search, ExternalLink, Loader2 } from 'lucide-react';
import { api } from '../../lib/api';
import { Document } from '../../lib/types';
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<LinkModalProps> = ({
isOpen,
onClose,
onInsertLink,
availableDocs,
}) => {
const [tab, setTab] = useState<'note' | 'web'>('note');
const [search, setSearch] = useState('');
const [docs, setDocs] = useState<Document[]>([]);
const [loading, setLoading] = useState(false);
const [webTitle, setWebTitle] = useState('');
const [webUrl, setWebUrl] = useState('');
const searchInputRef = useRef<HTMLInputElement | null>(null);
const webTitleInputRef = useRef<HTMLInputElement | null>(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]);
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 (
<div
className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4 backdrop-blur-xs animate-in fade-in duration-100"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="panel-surface shadow-panel border border-border rounded-card max-w-md w-full overflow-hidden flex flex-col max-h-[85vh]">
{/* Modal Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border bg-panel">
<div className="flex items-center gap-2">
<LinkIcon className="w-4 h-4 text-accent" />
<h3 className="text-sm font-bold text-main">Link einfügen</h3>
</div>
<button
type="button"
onClick={onClose}
className="p-1 rounded-btn text-muted hover:text-main hover:bg-panel-elevated transition-colors"
title="Schließen (Esc)"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Tab Switcher */}
<div className="flex border-b border-border bg-panel-elevated/40 p-1 gap-1">
<button
type="button"
onClick={() => setTab('note')}
className={`flex-1 py-1.5 px-3 rounded-btn text-xs font-semibold flex items-center justify-center gap-1.5 transition-colors ${
tab === 'note'
? 'bg-accent text-on-accent shadow-sm'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
>
<FileText className="w-3.5 h-3.5" />
<span>Notiz verknüpfen</span>
</button>
<button
type="button"
onClick={() => setTab('web')}
className={`flex-1 py-1.5 px-3 rounded-btn text-xs font-semibold flex items-center justify-center gap-1.5 transition-colors ${
tab === 'web'
? 'bg-accent text-on-accent shadow-sm'
: 'text-muted hover:text-main hover:bg-panel-elevated'
}`}
>
<Globe className="w-3.5 h-3.5" />
<span>Web-Link</span>
</button>
</div>
{/* Modal Body */}
<div className="p-4 flex-1 overflow-y-auto">
{tab === 'note' ? (
<div className="space-y-3">
<div className="relative">
<Search className="w-3.5 h-3.5 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted" />
<input
ref={searchInputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Notiz suchen…"
className="w-full bg-input border border-border focus:border-accent text-main text-xs pl-8 pr-3 py-1.5 rounded-btn focus:outline-none transition-colors"
/>
</div>
{loading ? (
<div className="py-8 flex items-center justify-center text-xs text-muted gap-2">
<Loader2 className="w-4 h-4 animate-spin text-accent" />
<span>Notizen werden geladen…</span>
</div>
) : (
<div className="max-h-60 overflow-y-auto space-y-1 pr-1">
{filteredDocs.map((d) => (
<button
key={d.id}
type="button"
onClick={() => handleSelectNote(d)}
className="w-full text-left px-3 py-2 rounded-btn hover:bg-panel-elevated flex items-center justify-between text-xs transition-colors group"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-base shrink-0">{d.icon || '📝'}</span>
<span className="font-medium text-main truncate group-hover:text-accent">
{d.title || 'Unbenannte Notiz'}
</span>
</div>
{d.folderPath && (
<span className="text-[10px] text-faint ml-2 shrink-0 truncate max-w-[100px]">
📁 {d.folderPath}
</span>
)}
</button>
))}
{filteredDocs.length === 0 && (
<div className="py-8 text-center text-xs text-muted">
{search
? `Keine Notiz für "${search}" gefunden.`
: 'Keine Notizen im Workspace vorhanden.'}
</div>
)}
</div>
)}
</div>
) : (
<form onSubmit={handleWebLinkSubmit} className="space-y-3">
<div>
<label className="block text-[11px] font-semibold text-muted mb-1">
Link-Text / Titel
</label>
<input
ref={webTitleInputRef}
type="text"
value={webTitle}
onChange={(e) => setWebTitle(e.target.value)}
placeholder="z. B. MindSift Dokumentation"
className="w-full bg-input border border-border focus:border-accent text-main text-xs px-3 py-1.5 rounded-btn focus:outline-none transition-colors"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-muted mb-1">
Web-Adresse (URL) *
</label>
<input
type="text"
required
value={webUrl}
onChange={(e) => setWebUrl(e.target.value)}
placeholder="https://example.com"
className="w-full bg-input border border-border focus:border-accent text-main text-xs px-3 py-1.5 rounded-btn focus:outline-none transition-colors"
/>
</div>
<div className="pt-2 flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="btn-action px-3 py-1.5 text-xs text-muted hover:text-main"
>
Abbrechen
</button>
<button
type="submit"
disabled={!webUrl.trim()}
className="btn-primary inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-on-accent rounded-btn disabled:opacity-50 transition-opacity"
>
<ExternalLink className="w-3.5 h-3.5" />
<span>Link einfügen</span>
</button>
</div>
</form>
)}
</div>
</div>
</div>
);
};
@@ -15,12 +15,15 @@ import {
Hash, Hash,
ExternalLink, ExternalLink,
Maximize2, Maximize2,
Link as LinkIcon,
FileText,
} from 'lucide-react'; } from 'lucide-react';
import { DocumentBlock, BlockType } from '../../lib/types'; import { DocumentBlock, BlockType } from '../../lib/types';
import { RemoteCursor } from '../../lib/useDocumentSync'; import { RemoteCursor } from '../../lib/useDocumentSync';
import { LinkModal } from './LinkModal';
export interface SlashCommand { export interface SlashCommand {
type: BlockType; type: BlockType | 'link';
label: string; label: string;
desc: string; desc: string;
icon: React.ReactNode; icon: React.ReactNode;
@@ -28,6 +31,7 @@ export interface SlashCommand {
const SLASH_COMMANDS: SlashCommand[] = [ const SLASH_COMMANDS: SlashCommand[] = [
{ type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> }, { type: 'todo', label: '/todo', desc: 'Aufgabe mit Checkbox', icon: <ListTodo className="w-3.5 h-3.5" /> },
{ type: 'link', label: '/link', desc: 'Notiz verknüpfen oder Web-Link einfügen', icon: <LinkIcon className="w-3.5 h-3.5" /> },
{ type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 className="w-3.5 h-3.5" /> }, { type: 'h1', label: '/h1', desc: 'Große Überschrift', icon: <Heading1 className="w-3.5 h-3.5" /> },
{ type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: <Heading2 className="w-3.5 h-3.5" /> }, { type: 'h2', label: '/h2', desc: 'Abschnitts-Überschrift', icon: <Heading2 className="w-3.5 h-3.5" /> },
{ type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: <Heading3 className="w-3.5 h-3.5" /> }, { type: 'h3', label: '/h3', desc: 'Unterabschnitt', icon: <Heading3 className="w-3.5 h-3.5" /> },
@@ -89,6 +93,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const [localContent, setLocalContent] = useState<string>(block.content || ''); const [localContent, setLocalContent] = useState<string>(block.content || '');
const [slashSearch, setSlashSearch] = useState<string | null>(null); const [slashSearch, setSlashSearch] = useState<string | null>(null);
const [slashIndex, setSlashIndex] = useState(0); const [slashIndex, setSlashIndex] = useState(0);
const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
const textareaRef = useRef<HTMLTextAreaElement | null>(null); const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const debounceTimerRef = useRef<number | null>(null); const debounceTimerRef = useRef<number | null>(null);
@@ -158,6 +163,11 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const applySlashCommand = (cmd: SlashCommand) => { const applySlashCommand = (cmd: SlashCommand) => {
setSlashSearch(null); setSlashSearch(null);
const clean = localContent.replace(/\/([a-z0-9]*)$/i, '').trim(); const clean = localContent.replace(/\/([a-z0-9]*)$/i, '').trim();
if (cmd.type === 'link') {
setLocalContent(clean);
setShowLinkModal(true);
return;
}
setLocalContent(clean); setLocalContent(clean);
flushDebounce(clean, { flushDebounce(clean, {
type: cmd.type, type: cmd.type,
@@ -165,6 +175,16 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
}); });
}; };
const handleInsertLink = (linkMarkdown: string) => {
setShowLinkModal(false);
const cursor = textareaRef.current?.selectionStart ?? localContent.length;
const before = localContent.slice(0, cursor);
const after = localContent.slice(cursor);
const newContent = `${before}${before && !before.endsWith(' ') ? ' ' : ''}${linkMarkdown}${after && !after.startsWith(' ') ? ' ' : ''}${after}`.trim();
setLocalContent(newContent);
flushDebounce(newContent);
};
const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleContentChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
if (!canEdit) return; if (!canEdit) return;
let val = e.target.value; let val = e.target.value;
@@ -339,14 +359,20 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const level = Math.max(0, block.level || 0); const level = Math.max(0, block.level || 0);
const visualLevel = Math.max(0, level - (levelOffset || 0)); const visualLevel = Math.max(0, level - (levelOffset || 0));
// Parse rich tokens (WikiLinks [[Page]] and #Tags) // Parse rich tokens (WikiLinks [[Page]], Markdown Links [Title](url), raw URLs, and #Tags)
const tokens = useMemo(() => { const tokens = useMemo(() => {
const parts = (localContent || '').split(/(\[\[.*?\]\]|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g); const parts = (localContent || '').split(
/(\[\[.*?\]\]|\[.*?\]\(https?:\/\/.*?\)|\bhttps?:\/\/[^\s]+|#[a-zA-Z0-9äöüÄÖÜß_-]+)/g
);
return parts.filter(Boolean); return parts.filter(Boolean);
}, [localContent]); }, [localContent]);
const hasSpecialTokens = tokens.some( const hasSpecialTokens = tokens.some(
(t) => t.startsWith('#') || (t.startsWith('[[') && t.endsWith(']]')) (t) =>
t.startsWith('#') ||
(t.startsWith('[[') && t.endsWith(']]')) ||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
/^https?:\/\//.test(t)
); );
return ( return (
@@ -619,12 +645,46 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
type="button" type="button"
onClick={() => onWikiLinkClick?.(page)} onClick={() => onWikiLinkClick?.(page)}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-info bg-status-info/10 hover:bg-status-info/20 rounded-btn transition-colors" className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-info bg-status-info/10 hover:bg-status-info/20 rounded-btn transition-colors"
title={`Notiz öffnen: ${page}`}
> >
<ExternalLink className="w-2.5 h-2.5 opacity-80" /> <FileText className="w-2.5 h-2.5 opacity-80" />
<span>{page}</span> <span>{page}</span>
</button> </button>
); );
} }
const mdMatch = part.match(/^\[(.*?)\]\((https?:\/\/.*?)\)$/);
if (mdMatch) {
const label = mdMatch[1];
const url = mdMatch[2];
return (
<a
key={idx}
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
title={url}
>
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
<span>{label || url}</span>
</a>
);
}
if (/^https?:\/\//.test(part)) {
return (
<a
key={idx}
href={part}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-status-ok bg-status-ok/10 hover:bg-status-ok/20 rounded-btn transition-colors"
title={part}
>
<ExternalLink className="w-2.5 h-2.5 opacity-80" />
<span className="truncate max-w-[160px]">{part}</span>
</a>
);
}
return null; return null;
})} })}
</div> </div>
@@ -689,6 +749,15 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
</button> </button>
)} )}
{/* Link Insertion Modal */}
{showLinkModal && (
<LinkModal
isOpen={showLinkModal}
onClose={() => setShowLinkModal(false)}
onInsertLink={handleInsertLink}
/>
)}
</div> </div>
); );
}; };