From e079d460507e3d40a14b65b2e27d10a8a7d5c447 Mon Sep 17 00:00:00 2001 From: Eva Decker Date: Sun, 24 Nov 2024 23:02:06 -0500 Subject: [PATCH] Improve design of settings page --- src/components/AppSidebar/AppSidebar.tsx | 58 ++-- src/components/Button/Button.tsx | 5 +- src/components/Card/Card.tsx | 2 +- src/components/ToggleButton/ToggleButton.tsx | 12 +- .../ToggleButtonGroup.stories.tsx | 10 + .../ToggleButtonGroup/ToggleButtonGroup.tsx | 27 ++ src/components/ToggleButtonGroup/index.ts | 1 + src/components/index.ts | 1 + src/components/utils.ts | 2 +- src/main.tsx | 2 +- .../_authenticated/settings/account.tsx | 279 +++++++++++------- src/utils/useTheme.ts | 24 ++ 12 files changed, 283 insertions(+), 140 deletions(-) create mode 100644 src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx create mode 100644 src/components/ToggleButtonGroup/ToggleButtonGroup.tsx create mode 100644 src/components/ToggleButtonGroup/index.ts create mode 100644 src/utils/useTheme.ts diff --git a/src/components/AppSidebar/AppSidebar.tsx b/src/components/AppSidebar/AppSidebar.tsx index a96a45d..fa106aa 100644 --- a/src/components/AppSidebar/AppSidebar.tsx +++ b/src/components/AppSidebar/AppSidebar.tsx @@ -1,19 +1,20 @@ +import { useTheme } from "@/utils/useTheme"; import { useAuthActions } from "@convex-dev/auth/react"; import { api } from "@convex/_generated/api"; import { THEMES, type Theme } from "@convex/constants"; -import { useMutation, useQuery } from "convex/react"; +import { useQuery } from "convex/react"; import { + Bug, CircleUser, Cog, + DatabaseZap, + FileClock, GlobeLock, LogOut, MessageCircleQuestion, Plus, Snail, } from "lucide-react"; -import { useTheme } from "next-themes"; -import { useState } from "react"; -import type { Selection } from "react-aria-components"; import { Badge } from "../Badge"; import { Button } from "../Button"; import { Link } from "../Link"; @@ -35,26 +36,11 @@ type AppSidebarProps = { export const AppSidebar = ({ children }: AppSidebarProps) => { const { signOut } = useAuthActions(); - const { theme, setTheme } = useTheme(); - const [selectedTheme, setSelectedTheme] = useState( - new Set([theme ?? "system"]), - ); + const { theme, themeSelection, setTheme } = useTheme(); + const user = useQuery(api.users.getCurrentUser); const isAdmin = user?.role === "admin"; - // Theme change - const updateTheme = useMutation(api.users.setUserTheme); - - const handleUpdateTheme = (theme: Selection) => { - const newTheme = [...theme][0] as Theme; - // Update the theme in the database - updateTheme({ theme: newTheme }); - // Update the theme in next-themes - setTheme(newTheme); - // Update the selected theme in the menu - setSelectedTheme(new Set([newTheme])); - }; - const handleSignOut = async () => { await signOut(); }; @@ -98,6 +84,23 @@ export const AppSidebar = ({ children }: AppSidebarProps) => { > About Namesake + + Changelog{" "} + v{APP_VERSION} + + + System Status + Settings @@ -108,8 +111,8 @@ export const AppSidebar = ({ children }: AppSidebarProps) => { {Object.entries(THEMES).map(([theme, details]) => ( @@ -125,6 +128,14 @@ export const AppSidebar = ({ children }: AppSidebarProps) => { )} + + Report an issue + { > Support… + Sign out diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 7ad997b..e8e1c72 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -16,7 +16,7 @@ export interface ButtonProps extends AriaButtonProps { export const buttonStyles = tv({ extend: focusRing, - base: "py-2 text-sm font-medium whitespace-nowrap rounded-lg flex gap-1.5 items-center justify-center border border-black/10 dark:border-white/10 cursor-pointer", + base: "py-2 text-sm font-medium whitespace-nowrap rounded-lg flex gap-1.5 items-center justify-center border border-black/10 dark:border-white/10", variants: { variant: { primary: "bg-purple-solid text-white", @@ -31,6 +31,7 @@ export const buttonStyles = tv({ medium: "h-10 px-3", }, isDisabled: { + false: "cursor-pointer", true: "cursor-default text-gray-dim opacity-50 forced-colors:text-[GrayText]", }, }, @@ -72,7 +73,7 @@ export function Button({ }), )} > - {Icon && } + {Icon && } {children} ); diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx index f9848a7..b482c14 100644 --- a/src/components/Card/Card.tsx +++ b/src/components/Card/Card.tsx @@ -9,8 +9,8 @@ export function Card({ children, className }: CardProps) { return (
{children} diff --git a/src/components/ToggleButton/ToggleButton.tsx b/src/components/ToggleButton/ToggleButton.tsx index c78a08b..cb0e5e1 100644 --- a/src/components/ToggleButton/ToggleButton.tsx +++ b/src/components/ToggleButton/ToggleButton.tsx @@ -4,20 +4,18 @@ import { composeRenderProps, } from "react-aria-components"; import { tv } from "tailwind-variants"; +import { buttonStyles } from "../Button"; import { focusRing } from "../utils"; const styles = tv({ extend: focusRing, - base: "px-5 py-2 text-sm text-center transition rounded-lg border border-black/10 dark:border-white/10 forced-colors:border-[ButtonBorder] cursor-pointer forced-color-adjust-none", + base: "h-10 px-3.5 [&:has(svg:only-child)]:px-2 text-sm text-center transition rounded-lg border border-black/10 dark:border-white/10 forced-colors:border-[ButtonBorder] shadow-[inset_0_1px_0_0_rgba(255,255,255,0.1)] dark:shadow-none forced-color-adjust-none", variants: { isSelected: { - false: - "bg-gray-1 hover:bg-gray-2 pressed:bg-gray-3 text-gray-normal dark:bg-gray-6 dark:hover:bg-gray-5 dark:pressed:bg-gray-4 dark:text-gray-1 forced-colors:!bg-[ButtonFace] forced-colors:!text-[ButtonText]", - true: "bg-purple-9 hover:bg-purple-10 text-white dark:bg-purpledark-9 dark:hover:bg-purpledark-10 forced-colors:!bg-[Highlight] forced-colors:!text-[HighlightText]", - }, - isDisabled: { - true: "bg-gray-1 dark:bg-gray-11 forced-colors:!bg-[ButtonFace] text-gray-3 dark:text-gray-6 forced-colors:!text-[GrayText] border-black/5 dark:border-white/5 forced-colors:border-[GrayText]", + false: buttonStyles.variants.variant.secondary, + true: "bg-gray-12 dark:bg-graydark-12 text-gray-1 dark:text-gray-12 shadow-sm", }, + isDisabled: buttonStyles.variants.isDisabled, }, }); diff --git a/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx b/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx new file mode 100644 index 0000000..990e5d5 --- /dev/null +++ b/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx @@ -0,0 +1,10 @@ +import type { Meta } from "@storybook/react"; +import { ToggleButtonGroup } from "."; + +const meta: Meta = { + component: ToggleButtonGroup, +}; + +export default meta; + +export const Example = (args: any) => ; diff --git a/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx b/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx new file mode 100644 index 0000000..ab69a92 --- /dev/null +++ b/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx @@ -0,0 +1,27 @@ +import { + ToggleButtonGroup as AriaToggleButtonGroup, + type ToggleButtonGroupProps, + composeRenderProps, +} from "react-aria-components"; +import { tv } from "tailwind-variants"; + +const styles = tv({ + base: "border rounded-lg grid grid-flow-col auto-cols-fr border-black/10 dark:border-white/10 *:border-0", + variants: { + orientation: { + horizontal: "flex-row", + vertical: "flex-col", + }, + }, +}); + +export function ToggleButtonGroup(props: ToggleButtonGroupProps) { + return ( + + styles({ orientation: props.orientation || "horizontal", className }), + )} + /> + ); +} diff --git a/src/components/ToggleButtonGroup/index.ts b/src/components/ToggleButtonGroup/index.ts new file mode 100644 index 0000000..6f19743 --- /dev/null +++ b/src/components/ToggleButtonGroup/index.ts @@ -0,0 +1 @@ +export * from "./ToggleButtonGroup"; diff --git a/src/components/index.ts b/src/components/index.ts index 9afe9f0..9089fc6 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -49,5 +49,6 @@ export * from "./TextArea"; export * from "./TextField"; export * from "./TimeField"; export * from "./ToggleButton"; +export * from "./ToggleButtonGroup"; export * from "./Toolbar"; export * from "./Tooltip"; diff --git a/src/components/utils.ts b/src/components/utils.ts index 58ebb18..967f130 100644 --- a/src/components/utils.ts +++ b/src/components/utils.ts @@ -7,7 +7,7 @@ export const focusRing = tv({ variants: { isFocusVisible: { false: "outline-0", - true: "outline-2 z-99", + true: "outline-2 z-9999", }, }, }); diff --git a/src/main.tsx b/src/main.tsx index 50c80d5..ba2a638 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -72,7 +72,7 @@ if (!rootElement.innerHTML) { - + diff --git a/src/routes/_authenticated/settings/account.tsx b/src/routes/_authenticated/settings/account.tsx index dc173b9..d76550b 100644 --- a/src/routes/_authenticated/settings/account.tsx +++ b/src/routes/_authenticated/settings/account.tsx @@ -1,81 +1,142 @@ import { Button, - Link, + Card, + Form, Modal, PageHeader, Switch, TextField, + ToggleButton, + ToggleButtonGroup, } from "@/components"; +import { useTheme } from "@/utils/useTheme"; import { useAuthActions } from "@convex-dev/auth/react"; import { api } from "@convex/_generated/api"; +import { THEMES } from "@convex/constants"; import { createFileRoute } from "@tanstack/react-router"; import { useMutation, useQuery } from "convex/react"; -import { Check, LoaderCircle } from "lucide-react"; -import { useEffect, useState } from "react"; -import { useDebouncedCallback } from "use-debounce"; +import { Pencil, Trash } from "lucide-react"; +import { useState } from "react"; +import { Header } from "react-aria-components"; export const Route = createFileRoute("/_authenticated/settings/account")({ component: SettingsAccountRoute, }); -function SettingsAccountRoute() { - const { signOut } = useAuthActions(); - const user = useQuery(api.users.getCurrentUser); +const SettingsSection = ({ + title, + children, +}: { title: string; children: React.ReactNode }) => ( +
+

{title}

+ + {children} + +
+); - // Name change field - // TODO: Extract all this debounce logic + field as a component for reuse - const updateName = useMutation(api.users.setCurrentUserName); - const [name, setName] = useState(user?.name ?? ""); - const [isUpdatingName, setIsUpdatingName] = useState(false); - const [didUpdateName, setDidUpdateName] = useState(false); +const SettingsItem = ({ + label, + description, + children, +}: { label: string; description?: string; children: React.ReactNode }) => ( +
+
+

{label}

+ {description && ( +

+ {description} +

+ )} +
+
{children}
+
+); - useEffect(() => { - if (!user?.name) return; - setName(user.name); - }, [user]); +const EditNameDialog = ({ + defaultName, + isOpen, + onOpenChange, + onSubmit, +}: { + defaultName: string; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onSubmit: () => void; +}) => { + const updateName = useMutation(api.users.setCurrentUserName); + const [name, setName] = useState(defaultName); - let timeout: NodeJS.Timeout | null = null; + const handleSubmit = () => { + updateName({ name }); + onSubmit(); + }; - useEffect(() => { - if (timeout) clearTimeout(timeout); - if (didUpdateName) - timeout = setTimeout(() => setDidUpdateName(false), 2000); - }, [didUpdateName, timeout]); + return ( + +
+ Edit name + +
+ + +
+ +
+ ); +}; - const debouncedNameSave = useDebouncedCallback((name) => { - updateName({ name }) - .then(() => { - setIsUpdatingName(false); - setDidUpdateName(true); - }) - .catch((error) => { - console.error(error); - setIsUpdatingName(false); - }); - }, 1000); +const DeleteAccountDialog = ({ + isOpen, + onOpenChange, + onSubmit, +}: { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onSubmit: () => void; +}) => { + const { signOut } = useAuthActions(); + const clearLocalStorage = () => { + localStorage.removeItem("theme"); + }; + const deleteAccount = useMutation(api.users.deleteCurrentUser); - const handleUpdateName = (value: string) => { - setIsUpdatingName(true); - setName(value); - debouncedNameSave(value); + const handleSubmit = () => { + clearLocalStorage(); + deleteAccount(); + signOut(); + onSubmit(); }; - const textFieldIcon = isUpdatingName ? ( - - ) : didUpdateName ? ( - - ) : undefined; + return ( + +
Delete account?
+

This will permanently erase your account and all data.

+
+ + +
+
+ ); +}; + +function SettingsAccountRoute() { + const user = useQuery(api.users.getCurrentUser); + const { themeSelection, setTheme } = useTheme(); + + const [isNameDialogOpen, setIsNameDialogOpen] = useState(false); + const [isDeleteAccountDialogOpen, setIsDeleteAccountDialogOpen] = + useState(false); // Is minor switch const updateIsMinor = useMutation(api.users.setCurrentUserIsMinor); - // Account deletion - const clearLocalStorage = () => { - localStorage.removeItem("theme"); - }; - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const deleteAccount = useMutation(api.users.deleteCurrentUser); - return ( <> @@ -84,66 +145,74 @@ function SettingsAccountRoute() { ) : user === null ? ( "User not found, please reload" ) : ( -
- - updateIsMinor({ isMinor: !user.isMinor })} - > - Is minor - - - - setIsDeleteModalOpen(isOpen)} - > - Delete account? -
- + setIsNameDialogOpen(false)} + /> + + + updateIsMinor({ isMinor: !user.isMinor })} + > + Is minor + + + + + + + {Object.entries(THEMES).map(([theme, details]) => ( + + {details.label} + + ))} + + + + + -
-
-
+ setIsDeleteAccountDialogOpen(false)} + /> + + + )} -
- {`Namesake v${APP_VERSION}`} - - System Status - - - Support - -
); } diff --git a/src/utils/useTheme.ts b/src/utils/useTheme.ts new file mode 100644 index 0000000..f9e06ad --- /dev/null +++ b/src/utils/useTheme.ts @@ -0,0 +1,24 @@ +import { api } from "@convex/_generated/api"; +import type { Theme } from "@convex/constants"; +import { useMutation } from "convex/react"; +import { useTheme as useNextTheme } from "next-themes"; +import type { Selection } from "react-aria-components"; + +export function useTheme() { + const { theme: nextTheme, setTheme: setNextTheme } = useNextTheme(); + + const updateTheme = useMutation(api.users.setUserTheme); + + const theme = (nextTheme ?? "system") as Theme; + const themeSelection = new Set([theme]); + + const setTheme = (theme: Selection) => { + const newTheme = [...theme][0] as Theme; + // Update the theme in the database + updateTheme({ theme: newTheme }); + // Update the theme in next-themes + setNextTheme(newTheme); + }; + + return { theme, themeSelection, setTheme }; +}