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([]); const [isLoading, setIsLoading] = useState(false); const [pageToDelete, setPageToDelete] = useState(null); const [newFolderName, setNewFolderName] = useState(""); const [showNewFolder, setShowNewFolder] = useState(false); const [sortKey, setSortKey] = useState("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({ w: 720, h: 520 }); // Env editor windows const { windows: envWindows, focusedId: envFocused, open: openEnvWin, update: updateEnvWin, close: closeEnvWin, focus: focusEnvWin } = useWindowManager({ 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 (
{/* Header row */}

Compose

{/* Path bar */}
{currentPath && ( )} {pathParts.map((part, i) => { const partPath = pathParts.slice(0, i + 1).join("/"); return ( ); })} {/* New folder inline input */} {showNewFolder && ( 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" /> )}
{/* File table */}
{isLoading ? (
Loading...
) : ( Status {sortedFiles.map((entry) => ( handleFileClick(entry)} > {entry.type === "folder" ? ( ) : entry.type === "env" ? ( ) : ( )} {entry.name} {entry.name === "index.uf" && ( homepage )} {entry.type === "file" && entry.name.endsWith(".uf") ? ( ) : null} {entry.type !== "folder" ? formatSize(entry.size) : "\u2014"} {formatTime(entry.last_modified)} {entry.type === "file" && entry.name.endsWith(".uf") && ( 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}`); } }} /> )} ))} {files.length === 0 && ( {currentPath ? "Empty folder." : "No files yet. Create a page to get started."} )}
)}
{/* Delete confirmation */} !open && setPageToDelete(null)} > Delete "{pageToDelete}"? This permanently deletes the file and its published version. This cannot be undone. Cancel Delete {/* Editor windows */} {editorWindows.map((win) => ( ))} {/* Env editor windows */} {envWindows.map((win) => ( ))}
); } // --------------------------------------------------------------------------- // 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 ( e.stopPropagation()} className="p-1 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" > } /> {entry.published ? ( ) : ( )} {moveTargets.length > 0 && ( <> {showMove && (
{moveTargets.map((t) => ( ))}
)} )}
); } // --------------------------------------------------------------------------- // 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 ( ); } // --------------------------------------------------------------------------- // Env editor floating window // --------------------------------------------------------------------------- function EnvEditorWindow({ win, focused, onUpdate, onClose, onFocus, }: { win: ManagedWindow; focused: boolean; onUpdate: (id: string, patch: Partial>) => void; onClose: (id: string) => void; onFocus: (id: string) => void; }) { const windowRef = useRef(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 (
{/* Toolbar */}
Environment Variables
{/* Help */}
One variable per line: KEY=value. Use source name : env "KEY" in pages.
{/* CodeMirror editor */}
); }