feat: link dialog finds bullets of all notes; richer sample data with help notes, deep nesting and linked to-dos
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m46s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 17:23:53 +01:00
1 parent ae96aa5f2b
commit ca56220e68
12 files changed
+303 -40

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mindsift-frontend",
"version": "0.46.0",
"version": "0.47.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mindsift-frontend",
"version": "0.46.0",
"version": "0.47.0",
"license": "Unlicense",
"dependencies": {
"@fontsource-variable/inter": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mindsift-frontend",
"private": true,
"version": "0.46.0",
"version": "0.47.0",
"license": "Unlicense",
"type": "module",
"scripts": {
+1 -26
View File
@@ -10,6 +10,7 @@ import {
} from 'lucide-react';
import { SearchHit } from '../lib/types';
import { api } from '../lib/api';
import { SnippetRenderer } from './SnippetRenderer';
interface CommandPaletteProps {
isOpen: boolean;
@@ -19,32 +20,6 @@ interface CommandPaletteProps {
onOpenList: (listId: string) => void;
}
// Safely render snippet containing <mark>...</mark> without dangerouslySetInnerHTML
const SnippetRenderer: React.FC<{ snippet: string }> = ({ snippet }) => {
const parts = useMemo(() => {
if (!snippet) return [];
return snippet.split(/(<mark>[\s\S]*?<\/mark>)/g);
}, [snippet]);
return (
<span className="text-muted text-xs line-clamp-2 leading-relaxed">
{parts.map((part, i) => {
if (part.startsWith('<mark>') && part.endsWith('</mark>')) {
const text = part.slice(6, -7);
return (
<mark
key={i}
className="bg-accent-dim text-accent font-semibold px-0.5 rounded"
>
{text}
</mark>
);
}
return <span key={i}>{part}</span>;
})}
</span>
);
};
export const CommandPalette: React.FC<CommandPaletteProps> = ({
isOpen,
@@ -0,0 +1,28 @@
import React, { useMemo } from 'react';
// Safely render snippet containing <mark>...</mark> without dangerouslySetInnerHTML
export const SnippetRenderer: React.FC<{ snippet: string }> = ({ snippet }) => {
const parts = useMemo(() => {
if (!snippet) return [];
return snippet.split(/(<mark>[\s\S]*?<\/mark>)/g);
}, [snippet]);
return (
<span className="text-muted text-xs line-clamp-2 leading-relaxed">
{parts.map((part, i) => {
if (part.startsWith('<mark>') && part.endsWith('</mark>')) {
const text = part.slice(6, -7);
return (
<mark
key={i}
className="bg-accent-dim text-accent font-semibold px-0.5 rounded"
>
{text}
</mark>
);
}
return <span key={i}>{part}</span>;
})}
</span>
);
};
@@ -14,6 +14,7 @@ import {
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;
@@ -134,6 +135,50 @@ export const LinkModal: React.FC<LinkModalProps> = ({
);
}, [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<BlockHit[]>([]);
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}]]`);
@@ -399,6 +444,33 @@ export const LinkModal: React.FC<LinkModalProps> = ({
))
)}
</div>
{/* Matching bullets of all notes: a click links that bullet */}
{search.trim().length >= 3 && (blockHitsLoading || blockHits.length > 0) && (
<>
<div className="flex items-center justify-between text-xs font-semibold text-muted uppercase tracking-wider px-0.5 pt-1">
<span>Punkte</span>
{blockHitsLoading && <Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />}
</div>
<div className="space-y-1.5 max-h-56 overflow-y-auto pr-0.5">
{blockHits.map((h) => (
<button
key={`${h.docId}:${h.blockId}`}
type="button"
onClick={() => onInsertLink(buildBlockLink(h.title || 'Notiz', h.blockId, h.content))}
className="w-full text-left p-2.5 min-h-11 bg-canvas border border-border rounded-card hover:border-accent/60 transition-colors cursor-pointer space-y-0.5"
title="Diesen Punkt verlinken"
>
<SnippetRenderer snippet={h.snippet} />
<span className="block text-[10px] text-faint truncate">
{h.icon ? `${h.icon} ` : ''}
{h.title || 'Unbenannte Notiz'}
</span>
</button>
))}
</div>
</>
)}
</div>
)}
</div>