- Add gateway.webApp config (enabled, port, dev) as the unified toggle for both the Next.js web UI and the built-in control UI - Spawn Next.js app alongside the gateway; stop it on shutdown - Auto-enable webApp in config for new and existing installs - Pre-build Next.js in deploy.sh and ship .next/ in the npm package so installed users get instant startup (no build step) - Gateway skips build when pre-built .next/ exists; builds on first run for dev/git-checkout users - Onboarding "Open the Web UI" now opens the Ironclaw web app - Fix pre-existing Next.js build errors (ES2023 lib, Tiptap v3 types, Suspense boundary, ReportConfig type alignment) - Rename deploy target from openclaw-ai-sdk to ironclaw Co-authored-by: Cursor <cursoragent@cursor.com>
1235 lines
40 KiB
TypeScript
1235 lines
40 KiB
TypeScript
"use client";
|
|
|
|
import { Suspense, useEffect, useState, useCallback, useRef, useMemo } from "react";
|
|
import { useSearchParams, useRouter } from "next/navigation";
|
|
import { WorkspaceSidebar } from "../components/workspace/workspace-sidebar";
|
|
import { type TreeNode } from "../components/workspace/file-manager-tree";
|
|
import { useWorkspaceWatcher } from "../hooks/use-workspace-watcher";
|
|
import { ObjectTable } from "../components/workspace/object-table";
|
|
import { ObjectKanban } from "../components/workspace/object-kanban";
|
|
import { DocumentView } from "../components/workspace/document-view";
|
|
import { FileViewer } from "../components/workspace/file-viewer";
|
|
import { DatabaseViewer } from "../components/workspace/database-viewer";
|
|
import { Breadcrumbs } from "../components/workspace/breadcrumbs";
|
|
import { EmptyState } from "../components/workspace/empty-state";
|
|
import { ReportViewer } from "../components/charts/report-viewer";
|
|
import { ChatPanel, type ChatPanelHandle } from "../components/chat-panel";
|
|
import { EntryDetailModal } from "../components/workspace/entry-detail-modal";
|
|
import { useSearchIndex } from "@/lib/search-index";
|
|
import { parseWorkspaceLink, isWorkspaceLink } from "@/lib/workspace-links";
|
|
|
|
// --- Types ---
|
|
|
|
type WorkspaceContext = {
|
|
exists: boolean;
|
|
organization?: { id?: string; name?: string; slug?: string };
|
|
members?: Array<{ id: string; name: string; email: string; role: string }>;
|
|
};
|
|
|
|
type ReverseRelation = {
|
|
fieldName: string;
|
|
sourceObjectName: string;
|
|
sourceObjectId: string;
|
|
displayField: string;
|
|
entries: Record<string, Array<{ id: string; label: string }>>;
|
|
};
|
|
|
|
type ObjectData = {
|
|
object: {
|
|
id: string;
|
|
name: string;
|
|
description?: string;
|
|
icon?: string;
|
|
default_view?: string;
|
|
display_field?: string;
|
|
};
|
|
fields: Array<{
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
enum_values?: string[];
|
|
enum_colors?: string[];
|
|
enum_multiple?: boolean;
|
|
related_object_id?: string;
|
|
relationship_type?: string;
|
|
related_object_name?: string;
|
|
sort_order?: number;
|
|
}>;
|
|
statuses: Array<{
|
|
id: string;
|
|
name: string;
|
|
color?: string;
|
|
sort_order?: number;
|
|
}>;
|
|
entries: Record<string, unknown>[];
|
|
relationLabels?: Record<string, Record<string, string>>;
|
|
reverseRelations?: ReverseRelation[];
|
|
effectiveDisplayField?: string;
|
|
};
|
|
|
|
type FileData = {
|
|
content: string;
|
|
type: "markdown" | "yaml" | "text";
|
|
};
|
|
|
|
type ContentState =
|
|
| { kind: "none" }
|
|
| { kind: "loading" }
|
|
| { kind: "object"; data: ObjectData }
|
|
| { kind: "document"; data: FileData; title: string }
|
|
| { kind: "file"; data: FileData; filename: string }
|
|
| { kind: "database"; dbPath: string; filename: string }
|
|
| { kind: "report"; reportPath: string; filename: string }
|
|
| { kind: "directory"; node: TreeNode };
|
|
|
|
type WebSession = {
|
|
id: string;
|
|
title: string;
|
|
createdAt: number;
|
|
updatedAt: number;
|
|
messageCount: number;
|
|
};
|
|
|
|
// --- Helpers ---
|
|
|
|
/** Detect virtual paths (skills, memories) that live outside the dench workspace. */
|
|
function isVirtualPath(path: string): boolean {
|
|
return path.startsWith("~");
|
|
}
|
|
|
|
/** Pick the right file API endpoint based on virtual vs real paths. */
|
|
function fileApiUrl(path: string): string {
|
|
return isVirtualPath(path)
|
|
? `/api/workspace/virtual-file?path=${encodeURIComponent(path)}`
|
|
: `/api/workspace/file?path=${encodeURIComponent(path)}`;
|
|
}
|
|
|
|
/** Find a node in the tree by exact path. */
|
|
function findNode(
|
|
tree: TreeNode[],
|
|
path: string,
|
|
): TreeNode | null {
|
|
for (const node of tree) {
|
|
if (node.path === path) {return node;}
|
|
if (node.children) {
|
|
const found = findNode(node.children, path);
|
|
if (found) {return found;}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Extract the object name from a tree path (last segment). */
|
|
function objectNameFromPath(path: string): string {
|
|
const segments = path.split("/");
|
|
return segments[segments.length - 1];
|
|
}
|
|
|
|
/**
|
|
* Resolve a path with fallback strategies:
|
|
* 1. Exact match
|
|
* 2. Try with knowledge/ prefix
|
|
* 3. Try stripping knowledge/ prefix
|
|
* 4. Match last segment against object names
|
|
*/
|
|
function resolveNode(
|
|
tree: TreeNode[],
|
|
path: string,
|
|
): TreeNode | null {
|
|
let node = findNode(tree, path);
|
|
if (node) {return node;}
|
|
|
|
if (!path.startsWith("knowledge/")) {
|
|
node = findNode(tree, `knowledge/${path}`);
|
|
if (node) {return node;}
|
|
}
|
|
|
|
if (path.startsWith("knowledge/")) {
|
|
node = findNode(tree, path.slice("knowledge/".length));
|
|
if (node) {return node;}
|
|
}
|
|
|
|
const lastSegment = path.split("/").pop();
|
|
if (lastSegment) {
|
|
function findByName(nodes: TreeNode[]): TreeNode | null {
|
|
for (const n of nodes) {
|
|
if (n.type === "object" && objectNameFromPath(n.path) === lastSegment) {return n;}
|
|
if (n.children) {
|
|
const found = findByName(n.children);
|
|
if (found) {return found;}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
node = findByName(tree);
|
|
if (node) {return node;}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// --- Main Page ---
|
|
|
|
export default function WorkspacePage() {
|
|
return (
|
|
<Suspense fallback={
|
|
<div className="flex h-screen items-center justify-center" style={{ background: "var(--color-bg)" }}>
|
|
<div className="w-6 h-6 border-2 rounded-full animate-spin" style={{ borderColor: "var(--color-border)", borderTopColor: "var(--color-accent)" }} />
|
|
</div>
|
|
}>
|
|
<WorkspacePageInner />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function WorkspacePageInner() {
|
|
const searchParams = useSearchParams();
|
|
const router = useRouter();
|
|
const initialPathHandled = useRef(false);
|
|
|
|
// Chat panel ref for session management
|
|
const chatRef = useRef<ChatPanelHandle>(null);
|
|
|
|
// Live-reactive tree via SSE watcher
|
|
const { tree, loading: treeLoading, exists: workspaceExists, refresh: refreshTree } = useWorkspaceWatcher();
|
|
|
|
// Search index for @ mention fuzzy search (files + entries)
|
|
const { search: searchIndex } = useSearchIndex();
|
|
|
|
const [context, setContext] = useState<WorkspaceContext | null>(null);
|
|
const [activePath, setActivePath] = useState<string | null>(null);
|
|
const [content, setContent] = useState<ContentState>({ kind: "none" });
|
|
const [showChatSidebar, setShowChatSidebar] = useState(true);
|
|
|
|
// Chat session state
|
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
|
const [sessions, setSessions] = useState<WebSession[]>([]);
|
|
const [sidebarRefreshKey, setSidebarRefreshKey] = useState(0);
|
|
|
|
// Entry detail modal state
|
|
const [entryModal, setEntryModal] = useState<{
|
|
objectName: string;
|
|
entryId: string;
|
|
} | null>(null);
|
|
|
|
// Derive file context for chat sidebar directly from activePath (stable across loading)
|
|
const fileContext = useMemo(() => {
|
|
if (!activePath) {return undefined;}
|
|
const filename = activePath.split("/").pop() || activePath;
|
|
return { path: activePath, filename };
|
|
}, [activePath]);
|
|
|
|
// Update content state when the agent edits the file (live reload)
|
|
const handleFileChanged = useCallback((newContent: string) => {
|
|
setContent((prev) => {
|
|
if (prev.kind === "document") {
|
|
return { ...prev, data: { ...prev.data, content: newContent } };
|
|
}
|
|
if (prev.kind === "file") {
|
|
return { ...prev, data: { ...prev.data, content: newContent } };
|
|
}
|
|
return prev;
|
|
});
|
|
}, []);
|
|
|
|
// Fetch workspace context on mount
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
async function loadContext() {
|
|
try {
|
|
const res = await fetch("/api/workspace/context");
|
|
const data = await res.json();
|
|
if (!cancelled) {setContext(data);}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
loadContext();
|
|
return () => { cancelled = true; };
|
|
}, []);
|
|
|
|
// Fetch chat sessions
|
|
const fetchSessions = useCallback(async () => {
|
|
try {
|
|
const res = await fetch("/api/web-sessions");
|
|
const data = await res.json();
|
|
setSessions(data.sessions ?? []);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
fetchSessions();
|
|
}, [fetchSessions, sidebarRefreshKey]);
|
|
|
|
const refreshSessions = useCallback(() => {
|
|
setSidebarRefreshKey((k) => k + 1);
|
|
}, []);
|
|
|
|
// Load content when path changes
|
|
const loadContent = useCallback(
|
|
async (node: TreeNode) => {
|
|
setActivePath(node.path);
|
|
setContent({ kind: "loading" });
|
|
|
|
try {
|
|
if (node.type === "object") {
|
|
const name = objectNameFromPath(node.path);
|
|
const res = await fetch(`/api/workspace/objects/${encodeURIComponent(name)}`);
|
|
if (!res.ok) {
|
|
setContent({ kind: "none" });
|
|
return;
|
|
}
|
|
const data: ObjectData = await res.json();
|
|
setContent({ kind: "object", data });
|
|
} else if (node.type === "document") {
|
|
// Use virtual-file API for ~skills/ and ~memories/ paths
|
|
const res = await fetch(fileApiUrl(node.path));
|
|
if (!res.ok) {
|
|
setContent({ kind: "none" });
|
|
return;
|
|
}
|
|
const data: FileData = await res.json();
|
|
setContent({
|
|
kind: "document",
|
|
data,
|
|
title: node.name.replace(/\.md$/, ""),
|
|
});
|
|
} else if (node.type === "database") {
|
|
setContent({ kind: "database", dbPath: node.path, filename: node.name });
|
|
} else if (node.type === "report") {
|
|
setContent({ kind: "report", reportPath: node.path, filename: node.name });
|
|
} else if (node.type === "file") {
|
|
const res = await fetch(fileApiUrl(node.path));
|
|
if (!res.ok) {
|
|
setContent({ kind: "none" });
|
|
return;
|
|
}
|
|
const data: FileData = await res.json();
|
|
setContent({ kind: "file", data, filename: node.name });
|
|
} else if (node.type === "folder") {
|
|
setContent({ kind: "directory", node });
|
|
}
|
|
} catch {
|
|
setContent({ kind: "none" });
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleNodeSelect = useCallback(
|
|
(node: TreeNode) => {
|
|
// Intercept chat folder item clicks
|
|
if (node.path.startsWith("~chats/")) {
|
|
const sessionId = node.path.slice("~chats/".length);
|
|
setActivePath(null);
|
|
setContent({ kind: "none" });
|
|
setActiveSessionId(sessionId);
|
|
chatRef.current?.loadSession(sessionId);
|
|
router.replace("/workspace", { scroll: false });
|
|
return;
|
|
}
|
|
// Clicking the Chats folder itself opens a new chat
|
|
if (node.path === "~chats") {
|
|
setActivePath(null);
|
|
setContent({ kind: "none" });
|
|
chatRef.current?.newSession();
|
|
router.replace("/workspace", { scroll: false });
|
|
return;
|
|
}
|
|
loadContent(node);
|
|
},
|
|
[loadContent, router],
|
|
);
|
|
|
|
// Build the enhanced tree: real tree + Chats virtual folder at the bottom
|
|
const enhancedTree = useMemo(() => {
|
|
const chatChildren: TreeNode[] = sessions.map((s) => ({
|
|
name: s.title || "Untitled chat",
|
|
path: `~chats/${s.id}`,
|
|
type: "file" as const,
|
|
virtual: true,
|
|
}));
|
|
|
|
const chatsFolder: TreeNode = {
|
|
name: "Chats",
|
|
path: "~chats",
|
|
type: "folder",
|
|
virtual: true,
|
|
children: chatChildren.length > 0 ? chatChildren : undefined,
|
|
};
|
|
|
|
return [...tree, chatsFolder];
|
|
}, [tree, sessions]);
|
|
|
|
// Sync URL bar when activePath changes
|
|
useEffect(() => {
|
|
const currentPath = searchParams.get("path");
|
|
const currentEntry = searchParams.get("entry");
|
|
|
|
if (activePath && activePath !== currentPath) {
|
|
const params = new URLSearchParams();
|
|
params.set("path", activePath);
|
|
if (currentEntry) {params.set("entry", currentEntry);}
|
|
router.replace(`/workspace?${params.toString()}`, { scroll: false });
|
|
}
|
|
}, [activePath, searchParams, router]);
|
|
|
|
// Open entry modal handler
|
|
const handleOpenEntry = useCallback(
|
|
(objectName: string, entryId: string) => {
|
|
setEntryModal({ objectName, entryId });
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
params.set("entry", `${objectName}:${entryId}`);
|
|
router.replace(`/workspace?${params.toString()}`, { scroll: false });
|
|
},
|
|
[searchParams, router],
|
|
);
|
|
|
|
// Close entry modal handler
|
|
const handleCloseEntry = useCallback(() => {
|
|
setEntryModal(null);
|
|
const params = new URLSearchParams(searchParams.toString());
|
|
params.delete("entry");
|
|
const qs = params.toString();
|
|
router.replace(qs ? `/workspace?${qs}` : "/workspace", { scroll: false });
|
|
}, [searchParams, router]);
|
|
|
|
// Auto-navigate to path from URL query param after tree loads
|
|
useEffect(() => {
|
|
if (initialPathHandled.current || treeLoading || tree.length === 0) {return;}
|
|
|
|
const pathParam = searchParams.get("path");
|
|
const entryParam = searchParams.get("entry");
|
|
|
|
if (pathParam) {
|
|
const node = resolveNode(tree, pathParam);
|
|
if (node) {
|
|
initialPathHandled.current = true;
|
|
loadContent(node);
|
|
}
|
|
}
|
|
|
|
// Also open entry modal from URL if present
|
|
if (entryParam && entryParam.includes(":")) {
|
|
const [objName, eid] = entryParam.split(":", 2);
|
|
if (objName && eid) {
|
|
setEntryModal({ objectName: objName, entryId: eid });
|
|
}
|
|
}
|
|
}, [tree, treeLoading, searchParams, loadContent]);
|
|
|
|
const handleBreadcrumbNavigate = useCallback(
|
|
(path: string) => {
|
|
if (!path) {
|
|
setActivePath(null);
|
|
setContent({ kind: "none" });
|
|
return;
|
|
}
|
|
const node = resolveNode(tree, path);
|
|
if (node) {
|
|
loadContent(node);
|
|
}
|
|
},
|
|
[tree, loadContent],
|
|
);
|
|
|
|
// Navigate to an object by name (used by relation links)
|
|
const handleNavigateToObject = useCallback(
|
|
(objectName: string) => {
|
|
function findObjectNode(nodes: TreeNode[]): TreeNode | null {
|
|
for (const node of nodes) {
|
|
if (node.type === "object" && objectNameFromPath(node.path) === objectName) {
|
|
return node;
|
|
}
|
|
if (node.children) {
|
|
const found = findObjectNode(node.children);
|
|
if (found) {return found;}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
const node = findObjectNode(tree);
|
|
if (node) {loadContent(node);}
|
|
},
|
|
[tree, loadContent],
|
|
);
|
|
|
|
/**
|
|
* Unified navigate handler for links in the editor and read mode.
|
|
* Handles /workspace?entry=..., /workspace?path=..., and legacy relative paths.
|
|
*/
|
|
const handleEditorNavigate = useCallback(
|
|
(href: string) => {
|
|
// Try parsing as a workspace URL first (/workspace?entry=... or /workspace?path=...)
|
|
const parsed = parseWorkspaceLink(href);
|
|
if (parsed) {
|
|
if (parsed.kind === "entry") {
|
|
handleOpenEntry(parsed.objectName, parsed.entryId);
|
|
return;
|
|
}
|
|
// File/object link -- resolve using the path from the URL
|
|
const node = resolveNode(tree, parsed.path);
|
|
if (node) {
|
|
handleNodeSelect(node);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Fallback: treat as a raw relative path (legacy links)
|
|
const node = resolveNode(tree, href);
|
|
if (node) {
|
|
handleNodeSelect(node);
|
|
}
|
|
},
|
|
[tree, handleNodeSelect, handleOpenEntry],
|
|
);
|
|
|
|
// Refresh the currently displayed object (e.g. after changing display field)
|
|
const refreshCurrentObject = useCallback(async () => {
|
|
if (content.kind !== "object") {return;}
|
|
const name = content.data.object.name;
|
|
try {
|
|
const res = await fetch(`/api/workspace/objects/${encodeURIComponent(name)}`);
|
|
if (!res.ok) {return;}
|
|
const data: ObjectData = await res.json();
|
|
setContent({ kind: "object", data });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, [content]);
|
|
|
|
// Top-level safety net: catch workspace link clicks anywhere in the page
|
|
// to prevent full-page navigation and handle via client-side state instead.
|
|
const handleContainerClick = useCallback(
|
|
(event: React.MouseEvent<HTMLDivElement>) => {
|
|
const target = event.target as HTMLElement;
|
|
const link = target.closest("a");
|
|
if (!link) {return;}
|
|
const href = link.getAttribute("href");
|
|
if (!href) {return;}
|
|
// Intercept /workspace?... links to handle them in-app
|
|
if (isWorkspaceLink(href)) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
handleEditorNavigate(href);
|
|
}
|
|
},
|
|
[handleEditorNavigate],
|
|
);
|
|
|
|
// Whether to show the main ChatPanel (no file/content selected)
|
|
const showMainChat = !activePath || content.kind === "none";
|
|
|
|
return (
|
|
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
|
<div className="flex h-screen" style={{ background: "var(--color-bg)" }} onClick={handleContainerClick}>
|
|
{/* Sidebar */}
|
|
<WorkspaceSidebar
|
|
tree={enhancedTree}
|
|
activePath={activePath}
|
|
onSelect={handleNodeSelect}
|
|
onRefresh={refreshTree}
|
|
orgName={context?.organization?.name}
|
|
loading={treeLoading}
|
|
/>
|
|
|
|
{/* Main content */}
|
|
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
{/* When a file is selected: show top bar with breadcrumbs */}
|
|
{activePath && content.kind !== "none" && (
|
|
<div
|
|
className="px-6 border-b flex-shrink-0 flex items-center justify-between"
|
|
style={{ borderColor: "var(--color-border)" }}
|
|
>
|
|
<Breadcrumbs
|
|
path={activePath}
|
|
onNavigate={handleBreadcrumbNavigate}
|
|
/>
|
|
<div className="flex items-center gap-1">
|
|
{/* Back to chat button */}
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setActivePath(null);
|
|
setContent({ kind: "none" });
|
|
router.replace("/workspace", { scroll: false });
|
|
}}
|
|
className="p-1.5 rounded-md transition-colors flex-shrink-0"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
title="Back to chat"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
|
|
</svg>
|
|
</button>
|
|
{/* Chat sidebar toggle */}
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowChatSidebar((v) => !v)}
|
|
className="p-1.5 rounded-md transition-colors flex-shrink-0"
|
|
style={{
|
|
color: showChatSidebar ? "var(--color-accent)" : "var(--color-text-muted)",
|
|
background: showChatSidebar ? "rgba(232, 93, 58, 0.1)" : "transparent",
|
|
}}
|
|
title={showChatSidebar ? "Hide chat" : "Chat about this file"}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Content area */}
|
|
<div className="flex-1 flex min-h-0">
|
|
{showMainChat ? (
|
|
/* Main chat view (default when no file is selected) */
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|
<ChatPanel
|
|
ref={chatRef}
|
|
onActiveSessionChange={(id) => {
|
|
setActiveSessionId(id);
|
|
}}
|
|
onSessionsChange={refreshSessions}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* File content area */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
<ContentRenderer
|
|
content={content}
|
|
workspaceExists={workspaceExists}
|
|
tree={tree}
|
|
activePath={activePath}
|
|
members={context?.members}
|
|
onNodeSelect={handleNodeSelect}
|
|
onNavigateToObject={handleNavigateToObject}
|
|
onRefreshObject={refreshCurrentObject}
|
|
onRefreshTree={refreshTree}
|
|
onNavigate={handleEditorNavigate}
|
|
onOpenEntry={handleOpenEntry}
|
|
searchFn={searchIndex}
|
|
/>
|
|
</div>
|
|
|
|
{/* Chat sidebar (file-scoped) */}
|
|
{fileContext && showChatSidebar && (
|
|
<aside
|
|
className="flex-shrink-0 border-l"
|
|
style={{
|
|
width: 380,
|
|
borderColor: "var(--color-border)",
|
|
background: "var(--color-bg)",
|
|
}}
|
|
>
|
|
<ChatPanel
|
|
compact
|
|
fileContext={fileContext}
|
|
onFileChanged={handleFileChanged}
|
|
/>
|
|
</aside>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</main>
|
|
|
|
{/* Entry detail modal (rendered on top of everything) */}
|
|
{entryModal && (
|
|
<EntryDetailModal
|
|
objectName={entryModal.objectName}
|
|
entryId={entryModal.entryId}
|
|
members={context?.members}
|
|
onClose={handleCloseEntry}
|
|
onNavigateEntry={(objName, eid) => handleOpenEntry(objName, eid)}
|
|
onNavigateObject={(objName) => {
|
|
handleCloseEntry();
|
|
handleNavigateToObject(objName);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Content Renderer ---
|
|
|
|
function ContentRenderer({
|
|
content,
|
|
workspaceExists,
|
|
tree,
|
|
activePath,
|
|
members,
|
|
onNodeSelect,
|
|
onNavigateToObject,
|
|
onRefreshObject,
|
|
onRefreshTree,
|
|
onNavigate,
|
|
onOpenEntry,
|
|
searchFn,
|
|
}: {
|
|
content: ContentState;
|
|
workspaceExists: boolean;
|
|
tree: TreeNode[];
|
|
activePath: string | null;
|
|
members?: Array<{ id: string; name: string; email: string; role: string }>;
|
|
onNodeSelect: (node: TreeNode) => void;
|
|
onNavigateToObject: (objectName: string) => void;
|
|
onRefreshObject: () => void;
|
|
onRefreshTree: () => void;
|
|
onNavigate: (href: string) => void;
|
|
onOpenEntry: (objectName: string, entryId: string) => void;
|
|
searchFn: (query: string, limit?: number) => import("@/lib/search-index").SearchIndexItem[];
|
|
}) {
|
|
switch (content.kind) {
|
|
case "loading":
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<div
|
|
className="w-6 h-6 border-2 rounded-full animate-spin"
|
|
style={{
|
|
borderColor: "var(--color-border)",
|
|
borderTopColor: "var(--color-accent)",
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
case "object":
|
|
return (
|
|
<ObjectView
|
|
data={content.data}
|
|
members={members}
|
|
onNavigateToObject={onNavigateToObject}
|
|
onRefreshObject={onRefreshObject}
|
|
onOpenEntry={onOpenEntry}
|
|
/>
|
|
);
|
|
|
|
case "document":
|
|
return (
|
|
<DocumentView
|
|
content={content.data.content}
|
|
title={content.title}
|
|
filePath={activePath ?? undefined}
|
|
tree={tree}
|
|
onSave={onRefreshTree}
|
|
onNavigate={onNavigate}
|
|
searchFn={searchFn}
|
|
/>
|
|
);
|
|
|
|
case "file":
|
|
return (
|
|
<FileViewer
|
|
content={content.data.content}
|
|
filename={content.filename}
|
|
type={content.data.type === "yaml" ? "yaml" : "text"}
|
|
/>
|
|
);
|
|
|
|
case "database":
|
|
return (
|
|
<DatabaseViewer
|
|
dbPath={content.dbPath}
|
|
filename={content.filename}
|
|
/>
|
|
);
|
|
|
|
case "report":
|
|
return (
|
|
<ReportViewer
|
|
reportPath={content.reportPath}
|
|
/>
|
|
);
|
|
|
|
case "directory":
|
|
return (
|
|
<DirectoryListing
|
|
node={content.node}
|
|
onNodeSelect={onNodeSelect}
|
|
/>
|
|
);
|
|
|
|
case "none":
|
|
default:
|
|
if (tree.length === 0) {
|
|
return <EmptyState workspaceExists={workspaceExists} />;
|
|
}
|
|
return <WelcomeView tree={tree} onNodeSelect={onNodeSelect} />;
|
|
}
|
|
}
|
|
|
|
// --- Object View (header + display field selector + table/kanban) ---
|
|
|
|
function ObjectView({
|
|
data,
|
|
members,
|
|
onNavigateToObject,
|
|
onRefreshObject,
|
|
onOpenEntry,
|
|
}: {
|
|
data: ObjectData;
|
|
members?: Array<{ id: string; name: string; email: string; role: string }>;
|
|
onNavigateToObject: (objectName: string) => void;
|
|
onRefreshObject: () => void;
|
|
onOpenEntry?: (objectName: string, entryId: string) => void;
|
|
}) {
|
|
const [updatingDisplayField, setUpdatingDisplayField] = useState(false);
|
|
|
|
const handleDisplayFieldChange = async (fieldName: string) => {
|
|
setUpdatingDisplayField(true);
|
|
try {
|
|
const res = await fetch(
|
|
`/api/workspace/objects/${encodeURIComponent(data.object.name)}/display-field`,
|
|
{
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ displayField: fieldName }),
|
|
},
|
|
);
|
|
if (res.ok) {
|
|
onRefreshObject();
|
|
}
|
|
} catch {
|
|
// ignore
|
|
} finally {
|
|
setUpdatingDisplayField(false);
|
|
}
|
|
};
|
|
|
|
const displayFieldCandidates = data.fields.filter(
|
|
(f) => !["relation", "boolean", "richtext"].includes(f.type),
|
|
);
|
|
|
|
const hasRelationFields = data.fields.some((f) => f.type === "relation");
|
|
const hasReverseRelations =
|
|
data.reverseRelations && data.reverseRelations.some(
|
|
(rr) => Object.keys(rr.entries).length > 0,
|
|
);
|
|
|
|
return (
|
|
<div className="p-6">
|
|
{/* Object header */}
|
|
<div className="mb-6">
|
|
<h1
|
|
className="text-2xl font-bold capitalize"
|
|
style={{ color: "var(--color-text)" }}
|
|
>
|
|
{data.object.name}
|
|
</h1>
|
|
{data.object.description && (
|
|
<p
|
|
className="text-sm mt-1"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{data.object.description}
|
|
</p>
|
|
)}
|
|
<div className="flex items-center gap-3 mt-3 flex-wrap">
|
|
<span
|
|
className="text-xs px-2 py-1 rounded-full"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
color: "var(--color-text-muted)",
|
|
border: "1px solid var(--color-border)",
|
|
}}
|
|
>
|
|
{data.entries.length} entries
|
|
</span>
|
|
<span
|
|
className="text-xs px-2 py-1 rounded-full"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
color: "var(--color-text-muted)",
|
|
border: "1px solid var(--color-border)",
|
|
}}
|
|
>
|
|
{data.fields.length} fields
|
|
</span>
|
|
|
|
{hasRelationFields && (
|
|
<span
|
|
className="text-xs px-2 py-1 rounded-full"
|
|
style={{
|
|
background: "rgba(96, 165, 250, 0.08)",
|
|
color: "#60a5fa",
|
|
border: "1px solid rgba(96, 165, 250, 0.2)",
|
|
}}
|
|
>
|
|
{data.fields.filter((f) => f.type === "relation").length} relation{data.fields.filter((f) => f.type === "relation").length !== 1 ? "s" : ""}
|
|
</span>
|
|
)}
|
|
{hasReverseRelations && (
|
|
<span
|
|
className="text-xs px-2 py-1 rounded-full"
|
|
style={{
|
|
background: "rgba(192, 132, 252, 0.08)",
|
|
color: "#c084fc",
|
|
border: "1px solid rgba(192, 132, 252, 0.2)",
|
|
}}
|
|
>
|
|
{data.reverseRelations!.filter((rr) => Object.keys(rr.entries).length > 0).length} linked from
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{displayFieldCandidates.length > 0 && (
|
|
<div className="flex items-center gap-2 mt-3">
|
|
<span
|
|
className="text-xs"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
Display field:
|
|
</span>
|
|
<select
|
|
value={data.effectiveDisplayField ?? ""}
|
|
onChange={(e) => handleDisplayFieldChange(e.target.value)}
|
|
disabled={updatingDisplayField}
|
|
className="text-xs px-2 py-1 rounded-md outline-none transition-colors cursor-pointer"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
color: "var(--color-text)",
|
|
border: "1px solid var(--color-border)",
|
|
opacity: updatingDisplayField ? 0.5 : 1,
|
|
}}
|
|
>
|
|
{displayFieldCandidates.map((f) => (
|
|
<option key={f.id} value={f.name}>
|
|
{f.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{updatingDisplayField && (
|
|
<div
|
|
className="w-3 h-3 border border-t-transparent rounded-full animate-spin"
|
|
style={{ borderColor: "var(--color-text-muted)" }}
|
|
/>
|
|
)}
|
|
<span
|
|
className="text-[10px]"
|
|
style={{ color: "var(--color-text-muted)", opacity: 0.6 }}
|
|
>
|
|
Used when other objects link here
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Table or Kanban */}
|
|
{data.object.default_view === "kanban" ? (
|
|
<ObjectKanban
|
|
objectName={data.object.name}
|
|
fields={data.fields}
|
|
entries={data.entries}
|
|
statuses={data.statuses}
|
|
members={members}
|
|
relationLabels={data.relationLabels}
|
|
/>
|
|
) : (
|
|
<ObjectTable
|
|
objectName={data.object.name}
|
|
fields={data.fields}
|
|
entries={data.entries}
|
|
members={members}
|
|
relationLabels={data.relationLabels}
|
|
reverseRelations={data.reverseRelations}
|
|
onNavigateToObject={onNavigateToObject}
|
|
onEntryClick={onOpenEntry ? (entryId) => onOpenEntry(data.object.name, entryId) : undefined}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Directory Listing ---
|
|
|
|
function DirectoryListing({
|
|
node,
|
|
onNodeSelect,
|
|
}: {
|
|
node: TreeNode;
|
|
onNodeSelect: (node: TreeNode) => void;
|
|
}) {
|
|
const children = node.children ?? [];
|
|
|
|
return (
|
|
<div className="p-6 max-w-4xl mx-auto">
|
|
<h1
|
|
className="text-2xl font-bold mb-1 capitalize"
|
|
style={{ color: "var(--color-text)" }}
|
|
>
|
|
{node.name}
|
|
</h1>
|
|
<p className="text-sm mb-6" style={{ color: "var(--color-text-muted)" }}>
|
|
{children.length} items
|
|
</p>
|
|
|
|
{children.length === 0 ? (
|
|
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>
|
|
This folder is empty.
|
|
</p>
|
|
) : (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{children.map((child) => (
|
|
<button
|
|
type="button"
|
|
key={child.path}
|
|
onClick={() => onNodeSelect(child)}
|
|
className="flex items-center gap-3 p-4 rounded-xl text-left transition-all duration-100 cursor-pointer"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
border: "1px solid var(--color-border)",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
(e.currentTarget as HTMLElement).style.borderColor =
|
|
"var(--color-text-muted)";
|
|
(e.currentTarget as HTMLElement).style.transform = "translateY(-1px)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
(e.currentTarget as HTMLElement).style.borderColor =
|
|
"var(--color-border)";
|
|
(e.currentTarget as HTMLElement).style.transform = "translateY(0)";
|
|
}}
|
|
>
|
|
<span
|
|
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
|
style={{
|
|
background:
|
|
child.type === "object"
|
|
? "rgba(232, 93, 58, 0.1)"
|
|
: child.type === "document"
|
|
? "rgba(96, 165, 250, 0.1)"
|
|
: child.type === "database"
|
|
? "rgba(192, 132, 252, 0.1)"
|
|
: child.type === "report"
|
|
? "rgba(34, 197, 94, 0.1)"
|
|
: "var(--color-surface-hover)",
|
|
color:
|
|
child.type === "object"
|
|
? "var(--color-accent)"
|
|
: child.type === "document"
|
|
? "#60a5fa"
|
|
: child.type === "database"
|
|
? "#c084fc"
|
|
: child.type === "report"
|
|
? "#22c55e"
|
|
: "var(--color-text-muted)",
|
|
}}
|
|
>
|
|
<NodeTypeIcon type={child.type} />
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div
|
|
className="text-sm font-medium truncate"
|
|
style={{ color: "var(--color-text)" }}
|
|
>
|
|
{child.name.replace(/\.md$/, "")}
|
|
</div>
|
|
<div className="text-xs capitalize" style={{ color: "var(--color-text-muted)" }}>
|
|
{child.type}
|
|
{child.children ? ` (${child.children.length})` : ""}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Welcome View (no selection) ---
|
|
|
|
function WelcomeView({
|
|
tree,
|
|
onNodeSelect,
|
|
}: {
|
|
tree: TreeNode[];
|
|
onNodeSelect: (node: TreeNode) => void;
|
|
}) {
|
|
const objects: TreeNode[] = [];
|
|
const documents: TreeNode[] = [];
|
|
|
|
function collect(nodes: TreeNode[]) {
|
|
for (const n of nodes) {
|
|
if (n.type === "object") {objects.push(n);}
|
|
else if (n.type === "document") {documents.push(n);}
|
|
if (n.children) {collect(n.children);}
|
|
}
|
|
}
|
|
collect(tree);
|
|
|
|
return (
|
|
<div className="p-8 max-w-4xl mx-auto">
|
|
<h1
|
|
className="text-2xl font-bold mb-2"
|
|
style={{ color: "var(--color-text)" }}
|
|
>
|
|
Workspace
|
|
</h1>
|
|
<p className="text-sm mb-8" style={{ color: "var(--color-text-muted)" }}>
|
|
Select an item from the sidebar, or browse the sections below.
|
|
</p>
|
|
|
|
{objects.length > 0 && (
|
|
<div className="mb-8">
|
|
<h2
|
|
className="text-sm font-medium uppercase tracking-wider mb-3"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
Objects
|
|
</h2>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{objects.map((obj) => (
|
|
<button
|
|
type="button"
|
|
key={obj.path}
|
|
onClick={() => onNodeSelect(obj)}
|
|
className="flex items-center gap-3 p-4 rounded-xl text-left transition-all duration-100 cursor-pointer"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
border: "1px solid var(--color-border)",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
(e.currentTarget as HTMLElement).style.borderColor =
|
|
"var(--color-accent)";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
(e.currentTarget as HTMLElement).style.borderColor =
|
|
"var(--color-border)";
|
|
}}
|
|
>
|
|
<span
|
|
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
|
style={{
|
|
background: "rgba(232, 93, 58, 0.1)",
|
|
color: "var(--color-accent)",
|
|
}}
|
|
>
|
|
<NodeTypeIcon type="object" />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<div
|
|
className="text-sm font-medium capitalize truncate"
|
|
style={{ color: "var(--color-text)" }}
|
|
>
|
|
{obj.name}
|
|
</div>
|
|
<div className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
|
{obj.defaultView === "kanban" ? "Kanban board" : "Table view"}
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{documents.length > 0 && (
|
|
<div>
|
|
<h2
|
|
className="text-sm font-medium uppercase tracking-wider mb-3"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
Documents
|
|
</h2>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{documents.map((doc) => (
|
|
<button
|
|
type="button"
|
|
key={doc.path}
|
|
onClick={() => onNodeSelect(doc)}
|
|
className="flex items-center gap-3 p-4 rounded-xl text-left transition-all duration-100 cursor-pointer"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
border: "1px solid var(--color-border)",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
(e.currentTarget as HTMLElement).style.borderColor = "#60a5fa";
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
(e.currentTarget as HTMLElement).style.borderColor =
|
|
"var(--color-border)";
|
|
}}
|
|
>
|
|
<span
|
|
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0"
|
|
style={{
|
|
background: "rgba(96, 165, 250, 0.1)",
|
|
color: "#60a5fa",
|
|
}}
|
|
>
|
|
<NodeTypeIcon type="document" />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<div
|
|
className="text-sm font-medium truncate"
|
|
style={{ color: "var(--color-text)" }}
|
|
>
|
|
{doc.name.replace(/\.md$/, "")}
|
|
</div>
|
|
<div className="text-xs" style={{ color: "var(--color-text-muted)" }}>
|
|
Document
|
|
</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Shared icon for node types ---
|
|
|
|
function NodeTypeIcon({ type }: { type: string }) {
|
|
switch (type) {
|
|
case "object":
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
|
|
</svg>
|
|
);
|
|
case "document":
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />
|
|
</svg>
|
|
);
|
|
case "folder":
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
|
</svg>
|
|
);
|
|
case "database":
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<ellipse cx="12" cy="5" rx="9" ry="3" />
|
|
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
|
|
<path d="M3 12A9 3 0 0 0 21 12" />
|
|
</svg>
|
|
);
|
|
case "report":
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="12" x2="12" y1="20" y2="10" />
|
|
<line x1="18" x2="18" y1="20" y2="4" />
|
|
<line x1="6" x2="6" y1="20" y2="14" />
|
|
</svg>
|
|
);
|
|
default:
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
|
</svg>
|
|
);
|
|
}
|
|
}
|