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
@@ -240,6 +240,9 @@ export const HelpView: React.FC<HelpViewProps> = ({ onBack }) => {
|
||||
<li>
|
||||
<strong className="text-main">Ansicht „Tags & Filter“:</strong> Im oberen Workspace-Reiter findest du die Tag-Übersicht. Hier siehst du alle verwendeten Hashtags, kannst Fundstellen aus allen Dokumenten gebündelt durchsuchen und sogar eigene freie Notizen auf Tag-Seiten hinterlegen.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Rückverweise:</strong> Am Ende jeder Notiz siehst du unter „Verlinkt auf diese Notiz“, welche anderen Notizen sie mit <code className="text-accent bg-panel px-1 py-0.5 rounded font-mono">[[Titel]]</code> verlinken. Ein Klick auf die Fundstelle springt direkt dorthin.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-main">Papierkorb:</strong> Gelöschte Notizen (auch aus gelöschten Ordnern) landen im „Papierkorb“ unten in der Ordnerleiste bzw. im mobilen Menü. Dort kannst du sie schreibgeschützt ansehen, wiederherstellen oder endgültig löschen. Solange eine Notiz im Papierkorb liegt, sehen sie auch die Personen nicht, mit denen sie geteilt war.
|
||||
</li>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DashboardStats,
|
||||
Document,
|
||||
TrashedDocument,
|
||||
Backlink,
|
||||
DocumentBlock,
|
||||
DocShare,
|
||||
KanbanColumn,
|
||||
@@ -653,6 +654,10 @@ export const api = {
|
||||
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async getBacklinks(id: string): Promise<Backlink[]> {
|
||||
return request<Backlink[]>(`/api/v1/documents/${id}/backlinks`);
|
||||
},
|
||||
|
||||
async getTrash(): Promise<TrashedDocument[]> {
|
||||
return request<TrashedDocument[]>('/api/v1/documents/trash');
|
||||
},
|
||||
|
||||
@@ -232,6 +232,16 @@ export interface DocShare {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/** A note linking to the current one via [[Title]] (GET /documents/{id}/backlinks). */
|
||||
export interface Backlink {
|
||||
docId: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
folderPath: string;
|
||||
blocks: { id: string; content: string }[];
|
||||
more?: number;
|
||||
}
|
||||
|
||||
/** A note in the trash (GET /documents/trash). */
|
||||
export interface TrashedDocument {
|
||||
id: string;
|
||||
|
||||
Reference in new issue
Block a user