feat(workspace): stack hashtag back button above heading and add dedicated hashtag markdown note (#tag.md)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 59s

This commit is contained in:
sandrodach committed 2026-10-25 14:58:54 +01:00
1 parent f1d9daca60
commit b0403e09bc
6 files changed
+379 -166

No files matched your search

+261 -160
View File
@@ -106,6 +106,9 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const [workspaceTags, setWorkspaceTags] = useState<TagSummary[]>([]);
const [isLoadingTag, setIsLoadingTag] = useState(false);
const [tagSearchQuery, setTagSearchQuery] = useState('');
const [tagNoteContent, setTagNoteContent] = useState('');
const [tagNoteSaveState, setTagNoteSaveState] = useState<'saved' | 'saving'>('saved');
const tagNoteSaveTimerRef = useRef<any>(null);
// Collapsible sidebar sections
const [isPinnedCollapsed, setIsPinnedCollapsed] = useState(false);
@@ -412,12 +415,18 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
const openTagPage = async (tag: string) => {
const clean = tag.replace(/^#/, '').toLowerCase();
if (tagNoteSaveTimerRef.current) {
clearTimeout(tagNoteSaveTimerRef.current);
tagNoteSaveTimerRef.current = null;
}
setSelectedTag(clean);
setWorkspaceMode('tags');
setIsLoadingTag(true);
try {
const details = await api.getTagDetails(clean);
setTagDetails(details);
setTagNoteContent(details.noteContent || '');
setTagNoteSaveState('saved');
} catch (err) {
console.error('Failed to load tag details', err);
} finally {
@@ -425,6 +434,40 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
}
};
const handleTagNoteChange = (newContent: string) => {
setTagNoteContent(newContent);
if (!selectedTag) return;
const targetTag = selectedTag;
setTagNoteSaveState('saving');
if (tagNoteSaveTimerRef.current) {
clearTimeout(tagNoteSaveTimerRef.current);
}
tagNoteSaveTimerRef.current = setTimeout(async () => {
try {
await api.updateTagPageNote(targetTag, newContent);
setTagDetails((prev) =>
prev && prev.tag === targetTag ? { ...prev, noteContent: newContent } : prev
);
setTagNoteSaveState('saved');
} catch (err) {
console.error('Failed to save tag note', err);
setTagNoteSaveState('saved');
}
}, 450);
};
const handleExportTagMarkdown = () => {
if (!selectedTag) return;
const md = `# #${selectedTag}\n\n${tagNoteContent || ''}\n`;
const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `tag-${selectedTag}.md`;
a.click();
URL.revokeObjectURL(url);
};
useEffect(() => {
loadDocs(initialDocId);
}, [initialDocId]);
@@ -3605,43 +3648,45 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<div className="space-y-4">
{/* Header Toolbar */}
<div className="p-4 panel-surface space-y-3 border border-border rounded-card">
{selectedTag && (
<div>
<button
type="button"
onClick={() => setSelectedTag(null)}
className="inline-flex items-center gap-1 text-[11px] font-medium text-muted hover:text-accent transition-colors cursor-pointer"
title="Zurück zur Themen-Übersicht"
>
<ArrowLeft className="w-3 h-3 text-accent" />
<span>Alle Themen</span>
</button>
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2.5">
{selectedTag ? (
<>
<button
type="button"
onClick={() => setSelectedTag(null)}
className="btn-action h-8 px-2.5 text-xs flex items-center gap-1.5"
title="Zurück zur Themen-Übersicht"
>
<ArrowLeft className="w-3.5 h-3.5 text-accent" />
<span>Alle Themen</span>
</button>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-500 dark:text-blue-400 font-bold">
<Hash className="w-4 h-4" />
</div>
<div>
<h2 className="text-base font-bold text-main leading-tight flex items-center gap-1.5">
<span>#{selectedTag}</span>
</h2>
<p className="text-[11px] text-muted">
{isLoadingTag
? 'Wird geladen…'
: `${tagDetails?.totalMatches || 0} ${
tagDetails?.totalMatches === 1 ? 'Abschnitt' : 'Abschnitte'
} in ${tagDetails?.documents.length || 0} ${
tagDetails?.documents.length === 1 ? 'Notiz' : 'Notizen'
}`}
</p>
</div>
<div className="w-9 h-9 rounded-lg bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-500 dark:text-blue-400 font-bold flex-shrink-0">
<Hash className="w-5 h-5" />
</div>
<div>
<h2 className="text-base font-bold text-main leading-tight flex items-center gap-1.5">
<span>#{selectedTag}</span>
</h2>
<p className="text-[11px] text-muted">
{isLoadingTag
? 'Wird geladen…'
: `${tagDetails?.totalMatches || 0} ${
tagDetails?.totalMatches === 1 ? 'Abschnitt' : 'Abschnitte'
} in ${tagDetails?.documents.length || 0} ${
tagDetails?.documents.length === 1 ? 'Notiz' : 'Notizen'
}`}
</p>
</div>
</>
) : (
<div className="flex items-center gap-2.5">
<div className="w-9 h-9 rounded-lg bg-accent/10 border border-accent/30 flex items-center justify-center text-accent">
<>
<div className="w-9 h-9 rounded-lg bg-accent/10 border border-accent/30 flex items-center justify-center text-accent flex-shrink-0">
<Hash className="w-5 h-5" />
</div>
<div>
@@ -3650,7 +3695,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
{workspaceTags.length} {workspaceTags.length === 1 ? 'Thema' : 'Themen'} im gesamten Workspace
</p>
</div>
</div>
</>
)}
</div>
@@ -3760,147 +3805,203 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
<Sparkles className="w-6 h-6 animate-spin mx-auto mb-2 text-accent" />
Hashtag-Verknüpfungen werden geladen…
</div>
) : !tagDetails || tagDetails.documents.length === 0 ? (
<div className="p-12 text-center text-muted text-xs panel-surface space-y-2">
<Hash className="w-8 h-8 mx-auto text-faint" />
<p className="font-medium text-main">Keine Bullet Points mit #{selectedTag} gefunden</p>
<p className="text-xs text-faint">
Füge einfach in irgendeiner Notiz <code className="text-accent">#{selectedTag}</code> in einen Bullet Point ein.
</p>
</div>
) : (
<div className="space-y-4">
{tagDetails.documents.map((docGroup) => (
<div
key={docGroup.docId}
className="panel-surface p-4 space-y-3 border border-border rounded-card hover:border-border/80 transition-colors"
>
{/* Document Header */}
<div className="flex flex-wrap items-center justify-between gap-2 pb-2 border-b border-border">
<div className="flex items-center gap-2 min-w-0">
<span className="text-base flex-shrink-0">{docGroup.icon || '📄'}</span>
<span className="font-semibold text-xs text-main truncate">{docGroup.title || 'Unbenannt'}</span>
{docGroup.folderPath && (
<span className="badge-neutral text-[10px] flex items-center gap-1 font-mono">
<Folder className="w-2.5 h-2.5" />
<span>{docGroup.folderPath}</span>
</span>
)}
<span className="text-[10px] text-faint">
• {docGroup.matchedBlocks.length}{' '}
{docGroup.matchedBlocks.length === 1 ? 'Treffer' : 'Treffer'}
</span>
</div>
{/* Dedicated Logseq-Style Hashtag Markdown Note Card (#tag.md) */}
<div className="panel-surface p-4 space-y-2.5 border border-border rounded-card">
<div className="flex flex-wrap items-center justify-between gap-2 pb-2 border-b border-border">
<div className="flex items-center gap-2">
<FileText className="w-3.5 h-3.5 text-accent" />
<span className="text-xs font-bold text-main">
Eigene Notiz zu #{selectedTag}
</span>
<span className="badge-neutral text-[10px] font-mono">
#{selectedTag}.md
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-faint font-mono">
{tagNoteSaveState === 'saving' ? 'Speichert…' : 'Gespeichert'}
</span>
<button
type="button"
onClick={() => {
const found = docs.find((d) => d.id === docGroup.docId);
if (found) {
setSelectedDoc(found);
setZoomedBlockId(null);
setWorkspaceMode('editor');
}
}}
className="px-2.5 py-1 text-xs font-medium text-accent hover:text-main bg-accent/10 hover:bg-accent/20 rounded-btn flex items-center gap-1 transition-colors"
onClick={handleExportTagMarkdown}
className="btn-action h-6 px-2 text-[11px] flex items-center gap-1"
title={`#${selectedTag}.md herunterladen`}
>
<span>Notiz öffnen</span>
<ExternalLink className="w-3 h-3" />
<Download className="w-3 h-3" />
<span>.md</span>
</button>
</div>
</div>
{/* Matched Bullet Points and their children */}
<div className="space-y-3 pl-1">
{docGroup.matchedBlocks.map((mb, idx) => (
<div
key={mb.rootBlock.id || idx}
className="space-y-1.5 bg-canvas/40 p-3 rounded-card border border-border/50"
>
{/* Root Bullet Point */}
<div className="flex items-start gap-2 group">
{mb.rootBlock.type === 'todo' ? (
<input
type="checkbox"
checked={!!mb.rootBlock.checked}
onChange={() => handleToggleRollupTodo(docGroup.docId, mb.rootBlock.id)}
className="w-4 h-4 mt-0.5 checkbox-square cursor-pointer flex-shrink-0"
/>
) : (
<div className="w-3.5 h-3.5 mt-1 rounded-full bg-accent/20 border border-accent flex items-center justify-center flex-shrink-0">
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
</div>
<textarea
value={tagNoteContent}
onChange={(e) => handleTagNoteChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Tab') {
e.preventDefault();
const target = e.currentTarget;
const start = target.selectionStart;
const end = target.selectionEnd;
const nextVal =
tagNoteContent.slice(0, start) + ' ' + tagNoteContent.slice(end);
handleTagNoteChange(nextVal);
requestAnimationFrame(() => {
target.setSelectionRange(start + 2, start + 2);
});
}
}}
rows={Math.max(4, tagNoteContent.split('\n').length + 1)}
placeholder={`Eigene Gedanken, Zusammenfassungen oder Markdown-Notizen direkt zum Thema #${selectedTag} festhalten (unabhängig von den anderen Workspace-Notizen)…`}
className="w-full bg-canvas/50 border border-border/60 rounded-card p-3 text-xs text-main leading-relaxed focus:outline-none focus:border-accent resize-y placeholder:text-faint"
/>
</div>
{/* Linked References / Verknüpfte Bullet Points from Workspace Notes */}
{!tagDetails || tagDetails.documents.length === 0 ? (
<div className="p-10 text-center text-muted text-xs panel-surface space-y-2 border border-border rounded-card">
<Hash className="w-7 h-7 mx-auto text-faint" />
<p className="font-medium text-main">Keine verknüpften Bullet Points mit #{selectedTag} in anderen Notizen</p>
<p className="text-xs text-faint">
Füge einfach in irgendeiner Notiz <code className="text-accent">#{selectedTag}</code> in einen Bullet Point ein, damit er hier automatisch verknüpft wird.
</p>
</div>
) : (
<div className="space-y-4">
{tagDetails.documents.map((docGroup) => (
<div
key={docGroup.docId}
className="panel-surface p-4 space-y-3 border border-border rounded-card hover:border-border/80 transition-colors"
>
{/* Document Header */}
<div className="flex flex-wrap items-center justify-between gap-2 pb-2 border-b border-border">
<div className="flex items-center gap-2 min-w-0">
<span className="text-base flex-shrink-0">{docGroup.icon || '📄'}</span>
<span className="font-semibold text-xs text-main truncate">{docGroup.title || 'Unbenannt'}</span>
{docGroup.folderPath && (
<span className="badge-neutral text-[10px] flex items-center gap-1 font-mono">
<Folder className="w-2.5 h-2.5" />
<span>{docGroup.folderPath}</span>
</span>
)}
<div
onClick={() => {
const found = docs.find((d) => d.id === docGroup.docId);
if (found) {
setSelectedDoc(found);
setZoomedBlockId(mb.rootBlock.id);
setWorkspaceMode('editor');
}
}}
className={`flex-1 text-xs leading-relaxed cursor-pointer hover:text-accent transition-colors ${
mb.rootBlock.checked ? 'line-through text-faint' : 'text-main'
}`}
title="Klick: Zu diesem Bullet Point in der Notiz zoomen"
>
{renderFormattedText(mb.rootBlock.content || '(Leerer Bullet Point)')}
</div>
<span className="text-[10px] text-faint">
• {docGroup.matchedBlocks.length}{' '}
{docGroup.matchedBlocks.length === 1 ? 'Treffer' : 'Treffer'}
</span>
</div>
{/* Children / Sub-bullets */}
{mb.children.length > 0 && (
<div className="space-y-1 mt-1 pl-4 border-l-2 border-border/60 ml-2">
{mb.children.map((child) => {
const indentLevel = Math.max(
0,
(child.level || 0) - (mb.rootBlock.level || 0) - 1
);
return (
<div
key={child.id}
style={{ paddingLeft: `${indentLevel * 14}px` }}
className="flex items-start gap-2 py-0.5 text-xs text-muted"
>
{child.type === 'todo' ? (
<input
type="checkbox"
checked={!!child.checked}
onChange={() =>
handleToggleRollupTodo(docGroup.docId, child.id)
}
className="w-3.5 h-3.5 mt-0.5 checkbox-square cursor-pointer flex-shrink-0"
/>
) : (
<span className="w-1.5 h-1.5 mt-1.5 rounded-full bg-muted/60 flex-shrink-0" />
)}
<div
onClick={() => {
const found = docs.find((d) => d.id === docGroup.docId);
if (found) {
setSelectedDoc(found);
setZoomedBlockId(child.id);
setWorkspaceMode('editor');
}
}}
className={`flex-1 text-xs leading-relaxed cursor-pointer hover:text-accent transition-colors ${
child.checked ? 'line-through text-faint' : 'text-muted'
}`}
title="Klick: In Notiz fokussieren"
>
{renderFormattedText(child.content || '')}
</div>
</div>
);
})}
</div>
)}
<button
type="button"
onClick={() => {
const found = docs.find((d) => d.id === docGroup.docId);
if (found) {
setSelectedDoc(found);
setZoomedBlockId(null);
setWorkspaceMode('editor');
}
}}
className="px-2.5 py-1 text-xs font-medium text-accent hover:text-main bg-accent/10 hover:bg-accent/20 rounded-btn flex items-center gap-1 transition-colors"
>
<span>Notiz öffnen</span>
<ExternalLink className="w-3 h-3" />
</button>
</div>
))}
</div>
{/* Matched Bullet Points and their children */}
<div className="space-y-3 pl-1">
{docGroup.matchedBlocks.map((mb, idx) => (
<div
key={mb.rootBlock.id || idx}
className="space-y-1.5 bg-canvas/40 p-3 rounded-card border border-border/50"
>
{/* Root Bullet Point */}
<div className="flex items-start gap-2 group">
{mb.rootBlock.type === 'todo' ? (
<input
type="checkbox"
checked={!!mb.rootBlock.checked}
onChange={() => handleToggleRollupTodo(docGroup.docId, mb.rootBlock.id)}
className="w-4 h-4 mt-0.5 checkbox-square cursor-pointer flex-shrink-0"
/>
) : (
<div className="w-3.5 h-3.5 mt-1 rounded-full bg-accent/20 border border-accent flex items-center justify-center flex-shrink-0">
<div className="w-1.5 h-1.5 rounded-full bg-accent" />
</div>
)}
<div
onClick={() => {
const found = docs.find((d) => d.id === docGroup.docId);
if (found) {
setSelectedDoc(found);
setZoomedBlockId(mb.rootBlock.id);
setWorkspaceMode('editor');
}
}}
className={`flex-1 text-xs leading-relaxed cursor-pointer hover:text-accent transition-colors ${
mb.rootBlock.checked ? 'line-through text-faint' : 'text-main'
}`}
title="Klick: Zu diesem Bullet Point in der Notiz zoomen"
>
{renderFormattedText(mb.rootBlock.content || '(Leerer Bullet Point)')}
</div>
</div>
{/* Children / Sub-bullets */}
{mb.children.length > 0 && (
<div className="space-y-1 mt-1 pl-4 border-l-2 border-border/60 ml-2">
{mb.children.map((child) => {
const indentLevel = Math.max(
0,
(child.level || 0) - (mb.rootBlock.level || 0) - 1
);
return (
<div
key={child.id}
style={{ paddingLeft: `${indentLevel * 14}px` }}
className="flex items-start gap-2 py-0.5 text-xs text-muted"
>
{child.type === 'todo' ? (
<input
type="checkbox"
checked={!!child.checked}
onChange={() =>
handleToggleRollupTodo(docGroup.docId, child.id)
}
className="w-3.5 h-3.5 mt-0.5 checkbox-square cursor-pointer flex-shrink-0"
/>
) : (
<span className="w-1.5 h-1.5 mt-1.5 rounded-full bg-muted/60 flex-shrink-0" />
)}
<div
onClick={() => {
const found = docs.find((d) => d.id === docGroup.docId);
if (found) {
setSelectedDoc(found);
setZoomedBlockId(child.id);
setWorkspaceMode('editor');
}
}}
className={`flex-1 text-xs leading-relaxed cursor-pointer hover:text-accent transition-colors ${
child.checked ? 'line-through text-faint' : 'text-muted'
}`}
title="Klick: In Notiz fokussieren"
>
{renderFormattedText(child.content || '')}
</div>
</div>
);
})}
</div>
)}
</div>
))}
</div>
</div>
))}
</div>
))}
)}
</div>
)}
</div>
+14
View File
@@ -442,6 +442,20 @@ export const api = {
};
},
async updateTagPageNote(
tag: string,
content: string
): Promise<{ tag: string; content: string; updatedAt: string }> {
const clean = encodeURIComponent(tag.replace(/^#/, ''));
return request<{ tag: string; content: string; updatedAt: string }>(
`/api/v1/workspace/tags/${clean}/note`,
{
method: 'PUT',
body: JSON.stringify({ content }),
}
);
},
// --- WORKSPACE DOCUMENTS ---
async getDocuments(): Promise<Document[]> {
const res = await request<Document[]>('/api/v1/documents');
+2
View File
@@ -223,6 +223,8 @@ export interface TagDocumentGroup {
export interface TagDetails {
tag: string;
noteContent?: string;
noteUpdatedAt?: string;
documents: TagDocumentGroup[];
totalMatches: number;
}