feat: layout fix

This commit is contained in:
Khairul Hidayat 2024-02-23 23:57:14 +00:00
parent 735fa9354b
commit 8bb3d2bd84
14 changed files with 325 additions and 157 deletions

View File

@ -12,9 +12,10 @@ import {
} from "../ui/dropdown-menu";
import { FaChevronDown } from "react-icons/fa";
import trpc from "~/lib/trpc";
import { usePageContext } from "~/renderer/context";
const Navbar = () => {
const { user } = useAuth();
const {user, urlPathname } = usePageContext();
const logout = trpc.auth.logout.useMutation({
onSuccess() {
window.location.reload();
@ -51,7 +52,7 @@ const Navbar = () => {
</DropdownMenuContent>
</DropdownMenu>
) : (
<Button>Log in</Button>
<Button href={"/auth/login?return=" + urlPathname}>Log in</Button>
)}
</div>
</div>

View File

@ -3,22 +3,37 @@ import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import React, { forwardRef } from "react";
import { IconType } from "react-icons/lib";
import { VariantProps, cva } from "class-variance-authority";
type Props = React.ComponentProps<typeof Button> & {
const variants = cva(
"text-slate-400 hover:bg-transparent hover:dark:bg-transparent p-0 flex-shrink-0",
{
variants: {
size: {
sm: "h-8 w-6",
md: "h-8 w-8",
lg: "h-10 w-10",
},
},
defaultVariants: {
size: "sm",
},
}
);
type Props = Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof variants> & {
icon: IconType;
};
};
const ActionButton = forwardRef(
({ icon: Icon, className, onClick, ...props }: Props, ref: any) => {
({ icon: Icon, className, size, onClick, ...props }: Props, ref: any) => {
return (
<Button
ref={ref}
variant="ghost"
size="sm"
className={cn(
"text-slate-400 hover:bg-transparent hover:dark:bg-transparent h-8 w-6 p-0 flex-shrink-0",
className
)}
className={cn(variants({ size }), className)}
onClick={(e) => {
if (onClick) {
e.preventDefault();

View File

@ -41,16 +41,18 @@ type ResizablePanelProps = Omit<
"defaultSize"
> & {
defaultSize: number | BreakpointValues<number>;
defaultCollapsed?: boolean | BreakpointValues<boolean>;
};
const ResizablePanel = forwardRef((props: ResizablePanelProps, ref: any) => {
const { defaultSize, ...restProps } = props;
const { defaultSize, defaultCollapsed, ...restProps } = props;
const initialSize = useBreakpointValue(defaultSize);
const initialCollapsed = useBreakpointValue(defaultCollapsed);
return (
<ResizablePrimitive.Panel
ref={ref}
defaultSize={initialSize}
defaultSize={initialCollapsed ? 0 : initialSize}
{...restProps}
/>
);

View File

@ -8,6 +8,7 @@ export type Tab = {
title: string;
icon?: React.ReactNode;
render?: () => React.ReactNode;
locked?: boolean;
};
type Props = {
@ -76,7 +77,7 @@ const Tabs = ({ tabs, current = 0, onChange, onClose }: Props) => {
icon={tab.icon}
isActive={idx === current}
onSelect={() => onChange && onChange(idx)}
onClose={() => onClose && onClose(idx)}
onClose={!tab.locked ? () => onClose && onClose(idx) : null}
/>
))}
</nav>
@ -93,12 +94,13 @@ type TabItemProps = {
icon?: React.ReactNode;
isActive?: boolean;
onSelect: () => void;
onClose: () => void;
onClose?: (() => void) | null;
};
const TabItem = ({
index,
title,
icon,
isActive,
onSelect,
onClose,
@ -116,19 +118,28 @@ const TabItem = ({
)}
onClick={onSelect}
>
<button className="pl-4 pr-0 truncate flex items-center self-stretch">
<FileIcon
file={{ isDirectory: false, filename: title }}
className="mr-1"
/>
<span className="truncate">{filename}</span>
<button
className={cn(
"pl-4 pr-4 truncate flex items-center self-stretch",
onClose ? "pr-0" : ""
)}
>
{icon != null ? (
icon
) : (
<FileIcon file={{ isDirectory: false, filename: title }} />
)}
<span className="inline-block ml-2 truncate">{filename}</span>
<span>{ext}</span>
</button>
{onClose ? (
<ActionButton
icon={FiX}
className="opacity-0 group-hover:opacity-100 transition-colors"
onClick={onClose}
onClick={() => onClose()}
/>
) : null}
</div>
);
};

34
hooks/useConsoleLogger.ts Normal file
View File

@ -0,0 +1,34 @@
import { createStore, useStore } from "zustand";
import { Decode } from "console-feed";
import { useEffect } from "react";
type Store = {
logs: any[];
};
const store = createStore<Store>(() => ({ logs: [] }));
export const useConsoleLogger = () => {
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;
}
store.setState((i) => ({ logs: [data, ...i.logs] }));
};
window.addEventListener("message", onMessage);
return () => {
window.removeEventListener("message", onMessage);
};
}, []);
};
export const useConsoleLogs = () => useStore(store, (i) => i.logs);

View File

@ -38,18 +38,17 @@ const ViewProjectPage = () => {
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 md:bg-transparent hover:bg-slate-500 transition-colors md:mx-1 w-2 md:data-[panel-group-direction=vertical]:h-2 md:rounded-lg"
: "bg-slate-800"
}
/>
<ResizablePanel
<WebPreview
defaultSize={40}
collapsible
collapsedSize={0}
minSize={10}
>
<WebPreview url={previewUrl} />
</ResizablePanel>
url={previewUrl}
/>
</ResizablePanelGroup>
</ProjectContext.Provider>
);

View File

@ -1,31 +1,9 @@
import { useEffect, useState } from "react";
import { Console, Decode } from "console-feed";
import type { Message } from "console-feed/lib/definitions/Console";
import { Console } from "console-feed";
import ErrorBoundary from "~/components/containers/error-boundary";
import { useConsoleLogs } from "~/hooks/useConsoleLogger";
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);
};
}, []);
const logs = useConsoleLogs();
return (
<div className="h-full flex flex-col bg-[#242424] border-t border-t-gray-600">

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ResizableHandle,
ResizablePanel,
@ -10,26 +10,21 @@ import trpc from "~/lib/trpc";
import EditorContext from "../context/editor";
import type { FileSchema } from "~/server/db/schema/file";
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";
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";
const Editor = () => {
const { project, pinnedFiles } = useData<Data>();
const trpcUtils = trpc.useUtils();
const projectCtx = useProjectContext();
const sidebarPanel = useRef<ImperativePanelHandle>(null);
const [breakpoint] = useBreakpoint();
const [sidebarExpanded, setSidebarExpanded] = useState(false);
const [curTabIdx, setCurTabIdx] = useState(0);
const [curOpenFiles, setOpenFiles] = useState<number[]>(
pinnedFiles.map((i) => i.id)
@ -53,22 +48,6 @@ const Editor = () => {
},
});
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?.length || curOpenFiles.length > 0) {
return;
@ -139,22 +118,33 @@ const Editor = () => {
[openedFilesData]
);
const refreshPreview = useCallback(() => {
previewStore.getState().refresh();
}, []);
const tabs = useMemo(() => {
let tabs: Tab[] = [];
const openFileList = useMemo(() => {
return curOpenFiles.map((fileId) => {
// opened files
tabs = tabs.concat(
curOpenFiles.map((fileId) => {
const fileData = openedFiles?.find((i) => i.id === fileId);
return {
title: fileData?.filename || "...",
render: () => (
<FileViewer id={fileId} onFileContentChange={refreshPreview} />
),
render: () => <FileViewer id={fileId} />,
};
}) satisfies Tab[];
}, [curOpenFiles, openedFiles, refreshPreview]);
})
);
// show console tab on mobile
if (breakpoint < 2) {
tabs.push({
title: "Console",
icon: <FiTerminal />,
render: () => <ConsoleLogger />,
locked: true,
});
}
return tabs;
}, [curOpenFiles, openedFiles, breakpoint]);
const PanelComponent = !projectCtx.isCompact ? Panel : "div";
@ -166,20 +156,19 @@ const Editor = () => {
onDeleteFile,
}}
>
<PanelComponent className="h-full relative">
<ResizablePanelGroup autoSaveId="veditor-panel" direction="horizontal">
<ResizablePanel
ref={sidebarPanel}
defaultSize={{ sm: 0, md: 25 }}
<PanelComponent className="h-full relative flex flex-col">
<ResizablePanelGroup
autoSaveId="veditor-panel"
direction="horizontal"
className="flex-1 order-2 md:order-1"
>
<Sidebar
defaultSize={{ sm: 50, md: 25 }}
defaultCollapsed={{ sm: true, md: false }}
minSize={10}
collapsible
collapsedSize={0}
className="bg-[#1e2536]"
onExpand={() => setSidebarExpanded(true)}
onCollapse={() => setSidebarExpanded(false)}
>
<Sidebar />
</ResizablePanel>
/>
<ResizableHandle className="bg-slate-900" />
@ -187,8 +176,8 @@ const Editor = () => {
<ResizablePanelGroup autoSaveId="code-editor" direction="vertical">
<ResizablePanel defaultSize={{ sm: 100, md: 80 }} minSize={20}>
<Tabs
tabs={openFileList}
current={curTabIdx}
tabs={tabs}
current={Math.min(Math.max(curTabIdx, 0), tabs.length - 1)}
onChange={setCurTabIdx}
onClose={onCloseFile}
/>
@ -212,13 +201,7 @@ const Editor = () => {
</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>
<StatusBar className="order-1 md:order-2" />
</PanelComponent>
</EditorContext.Provider>
);

View File

@ -4,22 +4,28 @@ import trpc from "~/lib/trpc";
import { useData } from "~/renderer/hooks";
import { Data } from "../+data";
import Spinner from "~/components/ui/spinner";
import { previewStore } from "../stores/web-preview";
type Props = {
id: number;
onFileContentChange?: () => void;
};
const FileViewer = ({ id, onFileContentChange }: Props) => {
const FileViewer = ({ id }: Props) => {
const { pinnedFiles } = useData<Data>();
const initialData = pinnedFiles.find((i) => i.id === id);
const { data, isLoading, refetch } = trpc.file.getById.useQuery(id, {
initialData,
});
const onFileContentChange = () => {
// refresh preview
previewStore.getState().refresh();
};
const updateFileContent = trpc.file.update.useMutation({
onSuccess: () => {
if (onFileContentChange) onFileContentChange();
onFileContentChange();
refetch();
},
});

View File

@ -1,22 +1,53 @@
import { ComponentProps, useCallback, useEffect, useRef } from "react";
import FileListing from "./file-listing";
import { FaUserCircle } from "react-icons/fa";
import { Button } from "~/components/ui/button";
import { ImperativePanelHandle } from "react-resizable-panels";
import useCommandKey from "~/hooks/useCommandKey";
import { sidebarStore } from "../stores/sidebar";
import { ResizablePanel } from "~/components/ui/resizable";
import { useBreakpointValue } from "~/hooks/useBreakpointValue";
type SidebarProps = ComponentProps<typeof ResizablePanel>;
const Sidebar = (props: SidebarProps) => {
const sidebarPanel = useRef<ImperativePanelHandle>(null);
const defaultSize = useBreakpointValue(props.defaultSize);
const toggleSidebar = useCallback(
(toggle?: boolean) => {
const sidebar = sidebarPanel.current;
if (!sidebar) {
return;
}
const expand = toggle != null ? toggle : !sidebar.isCollapsed();
if (expand) {
sidebar.collapse();
} else {
sidebar.expand();
sidebar.resize(defaultSize);
}
},
[sidebarPanel, defaultSize]
);
useCommandKey("b", toggleSidebar);
useEffect(() => {
sidebarStore.setState({ toggle: toggleSidebar });
}, [toggleSidebar]);
const Sidebar = () => {
return (
<ResizablePanel
ref={sidebarPanel}
className="bg-[#1e2536]"
onExpand={() => sidebarStore.setState({ expanded: true })}
onCollapse={() => sidebarStore.setState({ expanded: false })}
{...props}
>
<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>
</ResizablePanel>
);
};

View File

@ -0,0 +1,55 @@
import React from "react";
import { FiSidebar, FiSmartphone, FiUser } from "react-icons/fi";
import { useStore } from "zustand";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import { usePageContext } from "~/renderer/context";
import { sidebarStore } from "../stores/sidebar";
import ActionButton from "~/components/ui/action-button";
import { previewStore } from "../stores/web-preview";
import { useProjectContext } from "../context/project";
const StatusBar = ({ className }: React.ComponentProps<"div">) => {
const { user, urlPathname } = usePageContext();
const { isCompact } = useProjectContext();
const sidebarExpanded = useStore(sidebarStore, (i) => i.expanded);
const previewExpanded = useStore(previewStore, (i) => i.open);
if (isCompact) {
return null;
}
return (
<div
className={cn(
"h-10 flex items-center gap-1 pl-2 pr-3 w-full bg-slate-900 md:bg-[#242424] border-b md:border-b-0 md:border-t border-slate-900 md:border-black/30",
className
)}
>
<ActionButton
title="Toggle Sidebar"
icon={FiSidebar}
className={sidebarExpanded ? "text-white" : ""}
onClick={() => sidebarStore.getState().toggle()}
/>
<ActionButton
title="Toggle Preview Window"
icon={FiSmartphone}
className={previewExpanded ? "text-white" : ""}
onClick={() => previewStore.getState().toggle()}
/>
<div className="flex-1"></div>
<Button
href={user ? "/user" : "/auth/login?return=" + urlPathname}
className="h-full p-0 gap-2 text-xs"
variant="link"
>
<FiUser className="text-sm" />
{user?.name || "Log in"}
</Button>
</div>
);
};
export default StatusBar;

View File

@ -1,36 +1,73 @@
/* eslint-disable react/display-name */
import Panel from "~/components/ui/panel";
import { useCallback, useEffect, useRef } from "react";
import { ComponentProps, useCallback, useEffect, useRef } from "react";
import { useProjectContext } from "../context/project";
import { Button } from "~/components/ui/button";
import { FaRedo } from "react-icons/fa";
import { previewStore } from "../stores/web-preview";
import { ImperativePanelHandle } from "react-resizable-panels";
import useCommandKey from "~/hooks/useCommandKey";
import { ResizablePanel } from "~/components/ui/resizable";
import { useConsoleLogger } from "~/hooks/useConsoleLogger";
type WebPreviewProps = {
type WebPreviewProps = ComponentProps<typeof ResizablePanel> & {
url?: string | null;
};
const WebPreview = ({ url }: WebPreviewProps) => {
const WebPreview = ({ url, ...props }: WebPreviewProps) => {
const frameRef = useRef<HTMLIFrameElement>(null);
const panelRef = useRef<ImperativePanelHandle>(null);
const project = useProjectContext();
// hook into the console
useConsoleLogger();
const refresh = useCallback(() => {
if (frameRef.current) {
frameRef.current.src = `${url}?t=${Date.now()}`;
}
}, [url]);
const togglePanel = useCallback(
(toggle?: boolean) => {
const panel = panelRef.current;
if (!panel) {
return;
}
const expand = toggle != null ? toggle : !panel.isCollapsed();
if (expand) {
panel.collapse();
} else {
panel.expand();
panel.resize(
typeof props.defaultSize === "number" ? props.defaultSize : 25
);
}
},
[panelRef, props.defaultSize]
);
useCommandKey("p", togglePanel);
useEffect(() => {
previewStore.setState({ refresh });
previewStore.setState({ refresh, toggle: togglePanel });
refresh();
}, [refresh]);
}, [refresh, togglePanel]);
const PanelComponent = !project.isCompact ? Panel : "div";
return (
<ResizablePanel
ref={panelRef}
onExpand={() => previewStore.setState({ open: true })}
onCollapse={() => previewStore.setState({ open: false })}
{...props}
>
<PanelComponent className="h-full flex flex-col bg-slate-800">
<div className="h-10 flex items-center">
<p className="flex-1 truncate text-xs uppercase pl-4">Preview</p>
<div className="h-10 hidden md:flex items-center pl-4">
<p className="flex-1 truncate text-xs uppercase">Preview</p>
<Button
variant="ghost"
className="dark:hover:bg-slate-700"
@ -49,6 +86,7 @@ const WebPreview = ({ url }: WebPreviewProps) => {
/>
) : null}
</PanelComponent>
</ResizablePanel>
);
};

View File

@ -0,0 +1,11 @@
import { createStore } from "zustand";
type SidebarStore = {
expanded: boolean;
toggle: (toggle?: boolean) => void;
};
export const sidebarStore = createStore<SidebarStore>(() => ({
expanded: false,
toggle() {},
}));

View File

@ -1,9 +1,13 @@
import { createStore } from "zustand";
type PreviewStore = {
open: boolean;
toggle: (toggle?: boolean) => void;
refresh: () => void;
};
export const previewStore = createStore<PreviewStore>(() => ({
open: false,
toggle() {},
refresh: () => {},
}));