feat: composer improvements

This commit is contained in:
2026-04-05 09:53:01 +02:00
parent 7838760ca4
commit e1db06104e
17 changed files with 1301 additions and 332 deletions

View File

@@ -160,6 +160,59 @@ export async function fetchRemotePage(
return res.json();
}
// ---------------------------------------------------------------------------
// File Browser
// ---------------------------------------------------------------------------
export interface FileEntry {
name: string;
path: string;
type: "file" | "folder" | "env";
size: number | null;
last_modified: number | null;
title: string | null;
published: boolean;
}
export async function fetchFiles(path: string = ""): Promise<FileEntry[]> {
const params = path ? `?path=${encodeURIComponent(path)}` : "";
const res = await fetch(`/api/files${params}`);
return res.json();
}
export async function createFolder(path: string): Promise<void> {
const res = await fetch("/api/files/mkdir", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
});
if (!res.ok) throw new Error(await res.text());
}
export async function moveFile(from: string, to: string): Promise<void> {
const res = await fetch("/api/files/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ from, to }),
});
if (!res.ok) throw new Error(await res.text());
}
export async function fetchEnv(): Promise<string> {
const res = await fetch("/api/files/env");
const data = await res.json();
return data.content;
}
export async function saveEnv(content: string): Promise<void> {
const res = await fetch("/api/files/env", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) throw new Error(await res.text());
}
// ---------------------------------------------------------------------------
// Images
// ---------------------------------------------------------------------------

View File

@@ -410,9 +410,9 @@ export const EXAMPLES: Example[] = [
source: `page "Live Status" 60
cache 0
source cpu_pct : shell "grep 'cpu ' /proc/stat | awk '{print int(($2+$4)*100/($2+$4+$5))}'"
source mem_pct : shell "free | awk '/Mem/{print int($3/$2*100)}'"
source uptime : shell "uptime -p"
source cpu_pct : python "secrets.randbelow(60) + 20"
source mem_pct : python "secrets.randbelow(40) + 50"
source uptime : python "str(timedelta(seconds=secrets.randbelow(86400)))"
source timestamp : python "datetime.now().strftime('%H:%M:%S')"
box double "Node Monitor"

View File

@@ -1,6 +1,9 @@
/**
* Micron markup → HTML renderer using the micron-parser library.
* Reference: https://github.com/RFnexus/micron-parser-js
*
* Uses convertMicronToFragment (DOM-based) instead of convertMicronToHtml
* to avoid DOMPurify stripping nomadnetwork:// hrefs from link tags.
*/
import MicronParser from "micron-parser";
@@ -9,11 +12,14 @@ let darkParser: MicronParser | null = null;
let lightParser: MicronParser | null = null;
export function renderMicron(source: string, darkTheme: boolean = true): string {
if (darkTheme) {
if (!darkParser) darkParser = new MicronParser(true, true);
return darkParser.convertMicronToHtml(source);
} else {
if (!lightParser) lightParser = new MicronParser(false, true);
return lightParser.convertMicronToHtml(source);
}
const parser = darkTheme
? (darkParser ??= new MicronParser(true, true))
: (lightParser ??= new MicronParser(false, true));
const fragment = parser.convertMicronToFragment(source);
// Serialize the fragment to HTML string
const div = document.createElement("div");
div.appendChild(fragment);
return div.innerHTML;
}

View File

@@ -101,7 +101,7 @@ const frameLayout: FrameLayout = {
containerMaxW: "max-w-5xl",
containerMinW: "min-w-5xl",
title: { paddingTop: 65, height: 185, paddingLeft: 60, paddingRight: 500, paddingBottom: 25 },
content: { marginLeft: 268, marginTop: 63, width: 476, height: 377, paddingTop: 0, paddingBottom: 0 },
content: { marginLeft: 268, marginTop: 63, marginRight: 0, width: 476, height: 377, paddingTop: 0, paddingBottom: 0 },
nav: { top: 150, left: -210 },
};

View File

@@ -178,11 +178,20 @@ function buildGraphArrays(
// 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;
}
// ---------------------------------------------------------------------------
@@ -485,15 +494,83 @@ export default function BrowseView() {
return () => { unsub(); if (batchTimer) clearTimeout(batchTimer); flush(); };
}, []);
// ── Navigation helpers ──
const navigateTo = useCallback((winId: string, node: NetworkNode, path: string, prevData?: BrowseWinData) => {
const loading: BrowseWinData = {
node, pageHtml: null, pageLoading: true, pageError: null,
currentPath: path,
history: prevData?.history ?? [],
historyIndex: prevData?.historyIndex ?? -1,
};
updateWindow(winId, { data: loading });
fetchRemotePage(node.hash, path)
.then((res) => {
const html = res.content ? renderMicron(res.content, true) : null;
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;
updateWindow(winId, { data: { node, pageHtml: html, pageError: error, pageLoading: false, currentPath: path, history: newHistory, historyIndex: newIndex } });
})
.catch((e) => {
const error = String(e);
const entry: HistoryEntry = { path, html: null, error };
const prevHistory = loading.history.slice(0, loading.historyIndex + 1);
const newHistory = [...prevHistory, entry];
const newIndex = newHistory.length - 1;
updateWindow(winId, { data: { node, pageError: error, pageLoading: false, pageHtml: null, currentPath: path, history: newHistory, historyIndex: newIndex } });
});
}, [updateWindow]);
const navBack = useCallback((winId: string, data: BrowseWinData) => {
const newIndex = data.historyIndex - 1;
if (newIndex < 0) return;
const entry = data.history[newIndex]!;
updateWindow(winId, { data: { ...data, pageHtml: entry.html, pageError: entry.error, pageLoading: false, currentPath: entry.path, historyIndex: newIndex } });
}, [updateWindow]);
const navForward = useCallback((winId: string, data: BrowseWinData) => {
const newIndex = data.historyIndex + 1;
if (newIndex >= data.history.length) return;
const entry = data.history[newIndex]!;
updateWindow(winId, { data: { ...data, pageHtml: entry.html, pageError: entry.error, pageLoading: false, currentPath: entry.path, historyIndex: newIndex } });
}, [updateWindow]);
const navReload = useCallback((winId: string, data: BrowseWinData) => {
navigateTo(winId, data.node, data.currentPath, { ...data, historyIndex: data.historyIndex - 1 });
}, [navigateTo]);
// ── Node click ──
const handleNodeClick = useCallback((node: NetworkNode) => {
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 initData: BrowseWinData = { node, pageHtml: null, pageLoading: true, pageError: null, currentPath: "index.mu", history: [], historyIndex: -1 };
openWindow(id, initData);
navigateTo(id, node, "index.mu");
}, [openWindow, navigateTo]);
// ── Handle micron link clicks via event delegation ──
const handleContentClick = useCallback((e: React.MouseEvent, winId: string, data: BrowseWinData) => {
const anchor = (e.target as HTMLElement).closest("a");
if (!anchor) return;
e.preventDefault();
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);
}, [navigateTo]);
const clearSearch = useCallback(() => {
setFilter("");
@@ -502,8 +579,6 @@ export default function BrowseView() {
updateClusterLabels();
}, [updateClusterLabels]);
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(); }, []);
@@ -612,42 +687,63 @@ export default function BrowseView() {
</div>
)}
{windows.map((win) => (
<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>
{windows.map((win) => {
const d = win.data;
const canBack = d.historyIndex > 0;
const canFwd = d.historyIndex < d.history.length - 1;
return (
<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-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">
&#9664;
</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">
&#9654;
</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">
&#8635;
</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>
}
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>
))}
</FloatingWindow>
);
})}
</div>
);
}

View File

@@ -1,8 +1,8 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { MoreVertical, Plus, RotateCcw } from "lucide-react";
import { MoreVertical, Plus, FolderPlus, ChevronRight, Folder, FileText, KeyRound, ArrowLeft, ArrowUp, ArrowDown } from "lucide-react";
import { usePagesStore } from "@/stores/pagesStore";
import { restartNode } from "@/api/client";
import * as api from "@/api/client";
import StatusBadge from "@/components/dashboard/StatusBadge";
import { Button } from "@/components/ui/button";
import {
@@ -30,39 +30,104 @@ import {
} from "@/components/ui/alert-dialog";
import { useWindowManager } from "@/hooks/useWindowManager";
import EditorWindow, { type EditorWinData } from "@/components/editor/EditorWindow";
import EditorPane from "@/components/editor/EditorPane";
import EditorPointer from "@/components/editor/EditorPointer";
import FloatingWindow from "@/components/shared/FloatingWindow";
import type { ManagedWindow } from "@/hooks/useWindowManager";
// ---------------------------------------------------------------------------
// Env editor window data
// ---------------------------------------------------------------------------
interface EnvWinData {
kind: "env";
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function ComposeView() {
const { pages, isLoading, fetchPages, deletePage, publishPage, unpublishPage } =
usePagesStore();
const { deletePage, publishPage, unpublishPage } = usePagesStore();
// File browser state
const [currentPath, setCurrentPath] = useState("");
const [files, setFiles] = useState<api.FileEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
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 [newFolderName, setNewFolderName] = useState("");
const [showNewFolder, setShowNewFolder] = useState(false);
const [sortKey, setSortKey] = useState<"name" | "size" | "modified">("name");
const [sortAsc, setSortAsc] = useState(true);
const sortedFiles = useMemo(() => {
// Folders always first, then sort within each group
const folders = files.filter(f => f.type === "folder");
const rest = files.filter(f => f.type !== "folder");
const cmp = (a: api.FileEntry, b: api.FileEntry): number => {
let v = 0;
if (sortKey === "name") v = a.name.localeCompare(b.name);
else if (sortKey === "size") v = (a.size ?? 0) - (b.size ?? 0);
else if (sortKey === "modified") v = (a.last_modified ?? 0) - (b.last_modified ?? 0);
return sortAsc ? v : -v;
};
folders.sort(cmp);
rest.sort(cmp);
return [...folders, ...rest];
}, [files, sortKey, sortAsc]);
const toggleSort = (key: "name" | "size" | "modified") => {
if (sortKey === key) setSortAsc(!sortAsc);
else { setSortKey(key); setSortAsc(true); }
};
// Editor windows
const { windows: editorWindows, focusedId: editorFocused, open: openEditorWin, update: updateEditorWin, close: closeEditorWin, focus: focusEditorWin } = useWindowManager<EditorWinData>({ w: 720, h: 520 });
// Env editor windows
const { windows: envWindows, focusedId: envFocused, open: openEnvWin, update: updateEnvWin, close: closeEnvWin, focus: focusEnvWin } = useWindowManager<EnvWinData>({ w: 520, h: 400 });
const loadFiles = useCallback(async (path: string = currentPath) => {
setIsLoading(true);
try {
const entries = await api.fetchFiles(path);
setFiles(entries);
} finally {
setIsLoading(false);
}
}, [currentPath]);
useEffect(() => { loadFiles(currentPath); }, [currentPath]);
const navigateTo = (path: string) => setCurrentPath(path);
const navigateUp = () => {
if (!currentPath) return;
const parts = currentPath.split("/").filter(Boolean);
parts.pop();
setCurrentPath(parts.join("/"));
};
// Path breadcrumbs
const pathParts = currentPath ? currentPath.split("/").filter(Boolean) : [];
const openEditor = (name: string, isNew: boolean) => {
const id = isNew ? `new-${Date.now()}` : name;
open(id, { pageName: isNew ? "" : name, isNew });
// For files in subfolders, use full relative path as page name
const pageName = isNew ? "" : name;
const id = isNew ? `new-${Date.now()}` : pageName;
openEditorWin(id, { pageName, isNew });
};
useEffect(() => {
fetchPages();
}, []);
const handleRestart = async () => {
setRestarting(true);
try {
await restartNode();
toast.success("NomadNet restarted");
} catch (e) {
toast.error(`Restart failed: ${e}`);
} finally {
setRestarting(false);
}
const openEnvEditor = () => {
openEnvWin("env-editor", { kind: "env" });
};
const handlePublish = async (name: string) => {
try {
await publishPage(name);
toast.success(`"${name}" published`);
loadFiles();
} catch (e) {
toast.error(`Failed: ${e}`);
}
@@ -72,6 +137,7 @@ export default function ComposeView() {
try {
await unpublishPage(name);
toast.success(`"${name}" unpublished`);
loadFiles();
} catch (e) {
toast.error(`Failed: ${e}`);
}
@@ -79,28 +145,63 @@ export default function ComposeView() {
const handleDelete = async () => {
if (!pageToDelete) return;
await deletePage(pageToDelete);
// Extract stem from path for the pages API
const stem = pageToDelete.replace(/\.uf$/, "");
await deletePage(stem);
toast.success(`"${pageToDelete}" deleted`);
setPageToDelete(null);
loadFiles();
};
if (isLoading)
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
);
const handleCreateFolder = async () => {
const name = newFolderName.trim();
if (!name) return;
const folderPath = currentPath ? `${currentPath}/${name}` : name;
try {
await api.createFolder(folderPath);
toast.success(`Folder "${name}" created`);
setNewFolderName("");
setShowNewFolder(false);
loadFiles();
} catch (e) {
toast.error(`Failed: ${e}`);
}
};
const handleFileClick = (entry: api.FileEntry) => {
if (entry.type === "folder") {
navigateTo(entry.path);
} else if (entry.type === "env") {
openEnvEditor();
} else {
// Open .uf file in editor — strip .uf extension for page name
const pageName = entry.path.replace(/\.uf$/, "");
openEditor(pageName, false);
}
};
const formatSize = (size: number | null) => {
if (size == null) return "\u2014";
if (size < 1024) return `${size} B`;
return `${(size / 1024).toFixed(1)} KB`;
};
const formatTime = (ts: number | null) => {
if (ts == null) return "\u2014";
const d = new Date(ts * 1000);
return d.toLocaleDateString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
};
return (
<div>
<div>
<div className="flex flex-col h-full">
<div className="flex flex-col flex-1 min-h-0">
{/* Header row */}
<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" size="sm" onClick={handleRestart} disabled={restarting}>
<RotateCcw className="w-3 h-3 mr-1.5" />
Restart
<Button variant="outline" size="sm" onClick={() => setShowNewFolder(true)}>
<FolderPlus className="w-3 h-3 mr-1.5" />
New Folder
</Button>
<Button size="sm" onClick={() => openEditor("", true)}>
<Plus className="w-3 h-3 mr-1.5" />
@@ -109,64 +210,135 @@ export default function ComposeView() {
</div>
</div>
{/* Table */}
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Title</TableHead>
<TableHead>Status</TableHead>
<TableHead>Size</TableHead>
<TableHead className="w-8" />
</TableRow>
</TableHeader>
<TableBody>
{pages.map((p) => (
<TableRow
key={p.name}
className="cursor-pointer"
onClick={() => openEditor(p.name, false)}
>
<TableCell className="font-mono">
{p.name}
{p.name === "index" && (
<span className="ml-2 text-xs text-primary">homepage</span>
)}
</TableCell>
<TableCell className="text-muted-foreground">
{p.title ?? "\u2014"}
</TableCell>
<TableCell>
<StatusBadge published={p.published} hasSource={p.has_source} />
</TableCell>
<TableCell className="text-muted-foreground">
{p.size != null ? `${p.size} B` : "\u2014"}
</TableCell>
<TableCell className="text-right w-8">
<PageActions
published={p.published}
onEdit={() => openEditor(p.name, false)}
onPublish={() => handlePublish(p.name)}
onUnpublish={() => handleUnpublish(p.name)}
onDelete={() => setPageToDelete(p.name)}
/>
</TableCell>
</TableRow>
))}
{pages.length === 0 && (
{/* Path bar */}
<div className="flex items-center px-3 py-1.5 border-b border-border bg-muted/15 text-xs">
{currentPath && (
<button onClick={navigateUp} className="mr-2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
<ArrowLeft className="w-3.5 h-3.5" />
</button>
)}
<button onClick={() => navigateTo("")} className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer font-mono">
/
</button>
{pathParts.map((part, i) => {
const partPath = pathParts.slice(0, i + 1).join("/");
return (
<span key={partPath} className="flex items-center">
<ChevronRight className="w-3 h-3 mx-0.5 text-muted-foreground/50" />
<button onClick={() => navigateTo(partPath)} className="text-muted-foreground hover:text-foreground transition-colors cursor-pointer font-mono">
{part}
</button>
</span>
);
})}
{/* New folder inline input */}
{showNewFolder && (
<span className="flex items-center ml-4 gap-1">
<ChevronRight className="w-3 h-3 text-muted-foreground/50" />
<input
autoFocus
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateFolder();
if (e.key === "Escape") { setShowNewFolder(false); setNewFolderName(""); }
}}
placeholder="folder name"
className="h-5 px-1.5 text-xs bg-background border border-border rounded font-mono w-32 focus:outline-none focus:ring-1 focus:ring-primary"
/>
<button onClick={handleCreateFolder} className="text-primary text-xs cursor-pointer">create</button>
<button onClick={() => { setShowNewFolder(false); setNewFolderName(""); }} className="text-muted-foreground text-xs cursor-pointer">cancel</button>
</span>
)}
</div>
{/* File table */}
<div className="flex-1 min-h-0 overflow-auto">
{isLoading ? (
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">Loading...</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableCell
colSpan={5}
className="text-center text-muted-foreground py-8"
>
No pages yet. Create one to get started.
</TableCell>
<SortableHead label="Name" sortKey="name" currentKey={sortKey} asc={sortAsc} onToggle={toggleSort} />
<TableHead>Status</TableHead>
<SortableHead label="Size" sortKey="size" currentKey={sortKey} asc={sortAsc} onToggle={toggleSort} />
<SortableHead label="Modified" sortKey="modified" currentKey={sortKey} asc={sortAsc} onToggle={toggleSort} />
<TableHead className="w-8" />
</TableRow>
)}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{sortedFiles.map((entry) => (
<TableRow
key={entry.path}
className="cursor-pointer"
onClick={() => handleFileClick(entry)}
>
<TableCell className="font-mono">
<span className="flex items-center gap-2">
{entry.type === "folder" ? (
<Folder className="w-3.5 h-3.5 text-primary/70 shrink-0" />
) : entry.type === "env" ? (
<KeyRound className="w-3.5 h-3.5 text-amber-500/70 shrink-0" />
) : (
<FileText className="w-3.5 h-3.5 text-muted-foreground/50 shrink-0" />
)}
<span>{entry.name}</span>
{entry.name === "index.uf" && (
<span className="text-[10px] text-primary">homepage</span>
)}
</span>
</TableCell>
<TableCell>
{entry.type === "file" && entry.name.endsWith(".uf") ? (
<StatusBadge published={entry.published} hasSource={true} />
) : null}
</TableCell>
<TableCell className="text-muted-foreground">
{entry.type !== "folder" ? formatSize(entry.size) : "\u2014"}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatTime(entry.last_modified)}
</TableCell>
<TableCell className="text-right w-8">
{entry.type === "file" && entry.name.endsWith(".uf") && (
<FileActions
entry={entry}
folders={files.filter(f => f.type === "folder")}
currentPath={currentPath}
onEdit={() => handleFileClick(entry)}
onPublish={() => handlePublish(entry.path.replace(/\.uf$/, ""))}
onUnpublish={() => handleUnpublish(entry.path.replace(/\.uf$/, ""))}
onDelete={() => setPageToDelete(entry.path)}
onMove={async (to) => {
try {
await api.moveFile(entry.path, to);
toast.success(`Moved "${entry.name}" to ${to || "/"}`);
loadFiles();
} catch (e) {
toast.error(`Move failed: ${e}`);
}
}}
/>
)}
</TableCell>
</TableRow>
))}
{files.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
{currentPath ? "Empty folder." : "No files yet. Create a page to get started."}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
</div>
{/* Delete confirmation */}
<AlertDialog
open={pageToDelete !== null}
onOpenChange={(open) => !open && setPageToDelete(null)}
@@ -175,8 +347,7 @@ export default function ComposeView() {
<AlertDialogHeader>
<AlertDialogTitle>Delete "{pageToDelete}"?</AlertDialogTitle>
<AlertDialogDescription>
This permanently deletes the page and its source. This cannot be
undone.
This permanently deletes the file and its published version. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -191,15 +362,27 @@ export default function ComposeView() {
</AlertDialogContent>
</AlertDialog>
{/* Floating editor windows */}
{windows.map((win) => (
{/* Editor windows */}
{editorWindows.map((win) => (
<EditorWindow
key={win.id}
win={win}
focused={focusedId === win.id}
onUpdate={update}
onClose={close}
onFocus={focus}
focused={editorFocused === win.id}
onUpdate={updateEditorWin}
onClose={closeEditorWin}
onFocus={focusEditorWin}
/>
))}
{/* Env editor windows */}
{envWindows.map((win) => (
<EnvEditorWindow
key={win.id}
win={win}
focused={envFocused === win.id}
onUpdate={updateEnvWin}
onClose={closeEnvWin}
onFocus={focusEnvWin}
/>
))}
</div>
@@ -207,20 +390,40 @@ export default function ComposeView() {
}
/** Per-row action menu for a page. */
function PageActions({
published,
// ---------------------------------------------------------------------------
// File action menu
// ---------------------------------------------------------------------------
function FileActions({
entry,
folders,
currentPath,
onEdit,
onPublish,
onUnpublish,
onDelete,
onMove,
}: {
published: boolean;
entry: api.FileEntry;
folders: api.FileEntry[];
currentPath: string;
onEdit: () => void;
onPublish: () => void;
onUnpublish: () => void;
onDelete: () => void;
onMove: (to: string) => void;
}) {
const [showMove, setShowMove] = useState(false);
// Build move targets: parent dir (if in a subfolder) + sibling folders
const moveTargets: { label: string; path: string }[] = [];
if (currentPath) {
moveTargets.push({ label: "/ (root)", path: entry.name });
}
for (const f of folders) {
moveTargets.push({ label: f.name + "/", path: f.path + "/" + entry.name });
}
return (
<Popover>
<PopoverTrigger
@@ -233,12 +436,12 @@ function PageActions({
</button>
}
/>
<PopoverContent side="bottom" align="end" sideOffset={4} className="w-36 p-1">
<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>
{published ? (
{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"
@@ -249,6 +452,31 @@ function PageActions({
className="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
>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"
>
Move to
<ChevronRight className={`w-3 h-3 transition-transform ${showMove ? "rotate-90" : ""}`} />
</button>
{showMove && (
<div className="border-t border-border mt-0.5 pt-0.5">
{moveTargets.map((t) => (
<button
key={t.path}
onClick={(e) => { e.stopPropagation(); onMove(t.path); }}
className="w-full text-left px-4 py-1.5 text-xs font-mono text-muted-foreground hover:text-foreground hover:bg-accent transition-colors cursor-pointer flex items-center gap-1.5"
>
<Folder className="w-3 h-3 shrink-0" />
{t.label}
</button>
))}
</div>
)}
</>
)}
<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"
@@ -257,3 +485,134 @@ function PageActions({
</Popover>
);
}
// ---------------------------------------------------------------------------
// Sortable table header
// ---------------------------------------------------------------------------
function SortableHead({ label, sortKey, currentKey, asc, onToggle }: {
label: string;
sortKey: "name" | "size" | "modified";
currentKey: string;
asc: boolean;
onToggle: (key: "name" | "size" | "modified") => void;
}) {
const active = currentKey === sortKey;
return (
<TableHead>
<button
onClick={(e) => { e.stopPropagation(); onToggle(sortKey); }}
className="flex items-center gap-1 text-inherit hover:text-foreground transition-colors cursor-pointer"
>
{label}
{active && (asc
? <ArrowUp className="w-3 h-3" />
: <ArrowDown className="w-3 h-3" />
)}
</button>
</TableHead>
);
}
// ---------------------------------------------------------------------------
// Env editor floating window
// ---------------------------------------------------------------------------
function EnvEditorWindow({
win,
focused,
onUpdate,
onClose,
onFocus,
}: {
win: ManagedWindow<EnvWinData>;
focused: boolean;
onUpdate: (id: string, patch: Partial<ManagedWindow<EnvWinData>>) => void;
onClose: (id: string) => void;
onFocus: (id: string) => void;
}) {
const windowRef = useRef<HTMLDivElement>(null);
const [content, setContent] = useState("");
const [isDirty, setIsDirty] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
api.fetchEnv().then((c) => setContent(c));
}, []);
const handleChange = useCallback((v: string) => {
setContent(v);
setIsDirty(true);
}, []);
const handleSave = useCallback(async () => {
setSaving(true);
try {
await api.saveEnv(content);
setIsDirty(false);
toast.success(".env saved");
} catch (e) {
toast.error(`Save failed: ${e}`);
} finally {
setSaving(false);
}
}, [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]);
const handleClose = useCallback((id: string) => {
if (isDirty && !window.confirm("You have unsaved changes. Close anyway?")) return;
onClose(id);
}, [isDirty, onClose]);
return (
<FloatingWindow
id={win.id}
title=".env"
x={win.x} y={win.y} w={win.w} h={win.h}
zIndex={win.zIndex}
focused={focused}
onUpdate={onUpdate}
onClose={handleClose}
onFocus={onFocus}
minW={360} minH={200}
containerRef={windowRef}
>
<EditorPointer containerRef={windowRef} focused={focused} />
<div className="flex flex-col h-full">
{/* Toolbar */}
<div className="flex items-center px-3 py-1.5 border-b-2 border-border shrink-0 gap-2">
<KeyRound className="w-3.5 h-3.5 text-amber-500/70" />
<span className="text-xs font-semibold flex-1">Environment Variables</span>
<button
onClick={handleSave}
disabled={saving || !isDirty}
className="text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors cursor-pointer"
>
{saving ? "Saving..." : "Save"}
</button>
</div>
{/* Help */}
<div className="px-3 py-1.5 border-b border-border bg-muted/10 text-[10px] text-muted-foreground">
One variable per line: <span className="font-mono">KEY=value</span>. Use <span className="font-mono">source name : env "KEY"</span> in pages.
</div>
{/* CodeMirror editor */}
<div className="flex-1 min-h-0">
<EditorPane value={content} onChange={handleChange} />
</div>
</div>
</FloatingWindow>
);
}