313 lines
7.7 KiB
TypeScript
313 lines
7.7 KiB
TypeScript
import { ApiError } from "@/api";
|
|
import { CodeMirrorEditor } from "@/components/codemirror-editor";
|
|
import { Button } from "@/components/ui/button.tsx";
|
|
import {
|
|
Dialog,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarGroup,
|
|
SidebarGroupLabel,
|
|
SidebarHeader,
|
|
SidebarMenu,
|
|
SidebarMenuButton,
|
|
SidebarMenuItem,
|
|
SidebarProvider,
|
|
} from "@/components/ui/sidebar.tsx";
|
|
import { cn } from "@/lib/utils";
|
|
import { Link } from "@tanstack/react-router";
|
|
import {
|
|
ArrowLeft,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Hammer,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import { useEffect, useRef } from "react";
|
|
import { useStore } from "zustand";
|
|
import { useTemplate, useTemplateFile, useUpdateTemplateFile } from "./api";
|
|
import { BuildTemplateDialog } from "./build-template-dialog";
|
|
import { templateEditorRoute } from "./routes";
|
|
import {
|
|
type TemplateEditorStore,
|
|
TemplateEditorStoreContext,
|
|
createTemplateEditorStore,
|
|
useTemplateEditorStore,
|
|
} from "./template-editor-store";
|
|
import type { Template } from "./types";
|
|
|
|
function TemplateEditor() {
|
|
const { templateName, _splat } = templateEditorRoute.useParams();
|
|
const { data: template, isLoading, error } = useTemplate(templateName);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<main className="w-full h-full flex items-center justify-center">
|
|
<Loader2 className="animate-spin" />
|
|
</main>
|
|
);
|
|
}
|
|
|
|
if (error || !template) {
|
|
if (error === ApiError.NotFound) {
|
|
return (
|
|
<main className="w-full h-full flex flex-col items-center justify-center space-y-2">
|
|
<p>Template does not exist</p>
|
|
<Button variant="secondary">Create template</Button>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
let message = "";
|
|
switch (error) {
|
|
case ApiError.Network:
|
|
message = "We are having trouble contacting the server.";
|
|
break;
|
|
default:
|
|
message = "An error occurred on our end.";
|
|
break;
|
|
}
|
|
|
|
return (
|
|
<main className="w-full h-full flex flex-col items-center justify-center space-y-2">
|
|
<p className="text-destructive">{message}</p>
|
|
<Button variant="secondary">Refresh</Button>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
return <_TemplateEditor template={template} currentFilePath={_splat ?? ""} />;
|
|
}
|
|
|
|
function _TemplateEditor({
|
|
template,
|
|
currentFilePath,
|
|
}: { template: Template; currentFilePath: string }) {
|
|
const store = useRef<TemplateEditorStore | null>(null);
|
|
if (!store.current) {
|
|
store.current = createTemplateEditorStore({ template, currentFilePath });
|
|
}
|
|
|
|
const setCurrentFilePath = useStore(
|
|
store.current,
|
|
(state) => state.setCurrentFilePath,
|
|
);
|
|
|
|
useEffect(() => {
|
|
setCurrentFilePath(currentFilePath);
|
|
}, [setCurrentFilePath, currentFilePath]);
|
|
|
|
return (
|
|
<TemplateEditorStoreContext.Provider value={store.current}>
|
|
<SidebarProvider>
|
|
<EditorSidebar />
|
|
<div className="flex flex-col w-full min-w-0">
|
|
<EditorTopBar />
|
|
<main className="w-full h-full flex flex-col">
|
|
<Editor />
|
|
<TemplateBuildOutputPanel />
|
|
</main>
|
|
</div>
|
|
</SidebarProvider>
|
|
</TemplateEditorStoreContext.Provider>
|
|
);
|
|
}
|
|
|
|
function Editor() {
|
|
const templateName = useTemplateEditorStore((state) => state.template.name);
|
|
const currentPath = useTemplateEditorStore((state) => state.currentFilePath);
|
|
const {
|
|
isLoading,
|
|
data: fileContent,
|
|
error,
|
|
} = useTemplateFile(templateName, currentPath);
|
|
const saveTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const { updateTemplateFile } = useUpdateTemplateFile(templateName);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (saveTimeout.current) {
|
|
clearTimeout(saveTimeout.current);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
if (!currentPath) {
|
|
return (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<p>Select a file from the sidebar</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<p className="animate-pulse">Loading file…</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || fileContent === undefined) {
|
|
let message = "";
|
|
switch (error) {
|
|
case ApiError.NotFound:
|
|
message = "This file does not exist in the template.";
|
|
break;
|
|
case ApiError.Network:
|
|
message = "Having trouble contacting the server.";
|
|
break;
|
|
default:
|
|
message = "An error occured on our end.";
|
|
}
|
|
|
|
return (
|
|
<div className="w-full h-full flex items-center justify-center">
|
|
<p className="text-destructive">{message}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function onValueChanged(path: string, value: string) {
|
|
if (saveTimeout.current) {
|
|
clearTimeout(saveTimeout.current);
|
|
}
|
|
saveTimeout.current = setTimeout(() => {
|
|
updateTemplateFile(path, value);
|
|
}, 500);
|
|
}
|
|
|
|
return (
|
|
<CodeMirrorEditor
|
|
className="w-full flex-1 grow"
|
|
path={currentPath}
|
|
initialValue={fileContent}
|
|
onValueChanged={onValueChanged}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function EditorTopBar() {
|
|
const currentFilePath = useTemplateEditorStore(
|
|
(state) => state.currentFilePath,
|
|
);
|
|
const isBuildInProgress = useTemplateEditorStore(
|
|
(state) => state.isBuildInProgress,
|
|
);
|
|
|
|
return (
|
|
<Dialog>
|
|
<header className="sticky top-0 flex shrink-0 items-center justify-between gap-2 border-b bg-background p-4">
|
|
<p className="font-bold">{currentFilePath}</p>
|
|
<DialogTrigger>
|
|
<Button>
|
|
{isBuildInProgress ? (
|
|
<Loader2 className="animate-spin" />
|
|
) : (
|
|
<Hammer />
|
|
)}{" "}
|
|
Build
|
|
</Button>
|
|
</DialogTrigger>
|
|
</header>
|
|
<BuildTemplateDialog />
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function EditorSidebar() {
|
|
const templateName = useTemplateEditorStore((state) => state.template.name);
|
|
return (
|
|
<Sidebar>
|
|
<SidebarHeader>
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<SidebarMenuButton asChild className="opacity-80">
|
|
<Link to="/templates" className="text-xs">
|
|
<ArrowLeft /> All templates
|
|
</Link>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
<p className="px-2 font-semibold">{templateName}</p>
|
|
</SidebarHeader>
|
|
<SidebarContent>
|
|
<SidebarGroup>
|
|
<SidebarGroupLabel>Files</SidebarGroupLabel>
|
|
<EditorSidebarFileTree />
|
|
</SidebarGroup>
|
|
</SidebarContent>
|
|
</Sidebar>
|
|
);
|
|
}
|
|
|
|
function EditorSidebarFileTree() {
|
|
const template = useTemplateEditorStore((state) => state.template);
|
|
const currentFilePath = useTemplateEditorStore(
|
|
(state) => state.currentFilePath,
|
|
);
|
|
|
|
return (
|
|
<SidebarMenu>
|
|
{Object.values(template.files).map((file) => (
|
|
<SidebarMenuItem key={file.path}>
|
|
<SidebarMenuButton isActive={currentFilePath === file.path} asChild>
|
|
<Link to={`/templates/${template.name}/${file.path}`}>
|
|
{file.path}
|
|
</Link>
|
|
</SidebarMenuButton>
|
|
</SidebarMenuItem>
|
|
))}
|
|
</SidebarMenu>
|
|
);
|
|
}
|
|
|
|
function TemplateBuildOutputPanel() {
|
|
const isBuildOutputVisible = useTemplateEditorStore(
|
|
(state) => state.isBuildOutputVisible,
|
|
);
|
|
const toggleBuildOutput = useTemplateEditorStore(
|
|
(state) => state.toggleBuildOutput,
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"flex flex-col overflow-hidden w-full",
|
|
isBuildOutputVisible ? "h-96" : "",
|
|
)}
|
|
>
|
|
<div className="flex justify-between items-center border-y border-y-border pl-4 pr-2 py-0.5">
|
|
<p className="font-semibold text-sm">Build output</p>
|
|
<Button variant="ghost" size="icon" onClick={toggleBuildOutput}>
|
|
{isBuildOutputVisible ? <ChevronDown /> : <ChevronUp />}
|
|
</Button>
|
|
</div>
|
|
{isBuildOutputVisible ? <BuildOutput /> : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BuildOutput() {
|
|
const buildOutput = useTemplateEditorStore((state) => state.buildOutput);
|
|
const el = useRef<HTMLPreElement | null>(null);
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
|
|
useEffect(() => {
|
|
if (el.current) {
|
|
el.current.scrollTo(0, el.current.scrollHeight);
|
|
}
|
|
}, [buildOutput]);
|
|
|
|
return (
|
|
<pre ref={el} className="p-4 overflow-auto">
|
|
{buildOutput}
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
export { TemplateEditor };
|