diff options
| -rwxr-xr-x | bun.lockb | bin | 270624 -> 90744 bytes | |||
| -rw-r--r-- | web/app/dashboard/bookmarks/components/AddLink.tsx | 70 | ||||
| -rw-r--r-- | web/components/ui/form.tsx | 176 | ||||
| -rw-r--r-- | web/components/ui/label.tsx | 26 | ||||
| -rw-r--r-- | web/package.json | 3 |
5 files changed, 252 insertions, 23 deletions
| Binary files differ diff --git a/web/app/dashboard/bookmarks/components/AddLink.tsx b/web/app/dashboard/bookmarks/components/AddLink.tsx index fab4db8b..fb77786c 100644 --- a/web/app/dashboard/bookmarks/components/AddLink.tsx +++ b/web/app/dashboard/bookmarks/components/AddLink.tsx @@ -1,43 +1,67 @@ "use client"; import { Button } from "@/components/ui/button"; +import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import APIClient from "@/lib/api"; import { Plus } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useForm, SubmitErrorHandler } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { toast } from "@/components/ui/use-toast"; + +const formSchema = z.object({ + url: z.string().url({ message: "The link must be a valid URL" }), +}); export default function AddLink() { const router = useRouter(); - const [link, setLink] = useState(""); - const bookmarkLink = async () => { - const [_resp, error] = await APIClient.bookmarkLink(link); + const form = useForm<z.infer<typeof formSchema>>({ + resolver: zodResolver(formSchema), + }); + + async function onSubmit(value: z.infer<typeof formSchema>) { + const [_resp, error] = await APIClient.bookmarkLink(value.url); if (error) { - // TODO: Proper error handling - alert(error.message); + toast({ description: error.message, variant: "destructive" }); return; } router.refresh(); + } + + const onError: SubmitErrorHandler<z.infer<typeof formSchema>> = (errors) => { + toast({ + description: Object.values(errors) + .map((v) => v.message) + .join("\n"), + variant: "destructive", + }); }; return ( - <div className="py-4 container flex w-full items-center space-x-2"> - <Input - type="text" - placeholder="Link" - value={link} - onChange={(val) => setLink(val.target.value)} - onKeyUp={async (event) => { - if (event.key == "Enter") { - bookmarkLink(); - setLink(""); - } - }} - /> - <Button onClick={bookmarkLink}> - <Plus /> - </Button> - </div> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit, onError)}> + <div className="py-4 container flex w-full items-center space-x-2"> + <FormField + control={form.control} + name="url" + render={({ field }) => { + return ( + <FormItem className="flex-1"> + <FormControl> + <Input type="text" placeholder="Link" {...field} /> + </FormControl> + </FormItem> + ); + }} + /> + <Button type="submit"> + <Plus /> + </Button> + </div> + </form> + </Form> ); } diff --git a/web/components/ui/form.tsx b/web/components/ui/form.tsx new file mode 100644 index 00000000..4603f8b3 --- /dev/null +++ b/web/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> +> = { + name: TName +} + +const FormFieldContext = React.createContext<FormFieldContextValue>( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> +>({ + ...props +}: ControllerProps<TFieldValues, TName>) => { + return ( + <FormFieldContext.Provider value={{ name: props.name }}> + <Controller {...props} /> + </FormFieldContext.Provider> + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within <FormField>") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext<FormItemContextValue>( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + <FormItemContext.Provider value={{ id }}> + <div ref={ref} className={cn("space-y-2", className)} {...props} /> + </FormItemContext.Provider> + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( + <Label + ref={ref} + className={cn(error && "text-destructive", className)} + htmlFor={formItemId} + {...props} + /> + ) +}) +FormLabel.displayName = "FormLabel" + +const FormControl = React.forwardRef< + React.ElementRef<typeof Slot>, + React.ComponentPropsWithoutRef<typeof Slot> +>(({ ...props }, ref) => { + const { error, formItemId, formDescriptionId, formMessageId } = useFormField() + + return ( + <Slot + ref={ref} + id={formItemId} + aria-describedby={ + !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}` + } + aria-invalid={!!error} + {...props} + /> + ) +}) +FormControl.displayName = "FormControl" + +const FormDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, ...props }, ref) => { + const { formDescriptionId } = useFormField() + + return ( + <p + ref={ref} + id={formDescriptionId} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> + ) +}) +FormDescription.displayName = "FormDescription" + +const FormMessage = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, children, ...props }, ref) => { + const { error, formMessageId } = useFormField() + const body = error ? String(error?.message) : children + + if (!body) { + return null + } + + return ( + <p + ref={ref} + id={formMessageId} + className={cn("text-sm font-medium text-destructive", className)} + {...props} + > + {body} + </p> + ) +}) +FormMessage.displayName = "FormMessage" + +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +} diff --git a/web/components/ui/label.tsx b/web/components/ui/label.tsx new file mode 100644 index 00000000..53418217 --- /dev/null +++ b/web/components/ui/label.tsx @@ -0,0 +1,26 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const labelVariants = cva( + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" +) + +const Label = React.forwardRef< + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & + VariantProps<typeof labelVariants> +>(({ className, ...props }, ref) => ( + <LabelPrimitive.Root + ref={ref} + className={cn(labelVariants(), className)} + {...props} + /> +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/web/package.json b/web/package.json index 0c7ce127..8a315beb 100644 --- a/web/package.json +++ b/web/package.json @@ -10,9 +10,11 @@ "lint": "next lint" }, "dependencies": { + "@hookform/resolvers": "^3.3.4", "@next-auth/prisma-adapter": "^1.0.7", "@next/eslint-plugin-next": "^14.1.0", "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.5", "class-variance-authority": "^0.7.0", @@ -24,6 +26,7 @@ "prettier": "^3.2.5", "react": "^18", "react-dom": "^18", + "react-hook-form": "^7.50.1", "tailwind-merge": "^2.2.1", "tailwindcss-animate": "^1.0.7", "zod": "^3.22.4" |
