feat: rebuild app using vike

This commit is contained in:
2024-02-22 12:15:04 +00:00
parent 87ef4ca1e3
commit 9777ae1676
104 changed files with 2214 additions and 2976 deletions
+17
View File
@@ -0,0 +1,17 @@
import { useData } from "~/renderer/hooks";
import { Data } from "./+data";
import Link from "~/renderer/link";
const ViewPostPage = () => {
const { post } = useData<Data>();
return (
<div>
<Link href="/">Go Back</Link>
<h1>{post.title}</h1>
<p>{post.body}</p>
</div>
);
};
export default ViewPostPage;
+12
View File
@@ -0,0 +1,12 @@
import { PageContext } from "vike/types";
export const data = async (ctx: PageContext) => {
const id = ctx.routeParams?.id;
const post = await fetch(
"https://jsonplaceholder.typicode.com/posts/" + id
).then((response) => response.json());
return { post, title: post?.title };
};
export type Data = Awaited<ReturnType<typeof data>>;
+20
View File
@@ -0,0 +1,20 @@
import { usePageContext } from "~/renderer/context";
const Page = () => {
const pageContext = usePageContext();
let { abortReason } = pageContext;
if (!abortReason) {
abortReason = pageContext.is404
? "Page not found."
: "Something went wrong.";
}
return (
<div>
<p style={{ fontSize: "1.3em" }}>{abortReason}</p>
</div>
);
};
export default Page;
+24
View File
@@ -0,0 +1,24 @@
import type { Data } from "./+data";
import { useData } from "~/renderer/hooks";
import Link from "~/renderer/link";
const HomePage = () => {
const { posts } = useData<Data>();
if (!posts?.length) {
return <p>No posts.</p>;
}
return (
<div>
<h1>Posts</h1>
{posts.map((post: any) => (
<Link key={post.id} href={`/${post.id}`}>
{post.title}
</Link>
))}
</div>
);
};
export default HomePage;
+9
View File
@@ -0,0 +1,9 @@
export const data = async () => {
const posts = await fetch(
"https://jsonplaceholder.typicode.com/posts?_limit=20"
).then((response) => response.json());
return { posts };
};
export type Data = Awaited<ReturnType<typeof data>>;
+60
View File
@@ -0,0 +1,60 @@
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/ui/resizable";
import WebPreview from "./components/web-preview";
import { usePortrait } from "~/hooks/usePortrait";
import Editor from "./components/editor";
import ProjectContext from "./context/project";
import { cn } from "~/lib/utils";
import { withClientOnly } from "~/renderer/client-only";
import { useParams, useSearchParams } from "~/renderer/hooks";
import { BASE_URL } from "~/lib/consts";
const ViewProjectPage = () => {
const isPortrait = usePortrait();
const searchParams = useSearchParams();
const params = useParams();
const isCompact =
searchParams.get("compact") === "1" || searchParams.get("embed") === "1";
const slug = params["slug"];
const previewUrl = BASE_URL + `/api/preview/${slug}/index.html`;
return (
<ProjectContext.Provider value={{ slug, isCompact }}>
<ResizablePanelGroup
autoSaveId="main-panel"
direction={isPortrait ? "vertical" : "horizontal"}
className={cn("w-full !h-dvh bg-slate-600", !isCompact ? "md:p-4" : "")}
>
<ResizablePanel
defaultSize={isPortrait ? 50 : 60}
collapsible
collapsedSize={0}
minSize={isPortrait ? 10 : 30}
>
<Editor />
</ResizablePanel>
<ResizableHandle
withHandle
className={
!isCompact
? "bg-slate-800 md:bg-transparent hover:bg-slate-500 transition-colors md:mx-1 w-2 md:data-[panel-group-direction=vertical]:h-2 rounded-lg"
: "bg-slate-800"
}
/>
<ResizablePanel
defaultSize={isPortrait ? 50 : 40}
collapsible
collapsedSize={0}
minSize={10}
>
<WebPreview url={previewUrl} />
</ResizablePanel>
</ResizablePanelGroup>
</ProjectContext.Provider>
);
};
export default withClientOnly(ViewProjectPage);
@@ -0,0 +1,42 @@
import { useEffect, useState } from "react";
import { Console, Decode } from "console-feed";
import type { Message } from "console-feed/lib/definitions/Console";
import ErrorBoundary from "~/components/containers/error-boundary";
const ConsoleLogger = () => {
const [logs, setLogs] = useState<any[]>([]);
useEffect(() => {
const onMessage = (event: MessageEvent<any>) => {
const { data: eventData } = event;
if (!eventData || eventData.type !== "console") {
return;
}
const data = Decode(eventData.data);
if (!data || !data.method || !data.data) {
return;
}
setLogs((i) => [data, ...i]);
};
window.addEventListener("message", onMessage);
return () => {
window.removeEventListener("message", onMessage);
};
}, []);
return (
<div className="h-full flex flex-col bg-[#242424] border-t border-t-gray-600">
<p className="py-2 px-3 uppercase text-xs">Console</p>
<ErrorBoundary>
<div className="overflow-y-auto flex-1">
<Console logs={logs} variant="dark" />
</div>
</ErrorBoundary>
</div>
);
};
export default ConsoleLogger;
@@ -0,0 +1,99 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "../../../../components/ui/dialog";
import { UseDiscloseReturn } from "~/hooks/useDisclose";
import { Input } from "../../../../components/ui/input";
import { Button } from "../../../../components/ui/button";
import { useForm } from "~/hooks/useForm";
import { z } from "zod";
import FormErrorMessage from "../../../../components/ui/form-error-message";
import trpc from "~/lib/trpc";
import type { FileSchema } from "~/server/db/schema/file";
import { useWatch } from "react-hook-form";
type Props = {
disclose: UseDiscloseReturn<CreateFileSchema>;
onSuccess?: (file: FileSchema, type: "create" | "update") => void;
};
const fileSchema = z.object({
id: z.number().optional(),
parentId: z.number().optional().nullable(),
filename: z.string().min(1),
isDirectory: z.boolean().optional(),
});
export type CreateFileSchema = z.infer<typeof fileSchema>;
const defaultValues: z.infer<typeof fileSchema> = {
filename: "",
};
const CreateFileDialog = ({ disclose, onSuccess }: Props) => {
const form = useForm(fileSchema, disclose.data || defaultValues);
const isDir = useWatch({ name: "isDirectory", control: form.control });
const create = trpc.file.create.useMutation({
onSuccess(data) {
if (onSuccess) onSuccess(data, "create");
disclose.onClose();
},
});
const update = trpc.file.update.useMutation({
onSuccess(data) {
if (onSuccess) onSuccess(data, "update");
disclose.onClose();
},
});
const onSubmit = form.handleSubmit((values) => {
if (update.isPending || create.isPending) {
return;
}
if (values.id) {
update.mutate({ id: values.id!, ...values });
} else {
create.mutate(values);
}
});
return (
<Dialog open={disclose.isOpen} onOpenChange={disclose.onChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{[
!disclose.data?.id ? "Create" : "Rename",
isDir ? "Directory" : "File",
].join(" ")}
</DialogTitle>
</DialogHeader>
<form onSubmit={onSubmit}>
<FormErrorMessage form={form} />
<Input
placeholder={isDir ? "Directory Name" : "Filename"}
autoFocus
{...form.register("filename")}
/>
<div className="flex justify-end gap-4 mt-4">
<Button size="sm" variant="ghost" onClick={disclose.onClose}>
Cancel
</Button>
<Button type="submit" size="sm">
Submit
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};
export default CreateFileDialog;
+229
View File
@@ -0,0 +1,229 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "~/components/ui/resizable";
import Tabs, { Tab } 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 { usePortrait } from "~/hooks/usePortrait";
import Panel from "~/components/ui/panel";
import { previewStore } from "../stores/web-preview";
import { useProjectContext } from "../context/project";
import { ImperativePanelHandle } from "react-resizable-panels";
import Sidebar from "./sidebar";
import useCommandKey from "~/hooks/useCommandKey";
import { Button } from "~/components/ui/button";
import { FaCompress, FaCompressArrowsAlt } from "react-icons/fa";
import ConsoleLogger from "./console-logger";
const Editor = () => {
const isPortrait = usePortrait();
const trpcUtils = trpc.useUtils();
const [isMounted, setMounted] = useState(false);
const project = useProjectContext();
const sidebarPanel = useRef<ImperativePanelHandle>(null);
const [sidebarExpanded, setSidebarExpanded] = useState(false);
const [curTabIdx, setCurTabIdx] = useState(0);
const [curOpenFiles, setOpenFiles] = useState<number[]>([]);
const pinnedFiles = trpc.file.getAll.useQuery(
{ isPinned: true },
{ enabled: !isMounted }
);
const openedFilesData = trpc.file.getAll.useQuery(
{ id: curOpenFiles },
{ enabled: curOpenFiles.length > 0 }
);
const [openedFiles, setOpenedFiles] = useState<any[]>([]);
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(() => {
setMounted(true);
}, []);
const toggleSidebar = useCallback(() => {
const sidebar = sidebarPanel.current;
if (!sidebar) {
return;
}
if (sidebar.isExpanded()) {
sidebar.collapse();
} else {
sidebar.expand();
sidebar.resize(25);
}
}, [sidebarPanel]);
useCommandKey("b", toggleSidebar);
useEffect(() => {
if (!pinnedFiles.data?.length || curOpenFiles.length > 0) {
return;
}
pinnedFiles.data.forEach((file) => {
onOpenFile(file.id, false);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pinnedFiles.data]);
useEffect(() => {
if (openedFilesData.data) {
setOpenedFiles(openedFilesData.data);
}
}, [openedFilesData.data]);
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 refreshPreview = useCallback(() => {
previewStore.getState().refresh();
}, []);
const openFileList = useMemo(() => {
return curOpenFiles.map((fileId) => {
const fileData = openedFiles?.find((i) => i.id === fileId);
return {
title: fileData?.filename || "...",
render: () => (
<FileViewer id={fileId} onFileContentChange={refreshPreview} />
),
};
}) satisfies Tab[];
}, [curOpenFiles, openedFiles, refreshPreview]);
const PanelComponent = !project.isCompact ? Panel : "div";
return (
<EditorContext.Provider
value={{
onOpenFile,
onFileChanged,
onDeleteFile,
}}
>
<PanelComponent className="h-full relative">
<ResizablePanelGroup autoSaveId="veditor-panel" direction="horizontal">
<ResizablePanel
ref={sidebarPanel}
defaultSize={isPortrait ? 0 : 25}
minSize={10}
collapsible
collapsedSize={0}
className="bg-[#1e2536]"
onExpand={() => setSidebarExpanded(true)}
onCollapse={() => setSidebarExpanded(false)}
>
<Sidebar />
</ResizablePanel>
<ResizableHandle className="bg-slate-900" />
<ResizablePanel defaultSize={isPortrait ? 100 : 75}>
<ResizablePanelGroup
autoCapitalize="code-editor"
direction="vertical"
>
<ResizablePanel defaultSize={isPortrait ? 100 : 80} minSize={20}>
<Tabs
tabs={openFileList}
current={curTabIdx}
onChange={setCurTabIdx}
onClose={onCloseFile}
/>
</ResizablePanel>
{!isPortrait ? (
<>
<ResizableHandle />
<ResizablePanel
defaultSize={20}
collapsible
collapsedSize={0}
minSize={10}
>
<ConsoleLogger />
</ResizablePanel>
</>
) : null}
</ResizablePanelGroup>
</ResizablePanel>
</ResizablePanelGroup>
<Button
variant="ghost"
className="absolute bottom-0 left-0 w-12 h-12 rounded-none flex items-center justify-center"
onClick={toggleSidebar}
>
{sidebarExpanded ? <FaCompressArrowsAlt /> : <FaCompress />}
</Button>
</PanelComponent>
</EditorContext.Provider>
);
};
export default Editor;
@@ -0,0 +1,239 @@
"use client";
import React, { Fragment, useMemo, useState } from "react";
import { UseDiscloseReturn, useDisclose } from "~/hooks/useDisclose";
import {
FiChevronRight,
FiFilePlus,
FiFolderPlus,
FiMoreVertical,
} from "react-icons/fi";
import { FaCheck, FaThumbtack } from "react-icons/fa";
import trpc from "~/lib/trpc";
import type { FileSchema } from "~/server/db/schema/file";
import CreateFileDialog, { CreateFileSchema } from "./createfile-dialog";
import ActionButton from "../../../../components/ui/action-button";
import { useEditorContext } from "../context/editor";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "~/components/ui/dropdown-menu";
import { cn, getUrl } from "~/lib/utils";
import FileIcon from "~/components/ui/file-icon";
import copy from "copy-to-clipboard";
import { useParams } from "~/renderer/hooks";
const FileListing = () => {
const { onOpenFile, onFileChanged } = useEditorContext();
const createFileDlg = useDisclose<CreateFileSchema>();
const files = trpc.file.getAll.useQuery();
const fileList = useMemo(() => groupFiles(files.data, null), [files.data]);
return (
<Fragment>
<div className="h-10 flex items-center pl-4 pr-1">
<p className="text-xs uppercase truncate flex-1">My Project</p>
<ActionButton
icon={FiFilePlus}
onClick={() => createFileDlg.onOpen()}
/>
<ActionButton
icon={FiFolderPlus}
onClick={() =>
createFileDlg.onOpen({ isDirectory: true, filename: "" })
}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ActionButton icon={FiMoreVertical} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem>Upload File</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>Project Settings</DropdownMenuItem>
<DropdownMenuItem>Download Project</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="flex flex-col items-stretch flex-1 overflow-y-auto">
{fileList.map((file) => (
<FileItem key={file.id} file={file} createFileDlg={createFileDlg} />
))}
</div>
<CreateFileDialog
disclose={createFileDlg}
onSuccess={(file, type) => {
files.refetch();
if (type === "create" && !file.isDirectory && !file.isFile) {
onOpenFile && onOpenFile(file.id);
}
if (onFileChanged) {
onFileChanged(file);
}
}}
/>
</Fragment>
);
};
type TFile = Omit<FileSchema, "content"> & { children: TFile[] };
type FileItemProps = {
file: TFile;
createFileDlg: UseDiscloseReturn<CreateFileSchema>;
};
const FileItem = ({ file, createFileDlg }: FileItemProps) => {
const { slug } = useParams();
const { onOpenFile, onDeleteFile } = useEditorContext();
const [isCollapsed, setCollapsed] = useState(false);
const trpcUtils = trpc.useUtils();
const updateFile = trpc.file.update.useMutation({
onSuccess() {
trpcUtils.file.getAll.invalidate();
},
});
return (
<div className="w-full">
<div className="group text-slate-400 hover:text-white hover:bg-slate-700 transition-colors text-sm flex items-stretch relative w-full h-10">
<button
className="flex items-center pl-5 pr-3 gap-1 w-full text-left"
onClick={() => {
if (file.isDirectory) {
setCollapsed((i) => !i);
} else {
onOpenFile(file.id);
}
}}
>
{file.isDirectory ? (
<FiChevronRight
className={cn(
"absolute left-1 top-3 transition-transform",
isCollapsed ? "rotate-90" : ""
)}
/>
) : null}
<FileIcon file={file} />
<span className="flex-1 truncate">{file.filename}</span>
{file.isPinned ? <FaThumbtack /> : null}
</button>
<div className="flex items-center justify-end opacity-0 group-hover:opacity-100 transition-opacity absolute top-0 right-0 pr-1 h-full bg-slate-700">
{file.isDirectory ? (
<>
<ActionButton
icon={FiFilePlus}
onClick={() =>
createFileDlg.onOpen({ parentId: file.id, filename: "" })
}
/>
<ActionButton
icon={FiFolderPlus}
onClick={() =>
createFileDlg.onOpen({
parentId: file.id,
isDirectory: true,
filename: "",
})
}
/>
</>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ActionButton icon={FiMoreVertical} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => createFileDlg.onOpen(file)}>
Rename
</DropdownMenuItem>
{/* <DropdownMenuItem>Duplicate</DropdownMenuItem> */}
<DropdownMenuItem onClick={() => onDeleteFile(file.id)}>
Delete
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => copy(file.filename)}>
Copy Name
</DropdownMenuItem>
<DropdownMenuItem onClick={() => copy(file.path)}>
Copy Path
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => copy(getUrl(`api/preview/${slug}`, file.path))}
>
Copy URL
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
window.open(
getUrl(`api/preview/${slug}`, file.path),
"_blank"
)
}
>
Open in new tab
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
return updateFile.mutate({
id: file.id,
isPinned: !file.isPinned,
});
}}
>
Pinned
{file.isPinned ? <FaCheck className="ml-auto" /> : null}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{isCollapsed && file.children?.length > 0 ? (
<div className="flex flex-col items-stretch pl-4">
{file.children.map((file) => (
<FileItem key={file.id} file={file} createFileDlg={createFileDlg} />
))}
</div>
) : null}
</div>
);
};
function groupFiles(files?: any[] | null, parentId?: number | null) {
if (!files) {
return [];
}
const groupedFiles: TFile[] = [];
files.forEach((file) => {
if (file.parentId !== parentId) {
return;
}
groupedFiles.push(file);
if (file.isDirectory) {
file.children = groupFiles(files, file.id);
}
});
return groupedFiles;
}
export default FileListing;
@@ -0,0 +1,47 @@
"use client";
import { getFileExt } from "~/lib/utils";
import React from "react";
import CodeEditor from "../../../../components/ui/code-editor";
import trpc from "~/lib/trpc";
type Props = {
id: number;
onFileContentChange?: () => void;
};
const FileViewer = ({ id, onFileContentChange }: Props) => {
const { data, isLoading, refetch } = trpc.file.getById.useQuery(id);
const updateFileContent = trpc.file.update.useMutation({
onSuccess: () => {
if (onFileContentChange) onFileContentChange();
refetch();
},
});
if (isLoading) {
return <p>Loading...</p>;
}
if (!data || data.isDirectory) {
return <p>File not found.</p>;
}
const { filename } = data;
if (!data.isFile) {
const ext = getFileExt(filename);
return (
<CodeEditor
lang={ext}
value={data?.content || ""}
formatOnSave
onChange={(val) => updateFileContent.mutate({ id, content: val })}
/>
);
}
return null;
};
export default FileViewer;
@@ -0,0 +1,23 @@
import React from "react";
import FileListing from "./file-listing";
import { FaUserCircle } from "react-icons/fa";
import { Button } from "~/components/ui/button";
const Sidebar = () => {
return (
<aside className="flex flex-col items-stretch h-full">
<FileListing />
<div className="h-12 bg-[#1a1b26] pl-12">
<Button
variant="ghost"
className="h-12 w-full truncate flex justify-start text-left uppercase text-xs rounded-none"
>
<FaUserCircle className="mr-2 text-xl" />
<span className="truncate">Log in</span>
</Button>
</div>
</aside>
);
};
export default Sidebar;
@@ -0,0 +1,67 @@
/* eslint-disable react/display-name */
import Panel from "~/components/ui/panel";
import { useCallback, useEffect, useRef } from "react";
import { useProjectContext } from "../context/project";
import { Button } from "~/components/ui/button";
import { FaEllipsisV, FaRedo } from "react-icons/fa";
import { Input } from "~/components/ui/input";
import { previewStore } from "../stores/web-preview";
type WebPreviewProps = {
url?: string | null;
};
const WebPreview = ({ url }: WebPreviewProps) => {
const frameRef = useRef<HTMLIFrameElement>(null);
const project = useProjectContext();
const refresh = useCallback(() => {
if (frameRef.current) {
frameRef.current.src = `${url}?t=${Date.now()}`;
}
}, [url]);
useEffect(() => {
previewStore.setState({ refresh });
refresh();
}, [refresh]);
const PanelComponent = !project.isCompact ? Panel : "div";
return (
<PanelComponent className="h-full flex flex-col bg-slate-800">
<div className="h-10 flex items-center">
<Button
variant="ghost"
className="dark:hover:bg-slate-700"
onClick={refresh}
>
<FaRedo />
</Button>
<Input
className="flex-1 dark:bg-gray-900 dark:hover:bg-gray-950 h-8 rounded-full"
value={url || ""}
readOnly
/>
<Button
variant="ghost"
className="dark:hover:bg-slate-700"
onClick={() => {}}
>
<FaEllipsisV />
</Button>
</div>
{url != null ? (
<iframe
id="web-preview"
ref={frameRef}
className="border-none w-full flex-1 overflow-hidden bg-white"
sandbox="allow-scripts"
/>
) : null}
</PanelComponent>
);
};
export default WebPreview;
+21
View File
@@ -0,0 +1,21 @@
import type { FileSchema } from "~/server/db/schema/file";
import { createContext, useContext } from "react";
type TEditorContext = {
onOpenFile: (fileId: number) => void;
onFileChanged: (file: Omit<FileSchema, "content">) => void;
onDeleteFile: (fileId: number) => void;
};
const EditorContext = createContext<TEditorContext | null>(null);
export const useEditorContext = () => {
const ctx = useContext(EditorContext);
if (!ctx) {
throw new Error("Component not in EditorContext!");
}
return ctx;
};
export default EditorContext;
+19
View File
@@ -0,0 +1,19 @@
import { createContext, useContext } from "react";
type TProjectContext = {
slug: string;
isCompact?: boolean;
};
const ProjectContext = createContext<TProjectContext | null>(null);
export const useProjectContext = () => {
const ctx = useContext(ProjectContext);
if (!ctx) {
throw new Error("Component not in ProjectContext!");
}
return ctx;
};
export default ProjectContext;
@@ -0,0 +1,9 @@
import { createStore } from "zustand";
type PreviewStore = {
refresh: () => void;
};
export const previewStore = createStore<PreviewStore>(() => ({
refresh: () => {},
}));