aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rwxr-xr-xbun.lockbbin270624 -> 90744 bytes
-rw-r--r--web/app/dashboard/bookmarks/components/AddLink.tsx70
-rw-r--r--web/components/ui/form.tsx176
-rw-r--r--web/components/ui/label.tsx26
-rw-r--r--web/package.json3
5 files changed, 252 insertions, 23 deletions
diff --git a/bun.lockb b/bun.lockb
index 1ea151a9..b8f6722b 100755
--- a/bun.lockb
+++ b/bun.lockb
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"