feat(workspace): add /link slash command with note selector and web link modal
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 58s
This commit is contained in:
1 parent
1bcf6b4f18
commit
06f5f7043c
2 files changed
+345
-5
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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user