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
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:
1 parent
ca56220e68
commit
8238c8a73f
9 files changed
+262
-176
No files matched your search
@@ -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' ? (
|
||||
|
||||
Reference in new issue
Block a user