feat: refactoring
This commit is contained in:
@@ -6,6 +6,15 @@
|
||||
* (e.g. when adding multi-node support with /api/nodes/{id}/...).
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function json<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pages
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -26,12 +35,12 @@ export interface PageDetail {
|
||||
|
||||
export async function fetchPages(): Promise<PageMeta[]> {
|
||||
const res = await fetch("/api/pages");
|
||||
return res.json();
|
||||
return json(res);
|
||||
}
|
||||
|
||||
export async function fetchPage(name: string): Promise<PageDetail> {
|
||||
const res = await fetch(`/api/pages/${name}`);
|
||||
return res.json();
|
||||
return json(res);
|
||||
}
|
||||
|
||||
export async function savePage(
|
||||
@@ -44,12 +53,12 @@ export async function savePage(
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source, publish }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
return json(res);
|
||||
}
|
||||
|
||||
export async function deletePage(name: string): Promise<void> {
|
||||
await fetch(`/api/pages/${name}`, { method: "DELETE" });
|
||||
const res = await fetch(`/api/pages/${name}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -101,7 +110,7 @@ export interface DslMeta {
|
||||
|
||||
export async function fetchDslMeta(): Promise<DslMeta> {
|
||||
const res = await fetch("/api/dsl-meta");
|
||||
return res.json();
|
||||
return json(res);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -134,7 +143,7 @@ export interface NetworkNode {
|
||||
|
||||
export async function fetchBrowseNodes(): Promise<NetworkNode[]> {
|
||||
const res = await fetch("/api/browse/nodes");
|
||||
const data = await res.json();
|
||||
const data = await json<NetworkNode[] | unknown>(res);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
@@ -157,7 +166,7 @@ export async function fetchRemotePage(
|
||||
const res = await fetch(
|
||||
`/api/browse/page/${hash}?path=${encodeURIComponent(path)}`,
|
||||
);
|
||||
return res.json();
|
||||
return json(res);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -177,7 +186,7 @@ export interface FileEntry {
|
||||
export async function fetchFiles(path: string = ""): Promise<FileEntry[]> {
|
||||
const params = path ? `?path=${encodeURIComponent(path)}` : "";
|
||||
const res = await fetch(`/api/files${params}`);
|
||||
return res.json();
|
||||
return json(res);
|
||||
}
|
||||
|
||||
export async function createFolder(path: string): Promise<void> {
|
||||
@@ -200,7 +209,7 @@ export async function moveFile(from: string, to: string): Promise<void> {
|
||||
|
||||
export async function fetchEnv(): Promise<string> {
|
||||
const res = await fetch("/api/files/env");
|
||||
const data = await res.json();
|
||||
const data = await json<{ content: string }>(res);
|
||||
return data.content;
|
||||
}
|
||||
|
||||
@@ -226,6 +235,5 @@ export async function uploadImage(file: File): Promise<UploadResult> {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch("/api/upload-image", { method: "POST", body: form });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
return json(res);
|
||||
}
|
||||
|
||||
74
frontend/src/components/browse/BrowseNodeWindow.tsx
Normal file
74
frontend/src/components/browse/BrowseNodeWindow.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import FloatingWindow from "@/components/shared/FloatingWindow";
|
||||
import type { ManagedWindow } from "@/hooks/useWindowManager";
|
||||
import type { BrowseWinData } from "./types";
|
||||
|
||||
interface BrowseNodeWindowProps {
|
||||
win: ManagedWindow<BrowseWinData>;
|
||||
focused: boolean;
|
||||
onUpdate: (id: string, patch: Partial<ManagedWindow<BrowseWinData>>) => void;
|
||||
onClose: (id: string) => void;
|
||||
onFocus: (id: string) => void;
|
||||
onNavBack: (winId: string, data: BrowseWinData) => void;
|
||||
onNavForward: (winId: string, data: BrowseWinData) => void;
|
||||
onNavReload: (winId: string, data: BrowseWinData) => void;
|
||||
onContentClick: (e: React.MouseEvent, winId: string, data: BrowseWinData) => void;
|
||||
}
|
||||
|
||||
export default function BrowseNodeWindow({
|
||||
win, focused, onUpdate, onClose, onFocus,
|
||||
onNavBack, onNavForward, onNavReload, onContentClick,
|
||||
}: BrowseNodeWindowProps) {
|
||||
const d = win.data;
|
||||
const canBack = d.historyIndex > 0;
|
||||
const canFwd = d.historyIndex < d.history.length - 1;
|
||||
|
||||
return (
|
||||
<FloatingWindow
|
||||
id={win.id}
|
||||
title={d.node.name}
|
||||
x={win.x} y={win.y} w={win.w} h={win.h}
|
||||
zIndex={win.zIndex}
|
||||
focused={focused}
|
||||
onUpdate={onUpdate}
|
||||
onClose={onClose}
|
||||
onFocus={onFocus}
|
||||
addressBar={
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border shrink-0 bg-muted/15">
|
||||
<button onClick={() => onNavBack(win.id, d)} disabled={!canBack || d.pageLoading}
|
||||
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Back">
|
||||
◀
|
||||
</button>
|
||||
<button onClick={() => onNavForward(win.id, d)} disabled={!canFwd || d.pageLoading}
|
||||
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Forward">
|
||||
▶
|
||||
</button>
|
||||
<button onClick={() => onNavReload(win.id, d)} disabled={d.pageLoading}
|
||||
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Reload">
|
||||
↻
|
||||
</button>
|
||||
<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">
|
||||
<span className="text-muted-foreground/60 truncate">{d.node.hash.slice(0, 12)}\u2026/</span>{d.currentPath}
|
||||
</div>
|
||||
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider shrink-0">
|
||||
{d.pageLoading ? <span className="text-muted-foreground animate-pulse">loading</span>
|
||||
: d.pageError ? <><span className="w-1.5 h-1.5 rounded-full bg-destructive inline-block" /><span className="text-destructive">error</span></>
|
||||
: d.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">{d.node.type ?? "peer"}</span>
|
||||
{d.node.interface && <span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">via {d.node.interface}</span>}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div className="p-3 h-full overflow-auto" onClick={(e) => onContentClick(e, win.id, d)}>
|
||||
{d.pageLoading && <span className="text-muted-foreground text-xs animate-pulse">Requesting page...</span>}
|
||||
{d.pageError && <span className="text-destructive text-xs">{d.pageError}</span>}
|
||||
{d.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: d.pageHtml }} />}
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
}
|
||||
115
frontend/src/components/browse/BrowseSearchBar.tsx
Normal file
115
frontend/src/components/browse/BrowseSearchBar.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { DITHERED_SHADOW } from "@/components/shared/FloatingWindow";
|
||||
import type { NetworkNode } from "@/api/client";
|
||||
|
||||
interface BrowseSearchBarProps {
|
||||
filter: string;
|
||||
onFilterChange: (value: string) => void;
|
||||
onClear: () => void;
|
||||
suggestions: NetworkNode[];
|
||||
onSelectNode: (node: NetworkNode) => void;
|
||||
focusedWinId: string | null;
|
||||
windowCount: number;
|
||||
}
|
||||
|
||||
export default function BrowseSearchBar({
|
||||
filter, onFilterChange, onClear,
|
||||
suggestions, onSelectNode,
|
||||
focusedWinId, windowCount,
|
||||
}: BrowseSearchBarProps) {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [selectedSuggestion, setSelectedSuggestion] = useState(-1);
|
||||
|
||||
// Reset selection when suggestions change
|
||||
useEffect(() => { setSelectedSuggestion(-1); }, [suggestions]);
|
||||
|
||||
// Auto-focus search when no windows open
|
||||
useEffect(() => { searchInputRef.current?.focus(); }, []);
|
||||
useEffect(() => {
|
||||
if (windowCount === 0) searchInputRef.current?.focus();
|
||||
}, [windowCount]);
|
||||
|
||||
// Capture typing into search when no window is focused
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (focusedWinId) return;
|
||||
if (document.activeElement === searchInputRef.current) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (e.key.length !== 1) return;
|
||||
searchInputRef.current?.focus();
|
||||
};
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [focusedWinId]);
|
||||
|
||||
// Draggable position
|
||||
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 - 307 }); }, []);
|
||||
|
||||
const onSearchDragStart = useCallback((e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||
e.preventDefault();
|
||||
const pos = searchPos ?? { x: 0, y: 0 };
|
||||
searchDragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
|
||||
const onMove = (ev: MouseEvent) => { if (!searchDragRef.current) return; setSearchPos({ x: searchDragRef.current.origX + (ev.clientX - searchDragRef.current.startX), y: Math.max(0, searchDragRef.current.origY + (ev.clientY - searchDragRef.current.startY)) }); };
|
||||
const onUp = () => { searchDragRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
|
||||
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
|
||||
}, [searchPos]);
|
||||
|
||||
if (!searchPos) return null;
|
||||
|
||||
return createPortal(
|
||||
<div onMouseDown={onSearchDragStart}
|
||||
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) => onFilterChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") { onClear(); 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") { onSelectNode(entry); onClear(); }
|
||||
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 && (
|
||||
<button onClick={onClear} className="text-muted-foreground hover:text-foreground transition-colors text-xs leading-none px-1" title="Clear search (Esc)">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{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") { onSelectNode(entry); onClear(); } }}
|
||||
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);
|
||||
}
|
||||
98
frontend/src/components/browse/buildGraph.ts
Normal file
98
frontend/src/components/browse/buildGraph.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { NetworkNode } from "@/api/client";
|
||||
import { statusRGBA, type RGBA, type StatusColors } from "./graphColors";
|
||||
|
||||
export const SPACE_SIZE = 4096;
|
||||
const CENTER = SPACE_SIZE / 2;
|
||||
|
||||
export interface BuiltGraph {
|
||||
entries: NetworkNode[];
|
||||
positions: Float32Array;
|
||||
colors: Float32Array;
|
||||
sizes: Float32Array;
|
||||
clusterIndices: (number | undefined)[];
|
||||
clusterPositions: (number | undefined)[];
|
||||
clusterStrength: Float32Array;
|
||||
clusterNames: string[];
|
||||
hashToIndex: Map<string, number>;
|
||||
}
|
||||
|
||||
function findParentIface(
|
||||
entry: NetworkNode,
|
||||
interfaces: NetworkNode[],
|
||||
peerIndex: number,
|
||||
): string | undefined {
|
||||
if (entry.interface) {
|
||||
const iface = interfaces.find(i => i.name === entry.interface);
|
||||
if (iface) return iface.hash;
|
||||
}
|
||||
if (interfaces.length > 0) return interfaces[peerIndex % interfaces.length].hash;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function buildGraphArrays(
|
||||
rawNodes: NetworkNode[],
|
||||
prevHashToIndex: Map<string, number>,
|
||||
prevPositions: number[],
|
||||
theme: StatusColors,
|
||||
): BuiltGraph {
|
||||
const interfaces = rawNodes.filter(e => e.type === "interface").sort((a, b) => a.name.localeCompare(b.name));
|
||||
const selfNode = rawNodes.find(e => e.is_self && e.type !== "interface");
|
||||
const peers = rawNodes.filter(e => !e.is_self && e.type !== "interface");
|
||||
|
||||
const entries = selfNode ? [selfNode, ...peers] : peers;
|
||||
const selfIndex = selfNode ? 0 : -1;
|
||||
const n = entries.length;
|
||||
const nClusters = interfaces.length;
|
||||
const hashToIndex = new Map<string, number>();
|
||||
const positions = new Float32Array(n * 2);
|
||||
const colors = new Float32Array(n * 4);
|
||||
const sizes = new Float32Array(n);
|
||||
const clusterIndices: (number | undefined)[] = [];
|
||||
const clusterStrength = new Float32Array(n);
|
||||
|
||||
const clusterMap = new Map<string, number>();
|
||||
interfaces.forEach((iface, i) => clusterMap.set(iface.name, i));
|
||||
const selfClusterIndex = interfaces.length;
|
||||
|
||||
const clusterNames = [...interfaces.map(i => i.name), ...(selfNode ? [selfNode.name] : [])];
|
||||
const clusterPositions: (number | undefined)[] = [];
|
||||
|
||||
entries.forEach((entry, i) => {
|
||||
hashToIndex.set(entry.hash, i);
|
||||
|
||||
// Preserve existing position, or jitter near center for new points
|
||||
const prevIdx = prevHashToIndex.get(entry.hash);
|
||||
if (prevIdx !== undefined && prevPositions.length >= (prevIdx + 1) * 2) {
|
||||
positions[i * 2] = prevPositions[prevIdx * 2]!;
|
||||
positions[i * 2 + 1] = prevPositions[prevIdx * 2 + 1]!;
|
||||
} else {
|
||||
positions[i * 2] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5;
|
||||
positions[i * 2 + 1] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5;
|
||||
}
|
||||
|
||||
// Self node = bright white, others by status
|
||||
const rgba: RGBA = i === selfIndex
|
||||
? [1, 1, 1, 1]
|
||||
: statusRGBA(entry, theme);
|
||||
colors[i * 4 + 0] = rgba[0];
|
||||
colors[i * 4 + 1] = rgba[1];
|
||||
colors[i * 4 + 2] = rgba[2];
|
||||
colors[i * 4 + 3] = rgba[3];
|
||||
|
||||
sizes[i] = 3;
|
||||
|
||||
// Cluster assignment
|
||||
if (i === selfIndex) {
|
||||
clusterIndices.push(selfClusterIndex);
|
||||
} else {
|
||||
const pi = peers.indexOf(entry);
|
||||
const parentHash = findParentIface(entry, interfaces, pi);
|
||||
const parentName = parentHash ? interfaces.find(f => f.hash === parentHash)?.name : undefined;
|
||||
clusterIndices.push(parentName !== undefined ? clusterMap.get(parentName) : undefined);
|
||||
}
|
||||
|
||||
clusterStrength[i] = nClusters > 1 ? (nClusters - (i % nClusters)) / nClusters : 1;
|
||||
});
|
||||
|
||||
return { entries, positions, colors, sizes, clusterIndices, clusterPositions, clusterStrength, clusterNames, hashToIndex };
|
||||
}
|
||||
57
frontend/src/components/browse/graphColors.ts
Normal file
57
frontend/src/components/browse/graphColors.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { NetworkNode } from "@/api/client";
|
||||
|
||||
export type RGBA = [number, number, number, number];
|
||||
|
||||
export function cssVarToRGBA(varName: string): RGBA {
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
if (!raw) return [0.5, 0.5, 0.5, 1];
|
||||
const ctx = document.createElement("canvas").getContext("2d")!;
|
||||
ctx.fillStyle = raw;
|
||||
// ctx.fillStyle normalizes to #rrggbb
|
||||
const hex = ctx.fillStyle;
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
return [r, g, b, 1];
|
||||
}
|
||||
|
||||
export function lerpRGBA(a: RGBA, b: RGBA, t: number): RGBA {
|
||||
return [
|
||||
a[0] + (b[0] - a[0]) * t,
|
||||
a[1] + (b[1] - a[1]) * t,
|
||||
a[2] + (b[2] - a[2]) * t,
|
||||
a[3] + (b[3] - a[3]) * t,
|
||||
];
|
||||
}
|
||||
|
||||
export function brighten(c: RGBA, amount: number): RGBA {
|
||||
return [
|
||||
Math.min(1, c[0] + amount),
|
||||
Math.min(1, c[1] + amount),
|
||||
Math.min(1, c[2] + amount),
|
||||
c[3],
|
||||
];
|
||||
}
|
||||
|
||||
export interface StatusColors {
|
||||
online: RGBA;
|
||||
stale: RGBA;
|
||||
offline: RGBA;
|
||||
}
|
||||
|
||||
export function getThemeStatusColors(): StatusColors {
|
||||
const primary = brighten(cssVarToRGBA("--primary"), 0.15);
|
||||
const muted = cssVarToRGBA("--muted-foreground");
|
||||
return {
|
||||
online: primary,
|
||||
stale: lerpRGBA(primary, muted, 0.4),
|
||||
offline: muted,
|
||||
};
|
||||
}
|
||||
|
||||
export function statusRGBA(entry: NetworkNode, theme: StatusColors): RGBA {
|
||||
const age = Date.now() / 1000 - (entry.last_seen ?? 0);
|
||||
if (age < 300) return theme.online;
|
||||
if (age < 3600) return theme.stale;
|
||||
return theme.offline;
|
||||
}
|
||||
17
frontend/src/components/browse/types.ts
Normal file
17
frontend/src/components/browse/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { NetworkNode } from "@/api/client";
|
||||
|
||||
export interface HistoryEntry {
|
||||
path: string;
|
||||
html: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface BrowseWinData {
|
||||
node: NetworkNode;
|
||||
pageHtml: string | null;
|
||||
pageLoading: boolean;
|
||||
pageError: string | null;
|
||||
currentPath: string;
|
||||
history: HistoryEntry[];
|
||||
historyIndex: number;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { BookOpen, Upload } from "lucide-react";
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import type { StoreApi } from "zustand";
|
||||
import { useStore } from "zustand";
|
||||
import * as api from "@/api/client";
|
||||
@@ -10,24 +8,17 @@ import { createEditorStore, type EditorStore } from "@/stores/editorStore";
|
||||
import { usePagesStore } from "@/stores/pagesStore";
|
||||
import { useCompile } from "@/hooks/useCompile";
|
||||
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
|
||||
import { useKeyboardSave } from "@/hooks/useKeyboardSave";
|
||||
import { uframeHighlight } from "./uframeHighlight";
|
||||
import { uframeCommandSource, uframeValueHintSource, loadCommandsFromApi } from "./uframeCommands";
|
||||
import { keywordHoverTooltip } from "./uframeHover";
|
||||
import { EditorStoreContext } from "./EditorStoreContext";
|
||||
import EditorPane from "./EditorPane";
|
||||
import EditorPointer from "./EditorPointer";
|
||||
import PreviewPane from "./PreviewPane";
|
||||
import SourcePane from "./SourcePane";
|
||||
import ToolBar from "./ToolBar";
|
||||
import { EXAMPLES } from "./examples";
|
||||
import FloatingWindow from "@/components/shared/FloatingWindow";
|
||||
import type { ManagedWindow } from "@/hooks/useWindowManager";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
@@ -116,16 +107,11 @@ export default function EditorWindow({ win, focused, onUpdate, onClose, onFocus
|
||||
[pageName, ufSource, win.data.isNew, win.id],
|
||||
);
|
||||
|
||||
// Keyboard shortcuts — only fire when this window is focused
|
||||
useEffect(() => {
|
||||
if (!focused) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); handleSave(false); }
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "p") { e.preventDefault(); handleSave(true); }
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [focused, handleSave]);
|
||||
useKeyboardSave(
|
||||
useCallback(() => handleSave(false), [handleSave]),
|
||||
useCallback(() => handleSave(true), [handleSave]),
|
||||
focused,
|
||||
);
|
||||
|
||||
// Confirm close if dirty
|
||||
const handleClose = useCallback((id: string) => {
|
||||
@@ -175,81 +161,3 @@ export default function EditorWindow({ win, focused, onUpdate, onClose, onFocus
|
||||
}
|
||||
|
||||
|
||||
function SourcePane({
|
||||
ufSource,
|
||||
setSource,
|
||||
extensions,
|
||||
}: {
|
||||
ufSource: string;
|
||||
setSource: (s: string) => void;
|
||||
extensions: Extension[];
|
||||
}) {
|
||||
const [examplesOpen, setExamplesOpen] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const data = await api.uploadImage(file);
|
||||
toast.success(`Uploaded ${data.filename}`);
|
||||
setSource(`image "${data.path}" braille 30\n align center`);
|
||||
} catch (err) {
|
||||
toast.error(`Upload failed: ${err}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center px-4 py-2 border-b-2 border-border shrink-0 gap-2">
|
||||
<span className="font-medium text-foreground flex-1">Source</span>
|
||||
|
||||
<Popover open={examplesOpen} onOpenChange={setExamplesOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 cursor-pointer">
|
||||
<BookOpen className="h-3 w-3" />
|
||||
Examples
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent side="bottom" align="end" sideOffset={8}>
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>Insert Example</PopoverTitle>
|
||||
</PopoverHeader>
|
||||
<div className="flex flex-col gap-0.5 max-h-72 overflow-y-auto -mx-1">
|
||||
{EXAMPLES.map((ex) => (
|
||||
<button
|
||||
key={ex.name}
|
||||
onClick={() => { setSource(ex.source); setExamplesOpen(false); }}
|
||||
className="flex flex-col items-start px-2 py-1.5 text-left hover:bg-accent transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-sm font-medium">{ex.name}</span>
|
||||
<span className="text-xs text-muted-foreground leading-tight">{ex.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleUpload} />
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
{uploading ? "Uploading…" : "Image"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
<EditorPane value={ufSource} onChange={setSource} extensions={extensions} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
91
frontend/src/components/editor/SourcePane.tsx
Normal file
91
frontend/src/components/editor/SourcePane.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { BookOpen, Upload } from "lucide-react";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import * as api from "@/api/client";
|
||||
import EditorPane from "./EditorPane";
|
||||
import { EXAMPLES } from "./examples";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
interface SourcePaneProps {
|
||||
ufSource: string;
|
||||
setSource: (s: string) => void;
|
||||
extensions: Extension[];
|
||||
}
|
||||
|
||||
export default function SourcePane({ ufSource, setSource, extensions }: SourcePaneProps) {
|
||||
const [examplesOpen, setExamplesOpen] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const data = await api.uploadImage(file);
|
||||
toast.success(`Uploaded ${data.filename}`);
|
||||
setSource(`image "${data.path}" braille 30\n align center`);
|
||||
} catch (err) {
|
||||
toast.error(`Upload failed: ${err}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center px-4 py-2 border-b-2 border-border shrink-0 gap-2">
|
||||
<span className="font-medium text-foreground flex-1">Source</span>
|
||||
|
||||
<Popover open={examplesOpen} onOpenChange={setExamplesOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 cursor-pointer">
|
||||
<BookOpen className="h-3 w-3" />
|
||||
Examples
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent side="bottom" align="end" sideOffset={8}>
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>Insert Example</PopoverTitle>
|
||||
</PopoverHeader>
|
||||
<div className="flex flex-col gap-0.5 max-h-72 overflow-y-auto -mx-1">
|
||||
{EXAMPLES.map((ex) => (
|
||||
<button
|
||||
key={ex.name}
|
||||
onClick={() => { setSource(ex.source); setExamplesOpen(false); }}
|
||||
className="flex flex-col items-start px-2 py-1.5 text-left hover:bg-accent transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-sm font-medium">{ex.name}</span>
|
||||
<span className="text-xs text-muted-foreground leading-tight">{ex.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleUpload} />
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
{uploading ? "Uploading\u2026" : "Image"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
<EditorPane value={ufSource} onChange={setSource} extensions={extensions} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,20 +12,11 @@ const DEBOUNCE_MS = 400;
|
||||
* Accepts an optional store API for per-window instances; falls back to the global singleton.
|
||||
*/
|
||||
export function useCompile(storeApi?: StoreApi<EditorStore>) {
|
||||
const globalSource = useEditorStore((s) => s.ufSource);
|
||||
const globalSetResult = useEditorStore((s) => s.setCompileResult);
|
||||
const globalSetCompiling = useEditorStore((s) => s.setCompiling);
|
||||
const globalSetError = useEditorStore((s) => s.setCompileError);
|
||||
|
||||
const localSource = useStore(storeApi ?? useEditorStore, (s) => s.ufSource);
|
||||
const localSetResult = useStore(storeApi ?? useEditorStore, (s) => s.setCompileResult);
|
||||
const localSetCompiling = useStore(storeApi ?? useEditorStore, (s) => s.setCompiling);
|
||||
const localSetError = useStore(storeApi ?? useEditorStore, (s) => s.setCompileError);
|
||||
|
||||
const ufSource = storeApi ? localSource : globalSource;
|
||||
const setCompileResult = storeApi ? localSetResult : globalSetResult;
|
||||
const setCompiling = storeApi ? localSetCompiling : globalSetCompiling;
|
||||
const setCompileError = storeApi ? localSetError : globalSetError;
|
||||
const store = storeApi ?? useEditorStore;
|
||||
const ufSource = useStore(store, (s) => s.ufSource);
|
||||
const setCompileResult = useStore(store, (s) => s.setCompileResult);
|
||||
const setCompiling = useStore(store, (s) => s.setCompiling);
|
||||
const setCompileError = useStore(store, (s) => s.setCompileError);
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
27
frontend/src/hooks/useKeyboardSave.ts
Normal file
27
frontend/src/hooks/useKeyboardSave.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Registers Ctrl/Cmd+S (save) and optionally Ctrl/Cmd+P (publish) keyboard shortcuts.
|
||||
* Pass `enabled = false` to temporarily disable (e.g. when a window is not focused).
|
||||
*/
|
||||
export function useKeyboardSave(
|
||||
onSave: () => void,
|
||||
onPublish?: () => void,
|
||||
enabled = true,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
onSave();
|
||||
}
|
||||
if (onPublish && (e.metaKey || e.ctrlKey) && e.key === "p") {
|
||||
e.preventDefault();
|
||||
onPublish();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onSave, onPublish, enabled]);
|
||||
}
|
||||
@@ -4,9 +4,8 @@ import { useEditorStore } from "@/stores/editorStore";
|
||||
import type { EditorStore } from "@/stores/editorStore";
|
||||
|
||||
export function useUnsavedGuard(storeApi?: StoreApi<EditorStore>) {
|
||||
const globalDirty = useEditorStore((s) => s.isDirty);
|
||||
const localDirty = useStore(storeApi ?? useEditorStore, (s) => s.isDirty);
|
||||
const isDirty = storeApi ? localDirty : globalDirty;
|
||||
const store = storeApi ?? useEditorStore;
|
||||
const isDirty = useStore(store, (s) => s.isDirty);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
|
||||
@@ -1,198 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SPACE_SIZE = 4096;
|
||||
const CENTER = SPACE_SIZE / 2;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme-aware status colors — reads CSS variables, returns 0–1 RGBA
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function cssVarToRGBA(varName: string): [number, number, number, number] {
|
||||
const raw = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
|
||||
if (!raw) return [0.5, 0.5, 0.5, 1];
|
||||
const ctx = document.createElement("canvas").getContext("2d")!;
|
||||
ctx.fillStyle = raw;
|
||||
// ctx.fillStyle normalizes to #rrggbb
|
||||
const hex = ctx.fillStyle;
|
||||
const r = parseInt(hex.slice(1, 3), 16) / 255;
|
||||
const g = parseInt(hex.slice(3, 5), 16) / 255;
|
||||
const b = parseInt(hex.slice(5, 7), 16) / 255;
|
||||
return [r, g, b, 1];
|
||||
}
|
||||
|
||||
function lerpRGBA(
|
||||
a: [number, number, number, number],
|
||||
b: [number, number, number, number],
|
||||
t: number,
|
||||
): [number, number, number, number] {
|
||||
return [
|
||||
a[0] + (b[0] - a[0]) * t,
|
||||
a[1] + (b[1] - a[1]) * t,
|
||||
a[2] + (b[2] - a[2]) * t,
|
||||
a[3] + (b[3] - a[3]) * t,
|
||||
];
|
||||
}
|
||||
|
||||
function brighten(c: [number, number, number, number], amount: number): [number, number, number, number] {
|
||||
return [
|
||||
Math.min(1, c[0] + amount),
|
||||
Math.min(1, c[1] + amount),
|
||||
Math.min(1, c[2] + amount),
|
||||
c[3],
|
||||
];
|
||||
}
|
||||
|
||||
function getThemeStatusColors() {
|
||||
const primary = brighten(cssVarToRGBA("--primary"), 0.15);
|
||||
const muted = cssVarToRGBA("--muted-foreground");
|
||||
return {
|
||||
online: primary, // --primary brightened
|
||||
stale: lerpRGBA(primary, muted, 0.4), // blend, closer to primary
|
||||
offline: muted, // --muted-foreground
|
||||
};
|
||||
}
|
||||
|
||||
function statusRGBA(entry: NetworkNode, theme: ReturnType<typeof getThemeStatusColors>): [number, number, number, number] {
|
||||
const age = Date.now() / 1000 - (entry.last_seen ?? 0);
|
||||
if (age < 300) return theme.online;
|
||||
if (age < 3600) return theme.stale;
|
||||
return theme.offline;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build flat arrays for cosmos — positions around spaceSize/2
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface BuiltGraph {
|
||||
entries: NetworkNode[];
|
||||
positions: Float32Array;
|
||||
colors: Float32Array;
|
||||
sizes: Float32Array;
|
||||
clusterIndices: (number | undefined)[];
|
||||
clusterPositions: (number | undefined)[];
|
||||
clusterStrength: Float32Array;
|
||||
clusterNames: string[];
|
||||
hashToIndex: Map<string, number>;
|
||||
}
|
||||
|
||||
function findParentIface(
|
||||
entry: NetworkNode,
|
||||
interfaces: NetworkNode[],
|
||||
peerIndex: number,
|
||||
): string | undefined {
|
||||
if (entry.interface) {
|
||||
const iface = interfaces.find(i => i.name === entry.interface);
|
||||
if (iface) return iface.hash;
|
||||
}
|
||||
if (interfaces.length > 0) return interfaces[peerIndex % interfaces.length].hash;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildGraphArrays(
|
||||
rawNodes: NetworkNode[],
|
||||
prevHashToIndex: Map<string, number>,
|
||||
prevPositions: number[],
|
||||
theme: ReturnType<typeof getThemeStatusColors>,
|
||||
): BuiltGraph {
|
||||
const interfaces = rawNodes.filter(e => e.type === "interface").sort((a, b) => a.name.localeCompare(b.name));
|
||||
const selfNode = rawNodes.find(e => e.is_self && e.type !== "interface");
|
||||
const peers = rawNodes.filter(e => !e.is_self && e.type !== "interface");
|
||||
|
||||
// Peers + self node are points — interfaces are clusters, not nodes
|
||||
// Self node goes first so it's easy to find by index
|
||||
const entries = selfNode ? [selfNode, ...peers] : peers;
|
||||
const selfIndex = selfNode ? 0 : -1;
|
||||
const n = entries.length;
|
||||
const nClusters = interfaces.length;
|
||||
const hashToIndex = new Map<string, number>();
|
||||
const positions = new Float32Array(n * 2);
|
||||
const colors = new Float32Array(n * 4);
|
||||
const sizes = new Float32Array(n);
|
||||
const clusterIndices: (number | undefined)[] = [];
|
||||
const clusterStrength = new Float32Array(n);
|
||||
|
||||
// Cluster map: interface name → cluster index
|
||||
// Self node gets its own cluster at the end
|
||||
const clusterMap = new Map<string, number>();
|
||||
interfaces.forEach((iface, i) => clusterMap.set(iface.name, i));
|
||||
const selfClusterIndex = interfaces.length;
|
||||
|
||||
// Cluster names for labels
|
||||
const clusterNames = [...interfaces.map(i => i.name), ...(selfNode ? [selfNode.name] : [])];
|
||||
|
||||
// No explicit cluster positions — let cosmos use centermass
|
||||
const clusterPositions: (number | undefined)[] = [];
|
||||
|
||||
entries.forEach((entry, i) => {
|
||||
hashToIndex.set(entry.hash, i);
|
||||
|
||||
// Position: preserve existing, or jitter near center for new points
|
||||
const prevIdx = prevHashToIndex.get(entry.hash);
|
||||
if (prevIdx !== undefined && prevPositions.length >= (prevIdx + 1) * 2) {
|
||||
positions[i * 2] = prevPositions[prevIdx * 2]!;
|
||||
positions[i * 2 + 1] = prevPositions[prevIdx * 2 + 1]!;
|
||||
} else {
|
||||
positions[i * 2] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5;
|
||||
positions[i * 2 + 1] = CENTER + (Math.random() - 0.5) * SPACE_SIZE * 0.5;
|
||||
}
|
||||
|
||||
// Colors — self node uses bright white, others by status
|
||||
const rgba = i === selfIndex
|
||||
? [1, 1, 1, 1] as [number, number, number, number]
|
||||
: statusRGBA(entry, theme);
|
||||
colors[i * 4 + 0] = rgba[0];
|
||||
colors[i * 4 + 1] = rgba[1];
|
||||
colors[i * 4 + 2] = rgba[2];
|
||||
colors[i * 4 + 3] = rgba[3];
|
||||
|
||||
sizes[i] = 3;
|
||||
|
||||
// Cluster assignment — self node gets its own cluster, peers map to parent interface
|
||||
if (i === selfIndex) {
|
||||
clusterIndices.push(selfClusterIndex);
|
||||
} else {
|
||||
const pi = peers.indexOf(entry);
|
||||
const parentHash = findParentIface(entry, interfaces, pi);
|
||||
const parentName = parentHash ? interfaces.find(f => f.hash === parentHash)?.name : undefined;
|
||||
clusterIndices.push(parentName !== undefined ? clusterMap.get(parentName) : undefined);
|
||||
}
|
||||
|
||||
// Cluster strength
|
||||
clusterStrength[i] = nClusters > 1 ? (nClusters - (i % nClusters)) / nClusters : 1;
|
||||
});
|
||||
|
||||
return { entries, positions, colors, sizes, clusterIndices, clusterPositions, clusterStrength, clusterNames, hashToIndex };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Browse window data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface HistoryEntry {
|
||||
path: string;
|
||||
html: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
interface BrowseWinData {
|
||||
node: NetworkNode;
|
||||
pageHtml: string | null;
|
||||
pageLoading: boolean;
|
||||
pageError: string | null;
|
||||
currentPath: string;
|
||||
history: HistoryEntry[];
|
||||
historyIndex: number;
|
||||
}
|
||||
import { getThemeStatusColors } from "@/components/browse/graphColors";
|
||||
import { buildGraphArrays, SPACE_SIZE } from "@/components/browse/buildGraph";
|
||||
import type { BrowseWinData, HistoryEntry } from "@/components/browse/types";
|
||||
import BrowseNodeWindow from "@/components/browse/BrowseNodeWindow";
|
||||
import BrowseSearchBar from "@/components/browse/BrowseSearchBar";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
@@ -214,7 +29,6 @@ export default function BrowseView() {
|
||||
}, []);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const graphRef = useRef<Graph | null>(null);
|
||||
const entriesRef = useRef<NetworkNode[]>([]);
|
||||
const nodesMapRef = useRef<Map<string, NetworkNode>>(new Map());
|
||||
@@ -257,11 +71,6 @@ export default function BrowseView() {
|
||||
.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(() => {
|
||||
@@ -299,7 +108,6 @@ export default function BrowseView() {
|
||||
}
|
||||
}
|
||||
|
||||
// Update positions
|
||||
for (let i = 0; i < nClusters; i++) {
|
||||
const x = positions[i * 2];
|
||||
const y = positions[i * 2 + 1];
|
||||
@@ -311,97 +119,7 @@ export default function BrowseView() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Init Cosmos — matching the example's create-cosmos.ts ──
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const graph = new Graph(containerRef.current, {
|
||||
spaceSize: SPACE_SIZE,
|
||||
backgroundColor: "transparent",
|
||||
pointDefaultColor: "#888888",
|
||||
pointDefaultSize: 10,
|
||||
renderLinks: false,
|
||||
fitViewOnInit: true,
|
||||
fitViewDelay: 1500,
|
||||
fitViewPadding: 0.2,
|
||||
renderHoveredPointRing: true,
|
||||
hoveredPointRingColor: "#ffffff",
|
||||
scalePointsOnZoom: true,
|
||||
pointGreyoutOpacity: 0.1,
|
||||
// Simulation defaults — dynamically adjusted by node count in data update
|
||||
simulationGravity: 0.5,
|
||||
simulationRepulsion: 1,
|
||||
simulationCluster: 0.5,
|
||||
simulationDecay: 5000,
|
||||
simulationFriction: 0.85,
|
||||
simulationLinkSpring: 0,
|
||||
simulationLinkDistance: 1,
|
||||
// Events
|
||||
onClick: (index, _pos, _event) => {
|
||||
if (index === undefined) return;
|
||||
const entry = entriesRef.current[index];
|
||||
if (entry && entry.type !== "interface") handleNodeClick(entry);
|
||||
},
|
||||
onMouseMove: (index, pointPosition) => {
|
||||
if (index === undefined || !pointPosition || !graphRef.current) {
|
||||
setHoveredLabel(null);
|
||||
return;
|
||||
}
|
||||
const entry = entriesRef.current[index];
|
||||
if (!entry) return;
|
||||
const screen = graphRef.current.spaceToScreenPosition(pointPosition);
|
||||
setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] });
|
||||
},
|
||||
onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); },
|
||||
onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); updateSearchLabels(); },
|
||||
onZoom: () => { updateClusterLabels(); updateSearchLabels(); },
|
||||
});
|
||||
|
||||
graphRef.current = graph;
|
||||
|
||||
return () => {
|
||||
labelDivsRef.current.forEach(d => d.remove());
|
||||
labelDivsRef.current = [];
|
||||
graph.destroy();
|
||||
graphRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Update data — following the example's exact call order ──
|
||||
const isFirstLoadRef = useRef(true);
|
||||
useEffect(() => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph || graphData.entries.length === 0) return;
|
||||
|
||||
// Scale simulation params by node count
|
||||
// Reference: 10k nodes → repulsion 10, cluster 0.25, gravity 2
|
||||
// Scale logarithmically so it works from 10 to 10k nodes
|
||||
const n = graphData.entries.length;
|
||||
const scale = Math.log10(Math.max(10, n)) / Math.log10(10000); // 0..1
|
||||
graph.setConfig({
|
||||
simulationRepulsion: 0.5 + scale * 9.5, // 0.5 → 10
|
||||
simulationCluster: 1.0 - scale * 0.75, // 1.0 → 0.25
|
||||
simulationGravity: 0.25 + scale * 1.75, // 0.25 → 2
|
||||
});
|
||||
|
||||
graph.setPointPositions(graphData.positions);
|
||||
graph.setPointColors(graphData.colors);
|
||||
graph.setPointSizes(graphData.sizes);
|
||||
graph.setPointClusters(graphData.clusterIndices);
|
||||
graph.setClusterPositions(graphData.clusterPositions);
|
||||
graph.setPointClusterStrength(graphData.clusterStrength);
|
||||
graph.setLinks(new Float32Array(0));
|
||||
|
||||
if (isFirstLoadRef.current) {
|
||||
graph.render(1);
|
||||
isFirstLoadRef.current = false;
|
||||
} else {
|
||||
graph.render(0.1);
|
||||
}
|
||||
updateClusterLabels();
|
||||
}, [graphData]);
|
||||
|
||||
// ── Search highlighting + labels ──
|
||||
// ── Search highlighting labels ──
|
||||
const searchLabelDivsRef = useRef<Map<number, HTMLDivElement>>(new Map());
|
||||
const searchIndicesRef = useRef<number[] | null>(null);
|
||||
|
||||
@@ -411,7 +129,6 @@ export default function BrowseView() {
|
||||
searchIndicesRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Reposition search labels (called on tick/zoom alongside cluster labels)
|
||||
const updateSearchLabels = useCallback(() => {
|
||||
const graph = graphRef.current;
|
||||
const indices = searchIndicesRef.current;
|
||||
@@ -454,6 +171,92 @@ export default function BrowseView() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Init Cosmos ──
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const graph = new Graph(containerRef.current, {
|
||||
spaceSize: SPACE_SIZE,
|
||||
backgroundColor: "transparent",
|
||||
pointDefaultColor: "#888888",
|
||||
pointDefaultSize: 10,
|
||||
renderLinks: false,
|
||||
fitViewOnInit: true,
|
||||
fitViewDelay: 1500,
|
||||
fitViewPadding: 0.2,
|
||||
renderHoveredPointRing: true,
|
||||
hoveredPointRingColor: "#ffffff",
|
||||
scalePointsOnZoom: true,
|
||||
pointGreyoutOpacity: 0.1,
|
||||
simulationGravity: 0.5,
|
||||
simulationRepulsion: 1,
|
||||
simulationCluster: 0.5,
|
||||
simulationDecay: 5000,
|
||||
simulationFriction: 0.85,
|
||||
simulationLinkSpring: 0,
|
||||
simulationLinkDistance: 1,
|
||||
onClick: (index, _pos, _event) => {
|
||||
if (index === undefined) return;
|
||||
const entry = entriesRef.current[index];
|
||||
if (entry && entry.type !== "interface") handleNodeClick(entry);
|
||||
},
|
||||
onMouseMove: (index, pointPosition) => {
|
||||
if (index === undefined || !pointPosition || !graphRef.current) {
|
||||
setHoveredLabel(null);
|
||||
return;
|
||||
}
|
||||
const entry = entriesRef.current[index];
|
||||
if (!entry) return;
|
||||
const screen = graphRef.current.spaceToScreenPosition(pointPosition);
|
||||
setHoveredLabel({ text: entry.name, x: screen[0], y: screen[1] });
|
||||
},
|
||||
onSimulationTick: () => { updateClusterLabels(); updateSearchLabels(); },
|
||||
onSimulationEnd: () => { graphRef.current?.fitView(300, 0.2); updateClusterLabels(); updateSearchLabels(); },
|
||||
onZoom: () => { updateClusterLabels(); updateSearchLabels(); },
|
||||
});
|
||||
|
||||
graphRef.current = graph;
|
||||
|
||||
return () => {
|
||||
labelDivsRef.current.forEach(d => d.remove());
|
||||
labelDivsRef.current = [];
|
||||
graph.destroy();
|
||||
graphRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Update graph data ──
|
||||
const isFirstLoadRef = useRef(true);
|
||||
useEffect(() => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph || graphData.entries.length === 0) return;
|
||||
|
||||
const n = graphData.entries.length;
|
||||
const scale = Math.log10(Math.max(10, n)) / Math.log10(10000);
|
||||
graph.setConfig({
|
||||
simulationRepulsion: 0.5 + scale * 9.5,
|
||||
simulationCluster: 1.0 - scale * 0.75,
|
||||
simulationGravity: 0.25 + scale * 1.75,
|
||||
});
|
||||
|
||||
graph.setPointPositions(graphData.positions);
|
||||
graph.setPointColors(graphData.colors);
|
||||
graph.setPointSizes(graphData.sizes);
|
||||
graph.setPointClusters(graphData.clusterIndices);
|
||||
graph.setClusterPositions(graphData.clusterPositions);
|
||||
graph.setPointClusterStrength(graphData.clusterStrength);
|
||||
graph.setLinks(new Float32Array(0));
|
||||
|
||||
if (isFirstLoadRef.current) {
|
||||
graph.render(1);
|
||||
isFirstLoadRef.current = false;
|
||||
} else {
|
||||
graph.render(0.1);
|
||||
}
|
||||
updateClusterLabels();
|
||||
}, [graphData]);
|
||||
|
||||
// ── Search highlighting ──
|
||||
useEffect(() => {
|
||||
const graph = graphRef.current;
|
||||
if (!graph) return;
|
||||
@@ -466,7 +269,6 @@ export default function BrowseView() {
|
||||
if (searchMatchIndices.length <= 10) {
|
||||
graph.fitViewByPointIndices(searchMatchIndices, 500);
|
||||
}
|
||||
// Initial position — will be continuously updated on tick/zoom
|
||||
updateSearchLabels();
|
||||
} else {
|
||||
graph.unselectPoints();
|
||||
@@ -510,7 +312,6 @@ export default function BrowseView() {
|
||||
const error = res.content ? null : (res.error ?? "No content");
|
||||
const entry: HistoryEntry = { path, html, error };
|
||||
|
||||
// Build new history: truncate any forward entries, push new
|
||||
const prevHistory = loading.history.slice(0, loading.historyIndex + 1);
|
||||
const newHistory = [...prevHistory, entry];
|
||||
const newIndex = newHistory.length - 1;
|
||||
@@ -562,11 +363,8 @@ export default function BrowseView() {
|
||||
const dest = anchor.getAttribute("data-destination") ?? anchor.getAttribute("href") ?? "";
|
||||
if (!dest) return;
|
||||
|
||||
// Strip nomadnetwork:// prefix if present, normalize path
|
||||
let path = dest.replace(/^nomadnetwork:\/\//, "").replace(/^\/+/, "");
|
||||
// If it looks like a hash (hex, 32 chars), it's a node link — not a page path
|
||||
if (/^[0-9a-f]{32}$/i.test(path)) return;
|
||||
// Ensure .mu extension
|
||||
if (!path.endsWith(".mu")) path += ".mu";
|
||||
|
||||
navigateTo(winId, data.node, path, data);
|
||||
@@ -579,41 +377,6 @@ export default function BrowseView() {
|
||||
updateClusterLabels();
|
||||
}, [updateClusterLabels]);
|
||||
|
||||
|
||||
// ── 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 - 307 }); }, []);
|
||||
|
||||
const onSearchDragStart = useCallback((e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).tagName === "INPUT") return;
|
||||
e.preventDefault();
|
||||
const pos = searchPos ?? { x: 0, y: 0 };
|
||||
searchDragRef.current = { startX: e.clientX, startY: e.clientY, origX: pos.x, origY: pos.y };
|
||||
const onMove = (ev: MouseEvent) => { if (!searchDragRef.current) return; setSearchPos({ x: searchDragRef.current.origX + (ev.clientX - searchDragRef.current.startX), y: Math.max(0, searchDragRef.current.origY + (ev.clientY - searchDragRef.current.startY)) }); };
|
||||
const onUp = () => { searchDragRef.current = null; document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); };
|
||||
document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp);
|
||||
}, [searchPos]);
|
||||
|
||||
return (
|
||||
<div className="relative overflow-hidden" style={{ height: "100%" }}>
|
||||
<div ref={containerRef} className="absolute inset-0" />
|
||||
@@ -626,60 +389,15 @@ export default function BrowseView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 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(); 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 && (
|
||||
<button onClick={clearSearch} className="text-muted-foreground hover:text-foreground transition-colors text-xs leading-none px-1" title="Clear search (Esc)">
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
{/* 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)}
|
||||
<BrowseSearchBar
|
||||
filter={filter}
|
||||
onFilterChange={setFilter}
|
||||
onClear={clearSearch}
|
||||
suggestions={suggestions}
|
||||
onSelectNode={handleNodeClick}
|
||||
focusedWinId={focusedWinId}
|
||||
windowCount={windows.length}
|
||||
/>
|
||||
|
||||
{nodes.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm pointer-events-none">
|
||||
@@ -687,63 +405,20 @@ export default function BrowseView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{windows.map((win) => {
|
||||
const d = win.data;
|
||||
const canBack = d.historyIndex > 0;
|
||||
const canFwd = d.historyIndex < d.history.length - 1;
|
||||
return (
|
||||
<FloatingWindow
|
||||
{windows.map((win) => (
|
||||
<BrowseNodeWindow
|
||||
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}
|
||||
win={win}
|
||||
focused={focusedWinId === win.id}
|
||||
onUpdate={updateWindow}
|
||||
onClose={closeWindowById}
|
||||
onFocus={focusWindow}
|
||||
addressBar={
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 border-b border-border shrink-0 bg-muted/15">
|
||||
{/* Nav buttons */}
|
||||
<button onClick={() => navBack(win.id, d)} disabled={!canBack || d.pageLoading}
|
||||
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Back">
|
||||
◀
|
||||
</button>
|
||||
<button onClick={() => navForward(win.id, d)} disabled={!canFwd || d.pageLoading}
|
||||
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Forward">
|
||||
▶
|
||||
</button>
|
||||
<button onClick={() => navReload(win.id, d)} disabled={d.pageLoading}
|
||||
className="w-5 h-5 flex items-center justify-center rounded text-[10px] text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-default transition-colors" title="Reload">
|
||||
↻
|
||||
</button>
|
||||
{/* Address */}
|
||||
<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">
|
||||
<span className="text-muted-foreground/60 truncate">{d.node.hash.slice(0, 12)}…/</span>{d.currentPath}
|
||||
</div>
|
||||
<span className="flex items-center gap-1 text-[9px] uppercase tracking-wider shrink-0">
|
||||
{d.pageLoading ? <span className="text-muted-foreground animate-pulse">loading</span>
|
||||
: d.pageError ? <><span className="w-1.5 h-1.5 rounded-full bg-destructive inline-block" /><span className="text-destructive">error</span></>
|
||||
: d.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">{d.node.type ?? "peer"}</span>
|
||||
{d.node.interface && <span className="text-[9px] text-muted-foreground uppercase tracking-wider truncate">via {d.node.interface}</span>}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div className="p-3 h-full overflow-auto" onClick={(e) => handleContentClick(e, win.id, d)}>
|
||||
{d.pageLoading && <span className="text-muted-foreground text-xs animate-pulse">Requesting page...</span>}
|
||||
{d.pageError && <span className="text-destructive text-xs">{d.pageError}</span>}
|
||||
{d.pageHtml && <div className="font-mono text-[11px] leading-tight" dangerouslySetInnerHTML={{ __html: d.pageHtml }} />}
|
||||
</div>
|
||||
</FloatingWindow>
|
||||
);
|
||||
})}
|
||||
onNavBack={navBack}
|
||||
onNavForward={navForward}
|
||||
onNavReload={navReload}
|
||||
onContentClick={handleContentClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { toast } from "sonner";
|
||||
import { MoreVertical, Plus, FolderPlus, ChevronRight, Folder, FileText, KeyRound, ArrowLeft, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { usePagesStore } from "@/stores/pagesStore";
|
||||
import * as api from "@/api/client";
|
||||
import { useKeyboardSave } from "@/hooks/useKeyboardSave";
|
||||
import StatusBadge from "@/components/dashboard/StatusBadge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -39,6 +40,8 @@ import type { ManagedWindow } from "@/hooks/useWindowManager";
|
||||
// Env editor window data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type SortKey = "name" | "size" | "modified";
|
||||
|
||||
interface EnvWinData {
|
||||
kind: "env";
|
||||
}
|
||||
@@ -57,7 +60,7 @@ export default function ComposeView() {
|
||||
const [pageToDelete, setPageToDelete] = useState<string | null>(null);
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||
const [sortKey, setSortKey] = useState<"name" | "size" | "modified">("name");
|
||||
const [sortKey, setSortKey] = useState<SortKey>("name");
|
||||
const [sortAsc, setSortAsc] = useState(true);
|
||||
|
||||
const sortedFiles = useMemo(() => {
|
||||
@@ -76,7 +79,7 @@ export default function ComposeView() {
|
||||
return [...folders, ...rest];
|
||||
}, [files, sortKey, sortAsc]);
|
||||
|
||||
const toggleSort = (key: "name" | "size" | "modified") => {
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) setSortAsc(!sortAsc);
|
||||
else { setSortKey(key); setSortAsc(true); }
|
||||
};
|
||||
@@ -97,7 +100,7 @@ export default function ComposeView() {
|
||||
}
|
||||
}, [currentPath]);
|
||||
|
||||
useEffect(() => { loadFiles(currentPath); }, [currentPath]);
|
||||
useEffect(() => { loadFiles(currentPath); }, [currentPath, loadFiles]);
|
||||
|
||||
const navigateTo = (path: string) => setCurrentPath(path);
|
||||
|
||||
@@ -424,6 +427,8 @@ function FileActions({
|
||||
moveTargets.push({ label: f.name + "/", path: f.path + "/" + entry.name });
|
||||
}
|
||||
|
||||
const menuItem = "w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer";
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
@@ -437,26 +442,23 @@ function FileActions({
|
||||
}
|
||||
/>
|
||||
<PopoverContent side="bottom" align="end" sideOffset={4} className="w-44 p-1">
|
||||
<button
|
||||
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>
|
||||
<button onClick={(e) => { e.stopPropagation(); onEdit(); }} className={menuItem}>
|
||||
Edit
|
||||
</button>
|
||||
{entry.published ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onUnpublish(); }}
|
||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
||||
>Unpublish</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); onUnpublish(); }} className={menuItem}>
|
||||
Unpublish
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onPublish(); }}
|
||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
||||
>Publish</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); onPublish(); }} className={menuItem}>
|
||||
Publish
|
||||
</button>
|
||||
)}
|
||||
{moveTargets.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setShowMove(!showMove); }}
|
||||
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer flex items-center justify-between"
|
||||
className={`${menuItem} flex items-center justify-between`}
|
||||
>
|
||||
Move to…
|
||||
<ChevronRight className={`w-3 h-3 transition-transform ${showMove ? "rotate-90" : ""}`} />
|
||||
@@ -477,10 +479,9 @@ function FileActions({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-destructive hover:bg-accent transition-colors cursor-pointer"
|
||||
>Delete</button>
|
||||
<button onClick={(e) => { e.stopPropagation(); onDelete(); }} className={`${menuItem} text-destructive`}>
|
||||
Delete
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
@@ -493,10 +494,10 @@ function FileActions({
|
||||
|
||||
function SortableHead({ label, sortKey, currentKey, asc, onToggle }: {
|
||||
label: string;
|
||||
sortKey: "name" | "size" | "modified";
|
||||
sortKey: SortKey;
|
||||
currentKey: string;
|
||||
asc: boolean;
|
||||
onToggle: (key: "name" | "size" | "modified") => void;
|
||||
onToggle: (key: SortKey) => void;
|
||||
}) {
|
||||
const active = currentKey === sortKey;
|
||||
return (
|
||||
@@ -560,17 +561,7 @@ function EnvEditorWindow({
|
||||
}
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [focused, handleSave]);
|
||||
useKeyboardSave(handleSave, undefined, focused);
|
||||
|
||||
const handleClose = useCallback((id: string) => {
|
||||
if (isDirty && !window.confirm("You have unsaved changes. Close anyway?")) return;
|
||||
|
||||
@@ -1,29 +1,20 @@
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
import { BookOpen, Upload } from "lucide-react";
|
||||
import * as api from "@/api/client";
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { useEditorStore } from "@/stores/editorStore";
|
||||
import { usePagesStore } from "@/stores/pagesStore";
|
||||
import { useUnsavedGuard } from "@/hooks/useUnsavedGuard";
|
||||
import { useCompile } from "@/hooks/useCompile";
|
||||
import { useKeyboardSave } from "@/hooks/useKeyboardSave";
|
||||
import { uframeHighlight } from "@/components/editor/uframeHighlight";
|
||||
import { uframeCommandSource, uframeValueHintSource, loadCommandsFromApi } from "@/components/editor/uframeCommands";
|
||||
import { keywordHoverTooltip } from "@/components/editor/uframeHover";
|
||||
import EditorPane from "@/components/editor/EditorPane";
|
||||
import EditorPointer from "@/components/editor/EditorPointer";
|
||||
import PreviewPane from "@/components/editor/PreviewPane";
|
||||
import SourcePane from "@/components/editor/SourcePane";
|
||||
import ToolBar from "@/components/editor/ToolBar";
|
||||
import { EXAMPLES } from "@/components/editor/examples";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
ResizablePanelGroup,
|
||||
ResizablePanel,
|
||||
@@ -81,10 +72,8 @@ export default function EditorView() {
|
||||
setPageName(name);
|
||||
api.fetchPage(name).then((data) => {
|
||||
if (data.source != null) {
|
||||
useEditorStore.setState({
|
||||
ufSource: data.source,
|
||||
isDirty: false,
|
||||
});
|
||||
setSource(data.source);
|
||||
setDirty(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -118,20 +107,10 @@ export default function EditorView() {
|
||||
[pageName, ufSource, isNew, navigate],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
e.preventDefault();
|
||||
handleSave(false);
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "p") {
|
||||
e.preventDefault();
|
||||
handleSave(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [handleSave]);
|
||||
useKeyboardSave(
|
||||
useCallback(() => handleSave(false), [handleSave]),
|
||||
useCallback(() => handleSave(true), [handleSave]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
@@ -164,82 +143,3 @@ export default function EditorView() {
|
||||
}
|
||||
|
||||
|
||||
/** Source pane — editor with header bar matching the Preview pane */
|
||||
function SourcePane({
|
||||
ufSource,
|
||||
setSource,
|
||||
extensions,
|
||||
}: {
|
||||
ufSource: string;
|
||||
setSource: (s: string) => void;
|
||||
extensions: Extension[];
|
||||
}) {
|
||||
const [examplesOpen, setExamplesOpen] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const data = await api.uploadImage(file);
|
||||
toast.success(`Uploaded ${data.filename}`);
|
||||
setSource(`image "${data.path}" braille 30\n align center`);
|
||||
} catch (err) {
|
||||
toast.error(`Upload failed: ${err}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center px-4 py-2 border-b-2 border-border shrink-0 gap-2">
|
||||
<span className="font-medium text-foreground flex-1">Source</span>
|
||||
|
||||
<Popover open={examplesOpen} onOpenChange={setExamplesOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 cursor-pointer">
|
||||
<BookOpen className="h-3 w-3" />
|
||||
Examples
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<PopoverContent side="bottom" align="end" sideOffset={8}>
|
||||
<PopoverHeader>
|
||||
<PopoverTitle>Insert Example</PopoverTitle>
|
||||
</PopoverHeader>
|
||||
<div className="flex flex-col gap-0.5 max-h-72 overflow-y-auto -mx-1">
|
||||
{EXAMPLES.map((ex) => (
|
||||
<button
|
||||
key={ex.name}
|
||||
onClick={() => { setSource(ex.source); setExamplesOpen(false); }}
|
||||
className="flex flex-col items-start px-2 py-1.5 text-left hover:bg-accent transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="text-sm font-medium">{ex.name}</span>
|
||||
<span className="text-xs text-muted-foreground leading-tight">{ex.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={handleUpload} />
|
||||
<button
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors uppercase tracking-wider flex items-center gap-1 disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
<Upload className="h-3 w-3" />
|
||||
{uploading ? "Uploading…" : "Image"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0">
|
||||
<EditorPane value={ufSource} onChange={setSource} extensions={extensions} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -65,49 +65,4 @@ export function createEditorStore() {
|
||||
}));
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorStore>((set) => ({
|
||||
ufSource: "",
|
||||
isDirty: false,
|
||||
currentPage: null,
|
||||
|
||||
compiledAscii: "",
|
||||
compiledMicron: "",
|
||||
compiledScript: "",
|
||||
isDynamic: false,
|
||||
compileWarnings: [],
|
||||
isCompiling: false,
|
||||
compileError: null,
|
||||
|
||||
previewMode: "micron",
|
||||
|
||||
setSource: (s) => set({ ufSource: s, isDirty: true }),
|
||||
setCurrentPage: (p) => set({ currentPage: p }),
|
||||
setDirty: (v) => set({ isDirty: v }),
|
||||
setCompileResult: (ascii, micron, script, isDynamic, warnings) =>
|
||||
set({
|
||||
compiledAscii: ascii,
|
||||
compiledMicron: micron,
|
||||
compiledScript: script,
|
||||
isDynamic,
|
||||
compileWarnings: warnings,
|
||||
isCompiling: false,
|
||||
compileError: null,
|
||||
}),
|
||||
setCompiling: (v) => set({ isCompiling: v }),
|
||||
setCompileError: (e) => set({ compileError: e, isCompiling: false }),
|
||||
setPreviewMode: (mode) => set({ previewMode: mode }),
|
||||
reset: () =>
|
||||
set({
|
||||
ufSource: "",
|
||||
isDirty: false,
|
||||
currentPage: null,
|
||||
compiledAscii: "",
|
||||
compiledMicron: "",
|
||||
compiledScript: "",
|
||||
isDynamic: false,
|
||||
compileWarnings: [],
|
||||
isCompiling: false,
|
||||
compileError: null,
|
||||
previewMode: "micron",
|
||||
}),
|
||||
}));
|
||||
export const useEditorStore = createEditorStore();
|
||||
|
||||
Reference in New Issue
Block a user