619 lines
22 KiB
TypeScript
619 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 StatusBadge from "@/components/dashboard/StatusBadge";
|
|
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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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<"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) => {
|
|
// 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 items-center justify-center h-32 text-muted-foreground text-sm">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 });
|
|
}
|
|
|
|
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="w-full text-left px-3 py-1.5 text-xs hover:bg-accent transition-colors cursor-pointer"
|
|
>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(); onPublish(); }}
|
|
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"
|
|
>Delete</button>
|
|
</PopoverContent>
|
|
</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>
|
|
);
|
|
}
|