Initial SFERA platform baseline
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,955 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight, Filter, Loader2, Search } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { getMetadataTreeChildren, getMetadataTreePath, searchMetadataTree, type MetadataTreeNode } from "@/lib/api";
|
||||
import type { UiLanguage } from "@/lib/i18n";
|
||||
|
||||
type TreeMode = "overview" | "module" | "form" | "properties" | "events" | "versions" | "documentation" | "knowledge" | "learning" | "flowchart";
|
||||
type TreeFilter = "all" | "onec" | "sfera" | "environments";
|
||||
|
||||
type LazyMetadataTreeProps = {
|
||||
apiUrl?: string;
|
||||
className?: string;
|
||||
language: UiLanguage;
|
||||
projectId: string;
|
||||
root: MetadataTreeNode;
|
||||
selectedNode?: MetadataTreeNode | null;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 80;
|
||||
|
||||
const iconFiles: Record<string, string> = {
|
||||
attribute: "attribute.svg",
|
||||
"business-process": "businessProcess.svg",
|
||||
catalog: "catalog.svg",
|
||||
command: "command.svg",
|
||||
common: "common.svg",
|
||||
configuration: "common.svg",
|
||||
constant: "constant.svg",
|
||||
document: "document.svg",
|
||||
enum: "enum.svg",
|
||||
environment: "folder.svg",
|
||||
environments: "folder.svg",
|
||||
event: "eventSubscription.svg",
|
||||
"external-source": "externalDataSource.svg",
|
||||
extension: "folder.svg",
|
||||
form: "form.svg",
|
||||
folder: "folder.svg",
|
||||
"exchange-plan": "exchangePlan.svg",
|
||||
"scheduled-job": "scheduledJob.svg",
|
||||
journal: "documentJournal.svg",
|
||||
layout: "template.svg",
|
||||
metadata: "folder.svg",
|
||||
module: "commonModule.svg",
|
||||
movement: "operation.svg",
|
||||
plan: "chartsOfAccount.svg",
|
||||
processing: "dataProcessor.svg",
|
||||
project: "folder.svg",
|
||||
register: "accumulationRegister.svg",
|
||||
report: "report.svg",
|
||||
role: "role.svg",
|
||||
service: "http.svg",
|
||||
tabular: "tabularSection.svg",
|
||||
table: "tabularSection.svg",
|
||||
task: "task.svg",
|
||||
web: "http.svg"
|
||||
};
|
||||
|
||||
export function LazyMetadataTree({
|
||||
apiUrl,
|
||||
className = "",
|
||||
language,
|
||||
projectId,
|
||||
root,
|
||||
selectedNode,
|
||||
title
|
||||
}: LazyMetadataTreeProps) {
|
||||
const router = useRouter();
|
||||
const ROW_HEIGHT = 24;
|
||||
const OVERSCAN = 8;
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeFilter, setActiveFilter] = useState<TreeFilter>("all");
|
||||
const [activeRoutine, setActiveRoutine] = useState<string | null>(null);
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => new Set(["main-configuration", "common"]));
|
||||
const [nodesById, setNodesById] = useState(() => {
|
||||
const cachedRoot = typeof window === "undefined" ? null : readCachedTree(projectId);
|
||||
return reindexTree(mergeTreeRoots(root, cachedRoot));
|
||||
});
|
||||
const [searchResults, setSearchResults] = useState<MetadataTreeNode[]>([]);
|
||||
const [searchTotal, setSearchTotal] = useState(0);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [searchError, setSearchError] = useState<string | null>(null);
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; node: MetadataTreeNode } | null>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewportHeight, setViewportHeight] = useState(0);
|
||||
const treeViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const autoExpandedPathKey = useRef<string | null>(null);
|
||||
const selectedScrollKey = useRef<string | null>(null);
|
||||
const lastProjectId = useRef(projectId);
|
||||
const restoredScrollRef = useRef(false);
|
||||
const rootNode = nodesById[root.id] ?? root;
|
||||
const serverSearchActive = query.trim().length >= 2;
|
||||
const visibleChildren = useMemo(() => {
|
||||
const source = serverSearchActive ? searchResults : filterTreeChildren(rootNode.children, query);
|
||||
return applyTreeFilter(source, activeFilter);
|
||||
}, [activeFilter, query, rootNode.children, searchResults, serverSearchActive]);
|
||||
const visibleCount = visibleChildren.length;
|
||||
const virtualStart = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
|
||||
const virtualEnd = Math.min(visibleCount, Math.ceil((scrollTop + Math.max(viewportHeight, ROW_HEIGHT)) / ROW_HEIGHT) + OVERSCAN);
|
||||
const virtualItems = visibleChildren.slice(virtualStart, virtualEnd);
|
||||
const topSpacer = virtualStart * ROW_HEIGHT;
|
||||
const bottomSpacer = Math.max(0, (visibleCount - virtualEnd) * ROW_HEIGHT);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
setActiveRoutine(params.get("routine"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setNodesById((current) => {
|
||||
const cachedRoot = readCachedTree(projectId);
|
||||
const currentRoot = current[root.id];
|
||||
const mergedRoot = mergeTreeRoots(root, cachedRoot ?? currentRoot);
|
||||
return reindexTree(mergedRoot);
|
||||
});
|
||||
autoExpandedPathKey.current = null;
|
||||
selectedScrollKey.current = null;
|
||||
if (lastProjectId.current !== projectId) {
|
||||
setScrollTop(0);
|
||||
lastProjectId.current = projectId;
|
||||
restoredScrollRef.current = false;
|
||||
}
|
||||
}, [projectId, root]);
|
||||
|
||||
useEffect(() => {
|
||||
const defaults = new Set(["main-configuration", "common"]);
|
||||
try {
|
||||
const saved = window.localStorage.getItem(expandedStorageKey(projectId));
|
||||
setExpandedIds(saved ? new Set(JSON.parse(saved) as string[]) : defaults);
|
||||
} catch {
|
||||
setExpandedIds(defaults);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = Number(window.localStorage.getItem(scrollStorageKey(projectId)) ?? "0");
|
||||
if (!Number.isFinite(saved) || saved <= 0) {
|
||||
return;
|
||||
}
|
||||
window.requestAnimationFrame(() => {
|
||||
const viewport = treeViewportRef.current;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
viewport.scrollTop = saved;
|
||||
setScrollTop(saved);
|
||||
restoredScrollRef.current = true;
|
||||
});
|
||||
} catch {
|
||||
// Ignore local storage failures; the tree remains usable without persisted scroll.
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem(expandedStorageKey(projectId), JSON.stringify([...expandedIds]));
|
||||
}, [expandedIds, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const rootForCache = nodesById[root.id];
|
||||
if (rootForCache) {
|
||||
writeCachedTree(projectId, rootForCache);
|
||||
}
|
||||
}, [nodesById, projectId, root.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const close = () => setContextMenu(null);
|
||||
window.addEventListener("click", close);
|
||||
window.addEventListener("keydown", close);
|
||||
return () => {
|
||||
window.removeEventListener("click", close);
|
||||
window.removeEventListener("keydown", close);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = treeViewportRef.current;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
const syncViewport = () => {
|
||||
setViewportHeight(viewport.clientHeight);
|
||||
setScrollTop(viewport.scrollTop);
|
||||
try {
|
||||
window.localStorage.setItem(scrollStorageKey(projectId), String(viewport.scrollTop));
|
||||
} catch {
|
||||
// Ignore local storage failures; scroll persistence is a convenience.
|
||||
}
|
||||
};
|
||||
syncViewport();
|
||||
const observer = new ResizeObserver(syncViewport);
|
||||
observer.observe(viewport);
|
||||
viewport.addEventListener("scroll", syncViewport, { passive: true });
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
viewport.removeEventListener("scroll", syncViewport);
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedNode?.id) {
|
||||
return;
|
||||
}
|
||||
const pathKey = `${projectId}:${selectedNode.id}`;
|
||||
if (autoExpandedPathKey.current === pathKey) {
|
||||
return;
|
||||
}
|
||||
autoExpandedPathKey.current = pathKey;
|
||||
let cancelled = false;
|
||||
const expandSelectedPath = async () => {
|
||||
try {
|
||||
const pathResponse = await getMetadataTreePath(projectId, selectedNode.id, { apiUrl });
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setExpandedIds((current) => new Set([...current, ...pathResponse.path.slice(0, -1)]));
|
||||
let localRoot = nodesById[root.id] ?? root;
|
||||
let localIndex = reindexTree(localRoot);
|
||||
for (const step of pathResponse.steps) {
|
||||
const parentId = step.parent_id;
|
||||
const childId = step.child_id;
|
||||
const pageOffset = Math.floor(step.offset / PAGE_SIZE) * PAGE_SIZE;
|
||||
let parent = localIndex[parentId];
|
||||
if (parent && !parent.children.some((child) => child.id === childId) && parent.has_more) {
|
||||
const response = await getMetadataTreeChildren(projectId, parentId, {
|
||||
apiUrl,
|
||||
offset: pageOffset,
|
||||
limit: PAGE_SIZE
|
||||
});
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
localRoot = mergeTreeChildren(localRoot, parentId, response.children, response.has_more, response.total, response.offset);
|
||||
localIndex = reindexTree(localRoot);
|
||||
parent = localIndex[parentId];
|
||||
}
|
||||
}
|
||||
if (!cancelled) {
|
||||
setNodesById(reindexTree(localRoot));
|
||||
}
|
||||
} catch {
|
||||
// The pinned current object still gives a stable navigation target if a path cannot be resolved.
|
||||
}
|
||||
};
|
||||
void expandSelectedPath();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [apiUrl, nodesById, projectId, root, selectedNode?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedNode?.id || selectedScrollKey.current === selectedNode.id) {
|
||||
return;
|
||||
}
|
||||
if (restoredScrollRef.current) {
|
||||
selectedScrollKey.current = selectedNode.id;
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
const selectedElement = document.querySelector(`[data-tree-node-id="${escapeCssAttribute(selectedNode.id)}"]`);
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: "center", inline: "nearest" });
|
||||
selectedScrollKey.current = selectedNode.id;
|
||||
}
|
||||
}, 100);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [nodesById, selectedNode?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedQuery = query.trim();
|
||||
if (normalizedQuery.length < 2) {
|
||||
setSearchResults([]);
|
||||
setSearchTotal(0);
|
||||
setSearchError(null);
|
||||
setSearchLoading(false);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(async () => {
|
||||
setSearchLoading(true);
|
||||
setSearchError(null);
|
||||
try {
|
||||
const response = await searchMetadataTree(projectId, normalizedQuery, { apiUrl, limit: 80 });
|
||||
if (!controller.signal.aborted) {
|
||||
setSearchResults(response.results);
|
||||
setSearchTotal(response.total);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
setSearchError(error instanceof Error ? error.message : "Search failed");
|
||||
setSearchResults([]);
|
||||
setSearchTotal(0);
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}
|
||||
}, 250);
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [apiUrl, projectId, query]);
|
||||
|
||||
const setNodeExpanded = (nodeId: string, expanded: boolean) => {
|
||||
setExpandedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (expanded) {
|
||||
next.add(nodeId);
|
||||
} else {
|
||||
next.delete(nodeId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateNode = (nodeId: string, updater: (node: MetadataTreeNode) => MetadataTreeNode) => {
|
||||
setNodesById((current) => {
|
||||
const target = current[nodeId];
|
||||
if (!target) {
|
||||
return current;
|
||||
}
|
||||
const updated = updater(target);
|
||||
return reindexTree(replaceTreeNode(current[root.id] ?? rootNode, updated));
|
||||
});
|
||||
};
|
||||
|
||||
const mergeChildren = (nodeId: string, children: MetadataTreeNode[], hasMore: boolean, total: number, offset?: number) => {
|
||||
updateNode(nodeId, (node) => {
|
||||
return mergeNodeChildren(node, children, hasMore, total, offset);
|
||||
});
|
||||
};
|
||||
|
||||
const navigateToNode = (href: string) => {
|
||||
if (href !== "#") {
|
||||
persistViewportState(projectId, treeViewportRef.current, expandedIds);
|
||||
const [, query = ""] = href.split("?");
|
||||
setActiveRoutine(new URLSearchParams(query).get("routine"));
|
||||
router.push(href, { scroll: false });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={["flex min-h-0 flex-col", className].join(" ")}>
|
||||
<div className="border-b border-border bg-[#efeddc] px-2 py-2">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<TreeIcon icon="configuration" />
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{projectId}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex h-7 items-center gap-1 border-y border-border/70 px-1 text-muted-foreground">
|
||||
{["+", "✎", "×", "↑", "↓", "⟳", "⚙"].map((item) => (
|
||||
<button className="flex h-5 w-5 items-center justify-center rounded border border-transparent text-xs hover:border-border hover:bg-card" key={item} type="button">
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="mt-2 flex min-w-0 items-center gap-2 border border-border bg-background px-2 py-1.5 text-xs text-muted-foreground">
|
||||
<Search className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<input
|
||||
className="min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={language === "ru" ? "Поиск в дереве" : "Search tree"}
|
||||
value={query}
|
||||
/>
|
||||
{searchLoading ? <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" aria-hidden="true" /> : <Filter className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />}
|
||||
</label>
|
||||
<div className="mt-2 flex items-center gap-1 text-[11px]">
|
||||
<FilterButton active={activeFilter === "all"} label={language === "ru" ? "Все" : "All"} onClick={() => setActiveFilter("all")} value="all" />
|
||||
<FilterButton active={activeFilter === "onec"} label="1C" onClick={() => setActiveFilter("onec")} value="onec" />
|
||||
<FilterButton active={activeFilter === "sfera"} label="SFERA" onClick={() => setActiveFilter("sfera")} value="sfera" />
|
||||
<FilterButton
|
||||
active={activeFilter === "environments"}
|
||||
label={language === "ru" ? "Среды" : "Environments"}
|
||||
onClick={() => setActiveFilter("environments")}
|
||||
value="environments"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{selectedNode ? (
|
||||
<div className="border-b border-border bg-[#fffceb] px-2 py-2">
|
||||
<div className="text-[10px] font-semibold uppercase text-muted-foreground">
|
||||
{language === "ru" ? "Текущий объект" : "Current Object"}
|
||||
</div>
|
||||
<a
|
||||
className="mt-1 flex min-h-7 min-w-0 items-center gap-1.5 border border-border bg-background px-1.5 text-sm text-primary hover:bg-[#e9f1ff]"
|
||||
href={hrefForNode(language, projectId, selectedNode)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateToNode(hrefForNode(language, projectId, selectedNode));
|
||||
}}
|
||||
title={selectedNode.qualified_name ?? selectedNode.label}
|
||||
>
|
||||
<TreeIcon icon={selectedNode.icon} />
|
||||
<span className="min-w-0 flex-1 truncate">{selectedNode.qualified_name ?? selectedNode.label}</span>
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-auto px-1 py-2" data-virtual-scroll ref={treeViewportRef}>
|
||||
{searchError ? <div className="px-2 py-1 text-xs text-destructive">{searchError}</div> : null}
|
||||
{topSpacer > 0 ? <div aria-hidden="true" style={{ height: `${topSpacer}px` }} /> : null}
|
||||
{virtualItems.map((node) => (
|
||||
<LazyTreeNode
|
||||
activeRoutine={activeRoutine}
|
||||
apiUrl={apiUrl}
|
||||
key={node.id}
|
||||
language={language}
|
||||
mergeChildren={mergeChildren}
|
||||
node={node}
|
||||
projectId={projectId}
|
||||
query={query}
|
||||
expandedIds={expandedIds}
|
||||
openContextMenu={setContextMenu}
|
||||
navigateToNode={navigateToNode}
|
||||
searchMode={serverSearchActive}
|
||||
selectedNodeId={selectedNode?.id}
|
||||
setNodeExpanded={setNodeExpanded}
|
||||
/>
|
||||
))}
|
||||
{bottomSpacer > 0 ? <div aria-hidden="true" style={{ height: `${bottomSpacer}px` }} /> : null}
|
||||
</div>
|
||||
{contextMenu ? (
|
||||
<TreeContextMenu
|
||||
language={language}
|
||||
navigateToNode={navigateToNode}
|
||||
node={contextMenu.node}
|
||||
projectId={projectId}
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
/>
|
||||
) : null}
|
||||
<div className="border-t border-border bg-[#f7f5e7] px-2 py-1 text-[11px] text-muted-foreground">
|
||||
{query
|
||||
? language === "ru"
|
||||
? serverSearchActive
|
||||
? `Найдено во всем проекте: ${formatCount(searchTotal, language)}`
|
||||
: `Найдено в загруженных узлах: ${visibleChildren.length}`
|
||||
: serverSearchActive
|
||||
? `Matches in project: ${formatCount(searchTotal, language)}`
|
||||
: `Matches in loaded nodes: ${visibleChildren.length}`
|
||||
: language === "ru"
|
||||
? "Ветки загружаются при раскрытии"
|
||||
: "Branches load on expand"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LazyTreeNode({
|
||||
activeRoutine,
|
||||
apiUrl,
|
||||
expandedIds,
|
||||
language,
|
||||
mergeChildren,
|
||||
navigateToNode,
|
||||
node,
|
||||
openContextMenu,
|
||||
projectId,
|
||||
query,
|
||||
searchMode,
|
||||
selectedNodeId,
|
||||
setNodeExpanded
|
||||
}: {
|
||||
activeRoutine: string | null;
|
||||
apiUrl?: string;
|
||||
expandedIds: Set<string>;
|
||||
language: UiLanguage;
|
||||
mergeChildren: (nodeId: string, children: MetadataTreeNode[], hasMore: boolean, total: number, offset?: number) => void;
|
||||
navigateToNode: (href: string) => void;
|
||||
node: MetadataTreeNode;
|
||||
openContextMenu: (menu: { x: number; y: number; node: MetadataTreeNode }) => void;
|
||||
projectId: string;
|
||||
query: string;
|
||||
searchMode: boolean;
|
||||
selectedNodeId?: string;
|
||||
setNodeExpanded: (nodeId: string, expanded: boolean) => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const expanded = expandedIds.has(node.id);
|
||||
const childNodes = useMemo(() => filterTreeChildren(node.children, query), [node.children, query]);
|
||||
const canExpand = node.children.length > 0 || node.has_more;
|
||||
const title = node.count > 0 ? `${node.label} (${formatCount(node.count, language)})` : node.label;
|
||||
const isActive = node.id === selectedNodeId || Boolean(activeRoutine && (node.qualified_name === activeRoutine || node.label === activeRoutine));
|
||||
const loadedLabel =
|
||||
node.has_more && node.count > 0
|
||||
? `${formatCount(node.loaded_count || node.children.length, language)}/${formatCount(node.count, language)}`
|
||||
: null;
|
||||
|
||||
async function loadMore() {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const response = await getMetadataTreeChildren(projectId, node.id, {
|
||||
apiUrl,
|
||||
offset: node.loaded_count || node.children.length,
|
||||
limit: PAGE_SIZE
|
||||
});
|
||||
mergeChildren(node.id, response.children, response.has_more, response.total, response.offset);
|
||||
} catch (error) {
|
||||
setLoadError(error instanceof Error ? error.message : "Не удалось загрузить ветку");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (searchMode) {
|
||||
return;
|
||||
}
|
||||
const nextExpanded = !expanded;
|
||||
setNodeExpanded(node.id, nextExpanded);
|
||||
if (nextExpanded && node.children.length === 0 && node.has_more) {
|
||||
await loadMore();
|
||||
}
|
||||
}
|
||||
|
||||
const row = (
|
||||
<div
|
||||
className={[
|
||||
"flex min-h-6 min-w-0 items-center gap-1.5 px-1.5 text-sm text-foreground hover:bg-[#e9f1ff]",
|
||||
isActive ? "bg-[#dbeafe] text-primary" : ""
|
||||
].join(" ")}
|
||||
data-tree-node-id={node.id}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
openContextMenu({ x: event.clientX, y: event.clientY, node });
|
||||
}}
|
||||
onClick={(event) => {
|
||||
const target = event.target instanceof HTMLElement ? event.target : null;
|
||||
if (target?.closest("button,a")) {
|
||||
return;
|
||||
}
|
||||
const href = hrefForNode(language, projectId, node);
|
||||
navigateToNode(href);
|
||||
}}
|
||||
onDoubleClick={(event) => {
|
||||
const href = hrefForNode(language, projectId, node);
|
||||
if (href !== "#") {
|
||||
event.preventDefault();
|
||||
navigateToNode(href);
|
||||
}
|
||||
}}
|
||||
title={node.qualified_name ?? node.label}
|
||||
>
|
||||
<button
|
||||
aria-label={expanded ? "Свернуть" : "Развернуть"}
|
||||
className="flex h-4 w-4 shrink-0 items-center justify-center"
|
||||
disabled={!canExpand || searchMode}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void toggle();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{canExpand ? (
|
||||
loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ChevronRight className={["h-3.5 w-3.5 transition", expanded ? "rotate-90" : ""].join(" ")} />
|
||||
) : (
|
||||
<span className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<TreeIcon icon={node.icon} />
|
||||
<a
|
||||
className="min-w-0 flex-1 truncate"
|
||||
href={hrefForNode(language, projectId, node)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateToNode(hrefForNode(language, projectId, node));
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
{loadedLabel ? <span className="rounded border border-border bg-background px-1 text-[10px] text-muted-foreground">{loadedLabel}</span> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{row}
|
||||
{expanded ? (
|
||||
<div className="ml-4 border-l border-border/70 pl-1">
|
||||
{childNodes.map((child) => (
|
||||
<LazyTreeNode
|
||||
activeRoutine={activeRoutine}
|
||||
apiUrl={apiUrl}
|
||||
key={child.id}
|
||||
language={language}
|
||||
mergeChildren={mergeChildren}
|
||||
navigateToNode={navigateToNode}
|
||||
node={child}
|
||||
openContextMenu={openContextMenu}
|
||||
projectId={projectId}
|
||||
query={query}
|
||||
expandedIds={expandedIds}
|
||||
searchMode={searchMode}
|
||||
selectedNodeId={selectedNodeId}
|
||||
setNodeExpanded={setNodeExpanded}
|
||||
/>
|
||||
))}
|
||||
{node.has_more ? (
|
||||
<button className="ml-6 mt-1 h-6 px-2 text-xs text-primary hover:bg-[#e9f1ff]" disabled={loading} onClick={loadMore} type="button">
|
||||
{loading ? (language === "ru" ? "Загрузка..." : "Loading...") : (language === "ru" ? "Загрузить еще" : "Load more")}
|
||||
</button>
|
||||
) : null}
|
||||
{loadError ? <div className="ml-6 px-2 py-1 text-xs text-destructive">{loadError}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeContextMenu({
|
||||
language,
|
||||
navigateToNode,
|
||||
node,
|
||||
projectId,
|
||||
x,
|
||||
y
|
||||
}: {
|
||||
language: UiLanguage;
|
||||
navigateToNode: (href: string) => void;
|
||||
node: MetadataTreeNode;
|
||||
projectId: string;
|
||||
x: number;
|
||||
y: number;
|
||||
}) {
|
||||
const openHref = hrefForNode(language, projectId, node);
|
||||
const items = contextMenuItemsForNode(node, openHref, language);
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 w-56 border border-border bg-[#3f3f3f] py-1 text-sm text-white shadow-xl"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
<div className="border-b border-white/10 px-3 py-2 text-xs text-white/70">{node.qualified_name ?? node.label}</div>
|
||||
{items.map((item) => (
|
||||
<a
|
||||
className="block px-3 py-2 hover:bg-white/10"
|
||||
href={item.href}
|
||||
key={item.label}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
navigateToNode(item.href);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function contextMenuItemsForNode(node: MetadataTreeNode, openHref: string, language: UiLanguage) {
|
||||
const labels = {
|
||||
open: language === "ru" ? "Открыть" : "Open",
|
||||
openModule: language === "ru" ? "Открыть код" : "Open code",
|
||||
openHandler: language === "ru" ? "Открыть обработчик" : "Open handler",
|
||||
openForm: language === "ru" ? "Открыть форму" : "Open form",
|
||||
properties: language === "ru" ? "Свойства" : "Properties",
|
||||
versions: language === "ru" ? "Версии" : "Versions",
|
||||
impact: language === "ru" ? "Связи и влияние" : "Impact",
|
||||
knowledge: language === "ru" ? "Знания объекта" : "Object knowledge"
|
||||
};
|
||||
const value = `${node.kind} ${node.label} ${node.icon}`.toLocaleLowerCase("ru-RU");
|
||||
const items = [{ label: labels.open, href: openHref }];
|
||||
if (value.includes("module") || value.includes("модул") || value.includes("procedure") || value.includes("function") || value.includes("процед") || value.includes("функц")) {
|
||||
items.push({ label: labels.openModule, href: withMode(openHref, "module") });
|
||||
items.push({ label: labels.versions, href: withMode(openHref, "versions") });
|
||||
return items;
|
||||
}
|
||||
if (value.includes("command") || value.includes("команд") || value.includes("event") || value.includes("событ")) {
|
||||
items.push({ label: labels.openHandler, href: withMode(openHref, "module") });
|
||||
items.push({ label: labels.properties, href: withMode(openHref, "properties") });
|
||||
return items;
|
||||
}
|
||||
if (value.includes("form") || value.includes("форма")) {
|
||||
items.push({ label: labels.openForm, href: withMode(openHref, "form") });
|
||||
items.push({ label: labels.openModule, href: withMode(openHref, "module") });
|
||||
items.push({ label: labels.properties, href: withMode(openHref, "properties") });
|
||||
return items;
|
||||
}
|
||||
if (node.qualified_name) {
|
||||
items.push({ label: labels.openModule, href: withMode(openHref, "module") });
|
||||
items.push({ label: labels.properties, href: withMode(openHref, "properties") });
|
||||
items.push({ label: labels.versions, href: withMode(openHref, "versions") });
|
||||
items.push({ label: labels.impact, href: withMode(openHref, "documentation") });
|
||||
items.push({ label: labels.knowledge, href: withMode(openHref, "knowledge") });
|
||||
return items;
|
||||
}
|
||||
items.push({ label: labels.properties, href: withMode(openHref, "properties") });
|
||||
return items;
|
||||
}
|
||||
|
||||
function TreeIcon({ icon }: { icon: string }) {
|
||||
const file = iconFiles[icon] ?? iconFiles.folder;
|
||||
return <img alt="" aria-hidden="true" className="h-4 w-4 shrink-0" src={`/icons/1c-metadata/light/${file}`} />;
|
||||
}
|
||||
|
||||
function FilterButton({
|
||||
active,
|
||||
label,
|
||||
onClick,
|
||||
value
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
value: TreeFilter;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={[
|
||||
"h-6 border px-2",
|
||||
active ? "border-primary bg-primary text-primary-foreground" : "border-border bg-background text-foreground hover:bg-card"
|
||||
].join(" ")}
|
||||
data-lazy-tree-filter={value}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function hrefForNode(language: UiLanguage, projectId: string, node: MetadataTreeNode) {
|
||||
if (!node.qualified_name && node.kind !== "SFERA_SECTION") {
|
||||
return "#";
|
||||
}
|
||||
if (node.kind === "SFERA_SECTION" && node.label === "Обзор проекта") {
|
||||
return `/editor?lang=${language}&project=${encodeURIComponent(projectId)}&mode=overview`;
|
||||
}
|
||||
if (node.kind === "SFERA_SECTION" && node.label === "Блок-схема") {
|
||||
return `/editor?lang=${language}&project=${encodeURIComponent(projectId)}&mode=flowchart`;
|
||||
}
|
||||
const mode = modeForNode(node);
|
||||
return `/editor?lang=${language}&project=${encodeURIComponent(projectId)}&mode=${mode}&routine=${encodeURIComponent(node.qualified_name ?? node.label)}`;
|
||||
}
|
||||
|
||||
function withMode(href: string, mode: TreeMode) {
|
||||
if (href === "#") {
|
||||
return href;
|
||||
}
|
||||
const [path, query = ""] = href.split("?");
|
||||
const params = new URLSearchParams(query);
|
||||
params.set("mode", mode);
|
||||
return `${path}?${params.toString()}`;
|
||||
}
|
||||
|
||||
function modeForNode(node: MetadataTreeNode): TreeMode {
|
||||
const value = `${node.kind} ${node.label} ${node.icon}`.toLocaleLowerCase("ru-RU");
|
||||
if (value.includes("form") || value.includes("форма")) {
|
||||
return "form";
|
||||
}
|
||||
if (value.includes("event") || value.includes("событ")) {
|
||||
return "events";
|
||||
}
|
||||
if (value.includes("version") || value.includes("верс")) {
|
||||
return "versions";
|
||||
}
|
||||
if (value.includes("knowledge") || value.includes("знан")) {
|
||||
return "knowledge";
|
||||
}
|
||||
if (value.includes("flowchart") || value.includes("блок-схем")) {
|
||||
return "flowchart";
|
||||
}
|
||||
return "module";
|
||||
}
|
||||
|
||||
function filterTreeChildren(children: MetadataTreeNode[], query: string) {
|
||||
const normalized = query.trim().toLocaleLowerCase("ru-RU");
|
||||
if (!normalized) {
|
||||
return children;
|
||||
}
|
||||
return children
|
||||
.map((child) => filterTreeNode(child, normalized))
|
||||
.filter((child): child is MetadataTreeNode => Boolean(child));
|
||||
}
|
||||
|
||||
function filterTreeNode(node: MetadataTreeNode, normalizedQuery: string): MetadataTreeNode | null {
|
||||
const selfMatches =
|
||||
node.label.toLocaleLowerCase("ru-RU").includes(normalizedQuery) ||
|
||||
(node.qualified_name?.toLocaleLowerCase("ru-RU").includes(normalizedQuery) ?? false);
|
||||
const children = node.children
|
||||
.map((child) => filterTreeNode(child, normalizedQuery))
|
||||
.filter((child): child is MetadataTreeNode => Boolean(child));
|
||||
if (!selfMatches && children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return { ...node, children };
|
||||
}
|
||||
|
||||
function indexTree(root: MetadataTreeNode) {
|
||||
return reindexTree(root);
|
||||
}
|
||||
|
||||
function reindexTree(root: MetadataTreeNode) {
|
||||
const result: Record<string, MetadataTreeNode> = {};
|
||||
const visit = (node: MetadataTreeNode) => {
|
||||
result[node.id] = node;
|
||||
node.children.forEach(visit);
|
||||
};
|
||||
visit(root);
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeTreeRoots(base: MetadataTreeNode, preserved?: MetadataTreeNode | null): MetadataTreeNode {
|
||||
if (!preserved || base.id !== preserved.id) {
|
||||
return base;
|
||||
}
|
||||
const preservedById = reindexTree(preserved);
|
||||
const mergeNode = (node: MetadataTreeNode): MetadataTreeNode => {
|
||||
const cached = preservedById[node.id];
|
||||
const cachedChildren = cached?.children ?? [];
|
||||
const mergedChildrenById = new Map<string, MetadataTreeNode>();
|
||||
for (const child of node.children) {
|
||||
mergedChildrenById.set(child.id, mergeNode(child));
|
||||
}
|
||||
for (const child of cachedChildren) {
|
||||
if (!mergedChildrenById.has(child.id)) {
|
||||
mergedChildrenById.set(child.id, child);
|
||||
}
|
||||
}
|
||||
const nextChildren = [...mergedChildrenById.values()];
|
||||
const cachedLoaded = cached?.loaded_count ?? cachedChildren.length;
|
||||
const nodeLoaded = node.loaded_count ?? node.children.length;
|
||||
return {
|
||||
...node,
|
||||
children: nextChildren,
|
||||
loaded_count: Math.max(nodeLoaded, cachedLoaded, nextChildren.length),
|
||||
has_more: cached ? node.has_more && cached.has_more : node.has_more,
|
||||
count: Math.max(node.count ?? 0, cached?.count ?? 0)
|
||||
};
|
||||
};
|
||||
return mergeNode(base);
|
||||
}
|
||||
|
||||
function replaceTreeNode(root: MetadataTreeNode, replacement: MetadataTreeNode): MetadataTreeNode {
|
||||
if (root.id === replacement.id) {
|
||||
return replacement;
|
||||
}
|
||||
return {
|
||||
...root,
|
||||
children: root.children.map((child) => replaceTreeNode(child, replacement))
|
||||
};
|
||||
}
|
||||
|
||||
function mergeTreeChildren(
|
||||
root: MetadataTreeNode,
|
||||
nodeId: string,
|
||||
children: MetadataTreeNode[],
|
||||
hasMore: boolean,
|
||||
total: number,
|
||||
offset = 0
|
||||
): MetadataTreeNode {
|
||||
const target = reindexTree(root)[nodeId];
|
||||
if (!target) {
|
||||
return root;
|
||||
}
|
||||
return replaceTreeNode(root, mergeNodeChildren(target, children, hasMore, total, offset));
|
||||
}
|
||||
|
||||
function mergeNodeChildren(
|
||||
node: MetadataTreeNode,
|
||||
children: MetadataTreeNode[],
|
||||
hasMore: boolean,
|
||||
total: number,
|
||||
offset = node.children.length
|
||||
): MetadataTreeNode {
|
||||
const existingIds = new Set(node.children.map((child) => child.id));
|
||||
const nextChildren = [...node.children, ...children.filter((child) => !existingIds.has(child.id))];
|
||||
return {
|
||||
...node,
|
||||
children: nextChildren,
|
||||
count: total || node.count,
|
||||
loaded_count: Math.max(node.loaded_count || 0, offset + children.length, nextChildren.length),
|
||||
has_more: hasMore
|
||||
};
|
||||
}
|
||||
|
||||
function formatCount(value: number, language: UiLanguage) {
|
||||
return new Intl.NumberFormat(language === "ru" ? "ru-RU" : "en-US").format(value);
|
||||
}
|
||||
|
||||
function expandedStorageKey(projectId: string) {
|
||||
return `sfera.metadata-tree.expanded.${projectId}`;
|
||||
}
|
||||
|
||||
function scrollStorageKey(projectId: string) {
|
||||
return `sfera.metadata-tree.scroll.${projectId}`;
|
||||
}
|
||||
|
||||
function treeCacheStorageKey(projectId: string) {
|
||||
return `sfera.metadata-tree.cache.${projectId}`;
|
||||
}
|
||||
|
||||
function readCachedTree(projectId: string) {
|
||||
try {
|
||||
const payload = window.sessionStorage.getItem(treeCacheStorageKey(projectId));
|
||||
return payload ? (JSON.parse(payload) as MetadataTreeNode) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedTree(projectId: string, root: MetadataTreeNode) {
|
||||
try {
|
||||
window.sessionStorage.setItem(treeCacheStorageKey(projectId), JSON.stringify(root));
|
||||
} catch {
|
||||
try {
|
||||
window.sessionStorage.removeItem(treeCacheStorageKey(projectId));
|
||||
} catch {
|
||||
// Ignore storage failures; server paging still works.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function persistViewportState(projectId: string, viewport: HTMLDivElement | null, expandedIds: Set<string>) {
|
||||
try {
|
||||
window.localStorage.setItem(expandedStorageKey(projectId), JSON.stringify([...expandedIds]));
|
||||
if (viewport) {
|
||||
window.localStorage.setItem(scrollStorageKey(projectId), String(viewport.scrollTop));
|
||||
}
|
||||
} catch {
|
||||
// Ignore local storage failures; this only affects visual state restoration.
|
||||
}
|
||||
}
|
||||
|
||||
function applyTreeFilter(children: MetadataTreeNode[], filter: TreeFilter) {
|
||||
if (filter === "all") {
|
||||
return children;
|
||||
}
|
||||
if (filter === "sfera") {
|
||||
return children.filter((child) => child.kind === "SFERA_ROOT" || child.kind === "SFERA_SECTION" || child.label === "SFERA");
|
||||
}
|
||||
if (filter === "environments") {
|
||||
return children.filter((child) => child.kind === "ENVIRONMENTS" || child.label === "Среды");
|
||||
}
|
||||
return children.filter((child) => {
|
||||
if (child.kind === "MAIN_CONFIGURATION" || child.kind === "EXTENSION" || child.kind === "CONTEXT_CONFIGURATION" || child.kind === "REFERENCE_CONFIGURATION") {
|
||||
return true;
|
||||
}
|
||||
const lower = child.label.toLocaleLowerCase("ru-RU");
|
||||
return lower.includes("конфигурац") || lower.includes("расширение");
|
||||
});
|
||||
}
|
||||
|
||||
function escapeCssAttribute(value: string) {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Bell,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
FolderPlus,
|
||||
Search,
|
||||
Settings,
|
||||
Sparkles,
|
||||
UserCircle
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type React from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { messages, type UiLanguage } from "@/lib/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const environmentProjectStorageKey = "sfera.environment.selected-project";
|
||||
|
||||
export type ProjectOption = {
|
||||
project_id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const languageOptions = [
|
||||
{ language: "ru", labelKey: "languageRu" },
|
||||
{ language: "en", labelKey: "languageEn" }
|
||||
] as const;
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
apiStatus,
|
||||
language = "ru",
|
||||
projectId,
|
||||
projectOptions = []
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
apiStatus: "ok" | "error";
|
||||
language?: UiLanguage;
|
||||
projectId?: string;
|
||||
projectOptions?: ProjectOption[];
|
||||
}>) {
|
||||
useEffect(() => {
|
||||
const url = new URL(window.location.href);
|
||||
const urlProjectId = url.searchParams.get("project")?.trim();
|
||||
const savedProjectId = window.localStorage.getItem(environmentProjectStorageKey)?.trim();
|
||||
if (urlProjectId) {
|
||||
window.localStorage.setItem(environmentProjectStorageKey, urlProjectId);
|
||||
return;
|
||||
}
|
||||
if (savedProjectId && savedProjectId !== projectId) {
|
||||
url.searchParams.set("project", savedProjectId);
|
||||
if (language === "en") {
|
||||
url.searchParams.set("lang", "en");
|
||||
}
|
||||
window.location.replace(url.toString());
|
||||
return;
|
||||
}
|
||||
if (projectId) {
|
||||
window.localStorage.setItem(environmentProjectStorageKey, projectId);
|
||||
}
|
||||
}, [language, projectId]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<TopEnvironmentBar apiStatus={apiStatus} language={language} projectId={projectId} projectOptions={projectOptions} />
|
||||
{children}
|
||||
<EnvironmentStatusBar apiStatus={apiStatus} language={language} projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TopEnvironmentBar({
|
||||
agentStatusLabel,
|
||||
agentStatusTone = "info",
|
||||
apiStatus,
|
||||
language = "ru",
|
||||
onProjectChange,
|
||||
projectId,
|
||||
projectOptions = [],
|
||||
statusLabel
|
||||
}: Readonly<{
|
||||
agentStatusLabel?: string;
|
||||
agentStatusTone?: "success" | "warning" | "danger" | "info" | "neutral";
|
||||
apiStatus: "ok" | "error";
|
||||
language?: UiLanguage;
|
||||
onProjectChange?: (projectId: string) => void;
|
||||
projectId?: string;
|
||||
projectOptions?: ProjectOption[];
|
||||
statusLabel?: string;
|
||||
}>) {
|
||||
const t = messages[language];
|
||||
const [globalSearch, setGlobalSearch] = useState("");
|
||||
const projectIds = projectOptions.map((project) => project.project_id);
|
||||
const projects = projectIds.includes(projectId ?? "")
|
||||
? projectOptions
|
||||
: ([projectId ? { project_id: projectId, name: projectId } : null, ...projectOptions].filter(Boolean) as ProjectOption[]);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 border-b border-border bg-background/95 backdrop-blur">
|
||||
<div className="flex h-14 items-center gap-2 px-3 sm:px-4" data-top-project-bar>
|
||||
<a className="flex h-9 shrink-0 items-center gap-2 rounded-md px-2 text-sm font-semibold text-foreground" data-top-bar-logo href={editorHref(projectId, language)}>
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
SFERA
|
||||
</a>
|
||||
<TopSelector className="hidden xl:flex" dataMarker="workspace" label={t.workspaceSelector} value="Рабочее пространство" />
|
||||
<ProjectSelector
|
||||
currentProjectId={projectId}
|
||||
language={language}
|
||||
label={t.projectSelector}
|
||||
onProjectChange={onProjectChange}
|
||||
projects={projects}
|
||||
/>
|
||||
<TopActionLink className="hidden lg:inline-flex" dataMarker="project-settings" href={projectSettingsHref(projectId)} icon={Settings} label={t.projectSettings} />
|
||||
<TopActionLink className="hidden xl:inline-flex" dataMarker="create-project" href="/project-settings?new=1" icon={FolderPlus} label={t.createProject} />
|
||||
<TopSelector className="hidden lg:flex" dataMarker="environment" label={t.environmentSelector} value="Dev" />
|
||||
<TopSelector className="hidden 2xl:flex" dataMarker="active-task" label={t.activeTaskSelector} value={t.none} />
|
||||
<label className="flex min-w-[220px] flex-1 items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm text-muted-foreground" data-global-search>
|
||||
<Search className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<input
|
||||
aria-label={t.searchPlaceholder}
|
||||
className="h-5 min-w-0 flex-1 bg-transparent text-foreground outline-none placeholder:text-muted-foreground"
|
||||
data-global-search-input
|
||||
onChange={(event) => setGlobalSearch(event.target.value)}
|
||||
placeholder={t.searchPlaceholder}
|
||||
value={globalSearch}
|
||||
/>
|
||||
<span className="hidden rounded border border-border bg-background px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground lg:inline">Ctrl+K</span>
|
||||
</label>
|
||||
{statusLabel ? <Badge data-top-bar-badge="work-status">{statusLabel}</Badge> : null}
|
||||
<Badge data-top-bar-badge="api-status" tone={apiStatus === "ok" ? "success" : "danger"}>
|
||||
{apiStatus === "ok" ? t.apiOnline : t.apiOffline}
|
||||
</Badge>
|
||||
<Badge data-top-bar-badge="agent-status" tone={agentStatusTone} className="hidden xl:inline-flex">
|
||||
<Bot className="mr-1 h-3.5 w-3.5" aria-hidden="true" />
|
||||
{agentStatusLabel ?? t.agentOnline}
|
||||
</Badge>
|
||||
<Button aria-label={t.notifications} className="hidden w-9 px-0 lg:inline-flex" data-top-bar-button="notifications" variant="ghost">
|
||||
<Bell className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<div className="hidden h-9 items-center rounded-md border border-border bg-card p-1 sm:flex" data-top-bar-language>
|
||||
{languageOptions.map((option) => (
|
||||
<a
|
||||
className={cn(
|
||||
"flex h-7 items-center rounded px-2 text-xs font-medium transition",
|
||||
language === option.language
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
href={editorHref(projectId, option.language)}
|
||||
key={option.language}
|
||||
>
|
||||
{t[option.labelKey]}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<Button data-top-bar-button="profile" variant="primary">
|
||||
<UserCircle className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline">{t.profile}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnvironmentStatusBar({
|
||||
apiStatus,
|
||||
language = "ru",
|
||||
projectId
|
||||
}: Readonly<{ apiStatus: "ok" | "error"; language?: UiLanguage; projectId?: string }>) {
|
||||
const t = messages[language];
|
||||
return (
|
||||
<footer className="fixed bottom-0 left-0 right-0 z-20 flex h-8 items-center gap-4 overflow-x-auto border-t border-border bg-card px-4 text-xs text-muted-foreground" data-status-bar>
|
||||
<StatusItem label={language === "ru" ? "Проект" : "Project"} value={projectId ?? "не выбран"} marker="project" />
|
||||
<StatusItem label={t.snapshotStatus} value="2026.05.09.001" marker="snapshot" />
|
||||
<StatusItem label={t.agentStatus} value={apiStatus === "ok" ? t.online : t.offline} marker="agent" />
|
||||
<StatusItem label={t.parserStatus} value="OK" marker="parser" />
|
||||
<StatusItem label={t.diagnosticsStatus} value="3" marker="diagnostics" />
|
||||
<StatusItem label={t.taskStatus} value={t.none} marker="active-task" />
|
||||
<StatusItem label={t.privacyStatus} value={t.metadataOnly} marker="privacy" />
|
||||
<StatusItem className="lg:ml-auto" label={t.aiTokens} value="12k/100k" marker="ai-tokens" />
|
||||
<StatusItem label={language === "ru" ? "Пользователь" : "Current user"} value="current-user" marker="current-user" />
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectSelector({
|
||||
currentProjectId,
|
||||
label,
|
||||
language,
|
||||
onProjectChange,
|
||||
projects
|
||||
}: Readonly<{ currentProjectId?: string; label: string; language: UiLanguage; onProjectChange?: (projectId: string) => void; projects: ProjectOption[] }>) {
|
||||
function switchProject(projectId: string) {
|
||||
if (!projectId || projectId === currentProjectId) {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(environmentProjectStorageKey, projectId);
|
||||
if (onProjectChange) {
|
||||
onProjectChange(projectId);
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("project", projectId);
|
||||
if (language === "en") {
|
||||
params.set("lang", "en");
|
||||
}
|
||||
window.location.href = `${window.location.pathname}?${params.toString()}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<label
|
||||
className="flex h-9 shrink-0 items-center gap-1 rounded-md border border-border bg-card px-2.5 text-xs font-medium text-foreground"
|
||||
data-top-bar-selector="project"
|
||||
>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<select
|
||||
aria-label={language === "ru" ? "Выбор проекта" : "Project selector"}
|
||||
className="h-7 max-w-[190px] bg-transparent font-semibold outline-none"
|
||||
onChange={(event) => switchProject(event.target.value)}
|
||||
value={currentProjectId ?? ""}
|
||||
>
|
||||
{projects.length ? null : <option value="">Нет проектов</option>}
|
||||
{projects.map((project) => (
|
||||
<option key={project.project_id} value={project.project_id}>
|
||||
{project.name || project.project_id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function projectSettingsHref(projectId?: string) {
|
||||
return projectId ? `/project-settings?project=${encodeURIComponent(projectId)}` : "/project-settings";
|
||||
}
|
||||
|
||||
function editorHref(projectId?: string, language: UiLanguage = "ru") {
|
||||
const params = new URLSearchParams();
|
||||
if (language === "en") {
|
||||
params.set("lang", "en");
|
||||
}
|
||||
if (projectId) {
|
||||
params.set("project", projectId);
|
||||
}
|
||||
const query = params.toString();
|
||||
return query ? `/editor?${query}` : "/editor";
|
||||
}
|
||||
|
||||
function StatusItem({
|
||||
className,
|
||||
label,
|
||||
marker,
|
||||
value
|
||||
}: Readonly<{ className?: string; label: string; marker: string; value: string }>) {
|
||||
return (
|
||||
<span className={cn("shrink-0", className)} data-status-item={marker}>
|
||||
{label}: {value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function TopSelector({ className, dataMarker, label, value }: Readonly<{ className?: string; dataMarker: string; label: string; value: string }>) {
|
||||
return (
|
||||
<button className={cn("h-9 shrink-0 items-center gap-1 rounded-md border border-border bg-card px-2.5 text-xs font-medium text-foreground hover:bg-muted", className)} data-top-bar-selector={dataMarker} type="button">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span>{value}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TopActionLink({
|
||||
className,
|
||||
dataMarker,
|
||||
href,
|
||||
icon: Icon,
|
||||
label
|
||||
}: Readonly<{
|
||||
className?: string;
|
||||
dataMarker: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
}>) {
|
||||
return (
|
||||
<a
|
||||
className={cn("h-9 shrink-0 items-center gap-2 rounded-md border border-border bg-card px-2.5 text-xs font-medium text-foreground hover:bg-muted", className)}
|
||||
data-top-bar-action={dataMarker}
|
||||
href={href}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>{label}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type BadgeTone = "neutral" | "success" | "warning" | "danger" | "info";
|
||||
|
||||
const tones: Record<BadgeTone, string> = {
|
||||
neutral: "border-border bg-muted text-muted-foreground",
|
||||
success: "border-success/25 bg-success/10 text-success",
|
||||
warning: "border-warning/30 bg-warning/10 text-warning",
|
||||
danger: "border-destructive/25 bg-destructive/10 text-destructive",
|
||||
info: "border-info/25 bg-info/10 text-info"
|
||||
};
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
tone = "neutral",
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement> & {
|
||||
tone?: BadgeTone;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex h-6 items-center rounded-full border px-2.5 text-xs font-medium leading-none",
|
||||
tones[tone],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost";
|
||||
|
||||
const variants: Record<ButtonVariant, string> = {
|
||||
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
secondary: "border border-border bg-card text-foreground hover:bg-muted",
|
||||
ghost: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
};
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
className,
|
||||
variant = "secondary",
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant }) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center gap-2 rounded-lg px-3 text-sm font-medium transition disabled:pointer-events-none disabled:opacity-50",
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Card({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: Readonly<React.HTMLAttributes<HTMLElement>>) {
|
||||
return (
|
||||
<section className={cn("rounded-2xl border border-border bg-card p-4 shadow-soft", className)} {...props}>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user