Files
micronomicon/frontend/src/routes/ComposeView.tsx
2026-04-05 11:01:48 +02:00

611 lines
22 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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 Loader from "@/components/shared/Loader";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} 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
// ---------------------------------------------------------------------------
type SortKey = "name" | "size" | "modified";
interface EnvWinData {
kind: "env";
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export default function ComposeView() {
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 [newFolderName, setNewFolderName] = useState("");
const [showNewFolder, setShowNewFolder] = useState(false);
const [sortKey, setSortKey] = useState<SortKey>("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: SortKey) => {
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, loadFiles]);
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) => {
// 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 });
};
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}`);
}
};
const handleUnpublish = async (name: string) => {
try {
await unpublishPage(name);
toast.success(`"${name}" unpublished`);
loadFiles();
} catch (e) {
toast.error(`Failed: ${e}`);
}
};
const handleDelete = async () => {
if (!pageToDelete) return;
// Extract stem from path for the pages API
const stem = pageToDelete.replace(/\.uf$/, "");
await deletePage(stem);
toast.success(`"${pageToDelete}" deleted`);
setPageToDelete(null);
loadFiles();
};
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 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={() => 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" />
New Page
</Button>
</div>
</div>
{/* 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 flex-col items-center justify-center h-32 gap-3 text-muted-foreground text-xs"><Loader size={48} /> Loading...</div>
) : (
<Table>
<TableHeader>
<TableRow>
<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>
</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)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete "{pageToDelete}"?</AlertDialogTitle>
<AlertDialogDescription>
This permanently deletes the file and its published version. This cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-white hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Editor windows */}
{editorWindows.map((win) => (
<EditorWindow
key={win.id}
win={win}
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>
);
}
// ---------------------------------------------------------------------------
// File action menu
// ---------------------------------------------------------------------------
function FileActions({
entry,
folders,
currentPath,
onEdit,
onPublish,
onUnpublish,
onDelete,
onMove,
}: {
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 });
}
const menuItem = "w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer";
return (
<Popover>
<PopoverTrigger
render={
<button
onClick={(e) => e.stopPropagation()}
className="p-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<MoreVertical className="w-4 h-4" />
</button>
}
/>
<PopoverContent side="bottom" align="end" sideOffset={4} className="w-44 p-1">
<button onClick={(e) => { e.stopPropagation(); onEdit(); }} className={menuItem}>
Edit
</button>
{entry.published ? (
<button onClick={(e) => { e.stopPropagation(); onUnpublish(); }} className={menuItem}>
Unpublish
</button>
) : (
<button onClick={(e) => { e.stopPropagation(); onPublish(); }} className={menuItem}>
Publish
</button>
)}
{moveTargets.length > 0 && (
<>
<button
onClick={(e) => { e.stopPropagation(); setShowMove(!showMove); }}
className={`${menuItem} 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={`${menuItem} text-destructive`}>
Delete
</button>
</PopoverContent>
</Popover>
);
}
// ---------------------------------------------------------------------------
// Sortable table header
// ---------------------------------------------------------------------------
function SortableHead({ label, sortKey, currentKey, asc, onToggle }: {
label: string;
sortKey: SortKey;
currentKey: string;
asc: boolean;
onToggle: (key: SortKey) => 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]);
useKeyboardSave(handleSave, undefined, focused);
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>
);
}