feat: editor in popover
This commit is contained in:
@@ -3,6 +3,8 @@ import { createPortal } from "react-dom";
|
||||
import { Graph } from "@cosmos.gl/graph";
|
||||
import { subscribeBrowseNodes, fetchRemotePage, type NetworkNode } from "@/api/client";
|
||||
import { renderMicron } from "@/components/editor/micronRenderer";
|
||||
import FloatingWindow, { DITHERED_SHADOW } from "@/components/shared/FloatingWindow";
|
||||
import { useWindowManager } from "@/hooks/useWindowManager";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants — matching cosmos.gl clusters-with-labels example
|
||||
@@ -162,86 +164,14 @@ function buildGraphArrays(
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-window state & BrowserWindow
|
||||
// Browse window data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BrowserWin {
|
||||
id: string; node: NetworkNode;
|
||||
pageHtml: string | null; pageLoading: boolean; pageError: string | null;
|
||||
x: number; y: number; w: number; h: number; zIndex: number;
|
||||
}
|
||||
|
||||
let nextWinZ = 1;
|
||||
|
||||
const DITHERED_SHADOW = `
|
||||
3px 3px 0 0 var(--border), 5px 3px 0 0 transparent, 7px 3px 0 0 var(--border),
|
||||
4px 4px 0 0 transparent, 6px 4px 0 0 var(--border),
|
||||
3px 5px 0 0 var(--border), 5px 5px 0 0 transparent, 7px 5px 0 0 var(--border),
|
||||
4px 6px 0 0 var(--border), 6px 6px 0 0 transparent
|
||||
`;
|
||||
|
||||
function BrowserWindow({
|
||||
win, focused, onUpdate, onClose, onFocus,
|
||||
}: {
|
||||
win: BrowserWin; focused: boolean;
|
||||
onUpdate: (id: string, patch: Partial<BrowserWin>) => void;
|
||||
onClose: (id: string) => void; onFocus: (id: string) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||
const resizeRef = useRef<{ startX: number; startY: number; origW: number; origH: number } | null>(null);
|
||||
useEffect(() => { if (focused) ref.current?.focus(); }, [focused]);
|
||||
|
||||
const onDragStart = useCallback((e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
e.preventDefault(); onFocus(win.id);
|
||||
dragRef.current = { startX: e.clientX, startY: e.clientY, origX: win.x, origY: win.y };
|
||||
const onMove = (ev: MouseEvent) => { if (!dragRef.current) return; onUpdate(win.id, { x: dragRef.current.origX + (ev.clientX - dragRef.current.startX), y: Math.max(0, dragRef.current.origY + (ev.clientY - dragRef.current.startY)) }); };
|
||||
const onUp = () => { dragRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
|
||||
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
|
||||
}, [win.id, win.x, win.y, onUpdate, onFocus]);
|
||||
|
||||
const onResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault(); e.stopPropagation(); onFocus(win.id);
|
||||
resizeRef.current = { startX: e.clientX, startY: e.clientY, origW: win.w, origH: win.h };
|
||||
const onMove = (ev: MouseEvent) => { if (!resizeRef.current) return; onUpdate(win.id, { w: Math.max(320, resizeRef.current.origW + (ev.clientX - resizeRef.current.startX)), h: Math.max(200, resizeRef.current.origH + (ev.clientY - resizeRef.current.startY)) }); };
|
||||
const onUp = () => { resizeRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
|
||||
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
|
||||
}, [win.id, win.w, win.h, onUpdate, onFocus]);
|
||||
|
||||
return createPortal(
|
||||
<div ref={ref} tabIndex={-1} onKeyDown={(e) => { if (e.key === "Escape") onClose(win.id); }} onMouseDown={() => onFocus(win.id)}
|
||||
className="fixed z-999 flex flex-col bg-popover text-popover-foreground border-2 rounded-lg outline-none transition-[border-color,opacity] duration-150"
|
||||
style={{ left: win.x, top: win.y, width: win.w, height: win.h, zIndex: 999 + win.zIndex, borderColor: focused ? "var(--primary)" : "var(--border)", opacity: focused ? 1 : 0.85, boxShadow: DITHERED_SHADOW }}>
|
||||
<div onMouseDown={onDragStart} className="flex items-center gap-2 px-3 py-1.5 border-b-2 border-border cursor-grab active:cursor-grabbing select-none shrink-0 bg-muted/30 rounded-t-lg">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => onClose(win.id)} className="w-2.5 h-2.5 rounded-full bg-destructive hover:brightness-125 transition-all" />
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" /><span className="w-2.5 h-2.5 rounded-full bg-muted-foreground/30" />
|
||||
</div>
|
||||
<span className="flex-1 text-[10px] font-semibold uppercase tracking-wider truncate text-center">{win.node.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-1 border-b border-border shrink-0 bg-muted/15">
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider shrink-0">addr</span>
|
||||
<div className="flex-1 flex items-center h-5 px-2 bg-background/60 border border-border rounded text-[10px] font-mono text-foreground/80 truncate">{win.node.hash}</div>
|
||||
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider shrink-0">
|
||||
{win.pageLoading ? <span className="text-muted-foreground animate-pulse">loading</span>
|
||||
: win.pageError ? <><span className="w-1.5 h-1.5 rounded-full bg-destructive inline-block" /><span className="text-destructive">error</span></>
|
||||
: win.pageHtml ? <><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" /><span className="text-muted-foreground">ok</span></> : null}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto p-3">
|
||||
{win.pageLoading && <span className="text-muted-foreground text-xs animate-pulse">Requesting page...</span>}
|
||||
{win.pageError && <span className="text-destructive text-xs">{win.pageError}</span>}
|
||||
{win.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: win.pageHtml }} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-3 py-1 border-t border-border shrink-0 bg-muted/15 rounded-b-lg">
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">{win.node.type ?? "peer"}</span>
|
||||
{win.node.interface && <span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">via {win.node.interface}</span>}
|
||||
</div>
|
||||
<div onMouseDown={onResizeStart} className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize" style={{ touchAction: "none" }}>
|
||||
<svg viewBox="0 0 16 16" className="w-full h-full text-muted-foreground/50"><path d="M14 14L8 14L14 8Z" fill="currentColor" /><path d="M14 14L11 14L14 11Z" fill="currentColor" opacity="0.5" /></svg>
|
||||
</div>
|
||||
</div>, document.body);
|
||||
interface BrowseWinData {
|
||||
node: NetworkNode;
|
||||
pageHtml: string | null;
|
||||
pageLoading: boolean;
|
||||
pageError: string | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -251,8 +181,7 @@ function BrowserWindow({
|
||||
export default function BrowseView() {
|
||||
const [nodes, setNodes] = useState<NetworkNode[]>([]);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [windows, setWindows] = useState<BrowserWin[]>([]);
|
||||
const [focusedWinId, setFocusedWinId] = useState<string | null>(null);
|
||||
const { windows, focusedId: focusedWinId, open: openWindow, update: updateWindow, close: closeWindowById, focus: focusWindow } = useWindowManager<BrowseWinData>();
|
||||
const [hoveredLabel, setHoveredLabel] = useState<{ text: string; x: number; y: number } | null>(null);
|
||||
|
||||
const [themeRev, setThemeRev] = useState(0);
|
||||
@@ -289,7 +218,7 @@ export default function BrowseView() {
|
||||
clusterNamesRef.current = graphData.clusterNames;
|
||||
}, [nodes, graphData]);
|
||||
|
||||
// Search
|
||||
// Search — matches + autocomplete suggestions
|
||||
const searchMatchIndices = useMemo(() => {
|
||||
if (!filter.trim()) return null;
|
||||
const q = filter.trim().toLowerCase();
|
||||
@@ -300,6 +229,19 @@ export default function BrowseView() {
|
||||
return indices.length > 0 ? indices : null;
|
||||
}, [filter, graphData]);
|
||||
|
||||
const suggestions = useMemo(() => {
|
||||
if (!filter.trim()) return [];
|
||||
const q = filter.trim().toLowerCase();
|
||||
return graphData.entries
|
||||
.filter(e => e.name.toLowerCase().includes(q))
|
||||
.slice(0, 8);
|
||||
}, [filter, graphData]);
|
||||
|
||||
const [selectedSuggestion, setSelectedSuggestion] = useState(-1);
|
||||
|
||||
// Reset selection when suggestions change
|
||||
useEffect(() => { setSelectedSuggestion(-1); }, [suggestions]);
|
||||
|
||||
// ── Cluster labels — direct DOM like the example's create-cluster-labels.ts ──
|
||||
const labelDivsRef = useRef<HTMLDivElement[]>([]);
|
||||
const updateClusterLabels = useCallback(() => {
|
||||
@@ -363,6 +305,7 @@ export default function BrowseView() {
|
||||
renderHoveredPointRing: true,
|
||||
hoveredPointRingColor: "#ffffff",
|
||||
scalePointsOnZoom: true,
|
||||
pointGreyoutOpacity: 0.1,
|
||||
// Simulation defaults — dynamically adjusted by node count in data update
|
||||
simulationGravity: 0.5,
|
||||
simulationRepulsion: 1,
|
||||
@@ -387,9 +330,9 @@ export default function BrowseView() {
|
||||
const screen = graphRef.current.spaceToScreenPosition(pointPosition);
|
||||
setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] });
|
||||
},
|
||||
onSimulationTick: () => updateClusterLabels(),
|
||||
onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); },
|
||||
onZoom: () => updateClusterLabels(),
|
||||
onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); },
|
||||
onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); updateSearchLabels(); },
|
||||
onZoom: () => { updateClusterLabels(); updateSearchLabels(); },
|
||||
});
|
||||
|
||||
graphRef.current = graph;
|
||||
@@ -437,64 +380,79 @@ export default function BrowseView() {
|
||||
}, [graphData]);
|
||||
|
||||
// ── Search highlighting + labels ──
|
||||
const searchLabelDivsRef = useRef<HTMLDivElement[]>([]);
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchLabelDivsRef = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
const searchIndicesRef = useRef<number[] | null>(null);
|
||||
|
||||
const clearSearchLabels = useCallback(() => {
|
||||
if (searchTimerRef.current) { clearTimeout(searchTimerRef.current); searchTimerRef.current = null; }
|
||||
searchLabelDivsRef.current.forEach(d => d.remove());
|
||||
searchLabelDivsRef.current = [];
|
||||
searchLabelDivsRef.current.clear();
|
||||
searchIndicesRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Reposition search labels (called on tick/zoom alongside cluster labels)
|
||||
const updateSearchLabels = useCallback(() => {
|
||||
const graph = graphRef.current;
|
||||
const indices = searchIndicesRef.current;
|
||||
if (!graph || !indices || indices.length === 0) return;
|
||||
|
||||
const positions = graph.getPointPositions();
|
||||
const entries = entriesRef.current;
|
||||
const containerEl = containerRef.current;
|
||||
if (!containerEl) return;
|
||||
const rect = containerEl.getBoundingClientRect();
|
||||
|
||||
for (const idx of indices) {
|
||||
const x = positions[idx * 2];
|
||||
const y = positions[idx * 2 + 1];
|
||||
if (x === undefined || y === undefined) continue;
|
||||
const screen = graph.spaceToScreenPosition([x, y]);
|
||||
|
||||
let div = searchLabelDivsRef.current.get(idx);
|
||||
if (!div) {
|
||||
div = document.createElement("div");
|
||||
div.style.position = "fixed";
|
||||
div.style.pointerEvents = "none";
|
||||
div.style.padding = "2px 8px";
|
||||
div.style.borderRadius = "4px";
|
||||
div.style.background = "var(--popover)";
|
||||
div.style.border = "1px solid var(--primary)";
|
||||
div.style.color = "var(--primary)";
|
||||
div.style.fontFamily = "JetBrains Mono, monospace";
|
||||
div.style.fontWeight = "bold";
|
||||
div.style.fontSize = "11px";
|
||||
div.style.boxShadow = "0 2px 8px rgba(0,0,0,0.4)";
|
||||
div.style.whiteSpace = "nowrap";
|
||||
div.style.zIndex = "998";
|
||||
div.textContent = entries[idx]?.name ?? "";
|
||||
document.body.appendChild(div);
|
||||
searchLabelDivsRef.current.set(idx, div);
|
||||
}
|
||||
div.style.left = `${rect.left + screen[0] + 10}px`;
|
||||
div.style.top = `${rect.top + screen[1] - 8}px`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const graph = graphRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!graph || !container) return;
|
||||
if (!graph) return;
|
||||
|
||||
clearSearchLabels();
|
||||
|
||||
if (searchMatchIndices) {
|
||||
searchIndicesRef.current = searchMatchIndices;
|
||||
graph.selectPointsByIndices(searchMatchIndices);
|
||||
if (searchMatchIndices.length <= 10) {
|
||||
graph.fitViewByPointIndices(searchMatchIndices, 500);
|
||||
}
|
||||
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
searchTimerRef.current = null;
|
||||
if (!graphRef.current) return;
|
||||
const positions = graphRef.current.getPointPositions();
|
||||
const entries = entriesRef.current;
|
||||
for (const idx of searchMatchIndices) {
|
||||
const x = positions[idx * 2];
|
||||
const y = positions[idx * 2 + 1];
|
||||
if (x === undefined || y === undefined) continue;
|
||||
const screen = graphRef.current.spaceToScreenPosition([x, y]);
|
||||
const div = document.createElement("div");
|
||||
div.style.position = "absolute";
|
||||
div.style.pointerEvents = "none";
|
||||
div.style.left = `${screen[0] + 10}px`;
|
||||
div.style.top = `${screen[1] - 8}px`;
|
||||
div.style.padding = "2px 8px";
|
||||
div.style.borderRadius = "4px";
|
||||
div.style.background = "var(--popover)";
|
||||
div.style.border = "1px solid var(--border)";
|
||||
div.style.color = "var(--foreground)";
|
||||
div.style.fontFamily = "JetBrains Mono, monospace";
|
||||
div.style.fontSize = "11px";
|
||||
div.style.boxShadow = "0 2px 8px rgba(0,0,0,0.3)";
|
||||
div.style.whiteSpace = "nowrap";
|
||||
div.textContent = entries[idx]?.name ?? "";
|
||||
container.appendChild(div);
|
||||
searchLabelDivsRef.current.push(div);
|
||||
}
|
||||
}, 600);
|
||||
// Initial position — will be continuously updated on tick/zoom
|
||||
updateSearchLabels();
|
||||
} else {
|
||||
graph.unselectPoints();
|
||||
graph.fitView(300, 0.2);
|
||||
}
|
||||
|
||||
return () => clearSearchLabels();
|
||||
}, [searchMatchIndices, clearSearchLabels]);
|
||||
}, [searchMatchIndices, clearSearchLabels, updateSearchLabels]);
|
||||
|
||||
// ── SSE stream ──
|
||||
useEffect(() => {
|
||||
@@ -516,27 +474,13 @@ export default function BrowseView() {
|
||||
|
||||
// ── Node click ──
|
||||
const handleNodeClick = useCallback((node: NetworkNode) => {
|
||||
setWindows((prev) => {
|
||||
const existing = prev.find((w) => w.node.hash === node.hash);
|
||||
if (existing) { const z = ++nextWinZ; setFocusedWinId(existing.id); return prev.map((w) => w.id === existing.id ? { ...w, zIndex: z } : w); }
|
||||
const winW = 480, winH = 400, margin = 20, z = ++nextWinZ;
|
||||
const win: BrowserWin = {
|
||||
id: `${node.hash}-${Date.now()}`, node, pageHtml: null, pageLoading: true, pageError: null,
|
||||
x: Math.round(margin + Math.random() * (Math.max(margin, window.innerWidth - winW - margin) - margin)),
|
||||
y: Math.round(margin + Math.random() * (Math.max(margin, window.innerHeight - winH - margin) - margin)),
|
||||
w: winW, h: winH, zIndex: z,
|
||||
};
|
||||
setFocusedWinId(win.id);
|
||||
fetchRemotePage(node.hash)
|
||||
.then((res) => setWindows((ws) => ws.map((w) => w.id === win.id ? { ...w, pageHtml: res.content ? renderMicron(res.content, true) : null, pageError: res.content ? null : (res.error ?? "No content"), pageLoading: false } : w)))
|
||||
.catch((e) => setWindows((ws) => ws.map((w) => w.id === win.id ? { ...w, pageError: String(e), pageLoading: false } : w)));
|
||||
return [...prev, win];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateWindow = useCallback((id: string, patch: Partial<BrowserWin>) => setWindows((prev) => prev.map((w) => (w.id === id ? { ...w, ...patch } : w))), []);
|
||||
const closeWindowById = useCallback((id: string) => { setWindows((prev) => prev.filter((w) => w.id !== id)); setFocusedWinId((cur) => cur === id ? null : cur); }, []);
|
||||
const focusWindow = useCallback((id: string) => { setFocusedWinId((cur) => { if (cur === id) return cur; const z = ++nextWinZ; setWindows((prev) => prev.map((w) => (w.id === id ? { ...w, zIndex: z } : w))); return id; }); }, []);
|
||||
const id = node.hash;
|
||||
const data: BrowseWinData = { node, pageHtml: null, pageLoading: true, pageError: null };
|
||||
openWindow(id, data);
|
||||
fetchRemotePage(node.hash)
|
||||
.then((res) => updateWindow(id, { data: { node, pageHtml: res.content ? renderMicron(res.content, true) : null, pageError: res.content ? null : (res.error ?? "No content"), pageLoading: false } }))
|
||||
.catch((e) => updateWindow(id, { data: { node, pageError: String(e), pageLoading: false, pageHtml: null } }));
|
||||
}, [openWindow, updateWindow]);
|
||||
|
||||
const clearSearch = useCallback(() => {
|
||||
setFilter("");
|
||||
@@ -548,10 +492,29 @@ export default function BrowseView() {
|
||||
const nodeCount = nodes.filter(n => n.type !== "interface").length;
|
||||
const ifaceCount = nodes.filter(n => n.type === "interface").length;
|
||||
|
||||
// ── Capture typing into search when no window is focused ──
|
||||
useEffect(() => { searchInputRef.current?.focus(); }, []);
|
||||
useEffect(() => {
|
||||
if (windows.length === 0) searchInputRef.current?.focus();
|
||||
}, [windows.length]);
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
// Skip if a window is focused, or already in the search input, or modifier keys
|
||||
if (focusedWinId) return;
|
||||
if (document.activeElement === searchInputRef.current) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (e.key.length !== 1) return; // only printable characters
|
||||
|
||||
searchInputRef.current?.focus();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [focusedWinId]);
|
||||
|
||||
// ── Draggable search bar ──
|
||||
const [searchPos, setSearchPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const searchDragRef = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
|
||||
useEffect(() => { setSearchPos({ x: Math.round(window.innerWidth / 2 - 200), y: window.innerHeight - 130 }); }, []);
|
||||
useEffect(() => { setSearchPos({ x: Math.round(window.innerWidth / 2 - 200), y: window.innerHeight - 307 }); }, []);
|
||||
|
||||
const onSearchDragStart = useCallback((e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||
@@ -578,10 +541,21 @@ export default function BrowseView() {
|
||||
{/* Search bar */}
|
||||
{searchPos && createPortal(
|
||||
<div onMouseDown={onSearchDragStart}
|
||||
className="fixed z-999 flex items-center gap-3 px-3 py-1.5 bg-popover border-2 border-border rounded-lg cursor-grab active:cursor-grabbing"
|
||||
className="fixed z-999 flex items-center gap-3 px-3 py-1.5 bg-popover border-2 border-border focus-within:border-primary rounded-lg cursor-grab active:cursor-grabbing transition-[border-color] duration-150"
|
||||
style={{ left: searchPos.x, top: searchPos.y, width: 400, boxShadow: DITHERED_SHADOW }}>
|
||||
<input ref={searchInputRef} type="text" value={filter} onChange={(e) => setFilter(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Escape") { clearSearch(); e.currentTarget.blur(); } }}
|
||||
<input ref={searchInputRef} type="text" value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") { clearSearch(); e.currentTarget.blur(); return; }
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setSelectedSuggestion(i => Math.min(i + 1, suggestions.length - 1)); return; }
|
||||
if (e.key === "ArrowUp") { e.preventDefault(); setSelectedSuggestion(i => Math.max(i - 1, -1)); return; }
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const entry = selectedSuggestion >= 0 ? suggestions[selectedSuggestion] : suggestions[0];
|
||||
if (entry && entry.type !== "interface") { handleNodeClick(entry); clearSearch(); }
|
||||
return;
|
||||
}
|
||||
}}
|
||||
placeholder="Search nodes..."
|
||||
className="flex-1 h-7 px-2 text-xs bg-background/60 border border-border rounded placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary cursor-text" />
|
||||
{filter && (
|
||||
@@ -589,9 +563,34 @@ export default function BrowseView() {
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider whitespace-nowrap">
|
||||
{nodeCount} node{nodeCount !== 1 && "s"} · {ifaceCount} iface{ifaceCount !== 1 && "s"}
|
||||
</span>
|
||||
{/* Autocomplete dropdown */}
|
||||
{suggestions.length > 0 && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 bg-popover border border-border rounded-lg overflow-hidden"
|
||||
style={{ boxShadow: "0 4px 12px rgba(0,0,0,0.4)" }}>
|
||||
{suggestions.map((entry, i) => (
|
||||
<button
|
||||
key={entry.hash}
|
||||
onMouseDown={(e) => { e.preventDefault(); if (entry.type !== "interface") { handleNodeClick(entry); clearSearch(); } }}
|
||||
onMouseEnter={() => setSelectedSuggestion(i)}
|
||||
className={`w-full text-left px-3 py-1.5 text-xs font-mono flex items-center gap-2 transition-colors ${i === selectedSuggestion ? "bg-accent text-accent-foreground" : "text-foreground hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{
|
||||
backgroundColor: (() => {
|
||||
const age = Date.now() / 1000 - (entry.last_seen ?? 0);
|
||||
if (age < 300) return "var(--primary)";
|
||||
if (age < 3600) return "var(--muted-foreground)";
|
||||
return "var(--border)";
|
||||
})(),
|
||||
}} />
|
||||
<span className="truncate">{entry.name}</span>
|
||||
<span className="ml-auto text-[9px] text-muted-foreground uppercase shrink-0">
|
||||
{entry.interface ?? "peer"}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>, document.body)}
|
||||
|
||||
{nodes.length === 0 && (
|
||||
@@ -601,8 +600,40 @@ export default function BrowseView() {
|
||||
)}
|
||||
|
||||
{windows.map((win) => (
|
||||
<BrowserWindow key={win.id} win={win} focused={focusedWinId === win.id}
|
||||
onUpdate={updateWindow} onClose={closeWindowById} onFocus={focusWindow} />
|
||||
<FloatingWindow
|
||||
key={win.id}
|
||||
id={win.id}
|
||||
title={win.data.node.name}
|
||||
x={win.x} y={win.y} w={win.w} h={win.h}
|
||||
zIndex={win.zIndex}
|
||||
focused={focusedWinId === win.id}
|
||||
onUpdate={updateWindow}
|
||||
onClose={closeWindowById}
|
||||
onFocus={focusWindow}
|
||||
addressBar={
|
||||
<div className="flex items-center gap-2 px-3 py-1 border-b border-border shrink-0 bg-muted/15">
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider shrink-0">addr</span>
|
||||
<div className="flex-1 flex items-center h-5 px-2 bg-background/60 border border-border rounded text-[10px] font-mono text-foreground/80 truncate">{win.data.node.hash}</div>
|
||||
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider shrink-0">
|
||||
{win.data.pageLoading ? <span className="text-muted-foreground animate-pulse">loading</span>
|
||||
: win.data.pageError ? <><span className="w-1.5 h-1.5 rounded-full bg-destructive inline-block" /><span className="text-destructive">error</span></>
|
||||
: win.data.pageHtml ? <><span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" /><span className="text-muted-foreground">ok</span></> : null}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
footer={
|
||||
<div className="flex items-center gap-3 px-3 py-1 border-t border-border shrink-0 bg-muted/15 rounded-b-lg">
|
||||
<span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">{win.data.node.type ?? "peer"}</span>
|
||||
{win.data.node.interface && <span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">via {win.data.node.interface}</span>}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="p-3 h-full overflow-auto">
|
||||
{win.data.pageLoading && <span className="text-muted-foreground text-xs animate-pulse">Requesting page...</span>}
|
||||
{win.data.pageError && <span className="text-destructive text-xs">{win.data.pageError}</span>}
|
||||
{win.data.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: win.data.pageHtml }} />}
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { MoreVertical, Plus, RotateCcw } from "lucide-react";
|
||||
import { usePagesStore } from "@/stores/pagesStore";
|
||||
@@ -29,13 +28,20 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useWindowManager } from "@/hooks/useWindowManager";
|
||||
import EditorWindow, { type EditorWinData } from "@/components/editor/EditorWindow";
|
||||
|
||||
export default function ComposeView() {
|
||||
const { pages, isLoading, fetchPages, deletePage, publishPage, unpublishPage } =
|
||||
usePagesStore();
|
||||
const navigate = useNavigate();
|
||||
const [pageToDelete, setPageToDelete] = useState<string | null>(null);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const { windows, focusedId, open, update, close, focus } = useWindowManager<EditorWinData>({ w: 720, h: 520 });
|
||||
|
||||
const openEditor = (name: string, isNew: boolean) => {
|
||||
const id = isNew ? `new-${Date.now()}` : name;
|
||||
open(id, { pageName: isNew ? "" : name, isNew });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPages();
|
||||
@@ -89,15 +95,15 @@ export default function ComposeView() {
|
||||
<div>
|
||||
<div>
|
||||
{/* Header row */}
|
||||
<div className="flex items-center px-4 py-2 border-b-2 border-border">
|
||||
<h1 className="text-sm font-semibold flex-1">Compose</h1>
|
||||
<div className="flex items-center px-2 py-1.5 border-b-2 border-border">
|
||||
<h1 className="text-xs font-semibold flex-1">Compose</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleRestart} disabled={restarting}>
|
||||
<RotateCcw className="w-4 h-4 mr-2" />
|
||||
<Button variant="outline" size="sm" onClick={handleRestart} disabled={restarting}>
|
||||
<RotateCcw className="w-3 h-3 mr-1.5" />
|
||||
Restart
|
||||
</Button>
|
||||
<Button onClick={() => navigate("/editor/new")}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
<Button size="sm" onClick={() => openEditor("", true)}>
|
||||
<Plus className="w-3 h-3 mr-1.5" />
|
||||
New Page
|
||||
</Button>
|
||||
</div>
|
||||
@@ -119,7 +125,7 @@ export default function ComposeView() {
|
||||
<TableRow
|
||||
key={p.name}
|
||||
className="cursor-pointer"
|
||||
onClick={() => navigate(`/editor/${p.name}`)}
|
||||
onClick={() => openEditor(p.name, false)}
|
||||
>
|
||||
<TableCell className="font-mono">
|
||||
{p.name}
|
||||
@@ -138,8 +144,8 @@ export default function ComposeView() {
|
||||
</TableCell>
|
||||
<TableCell className="text-right w-8">
|
||||
<PageActions
|
||||
name={p.name}
|
||||
published={p.published}
|
||||
onEdit={() => openEditor(p.name, false)}
|
||||
onPublish={() => handlePublish(p.name)}
|
||||
onUnpublish={() => handleUnpublish(p.name)}
|
||||
onDelete={() => setPageToDelete(p.name)}
|
||||
@@ -184,6 +190,18 @@ export default function ComposeView() {
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Floating editor windows */}
|
||||
{windows.map((win) => (
|
||||
<EditorWindow
|
||||
key={win.id}
|
||||
win={win}
|
||||
focused={focusedId === win.id}
|
||||
onUpdate={update}
|
||||
onClose={close}
|
||||
onFocus={focus}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -191,20 +209,18 @@ export default function ComposeView() {
|
||||
|
||||
/** Per-row action menu for a page. */
|
||||
function PageActions({
|
||||
name,
|
||||
published,
|
||||
onEdit,
|
||||
onPublish,
|
||||
onUnpublish,
|
||||
onDelete,
|
||||
}: {
|
||||
name: string;
|
||||
published: boolean;
|
||||
onEdit: () => void;
|
||||
onPublish: () => void;
|
||||
onUnpublish: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
@@ -219,7 +235,7 @@ function PageActions({
|
||||
/>
|
||||
<PopoverContent side="bottom" align="end" sideOffset={4} className="w-36 p-1">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); navigate(`/editor/${name}`); }}
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
||||
>Edit</button>
|
||||
{published ? (
|
||||
|
||||
Reference in New Issue
Block a user