From 8d9f677c4e9ccfcc6dc9297864dc49446fb5ee59 Mon Sep 17 00:00:00 2001 From: Ellie Huxtable Date: Wed, 10 Jul 2024 15:56:33 +0100 Subject: feat(gui): use fancy new side nav (#2243) * feat(gui): use fancy new side nav * compact only sidebar, no expand-collapse * custom drag region, remove titlebar * add user popup * wire up login/logout/register, move user button to bottom and add menu * link help and feedback to forum --- ui/src/App.tsx | 257 +++++++++++++++------ ui/src/assets/icon.svg | 1 + ui/src/components/LoginOrRegister.tsx | 14 +- ui/src/components/Sidebar/Sidebar.tsx | 328 +++++++++++++++++++++++++++ ui/src/components/Sidebar/index.tsx | 4 + ui/src/components/runbooks/editor/Editor.tsx | 4 + ui/src/lib/utils.ts | 31 ++- ui/src/main.tsx | 6 +- ui/src/pages/Dotfiles.tsx | 2 +- ui/src/pages/History.tsx | 2 +- ui/src/pages/Home.tsx | 2 +- ui/src/pages/Runbooks.tsx | 2 +- ui/src/state/client.ts | 4 + 13 files changed, 569 insertions(+), 88 deletions(-) create mode 100644 ui/src/assets/icon.svg create mode 100644 ui/src/components/Sidebar/Sidebar.tsx create mode 100644 ui/src/components/Sidebar/index.tsx (limited to 'ui/src') diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 6dba3d71..7a9ac395 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,10 +1,17 @@ import "./App.css"; +import { open } from "@tauri-apps/plugin-shell"; -import { useState, ReactElement } from "react"; +import { useState, ReactElement, useEffect } from "react"; import { useStore } from "@/state/store"; -import Button, { ButtonStyle } from "@/components/Button"; import { Toaster } from "@/components/ui/toaster"; +import { + SettingsIcon, + CircleHelpIcon, + KeyRoundIcon, + LogOutIcon, +} from "lucide-react"; +import { Icon } from "@iconify/react"; import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; @@ -28,6 +35,35 @@ import Dotfiles from "./pages/Dotfiles.tsx"; import LoginOrRegister from "./components/LoginOrRegister.tsx"; import Runbooks from "./pages/Runbooks.tsx"; +import { + Avatar, + User, + Button, + ScrollShadow, + Spacer, + Tooltip, + Dropdown, + DropdownItem, + DropdownMenu, + DropdownSection, + DropdownTrigger, + Modal, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + useDisclosure, + Checkbox, + Input, + Link, +} from "@nextui-org/react"; +import { cn } from "@/lib/utils"; +import { sectionItems } from "@/components/Sidebar/sidebar-items"; +import Sidebar, { SidebarItem } from "@/components/Sidebar"; +import icon from "@/assets/icon.svg"; +import iconText from "@/assets/logo-light.svg"; +import { logout } from "./state/client.ts"; + enum Section { Home, History, @@ -54,90 +90,165 @@ function App() { // pages const [section, setSection] = useState(Section.Home); const user = useStore((state) => state.user); - console.log(user); + const refreshUser = useStore((state) => state.refreshUser); + const { isOpen, onOpen, onOpenChange } = useDisclosure(); - const navigation = [ - { - name: "Home", - icon: HomeIcon, - section: Section.Home, - }, - { - name: "History", - icon: ClockIcon, - section: Section.History, - }, - { - name: "Dotfiles", - icon: WrenchScrewdriverIcon, - section: Section.Dotfiles, - }, + const navigation: SidebarItem[] = [ { - name: "Runbooks", - icon: ChevronRightSquare, - section: Section.Runbooks, + key: "personal", + title: "Personal", + items: [ + { + key: "home", + icon: "solar:home-2-linear", + title: "Home", + onPress: () => setSection(Section.Home), + }, + { + key: "runbooks", + icon: "solar:notebook-linear", + title: "Runbooks", + onPress: () => { + console.log("runbooks"); + setSection(Section.Runbooks); + }, + }, + { + key: "history", + icon: "solar:history-outline", + title: "History", + onPress: () => setSection(Section.History), + }, + { + key: "dotfiles", + icon: "solar:file-smile-linear", + title: "Dotfiles", + onPress: () => setSection(Section.Dotfiles), + }, + ], }, ]; return ( -
-
-
-
- Atuin +
+
+
+
+ icon
-
+ + + + + + + +
+ + + + + + + + + + } + > + Settings + + + + open("https://forum.atuin.sh")} + startContent={ + + } + > + Help & Feedback + + + {(user.username && ( + + } + onClick={() => { + logout(); + refreshUser(); + }} + > + Log Out + + )) || ( + } + onPress={onOpen} + > + Log in or Register + )} - - - + + +
{renderMain(section)} + + + + {(onClose) => ( + <> + + + )} + +
); } diff --git a/ui/src/assets/icon.svg b/ui/src/assets/icon.svg new file mode 100644 index 00000000..0e4dd607 --- /dev/null +++ b/ui/src/assets/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/src/components/LoginOrRegister.tsx b/ui/src/components/LoginOrRegister.tsx index f05a9a24..97f8a790 100644 --- a/ui/src/components/LoginOrRegister.tsx +++ b/ui/src/components/LoginOrRegister.tsx @@ -6,6 +6,7 @@ import { useStore } from "@/state/store"; interface LoginProps { toggleRegister: () => void; + onClose: () => void; } function Login(props: LoginProps) { @@ -24,7 +25,7 @@ function Login(props: LoginProps) { try { await login(username, password, key); refreshUser(); - console.log("Logged in"); + props.onClose(); } catch (e: any) { console.error(e); setErrors(e); @@ -171,6 +172,7 @@ function Login(props: LoginProps) { interface RegisterProps { toggleLogin: () => void; + onClose: () => void; } function Register(props: RegisterProps) { @@ -185,13 +187,11 @@ function Register(props: RegisterProps) { const email = form.email.value; const password = form.password.value; - console.log("Logging in..."); try { await register(username, email, password); refreshUser(); - console.log("Logged in"); + props.onClose(); } catch (e: any) { - console.error(e); setErrors(e); } }; @@ -330,12 +330,12 @@ function Register(props: RegisterProps) { ); } -export default function LoginOrRegister() { +export default function LoginOrRegister({ onClose }: { onClose: () => void }) { let [login, setLogin] = useState(false); if (login) { - return setLogin(false)} />; + return setLogin(false)} />; } - return setLogin(true)} />; + return setLogin(true)} />; } diff --git a/ui/src/components/Sidebar/Sidebar.tsx b/ui/src/components/Sidebar/Sidebar.tsx new file mode 100644 index 00000000..99e2bf82 --- /dev/null +++ b/ui/src/components/Sidebar/Sidebar.tsx @@ -0,0 +1,328 @@ +"use client"; + +import { + Accordion, + AccordionItem, + type ListboxProps, + type ListboxSectionProps, + type Selection, +} from "@nextui-org/react"; +import React from "react"; +import { + Listbox, + Tooltip, + ListboxItem, + ListboxSection, +} from "@nextui-org/react"; +import { Icon } from "@iconify/react"; + +import { cn } from "@/lib/utils"; + +export enum SidebarItemType { + Nest = "nest", +} + +export type SidebarItem = { + key: string; + title: string; + icon?: string; + href?: string; + onPress?: () => void; + type?: SidebarItemType.Nest; + startContent?: React.ReactNode; + endContent?: React.ReactNode; + items?: SidebarItem[]; + className?: string; +}; + +export type SidebarProps = Omit, "children"> & { + items: SidebarItem[]; + isCompact?: boolean; + hideEndContent?: boolean; + iconClassName?: string; + sectionClasses?: ListboxSectionProps["classNames"]; + classNames?: ListboxProps["classNames"]; + defaultSelectedKey: string; + onSelect?: (key: string) => void; +}; + +const Sidebar = React.forwardRef( + ( + { + items, + isCompact, + defaultSelectedKey, + onSelect, + hideEndContent, + sectionClasses: sectionClassesProp = {}, + itemClasses: itemClassesProp = {}, + iconClassName, + classNames, + className, + ...props + }, + ref, + ) => { + const [selected, setSelected] = + React.useState(defaultSelectedKey); + + const sectionClasses = { + ...sectionClassesProp, + base: cn(sectionClassesProp?.base, "w-full", { + "p-0 max-w-[44px]": isCompact, + }), + group: cn(sectionClassesProp?.group, { + "flex flex-col gap-1": isCompact, + }), + heading: cn(sectionClassesProp?.heading, { + hidden: isCompact, + }), + }; + + const itemClasses = { + ...itemClassesProp, + base: cn(itemClassesProp?.base, { + "w-11 h-11 gap-0 p-0": isCompact, + }), + }; + + const renderNestItem = React.useCallback( + (item: SidebarItem) => { + const isNestType = + item.items && + item.items?.length > 0 && + item?.type === SidebarItemType.Nest; + + if (isNestType) { + // Is a nest type item , so we need to remove the href + delete item.href; + } + + return ( + + ) : ( + item.startContent ?? null + ) + } + title={isCompact || isNestType ? null : item.title} + > + {isCompact ? ( + +
+ {item.icon ? ( + + ) : ( + item.startContent ?? null + )} +
+
+ ) : null} + {!isCompact && isNestType ? ( + + + + + {item.title} + +
+ ) : ( + item.startContent ?? null + ) + } + > + {item.items && item.items?.length > 0 ? ( + + {item.items.map(renderItem)} + + ) : ( + renderItem(item) + )} + + + ) : null} + + ); + }, + [isCompact, hideEndContent, iconClassName, items], + ); + + const renderItem = React.useCallback( + (item: SidebarItem) => { + const isNestType = + item.items && + item.items?.length > 0 && + item?.type === SidebarItemType.Nest; + + if (isNestType) { + return renderNestItem(item); + } + + return ( + + ) : ( + item.startContent ?? null + ) + } + textValue={item.title} + title={isCompact ? null : item.title} + > + {isCompact ? ( + +
+ {item.icon ? ( + + ) : ( + item.startContent ?? null + )} +
+
+ ) : null} +
+ ); + }, + [isCompact, hideEndContent, iconClassName, itemClasses?.base], + ); + + return ( + { + const key = Array.from(keys)[0]; + + setSelected(key as React.Key); + onSelect?.(key as string); + }} + {...props} + > + {(item) => { + return item.items && + item.items?.length > 0 && + item?.type === SidebarItemType.Nest ? ( + renderNestItem(item) + ) : item.items && item.items?.length > 0 ? ( + + {item.items.map(renderItem)} + + ) : ( + renderItem(item) + ); + }} + + ); + }, +); + +Sidebar.displayName = "Sidebar"; + +export default Sidebar; diff --git a/ui/src/components/Sidebar/index.tsx b/ui/src/components/Sidebar/index.tsx new file mode 100644 index 00000000..10020952 --- /dev/null +++ b/ui/src/components/Sidebar/index.tsx @@ -0,0 +1,4 @@ +import Sidebar, { SidebarItem } from "./Sidebar"; + +export type { SidebarItem }; +export default Sidebar; diff --git a/ui/src/components/runbooks/editor/Editor.tsx b/ui/src/components/runbooks/editor/Editor.tsx index 81ea84cf..3f05d9f3 100644 --- a/ui/src/components/runbooks/editor/Editor.tsx +++ b/ui/src/components/runbooks/editor/Editor.tsx @@ -60,6 +60,10 @@ export default function Editor() { content: "Atuin runbooks", id: "foo", }, + { + type: "run", + id: "bar", + }, ], }); diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index d084ccad..c56d3687 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -1,6 +1,31 @@ -import { type ClassValue, clsx } from "clsx" -import { twMerge } from "tailwind-merge" +import type { ClassValue } from "clsx"; + +import clsx from "clsx"; +import { extendTailwindMerge } from "tailwind-merge"; + +const COMMON_UNITS = ["small", "medium", "large"]; + +/** + * We need to extend the tailwind merge to include NextUI's custom classes. + * + * So we can use classes like `text-small` or `text-default-500` and override them. + */ +const twMerge = extendTailwindMerge({ + extend: { + theme: { + opacity: ["disabled"], + spacing: ["divider"], + borderWidth: COMMON_UNITS, + borderRadius: COMMON_UNITS, + }, + classGroups: { + shadow: [{ shadow: COMMON_UNITS }], + "font-size": [{ text: ["tiny", ...COMMON_UNITS] }], + "bg-image": ["bg-stripe-gradient"], + }, + }, +}); export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)) + return twMerge(clsx(inputs)); } diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 81da3460..dc4c24a3 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,10 +1,14 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import { NextUIProvider, Spacer } from "@nextui-org/react"; import App from "./App"; import "./styles.css"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - + +
+ + , ); diff --git a/ui/src/pages/Dotfiles.tsx b/ui/src/pages/Dotfiles.tsx index cd80be66..56f570be 100644 --- a/ui/src/pages/Dotfiles.tsx +++ b/ui/src/pages/Dotfiles.tsx @@ -98,7 +98,7 @@ export default function Dotfiles() { console.log(current); return ( -
+
Manage your shell aliases, variables and paths diff --git a/ui/src/pages/History.tsx b/ui/src/pages/History.tsx index 81c83f99..64c2fca6 100644 --- a/ui/src/pages/History.tsx +++ b/ui/src/pages/History.tsx @@ -84,7 +84,7 @@ export default function Search() { return ( <> -
+

A history of all the commands you run in your shell.

diff --git a/ui/src/pages/Home.tsx b/ui/src/pages/Home.tsx index 9b1c0976..b7c4503d 100644 --- a/ui/src/pages/Home.tsx +++ b/ui/src/pages/Home.tsx @@ -125,7 +125,7 @@ export default function Home() { } return ( -
+
diff --git a/ui/src/pages/Runbooks.tsx b/ui/src/pages/Runbooks.tsx index 71887310..4237e065 100644 --- a/ui/src/pages/Runbooks.tsx +++ b/ui/src/pages/Runbooks.tsx @@ -2,7 +2,7 @@ import Editor from "@/components/runbooks/editor/Editor"; export default function Runbooks() { return ( -
+
); diff --git a/ui/src/state/client.ts b/ui/src/state/client.ts index 5ec0d8a7..c46fc4e6 100644 --- a/ui/src/state/client.ts +++ b/ui/src/state/client.ts @@ -20,6 +20,10 @@ export async function login( return await invoke("login", { username, password, key }); } +export async function logout(): Promise { + return await invoke("logout"); +} + export async function register( username: string, email: string, -- cgit v1.3.1