New libraries: - workspace-links.ts: builders, parsers, and type guards for workspace URLs (/workspace?path=... for files/objects, /workspace?entry=objName:id for entries). Replaces ad-hoc relative-path links with real navigable URLs. - search-index.ts: useSearchIndex hook that fetches a unified search index from the API and provides Fuse.js-powered fuzzy search across files, objects, and database entries. Exposes a stable ref-based search function safe for capture in tiptap extensions. New API routes: - GET /api/workspace/search-index: builds a flat search index from the filesystem tree (knowledge/, reports/, top-level files) and all DuckDB object entries with display-field labels and preview fields. - GET /api/workspace/objects/[name]/entries/[id]: fetches a single entry with all field values, resolved relation labels, reverse relations (incoming links from other objects), and the effective display field. New component: - EntryDetailModal: slide-over modal for viewing an individual entry's fields, enum badges, user avatars, clickable relation chips (navigate to related entries), reverse relation sections, and timestamps. Supports Escape to close and backdrop click dismiss. Slash command refactor (slash-command.tsx): - New createWorkspaceMention(searchFn) replaces the old file-only @ mention with unified search across files, objects, and entries. - searchItemToSlashItem() converts search index items into tiptap suggestion items with proper icons, badges (object name pill for entries), and link insertion commands using real workspace URLs. - Legacy createFileMention(tree) now delegates to createWorkspaceMention with a simple substring-match fallback for when no search index is available. Editor integration (markdown-editor.tsx, document-view.tsx): - MarkdownEditor accepts optional searchFn prop; when provided, uses createWorkspaceMention instead of the legacy createFileMention. - Link click interception now uses the shared isWorkspaceLink() helper and registers handlers in capture phase for reliable interception. - DocumentView forwards searchFn to editor and adds a delegated click handler in read mode to intercept workspace links and navigate via onNavigate. Object table (object-table.tsx): - Added onEntryClick prop; table rows are now clickable with cursor-pointer styling, firing the callback with the entry ID. Workspace page (page.tsx): - Integrates useSearchIndex hook and passes search function down to editor. - Entry detail modal state with URL synchronization (?entry=objName:id param). - New resolveNode() with fallback strategies: exact match, knowledge/ prefix toggle, and last-segment object name matching. - Unified handleEditorNavigate() dispatches /workspace?entry=... to the modal and /workspace?path=... to file/object navigation. - URL bar syncs with activePath via router.replace (no full page reloads). - Top-level container click safety net catches any workspace link clicks that bubble up unhandled. Styles (globals.css): - Added .slash-cmd-item-badge for object-name pills in the @ mention popup.
191 lines
5.9 KiB
TypeScript
191 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, type MouseEvent as ReactMouseEvent } from "react";
|
|
import dynamic from "next/dynamic";
|
|
import { splitReportBlocks, hasReportBlocks } from "@/lib/report-blocks";
|
|
import { isWorkspaceLink } from "@/lib/workspace-links";
|
|
import type { TreeNode, MentionSearchFn } from "./slash-command";
|
|
|
|
// Load markdown renderer client-only to avoid SSR issues with ESM-only packages
|
|
const MarkdownContent = dynamic(
|
|
() =>
|
|
import("./markdown-content").then((mod) => mod.MarkdownContent),
|
|
{
|
|
ssr: false,
|
|
loading: () => (
|
|
<div className="animate-pulse space-y-3 py-4">
|
|
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "80%" }} />
|
|
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "60%" }} />
|
|
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "70%" }} />
|
|
</div>
|
|
),
|
|
},
|
|
);
|
|
|
|
// Lazy-load ReportCard (uses Recharts which is heavy)
|
|
const ReportCard = dynamic(
|
|
() =>
|
|
import("../charts/report-card").then((m) => ({ default: m.ReportCard })),
|
|
{
|
|
ssr: false,
|
|
loading: () => (
|
|
<div
|
|
className="h-48 rounded-xl animate-pulse my-4"
|
|
style={{ background: "var(--color-surface)" }}
|
|
/>
|
|
),
|
|
},
|
|
);
|
|
|
|
// Lazy-load the Tiptap-based editor (heavy -- keep out of initial bundle)
|
|
const MarkdownEditor = dynamic(
|
|
() => import("./markdown-editor").then((m) => ({ default: m.MarkdownEditor })),
|
|
{
|
|
ssr: false,
|
|
loading: () => (
|
|
<div className="animate-pulse space-y-3 py-4 px-6">
|
|
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "80%" }} />
|
|
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "60%" }} />
|
|
<div className="h-4 rounded" style={{ background: "var(--color-surface)", width: "70%" }} />
|
|
</div>
|
|
),
|
|
},
|
|
);
|
|
|
|
type DocumentViewProps = {
|
|
content: string;
|
|
title?: string;
|
|
filePath?: string;
|
|
tree?: TreeNode[];
|
|
onSave?: () => void;
|
|
onNavigate?: (path: string) => void;
|
|
searchFn?: MentionSearchFn;
|
|
};
|
|
|
|
export function DocumentView({
|
|
content,
|
|
title,
|
|
filePath,
|
|
tree,
|
|
onSave,
|
|
onNavigate,
|
|
searchFn,
|
|
}: DocumentViewProps) {
|
|
const [editMode, setEditMode] = useState(!!filePath);
|
|
|
|
// Strip YAML frontmatter if present
|
|
const body = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n/, "");
|
|
|
|
// Extract title from first H1 if no title provided
|
|
const h1Match = body.match(/^#\s+(.+)/m);
|
|
const displayTitle = title ?? h1Match?.[1];
|
|
const markdownBody =
|
|
displayTitle && h1Match ? body.replace(/^#\s+.+\n?/, "") : body;
|
|
|
|
// If we have a filePath and editing is enabled, render the Tiptap editor
|
|
if (editMode && filePath) {
|
|
return (
|
|
<div className="max-w-3xl mx-auto">
|
|
<MarkdownEditor
|
|
content={body}
|
|
rawContent={content}
|
|
filePath={filePath}
|
|
tree={tree ?? []}
|
|
onSave={onSave}
|
|
onNavigate={onNavigate}
|
|
onSwitchToRead={() => setEditMode(false)}
|
|
searchFn={searchFn}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Check if the markdown contains embedded report-json blocks
|
|
const hasReports = hasReportBlocks(markdownBody);
|
|
|
|
// Intercept workspace-internal links in read mode (delegated click handler)
|
|
const handleLinkClick = useCallback(
|
|
(event: ReactMouseEvent<HTMLDivElement>) => {
|
|
if (!onNavigate) {return;}
|
|
const target = event.target as HTMLElement;
|
|
const link = target.closest("a");
|
|
if (!link) {return;}
|
|
const href = link.getAttribute("href");
|
|
if (!href) {return;}
|
|
if (isWorkspaceLink(href)) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onNavigate(href);
|
|
}
|
|
},
|
|
[onNavigate],
|
|
);
|
|
|
|
return (
|
|
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
|
<div className="max-w-3xl mx-auto px-6 py-8" onClick={handleLinkClick}>
|
|
{/* Header row with title + edit button */}
|
|
<div className="flex items-start justify-between gap-4">
|
|
{displayTitle && (
|
|
<h1
|
|
className="text-3xl font-bold mb-6 flex-1"
|
|
style={{ color: "var(--color-text)" }}
|
|
>
|
|
{displayTitle}
|
|
</h1>
|
|
)}
|
|
{filePath && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditMode(true)}
|
|
className="editor-mode-toggle flex-shrink-0 mt-1"
|
|
title="Edit this document"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
|
<path d="m15 5 4 4" />
|
|
</svg>
|
|
<span>Edit</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{hasReports ? (
|
|
<EmbeddedReportContent content={markdownBody} />
|
|
) : (
|
|
<div className="workspace-prose">
|
|
<MarkdownContent content={markdownBody} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Renders markdown content that contains embedded report-json blocks.
|
|
* Splits the content into alternating markdown and interactive chart sections.
|
|
*/
|
|
function EmbeddedReportContent({ content }: { content: string }) {
|
|
const segments = splitReportBlocks(content);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{segments.map((segment, index) => {
|
|
if (segment.type === "report-artifact") {
|
|
return (
|
|
<div key={index} className="my-6">
|
|
<ReportCard config={segment.config} />
|
|
</div>
|
|
);
|
|
}
|
|
// Text segment -- render as markdown
|
|
return (
|
|
<div key={index} className="workspace-prose">
|
|
<MarkdownContent content={segment.text} />
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|