import { useCallback, useEffect, useMemo, useState } from "react"; import { ResizableHandle, ResizablePanel, ResizablePanelGroup, } from "~/components/ui/resizable"; import Tabs, { Tab, TabView } from "~/components/ui/tabs"; import FileViewer from "./file-viewer"; import trpc from "~/lib/trpc"; import EditorContext from "../context/editor"; import type { FileSchema } from "~/server/db/schema/file"; import { useProjectContext } from "../context/project"; import Sidebar from "./sidebar"; import ConsoleLogger from "./console-logger"; import { useData } from "~/renderer/hooks"; import { Data } from "../+data"; import { useBreakpoint } from "~/hooks/useBreakpoint"; import StatusBar from "./status-bar"; import { FiTerminal } from "react-icons/fi"; import SettingsDialog from "./settings-dialog"; import FileIcon from "~/components/ui/file-icon"; import { api } from "~/lib/api"; import { useMutation } from "@tanstack/react-query"; import { Button } from "~/components/ui/button"; import { FaExternalLinkAlt } from "react-icons/fa"; import { BASE_URL } from "~/lib/consts"; const Editor = () => { const { project, initialFiles } = useData<Data>(); const trpcUtils = trpc.useUtils(); const { isEmbed } = useProjectContext(); const [breakpoint] = useBreakpoint(); const [curTabIdx, setCurTabIdx] = useState(0); const [curOpenFiles, setOpenFiles] = useState<number[]>( initialFiles.map((i) => i.id) ); const openedFilesData = trpc.file.getAll.useQuery( { projectId: project.id!, id: curOpenFiles }, { enabled: curOpenFiles.length > 0, initialData: initialFiles } ); const [openedFiles, setOpenedFiles] = useState<any[]>(initialFiles); const generateThumbnail = useMutation({ mutationFn: () => { return api(`/thumbnail/${project.slug!}`, { method: "PATCH" }); }, }); const deleteFile = trpc.file.delete.useMutation({ onSuccess: (file) => { trpcUtils.file.getAll.invalidate(); onFileChanged(file); const openFileIdx = curOpenFiles.indexOf(file.id); if (openFileIdx >= 0) { onCloseFile(openFileIdx); } }, }); useEffect(() => { if (!initialFiles?.length || curOpenFiles.length > 0) { return; } initialFiles.forEach((file) => { onOpenFile(file.id, false); }); return () => { setOpenFiles([]); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialFiles]); useEffect(() => { if (openedFilesData.data) { setOpenedFiles(openedFilesData.data); } }, [openedFilesData.data]); // useEffect(() => { // // start API sandbox // api(`/sandbox/${project.slug}/start`, { method: "POST" }).catch(() => {}); // }, [project]); useEffect(() => { if (isEmbed) { return; } const itv = setInterval(() => generateThumbnail.mutate(), 60000); const generate = setTimeout(() => generateThumbnail.mutate(), 1000); return () => { clearInterval(itv); clearTimeout(generate); }; }, [isEmbed]); const onOpenFile = useCallback( (fileId: number, autoSwitchTab = true) => { const idx = curOpenFiles.indexOf(fileId); if (idx >= 0) { return setCurTabIdx(idx); } setOpenFiles((state) => { if (autoSwitchTab) { setCurTabIdx(state.length); } return [...state, fileId]; }); }, [curOpenFiles] ); const onDeleteFile = useCallback( (fileId: number) => { if ( window.confirm("Are you sure want to delete this files?") && !deleteFile.isPending ) { deleteFile.mutate(fileId); } }, [deleteFile] ); const onCloseFile = useCallback( (idx: number) => { const _f = [...curOpenFiles]; _f.splice(idx, 1); setOpenFiles(_f); if (curTabIdx === idx) { setCurTabIdx(Math.max(0, idx - 1)); } }, [curOpenFiles, curTabIdx] ); const onFileChanged = useCallback( (_file: Omit<FileSchema, "content">) => { openedFilesData.refetch(); }, [openedFilesData] ); const tabs = useMemo(() => { let tabs: Tab[] = []; // opened files tabs = tabs.concat( curOpenFiles.map((fileId) => { const fileData = openedFiles?.find((i) => i.id === fileId); const filename = fileData?.filename || "..."; return { title: filename, icon: <FileIcon file={{ isDirectory: false, filename }} />, render: () => <FileViewer id={fileId} />, }; }) ); // show console tab on mobile if (breakpoint < 2) { tabs.push({ title: "Console", icon: <FiTerminal />, render: () => <ConsoleLogger />, locked: true, }); } // tabs.push({ // title: "API", // icon: <FiServer />, // render: () => <APIManager />, // locked: true, // }); return tabs; }, [curOpenFiles, openedFiles, breakpoint]); const currentTab = Math.min(Math.max(curTabIdx, 0), tabs.length - 1); return ( <EditorContext.Provider value={{ onOpenFile, onFileChanged, onDeleteFile, }} > <div className="h-full relative flex flex-col"> <ResizablePanelGroup autoSaveId={!isEmbed ? "veditor-panel" : null} direction="horizontal" className="flex-1 order-2 md:order-1" > <Sidebar defaultSize={{ md: 50, lg: 25 }} defaultCollapsed={{ md: true, lg: false }} minSize={10} collapsible collapsedSize={0} /> <ResizableHandle className="w-0" /> <ResizablePanel defaultSize={{ md: 100, lg: 75 }}> <ResizablePanelGroup autoSaveId={!isEmbed ? "code-editor" : null} direction="vertical" > <ResizablePanel defaultSize={{ sm: 100, md: 80 }} minSize={20}> <div className="w-full h-full flex flex-col items-stretch bg-slate-800"> <div className="flex items-center"> <Tabs tabs={tabs} current={currentTab} onChange={setCurTabIdx} onClose={onCloseFile} className="flex-1 p-1 md:h-12 md:p-1.5 md:gap-1.5" /> <Button variant="ghost" className="dark:hover:bg-slate-700" onClick={() => window.open(`${BASE_URL}/${project.slug}`, "_blank") } > <FaExternalLinkAlt /> </Button> </div> <TabView tabs={tabs} current={currentTab} className="flex-1" /> </div> </ResizablePanel> {breakpoint >= 2 ? ( <> <ResizableHandle className="!h-0" /> <ResizablePanel defaultSize={{ sm: 0, md: 20 }} collapsible collapsedSize={0} minSize={10} > <ConsoleLogger /> </ResizablePanel> </> ) : null} </ResizablePanelGroup> </ResizablePanel> </ResizablePanelGroup> <StatusBar className="order-1 md:order-2" /> </div> <SettingsDialog /> </EditorContext.Provider> ); }; export default Editor;