feat(workspace): hashtag page shows the live outliner of each note's tagged branches, edited in place
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m45s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-11-01 17:54:14 +01:00
1 parent ca56220e68
commit 8238c8a73f
9 files changed
+262 -176

No files matched your search

+57 -160
View File
@@ -58,6 +58,7 @@ import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession
import { MergeDialog } from './MergeDialog';
import { CountBadge } from '../../components/CountBadge';
import { EmojiIconButton } from '../../components/EmojiIconButton';
import { LazyMount } from '../../components/LazyMount';
import { extractHashtags } from '../../lib/hashtags';
interface WorkspaceViewProps {
@@ -1438,13 +1439,27 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
return Array.from(map.values());
}, [docs, selectedTag, tagDetails]);
const activeTagTotalMatches = useMemo(() => {
let count = 0;
for (const g of activeTagGroups) {
count += g.matchedBlocks.length;
// The hashtag page keeps its order while open: a note whose last hashtag was just removed stays in its
// place (its editor then shows nothing), new matches come at the end; leaving the page starts afresh
const tagPageOrderRef = useRef<{ tag: string | null; groups: typeof activeTagGroups }>({ tag: null, groups: [] });
const tagPageGroups = useMemo(() => {
const prev = tagPageOrderRef.current;
const byId = new Map(activeTagGroups.map((g) => [g.docId, g]));
let groups = activeTagGroups;
if (prev.tag === selectedTag) {
const known = new Set(prev.groups.map((g) => g.docId));
groups = [
...prev.groups.map((g) => byId.get(g.docId) || g),
...activeTagGroups.filter((g) => !known.has(g.docId)),
];
}
return count;
}, [activeTagGroups]);
tagPageOrderRef.current = { tag: selectedTag, groups };
return groups;
}, [activeTagGroups, selectedTag]);
useEffect(() => {
if (subView !== 'tag_detail') tagPageOrderRef.current = { tag: null, groups: [] };
}, [subView]);
const selectedDocObj = useMemo(
() => docs.find((d) => d.id === selectedDocId) || null,
@@ -2386,7 +2401,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
</div>
</div>
) : subView === 'tag_detail' && selectedTag ? (
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-canvas">
<div className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8 bg-editor">
<div className="w-full space-y-4">
{/* Back to all tags */}
<div className="flex items-center justify-between border-b border-border pb-3">
@@ -2435,160 +2450,42 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
/>
</div>
{/* Matched Document Blocks */}
<div className="space-y-3">
<h3 className="text-[10px] font-bold uppercase tracking-wider text-faint">
Verknüpfte Outliner-Blöcke & Unterpunkte ({activeTagTotalMatches})
</h3>
{activeTagGroups.length === 0 ? (
<div className="p-8 text-center text-muted text-xs space-y-1 border border-border rounded-card bg-canvas">
<p className="font-semibold text-main">Keine Blöcke mit #{selectedTag} gefunden.</p>
</div>
) : (
activeTagGroups.map((docGroup) => {
const docObj = docs.find((d) => d.id === docGroup.docId);
return (
<div
key={docGroup.docId}
className="p-3 rounded-card border border-border bg-canvas space-y-2"
>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => handleJumpToDocBlock(docGroup.docId)}
className="flex items-center gap-2 text-xs font-semibold text-accent hover:underline cursor-pointer"
>
{docGroup.icon && <span>{docGroup.icon}</span>}
<span>{docGroup.title}</span>
{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>
{/* Matched bullets: each note as a fixed heading with the live outliner of its branches below,
edited right in the note (DocumentEditor variant "tag"); editors load when scrolled near */}
{tagPageGroups.length === 0 ? (
<p className="py-8 text-center text-xs text-muted">Keine Punkte mit #{selectedTag} gefunden.</p>
) : (
<div className="space-y-2 -mx-2 sm:-mx-8">
{tagPageGroups.map((docGroup) => (
<LazyMount
key={docGroup.docId}
placeholder={
<div className="px-2 sm:px-8 py-2 min-h-[6rem] text-lg font-semibold text-muted">
{docGroup.icon && <span className="mr-2">{docGroup.icon}</span>}
{docGroup.title}
</div>
<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 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"
>
<div className="flex items-start gap-2 flex-1 min-w-0">
{mb.rootBlock.type === 'todo' ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (docObj) {
handleToggleRollupTodo(
docObj,
mb.rootBlock.id,
Boolean(mb.rootBlock.checked)
);
}
}}
className={`w-3.5 h-3.5 mt-0.5 checkbox-square flex items-center justify-center shrink-0 border cursor-pointer ${
mb.rootBlock.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-accent font-bold select-none text-sm leading-none mt-0.5">
•
</span>
)}
<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>
}
>
<DocumentEditor
key={`${docGroup.docId}:${selectedTag}`}
variant="tag"
tagFilter={selectedTag}
docId={docGroup.docId}
currentUser={currentUser}
initialDoc={docs.find((d) => d.id === docGroup.docId) || null}
availableFolders={allFolderPaths}
onDocMetadataChange={handleDocMetadataChange}
onDeleteDoc={handleDeleteDocument}
onOpenShareModal={openDocShareModal}
onSelectTag={handleSelectTag}
onWikiLinkClick={handleWikiLinkClick}
onOpenDocBlock={handleJumpToDocBlock}
onOpenTask={onOpenTask}
/>
</LazyMount>
))}
</div>
)}
</div>
</div>
) : subView === 'todos' ? (