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
This commit is contained in:
Ellie Huxtable 2024-07-10 15:56:33 +01:00 committed by GitHub
parent 353981e794
commit 8d9f677c4e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 6402 additions and 4064 deletions

View File

@ -14,6 +14,7 @@ pub mod history;
pub mod import;
pub mod kv;
pub mod login;
pub mod logout;
pub mod ordering;
pub mod record;
pub mod register;

View File

@ -0,0 +1,17 @@
use eyre::{Context, Result};
use fs_err::remove_file;
use crate::settings::Settings;
pub fn logout(settings: &Settings) -> Result<()> {
let session_path = settings.session_path.as_str();
if settings.logged_in() {
remove_file(session_path).context("Failed to remove session file")?;
println!("You have logged out!");
} else {
println!("You are not logged in");
}
Ok(())
}

View File

@ -1,17 +1,6 @@
use eyre::{Context, Result};
use fs_err::remove_file;
use atuin_client::settings::Settings;
use eyre::Result;
pub fn run(settings: &Settings) -> Result<()> {
let session_path = settings.session_path.as_str();
if settings.logged_in() {
remove_file(session_path).context("Failed to remove session file")?;
println!("You have logged out!");
} else {
println!("You are not logged in");
}
Ok(())
atuin_client::logout::logout(settings)
}

1
ui/.npmrc Normal file
View File

@ -0,0 +1 @@
public-hoist-pattern[]=*@nextui-org/*

1
ui/backend/Cargo.lock generated
View File

@ -6481,6 +6481,7 @@ dependencies = [
"atuin-dotfiles",
"atuin-history",
"bytes",
"cocoa",
"comrak",
"eyre",
"nix 0.29.0",

View File

@ -18,7 +18,7 @@ atuin-dotfiles = { path = "../../crates/atuin-dotfiles", version = "0.4.0" }
atuin-history = { path = "../../crates/atuin-history", version = "0.3.0" }
eyre = "0.6"
tauri = { version = "2.0.0-beta", features = ["tray-icon"] }
tauri = { version = "2.0.0-beta", features = [ "tray-icon"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
time = "0.3.36"
@ -34,6 +34,9 @@ vt100 = "0.15.2"
bytes = "1.6.0"
nix = "0.29.0"
[target."cfg(target_os = \"macos\")".dependencies]
cocoa = "0.25"
[dependencies.sqlx]
version = "0.7"
features = ["runtime-tokio-rustls", "time", "postgres", "uuid"]

View File

@ -15,6 +15,7 @@
"sql:allow-load",
"sql:allow-execute",
"sql:allow-select",
"window:allow-start-dragging",
{
"identifier": "http:default",
"allow": ["https://api.atuin.sh/*"]

View File

@ -110,6 +110,16 @@ async fn login(username: String, password: String, key: String) -> Result<String
Ok(session)
}
#[tauri::command]
async fn logout() -> Result<(), String> {
let settings = Settings::new().map_err(|e| e.to_string())?;
atuin_client::logout::logout(&settings)
.map_err(|e| e.to_string())?;
Ok(())
}
#[tauri::command]
async fn register(username: String, email: String, password: String) -> Result<String, String> {
let settings = Settings::new().map_err(|e| e.to_string())?;
@ -257,6 +267,7 @@ fn main() {
config,
session,
login,
logout,
register,
history_calendar,
run::pty::pty_open,

View File

@ -15,7 +15,9 @@
"width": 1200,
"height": 800,
"minWidth": 1000,
"minHeight": 500
"minHeight": 500,
"titleBarStyle": "Overlay",
"hiddenTitle": true
}
]
},

View File

@ -22,6 +22,7 @@
"@codemirror/view": "^6.28.2",
"@headlessui/react": "^2.1.1",
"@heroicons/react": "^2.1.4",
"@nextui-org/react": "^2.4.2",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-slot": "^1.1.0",
@ -42,6 +43,7 @@
"clsx": "^2.1.1",
"core": "link:@tauri-apps/api/core",
"date-fns": "^3.6.0",
"framer-motion": "^11.2.13",
"highlight.js": "^11.9.0",
"lucide-react": "^0.402.0",
"luxon": "^3.4.4",
@ -51,16 +53,19 @@
"react-activity-calendar": "^2.2.10",
"react-dom": "^18.3.1",
"react-spinners": "^0.14.1",
"react-router-dom": "^6.24.1",
"react-tooltip": "^5.27.0",
"react-window": "^1.8.10",
"react-window-infinite-loader": "^1.0.9",
"recharts": "^2.12.7",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^3.1.0",
"vaul": "^0.9.1",
"zustand": "^4.5.2"
},
"devDependencies": {
"@iconify/react": "^5.0.1",
"@tauri-apps/cli": "2.0.0-beta.20",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",

9411
ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -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 = [
const navigation: SidebarItem[] = [
{
name: "Home",
icon: HomeIcon,
section: Section.Home,
key: "personal",
title: "Personal",
items: [
{
key: "home",
icon: "solar:home-2-linear",
title: "Home",
onPress: () => setSection(Section.Home),
},
{
name: "History",
icon: ClockIcon,
section: Section.History,
key: "runbooks",
icon: "solar:notebook-linear",
title: "Runbooks",
onPress: () => {
console.log("runbooks");
setSection(Section.Runbooks);
},
},
{
name: "Dotfiles",
icon: WrenchScrewdriverIcon,
section: Section.Dotfiles,
key: "history",
icon: "solar:history-outline",
title: "History",
onPress: () => setSection(Section.History),
},
{
name: "Runbooks",
icon: ChevronRightSquare,
section: Section.Runbooks,
key: "dotfiles",
icon: "solar:file-smile-linear",
title: "Dotfiles",
onPress: () => setSection(Section.Dotfiles),
},
],
},
];
return (
<div>
<div className="fixed inset-y-0 z-50 flex w-60 flex-col">
<div className="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4">
<div className="flex h-16 shrink-0 items-center">
<img className="h-8 w-auto" src={Logo} alt="Atuin" />
<div className="flex h-dvh w-full select-none">
<div className="relative flex h-full flex-col !border-r-small border-divider p-6 transition-width px-2 pb-6 pt-9 w-16 items-center">
<div className="flex items-center gap-0 px-3 justify-center">
<div className="flex h-8 w-8">
<img src={icon} alt="icon" className="h-8 w-8" />
</div>
<nav className="flex flex-1 flex-col">
<ul role="list" className="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" className="-mx-2 space-y-1 w-full">
{navigation.map((item) => (
<li key={item.name}>
<button
onClick={() => setSection(item.section)}
className={classNames(
section == item.section
? "bg-gray-50 text-green-600"
: "text-gray-700 hover:text-green-600 hover:bg-gray-50",
"group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold w-full",
)}
</div>
<ScrollShadow className="-mr-6 h-full max-h-full py-6 pr-6">
<Sidebar
defaultSelectedKey="home"
isCompact={true}
items={navigation}
/>
</ScrollShadow>
<Spacer y={2} />
<div className="flex items-center gap-3 px-3">
<Dropdown showArrow placement="right-start">
<DropdownTrigger>
<Button disableRipple isIconOnly radius="full" variant="light">
<Avatar
isBordered
className="flex-none"
size="sm"
name={user.username || ""}
/>
</Button>
</DropdownTrigger>
<DropdownMenu aria-label="Custom item styles">
<DropdownItem
key="profile"
isReadOnly
className="h-14 opacity-100"
textValue="Signed in as"
>
<item.icon
className={classNames(
section == item.section
? "text-green-600"
: "text-gray-400 group-hover:text-green-600",
"h-6 w-6 shrink-0",
)}
aria-hidden="true"
<User
avatarProps={{
size: "sm",
name: user.username || "Anonymous User",
showFallback: true,
imgProps: {
className: "transition-none",
},
}}
classNames={{
name: "text-default-600",
description: "text-default-500",
}}
description={
user.bio || (user.username && "No bio") || "Sign up now"
}
name={user.username || "Anonymous User"}
/>
{item.name}
</button>
</li>
))}
</ul>
</li>
<li className="mt-auto">
{user && user.username === "" && !user.username && (
<Dialog>
<DialogTrigger className="w-full">
<Button
text={"Login or Register"}
style={ButtonStyle.PrimarySmFill}
/>
</DialogTrigger>
<DialogContent>
<LoginOrRegister />
</DialogContent>
</Dialog>
</DropdownItem>
<DropdownItem
key="settings"
description="Configure Atuin"
startContent={<Icon icon="solar:settings-linear" width={24} />}
>
Settings
</DropdownItem>
<DropdownSection aria-label="Help & Feedback">
<DropdownItem
key="help_and_feedback"
description="Get in touch"
onPress={() => open("https://forum.atuin.sh")}
startContent={
<Icon width={24} icon="solar:question-circle-linear" />
}
>
Help & Feedback
</DropdownItem>
{(user.username && (
<DropdownItem
key="logout"
startContent={
<Icon width={24} icon="solar:logout-broken" />
}
onClick={() => {
logout();
refreshUser();
}}
>
Log Out
</DropdownItem>
)) || (
<DropdownItem
key="signup"
description="Sync, backup and share your data"
className="bg-emerald-100"
startContent={<KeyRoundIcon size="18px" />}
onPress={onOpen}
>
Log in or Register
</DropdownItem>
)}
</li>
</ul>
</nav>
</DropdownSection>
</DropdownMenu>
</Dropdown>
</div>
</div>
{renderMain(section)}
<Toaster />
<Modal isOpen={isOpen} onOpenChange={onOpenChange} placement="top-center">
<ModalContent className="p-8">
{(onClose) => (
<>
<LoginOrRegister onClose={onClose} />
</>
)}
</ModalContent>
</Modal>
</div>
);
}

1
ui/src/assets/icon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -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<boolean>(false);
if (login) {
return <Login toggleRegister={() => setLogin(false)} />;
return <Login onClose={onClose} toggleRegister={() => setLogin(false)} />;
}
return <Register toggleLogin={() => setLogin(true)} />;
return <Register onClose={onClose} toggleLogin={() => setLogin(true)} />;
}

View File

@ -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<ListboxProps<SidebarItem>, "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<HTMLElement, SidebarProps>(
(
{
items,
isCompact,
defaultSelectedKey,
onSelect,
hideEndContent,
sectionClasses: sectionClassesProp = {},
itemClasses: itemClassesProp = {},
iconClassName,
classNames,
className,
...props
},
ref,
) => {
const [selected, setSelected] =
React.useState<React.Key>(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 (
<ListboxItem
{...item}
key={item.key}
classNames={{
base: cn(
{
"h-auto p-0": !isCompact && isNestType,
},
{
"inline-block w-11": isCompact && isNestType,
},
),
}}
endContent={
isCompact || isNestType || hideEndContent
? null
: item.endContent ?? null
}
startContent={
isCompact || isNestType ? null : item.icon ? (
<Icon
className={cn(
"text-default-500 group-data-[selected=true]:text-foreground",
iconClassName,
)}
icon={item.icon}
width={24}
/>
) : (
item.startContent ?? null
)
}
title={isCompact || isNestType ? null : item.title}
>
{isCompact ? (
<Tooltip content={item.title} placement="right">
<div className="flex w-full items-center justify-center">
{item.icon ? (
<Icon
className={cn(
"text-default-500 group-data-[selected=true]:text-foreground",
iconClassName,
)}
icon={item.icon}
width={24}
/>
) : (
item.startContent ?? null
)}
</div>
</Tooltip>
) : null}
{!isCompact && isNestType ? (
<Accordion className={"p-0"}>
<AccordionItem
key={item.key}
aria-label={item.title}
classNames={{
heading: "pr-3",
trigger: "p-0",
content: "py-0 pl-4",
}}
title={
item.icon ? (
<div
className={"flex h-11 items-center gap-2 px-2 py-1.5"}
>
<Icon
className={cn(
"text-default-500 group-data-[selected=true]:text-foreground",
iconClassName,
)}
icon={item.icon}
width={24}
/>
<span className="text-small font-medium text-default-500 group-data-[selected=true]:text-foreground">
{item.title}
</span>
</div>
) : (
item.startContent ?? null
)
}
>
{item.items && item.items?.length > 0 ? (
<Listbox
className={"mt-0.5"}
classNames={{
list: cn("border-l border-default-200 pl-4"),
}}
items={item.items}
variant="flat"
>
{item.items.map(renderItem)}
</Listbox>
) : (
renderItem(item)
)}
</AccordionItem>
</Accordion>
) : null}
</ListboxItem>
);
},
[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 (
<ListboxItem
{...item}
key={item.key}
endContent={
isCompact || hideEndContent ? null : item.endContent ?? null
}
startContent={
isCompact ? null : item.icon ? (
<Icon
className={cn(
"text-default-500 group-data-[selected=true]:text-foreground",
iconClassName,
)}
icon={item.icon}
width={24}
/>
) : (
item.startContent ?? null
)
}
textValue={item.title}
title={isCompact ? null : item.title}
>
{isCompact ? (
<Tooltip content={item.title} placement="right">
<div className="flex w-full items-center justify-center">
{item.icon ? (
<Icon
className={cn(
"text-default-500 group-data-[selected=true]:text-foreground",
iconClassName,
)}
icon={item.icon}
width={24}
/>
) : (
item.startContent ?? null
)}
</div>
</Tooltip>
) : null}
</ListboxItem>
);
},
[isCompact, hideEndContent, iconClassName, itemClasses?.base],
);
return (
<Listbox
key={isCompact ? "compact" : "default"}
ref={ref}
hideSelectedIcon
as="nav"
className={cn("list-none", className)}
classNames={{
...classNames,
list: cn("items-center", classNames?.list),
}}
color="default"
itemClasses={{
...itemClasses,
base: cn(
"px-3 min-h-11 rounded-large h-[44px] data-[selected=true]:bg-default-100",
itemClasses?.base,
),
title: cn(
"text-small font-medium text-default-500 group-data-[selected=true]:text-foreground",
itemClasses?.title,
),
}}
items={items}
selectedKeys={[selected] as unknown as Selection}
selectionMode="single"
variant="flat"
onSelectionChange={(keys) => {
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 ? (
<ListboxSection
key={item.key}
classNames={sectionClasses}
showDivider={isCompact}
title={item.title}
>
{item.items.map(renderItem)}
</ListboxSection>
) : (
renderItem(item)
);
}}
</Listbox>
);
},
);
Sidebar.displayName = "Sidebar";
export default Sidebar;

View File

@ -0,0 +1,4 @@
import Sidebar, { SidebarItem } from "./Sidebar";
export type { SidebarItem };
export default Sidebar;

View File

@ -60,6 +60,10 @@ export default function Editor() {
content: "Atuin runbooks",
id: "foo",
},
{
type: "run",
id: "bar",
},
],
});

View File

@ -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));
}

View File

@ -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(
<React.StrictMode>
<NextUIProvider>
<div data-tauri-drag-region className="w-full h-8 absolute" />
<App />
</NextUIProvider>
</React.StrictMode>,
);

View File

@ -98,7 +98,7 @@ export default function Dotfiles() {
console.log(current);
return (
<div className="pl-60">
<div className="w-full flex-1 flex-col p-4">
<div className="p-10">
<Header current={current} setCurrent={setCurrent} />
Manage your shell aliases, variables and paths

View File

@ -84,7 +84,7 @@ export default function Search() {
return (
<>
<div className="pl-60">
<div className="w-full flex-1 flex-col p-4">
<div className="p-10 history-header">
<Header />
<p>A history of all the commands you run in your shell.</p>

View File

@ -125,7 +125,7 @@ export default function Home() {
}
return (
<div className="pl-60">
<div className="w-full flex-1 flex-col p-4">
<div className="p-10">
<Header name={user.username} />

View File

@ -2,7 +2,7 @@ import Editor from "@/components/runbooks/editor/Editor";
export default function Runbooks() {
return (
<div className="pl-60 p-4 ">
<div className="w-full flex-1 flex-col p-4">
<Editor />
</div>
);

View File

@ -20,6 +20,10 @@ export async function login(
return await invoke("login", { username, password, key });
}
export async function logout(): Promise<string> {
return await invoke("logout");
}
export async function register(
username: string,
email: string,

View File

@ -1,11 +1,14 @@
const { nextui } = require("@nextui-org/react");
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
darkMode: "class",
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
"./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}",
],
prefix: "",
theme: {
@ -73,5 +76,5 @@ module.exports = {
},
},
},
plugins: [require("tailwindcss-animate")],
}
plugins: [require("tailwindcss-animate"), nextui()],
};