Implements two key knowledge module features: **#62 - Backlinks Display:** - Added BacklinksList component to show entries that link to current entry - Fetches backlinks from /api/knowledge/entries/:slug/backlinks - Displays entry title, summary, and link context - Clickable links to navigate to linking entries - Loading, error, and empty states **#64 - Wiki-Link Rendering:** - Added WikiLinkRenderer component to parse and render wiki-links - Supports [[slug]] and [[slug|display text]] syntax - Converts wiki-links to clickable navigation links - Distinct styling (blue color, dotted underline) - XSS protection via HTML escaping - Memoized HTML processing for performance **Components:** - BacklinksList.tsx - Backlinks display with empty/loading/error states - WikiLinkRenderer.tsx - Wiki-link parser and renderer - Updated EntryViewer.tsx to use WikiLinkRenderer - Integrated BacklinksList into entry detail page **API:** - Added fetchBacklinks() function in knowledge.ts - Added KnowledgeBacklink type to shared types **Tests:** - Comprehensive tests for BacklinksList (8 tests) - Comprehensive tests for WikiLinkRenderer (14 tests) - All tests passing with Vitest **Type Safety:** - Strict TypeScript compliance - No 'any' types - Proper error handling
This commit is contained in:
160
apps/web/src/components/knowledge/BacklinksList.tsx
Normal file
160
apps/web/src/components/knowledge/BacklinksList.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import type { KnowledgeBacklink } from "@mosaic/shared";
|
||||
import { Clock, Link2, FileText } from "lucide-react";
|
||||
|
||||
interface BacklinksListProps {
|
||||
/** Array of backlinks to display */
|
||||
backlinks: KnowledgeBacklink[];
|
||||
/** Loading state */
|
||||
isLoading?: boolean;
|
||||
/** Error message if loading failed */
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* BacklinksList - Displays entries that link to the current entry
|
||||
*
|
||||
* Features:
|
||||
* - Shows entry title, summary, and link count
|
||||
* - Click to navigate to linking entry
|
||||
* - Empty state when no backlinks exist
|
||||
* - Loading skeleton
|
||||
*/
|
||||
export function BacklinksList({
|
||||
backlinks,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
}: BacklinksListProps): React.ReactElement {
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5" />
|
||||
Backlinks
|
||||
</h3>
|
||||
<div className="space-y-3 animate-pulse">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg"
|
||||
>
|
||||
<div className="h-5 bg-gray-300 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-600 rounded w-full"></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5" />
|
||||
Backlinks
|
||||
</h3>
|
||||
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
|
||||
<p className="text-sm text-red-800 dark:text-red-200">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (backlinks.length === 0) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5" />
|
||||
Backlinks
|
||||
</h3>
|
||||
<div className="p-6 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 text-gray-400 dark:text-gray-600" />
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
No other entries link to this page yet.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
Use <code className="px-1 py-0.5 bg-gray-200 dark:bg-gray-700 rounded">[[slug]]</code> to create links
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Backlinks list
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5" />
|
||||
Backlinks
|
||||
<span className="text-sm font-normal text-gray-500 dark:text-gray-400">
|
||||
({backlinks.length})
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{backlinks.map((backlink) => (
|
||||
<Link
|
||||
key={backlink.id}
|
||||
href={`/knowledge/${backlink.source.slug}`}
|
||||
className="block p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-blue-400 dark:hover:border-blue-600 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{backlink.source.title}
|
||||
</h4>
|
||||
{backlink.source.summary && (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1 line-clamp-2">
|
||||
{backlink.source.summary}
|
||||
</p>
|
||||
)}
|
||||
{backlink.context && (
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-500 italic bg-gray-100 dark:bg-gray-900 px-2 py-1 rounded">
|
||||
“{backlink.context}”
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0 flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<Clock className="w-3 h-3" />
|
||||
<span>{formatDate(backlink.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for display
|
||||
*/
|
||||
function formatDate(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - d.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return "Today";
|
||||
} else if (diffDays === 1) {
|
||||
return "Yesterday";
|
||||
} else if (diffDays < 7) {
|
||||
return `${diffDays}d ago`;
|
||||
} else if (diffDays < 30) {
|
||||
return `${Math.floor(diffDays / 7)}w ago`;
|
||||
} else {
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user