feat(workspace): backlinks at the end of a note
GET /documents/{id}/backlinks lists the notes the user can open whose
blocks link [[Title]] (also |alias and #section, case-insensitive),
excluding the note itself and the trash. The collapsible "Verlinkt auf
diese Notiz" section shows each note with folder and the linking bullets;
a click jumps straight to the bullet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f69f74fdc7
commit
677fe0fc0a
11 files changed
+303
No files matched your search
@@ -0,0 +1,118 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, Folder, Link2 } from 'lucide-react';
|
||||
import { Backlink } from '../../lib/types';
|
||||
import { api } from '../../lib/api';
|
||||
|
||||
interface BacklinksSectionProps {
|
||||
docId: string;
|
||||
title: string;
|
||||
onOpen: (docId: string, blockId?: string) => void;
|
||||
}
|
||||
|
||||
const COLLAPSED_KEY = 'mindsift:backlinks_collapsed';
|
||||
|
||||
// Shows the [[Wiki-Link]] parts of a block in the accent color, the rest as plain text
|
||||
const Snippet: React.FC<{ text: string }> = ({ text }) => (
|
||||
<>
|
||||
{text.split(/(\[\[[^\]]*\]\])/g).map((part, i) =>
|
||||
part.startsWith('[[') && part.endsWith(']]') ? (
|
||||
<span key={i} className="text-accent font-medium">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
<React.Fragment key={i}>{part}</React.Fragment>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// "Verlinkt auf diese Notiz" at the end of a note. Hidden while nothing links here; reloads shortly
|
||||
// after the title changes, since links are matched by title.
|
||||
export const BacklinksSection: React.FC<BacklinksSectionProps> = ({ docId, title, onOpen }) => {
|
||||
const [links, setLinks] = useState<Backlink[]>([]);
|
||||
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(COLLAPSED_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
api
|
||||
.getBacklinks(docId)
|
||||
.then((res) => !cancelled && setLinks(res))
|
||||
.catch(() => !cancelled && setLinks([]));
|
||||
}, 400);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [docId, title]);
|
||||
|
||||
if (links.length === 0) return null;
|
||||
|
||||
const toggle = () => {
|
||||
setCollapsed((prev) => {
|
||||
try {
|
||||
localStorage.setItem(COLLAPSED_KEY, String(!prev));
|
||||
} catch {}
|
||||
return !prev;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="mt-10 pt-4 border-t border-border" aria-label="Rückverweise">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={!collapsed}
|
||||
className="w-full flex items-center gap-1.5 min-h-[44px] sm:min-h-0 py-1 text-xs font-semibold text-muted hover:text-main cursor-pointer"
|
||||
>
|
||||
{collapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||
<Link2 className="w-3.5 h-3.5 text-accent" />
|
||||
<span>Verlinkt auf diese Notiz ({links.length})</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{links.map((l) => (
|
||||
<li key={l.docId} className="p-3 bg-panel border border-border rounded-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(l.docId)}
|
||||
className="flex items-center gap-2 min-w-0 max-w-full text-left cursor-pointer group"
|
||||
>
|
||||
<span className="shrink-0">{l.icon || '📝'}</span>
|
||||
<span className="text-sm font-semibold text-main truncate group-hover:text-accent">
|
||||
{l.title || 'Unbenannte Notiz'}
|
||||
</span>
|
||||
{l.folderPath && (
|
||||
<span className="hidden sm:inline-flex items-center gap-1 text-[11px] text-faint min-w-0">
|
||||
<Folder className="w-3 h-3 shrink-0" />
|
||||
<span className="truncate">{l.folderPath}</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{l.blocks.map((b) => (
|
||||
<li key={b.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(l.docId, b.id)}
|
||||
className="w-full text-left text-xs text-muted hover:text-main px-2 py-1.5 rounded-btn hover:bg-panel-elevated border-l-2 border-border line-clamp-3 break-words cursor-pointer"
|
||||
>
|
||||
<Snippet text={b.content} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!!l.more && <p className="mt-1 text-[11px] text-faint">… und {l.more} weitere Stellen</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -41,12 +41,14 @@ export interface DocumentEditorProps {
|
||||
onOpenShareModal: (doc: Document) => void;
|
||||
onSelectTag: (tag: string) => void;
|
||||
onWikiLinkClick?: (pageTitle: string) => void;
|
||||
onOpenDocBlock?: (docId: string, blockId?: string) => void;
|
||||
onBackMobile?: () => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
onSaveStatusChange?: (status: DocSaveStatus, errorMsg?: string) => void;
|
||||
}
|
||||
|
||||
import { EmojiPickerModal } from './EmojiPickerModal';
|
||||
import { BacklinksSection } from './BacklinksSection';
|
||||
|
||||
function ensureNonEmptyBlocks(docId: string, blocks?: DocumentBlock[]): DocumentBlock[] {
|
||||
if (!blocks || blocks.length === 0) {
|
||||
@@ -182,6 +184,7 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onDocMetadataChange,
|
||||
onDeleteDoc,
|
||||
onRestoreDoc,
|
||||
onOpenDocBlock,
|
||||
onOpenShareModal,
|
||||
onSelectTag,
|
||||
onWikiLinkClick,
|
||||
@@ -1070,6 +1073,8 @@ export const DocumentEditor: React.FC<DocumentEditorProps> = ({
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
/>
|
||||
|
||||
{onOpenDocBlock && !inTrash && <BacklinksSection docId={docId} title={title} onOpen={onOpenDocBlock} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1583,6 +1583,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
onOpenShareModal={openDocShareModal}
|
||||
onSelectTag={handleSelectTag}
|
||||
onWikiLinkClick={handleWikiLinkClick}
|
||||
onOpenDocBlock={handleJumpToDocBlock}
|
||||
onBackMobile={() => setMobileShowDetail(false)}
|
||||
onOpenTask={onOpenTask}
|
||||
onSaveStatusChange={(status, err) => {
|
||||
|
||||
Reference in new issue
Block a user