feat: editor in popover

This commit is contained in:
2026-04-05 00:31:47 +02:00
parent 3eec7f316e
commit 914945279f
17 changed files with 755 additions and 266 deletions

View File

@@ -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() {
&times;
</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>
);

View File

@@ -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 ? (