DEV Community

Cover image for (Part 5) Build a Simple Chat Character Gallery: Adding Create New Chatbot Feature
James
James

Posted on • Originally published at jamestedy.hashnode.dev

(Part 5) Build a Simple Chat Character Gallery: Adding Create New Chatbot Feature

👋 Recap from Previous Post

In the last part, we enhanced our chatbot gallery by adding a new feature to add new chatbot to the gallery:

Searchbar: Lets users dynamically search chatbots by name.
Filter by Interests & Personality: Added interactive filter buttons (desktop view) and dropdowns (mobile view) to narrow down chatbot results.
State Management: We used React useState to manage selected filters and updated the filtered list of chatbots accordingly.
Dynamic Filtering: The filteredChatbots array now updates based on search input and selected tags in real-time.

By the end of that post, your Gallery Page allowed users to:

  • Search by chatbot name
  • Filter by selected Interests
  • Filter by selected Personality traits

Adding Create New Chatbot Feature

🎯 Goals for This Post

In this tutorial, we’ll add a new feature that allows users to add a new chatbot to the gallery. We’ll implement a new “Add Chatbot” button placed between the Searchbar and Filter. When clicked, it will open a modal (popup) containing a form where users can input chatbot details.

By the end of this section, you’ll have:

  • A fully functional “Add Chatbot” button
  • A responsive modal form to input chatbot details

📱 Step by Step Guide



1. Creating the Button

The first thing we’ll create is the “Add Chatbot” button. This will be a simple button, and we’ll re-use the existing Button component we’ve previously built. To keep things organized, we’ll place it in a new component called AddChatbotButton. Since this button is specific to the gallery page, the file will be located at: src/modules/gallery/components/AddChatbotButton.tsx

We will be needing an icon, so don’t forget to install

pnpm add @phospor-icons/react 
Enter fullscreen mode Exit fullscreen mode

Here’s the code for AddChatbotButton:


import { Button } from '@/components/ui/button';
import React from 'react';
import { cn } from '@/lib/utils';
import { PlusIcon } from '@phosphor-icons/react';

interface AddChatbotButtonProps {
  onClick: () => void;
}

export const AddChatbotButton: React.FC<AddChatbotButtonProps> = ({
  onClick,
}) => {
  return (
    <Button
      className={cn(
        'ml-2 px-6 py-3 flex',
        'bg-gradient-to-r from-purple-600 via-pink-500 to-orange-500',
        'text-white font-semibold text-sm',
        'border-0 rounded-xl',
        'shadow-lg shadow-purple-500/25',
        'hover:shadow-xl hover:shadow-purple-500/40',
        'hover:scale-105 hover:-translate-y-0.5',
        'transition-all duration-300 ease-out',
        'focus:outline-none focus:ring-4 focus:ring-purple-500/50',
        'active:scale-95 active:translate-y-0'
      )}
      onClick={onClick}
      aria-label="Add new chatbot"
    >
      <span className="flex items-center gap-2">
        <PlusIcon size={20} />
        Add Chatbot
      </span>
    </Button>
  );
};
Enter fullscreen mode Exit fullscreen mode

As shown above, we’re importing the Button component from our shared components and applying some custom styles to it. We’ve also added an onClick handler for the button’s click event, along with an icon and label text. Simple and straightforward.

Now you can add it to the GalleryPage and place it in between the Searchbar and FilterControl. Then you can add a state to manage what happens when it is clicked:

Here’s what the code looks like:

// .... imports etc

const GalleryPage = () => {
  const router = useRouter();

  const [searchTerm, setSearchTerm] = useState('');
  const [selectedInterest, setSelectedInterest] = useState<string[]>([]);
  const [selectedPersonality, setSelectedPersonality] = useState<string[]>([]);
  // Add state here
  const [isModalOpen, setModalOpen] = useState(false);

// ....

  return (
// ........ other html before
            <div className="flex gap-x-2">
              <SearchBar
                value={searchTerm}
                onChange={setSearchTerm}
                placeholder="Search chatbots by name or description..."
              />
            </div>
            {/* Add the Chatbot Button here */}
            <AddChatbotButton onClick={() => setModalOpen(true)} />

            {/* Filter for Interests */}
            <div className="bg-white p-4 rounded-lg shadow">
              <h2 className="text-lg font-medium mb-3">Filter by Interests</h2>
              <FilterControls
                values={allInterests}
                selectedValues={selectedInterest}
                onToggle={handleInterestsToggle}
              />
            </div>
// ........ other html after
  )
Enter fullscreen mode Exit fullscreen mode



2. Creating the Modal

Now let’s build the form for creating a new chatbot. We’ll structure it from top to bottom starting with the header, followed by the form fields, and then the action buttons.

Creating Dialog (Modal) reusable components

To begin, we’ll install the @radix-ui/react-dialog package. This will serve as the foundation for our custom dialog component. It provides accessible, unstyled primitives, making it a great choice for building consistent and flexible modal components.

Run the following command to install it:

pnpm add @radix-ui/react-dialog 
Enter fullscreen mode Exit fullscreen mode

We’ll place our custom dialog component inside src/components/ui/dialog.tsx. This file will contain all custom dialog components based on @radix-ui/react-dialog. We’ll also apply our own styles to these components to match the overall design of the app.

Here’s the full code for the file:

import React, { ComponentPropsWithoutRef, HTMLAttributes } from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cn } from '@/lib/utils';
import { XIcon } from '@phosphor-icons/react';

const Dialog = DialogPrimitive.Root;

const DialogPortal = DialogPrimitive.Portal;

const DialogOverlay = ({
  className,
  ...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>) => (
  <DialogPrimitive.Overlay
    className={cn(
      'fixed inset-0 z-50 bg-black/80  data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
      className
    )}
    {...props}
  />
);

const DialogContent = ({
  className,
  children,
  ...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Content>) => (
  <DialogPortal>
    <DialogOverlay />
    <DialogPrimitive.Content
      className={cn(
        'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
        className
      )}
      {...props}
    >
      {children}
      <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
        <XIcon className="h-4 w-4" />
        <span className="sr-only">Close</span>
      </DialogPrimitive.Close>
    </DialogPrimitive.Content>
  </DialogPortal>
);

const DialogHeader = ({
  className,
  ...props
}: HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      'flex flex-col space-y-1.5 text-center sm:text-left',
      className
    )}
    {...props}
  />
);

const DialogFooter = ({
  className,
  ...props
}: HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
      className
    )}
    {...props}
  />
);

const DialogTitle = ({
  className,
  ...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Title>) => (
  <DialogPrimitive.Title
    className={cn(
      'text-lg font-semibold leading-none tracking-tight',
      className
    )}
    {...props}
  />
);

const DialogDescription = ({
  className,
  ...props
}: ComponentPropsWithoutRef<typeof DialogPrimitive.Description>) => (
  <DialogPrimitive.Description
    className={cn('text-sm text-muted-foreground', className)}
    {...props}
  />
);

export {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogFooter,
  DialogTitle,
  DialogDescription,
};
Enter fullscreen mode Exit fullscreen mode

We’ve created several components to build a custom, reusable dialog system. Here’s a brief explanation of each one:

  • Dialog: The root component that manages the open/close state of the dialog.
  • DialogPortal: Renders the overlay and content in a React portal (typically into the ), allowing the dialog to appear above other content regardless of DOM hierarchy.
  • DialogOverlay: A full-screen background layer that dims the rest of the UI and prevents interaction with it while the dialog is open.
  • DialogContent: The main dialog window. It includes the portal and overlay, and renders any custom content passed via children. It also includes a close button in the top-right corner.
  • DialogHeader: A layout wrapper for the top section of the dialog, typically used to contain the title and optional description.
  • DialogFooter: A layout wrapper for the bottom section of the dialog, usually used for action buttons. It stacks vertically on mobile and horizontally on desktop.
  • DialogTitle: An accessible title element that is announced by screen readers when the dialog opens.
  • DialogDescription: An optional accessible description for additional context, also announced by screen readers when the dialog opens.

Creating AddChatbotModal component

Now that we’ve built our reusable dialog components, it’s time to create the AddChatbotModal component. This modal will be located at: src/modules/gallery/components/AddChatbotModal.tsx. We’ll begin by building the header section of the modal.

Here’s the code for the header:

import React from "react";
import { Button } from "@/components/ui/button";
import Input from "@/components/ui/input";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";

interface AddChatbotModalProps {
  isOpen: boolean;
  onClose: () => void;
  onSubmitSuccess?: () => void;
}

export const AddChatbotModal: React.FC<AddChatbotModalProps> = ({
  isOpen,
  onClose,
  onSubmitSuccess,
}) => {

  return (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border-0 p-0">
        <div className="bg-gradient-to-br from-purple-600 via-pink-500 to-orange-500 p-6 rounded-t-2xl">
          <DialogHeader>
            <DialogTitle className="text-2xl font-bold text-white">
              Create New Chatbot
            </DialogTitle>
            <DialogDescription className="text-purple-100 mt-2">
              Design a unique AI personality for the marketplace
            </DialogDescription>
          </DialogHeader>
        </div>
      </DialogContent>
    </Dialog>
  );
};
Enter fullscreen mode Exit fullscreen mode

In this section, we’re using the Dialog and DialogContent components we created earlier to structure the modal. Inside the DialogContent, we build the header portion of the modal. If you render this component now, you should be able to clearly see the header appear.

Next, we’ll add the main content of the modal, the form. If you refer to the screenshot I shared earlier, most of the form fields follow a consistent layout: a label followed by an input element. The only exception is the “Personality” field, which will use a <textarea> instead of a standard input.

Here’s an example of the markup for one field. You can duplicate this structure to create the other inputs:

// ... rest of the code

export const AddChatbotModal: React.FC<AddChatbotModalProps> = ({
  isOpen,
  onClose,
  onSubmitSuccess,
}) => {
return (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border-0 p-0">
        <div className="bg-gradient-to-br from-purple-600 via-pink-500 to-orange-500 p-6 rounded-t-2xl">
          <DialogHeader>
            <DialogTitle className="text-2xl font-bold text-white">
              Create New Chatbot
            </DialogTitle>
            <DialogDescription className="text-purple-100 mt-2">
              Design a unique AI personality for the marketplace
            </DialogDescription>
          </DialogHeader>
        </div>

        {/* The Form Part */}
        <div className="p-6 space-y-6">
          <div className="space-y-4">
            <div>
              <label className="block text-sm font-semibold text-gray-700 mb-2">
                Name
                <span className="text-red-500 ml-1">*</span>
              </label>
              <Input
                placeholder="e.g., Tech Assistant Pro"
                className="border-2 border-purple-200 focus:border-purple-500 focus:ring-purple-500/20 rounded-lg"
              />
            </div>

            <div>
              <label className="block text-sm font-semibold text-gray-700 mb-2">
                Personality
              </label>
              <textarea
                placeholder="Describe the chatbot's core personality traits..."
                className={cn(
                  "w-full px-4 py-3 border-2 border-purple-200 rounded-lg bg-white",
                  "focus:border-purple-500 focus:ring-purple-500/20",
                  "text-sm leading-relaxed resize-none",
                  "transition-all duration-200"
                )}
                rows={2}
              />
           </div>

            {/* Put the rest of the input here */}
         </div>
        </div>
      </DialogContent>
    </Dialog>
)
Enter fullscreen mode Exit fullscreen mode

Finally, let’s add the footer section with the action buttons. This includes two buttons:

  • Cancel – Closes the modal
  • Create Chatbot – Submits the form and triggers the API call to create a new chatbot

Here’s the code for the button group:

    return (
    <Dialog open={isOpen} onOpenChange={onClose}>
      <DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border-0 p-0">
        {/** Form */}
        <div className="p-6 space-y-6">
            {/** Form here */}
        </div>

        {/** Footer here */}
        <DialogFooter className="px-6 py-4 bg-gradient-to-r from-purple-50 to-pink-50 border-t border-purple-200 rounded-b-2xl">
          <Button
            variant="outline"
            onClick={onClose}
            className="border-purple-300 text-purple-700 hover:bg-purple-100 hover:border-purple-400"
          >
            Cancel
          </Button>
          <Button
            onClick={() => {}}
            disabled={isPending}
            className={cn(
              "bg-gradient-to-r from-purple-600 to-pink-600",
              "hover:from-purple-700 hover:to-pink-700",
              "text-white font-semibold",
              "border-0 shadow-lg shadow-purple-500/25",
              "hover:shadow-xl hover:shadow-purple-500/40",
              "hover:scale-105 transition-all duration-200",
              isPending && "opacity-70 cursor-not-allowed"
            )}
          >
            {isPending ? "Creating..." : "Create Chatbot"}
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
    )
Enter fullscreen mode Exit fullscreen mode

You might notice that isPending is used but not defined yet. For now, you can set it to false as a placeholder we’ll implement its functionality later.

At this point, your modal should visually match the screenshot I showed earlier. However, it’s still non-functional. In the next step, we’ll integrate react-hook-form to handle form state, validation, and submission.

Before we continue, make sure to install the necessary dependencies:

pnpm install react-hook-form @hookform/resolvers 
Enter fullscreen mode Exit fullscreen mode

These are required for integrating react-hook-form along with the zodResolver, which we’ll use for schema validation.

I’ll provide the personaSchema we’re using shortly.

Here’s the updated version of the AddChatbotModal component:

// ... rest of code

export const AddChatbotModal: React.FC<AddChatbotModalProps> = ({
  isOpen,
  onClose,
  onSubmitSuccess,
}) => {
  const {
    register,
    handleSubmit,
    formState: { errors },
    reset,
  } = useForm({
    resolver: zodResolver(personaSchema),
    defaultValues: {
      name: "",
      personality: "",
      tone_style: "",
      interests: "",
      behaviour: "",
      age: 0,
      gender: "",
    },
  });

  const onFormSubmit = (data: {
    name: string;
    personality?: string;
    tone_style?: string;
    interests?: string;
    behaviour?: string;
    age: number;
    gender: string;
  }) => {
    // Handle Form Here
  };

  const isPending = false;

  return (
      <Dialog open={isOpen} onOpenChange={onClose}>
        <DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border-0 p-0">
          {/** ... other HTML tags above */}
          <DialogFooter className="px-6 py-4 bg-gradient-to-r from-purple-50 to-pink-50 border-t border-purple-200 rounded-b-2xl">
            <Button
              variant="outline"
              onClick={onClose}
              className="border-purple-300 text-purple-700 hover:bg-purple-100 hover:border-purple-400"
            >
              Cancel
            </Button>
            <Button
              onClick={handleSubmit(onFormSubmit)}
              disabled={isPending}
              className={cn(
                "bg-gradient-to-r from-purple-600 to-pink-600",
                "hover:from-purple-700 hover:to-pink-700",
                "text-white font-semibold",
                "border-0 shadow-lg shadow-purple-500/25",
                "hover:shadow-xl hover:shadow-purple-500/40",
                "hover:scale-105 transition-all duration-200",
                isPending && "opacity-70 cursor-not-allowed"
              )}
            >
              {isPending ? "Creating..." : "Create Chatbot"}
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
  )
Enter fullscreen mode Exit fullscreen mode

In the example above, we’ve initialized useForm from react-hook-form and used zodResolver from @hookform/resolvers to connect our schema validation.

Next, I’ll show you how to register each input field using register, and how to display validation error messages beneath them.

Here’s a sample input field with validation applied:

return (
      <Dialog open={isOpen} onOpenChange={onClose}>
        <DialogContent className="sm:max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border-0 p-0">
            <div className="p-6 space-y-6">
              <div className="space-y-4">
                <div>
                  <label className="block text-sm font-semibold text-gray-700 mb-2">
                    Name
                    <span className="text-red-500 ml-1">*</span>
                  </label>
                  <Input
                    {...register('name')}
                    placeholder="e.g., Tech Assistant Pro"
                    className="border-2 border-purple-200 focus:border-purple-500 focus:ring-purple-500/20 rounded-lg"
                  />
                  {errors.name && (
                    <p className="text-red-500 text-xs mt-1">
                      {errors.name.message}
                    </p>
                  )}
                </div>

                <div>
                  <label className="block text-sm font-semibold text-gray-700 mb-2">
                    Personality
                  </label>
                  <textarea
                    {...register('personality')}
                    placeholder="Describe the chatbot's core personality traits..."
                    className={cn(
                      'w-full px-4 py-3 border-2 border-purple-200 rounded-lg bg-white',
                      'focus:border-purple-500 focus:ring-purple-500/20',
                      'text-sm leading-relaxed resize-none',
                      'transition-all duration-200'
                    )}
                    rows={2}
                  />
                </div>

                <div>
                  <label className="block text-sm font-semibold text-gray-700 mb-2">
                    Tone Style
                  </label>
                  <Input
                    {...register('tone_style')}
                    placeholder="e.g., professional, casual, humorous"
                    className="border-2 border-purple-200 focus:border-purple-500 focus:ring-purple-500/20 rounded-lg"
                  />
                </div>

                <div>
                  <label className="block text-sm font-semibold text-gray-700 mb-2">
                    Interests
                  </label>
                  <Input
                    {...register('interests')}
                    placeholder="e.g., technology, sports, music"
                    className="border-2 border-purple-200 focus:border-purple-500 focus:ring-purple-500/20 rounded-lg"
                  />
                </div>

                <div>
                  <label className="block text-sm font-semibold text-gray-700 mb-2">
                    Behavior
                  </label>
                  <Input
                    {...register('behaviour')}
                    placeholder="e.g., helpful, inquisitive, reserved"
                    className="border-2 border-purple-200 focus:border-purple-500 focus:ring-purple-500/20 rounded-lg"
                  />
                </div>

                <div className="grid grid-cols-2 gap-4">
                  <div>
                    <label className="block text-sm font-semibold text-gray-700 mb-2">
                      Age
                    </label>
                    <Input
                      type="number"
                      {...register('age', { valueAsNumber: true })}
                      placeholder="e.g., 25"
                      className="border-2 border-purple-200 focus:border-purple-500 focus:ring-purple-500/20 rounded-lg"
                    />
                  </div>
                  <div>
                    <label className="block text-sm font-semibold text-gray-700 mb-2">
                      Gender
                    </label>
                    <Input
                      {...register('gender')}
                      placeholder="e.g., male, female, non-binary"
                      className="border-2 border-purple-200 focus:border-purple-500 focus:ring-purple-500/20 rounded-lg"
                    />
                  </div>
                </div>
              </div>
            </div>
            {/** ... footer HTML tags */}
        </DialogContent>
      </Dialog>
)
Enter fullscreen mode Exit fullscreen mode

We’ll place the new personaSchema.ts file under a new directory: src/schema/personaSchema.ts. This folder will be used to store all schema definitions, whether for form validation or API response validation.

Here’s the code for personaSchema.ts:

import z from "zod/v4";

export const personaSchema = z.object({
  name: z.string(),
  personality: z.string().optional(),
  tone_style: z.string().optional(),
  interests: z.string().optional(),
  behaviour: z.string().optional(),
  age: z.number(),
  gender: z.string(),
});
Enter fullscreen mode Exit fullscreen mode

🚀 What’s Next?

By now, you should have a working “Add Chatbot” button that opens a modal when clicked. In the next part, we’ll integrate the API so you can create new chatbots and fetch all the chatbots you’ve created to display them in your gallery.

New posts will be released every 2–3 days, so make sure to subscribe or bookmark this page to stay updated!

Please check Part 6 here:
👉 (Part 6) Build a Simple Chat Character Gallery: Integrating the API

Top comments (0)