feat(workspace): real-time live child block resolution and interactive checkboxes in hashtag overview
MindSift CI & Docker Release / Build, Test in Container & Push (push) Failing after 18s

This commit is contained in:
sandrodach committed 2026-10-27 07:27:12 +01:00
1 parent 149cfdc244
commit 7a8d759bf2
1 file changed
+223 -73
+223 -73
View File
@@ -812,6 +812,87 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return count; return count;
}, [docs]); }, [docs]);
// Real-time matched blocks and all nested children for the selected hashtag
const activeTagGroups = useMemo(() => {
if (!selectedTag) return [];
const cleanTag = selectedTag.toLowerCase();
const map = new Map<
string,
{
docId: string;
title: string;
icon: string;
folderPath: string;
matchedBlocks: { rootBlock: DocumentBlock; children: DocumentBlock[] }[];
}
>();
for (const d of docs) {
const blks = d.blocks || [];
if (blks.length === 0) continue;
const matched: { rootBlock: DocumentBlock; children: DocumentBlock[] }[] = [];
const processedChildIds = new Set<string>();
for (let i = 0; i < blks.length; i++) {
const b = blks[i];
if (processedChildIds.has(b.id)) continue;
const content = b.content || '';
const regex = new RegExp(`(?:^|[\\s(])#(${cleanTag})(?=[\\s.,!?:;)#\\]}]|$)`, 'i');
if (regex.test(content)) {
const children: DocumentBlock[] = [];
const rootLevel = b.level || 0;
for (let j = i + 1; j < blks.length; j++) {
const nextB = blks[j];
if ((nextB.level || 0) > rootLevel) {
children.push(nextB);
processedChildIds.add(nextB.id);
} else {
break;
}
}
matched.push({ rootBlock: b, children });
}
}
if (matched.length > 0) {
map.set(d.id, {
docId: d.id,
title: d.title || 'Unbenannte Notiz',
icon: d.icon || '📝',
folderPath: d.folderPath || '',
matchedBlocks: matched,
});
}
}
if (tagDetails?.documents) {
for (const dg of tagDetails.documents) {
if (!map.has(dg.docId)) {
map.set(dg.docId, {
docId: dg.docId,
title: dg.title || 'Unbenannte Notiz',
icon: dg.icon || '📝',
folderPath: dg.folderPath || '',
matchedBlocks: dg.matchedBlocks || [],
});
}
}
}
return Array.from(map.values());
}, [docs, selectedTag, tagDetails]);
const activeTagTotalMatches = useMemo(() => {
let count = 0;
for (const g of activeTagGroups) {
count += g.matchedBlocks.length;
}
return count;
}, [activeTagGroups]);
const selectedDocObj = useMemo( const selectedDocObj = useMemo(
() => docs.find((d) => d.id === selectedDocId) || null, () => docs.find((d) => d.id === selectedDocId) || null,
[docs, selectedDocId] [docs, selectedDocId]
@@ -1143,87 +1224,156 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{/* Matched Document Blocks */} {/* Matched Document Blocks */}
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-[10px] font-bold uppercase tracking-wider text-faint"> <h3 className="text-[10px] font-bold uppercase tracking-wider text-faint">
Verknüpfte Outliner-Blöcke & Unterpunkte ({tagDetails?.totalMatches || 0}) Verknüpfte Outliner-Blöcke & Unterpunkte ({activeTagTotalMatches})
</h3> </h3>
{tagDetails?.documents.map((docGroup) => ( {activeTagGroups.length === 0 ? (
<div <div className="p-8 text-center text-muted text-xs space-y-1 border border-border rounded-card bg-canvas">
key={docGroup.docId} <p className="font-semibold text-main">Keine Blöcke mit #{selectedTag} gefunden.</p>
className="p-3 rounded-card border border-border bg-canvas space-y-2" </div>
> ) : (
<button activeTagGroups.map((docGroup) => {
type="button" const docObj = docs.find((d) => d.id === docGroup.docId);
onClick={() => handleJumpToDocBlock(docGroup.docId)} return (
className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer" <div
> key={docGroup.docId}
<span>{docGroup.icon || '📝'}</span> className="p-3 rounded-card border border-border bg-canvas space-y-2"
<span>{docGroup.title}</span> >
{docGroup.folderPath && ( <div className="flex items-center justify-between">
<span className="badge-neutral text-[10px] font-normal"> <button
📁 {docGroup.folderPath} type="button"
</span> onClick={() => handleJumpToDocBlock(docGroup.docId)}
)} className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer"
</button>
<div className="space-y-2 pl-3 border-l-2 border-border/70">
{docGroup.matchedBlocks.map((mb) => (
<div key={mb.rootBlock.id} className="space-y-1">
{/* Root Bullet Point containing the hashtag */}
<div
onClick={() => handleJumpToDocBlock(docGroup.docId, mb.rootBlock.id)}
className="flex items-start gap-1.5 text-xs text-main font-medium group cursor-pointer hover:bg-panel-elevated p-1 rounded-btn transition-colors"
title="Klick: Zu dieser Zeile springen"
> >
<span className="text-accent font-bold select-none">•</span> <span>{docGroup.icon || '📝'}</span>
<span className="flex-1 break-words">{mb.rootBlock.content}</span> <span>{docGroup.title}</span>
</div> {docGroup.folderPath && (
<span className="badge-neutral text-[10px] font-normal">
📁 {docGroup.folderPath}
</span>
)}
</button>
<button
type="button"
onClick={() => handleJumpToDocBlock(docGroup.docId)}
className="text-[10px] text-accent hover:underline px-1.5 py-0.5 rounded-btn hover:bg-canvas cursor-pointer flex items-center gap-1"
title="Notiz öffnen"
>
<span>Notiz öffnen</span>
<ExternalLink className="w-2.5 h-2.5" />
</button>
</div>
{/* All Children indented */} <div className="space-y-2 pl-3 border-l-2 border-border/70">
{mb.children && mb.children.length > 0 && ( {docGroup.matchedBlocks.map((mb) => (
<div className="space-y-0.5"> <div key={mb.rootBlock.id} className="space-y-1">
{mb.children.map((ch) => { {/* Root Bullet Point containing the hashtag */}
const relLevel = Math.max( <div
1, onClick={() => handleJumpToDocBlock(docGroup.docId, mb.rootBlock.id)}
(ch.level || 0) - (mb.rootBlock.level || 0) className="flex items-start justify-between gap-1.5 text-xs text-main font-medium group cursor-pointer hover:bg-panel-elevated p-1.5 rounded-btn transition-colors"
); title="Klick: Zu dieser Zeile springen und markieren"
return ( >
<div <div className="flex items-start gap-2 flex-1 min-w-0">
key={ch.id} {mb.rootBlock.type === 'todo' ? (
onClick={() => handleJumpToDocBlock(docGroup.docId, ch.id)} <button
style={{ paddingLeft: `${relLevel * 16}px` }} type="button"
className="flex items-start gap-1.5 text-xs text-muted hover:text-main group cursor-pointer hover:bg-panel-elevated p-1 rounded-btn transition-colors" onClick={(e) => {
title="Klick: Zu dieser Zeile springen" e.stopPropagation();
> if (docObj) {
{ch.type === 'todo' ? ( handleToggleRollupTodo(
<span docObj,
className={`w-3.5 h-3.5 mt-0.5 checkbox-square flex items-center justify-center shrink-0 border ${ mb.rootBlock.id,
ch.checked Boolean(mb.rootBlock.checked)
? 'bg-status-ok border-status-ok text-on-accent' );
: 'border-strong text-transparent' }
}`} }}
> className={`w-3.5 h-3.5 mt-0.5 checkbox-square flex items-center justify-center shrink-0 border cursor-pointer ${
<Check className="w-2.5 h-2.5 stroke-[3]" /> mb.rootBlock.checked
</span> ? 'bg-status-ok border-status-ok text-on-accent'
) : ( : 'border-strong hover:border-accent text-transparent'
<span className="text-faint group-hover:text-accent font-bold select-none">
◦
</span>
)}
<span
className={`flex-1 break-words ${
ch.checked ? 'line-through text-faint' : ''
}`} }`}
> >
{ch.content} <Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
) : (
<span className="text-accent font-bold select-none text-sm leading-none mt-0.5">
•
</span> </span>
</div> )}
); <span
})} className={`flex-1 break-words ${
mb.rootBlock.checked ? 'line-through text-faint' : ''
}`}
>
{mb.rootBlock.content}
</span>
</div>
<ExternalLink className="w-3 h-3 text-faint group-hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5" />
</div>
{/* All Children indented */}
{mb.children && mb.children.length > 0 && (
<div className="space-y-0.5">
{mb.children.map((ch) => {
const relLevel = Math.max(
1,
(ch.level || 0) - (mb.rootBlock.level || 0)
);
return (
<div
key={ch.id}
onClick={() => handleJumpToDocBlock(docGroup.docId, ch.id)}
style={{ paddingLeft: `${relLevel * 16}px` }}
className="flex items-start justify-between gap-1.5 text-xs text-muted hover:text-main group cursor-pointer hover:bg-panel-elevated p-1 rounded-btn transition-colors"
title="Klick: Zu dieser Zeile springen und markieren"
>
<div className="flex items-start gap-2 flex-1 min-w-0">
{ch.type === 'todo' ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (docObj) {
handleToggleRollupTodo(
docObj,
ch.id,
Boolean(ch.checked)
);
}
}}
className={`w-3.5 h-3.5 mt-0.5 checkbox-square flex items-center justify-center shrink-0 border cursor-pointer ${
ch.checked
? 'bg-status-ok border-status-ok text-on-accent'
: 'border-strong hover:border-accent text-transparent'
}`}
>
<Check className="w-2.5 h-2.5 stroke-[3]" />
</button>
) : (
<span className="text-faint group-hover:text-accent font-bold select-none text-xs leading-none mt-1">
◦
</span>
)}
<span
className={`flex-1 break-words ${
ch.checked ? 'line-through text-faint' : ''
}`}
>
{ch.content}
</span>
</div>
<ExternalLink className="w-3 h-3 text-faint group-hover:text-accent opacity-0 group-hover:opacity-100 transition-opacity shrink-0 mt-0.5" />
</div>
);
})}
</div>
)}
</div> </div>
)} ))}
</div> </div>
))} </div>
</div> );
</div> })
))} )}
</div> </div>
</div> </div>
</div> </div>