diff --git a/src/components/common/Button.tsx b/src/components/common/Button.tsx index f4b2751d..d619afd4 100644 --- a/src/components/common/Button.tsx +++ b/src/components/common/Button.tsx @@ -1,42 +1,29 @@ import { forwardRef } from "react"; import { twMerge } from "tailwind-merge"; -const variants = [ - { - name: "primary", - className: - "border-transparent bg-indigo-600 text-white enabled:hover:bg-indigo-700", - }, - { - name: "secondary", - className: - "border-gray-300 bg-white text-gray-700 enabled:hover:bg-gray-50", - }, - { - name: "danger", - className: - "border-transparent bg-red-600 text-white enabled:hover:bg-red-700 focus:ring-red-500", - }, -] as const; +export enum BUTTON_VARIANT { + PRIMARY = "border-transparent bg-indigo-600 text-white enabled:hover:bg-indigo-700", + SECONDARY = "border-gray-300 bg-white text-gray-700 enabled:hover:bg-gray-50", + DANGER = "border-transparent bg-red-600 text-white enabled:hover:bg-red-700 focus:ring-red-500", + TEXT = "rounded-md border-none bg-gray-100 font-medium text-indigo-600 shadow-none hover:text-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:ring-offset-gray-100", +} export const Button = forwardRef< HTMLButtonElement, JSX.IntrinsicElements["button"] & { - variant?: typeof variants[number]["name"]; + variant?: BUTTON_VARIANT; } ->(({ variant = "primary", children, ...props }, ref) => { +>(({ variant = BUTTON_VARIANT.PRIMARY, className, ...props }, ref) => { return ( + ref={ref} + {...props} + /> ); }); diff --git a/src/components/dashboard/heading/Heading.tsx b/src/components/dashboard/heading/Heading.tsx new file mode 100644 index 00000000..7eec8af5 --- /dev/null +++ b/src/components/dashboard/heading/Heading.tsx @@ -0,0 +1,32 @@ +import { twMerge } from "tailwind-merge"; + +import type { HTMLAttributes } from "react"; + +const variants = { + 1: "text-2xl", + 2: "text-xl", + 3: "text-lg", + 4: "text-base", + 5: "text-sm", + 6: "text-xs", +} as const; + +type HeadingProps = Readonly<{ + level: keyof typeof variants; +}> & + HTMLAttributes; + +export const Heading = ({ level, className, ...props }: HeadingProps) => { + const Tag = `h${level}` as const; + + return ( + + ); +}; diff --git a/src/components/dashboard/settingsLayout/SettingsLayout.tsx b/src/components/dashboard/settingsLayout/SettingsLayout.tsx new file mode 100644 index 00000000..d7e06a8b --- /dev/null +++ b/src/components/dashboard/settingsLayout/SettingsLayout.tsx @@ -0,0 +1,26 @@ +import { Heading } from "../heading/Heading"; +import { Tabs } from "../tabs/Tabs"; + +import type { ReactNode } from "react"; + +const tabs = [ + { + label: "General", + pathname: "/dashboard/settings", + }, +]; + +interface SettingsLayoutProps { + readonly title: string; + readonly children: ReactNode; +} + +export const SettingsLayout = ({ title, children }: SettingsLayoutProps) => ( +
+ + {title} + + + {children} +
+); diff --git a/src/components/dashboard/settingsRow/RowForm.tsx b/src/components/dashboard/settingsRow/RowForm.tsx new file mode 100644 index 00000000..1eb095f4 --- /dev/null +++ b/src/components/dashboard/settingsRow/RowForm.tsx @@ -0,0 +1,70 @@ +import { twMerge } from "tailwind-merge"; + +import { useZodForm } from "../../../hooks/useZodForm"; +import { onPromise } from "../../../utils/functions"; +import { Button } from "../../common/Button"; +import { Input } from "../../common/Input"; + +import { + LEFT_SECTION_STYLES, + RIGHT_SECTION_STYLES, + SECTIONS_WRAPPER_STYLES, +} from "./SettingsRow"; + +import type { HTMLInputTypeAttribute } from "react"; +import type { ZodObject, ZodString } from "zod"; + +interface RowFormProps { + readonly schema: ZodObject<{ content: ZodString }>; + readonly content: string; + readonly contentType: HTMLInputTypeAttribute; + readonly onSubmit: (content: string) => void; + readonly onCancel?: () => void; +} + +export const RowForm = ({ + schema, + content, + contentType, + onSubmit, + onCancel, +}: RowFormProps) => { + const { + handleFormSubmit, + register, + formState: { errors }, + } = useZodForm( + schema, + { + onSubmit: ({ content }) => { + onSubmit(content); + }, + }, + { defaultValues: { content } }, + ); + + return ( +
+
+ + {errors.content && ( +

{errors.content.message}

+ )} +
+
+ + +
+
+ ); +}; diff --git a/src/components/dashboard/settingsRow/SettingsRow.tsx b/src/components/dashboard/settingsRow/SettingsRow.tsx new file mode 100644 index 00000000..0a8d5ad0 --- /dev/null +++ b/src/components/dashboard/settingsRow/SettingsRow.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; + +import { Button, BUTTON_VARIANT } from "../../common/Button"; + +import { RowForm } from "./RowForm"; + +import type { HTMLInputTypeAttribute } from "react"; +import type { ZodObject, ZodString } from "zod"; + +export const SECTIONS_WRAPPER_STYLES = + "mt-1 flex items-start sm:col-span-2 sm:mt-0 sm:items-center"; +export const LEFT_SECTION_STYLES = "grow text-gray-800"; +export const RIGHT_SECTION_STYLES = + "ml-2.5 flex h-full flex-shrink-0 flex-col items-stretch gap-1 font-medium sm:flex-row sm:items-center"; + +interface SettingsRowProps { + readonly label: string; + readonly schema: ZodObject<{ content: ZodString }>; + readonly contentType: HTMLInputTypeAttribute; + readonly content?: string; + readonly onChange: (content: string) => void; +} + +export const SettingsRow = ({ + label, + schema, + contentType, + content, + onChange, +}: SettingsRowProps) => { + const [isEditMode, setIsEditMode] = useState(false); + + const handleFormSubmit = (content: string) => { + onChange(content); + setIsEditMode(false); + }; + + return ( +
  • +
    {label}
    + {isEditMode ? ( + setIsEditMode(false)} + /> + ) : ( +
    +
    {content}
    +
    + +
    +
    + )} +
  • + ); +}; diff --git a/src/components/dashboard/sync/SyncsList.tsx b/src/components/dashboard/sync/SyncsList.tsx index c40736a1..37963cc9 100644 --- a/src/components/dashboard/sync/SyncsList.tsx +++ b/src/components/dashboard/sync/SyncsList.tsx @@ -1,6 +1,6 @@ import { memo } from "react"; -import { Button } from "../../common/Button"; +import { Button, BUTTON_VARIANT } from "../../common/Button"; import { SyncItem } from "./SyncItem"; @@ -111,7 +111,7 @@ export const SyncsList = memo(
    - diff --git a/src/components/tile/deviceTile/DeviceTile.tsx b/src/components/tile/deviceTile/DeviceTile.tsx index 2e392678..37858e5c 100644 --- a/src/components/tile/deviceTile/DeviceTile.tsx +++ b/src/components/tile/deviceTile/DeviceTile.tsx @@ -4,7 +4,7 @@ import { memo } from "react"; import RemarkableIcon from "public/svg/remarkable.svg"; import { onPromise } from "../../../utils/functions"; -import { Button } from "../../common/Button"; +import { Button, BUTTON_VARIANT } from "../../common/Button"; import type { Device } from "@prisma/client"; @@ -32,7 +32,7 @@ export const DeviceTile = memo(({ device, onDelete }) => { diff --git a/src/hooks/useZodForm.ts b/src/hooks/useZodForm.ts new file mode 100644 index 00000000..ac5f1e31 --- /dev/null +++ b/src/hooks/useZodForm.ts @@ -0,0 +1,29 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; + +import type { + SubmitHandler, + SubmitErrorHandler, + UseFormProps, +} from "react-hook-form"; +import type { ZodType, TypeOf } from "zod"; + +interface Options { + onSubmit: SubmitHandler>; + onInvalid?: SubmitErrorHandler>; +} + +export const useZodForm = ( + schema: T, + { onSubmit, onInvalid }: Options, + props?: Omit>, "resolver">, +) => { + const { handleSubmit, ...rest } = useForm>({ + resolver: zodResolver(schema), + ...props, + }); + + const handleFormSubmit = handleSubmit(onSubmit, onInvalid); + + return { handleFormSubmit, ...rest }; +}; diff --git a/src/pages/dashboard/settings.tsx b/src/pages/dashboard/settings.tsx deleted file mode 100644 index e4366cfb..00000000 --- a/src/pages/dashboard/settings.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { DashboardLayout } from "../../components/dashboard/layout/Layout"; -import { SettingsView } from "../../views/dashboard/settings/Settings"; - -import type { NextPage } from "next"; - -const Settings: NextPage = () => { - return ( - - - - ); -}; - -export default Settings; diff --git a/src/pages/dashboard/settings/index.tsx b/src/pages/dashboard/settings/index.tsx new file mode 100644 index 00000000..c448bbc2 --- /dev/null +++ b/src/pages/dashboard/settings/index.tsx @@ -0,0 +1,17 @@ +import { DashboardLayout } from "../../../components/dashboard/layout/Layout"; +import { SettingsLayout } from "../../../components/dashboard/settingsLayout/SettingsLayout"; +import { SettingsView } from "../../../views/dashboard/settings/Settings"; + +import type { NextPage } from "next"; + +const Settings: NextPage = () => { + return ( + + + + + + ); +}; + +export default Settings; diff --git a/src/utils/validation.ts b/src/utils/validation.ts index c7e4312f..46256129 100644 --- a/src/utils/validation.ts +++ b/src/utils/validation.ts @@ -67,6 +67,23 @@ export const unregisterAndDisconnectDeviceSchema = z.object({ id: z.number(), }); +export const updateUserNameSchema = z.object({ + content: z.string().min(1, "Name is required."), +}); + +export const updateUserEmailSchema = z.object({ + content: z.string().email("Email must be a valid email."), +}); + +export const updateUserPasswordSchema = z.object({ + content: z + .string() + .regex( + PASSWORD_REGEX, + "Password must contain an uppercase letter, a special character, a number and must be at least 8 characters long.", + ), +}); + export type RegisterUserInput = TypeOf; export type LoginUserInput = TypeOf; export type CreateFeedInput = TypeOf; diff --git a/src/views/dashboard/Home.tsx b/src/views/dashboard/Home.tsx index 116cc68d..30e36629 100644 --- a/src/views/dashboard/Home.tsx +++ b/src/views/dashboard/Home.tsx @@ -4,8 +4,9 @@ import { toast } from "react-hot-toast"; import EmptySyncsIcon from "public/svg/empty-syncs.svg"; -import { Button } from "../../components/common/Button"; +import { Button, BUTTON_VARIANT } from "../../components/common/Button"; import { Empty } from "../../components/common/Empty"; +import { Heading } from "../../components/dashboard/heading/Heading"; import { Profile } from "../../components/dashboard/profile/Profile"; import { SyncsList } from "../../components/dashboard/sync/SyncsList"; import { Tile } from "../../components/dashboard/tile/Tile"; @@ -92,7 +93,7 @@ export const HomeView = () => {
    diff --git a/src/views/dashboard/settings/Settings.tsx b/src/views/dashboard/settings/Settings.tsx index c15c4c9d..1b94db0e 100644 --- a/src/views/dashboard/settings/Settings.tsx +++ b/src/views/dashboard/settings/Settings.tsx @@ -1,7 +1,54 @@ +import { useSession } from "next-auth/react"; + +import { Heading } from "../../../components/dashboard/heading/Heading"; +import { SettingsRow } from "../../../components/dashboard/settingsRow/SettingsRow"; +import { + updateUserEmailSchema, + updateUserNameSchema, + updateUserPasswordSchema, +} from "../../../utils/validation"; + export const SettingsView = () => { + const { data } = useSession(); + + const handleRowChange = (content: string) => { + console.log("saving...", content); + }; + return ( -
    -

    Settings view coming soon...

    -
    +
    +
    + Profile +

    + This information will be displayed publicly so be careful what you + share. +

    +
    + + {data?.user && ( +
      + + + +
    + )} +
    ); };